diff --git a/src/webui/api/synccontroller.cpp b/src/webui/api/synccontroller.cpp index 574a833ed..469f58459 100644 --- a/src/webui/api/synccontroller.cpp +++ b/src/webui/api/synccontroller.cpp @@ -120,6 +120,28 @@ namespace const QString KEY_FULL_UPDATE = u"full_update"_s; const QString KEY_RESPONSE_ID = u"rid"_s; + const QString KEY_TORRENT_HAS_TRACKER_WARNING = u"has_tracker_warning"_s; + const QString KEY_TORRENT_HAS_TRACKER_ERROR = u"has_tracker_error"_s; + const QString KEY_TORRENT_HAS_OTHER_ANNOUNCE_ERROR = u"has_other_announce_error"_s; + + QStringList asStrings(const QSet &torrentIDs) + { + QStringList result; + result.reserve(torrentIDs.size()); + for (const BitTorrent::TorrentID &torrentID : torrentIDs) + result.emplaceBack(torrentID.toString()); + + return result; + } + + bool hasWarningMessage(const BitTorrent::TrackerEntryStatus &status) + { + return std::ranges::any_of(status.endpoints, [](const BitTorrent::TrackerEndpointStatus &endpointEntry) + { + return (endpointEntry.state == BitTorrent::TrackerEndpointState::Working) && !endpointEntry.message.isEmpty(); + }); + } + QVariantMap processMap(const QVariantMap &prevData, const QVariantMap &data); std::pair processHash(QVariantHash prevData, const QVariantHash &data); std::pair processList(QVariantList prevData, const QVariantList &data); @@ -384,6 +406,39 @@ namespace return QJsonObject::fromVariantMap(syncData); } + + void addAnnounceStats(QVariantMap &serializedTorrent, const BitTorrent::Torrent *torrent) + { + bool hasTrackerWarning = false; + bool hasTrackerError = false; + bool hasOtherAnnounceError = false; + for (const BitTorrent::TrackerEntryStatus &status : asConst(torrent->trackers())) + { + switch (status.state) + { + case BitTorrent::TrackerEndpointState::Working: + if (!hasTrackerWarning && hasWarningMessage(status)) + hasTrackerWarning = true; + break; + case BitTorrent::TrackerEndpointState::TrackerError: + hasTrackerError = true; + break; + case BitTorrent::TrackerEndpointState::NotWorking: + case BitTorrent::TrackerEndpointState::Unreachable: + hasOtherAnnounceError = true; + break; + default: + break; + } + + if (hasTrackerWarning && hasTrackerError && hasOtherAnnounceError) + break; + } + + serializedTorrent[KEY_TORRENT_HAS_TRACKER_WARNING] = hasTrackerWarning; + serializedTorrent[KEY_TORRENT_HAS_TRACKER_ERROR] = hasTrackerError; + serializedTorrent[KEY_TORRENT_HAS_OTHER_ANNOUNCE_ERROR] = hasOtherAnnounceError; + } } SyncController::SyncController(IApplication *app, QObject *parent) @@ -445,6 +500,9 @@ void SyncController::updateFreeDiskSpace(const qint64 freeDiskSpace) // - "seen_complete": Indicates the time when the torrent was last seen complete/whole // - "last_activity": Last time when a chunk was downloaded/uploaded // - "total_size": Size including unwanted data +// - "has_tracker_warning": the torrent has working tracker that has a message +// - "has_tracker_error": the torrent has a tracker error +// - "has_other_announce_error": the torrent has other problems announcing to a tracker // Server state map may contain the following keys: // - "connection_status": connection status // - "dht_nodes": DHT nodes count @@ -488,6 +546,7 @@ void SyncController::maindataAction() connect(btSession, &BitTorrent::Session::trackersAdded, this, &SyncController::onTorrentTrackersChanged); connect(btSession, &BitTorrent::Session::trackersRemoved, this, &SyncController::onTorrentTrackersChanged); connect(btSession, &BitTorrent::Session::trackersChanged, this, &SyncController::onTorrentTrackersChanged); + connect(btSession, &BitTorrent::Session::trackerEntryStatusesUpdated, this, &SyncController::onTorrentTrackerEntryStatusesUpdated); } const int acceptedID = params()[u"rid"_s].toInt(); @@ -526,6 +585,7 @@ void SyncController::makeMaindataSnapshot() QVariantMap serializedTorrent = serialize(*torrent); serializedTorrent.remove(KEY_TORRENT_ID); + addAnnounceStats(serializedTorrent, torrent); for (const BitTorrent::TrackerEntryStatus &status : asConst(torrent->trackers())) m_knownTrackers[status.url].insert(torrentID); @@ -547,14 +607,8 @@ void SyncController::makeMaindataSnapshot() for (const Tag &tag : asConst(session->tags())) m_maindataSnapshot.tags.append(tag.toString()); - for (auto trackersIter = m_knownTrackers.cbegin(); trackersIter != m_knownTrackers.cend(); ++trackersIter) - { - QStringList torrentIDs; - for (const BitTorrent::TorrentID &torrentID : asConst(trackersIter.value())) - torrentIDs.append(torrentID.toString()); - - m_maindataSnapshot.trackers[trackersIter.key()] = torrentIDs; - } + for (const auto &[tracker, torrentIDs] : m_knownTrackers.asKeyValueRange()) + m_maindataSnapshot.trackers[tracker] = asStrings(torrentIDs); m_maindataSnapshot.serverState = getTransferInfo(); m_maindataSnapshot.serverState[KEY_TRANSFER_FREESPACEONDISK] = m_freeDiskSpace; @@ -579,8 +633,12 @@ QJsonObject SyncController::generateMaindataSyncData(const int id, const bool fu for (const BitTorrent::TorrentID &torrentID : asConst(m_updatedTorrents)) m_maindataSyncBuf.removedTorrents.removeOne(torrentID.toString()); + for (const BitTorrent::TorrentID &torrentID : asConst(m_removedTorrents)) - m_maindataSyncBuf.torrents.remove(torrentID.toString()); + { + const QString torrentIDStr = torrentID.toString(); + m_maindataSyncBuf.torrents.remove(torrentIDStr); + } for (const QString &tracker : asConst(m_updatedTrackers)) m_maindataSyncBuf.removedTrackers.removeOne(tracker); @@ -635,30 +693,64 @@ QJsonObject SyncController::generateMaindataSyncData(const int id, const bool fu QVariantMap serializedTorrent = serialize(*torrent); serializedTorrent.remove(KEY_TORRENT_ID); - auto &torrentSnapshot = m_maindataSnapshot.torrents[torrentID.toString()]; + const QString torrentIDStr = torrentID.toString(); + auto &torrentSnapshot = m_maindataSnapshot.torrents[torrentIDStr]; + + if (m_announcedTorrents.contains(torrentID)) + { + addAnnounceStats(serializedTorrent, torrent); + } + else + { + serializedTorrent[KEY_TORRENT_HAS_TRACKER_WARNING] = torrentSnapshot[KEY_TORRENT_HAS_TRACKER_WARNING]; + serializedTorrent[KEY_TORRENT_HAS_TRACKER_ERROR] = torrentSnapshot[KEY_TORRENT_HAS_TRACKER_ERROR]; + serializedTorrent[KEY_TORRENT_HAS_OTHER_ANNOUNCE_ERROR] = torrentSnapshot[KEY_TORRENT_HAS_OTHER_ANNOUNCE_ERROR]; + } if (const QVariantMap syncData = processMap(torrentSnapshot, serializedTorrent); !syncData.isEmpty()) { - m_maindataSyncBuf.torrents[torrentID.toString()] = syncData; + m_maindataSyncBuf.torrents[torrentIDStr] = syncData; torrentSnapshot = serializedTorrent; } } + + for (const BitTorrent::TorrentID &torrentID : asConst(m_announcedTorrents)) + { + if (m_updatedTorrents.contains(torrentID)) + continue; + + const BitTorrent::Torrent *torrent = session->getTorrent(torrentID); + Q_ASSERT(torrent); + + const QString torrentIDStr = torrentID.toString(); + auto &torrentSnapshot = m_maindataSnapshot.torrents[torrentIDStr]; + + // Only announce stats are changed so don't need to serialize torrent again + QVariantMap serializedTorrent = torrentSnapshot; + addAnnounceStats(serializedTorrent, torrent); + + if (const QVariantMap syncData = processMap(torrentSnapshot, serializedTorrent); !syncData.isEmpty()) + { + m_maindataSyncBuf.torrents[torrentIDStr] = syncData; + torrentSnapshot = serializedTorrent; + } + } + m_updatedTorrents.clear(); + m_announcedTorrents.clear(); for (const BitTorrent::TorrentID &torrentID : asConst(m_removedTorrents)) { - m_maindataSyncBuf.removedTorrents.append(torrentID.toString()); - m_maindataSnapshot.torrents.remove(torrentID.toString()); + const QString torrentIDStr = torrentID.toString(); + + m_maindataSyncBuf.removedTorrents.append(torrentIDStr); + m_maindataSnapshot.torrents.remove(torrentIDStr); } m_removedTorrents.clear(); for (const QString &tracker : asConst(m_updatedTrackers)) { - const QSet torrentIDs = m_knownTrackers[tracker]; - QStringList serializedTorrentIDs; - serializedTorrentIDs.reserve(torrentIDs.size()); - for (const BitTorrent::TorrentID &torrentID : torrentIDs) - serializedTorrentIDs.append(torrentID.toString()); + const QStringList serializedTorrentIDs = asStrings(m_knownTrackers[tracker]); m_maindataSyncBuf.trackers[tracker] = serializedTorrentIDs; m_maindataSnapshot.trackers[tracker] = serializedTorrentIDs; @@ -847,6 +939,7 @@ void SyncController::onTorrentAdded(BitTorrent::Torrent *torrent) m_removedTorrents.remove(torrentID); m_updatedTorrents.insert(torrentID); + m_announcedTorrents.insert(torrentID); for (const BitTorrent::TrackerEntryStatus &status : asConst(torrent->trackers())) { @@ -860,6 +953,7 @@ void SyncController::onTorrentAboutToBeRemoved(BitTorrent::Torrent *torrent) { const BitTorrent::TorrentID torrentID = torrent->id(); + m_announcedTorrents.remove(torrentID); m_updatedTorrents.remove(torrentID); m_removedTorrents.insert(torrentID); @@ -899,6 +993,7 @@ void SyncController::onTorrentMetadataReceived(BitTorrent::Torrent *torrent) void SyncController::onTorrentStopped(BitTorrent::Torrent *torrent) { m_updatedTorrents.insert(torrent->id()); + m_announcedTorrents.insert(torrent->id()); } void SyncController::onTorrentStarted(BitTorrent::Torrent *torrent) @@ -981,4 +1076,12 @@ void SyncController::onTorrentTrackersChanged(BitTorrent::Torrent *torrent) m_removedTrackers.remove(currentTracker); } } + + m_announcedTorrents.insert(torrentID); +} + +void SyncController::onTorrentTrackerEntryStatusesUpdated(const BitTorrent::Torrent *torrent + , [[maybe_unused]] const QHash &updatedTrackers) +{ + m_announcedTorrents.insert(torrent->id()); } diff --git a/src/webui/api/synccontroller.h b/src/webui/api/synccontroller.h index af2223827..9d7009c00 100644 --- a/src/webui/api/synccontroller.h +++ b/src/webui/api/synccontroller.h @@ -1,6 +1,6 @@ /* * Bittorrent Client using Qt and libtorrent. - * Copyright (C) 2018-2023 Vladimir Golovnev + * Copyright (C) 2018-2025 Vladimir Golovnev * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -38,6 +38,7 @@ namespace BitTorrent { class Torrent; + struct TrackerEntryStatus; } class SyncController : public APIController @@ -79,6 +80,8 @@ private: void onTorrentTagRemoved(BitTorrent::Torrent *torrent, const Tag &tag); void onTorrentsUpdated(const QList &torrents); void onTorrentTrackersChanged(BitTorrent::Torrent *torrent); + void onTorrentTrackerEntryStatusesUpdated(const BitTorrent::Torrent *torrent + , const QHash &updatedTrackers); qint64 m_freeDiskSpace = 0; @@ -94,20 +97,24 @@ private: QSet m_updatedTrackers; QSet m_removedTrackers; QSet m_updatedTorrents; + QSet m_announcedTorrents; QSet m_removedTorrents; struct MaindataSyncBuf { QHash categories; - QVariantList tags; - QHash torrents; - QHash trackers; - QVariantMap serverState; - QStringList removedCategories; + + QVariantList tags; QStringList removedTags; + + QHash torrents; QStringList removedTorrents; + + QHash trackers; QStringList removedTrackers; + + QVariantMap serverState; }; MaindataSyncBuf m_maindataSnapshot; diff --git a/src/webui/webapplication.h b/src/webui/webapplication.h index b5f015449..a55b3a7de 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, 11, 6}; +inline const Utils::Version<3, 2> API_VERSION {2, 11, 7}; class APIController; class AuthController; diff --git a/src/webui/www/private/images/tracker-error.svg b/src/webui/www/private/images/tracker-error.svg new file mode 100644 index 000000000..8a531c3f2 --- /dev/null +++ b/src/webui/www/private/images/tracker-error.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/webui/www/private/images/tracker-warning.svg b/src/webui/www/private/images/tracker-warning.svg new file mode 100644 index 000000000..4e473d9e3 --- /dev/null +++ b/src/webui/www/private/images/tracker-warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/webui/www/private/images/trackerless.svg b/src/webui/www/private/images/trackerless.svg new file mode 100644 index 000000000..ab9d8e751 --- /dev/null +++ b/src/webui/www/private/images/trackerless.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index 7f95a74f5..d0b13f1c1 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -161,7 +161,10 @@ let setTagFilter = () => {}; /* Trackers filter */ const TRACKERS_ALL = "b4af0e4c-e76d-4bac-a392-46cbc18d9655"; +const TRACKERS_ANNOUNCE_ERROR = "d0b4cad2-9f6f-4e7f-8d4b-f80a103dd436"; +const TRACKERS_ERROR = "b551cfc3-64e9-4393-bc88-5d6ea2fab5cc"; const TRACKERS_TRACKERLESS = "e24bd469-ea22-404c-8e2e-a17c82f37ea0"; +const TRACKERS_WARNING = "82a702c5-210c-412b-829f-97632d7557e9"; // Map> const trackerMap = new Map(); @@ -685,17 +688,41 @@ window.addEventListener("DOMContentLoaded", (event) => { const span = trackerFilterItem.firstElementChild; span.lastChild.textContent = `${text} (${count})`; + switch (host) { + case TRACKERS_ANNOUNCE_ERROR: + case TRACKERS_ERROR: + span.lastElementChild.src = "images/tracker-error.svg"; + break; + case TRACKERS_TRACKERLESS: + span.lastElementChild.src = "images/trackerless.svg"; + break; + case TRACKERS_WARNING: + span.lastElementChild.src = "images/tracker-warning.svg"; + break; + } + return trackerFilterItem; }; - let trackerlessTorrentsCount = 0; - for (const { full_data: { trackers_count: trackersCount } } of torrentsTable.getRowValues()) { - if (trackersCount === 0) - trackerlessTorrentsCount += 1; + let trackerlessCount = 0; + let trackerErrorCount = 0; + let announceErrorCount = 0; + let trackerWarningCount = 0; + for (const { full_data } of torrentsTable.getRowValues()) { + if (full_data.trackers_count === 0) + trackerlessCount += 1; + + // counting bools by adding them + trackerErrorCount += full_data.has_tracker_error; + announceErrorCount += full_data.has_other_announce_error; + trackerWarningCount += full_data.has_tracker_warning; } trackerFilterList.appendChild(createLink(TRACKERS_ALL, "QBT_TR(All)QBT_TR[CONTEXT=TrackerFiltersList]", torrentsTable.getRowSize())); - trackerFilterList.appendChild(createLink(TRACKERS_TRACKERLESS, "QBT_TR(Trackerless)QBT_TR[CONTEXT=TrackerFiltersList]", trackerlessTorrentsCount)); + trackerFilterList.appendChild(createLink(TRACKERS_TRACKERLESS, "QBT_TR(Trackerless)QBT_TR[CONTEXT=TrackerFiltersList]", trackerlessCount)); + trackerFilterList.appendChild(createLink(TRACKERS_ERROR, "QBT_TR(Tracker error)QBT_TR[CONTEXT=TrackerFiltersList]", trackerErrorCount)); + trackerFilterList.appendChild(createLink(TRACKERS_ANNOUNCE_ERROR, "QBT_TR(Other error)QBT_TR[CONTEXT=TrackerFiltersList]", announceErrorCount)); + trackerFilterList.appendChild(createLink(TRACKERS_WARNING, "QBT_TR(Warning)QBT_TR[CONTEXT=TrackerFiltersList]", trackerWarningCount)); // Sort trackers by hostname const sortedList = []; diff --git a/src/webui/www/private/scripts/contextmenu.js b/src/webui/www/private/scripts/contextmenu.js index a16424b07..6ab9a0c12 100644 --- a/src/webui/www/private/scripts/contextmenu.js +++ b/src/webui/www/private/scripts/contextmenu.js @@ -620,11 +620,18 @@ window.qBittorrent.ContextMenu ??= (() => { class TrackersFilterContextMenu extends FilterListContextMenu { updateMenuItems() { - const id = this.options.element.id; - if ((id !== TRACKERS_ALL) && (id !== TRACKERS_TRACKERLESS)) - this.showItem("deleteTracker"); - else - this.hideItem("deleteTracker"); + switch (this.options.element.id) { + case TRACKERS_ALL: + case TRACKERS_ANNOUNCE_ERROR: + case TRACKERS_ERROR: + case TRACKERS_TRACKERLESS: + case TRACKERS_WARNING: + this.hideItem("deleteTracker"); + break; + default: + this.showItem("deleteTracker"); + break; + } this.updateTorrentActions(); } diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index a2db88589..f1c5c2444 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -1558,7 +1558,7 @@ window.qBittorrent.DynamicTable ??= (() => { }; } - applyFilter(row, filterName, category, tag, tracker, filterTerms) { + applyFilter(row, filterName, category, tag, trackerHost, filterTerms) { const state = row["full_data"].state; let inactive = false; @@ -1666,17 +1666,32 @@ window.qBittorrent.DynamicTable ??= (() => { } } - switch (tracker) { + switch (trackerHost) { case TRACKERS_ALL: break; // do nothing + case TRACKERS_ANNOUNCE_ERROR: + if (!row["full_data"]["has_other_announce_error"]) + return false; + break; + + case TRACKERS_ERROR: + if (!row["full_data"]["has_tracker_error"]) + return false; + break; + case TRACKERS_TRACKERLESS: if (row["full_data"].trackers_count > 0) return false; break; + case TRACKERS_WARNING: + if (!row["full_data"]["has_tracker_warning"]) + return false; + break; + default: { - const trackerTorrentMap = trackerMap.get(tracker); + const trackerTorrentMap = trackerMap.get(trackerHost); if (trackerTorrentMap !== undefined) { let found = false; for (const torrents of trackerTorrentMap.values()) { diff --git a/src/webui/www/private/scripts/mocha-init.js b/src/webui/www/private/scripts/mocha-init.js index e7473071f..4b6e9a28b 100644 --- a/src/webui/www/private/scripts/mocha-init.js +++ b/src/webui/www/private/scripts/mocha-init.js @@ -1093,7 +1093,11 @@ const initializeWindows = () => { }; deleteTrackerFN = (trackerHost) => { - if ((trackerHost === TRACKERS_ALL) || (trackerHost === TRACKERS_TRACKERLESS)) + if ((trackerHost === TRACKERS_ALL) + || (trackerHost === TRACKERS_ANNOUNCE_ERROR) + || (trackerHost === TRACKERS_ERROR) + || (trackerHost === TRACKERS_TRACKERLESS) + || (trackerHost === TRACKERS_WARNING)) return; const contentURL = new URL("confirmtrackerdeletion.html", window.location); diff --git a/src/webui/www/private/scripts/prop-trackers.js b/src/webui/www/private/scripts/prop-trackers.js index 05a5de700..b95bef5e7 100644 --- a/src/webui/www/private/scripts/prop-trackers.js +++ b/src/webui/www/private/scripts/prop-trackers.js @@ -74,10 +74,11 @@ window.qBittorrent.PropTrackers ??= (() => { return; const selectedTrackers = torrentTrackersTable.selectedRowsIds(); - torrentTrackersTable.clear(); const trackers = await response.json(); if (trackers) { + torrentTrackersTable.clear(); + trackers.each((tracker) => { let status; switch (tracker.status) { @@ -122,7 +123,7 @@ window.qBittorrent.PropTrackers ??= (() => { }) .finally(() => { clearTimeout(loadTrackersDataTimer); - loadTrackersDataTimer = loadTrackersData.delay(10000); + loadTrackersDataTimer = loadTrackersData.delay(window.qBittorrent.Client.getSyncMainDataInterval()); }); }; diff --git a/src/webui/www/webui.qrc b/src/webui/www/webui.qrc index 123790da2..d2ddbe9e6 100644 --- a/src/webui/www/webui.qrc +++ b/src/webui/www/webui.qrc @@ -372,6 +372,9 @@ private/images/torrent-start-forced.svg private/images/torrent-start.svg private/images/torrent-stop.svg + private/images/tracker-error.svg + private/images/tracker-warning.svg + private/images/trackerless.svg private/images/trackers.svg private/images/upload.svg private/images/view-categories.svg