From eac1f24f0bda52621506a38ebbae1c43ee30cc02 Mon Sep 17 00:00:00 2001 From: Ivan Kara Date: Fri, 13 Feb 2026 22:16:35 +0700 Subject: [PATCH] feat(web): add webseeds list (#8421) * Add webseeds list to web ui * Better webseed url column title * Apply suggestions * Follow existing table row creation style * Fix webseed table speed-down column * Fix empty class error * Add webseeds_ex to rpc changelogs * Apply suggestions from CI * Fix webseeds_ex rpc spec --- docs/rpc-spec.md | 10 +++ libtransmission/quark.cc | 4 + libtransmission/quark.h | 4 + libtransmission/rpcimpl.cc | 20 +++++ web/assets/css/transmission-app.scss | 7 ++ web/src/inspector.js | 117 ++++++++++++++++++++++----- web/src/torrent.js | 4 + 7 files changed, 147 insertions(+), 19 deletions(-) diff --git a/docs/rpc-spec.md b/docs/rpc-spec.md index 5f7f06678..1ee0516d9 100644 --- a/docs/rpc-spec.md +++ b/docs/rpc-spec.md @@ -313,6 +313,7 @@ The 'source' column here corresponds to the data structure there. | `upload_ratio`| double| tr_stat | `wanted`| array (see below)| n/a | `webseeds`| array of strings | tr_tracker_view +| `webseeds_ex`| array (see below)| n/a | `webseeds_sending_to_us`| number| tr_stat `availability`: An array of `piece_count` numbers representing the number of connected peers that have each piece, or -1 if we already have the piece ourselves. @@ -437,6 +438,14 @@ Files are returned in the order they are laid out in the torrent. References to | `tier` | number | tr_tracker_view +`webseeds_ex`: array of objects, each containing: + +| Key | Value Type | transmission.h source +|:--|:--|:-- +| `url` | string | tr_webseed_view +| `is_downloading` | boolean | tr_webseed_view +| `download_bytes_per_second` | number | tr_webseed_view + `wanted`: An array of `tr_torrentFileCount()` booleans, true if the corresponding file is to be downloaded. (Source: `tr_file_view`) **Note:** For backwards compatibility, in the old bespoke API, `wanted` is serialized as an array of `0` or `1` that should be treated as booleans. @@ -1109,3 +1118,4 @@ Transmission 4.2.0 (`rpc_version_semver` 6.1.0, `rpc_version`: ?) | Method | Description |:---|:--- +| `torrent_get` | new arg `webseeds_ex` diff --git a/libtransmission/quark.cc b/libtransmission/quark.cc index 97b47df1e..01e29f905 100644 --- a/libtransmission/quark.cc +++ b/libtransmission/quark.cc @@ -142,6 +142,7 @@ auto constexpr MyStatic = std::array{ "downloadLimit"sv, // rpc "downloadLimited"sv, // rpc "downloadSpeed"sv, // rpc + "download_bytes_per_second"sv, // rpc "download_count"sv, // rpc "download_dir"sv, // daemon, gtk app, rpc, tr_session::Settings "download_dir_free_space"sv, // rpc @@ -263,6 +264,7 @@ auto constexpr MyStatic = std::array{ "isUTP"sv, // rpc "isUploadingTo"sv, // rpc "is_backup"sv, // rpc + "is_downloading"sv, // rpc "is_downloading_from"sv, // rpc "is_encrypted"sv, // rpc "is_finished"sv, // rpc @@ -708,6 +710,7 @@ auto constexpr MyStatic = std::array{ "uploadedEver"sv, // rpc "uploaded_bytes"sv, // rpc, stats.json "uploaded_ever"sv, // rpc + "url"sv, // rpc "url-list"sv, // .torrent "use-global-speed-limit"sv, // .resume "use-speed-limit"sv, // .resume @@ -729,6 +732,7 @@ auto constexpr MyStatic = std::array{ "watch_dir_force_generic"sv, // daemon "webseeds"sv, // rpc "webseedsSendingToUs"sv, // rpc + "webseeds_ex"sv, // rpc "webseeds_sending_to_us"sv, // rpc "yourip"sv, // BEP0010; BT protocol }; diff --git a/libtransmission/quark.h b/libtransmission/quark.h index 0a42044b6..7f88f16af 100644 --- a/libtransmission/quark.h +++ b/libtransmission/quark.h @@ -155,6 +155,7 @@ enum // NOLINT(performance-enum-size) TR_KEY_download_limit_camel_APICOMPAT, TR_KEY_download_limited_camel_APICOMPAT, TR_KEY_download_speed_camel_APICOMPAT, + TR_KEY_download_bytes_per_second, TR_KEY_download_count, TR_KEY_download_dir, TR_KEY_download_dir_free_space, @@ -276,6 +277,7 @@ enum // NOLINT(performance-enum-size) TR_KEY_is_utp_camel_APICOMPAT, TR_KEY_is_uploading_to_camel_APICOMPAT, TR_KEY_is_backup, + TR_KEY_is_downloading, TR_KEY_is_downloading_from, TR_KEY_is_encrypted, TR_KEY_is_finished, @@ -721,6 +723,7 @@ enum // NOLINT(performance-enum-size) TR_KEY_uploaded_ever_camel_APICOMPAT, TR_KEY_uploaded_bytes, TR_KEY_uploaded_ever, + TR_KEY_url, TR_KEY_url_list, TR_KEY_use_global_speed_limit_kebab_APICOMPAT, TR_KEY_use_speed_limit_kebab_APICOMPAT, @@ -742,6 +745,7 @@ enum // NOLINT(performance-enum-size) TR_KEY_watch_dir_force_generic, TR_KEY_webseeds, TR_KEY_webseeds_sending_to_us_camel_APICOMPAT, + TR_KEY_webseeds_ex, TR_KEY_webseeds_sending_to_us, TR_KEY_yourip, TR_N_KEYS diff --git a/libtransmission/rpcimpl.cc b/libtransmission/rpcimpl.cc index b0c841f3e..80bd87fcd 100644 --- a/libtransmission/rpcimpl.cc +++ b/libtransmission/rpcimpl.cc @@ -554,6 +554,23 @@ namespace make_torrent_field_helpers return tr_variant{ std::move(vec) }; } +[[nodiscard]] auto make_webseed_ex_vec(tr_torrent const& tor) +{ + auto const n_webseeds = tor.webseed_count(); + auto vec = tr_variant::Vector{}; + vec.reserve(n_webseeds); + for (size_t idx = 0U; idx != n_webseeds; ++idx) + { + auto const webseed = tr_torrentWebseed(&tor, idx); + auto webseed_map = tr_variant::Map{ 3U }; + webseed_map.try_emplace(TR_KEY_url, webseed.url); + webseed_map.try_emplace(TR_KEY_is_downloading, webseed.is_downloading); + webseed_map.try_emplace(TR_KEY_download_bytes_per_second, webseed.download_bytes_per_second); + vec.emplace_back(std::move(webseed_map)); + } + return tr_variant{ std::move(vec) }; +} + [[nodiscard]] auto make_tracker_vec(tr_torrent const& tor) { auto const& trackers = tor.announce_list(); @@ -768,6 +785,7 @@ namespace make_torrent_field_helpers case TR_KEY_uploaded_ever: case TR_KEY_wanted: case TR_KEY_webseeds: + case TR_KEY_webseeds_ex: case TR_KEY_webseeds_sending_to_us: return true; @@ -942,6 +960,8 @@ namespace make_torrent_field_helpers return make_file_wanted_vec(tor); case TR_KEY_webseeds: return make_webseed_vec(tor); + case TR_KEY_webseeds_ex: + return make_webseed_ex_vec(tor); case TR_KEY_webseeds_sending_to_us: return st.webseeds_sending_to_us; default: diff --git a/web/assets/css/transmission-app.scss b/web/assets/css/transmission-app.scss index a2de686f1..8929979be 100644 --- a/web/assets/css/transmission-app.scss +++ b/web/assets/css/transmission-app.scss @@ -1185,6 +1185,7 @@ a { table-layout: fixed; text-align: left; width: 100%; + margin-bottom: 1rem; td, th { @@ -1252,6 +1253,12 @@ a { cursor: pointer; width: 10%; } + + .webseed-url { + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } /// FILE PRIORITY BUTTONS diff --git a/web/src/inspector.js b/web/src/inspector.js index 32e81e570..478c3168c 100644 --- a/web/src/inspector.js +++ b/web/src/inspector.js @@ -18,6 +18,8 @@ const peer_column_classes = [ 'peer-app-name', ]; +const webseed_column_classes = ['url', 'speed-down']; + export class Inspector extends EventTarget { constructor(controller) { super(); @@ -138,6 +140,26 @@ export class Inspector extends EventTarget { } static _createPeersPage() { + const container = document.createElement('div'); + + // Webseeds table + const webseedsTable = document.createElement('table'); + webseedsTable.classList.add('peer-list'); + const webseedsThead = document.createElement('thead'); + const webseedsTr = document.createElement('tr'); + const webseedsHeaders = ['Web Seeds', 'Down']; + for (const [index, name] of webseedsHeaders.entries()) { + const th = document.createElement('th'); + th.classList.add(webseed_column_classes[index]); + setTextContent(th, name); + webseedsTr.append(th); + } + const webseedsTbody = document.createElement('tbody'); + webseedsThead.append(webseedsTr); + webseedsTable.append(webseedsThead); + webseedsTable.append(webseedsTbody); + + // Peers table const table = document.createElement('table'); table.classList.add('peer-list'); const thead = document.createElement('thead'); @@ -157,9 +179,16 @@ export class Inspector extends EventTarget { thead.append(tr); table.append(thead); table.append(tbody); + + container.append(webseedsTable); + container.append(table); + return { - root: table, + peersTable: table, + root: container, tbody, + webseedsTable, + webseedsTbody, }; } @@ -601,7 +630,7 @@ export class Inspector extends EventTarget { _updatePeers() { const fmt = Formatter; const { elements, torrents } = this; - const { tbody } = elements.peers; + const { tbody, webseedsTbody, webseedsTable } = elements.peers; const cell_setters = [ (peer, td) => { @@ -632,36 +661,86 @@ export class Inspector extends EventTarget { }, ]; - const rows = []; + const webseed_cell_setters = [ + (webseed, td) => { + setTextContent(td, webseed.url); + td.setAttribute('title', webseed.url); + }, + (webseed, td) => { + setTextContent( + td, + webseed.download_bytes_per_second + ? fmt.speedBps(webseed.download_bytes_per_second) + : '', + ); + }, + ]; + + const webseedsRows = []; + const peersRows = []; + let hasWebseeds = false; + for (const tor of torrents) { - // torrent name - const tortr = document.createElement('tr'); - tortr.classList.add('torrent-row'); - const tortd = document.createElement('td'); - tortd.setAttribute('colspan', cell_setters.length); - setTextContent(tortd, tor.getName()); - tortr.append(tortd); - rows.push(tortr); + // create base torrent row + const baseTortr = document.createElement('tr'); + baseTortr.classList.add('torrent-row'); + const baseTortd = document.createElement('td'); + setTextContent(baseTortd, tor.getName()); + baseTortr.append(baseTortd); + + // webseeds + const webseeds = tor.getWebseedsEx(); + if (webseeds.length > 0) { + hasWebseeds = true; + const tortr = baseTortr.cloneNode(true); + tortr.firstChild.setAttribute('colspan', webseed_cell_setters.length); + webseedsRows.push(tortr); + + // webseed rows + for (const webseed of webseeds) { + const tr = document.createElement('tr'); + tr.classList.add('webseed-row'); + for (const [index, setter] of webseed_cell_setters.entries()) { + const td = document.createElement('td'); + td.classList.add(webseed_column_classes[index]); + setter(webseed, td); + tr.append(td); + } + webseedsRows.push(tr); + } + } + + // peers + const tortr = baseTortr.cloneNode(true); + tortr.firstChild.setAttribute('colspan', cell_setters.length); + peersRows.push(tortr); // peers for (const peer of tor.getPeers()) { const tr = document.createElement('tr'); - tr.classList.add('peer-row'); for (const [index, setter] of cell_setters.entries()) { const td = document.createElement('td'); td.classList.add(peer_column_classes[index]); setter(peer, td); tr.append(td); } - rows.push(tr); + peersRows.push(tr); } - - // TODO: modify instead of rebuilding wholesale? - while (tbody.firstChild) { - tbody.firstChild.remove(); - } - tbody.append(...rows); } + + // TODO: modify instead of rebuilding wholesale? + webseedsTable.style.display = hasWebseeds ? '' : 'none'; + while (webseedsTbody.firstChild) { + webseedsTbody.firstChild.remove(); + } + if (hasWebseeds) { + webseedsTbody.append(...webseedsRows); + } + + while (tbody.firstChild) { + tbody.firstChild.remove(); + } + tbody.append(...peersRows); } /// TRACKERS PAGE diff --git a/web/src/torrent.js b/web/src/torrent.js index 8b5cb2782..81f3060a6 100644 --- a/web/src/torrent.js +++ b/web/src/torrent.js @@ -189,6 +189,9 @@ export class Torrent extends EventTarget { getPeers() { return this.fields.peers || []; } + getWebseedsEx() { + return this.fields.webseeds_ex || []; + } getPeersConnected() { return this.fields.peers_connected; } @@ -653,4 +656,5 @@ Torrent.Fields.StatsExtra = [ 'peers', 'start_date', 'tracker_stats', + 'webseeds_ex', ];