From 2f898e9fb586d40f82a61b129b0afd6eaaae78d1 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:32:03 -0400 Subject: [PATCH 1/2] Add the Downloads widget Show active downloads from qBittorrent, Transmission, Deluge, SABnzbd and NZBGet as parcels on a conveyor belt, with the time until the last one finishes. Bundled widget pages now fill their card. --- README.md | 1 + api/src/widgets.js | 1 + api/test/downloads-data.test.js | 447 ++++++++++++++++++ ui/admin/index.html | 2 +- ui/index.html | 2 +- ui/js/admin-app-form.js | 10 +- ui/js/admin-auth.js | 4 +- ui/js/admin-color-control.js | 8 +- ui/js/admin-drag.js | 2 +- ui/js/admin-list.js | 12 +- ui/js/admin-settings.js | 8 +- ui/js/admin-shared.js | 4 +- ui/js/admin-widget-form.js | 10 +- ui/js/admin.js | 20 +- ui/js/background.js | 2 +- ui/js/dashboard.js | 10 +- ui/js/icon-set.js | 10 + ui/js/listbox.js | 4 +- ui/js/spotlight.js | 2 +- ui/js/ui.js | 2 +- ui/js/utils.js | 13 +- ui/js/widget-config-form.js | 8 +- ui/js/widget-glyphs.js | 3 +- ui/test/downloads-belt.test.mjs | 65 +++ ui/test/widget-colours.test.mjs | 17 + ui/test/widget-frame-fit.test.mjs | 8 +- ui/test/widget-live-regions.test.mjs | 1 + ui/widgets/downloads/belt-logic.js | 108 +++++ ui/widgets/downloads/data.js | 274 +++++++++++ ui/widgets/downloads/demo.js | 31 ++ ui/widgets/downloads/download-items.js | 266 +++++++++++ ui/widgets/downloads/i18n/de.json | 60 +++ ui/widgets/downloads/i18n/en.json | 60 +++ ui/widgets/downloads/i18n/es.json | 67 +++ ui/widgets/downloads/i18n/fa.json | 60 +++ ui/widgets/downloads/i18n/fr.json | 67 +++ ui/widgets/downloads/i18n/zh-Hans.json | 53 +++ ui/widgets/downloads/index.html | 608 +++++++++++++++++++++++++ ui/widgets/downloads/widget.json | 143 ++++++ ui/widgets/weather/index.html | 2 +- 40 files changed, 2410 insertions(+), 65 deletions(-) create mode 100644 api/test/downloads-data.test.js create mode 100644 ui/test/downloads-belt.test.mjs create mode 100644 ui/widgets/downloads/belt-logic.js create mode 100644 ui/widgets/downloads/data.js create mode 100644 ui/widgets/downloads/demo.js create mode 100644 ui/widgets/downloads/download-items.js create mode 100644 ui/widgets/downloads/i18n/de.json create mode 100644 ui/widgets/downloads/i18n/en.json create mode 100644 ui/widgets/downloads/i18n/es.json create mode 100644 ui/widgets/downloads/i18n/fa.json create mode 100644 ui/widgets/downloads/i18n/fr.json create mode 100644 ui/widgets/downloads/i18n/zh-Hans.json create mode 100644 ui/widgets/downloads/index.html create mode 100644 ui/widgets/downloads/widget.json diff --git a/README.md b/README.md index c2fc55e3..65927023 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Widgets and the services they read: - **System summary**: CPU, memory, disk, throughput, uptime, and network speed from SpeedTest Tracker or MySpeed. Reports on this machine, or on a host running Glances, Beszel, the Unraid API, Proxmox VE or TrueNAS - **Disk health**: TrueNAS, Unraid, Scrutiny - **Backup**: Duplicati, Kopia, Proxmox Backup Server, Backrest +- **Downloads**: qBittorrent, Transmission, Deluge, SABnzbd, NZBGet - **Connections**: Gluetun, Psiphon Conduit, Netbird, Plausible, Umami - **Dashboard switch**: links to your other Stackyard dashboards diff --git a/api/src/widgets.js b/api/src/widgets.js index 903cacb5..5113c847 100644 --- a/api/src/widgets.js +++ b/api/src/widgets.js @@ -25,6 +25,7 @@ const VALID_GLYPHS = new Set([ 'network', 'merge', 'panels', + 'parcel', ]); const VALID_FIELDTYPES = new Set([ 'text', diff --git a/api/test/downloads-data.test.js b/api/test/downloads-data.test.js new file mode 100644 index 00000000..fc442c5b --- /dev/null +++ b/api/test/downloads-data.test.js @@ -0,0 +1,447 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const widget = path.join(__dirname, '..', '..', 'ui', 'widgets', 'downloads'); +const items = require(path.join(widget, 'download-items.js')); +const { errorParts } = require('../test-support/widget-ctx'); +const { dispatchProvider } = require('../src/provider-dispatch'); +const { normalizeBase } = require('../src/widget-data'); +const { serviceLink } = require('../../ui/js/service-link.js'); + +/* A fresh data.js per test: Transmission and Deluge keep a session per server. */ +function freshData() { + for (const k of Object.keys(require.cache)) if (k.startsWith(widget)) delete require.cache[k]; + return require(path.join(widget, 'data.js')); +} + +function ctxFor(config, route) { + const calls = []; + const ctx = { + config, + normalizeBase, + serviceLink, + fetchJSON: async (url, opts = {}) => { + calls.push({ url, opts }); + return route(url, opts, calls.length); + }, + ...errorParts(), + }; + ctx.dispatchProvider = (handlers, opts) => dispatchProvider(ctx, handlers, opts); + return { ctx, calls }; +} + +const GB = 1e9; +const NOW = Date.now(); + +test('qBittorrent: downloading, stalled and failed torrents become items; stopped and queued do not', () => { + const t = (name, state, extra = {}) => ({ name, state, size: 100, amount_left: 40, dlspeed: 5, eta: 12, ...extra }); + const out = items.qbitItems([ + t('a', 'downloading'), + t('b', 'stalledDL'), + t('c', 'missingFiles'), + t('d', 'error'), + t('e', 'stoppedDL'), + t('f', 'queuedDL'), + t('g', 'metaDL', { eta: 8640000 }), + ]); + assert.deepEqual( + out.map(i => [i.name, i.state, i.reason || '']), + [ + ['a', 'run', ''], + ['b', 'stall', 'noPeers'], + ['c', 'error', 'missingFiles'], + ['d', 'error', 'error'], + ['g', 'run', ''], + ], + ); + assert.equal(out[0].progress, 0.6); + assert.equal(out[4].eta, null, 'qBittorrent sends 8640000 for "no estimate"'); +}); + +test('Transmission: reads JSON-RPC 2.0 names and the older camelCase names alike', () => { + const snake = { + name: 'a', + status: 4, + error: 0, + eta: 30, + size_when_done: 200, + left_until_done: 50, + rate_download: 9, + is_stalled: false, + }; + const camel = { + name: 'b', + status: 4, + error: 0, + eta: 30, + sizeWhenDone: 200, + leftUntilDone: 50, + rateDownload: 9, + isStalled: true, + }; + const [a, b] = items.transmissionItems([snake, camel]); + assert.deepEqual([a.progress, a.state, a.speed], [0.75, 'run', 9]); + assert.deepEqual([b.progress, b.state, b.reason], [0.75, 'stall', 'noPeers']); +}); + +test('Transmission: a local error fails the item; a tracker warning does not', () => { + const base = { status: 4, size_when_done: 10, left_until_done: 5, eta: 3 }; + const out = items.transmissionItems([ + { ...base, name: 'local', error: 3 }, + { ...base, name: 'tracker', error: 2 }, + { ...base, name: 'paused', status: 0, error: 0 }, + ]); + assert.deepEqual( + out.map(i => [i.name, i.state]), + [ + ['local', 'error'], + ['tracker', 'run'], + ], + ); +}); + +test('Deluge: a downloading torrent with no seeds and no rate is stalled', () => { + const t = (state, extra) => ({ + name: state, + state, + total_wanted: 100, + total_done: 25, + download_payload_rate: 0, + num_seeds: 0, + eta: 0, + ...extra, + }); + const out = items.delugeItems({ + h1: t('Downloading', { download_payload_rate: 50, num_seeds: 3, eta: 9 }), + h2: t('Downloading'), + h3: t('Error'), + h4: t('Paused'), + }); + assert.deepEqual( + out.map(i => [i.state, i.eta]), + [ + ['run', 9], + ['stall', null], + ['error', null], + ], + ); +}); + +test('SABnzbd: sizes and speed arrive as text; the queue speed goes to the first job', () => { + const queue = { + paused: false, + kbpersec: '2048.00', + slots: [ + { filename: 'one', mb: '100.00', mbleft: '25.00', status: 'Downloading', timeleft: '0:01:30' }, + { filename: 'two', mb: '50.00', mbleft: '50.00', status: 'Downloading', timeleft: '1:00:00:00' }, + { filename: 'three', mb: '5', mbleft: '5', status: 'Queued', timeleft: '0:00:00' }, + ], + }; + const out = items.sabItems(queue); + assert.equal(out.length, 2); + assert.deepEqual([out[0].progress, out[0].speed, out[0].eta], [0.75, 2048 * 1024, 90]); + assert.deepEqual([out[1].speed, out[1].eta], [0, null], 'a job not being fetched has no estimate'); + assert.deepEqual(items.sabItems({ ...queue, paused: true }), []); +}); + +test('SABnzbd: time left reads h:mm:ss and d:hh:mm:ss', () => { + assert.equal(items.clockSeconds('0:16:44'), 1004); + assert.equal(items.clockSeconds('2:01:02:03'), 2 * 86400 + 3600 + 123); + assert.equal(items.clockSeconds('soon'), null); +}); + +test('NZBGet: joins 64-bit halves and gives the rate to the downloading job', () => { + const g = (name, Status, remaining) => ({ + NZBName: name, + Status, + FileSizeHi: 1, + FileSizeLo: 0, + RemainingSizeHi: 0, + RemainingSizeLo: remaining, + }); + const out = items.nzbgetItems([g('big', 'DOWNLOADING', 2 ** 31), g('next', 'QUEUED', 10)], { + DownloadRate: 1000, + DownloadRateHi: 0, + DownloadRateLo: 1000, + }); + assert.equal(out.length, 1); + assert.equal(out[0].size, 2 ** 32); + assert.equal(out[0].progress, 0.5); + assert.equal(out[0].eta, Math.round(2 ** 31 / 1000)); + assert.deepEqual(items.nzbgetItems([g('big', 'DOWNLOADING', 5)], { DownloadPaused: true }), []); +}); + +test('items: closest to done first, then stalled, then failed, capped', () => { + const mk = (name, state, eta, progress = 0.5) => ({ name, state, eta, progress, size: 1, speed: 0 }); + const many = Array.from({ length: 15 }, (_, i) => mk(`run${i}`, 'run', 100 - i)); + const { items: list } = items.arrange([mk('broken', 'error', null), mk('stuck', 'stall', null), ...many]); + assert.equal(list.length, items.MAX_ITEMS); + assert.equal(list[0].name, 'run14'); + const small = items.arrange([mk('broken', 'error', null), mk('stuck', 'stall', null), mk('a', 'run', 5)]).items; + assert.deepEqual( + small.map(i => i.name), + ['a', 'stuck', 'broken'], + ); +}); + +test('the heading summary counts every download, not only the ones shown', () => { + const mk = (name, state, eta) => ({ name, state, eta, progress: 0.5, size: 1, speed: 1000 }); + const many = Array.from({ length: 15 }, (_, i) => mk(`run${i}`, 'run', 100 + i)); + const { summary } = items.arrange([ + { ...mk('broken', 'error', null), reason: 'missingFiles' }, + mk('stuck', 'stall', null), + ...many, + ]); + assert.deepEqual(summary, { kind: 'time', seconds: 114, moving: 15, speed: 15000, stalled: 1, failed: 1 }); +}); + +test('with no estimate from one moving download, the summary has no time', () => { + assert.equal( + items.arrange([{ name: 'a', state: 'run', eta: null, progress: 0, size: 1, speed: 0 }]).summary.seconds, + null, + ); +}); + +test('with nothing moving, the summary names the problem; an error wins over a stall', () => { + const stall = { name: 's', state: 'stall', eta: null, progress: 0, size: 1, speed: 0, reason: 'noPeers' }; + const err = { name: 'e', state: 'error', eta: null, progress: 0, size: 1, speed: 0, reason: 'missingFiles' }; + assert.deepEqual(items.arrange([stall]).summary, { kind: 'stall', reason: 'noPeers' }); + assert.deepEqual(items.arrange([stall, err]).summary, { kind: 'error', reason: 'missingFiles' }); + assert.deepEqual(items.arrange([]).summary, { kind: 'done' }); +}); + +test('SABnzbd: completed history counts as finished', async () => { + const now = Math.floor(NOW / 1000); + const { ctx, calls } = ctxFor({ service: 'sabnzbd', url: 'http://s:8080', apiKey: 'k' }, url => + url.includes('mode=history') + ? { + status: 200, + data: { + history: { + slots: [ + { status: 'Completed', completed: now - 60 }, + { status: 'Failed', completed: now }, + ], + }, + }, + } + : { status: 200, data: { queue: { paused: false, kbpersec: '0', slots: [] } } }, + ); + const out = await freshData()(ctx); + assert.ok(calls.some(c => /mode=history&limit=100&output=json/.test(c.url))); + assert.equal(out.finished.length, 1); +}); + +test('a key with a line break or space is refused before it reaches a header', async () => { + for (const apiKey of ['a\r\nX-Evil: 1', 'two words']) { + const { ctx } = ctxFor({ service: 'qbittorrent', url: 'http://q', apiKey }, () => assert.fail('no request')); + await assert.rejects(freshData()(ctx), e => e.kind === 'invalid'); + } +}); + +test('a username with a colon is refused: it would split the sign-in', async () => { + const { ctx } = ctxFor({ service: 'nzbget', url: 'http://n', username: 'a:b', password: 'p' }, () => + assert.fail('no request'), + ); + await assert.rejects(freshData()(ctx), e => e.kind === 'invalid'); +}); + +test('names lose direction and control characters that could disguise them', () => { + const [item] = items.qbitItems([ + { name: 'Movie \u202Eevil.exe\u202C\n.mkv', state: 'downloading', size: 1, amount_left: 1, eta: 1 }, + ]); + assert.equal(item.name, 'Movie evil.exe.mkv'); +}); + +test('finished times keep the last 48 hours, newest first, in milliseconds', () => { + const s = Math.floor(NOW / 1000); + assert.deepEqual(items.finishedTimes([s - 60, -1, 0, s - 3 * 86400, s - 7200], NOW), [ + (s - 60) * 1000, + (s - 7200) * 1000, + ]); +}); + +test('qBittorrent: sends the key as a Bearer token and returns items and a link', async () => { + const { ctx, calls } = ctxFor({ service: 'qbittorrent', url: 'qbit.lan:8080', apiKey: 'qbt_x' }, url => { + if (url.includes('filter=downloading')) + return { + status: 200, + data: [{ name: 'deb.iso', state: 'downloading', size: 2 * GB, amount_left: GB, dlspeed: 1e6, eta: 1000 }], + }; + if (url.includes('filter=completed')) + return { status: 200, data: [{ completion_on: Math.floor(NOW / 1000) - 60 }] }; + return { status: 200, data: [] }; + }); + const out = await freshData()(ctx); + assert.equal(calls[0].opts.headers.Authorization, 'Bearer qbt_x'); + assert.equal(out.items[0].progress, 0.5); + assert.equal(out.finished.length, 1); + assert.equal(out.link, 'http://qbit.lan:8080'); +}); + +test('qBittorrent: a refused key is an auth failure that names the version it needs', async () => { + const { ctx } = ctxFor({ service: 'qbittorrent', url: 'http://q:8080', apiKey: 'old' }, () => ({ + status: 403, + data: 'Forbidden', + })); + await assert.rejects(freshData()(ctx), e => e.kind === 'auth' && /5\.2/.test(e.message)); +}); + +test('SABnzbd: "API Key Incorrect" is an auth failure', async () => { + const { ctx } = ctxFor({ service: 'sabnzbd', url: 'http://s:8080', apiKey: 'bad' }, () => ({ + status: 403, + data: 'API Key Incorrect', + })); + await assert.rejects(freshData()(ctx), e => e.kind === 'auth'); +}); + +test('NZBGet: reads status, groups and history over GET with Basic auth', async () => { + const { ctx, calls } = ctxFor({ service: 'nzbget', url: 'http://n:6789', username: 'u', password: 'p' }, url => { + if (url.endsWith('/status')) return { status: 200, data: { result: { DownloadRate: 0 } } }; + if (url.endsWith('/listgroups')) return { status: 200, data: { result: [] } }; + return { status: 200, data: { result: [] } }; + }); + const out = await freshData()(ctx); + assert.equal(calls[0].opts.method, undefined); + assert.equal(calls[0].opts.headers.Authorization, 'Basic ' + Buffer.from('u:p').toString('base64')); + assert.deepEqual(out.items, []); +}); + +test('Transmission: answers the 409 with its session id and keeps it for the next poll', async () => { + const reply = { + jsonrpc: '2.0', + id: 1, + result: { + torrents: [{ name: 't', status: 4, error: 0, eta: 5, size_when_done: 10, left_until_done: 5, rate_download: 2 }], + }, + }; + const route = (url, opts) => + opts.headers['X-Transmission-Session-Id'] === 'sid1' + ? { status: 200, data: JSON.stringify(reply), headers: {} } + : { status: 409, data: '', headers: { 'x-transmission-session-id': 'sid1' } }; + const data = freshData(); + const first = ctxFor({ service: 'transmission', url: 'http://t:9091' }, route); + const out = await data(first.ctx); + assert.equal(first.calls.length, 2); + assert.equal(first.calls[0].url, 'http://t:9091/transmission/rpc'); + assert.equal(out.items[0].progress, 0.5); + const second = ctxFor({ service: 'transmission', url: 'http://t:9091' }, route); + await data(second.ctx); + assert.equal(second.calls.length, 1, 'the second poll reuses the session id'); +}); + +test('Transmission: falls back to the original protocol when the server does not speak JSON-RPC 2.0', async () => { + const { ctx, calls } = ctxFor({ service: 'transmission', url: 'http://t:9091/transmission/rpc' }, (url, opts) => { + const body = JSON.parse(opts.body); + if (body.jsonrpc) + return { + status: 200, + data: JSON.stringify({ result: 'method name not recognized', arguments: {} }), + headers: {}, + }; + assert.ok(body.arguments.fields.includes('sizeWhenDone')); + return { + status: 200, + data: JSON.stringify({ + result: 'success', + arguments: { torrents: [{ name: 'old', status: 4, eta: 9, sizeWhenDone: 4, leftUntilDone: 1 }] }, + }), + headers: {}, + }; + }); + const out = await freshData()(ctx); + assert.equal(calls[0].url, 'http://t:9091/transmission/rpc'); + assert.equal(out.items[0].name, 'old'); +}); + +test('Transmission: a refused password is an auth failure', async () => { + const { ctx } = ctxFor({ service: 'transmission', url: 'http://t:9091', username: 'u', password: 'x' }, () => ({ + status: 401, + data: '', + headers: {}, + })); + await assert.rejects(freshData()(ctx), e => e.kind === 'auth'); +}); + +function delugeRoute({ connected = true, expireAfterLogin = false } = {}) { + let logins = 0; + let served = 0; + const route = (url, opts) => { + const { method, params } = JSON.parse(opts.body); + if (method === 'auth.login') { + logins++; + return params[0] === 'deluge' + ? { + status: 200, + data: { result: true, error: null }, + headers: { 'set-cookie': [`_session_id=s${logins}; Expires=x; Path=/json`] }, + } + : { status: 200, data: { result: false, error: null }, headers: {} }; + } + if (expireAfterLogin && opts.headers.Cookie === '_session_id=s1' && served++ === 0) + return { status: 200, data: { result: null, error: { message: 'Not authenticated', code: 1 } }, headers: {} }; + if (method === 'web.connected') return { status: 200, data: { result: connected, error: null }, headers: {} }; + if (method === 'web.get_hosts') + return { + status: 200, + data: { result: [['hostid', '127.0.0.1', 58846, 'localclient']], error: null }, + headers: {}, + }; + if (method === 'web.connect') return { status: 200, data: { result: [], error: null }, headers: {} }; + if (method === 'web.update_ui') + return { + status: 200, + data: { + result: { + torrents: { + h: { + name: 'deb.iso', + state: 'Downloading', + total_wanted: 10, + total_done: 5, + download_payload_rate: 3, + num_seeds: 2, + eta: 2, + }, + }, + }, + error: null, + }, + headers: {}, + }; + return { status: 500, data: {}, headers: {} }; + }; + return { route, logins: () => logins }; +} + +test('Deluge: logs in, connects the web UI to its daemon when needed, and reads the torrents', async () => { + const d = delugeRoute({ connected: false }); + const { ctx, calls } = ctxFor({ service: 'deluge', url: 'http://d:8112', password: 'deluge' }, d.route); + const out = await freshData()(ctx); + const methods = calls.map(c => JSON.parse(c.opts.body).method); + assert.deepEqual(methods, ['auth.login', 'web.connected', 'web.get_hosts', 'web.connect', 'web.update_ui']); + assert.equal(calls[1].opts.headers.Cookie, '_session_id=s1'); + assert.equal(out.items[0].progress, 0.5); +}); + +test('Deluge: an expired session logs in again once', async () => { + const d = delugeRoute({ expireAfterLogin: true }); + const { ctx } = ctxFor({ service: 'deluge', url: 'http://d:8112', password: 'deluge' }, d.route); + const out = await freshData()(ctx); + assert.equal(d.logins(), 2); + assert.equal(out.items.length, 1); +}); + +test('Deluge: a wrong password is an auth failure', async () => { + const d = delugeRoute(); + const { ctx } = ctxFor({ service: 'deluge', url: 'http://d:8112', password: 'nope' }, d.route); + await assert.rejects(freshData()(ctx), e => e.kind === 'auth'); +}); + +test('every client needs an address', async () => { + for (const service of ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission', 'deluge']) { + const { ctx } = ctxFor({ service, apiKey: 'k', password: 'p' }, () => assert.fail('no request without an address')); + await assert.rejects(freshData()(ctx), e => e.kind === 'invalid', service); + } +}); diff --git a/ui/admin/index.html b/ui/admin/index.html index 3ab019b2..7a36a740 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -318,6 +318,6 @@