From a265ba7fd287c5508463a0476501330c7fee0a48 Mon Sep 17 00:00:00 2001 From: tehcneko Date: Mon, 11 Aug 2025 16:20:58 +0800 Subject: [PATCH] WebUI: Implement missing tracker list features Implemented: Tracker endpoints in the list, missing "Tracker Error" and "Unreachable" status, "Next Announce" and "Min Announce" column and double click to edit tracker url. PR #23045. --- WebAPI_Changelog.md | 7 + src/webui/api/torrentscontroller.cpp | 45 ++++- src/webui/webapplication.h | 2 +- src/webui/www/private/index.html | 6 +- src/webui/www/private/scripts/dynamicTable.js | 164 +++++++++++++++++- .../www/private/scripts/prop-trackers.js | 86 +++++---- 6 files changed, 271 insertions(+), 39 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 bb210bffd..1df3a820c 100644 --- a/src/webui/api/torrentscontroller.cpp +++ b/src/webui/api/torrentscontroller.cpp @@ -28,6 +28,8 @@ #include "torrentscontroller.h" +#include +#include #include #include @@ -67,14 +69,19 @@ // Tracker keys const QString KEY_TRACKER_URL = u"url"_s; +const QString KEY_TRACKER_NAME = u"name"_s; const QString KEY_TRACKER_UPDATING = u"updating"_s; const QString KEY_TRACKER_STATUS = u"status"_s; const QString KEY_TRACKER_TIER = u"tier"_s; const QString KEY_TRACKER_MSG = u"msg"_s; +const QString KEY_TRACKER_BT_VERSION = u"bt_version"_s; const QString KEY_TRACKER_PEERS_COUNT = u"num_peers"_s; const QString KEY_TRACKER_SEEDS_COUNT = u"num_seeds"_s; const QString KEY_TRACKER_LEECHES_COUNT = u"num_leeches"_s; const QString KEY_TRACKER_DOWNLOADED_COUNT = u"num_downloaded"_s; +const QString KEY_TRACKER_NEXT_ANNOUNCE = u"next_announce"_s; +const QString KEY_TRACKER_MIN_ANNOUNCE = u"min_announce"_s; +const QString KEY_TRACKER_ENDPOINTS = u"endpoints"_s; // Web seed keys const QString KEY_WEBSEED_URL = u"url"_s; @@ -269,24 +276,52 @@ namespace QJsonArray getTrackers(const BitTorrent::Torrent *const torrent) { + const auto now = std::chrono::system_clock::now(); + const auto timepointNow = BitTorrent::AnnounceTimePoint::clock::now(); + const auto toSecondsSinceEpoch = [&now, &timepointNow](const BitTorrent::AnnounceTimePoint &time) -> qint64 + { + const auto timeEpoch = (now + (time - timepointNow)).time_since_epoch(); + return std::chrono::duration_cast(timeEpoch).count(); + }; + QJsonArray trackerList; for (const BitTorrent::TrackerEntryStatus &tracker : asConst(torrent->trackers())) { - const bool isNotWorking = (tracker.state == BitTorrent::TrackerEndpointState::NotWorking) - || (tracker.state == BitTorrent::TrackerEndpointState::TrackerError) - || (tracker.state == BitTorrent::TrackerEndpointState::Unreachable); + QJsonArray endpointsList; + + for (const BitTorrent::TrackerEndpointStatus &endpoint : tracker.endpoints) + { + endpointsList << QJsonObject + { + {KEY_TRACKER_NAME, endpoint.name}, + {KEY_TRACKER_UPDATING, endpoint.isUpdating}, + {KEY_TRACKER_STATUS, static_cast(endpoint.state)}, + {KEY_TRACKER_MSG, endpoint.message}, + {KEY_TRACKER_BT_VERSION, static_cast(endpoint.btVersion)}, + {KEY_TRACKER_PEERS_COUNT, endpoint.numPeers}, + {KEY_TRACKER_SEEDS_COUNT, endpoint.numSeeds}, + {KEY_TRACKER_LEECHES_COUNT, endpoint.numLeeches}, + {KEY_TRACKER_DOWNLOADED_COUNT, endpoint.numDownloaded}, + {KEY_TRACKER_NEXT_ANNOUNCE, toSecondsSinceEpoch(endpoint.nextAnnounceTime)}, + {KEY_TRACKER_MIN_ANNOUNCE, toSecondsSinceEpoch(endpoint.minAnnounceTime)} + }; + } + trackerList << QJsonObject { {KEY_TRACKER_URL, tracker.url}, {KEY_TRACKER_TIER, tracker.tier}, {KEY_TRACKER_UPDATING, tracker.isUpdating}, - {KEY_TRACKER_STATUS, static_cast((isNotWorking ? BitTorrent::TrackerEndpointState::NotWorking : tracker.state))}, + {KEY_TRACKER_STATUS, static_cast(tracker.state)}, {KEY_TRACKER_MSG, tracker.message}, {KEY_TRACKER_PEERS_COUNT, tracker.numPeers}, {KEY_TRACKER_SEEDS_COUNT, tracker.numSeeds}, {KEY_TRACKER_LEECHES_COUNT, tracker.numLeeches}, - {KEY_TRACKER_DOWNLOADED_COUNT, tracker.numDownloaded} + {KEY_TRACKER_DOWNLOADED_COUNT, tracker.numDownloaded}, + {KEY_TRACKER_NEXT_ANNOUNCE, toSecondsSinceEpoch(tracker.nextAnnounceTime)}, + {KEY_TRACKER_MIN_ANNOUNCE, toSecondsSinceEpoch(tracker.minAnnounceTime)}, + {KEY_TRACKER_ENDPOINTS, endpointsList} }; } diff --git a/src/webui/webapplication.h b/src/webui/webapplication.h index ef018c8b0..889da879b 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 88ceef914..9735b5abc 100644 --- a/src/webui/www/private/index.html +++ b/src/webui/www/private/index.html @@ -259,11 +259,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 3215c18bf..a43fb995e 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -2078,15 +2078,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(); } @@ -2101,6 +2154,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; @@ -2155,6 +2245,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; } @@ -2167,6 +2259,76 @@ window.qBittorrent.DynamicTable ??= (() => { td.textContent = status; td.title = status; }; + + const friendlyDuration = function(td, row) { + const value = this.getRowValue(row) ?? 0; + const seconds = Math.max(value - (Date.now() / 1000), 0); + const duration = window.qBittorrent.Misc.friendlyDuration(seconds, 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)