From 4b0bef041a20ecc16cd90dccb7baec753549dc63 Mon Sep 17 00:00:00 2001 From: tehcneko Date: Thu, 31 Jul 2025 14:23:32 +0100 Subject: [PATCH] WebUI: Implement missing tracker list features --- WebAPI_Changelog.md | 7 + src/webui/api/torrentscontroller.cpp | 21 ++- src/webui/webapplication.h | 2 +- src/webui/www/private/index.html | 6 +- src/webui/www/private/scripts/dynamicTable.js | 162 +++++++++++++++++- .../www/private/scripts/prop-trackers.js | 86 ++++++---- 6 files changed, 241 insertions(+), 43 deletions(-) diff --git a/WebAPI_Changelog.md b/WebAPI_Changelog.md index 3bfe4356c..2345df422 100644 --- a/WebAPI_Changelog.md +++ b/WebAPI_Changelog.md @@ -1,5 +1,12 @@ # WebAPI Changelog +## 2.13.0 +* [#23045](https://github.com/qbittorrent/qBittorrent/pull/23045) + * `torrents/trackers` returns three new fields: `next_announce`, `min_announce` and `endpoints` + * `endpoints` is an array of tracker endpoints, each with `name`, `updating`, `status`, `msg`, `bt_version`, `num_peers`, `num_peers`, `num_leeches`, `num_downloaded`, `next_announce` and `min_announce` fields + * `torrents/trackers` now returns `5` and `6` in `status` field as possible values + * `5` for `Tracker error` and `6` for `Unreachable` + ## 2.12.1 * [#23031](https://github.com/qbittorrent/qBittorrent/pull/23031) * Add `torrents/setComment` endpoint with parameters `hashes` and `comment` for setting a new torrent comment diff --git a/src/webui/api/torrentscontroller.cpp b/src/webui/api/torrentscontroller.cpp index 6c3da9ca3..63aba4e7b 100644 --- a/src/webui/api/torrentscontroller.cpp +++ b/src/webui/api/torrentscontroller.cpp @@ -28,6 +28,8 @@ #include "torrentscontroller.h" +#include +#include #include #include @@ -274,7 +276,12 @@ namespace QJsonArray getTrackers(const BitTorrent::Torrent *const torrent) { - auto now = BitTorrent::AnnounceTimePoint::clock::now(); + const auto toSeconds = [](const std::chrono::nanoseconds &duration) -> qint64 + { + return std::max(0, std::chrono::duration_cast(duration).count()); + }; + + const auto now = BitTorrent::AnnounceTimePoint::clock::now(); QJsonArray trackerList; for (const BitTorrent::TrackerEntryStatus &tracker : asConst(torrent->trackers())) @@ -294,10 +301,8 @@ namespace {KEY_TRACKER_SEEDS_COUNT, endpoint.numSeeds}, {KEY_TRACKER_LEECHES_COUNT, endpoint.numLeeches}, {KEY_TRACKER_DOWNLOADED_COUNT, endpoint.numDownloaded}, - {KEY_TRACKER_NEXT_ANNOUNCE, std::max(0, - std::chrono::duration_cast(endpoint.nextAnnounceTime - now).count())}, - {KEY_TRACKER_MIN_ANNOUNCE, std::max(0, - std::chrono::duration_cast(endpoint.minAnnounceTime - now).count())} + {KEY_TRACKER_NEXT_ANNOUNCE, toSeconds(endpoint.nextAnnounceTime - now)}, + {KEY_TRACKER_MIN_ANNOUNCE, toSeconds(endpoint.minAnnounceTime - now)} }; } @@ -312,10 +317,8 @@ namespace {KEY_TRACKER_SEEDS_COUNT, tracker.numSeeds}, {KEY_TRACKER_LEECHES_COUNT, tracker.numLeeches}, {KEY_TRACKER_DOWNLOADED_COUNT, tracker.numDownloaded}, - {KEY_TRACKER_NEXT_ANNOUNCE, std::max(0, - std::chrono::duration_cast(tracker.nextAnnounceTime - now).count())}, - {KEY_TRACKER_MIN_ANNOUNCE, std::max(0, - std::chrono::duration_cast(tracker.minAnnounceTime - now).count())}, + {KEY_TRACKER_NEXT_ANNOUNCE, toSeconds(tracker.nextAnnounceTime - now)}, + {KEY_TRACKER_MIN_ANNOUNCE, toSeconds(tracker.minAnnounceTime - now)}, {KEY_TRACKER_ENDPOINTS, endpointsList} }; } diff --git a/src/webui/webapplication.h b/src/webui/webapplication.h index 7c7fc15fe..719b13da0 100644 --- a/src/webui/webapplication.h +++ b/src/webui/webapplication.h @@ -53,7 +53,7 @@ #include "base/utils/version.h" #include "api/isessionmanager.h" -inline const Utils::Version<3, 2> API_VERSION {2, 12, 1}; +inline const Utils::Version<3, 2> API_VERSION {2, 13, 0}; class APIController; class AuthController; diff --git a/src/webui/www/private/index.html b/src/webui/www/private/index.html index 8ba14dd6a..1a34c6f9f 100644 --- a/src/webui/www/private/index.html +++ b/src/webui/www/private/index.html @@ -245,11 +245,11 @@
  • QBT_TR(Add peers...)QBT_TR[CONTEXT=PeerListWidget] QBT_TR(Add peers...)QBT_TR[CONTEXT=PeerListWidget]
  • diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index 20392d5a4..c8ba4ea87 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -2074,15 +2074,68 @@ window.qBittorrent.DynamicTable ??= (() => { } class TorrentTrackersTable extends DynamicTable { + collapseState = new Map(); // { rowId: String, isCollapsed: bool } + + isTrackerCollapsed(id) { + return this.collapseState.get(id) ?? true; + } + + toggleTrackerCollapsed(id) { + this.collapseState.set(id, !this.isTrackerCollapsed(id)); + this.#updateTrackerRowState(id, this.isTrackerCollapsed(id)); + } + + #updateEndpointVisibility(endpoint, shouldHide) { + const span = document.getElementById(`trackersTableTrackerUrl${endpoint}`); + // span won't exist if row has been filtered out + if (span === null) + return; + const tr = span.parentElement.parentElement; + tr.classList.toggle("invisible", shouldHide); + } + + #updateTrackerCollapseIcon(tracker, isCollapsed) { + const span = document.getElementById(`trackersTableTrackerUrl${tracker}`); + // span won't exist if row has been filtered out + if (span === null) + return; + const td = span.parentElement; + + // rotate the collapse icon + const collapseIcon = td.firstElementChild; + collapseIcon.classList.toggle("rotate", isCollapsed); + } + + #updateTrackerRowState(id, shouldCollapse) { + // collapsed rows will be filtered out when using virtual list + if (this.useVirtualList) + return; + + this.#updateTrackerCollapseIcon(id, shouldCollapse); + + for (const row of this.getRowValues()) { + const parentId = row.full_data._tracker; + if (parentId === id) + this.#updateEndpointVisibility(row.rowId, shouldCollapse); + } + } + + clearCollapseState() { + this.collapseState.clear(); + } + initColumns() { + this.newColumn("url", "", "QBT_TR(URL/Announce Endpoint)QBT_TR[CONTEXT=TrackerListWidget]", 250, true); this.newColumn("tier", "", "QBT_TR(Tier)QBT_TR[CONTEXT=TrackerListWidget]", 35, true); - this.newColumn("url", "", "QBT_TR(URL)QBT_TR[CONTEXT=TrackerListWidget]", 250, true); + this.newColumn("btVersion", "", "QBT_TR(BT Protocol)QBT_TR[CONTEXT=TrackerListWidget]", 35, true); this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TrackerListWidget]", 125, true); this.newColumn("peers", "", "QBT_TR(Peers)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); this.newColumn("seeds", "", "QBT_TR(Seeds)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); this.newColumn("leeches", "", "QBT_TR(Leeches)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); this.newColumn("downloaded", "", "QBT_TR(Times Downloaded)QBT_TR[CONTEXT=TrackerListWidget]", 100, true); this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=TrackerListWidget]", 250, true); + this.newColumn("nextAnnounce", "", "QBT_TR(Next Announce)QBT_TR[CONTEXT=TrackerListWidget]", 150, true); + this.newColumn("minAnnounce", "", "QBT_TR(Min Announce)QBT_TR[CONTEXT=TrackerListWidget]", 150, true); this.initColumnsFunctions(); } @@ -2097,6 +2150,43 @@ window.qBittorrent.DynamicTable ??= (() => { return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2); }; + this.columns["url"].updateTd = (td, row) => { + const id = row.rowId; + const data = row.full_data; + + let collapseIcon = td.firstElementChild; + if (collapseIcon === null) { + collapseIcon = document.createElement("img"); + collapseIcon.src = "images/go-down.svg"; + collapseIcon.className = "filesTableCollapseIcon"; + collapseIcon.addEventListener("click", (e) => { + const id = collapseIcon.dataset.id; + this.toggleTrackerCollapsed(id); + if (this.useVirtualList) + this.rerender(); + }); + td.append(collapseIcon); + } + if (data._isTracker) { + collapseIcon.style.display = "inline"; + collapseIcon.style.visibility = data._hasEndpoints ? "visible" : "hidden"; + collapseIcon.dataset.id = id; + collapseIcon.classList.toggle("rotate", this.isTrackerCollapsed(id)); + } + else { + collapseIcon.style.display = "none"; + } + + let span = td.children[1]; + if (span === undefined) { + span = document.createElement("span"); + td.append(span); + } + span.id = `trackersTableTrackerUrl${id}`; + span.textContent = data.url; + span.style.marginLeft = data._isTracker ? "0" : "20px"; + }; + this.columns["url"].compareRows = naturalSort; this.columns["status"].compareRows = naturalSort; this.columns["message"].compareRows = naturalSort; @@ -2151,6 +2241,8 @@ window.qBittorrent.DynamicTable ??= (() => { statusClass = "trackerUpdating"; break; case "QBT_TR(Not working)QBT_TR[CONTEXT=TrackerListWidget]": + case "QBT_TR(Tracker error)QBT_TR[CONTEXT=TrackerListWidget]": + case "QBT_TR(Unreachable)QBT_TR[CONTEXT=TrackerListWidget]": statusClass = "trackerNotWorking"; break; } @@ -2163,6 +2255,74 @@ window.qBittorrent.DynamicTable ??= (() => { td.textContent = status; td.title = status; }; + + const friendlyDuration = function(td, row) { + const duration = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row) ?? 0, window.qBittorrent.Misc.MAX_ETA); + td.textContent = duration; + td.title = duration; + }; + + this.columns["nextAnnounce"].updateTd = friendlyDuration; + this.columns["minAnnounce"].updateTd = friendlyDuration; + } + + getFilteredAndSortedRows() { + const trackers = []; + const trakcerEndpoints = new Map(); + + for (const row of this.getRowValues()) { + const tracker = row.full_data._tracker; + if (tracker) { + if (this.useVirtualList && this.isTrackerCollapsed(tracker)) + continue; + const endpoints = trakcerEndpoints.get(tracker); + if (endpoints === undefined) + trakcerEndpoints.set(tracker, [row]); + else + endpoints.push(row); + } + else { + trackers.push(row); + } + } + + const column = this.columns[this.sortedColumn]; + const isReverseSort = this.reverseSort === "0"; + const sortRows = (row1, row2) => { + const result = column.compareRows(row1, row2); + return isReverseSort ? result : -result; + }; + + const result = []; + for (const tracker of trackers.sort(sortRows)) { + result.push(tracker); + const endpoints = trakcerEndpoints.get(tracker.rowId) || []; + result.push(...endpoints.sort(sortRows)); + } + + return result; + } + + updateTable(fullUpdate = false) { + super.updateTable(fullUpdate); + if (!this.useVirtualList) { + for (const row of this.getRowValues()) { + if (row.full_data._isTracker) + continue; + this.#updateEndpointVisibility(row.rowId, this.isTrackerCollapsed(row.full_data._tracker)); + } + } + } + + setupCommonEvents() { + super.setupCommonEvents(); + this.dynamicTableDiv.addEventListener("dblclick", (e) => { + const tr = e.target.closest("tr"); + if (!tr || (tr.rowId.startsWith("** [") || tr.rowId.startsWith("endpoint|"))) + return; + + window.qBittorrent.PropTrackers.editTracker(tr); + }); } } diff --git a/src/webui/www/private/scripts/prop-trackers.js b/src/webui/www/private/scripts/prop-trackers.js index 0e74dd071..f5fb10f81 100644 --- a/src/webui/www/private/scripts/prop-trackers.js +++ b/src/webui/www/private/scripts/prop-trackers.js @@ -32,6 +32,7 @@ window.qBittorrent ??= {}; window.qBittorrent.PropTrackers ??= (() => { const exports = () => { return { + editTracker: editTrackerFN, updateData: updateData, clear: clear }; @@ -42,6 +43,25 @@ window.qBittorrent.PropTrackers ??= (() => { const torrentTrackersTable = new window.qBittorrent.DynamicTable.TorrentTrackersTable(); let loadTrackersDataTimer = -1; + const trackerStatusText = (tracker) => { + if (tracker.updating) + return "QBT_TR(Updating...)QBT_TR[CONTEXT=TrackerListWidget]"; + switch (tracker.status) { + case 0: + return "QBT_TR(Disabled)QBT_TR[CONTEXT=TrackerListWidget]"; + case 1: + return "QBT_TR(Not contacted yet)QBT_TR[CONTEXT=TrackerListWidget]"; + case 2: + return "QBT_TR(Working)QBT_TR[CONTEXT=TrackerListWidget]"; + case 4: + return "QBT_TR(Not working)QBT_TR[CONTEXT=TrackerListWidget]"; + case 5: + return "QBT_TR(Tracker error)QBT_TR[CONTEXT=TrackerListWidget]"; + case 6: + return "QBT_TR(Unreachable)QBT_TR[CONTEXT=TrackerListWidget]"; + } + }; + const loadTrackersData = () => { if (document.hidden) return; @@ -53,11 +73,13 @@ window.qBittorrent.PropTrackers ??= (() => { const new_hash = torrentsTable.getCurrentTorrentID(); if (new_hash === "") { torrentTrackersTable.clear(); + torrentTrackersTable.clearCollapseState(); clearTimeout(loadTrackersDataTimer); return; } if (new_hash !== current_hash) { torrentTrackersTable.clear(); + torrentTrackersTable.clearCollapseState(); current_hash = new_hash; } @@ -79,43 +101,50 @@ window.qBittorrent.PropTrackers ??= (() => { if (trackers) { torrentTrackersTable.clear(); + const notApplicable = "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]"; trackers.each((tracker) => { - let status; - - if (tracker.updating) { - status = "QBT_TR(Updating...)QBT_TR[CONTEXT=TrackerListWidget]"; - } - else { - switch (tracker.status) { - case 0: - status = "QBT_TR(Disabled)QBT_TR[CONTEXT=TrackerListWidget]"; - break; - case 1: - status = "QBT_TR(Not contacted yet)QBT_TR[CONTEXT=TrackerListWidget]"; - break; - case 2: - status = "QBT_TR(Working)QBT_TR[CONTEXT=TrackerListWidget]"; - break; - case 4: - status = "QBT_TR(Not working)QBT_TR[CONTEXT=TrackerListWidget]"; - break; - } - } - const row = { rowId: tracker.url, tier: (tracker.tier >= 0) ? tracker.tier : "", + btVersion: "", url: tracker.url, - status: status, - peers: (tracker.num_peers >= 0) ? tracker.num_peers : "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]", - seeds: (tracker.num_seeds >= 0) ? tracker.num_seeds : "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]", - leeches: (tracker.num_leeches >= 0) ? tracker.num_leeches : "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]", - downloaded: (tracker.num_downloaded >= 0) ? tracker.num_downloaded : "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]", + status: trackerStatusText(tracker), + peers: (tracker.num_peers >= 0) ? tracker.num_peers : notApplicable, + seeds: (tracker.num_seeds >= 0) ? tracker.num_seeds : notApplicable, + leeches: (tracker.num_leeches >= 0) ? tracker.num_leeches : notApplicable, + downloaded: (tracker.num_downloaded >= 0) ? tracker.num_downloaded : notApplicable, message: tracker.msg, + nextAnnounce: tracker.next_announce, + minAnnounce: tracker.min_announce, + _isTracker: true, + _hasEndpoints: tracker.endpoints && (tracker.endpoints.length > 0), _sortable: !tracker.url.startsWith("** [") }; torrentTrackersTable.updateRowData(row); + + if (tracker.endpoints !== undefined) { + for (const endpoint of tracker.endpoints) { + const row = { + rowId: `endpoint|${tracker.url}|${endpoint.name}|${endpoint.bt_version}`, + tier: "", + btVersion: `v${endpoint.bt_version}`, + url: endpoint.name, + status: trackerStatusText(endpoint), + peers: (endpoint.num_peers >= 0) ? endpoint.num_peers : notApplicable, + seeds: (endpoint.num_seeds >= 0) ? endpoint.num_seeds : notApplicable, + leeches: (endpoint.num_leeches >= 0) ? endpoint.num_leeches : notApplicable, + downloaded: (endpoint.num_downloaded >= 0) ? endpoint.num_downloaded : notApplicable, + message: endpoint.msg, + nextAnnounce: endpoint.next_announce, + minAnnounce: endpoint.min_announce, + _isTracker: false, + _tracker: tracker.url, + _sortable: true, + }; + torrentTrackersTable.updateRowData(row); + } + } }); torrentTrackersTable.updateTable(false); @@ -163,7 +192,7 @@ window.qBittorrent.PropTrackers ??= (() => { onShow: function() { const selectedTrackers = torrentTrackersTable.selectedRowsIds(); const containsStaticTracker = selectedTrackers.some((tracker) => { - return tracker.startsWith("** ["); + return tracker.startsWith("** [") || tracker.startsWith("endpoint|"); }); if (containsStaticTracker || (selectedTrackers.length === 0)) { @@ -171,7 +200,6 @@ window.qBittorrent.PropTrackers ??= (() => { this.hideItem("RemoveTracker"); this.hideItem("CopyTrackerUrl"); this.hideItem("ReannounceTrackers"); - this.hideItem("ReannounceAllTrackers"); } else { if (selectedTrackers.length === 1)