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
This commit is contained in:
Ivan Kara
2026-02-13 22:16:35 +07:00
committed by GitHub
parent 93a65c9c66
commit eac1f24f0b
7 changed files with 147 additions and 19 deletions

View File

@@ -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`

View File

@@ -142,6 +142,7 @@ auto constexpr MyStatic = std::array<std::string_view, TR_N_KEYS>{
"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<std::string_view, TR_N_KEYS>{
"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<std::string_view, TR_N_KEYS>{
"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<std::string_view, TR_N_KEYS>{
"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
};

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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',
];