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..cd11606a --- /dev/null +++ b/api/test/downloads-data.test.js @@ -0,0 +1,498 @@ +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: 'waiting', mb: '9', mbleft: '9', status: 'Propagating', timeleft: '0:00:00' }, + { 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.deepEqual( + out.map(i => i.name), + ['one', 'two'], + ); + 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, 86400], 'a later job keeps the time SABnzbd gives it'); + assert.equal(items.arrange(out).summary.seconds, 86400); + assert.deepEqual( + items.sabItems({ ...queue, kbpersec: '0' }).map(i => i.eta), + [null, null], + ); + 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('NZBGet: paused repair files do not count, and a later job finishes after the ones ahead', () => { + const g = (name, size, remaining, held) => ({ + NZBName: name, + Status: 'DOWNLOADING', + FileSizeLo: size, + RemainingSizeLo: remaining, + PausedSizeLo: held, + }); + const out = items.nzbgetItems([g('a', 1100, 100, 100), g('b', 3000, 2000, 0)], { DownloadRate: 100 }); + assert.deepEqual( + out.map(i => [i.size, i.progress, i.eta]), + [ + [1000, 1, 0], + [3000, 0.333, 20], + ], + ); + assert.equal(items.arrange(out).summary.seconds, 20); +}); + +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\u061C 2023\u0085', + state: 'downloading', + size: 1, + amount_left: 1, + eta: 1, + }, + ]); + assert.equal(item.name, 'Movie evil.exe.mkv 2023'); +}); + +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, + hosts = [['hostid', '127.0.0.1', 58846, 'localclient']], +} = {}) { + 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: hosts, 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: with several daemons listed, the widget connects to none of them', async () => { + const d = delugeRoute({ + connected: false, + hosts: [ + ['a', '10.0.0.1', 58846, 'x'], + ['b', '10.0.0.2', 58846, 'x'], + ], + }); + const { ctx, calls } = ctxFor({ service: 'deluge', url: 'http://d:8112', password: 'deluge' }, d.route); + await assert.rejects(freshData()(ctx), e => e.kind === 'upstream' && /not connected/.test(e.message)); + assert.ok(!calls.some(c => JSON.parse(c.opts.body).method === 'web.connect')); +}); + +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/e2e/downloads-pager.spec.js b/e2e/downloads-pager.spec.js new file mode 100644 index 00000000..d25ca1ff --- /dev/null +++ b/e2e/downloads-pager.spec.js @@ -0,0 +1,67 @@ +// @ts-check +/* The Downloads pager and failure state, driven in the dashboard frame it ships in. */ + +const { test, expect } = require('@playwright/test'); +const { seedConfig, dismissSetupPrompt } = require('./helpers'); + +const item = (name, eta) => ({ name, size: 1e9, progress: 0.5, speed: 1e6, eta, state: 'run' }); +const reading = { + items: [item('first.iso', 60), item('second.iso', 120)], + summary: { kind: 'time', seconds: 120, moving: 2, speed: 2e6, stalled: 0, failed: 0 }, + finished: [], + link: '', +}; + +const widget = id => ({ + id, + type: 'widget', + label: 'Downloads', + widgetType: 'downloads', + widgetSize: 'small', + widgetConfig: { service: 'qbittorrent', url: 'http://qb.test:8080' }, +}); + +/** @param {import('@playwright/test').Page} page @param {(r: import('@playwright/test').Route) => unknown} answer */ +async function stub(page, answer) { + await page.route('**/api/badges', r => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })); + await page.route('**/api/health', r => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })); + await page.route('**/api/widget-data/dl-*', answer); +} + +test.use({ reducedMotion: 'reduce' }); + +test.beforeEach(async ({ request }) => { + await seedConfig(request, { items: [widget('dl-one')] }); + await dismissSetupPrompt(request); +}); + +test('the pager names its position and Next wraps to the first download with focus kept', async ({ page }) => { + await stub(page, r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(reading) })); + await page.goto('/'); + const frame = page.frameLocator('iframe[src*="dl-one"]'); + const pager = frame.getByRole('group', { name: 'Download 1 of 2' }); + await expect(pager).toBeVisible(); + + const next = frame.locator('#next'); + await next.focus(); + await page.keyboard.press('Enter'); + await expect(frame.getByRole('group', { name: 'Download 2 of 2' })).toBeVisible(); + await expect(next).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(pager).toBeVisible(); + await expect(next).toBeFocused(); +}); + +test('a failure before the first reading shows no loading boxes, also after a resize', async ({ page }) => { + await stub(page, r => r.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"stub"}' })); + await page.goto('/'); + const frame = page.frameLocator('iframe[src*="dl-one"]'); + await expect(frame.locator('#cap')).not.toBeEmpty(); + await expect(frame.locator('.ghost')).toHaveCount(0); + + const size = page.viewportSize() || { width: 1280, height: 720 }; + await page.setViewportSize({ width: size.width - 40, height: size.height }); + await frame.locator('html').evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))); + await expect(frame.locator('.ghost')).toHaveCount(0); + await expect(frame.locator('#cap')).not.toBeEmpty(); +}); 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 @@

General

- + diff --git a/ui/index.html b/ui/index.html index c7150974..ddf74278 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 22d03f07..ae25b97b 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -1,4 +1,4 @@ -import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=fd0b5b07'; +import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=cfee1271'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, @@ -33,13 +33,13 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=c1785fc2'; -import { createListbox } from '/js/listbox.js?v=799754e6'; +} from '/js/admin-shared.js?v=a3e8bc29'; +import { createListbox } from '/js/listbox.js?v=9a579bea'; import { MAX_LABELS } from '/js/badge-logic.js?v=85b1bc96'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=93c6a8d6'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=e2a0169e'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=a60b17ac'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { iconSvg } from '/js/icon-set.js?v=393b5749'; +import { iconSvg } from '/js/icon-set.js?v=941db5a1'; export function buildFolderForm(body, item) { const children = item?.children || []; diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 4ea0c361..45ed2b51 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,8 +1,8 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=c1785fc2'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=a3e8bc29'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=a60b17ac'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; -import { el, inp as inpById, qa } from '/js/utils.js?v=fd0b5b07'; +import { el, inp as inpById, qa } from '/js/utils.js?v=cfee1271'; import { blockingScreenFor } from '/js/config-recovery.js?v=85138697'; export async function checkAuth(onLogin) { diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index c1094527..3345f740 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,9 +1,9 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=c1785fc2'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=a3e8bc29'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=fd0b5b07'; -import { iconSvg } from '/js/icon-set.js?v=393b5749'; -import { createListbox } from '/js/listbox.js?v=799754e6'; +import { qa, q } from '/js/utils.js?v=cfee1271'; +import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { createListbox } from '/js/listbox.js?v=9a579bea'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index 436b0010..bf5bf960 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -13,7 +13,7 @@ import { applyDrop, canDropInGroup, canJoinFolder, folderRowZone } from '/js/adm import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { state } from '/js/admin-state.js?v=064b9b85'; import { snapshotItems } from '/js/admin-save-logic.js?v=32cb6aeb'; -import { qa } from '/js/utils.js?v=fd0b5b07'; +import { qa } from '/js/utils.js?v=cfee1271'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index a71d7b41..8ca7f5ba 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -15,16 +15,16 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; import { snapshotItems } from '/js/admin-save-logic.js?v=32cb6aeb'; import { isShownChild, reorderItems, shownChildren, topLevelRows } from '/js/admin-logic.js?v=b3befae4'; import { listGroup } from '/js/admin-drag-logic.js?v=1d1d553e'; -import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=6d05f659'; +import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=14bd8c6c'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { paintIcon, toast } from '/js/admin-shared.js?v=c1785fc2'; -import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=fd0b5b07'; +import { paintIcon, toast } from '/js/admin-shared.js?v=a3e8bc29'; +import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=cfee1271'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=07fe5842'; -import { widgetGlyph } from '/js/widget-glyphs.js?v=7824a4ae'; -import { iconSvg } from '/js/icon-set.js?v=393b5749'; +import { sizeLabel } from '/js/admin-widget-form.js?v=2427cc08'; +import { widgetGlyph } from '/js/widget-glyphs.js?v=f0c89a0a'; +import { iconSvg } from '/js/icon-set.js?v=941db5a1'; /** @type {{ openModal: (idx: number|null) => void, openFolderPicker: (appId: string|null, targetFolderId?: string|null) => void, diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 329d8252..2e419b23 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=c1785fc2'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=a3e8bc29'; import { socketProbeAdvice } from '/js/admin-error.js?v=a60b17ac'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -11,11 +11,11 @@ import { BLOCK, } from '/js/admin-logic.js?v=b3befae4'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; -import { el, inp, setUserText } from '/js/utils.js?v=fd0b5b07'; +import { el, inp, setUserText } from '/js/utils.js?v=cfee1271'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=32cb6aeb'; -import { renderColorControl } from '/js/admin-color-control.js?v=93c6a8d6'; -import { BACKDROP } from '/js/background.js?v=26148fef'; +import { renderColorControl } from '/js/admin-color-control.js?v=e2a0169e'; +import { BACKDROP } from '/js/background.js?v=72db97ac'; import { ALLOWED_HOSTS_MAX, firstBadHost, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 2f175119..fafe38bb 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,11 +1,11 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=b3befae4'; -import { el, isolate, q } from '/js/utils.js?v=fd0b5b07'; +import { el, isolate, q } from '/js/utils.js?v=cfee1271'; import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=a60b17ac'; import { formatList, formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { iconChain } from '/js/icons.js?v=5ac01d72'; -import { iconSvg } from '/js/icon-set.js?v=393b5749'; +import { iconSvg } from '/js/icon-set.js?v=941db5a1'; import { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; export const API = ''; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 50512437..6c46e455 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,12 +1,12 @@ import { state } from '/js/admin-state.js?v=064b9b85'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=c1785fc2'; -import { createListbox } from '/js/listbox.js?v=799754e6'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=d41cd647'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=a3e8bc29'; +import { createListbox } from '/js/listbox.js?v=9a579bea'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=8b444e40'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=b3befae4'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { focusFirst, q, qi, qa } from '/js/utils.js?v=fd0b5b07'; -import { iconSvg } from '/js/icon-set.js?v=393b5749'; +import { focusFirst, q, qi, qa } from '/js/utils.js?v=cfee1271'; +import { iconSvg } from '/js/icon-set.js?v=941db5a1'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); diff --git a/ui/js/admin.js b/ui/js/admin.js index 66b81623..8f8085fd 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,10 +4,10 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=fa682069'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=e3d0591d'; +} from '/js/admin-app-form.js?v=896b8d1d'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=6466f02d'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=dc087df8'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=a0568eab'; import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=b3befae4'; import { afterImport, @@ -31,7 +31,7 @@ import { wireUnsplashKey, wireProxmox, applyHealthChecks, -} from '/js/admin-settings.js?v=25c789ee'; +} from '/js/admin-settings.js?v=5709194e'; import { apiGet, apiPost, @@ -45,17 +45,17 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=c1785fc2'; +} from '/js/admin-shared.js?v=a3e8bc29'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=07fe5842'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=2427cc08'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=799754e6'; +import { createListbox } from '/js/listbox.js?v=9a579bea'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=393b5749'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=941db5a1'; import { clearSkipTls, convert, @@ -90,8 +90,8 @@ import { storeGet, storeSet, tgt, -} from '/js/utils.js?v=fd0b5b07'; -import { applyBackground, resolveBackground } from '/js/background.js?v=26148fef'; +} from '/js/utils.js?v=cfee1271'; +import { applyBackground, resolveBackground } from '/js/background.js?v=72db97ac'; import { parseYamlTolerant, YamlLiteError, YamlTooLargeError } from '/js/yaml-lite.js?v=a42ef914'; ensureSprite(); diff --git a/ui/js/background.js b/ui/js/background.js index 1d4364df..45680ae1 100644 --- a/ui/js/background.js +++ b/ui/js/background.js @@ -1,5 +1,5 @@ // @ts-check -import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=fd0b5b07'; +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=cfee1271'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; export const BACKDROP = '#0d1117'; diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index d9ce02de..4e57d4a1 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -28,9 +28,9 @@ import { storeSet, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=fd0b5b07'; +} from '/js/utils.js?v=cfee1271'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=7f345935'; +import { initSpotlight } from '/js/spotlight.js?v=e6c4a09a'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=ed7c5565'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; @@ -46,7 +46,7 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=fc20cad0'; +} from '/js/ui.js?v=43203089'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=85b1bc96'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=23793736'; @@ -59,12 +59,12 @@ import { restorePage, wheelPager, } from '/js/dashboard-logic.js?v=6a29cedc'; -import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=26148fef'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=72db97ac'; import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=393b5749'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=941db5a1'; import { pageTheme, paletteColor } from '/js/palette.js?v=3fb8ae43'; import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=eeafa4b5'; diff --git a/ui/js/icon-set.js b/ui/js/icon-set.js index 0c7b97bc..f6ae09c9 100644 --- a/ui/js/icon-set.js +++ b/ui/js/icon-set.js @@ -229,6 +229,16 @@ export const ICONS = { dot(17, 14, T.NODE, 'accent'), ], }, + downloads: { + inkMask: false, + shapes: [ + detail(2, 3, 11, 4, T.R_SM, 'accent'), + detail(15, 3, 7, 4, T.R_SM), + rect(3, 9, 18, 12, T.R_MD), + bar(6, 13, 9), + bar(6, 17, 5), + ], + }, nowplaying: { shapes: [rect(3, 3, 18, 18, T.R_LG), shape('M10 8v8l6-4z', 'accent', { rj: true })] }, system: { shapes: [line('M3 16 7 12 11 15 16 8'), dot(19, 6, T.NODE, 'accent')] }, weather: { diff --git a/ui/js/listbox.js b/ui/js/listbox.js index 68e7031b..85808069 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -10,8 +10,8 @@ import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=b3befae4'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa } from '/js/utils.js?v=fd0b5b07'; -import { iconSvg } from '/js/icon-set.js?v=393b5749'; +import { qa } from '/js/utils.js?v=cfee1271'; +import { iconSvg } from '/js/icon-set.js?v=941db5a1'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/ui/js/spotlight.js b/ui/js/spotlight.js index 9a91db3d..2b60e6b2 100644 --- a/ui/js/spotlight.js +++ b/ui/js/spotlight.js @@ -1,5 +1,5 @@ import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=fd0b5b07'; +import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=cfee1271'; import { t } from '/js/i18n.js?v=ed7c5565'; /* Attached to the window so a re-open can undo the previous one. */ diff --git a/ui/js/ui.js b/ui/js/ui.js index 7ad51734..1d6631ed 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -16,7 +16,7 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=fd0b5b07'; +} from '/js/utils.js?v=cfee1271'; import { t, currentLang } from '/js/i18n.js?v=ed7c5565'; import { toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=349f4300'; diff --git a/ui/js/utils.js b/ui/js/utils.js index 3257a3b5..461d1054 100644 --- a/ui/js/utils.js +++ b/ui/js/utils.js @@ -542,18 +542,17 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla const w = card.clientWidth, h = card.clientHeight; if (!w || !h) return; - /* Contain, not cover. With a matched aspect the two are the same and this - fills the card exactly. Where the card's aspect drifts from the design's, - cover crops the widget instead, and the card clips what it cropped. */ const s = Math.min(w / dw, h / dh); - /* Whole pixels. A fractional offset or size blurs the page on a 1x screen. */ + /* A bundled page fills the card and lays out at the card's shape, zoomed to + the design's scale. Contain leaves strips of card beside anything drawn to + the page edge. Whole pixels: a fractional size blurs on a 1x screen. */ if (zoomable) { - const fw = Math.round(dw * s), - fh = Math.round(dh * s); + const fw = Math.round(w), + fh = Math.round(h); ifr.style.width = fw + 'px'; ifr.style.height = fh + 'px'; ifr.style.transform = `translate(${Math.round((w - fw) / 2)}px, ${Math.round((h - fh) / 2)}px)`; - ifr.setAttribute('data-zoom', String(fw / dw)); + ifr.setAttribute('data-zoom', String(Math.min(fw / dw, fh / dh))); applyZoom(); } else { ifr.style.transform = `translate(${Math.round((w - dw * s) / 2)}px, ${Math.round((h - dh * s) / 2)}px) scale(${s})`; diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index e27a19c5..12b94d04 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=c1785fc2'; -import { createListbox } from '/js/listbox.js?v=799754e6'; -import { renderColorControl } from '/js/admin-color-control.js?v=93c6a8d6'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=a3e8bc29'; +import { createListbox } from '/js/listbox.js?v=9a579bea'; +import { renderColorControl } from '/js/admin-color-control.js?v=e2a0169e'; import { seedCarried, applyOptionSet, @@ -15,7 +15,7 @@ import { visibleFieldFlags, } from '/js/admin-logic.js?v=b3befae4'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=a60b17ac'; -import { qi } from '/js/utils.js?v=fd0b5b07'; +import { qi } from '/js/utils.js?v=cfee1271'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { SAVED_ROW } from '/js/saved-row.js?v=44039df6'; diff --git a/ui/js/widget-glyphs.js b/ui/js/widget-glyphs.js index e0c52055..f4f4db5d 100644 --- a/ui/js/widget-glyphs.js +++ b/ui/js/widget-glyphs.js @@ -1,5 +1,5 @@ // @ts-check -import { iconSvg } from '/js/icon-set.js?v=393b5749'; +import { iconSvg } from '/js/icon-set.js?v=941db5a1'; /* Keys are the glyph names in widget.json files. Renaming one breaks that widget. */ /** @type {Record} */ @@ -15,6 +15,7 @@ const GLYPH_ICON = { network: 'connections', merge: 'github', panels: 'switcher', + parcel: 'downloads', }; /** The glyph names a manifest may declare. */ diff --git a/ui/test/downloads-belt.test.mjs b/ui/test/downloads-belt.test.mjs new file mode 100644 index 00000000..49c6544e --- /dev/null +++ b/ui/test/downloads-belt.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + ENDS, + finishedToday, + hoursMinutes, + leftover, + LEFTOVER_CHANCE, + lidOpen, + startOfDay, + variations, +} from '../widgets/downloads/belt-logic.js'; + +const item = (name, state = 'run', eta = 60, progress = 0.3) => ({ name, state, eta, progress, size: 1, speed: 1000 }); + +test('neighbouring boxes never share a shade or a tape ending', () => { + const names = Array.from({ length: 40 }, (_, i) => `file-${i}.iso`); + const v = variations(names.map(n => item(n))); + for (let i = 1; i < v.length; i++) { + assert.notEqual(v[i].tone, v[i - 1].tone); + assert.notEqual(v[i].end, v[i - 1].end); + assert.ok(ENDS.includes(v[i].end)); + } +}); + +test('a download keeps its look from one reading to the next', () => { + const list = [item('a.iso'), item('b.mkv'), item('c.zip')]; + assert.deepEqual(variations(list), variations(list.map(i => ({ ...i, progress: 0.9 })))); +}); + +test('a flap left ajar shuts once the tape passes the middle, and never on a problem', () => { + const v = { ajar: true }; + assert.equal(lidOpen(v, item('x', 'run', 5, 0.49)), true); + assert.equal(lidOpen(v, item('x', 'run', 5, 0.5)), false); + assert.equal(lidOpen(v, item('x', 'stall', null, 0.1)), false); + assert.equal(lidOpen({ ajar: false }, item('x')), false); +}); + +test('finished today counts from local midnight', () => { + const now = new Date(2026, 9, 24, 14, 30).getTime(); + const midnight = startOfDay(now); + const r = finishedToday([midnight + 1000, midnight - 1000, now - 60000], now); + assert.deepEqual(r, { count: 2, last: now - 60000 }); + assert.deepEqual(finishedToday(undefined, now), { count: 0, last: null }); +}); + +test('time left rounds up to whole minutes and never reads zero', () => { + assert.deepEqual(hoursMinutes(1), { h: 0, m: 1 }); + assert.deepEqual(hoursMinutes(3600 * 1 + 23 * 60 + 5), { h: 1, m: 24 }); +}); + +test('leftovers appear about one load in ten and stay inside the belt', () => { + let seen = 0; + const kinds = new Set(); + for (let seed = 0; seed < 5000; seed++) { + const l = leftover(seed); + if (!l) continue; + seen++; + kinds.add(l.kind); + assert.ok(l.x >= 0 && l.x < 1); + } + assert.ok(Math.abs(seen / 5000 - LEFTOVER_CHANCE) < 0.02, `${seen} in 5000`); + assert.deepEqual([...kinds].sort(), ['cutter', 'roll']); + assert.deepEqual(leftover(42), leftover(42)); +}); diff --git a/ui/test/widget-colours.test.mjs b/ui/test/widget-colours.test.mjs index e2e1883e..476c29ab 100644 --- a/ui/test/widget-colours.test.mjs +++ b/ui/test/widget-colours.test.mjs @@ -106,6 +106,23 @@ const BESPOKE = new Map( '#96969E': 'connections VPN, idle dots', '#FAF6EE': 'books, title ink on a dark spine', '#E8E8EA': 'widget template, text in the dark theme', + '#0088FF': 'downloads, system blue on the light card (--sy-blue, light)', + '#B25000': 'downloads, stalled amber on white, 5.2:1 (--sy-yellow-hi, light)', + '#D70015': 'downloads, failed red on white, 5.4:1 (--sy-red-hi, light)', + '#248A3D': 'downloads, done green on white (--sy-green-hi palette step, light)', + '#C7C7CC': 'downloads, belt rail in the light theme (--sy-gray3, light)', + '#F2F2F7': 'downloads, pager and placeholder fill in the light theme (--sy-gray6, light)', + /* A drawn cardboard box: three shades, each with a top, front and side. */ + '#E9BE85': 'downloads, kraft box top', + '#D4A062': 'downloads, kraft box front', + '#B5823F': 'downloads, kraft box side and seam', + '#D9A96C': 'downloads, darker kraft box top', + '#BE8746': 'downloads, darker kraft box front', + '#9C6A31': 'downloads, darker kraft box side', + '#F3D3A6': 'downloads, lighter kraft box top', + '#E3BB88': 'downloads, lighter kraft box front', + '#C49556': 'downloads, lighter kraft box side', + '#5A3A18': 'downloads, inside of a box, and a tape roll core', }).map(([k, v]) => [k.toUpperCase(), v]), ); diff --git a/ui/test/widget-frame-fit.test.mjs b/ui/test/widget-frame-fit.test.mjs index 83fd0a01..58a4616e 100644 --- a/ui/test/widget-frame-fit.test.mjs +++ b/ui/test/widget-frame-fit.test.mjs @@ -66,6 +66,12 @@ async function mount(src) { const whole = v => Number.isInteger(Number.parseFloat(v)); +test('a bundled widget fills its card, so nothing drawn to the page edge leaves a strip of card', async () => { + const { frame } = await mount('/widgets/downloads/index.html?id=1'); + assert.equal(frame.style.width, '330px'); + assert.equal(frame.style.height, '148px'); +}); + test('a bundled widget is sized to whole pixels and zoomed, not scaled', async () => { const { frame, root } = await mount('/widgets/connections/connections-map.html?id=1'); assert.ok(whole(frame.style.width) && whole(frame.style.height), `${frame.style.width} x ${frame.style.height}`); @@ -73,7 +79,7 @@ test('a bundled widget is sized to whole pixels and zoomed, not scaled', async ( assert.ok(t, `unexpected transform ${frame.style.transform}`); assert.ok(whole(t[1]) && whole(t[2]), 'the frame sits at a fractional pixel'); const zoom = Number(frame.getAttribute('data-zoom')); - assert.equal(zoom, Number.parseFloat(frame.style.width) / 360); + assert.equal(zoom, Math.min(Number.parseFloat(frame.style.width) / 360, Number.parseFloat(frame.style.height) / 170)); assert.equal(root.style.zoom, String(zoom), 'the page is not zoomed'); }); diff --git a/ui/test/widget-live-regions.test.mjs b/ui/test/widget-live-regions.test.mjs index b7481cc5..57a1453c 100644 --- a/ui/test/widget-live-regions.test.mjs +++ b/ui/test/widget-live-regions.test.mjs @@ -21,6 +21,7 @@ const ALLOWED = new Map([ ['books/index.html#sr', 'the book under the pointer or the focus'], ['nowplaying/index.html#sr', 'the track the user moved to with previous or next'], ['disk-health/index.html#tip', 'the bay under the pointer or the focus'], + ['downloads/index.html#sr', 'the download the user moved to with previous or next'], ]); function widgetDocs() { diff --git a/ui/widgets/downloads/belt-logic.js b/ui/widgets/downloads/belt-logic.js new file mode 100644 index 00000000..c4b90530 --- /dev/null +++ b/ui/widgets/downloads/belt-logic.js @@ -0,0 +1,108 @@ +// @ts-check + +/** @typedef {{ name: string, size: number, progress: number, speed: number, + eta: number|null, state: 'run'|'stall'|'error', reason?: string }} Item */ + +/** @param {string} s @returns {number} */ +function hash(s) { + let h = 7; + for (const c of String(s)) h = (h * 31 + (c.codePointAt(0) || 0)) >>> 0; + return h; +} + +const TONE_COUNT = 3; +export const ENDS = Object.freeze(['torn', 'cut', 'angled', 'tab']); +const EXTRA_HEIGHT = Object.freeze([0, 3, 6]); + +/** Shade, extra height, tape ending and a lid left ajar per box, picked from the name so a + download keeps its look. Neighbours never share a shade or an ending. + @param {Item[]} items @returns {{ tone: number, extra: number, end: string, ajar: boolean }[]} */ +export function variations(items) { + /** @type {{ tone: number, extra: number, end: string, ajar: boolean }[]} */ + const out = []; + for (const it of items) { + const prev = out[out.length - 1]; + let tone = hash(it.name) % TONE_COUNT; + if (prev && prev.tone === tone) tone = (tone + 1) % TONE_COUNT; + let end = hash(it.name + 'e') % ENDS.length; + if (prev && ENDS[end] === prev.end) end = (end + 1) % ENDS.length; + const ajar = hash(it.name + 'a') % 3 === 0; + out.push({ tone, extra: EXTRA_HEIGHT[hash(it.name + 'h') % EXTRA_HEIGHT.length], end: ENDS[end], ajar }); + } + return out; +} + +/** Midnight at the start of `now`'s day, in this runtime's time zone. + @param {number} now @returns {number} */ +export function startOfDay(now) { + const d = new Date(now); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +/** How many finished today, and the newest. + @param {number[]} finished epoch ms @param {number} now + @returns {{ count: number, last: number|null }} */ +export function finishedToday(finished, now) { + const from = startOfDay(now); + const today = (Array.isArray(finished) ? finished : []).filter( + t => Number.isFinite(t) && t >= from && t <= now + 60000, + ); + return { count: today.length, last: today.length ? Math.max(...today) : null }; +} + +/** Whole hours and minutes, rounded up to the next minute so "0 min" never shows. + @param {number} seconds @returns {{ h: number, m: number }} */ +export function hoursMinutes(seconds) { + const total = Math.max(1, Math.ceil(seconds / 60)); + return { h: Math.floor(total / 60), m: total % 60 }; +} + +/** @param {number} seed @returns {() => number} */ +function rng(seed) { + let s = seed | 0; + return () => { + s = (s + 0x6d2b79f5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export const LEFTOVER_CHANCE = 0.1; +const ROLL_KINDS = Object.freeze(['packing', 'clear', 'blue', 'masking', 'yellow']); +const CUTTER_KINDS = Object.freeze(['yellow', 'orange', 'blue', 'grey']); + +/** Something left on an empty belt about one load in ten, or null. `x` is a + fraction of the free width, so the caller keeps it inside the card. + @param {number} seed + @returns {null | { kind: 'roll', tape: string, fill: number, tail: number, at: number, x: number } + | { kind: 'cutter', colour: string, open: boolean, angle: number, x: number }} */ +export function leftover(seed) { + const r = rng(seed); + if (r() >= LEFTOVER_CHANCE) return null; + const x = r(); + if (r() < 0.65) { + return { + kind: 'roll', + tape: ROLL_KINDS[Math.floor(r() * ROLL_KINDS.length)], + fill: r(), + tail: r(), + at: r(), + x, + }; + } + return { + kind: 'cutter', + colour: CUTTER_KINDS[Math.floor(r() * CUTTER_KINDS.length)], + open: r() < 0.6, + angle: -22 + r() * 30, + x, + }; +} + +/** The untaped flap stays slightly open until the tape reaches the middle of the lid. + @param {{ ajar: boolean }} v @param {Item} item @returns {boolean} */ +export function lidOpen(v, item) { + return !!v.ajar && item.state === 'run' && item.progress < 0.5; +} diff --git a/ui/widgets/downloads/data.js b/ui/widgets/downloads/data.js new file mode 100644 index 00000000..71222f5c --- /dev/null +++ b/ui/widgets/downloads/data.js @@ -0,0 +1,278 @@ +/* Keep every request read-only. Transmission and Deluge take a POST body: keep + its method names fixed here. A method taken from config turns this into a + relay that can change or delete downloads. */ + +const items = require('./download-items'); + +const FETCH_MS = 8000; + +const authKind = (status, ctx) => ([401, 403].includes(status) ? ctx.KIND.AUTH : ctx.KIND.UPSTREAM); + +function required(value, message, ctx) { + const v = typeof value === 'string' ? value.trim() : ''; + if (!v) ctx.fail(message, { kind: ctx.KIND.INVALID }); + return v; +} + +/* A key goes into a request header as typed. */ +const HEADER_SAFE = /^[\x21-\x7e]+$/; +function headerKey(value, message, ctx) { + const v = required(value, message, ctx); + if (!HEADER_SAFE.test(v)) ctx.fail('The API key has characters a key cannot hold', { kind: ctx.KIND.INVALID }); + return v; +} + +function login(ctx) { + const username = String(ctx.config.username || '').trim(); + if (username.includes(':')) ctx.fail('A username cannot contain a colon', { kind: ctx.KIND.INVALID }); + return basicAuth(username, String(ctx.config.password || '')); +} + +/* Per server. Bounded: a preview can name any address. */ +const SESSIONS_KEPT = 32; +function remember(map, key, value) { + map.delete(key); + map.set(key, value); + if (map.size > SESSIONS_KEPT) map.delete(map.keys().next().value); +} + +function basicAuth(username, password) { + return username || password + ? { Authorization: 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64') } + : {}; +} + +function connection(ctx) { + const c = ctx.config; + return { + base: ctx.normalizeBase(required(c.url, 'Enter the download client address', ctx)), + skipTls: c.skipTls === true, + }; +} + +async function qbittorrent(ctx) { + const { base, skipTls } = connection(ctx); + const key = headerKey(ctx.config.apiKey, 'Enter the qBittorrent API key', ctx); + const get = async path => { + const r = await ctx.fetchJSON(`${base}/api/v2${path}`, { + headers: { Authorization: `Bearer ${key}` }, + skipTls, + timeout: FETCH_MS, + }); + if (r.status === 401 || r.status === 403) + ctx.fail('qBittorrent refused the API key. It needs qBittorrent 5.2 or newer.', { kind: ctx.KIND.AUTH }); + if (r.status !== 200) ctx.fail(`qBittorrent returned HTTP ${r.status}`, { kind: ctx.KIND.UPSTREAM }); + if (!Array.isArray(r.data)) + ctx.fail('qBittorrent sent a reply in an unexpected shape', { kind: ctx.KIND.UPSTREAM }); + return r.data; + }; + const [active, failed, done] = await Promise.all([ + get('/torrents/info?filter=downloading'), + get('/torrents/info?filter=errored'), + get('/torrents/info?filter=completed&sort=completion_on&reverse=true&limit=100').catch(() => []), + ]); + return { found: items.qbitItems([...active, ...failed]), finished: items.qbitFinished(done) }; +} + +async function sabnzbd(ctx) { + const { base, skipTls } = connection(ctx); + const key = required(ctx.config.apiKey, 'Enter the SABnzbd API key', ctx); + const get = async (mode, extra = '') => { + const r = await ctx.fetchJSON(`${base}/api?mode=${mode}${extra}&output=json&apikey=${encodeURIComponent(key)}`, { + skipTls, + timeout: FETCH_MS, + }); + if (r.status === 401 || r.status === 403) ctx.fail('SABnzbd refused the API key', { kind: ctx.KIND.AUTH }); + if (r.status !== 200) ctx.fail(`SABnzbd returned HTTP ${r.status}`, { kind: ctx.KIND.UPSTREAM }); + if (typeof r.data === 'string' && /api key/i.test(r.data)) + ctx.fail('SABnzbd refused the API key', { kind: ctx.KIND.AUTH }); + if (!r.data || typeof r.data !== 'object' || !r.data[mode]) + ctx.fail('SABnzbd sent a reply in an unexpected shape', { kind: ctx.KIND.UPSTREAM }); + return r.data[mode]; + }; + const [queue, history] = await Promise.all([get('queue'), get('history', '&limit=100').catch(() => null)]); + return { found: items.sabItems(queue), finished: history ? items.sabFinished(history) : [] }; +} + +async function nzbget(ctx) { + const { base, skipTls } = connection(ctx); + const headers = login(ctx); + const call = async method => { + const r = await ctx.fetchJSON(`${base}/jsonrpc/${method}`, { headers, skipTls, timeout: FETCH_MS }); + if (r.status === 401 || r.status === 403) + ctx.fail('NZBGet refused the username or password', { kind: ctx.KIND.AUTH }); + if (r.status !== 200) ctx.fail(`NZBGet returned HTTP ${r.status}`, { kind: ctx.KIND.UPSTREAM }); + if (!r.data || typeof r.data !== 'object' || !('result' in r.data)) + ctx.fail('NZBGet sent a reply in an unexpected shape', { kind: ctx.KIND.UPSTREAM }); + return r.data.result; + }; + const [status, groups, history] = await Promise.all([ + call('status'), + call('listgroups'), + call('history').catch(() => []), + ]); + return { found: items.nzbgetItems(groups, status), finished: items.nzbgetFinished(history) }; +} + +/* Transmission answers 409 with a session id that every request must carry. + Versions before 4.1 do not speak JSON-RPC 2.0. */ +const _transmission = new Map(); + +function transmissionRpcUrl(base) { + return /\/rpc$/.test(new URL(base).pathname) ? base : `${base}/transmission/rpc`; +} + +const T_FIELDS = [ + 'name', + 'status', + 'error', + 'eta', + 'size_when_done', + 'left_until_done', + 'rate_download', + 'is_stalled', + 'percent_done', + 'done_date', +]; +const toCamel = s => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); + +async function transmission(ctx) { + const { base, skipTls } = connection(ctx); + const url = transmissionRpcUrl(base); + const auth = login(ctx); + const known = _transmission.get(url) || { session: '', legacy: false }; + + const post = async (body, session) => { + const r = await ctx.fetchJSON(url, { + method: 'POST', + headers: { + ...auth, + 'Content-Type': 'application/json', + ...(session ? { 'X-Transmission-Session-Id': session } : {}), + }, + body: JSON.stringify(body), + skipTls, + timeout: FETCH_MS, + withHeaders: true, + raw: true, + }); + if (r.status === 401 || r.status === 403) + ctx.fail('Transmission refused the username or password', { kind: ctx.KIND.AUTH }); + return r; + }; + const send = async (body, conn) => { + let r = await post(body, conn.session); + if (r.status === 409) { + const fresh = r.headers && r.headers['x-transmission-session-id']; + if (!fresh) ctx.fail('Transmission sent no session id', { kind: ctx.KIND.UPSTREAM }); + conn.session = String(fresh); + r = await post(body, conn.session); + } + if (r.status !== 200) ctx.fail(`Transmission returned HTTP ${r.status}`, { kind: authKind(r.status, ctx) }); + try { + return JSON.parse(r.data); + } catch { + return ctx.fail('Transmission sent a reply that is not JSON', { kind: ctx.KIND.UPSTREAM }); + } + }; + + const state = { ...known }; + let torrents = null; + if (!state.legacy) { + const reply = await send({ jsonrpc: '2.0', method: 'torrent_get', params: { fields: T_FIELDS }, id: 1 }, state); + if (reply && reply.jsonrpc === '2.0' && Array.isArray(reply.result?.torrents)) torrents = reply.result.torrents; + else if (reply && reply.jsonrpc === '2.0') + ctx.fail('Transmission could not list torrents', { kind: ctx.KIND.UPSTREAM }); + else state.legacy = true; + } + if (!torrents) { + const reply = await send({ method: 'torrent-get', arguments: { fields: T_FIELDS.map(toCamel) } }, state); + if (reply?.result !== 'success' || !Array.isArray(reply.arguments?.torrents)) + ctx.fail('Transmission could not list torrents', { kind: ctx.KIND.UPSTREAM }); + torrents = reply.arguments.torrents; + } + remember(_transmission, url, state); + return { found: items.transmissionItems(torrents), finished: items.transmissionFinished(torrents) }; +} + +/* Deluge's web UI lists no torrents until it is connected to a daemon. */ +const _deluge = new Map(); +const D_FIELDS = [ + 'name', + 'state', + 'total_wanted', + 'total_done', + 'download_payload_rate', + 'eta', + 'num_seeds', + 'is_finished', + 'completed_time', +]; + +async function deluge(ctx) { + const { base, skipTls } = connection(ctx); + const password = required(ctx.config.password, 'Enter the Deluge web password', ctx); + const url = `${base}/json`; + let id = 0; + const rpc = async (method, params, session) => { + const r = await ctx.fetchJSON(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(session ? { Cookie: session } : {}) }, + body: JSON.stringify({ method, params, id: ++id }), + skipTls, + timeout: FETCH_MS, + withHeaders: true, + }); + if (r.status !== 200) ctx.fail(`Deluge returned HTTP ${r.status}`, { kind: authKind(r.status, ctx) }); + if (!r.data || typeof r.data !== 'object') + ctx.fail('Deluge sent a reply in an unexpected shape', { kind: ctx.KIND.UPSTREAM }); + return r; + }; + const signIn = async () => { + const r = await rpc('auth.login', [password]); + if (r.data.result !== true) ctx.fail('Deluge refused the password', { kind: ctx.KIND.AUTH }); + const set = [].concat(r.headers?.['set-cookie'] || []).map(String); + const cookie = set.map(c => c.split(';')[0]).find(c => c.startsWith('_session_id=')); + if (!cookie) ctx.fail('Deluge sent no session cookie', { kind: ctx.KIND.UPSTREAM }); + remember(_deluge, url, { cookie, password }); + return cookie; + }; + const cached = _deluge.get(url); + let cookie = cached && cached.password === password ? cached.cookie : await signIn(); + + const authed = async (method, params) => { + let r = await rpc(method, params, cookie); + if (r.data.error && r.data.error.code === 1) { + cookie = await signIn(); + r = await rpc(method, params, cookie); + } + if (r.data.error) ctx.fail('Deluge could not answer', { kind: ctx.KIND.UPSTREAM }); + return r.data.result; + }; + + if ((await authed('web.connected', [])) !== true) { + /* Connect only to the one daemon there is. The web UI's connection is + shared, so guessing among several moves every other user to it. */ + const hosts = await authed('web.get_hosts', []); + const list = Array.isArray(hosts) ? hosts : []; + if (list.length > 1) ctx.fail('Deluge is not connected to a daemon', { kind: ctx.KIND.UPSTREAM }); + const host = Array.isArray(list[0]) ? list[0][0] : null; + if (typeof host !== 'string') ctx.fail('Deluge has no daemon to connect to', { kind: ctx.KIND.UPSTREAM }); + await authed('web.connect', [host]); + } + const ui = await authed('web.update_ui', [D_FIELDS, {}]); + const torrents = ui && typeof ui === 'object' ? ui.torrents : null; + return { found: items.delugeItems(torrents), finished: items.delugeFinished(torrents) }; +} + +module.exports = async function (ctx) { + const out = await ctx.dispatchProvider( + { qbittorrent, sabnzbd, nzbget, transmission, deluge }, + { field: 'service', default: 'qbittorrent' }, + ); + return { + ...items.arrange(out.found), + finished: items.finishedTimes(out.finished), + link: ctx.serviceLink(ctx.config.href, ctx.config.url), + }; +}; diff --git a/ui/widgets/downloads/demo.js b/ui/widgets/downloads/demo.js new file mode 100644 index 00000000..78662a36 --- /dev/null +++ b/ui/widgets/downloads/demo.js @@ -0,0 +1,31 @@ +const { arrange } = require('./download-items'); + +const GB = 1e9; + +module.exports = function downloadsDemo(ctx) { + const { wave, round } = ctx.demo; + const job = (name, size, base, speed, phase) => { + const progress = round(Math.min(0.99, base + wave(900, 0, 0.12, phase)), 3); + const rate = round(speed * wave(40, 0.8, 1.2, phase)); + return { name, size, progress, speed: rate, eta: Math.round(((1 - progress) * size) / rate), state: 'run' }; + }; + const now = Date.now(); + return { + ...arrange([ + job('Fedora-Workstation-Live-43.iso', 2.4 * GB, 0.8, 9.8e6, 0), + job('Sintel.2010.mkv', 1.1 * GB, 0.5, 6.2e6, 1), + job('ubuntu-26.04-desktop-amd64.iso', 6.1 * GB, 0.3, 11.2e6, 2), + { + name: 'big-buck-bunny-4k-60fps.mkv', + size: 3.6 * GB, + progress: 0.23, + speed: 0, + eta: null, + state: 'stall', + reason: 'noPeers', + }, + ]), + finished: [now - 27 * 60000, now - 2.5 * 3600000, now - 5 * 3600000], + link: '', + }; +}; diff --git a/ui/widgets/downloads/download-items.js b/ui/widgets/downloads/download-items.js new file mode 100644 index 00000000..95c5f50b --- /dev/null +++ b/ui/widgets/downloads/download-items.js @@ -0,0 +1,270 @@ +const MAX_ITEMS = 12; +const FINISHED_WINDOW_MS = 48 * 3600 * 1000; +const MAX_FINISHED = 100; +const MIB = 1024 * 1024; + +/** A finite number, or null. Some clients send numbers as text. */ +function num(v) { + if (typeof v === 'number') return Number.isFinite(v) ? v : null; + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v); + return Number.isFinite(n) ? n : null; + } + return null; +} + +const nonNeg = v => { + const n = num(v); + return n != null && n >= 0 ? n : 0; +}; +const str = v => (typeof v === 'string' ? v : ''); +const list = v => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object' && !Array.isArray(x)) : []); +/* NZBGet splits 64-bit sizes into two 32-bit halves. */ +const hiLo = (hi, lo) => nonNeg(hi) * 2 ** 32 + nonNeg(lo); + +/** One download. `eta` is seconds, or null when the client has no estimate. + `state` is run, stall or error; `reason` names a stall or error. */ +function makeItem({ name, size, left, speed, eta, state = 'run', reason = '' }) { + const total = nonNeg(size); + const remaining = Math.min(nonNeg(left), total); + const progress = total > 0 ? (total - remaining) / total : 0; + const e = num(eta); + return { + name: clean(name), + size: Math.round(total), + progress: Math.round(Math.min(1, Math.max(0, progress)) * 1000) / 1000, + speed: Math.round(nonNeg(speed)), + eta: state === 'run' && e != null && e >= 0 ? Math.round(e) : null, + state, + ...(reason ? { reason } : {}), + }; +} + +/* Direction controls can make a name read as something else: "evil\u202Eexe.txt". */ +const DISGUISE = new Set([ + 0x061c, 0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069, +]); +const control = n => n < 0x20 || (n >= 0x7f && n <= 0x9f); +const clean = name => + [...String(name || '')].filter(c => !control(c.codePointAt(0)) && !DISGUISE.has(c.codePointAt(0))).join(''); + +const RANK = { run: 0, stall: 1, error: 2 }; + +/** What the heading says, over every download, not only the ones shown. + `time`: `seconds` until the last moving one finishes, or null without an + estimate from each. `stall` and `error`: nothing moves; the first problem. */ +function summarize(items) { + const moving = items.filter(i => i.state === 'run'); + const stalled = items.filter(i => i.state === 'stall').length; + const failed = items.filter(i => i.state === 'error').length; + if (moving.length) { + const etas = moving.map(i => i.eta); + const seconds = etas.every(e => typeof e === 'number') ? Math.max(...etas) : null; + const speed = moving.reduce((t, i) => t + i.speed, 0); + return { kind: 'time', seconds, moving: moving.length, speed, stalled, failed }; + } + const problem = items.find(i => i.state === 'error') || items.find(i => i.state === 'stall'); + if (problem) return { kind: problem.state, reason: problem.reason || '' }; + return { kind: 'done' }; +} + +/** Closest to done first, then stalled, then failed; at most MAX_ITEMS. */ +function arrange(items) { + const sorted = items + .filter(i => i.name) + .sort((a, b) => { + if (a.state !== b.state) return RANK[a.state] - RANK[b.state]; + if (a.eta == null || b.eta == null) return (a.eta == null) - (b.eta == null) || b.progress - a.progress; + return a.eta - b.eta; + }); + return { items: sorted.slice(0, MAX_ITEMS), summary: summarize(sorted) }; +} + +/** Completion times in epoch ms within the window, newest first. Which ones + count as today is decided in the browser, in the reader's time zone. */ +function finishedTimes(seconds, now = Date.now()) { + return seconds + .map(s => num(s)) + .filter(s => s != null && s > 0) + .map(s => s * 1000) + .filter(ms => ms <= now + 60000 && now - ms <= FINISHED_WINDOW_MS) + .sort((a, b) => b - a) + .slice(0, MAX_FINISHED); +} + +const QBIT_RUN = new Set(['downloading', 'forcedDL', 'metaDL', 'forcedMetaDL', 'allocating']); +const QBIT_STALL = new Set(['stalledDL']); +const QBIT_ERROR = { error: 'error', missingFiles: 'missingFiles' }; +const QBIT_NO_ETA = 8640000; + +function qbitItems(torrents) { + const out = []; + for (const t of list(torrents)) { + const state = str(t.state); + const base = { + name: str(t.name), + size: t.size ?? t.total_size, + left: t.amount_left, + speed: t.dlspeed, + eta: num(t.eta) === QBIT_NO_ETA ? null : t.eta, + }; + if (QBIT_RUN.has(state)) out.push(makeItem(base)); + else if (QBIT_STALL.has(state)) out.push(makeItem({ ...base, state: 'stall', reason: 'noPeers' })); + else if (Object.hasOwn(QBIT_ERROR, state)) + out.push(makeItem({ ...base, state: 'error', reason: QBIT_ERROR[state] })); + } + return out; +} + +const qbitFinished = torrents => list(torrents).map(t => t.completion_on); + +/* Transmission error 3 is a local error. Errors 1 and 2 are tracker messages on + a download that still runs. */ +const tField = (t, snake, camel) => (t[snake] !== undefined ? t[snake] : t[camel]); + +function transmissionItems(torrents) { + const out = []; + for (const t of list(torrents)) { + const base = { + name: str(t.name), + size: tField(t, 'size_when_done', 'sizeWhenDone'), + left: tField(t, 'left_until_done', 'leftUntilDone'), + speed: tField(t, 'rate_download', 'rateDownload'), + eta: tField(t, 'eta', 'eta'), + }; + if (num(t.error) === 3) out.push(makeItem({ ...base, state: 'error', reason: 'error' })); + else if (num(t.status) === 4) { + out.push( + tField(t, 'is_stalled', 'isStalled') === true + ? makeItem({ ...base, state: 'stall', reason: 'noPeers' }) + : makeItem(base), + ); + } + } + return out; +} + +const transmissionFinished = torrents => + list(torrents) + .filter(t => num(tField(t, 'percent_done', 'percentDone')) === 1) + .map(t => tField(t, 'done_date', 'doneDate')); + +function delugeItems(torrents) { + const out = []; + const all = torrents && typeof torrents === 'object' ? Object.values(torrents) : []; + for (const t of list(all)) { + const state = str(t.state); + const wanted = nonNeg(t.total_wanted); + const base = { + name: str(t.name), + size: wanted, + left: wanted - nonNeg(t.total_done), + speed: t.download_payload_rate, + eta: num(t.eta) > 0 ? t.eta : null, + }; + if (state === 'Error') out.push(makeItem({ ...base, state: 'error', reason: 'error' })); + else if (state === 'Downloading') { + const idle = nonNeg(t.download_payload_rate) === 0 && nonNeg(t.num_seeds) === 0; + out.push(idle ? makeItem({ ...base, state: 'stall', reason: 'noPeers' }) : makeItem(base)); + } + } + return out; +} + +const delugeFinished = torrents => + list(torrents && typeof torrents === 'object' ? Object.values(torrents) : []) + .filter(t => t.is_finished === true) + .map(t => t.completed_time); + +/** "1:02:03" or "2:01:02:03" as seconds, or null. */ +function clockSeconds(text) { + if (typeof text !== 'string' || !/^\d+(:\d{1,2}){2,3}$/.test(text.trim())) return null; + return text + .trim() + .split(':') + .map(Number) + .reduce((acc, n, i, all) => acc + n * [1, 60, 3600, 86400][all.length - 1 - i], 0); +} + +/* SABnzbd reports one speed for the whole queue. It belongs to the first job. + Each slot's time left already counts the jobs ahead of it. */ +function sabItems(queue) { + if (!queue || typeof queue !== 'object' || queue.paused === true) return []; + const speed = nonNeg(queue.kbpersec) * 1024; + let speedGiven = false; + const out = []; + for (const s of list(queue.slots)) { + if (str(s.status) !== 'Downloading') continue; + const mySpeed = speedGiven ? 0 : speed; + speedGiven = true; + out.push( + makeItem({ + name: str(s.filename), + size: nonNeg(s.mb) * MIB, + left: nonNeg(s.mbleft) * MIB, + speed: mySpeed, + eta: speed > 0 ? clockSeconds(s.timeleft) : null, + }), + ); + } + return out; +} + +const sabFinished = history => + list(history && history.slots) + .filter(h => str(h.status) === 'Completed') + .map(h => h.completed); + +/* NZBGet reports one rate for the whole queue. It belongs to the first job. + Jobs download in turn, so each one finishes after the jobs ahead of it. + Paused files are repair files NZBGet fetches only when needed. */ +function nzbgetItems(groups, status) { + const rate = + status && typeof status === 'object' + ? hiLo(status.DownloadRateHi, status.DownloadRateLo) || nonNeg(status.DownloadRate) + : 0; + const paused = !!(status && status.DownloadPaused === true); + let rateGiven = false; + let ahead = 0; + const out = []; + for (const g of list(groups)) { + if (paused || str(g.Status) !== 'DOWNLOADING') continue; + const mySpeed = rateGiven ? 0 : rate; + rateGiven = true; + const held = hiLo(g.PausedSizeHi, g.PausedSizeLo); + const left = Math.max(0, hiLo(g.RemainingSizeHi, g.RemainingSizeLo) - held); + ahead += left; + out.push( + makeItem({ + name: str(g.NZBName), + size: Math.max(0, hiLo(g.FileSizeHi, g.FileSizeLo) - held), + left, + speed: mySpeed, + eta: rate > 0 ? ahead / rate : null, + }), + ); + } + return out; +} + +const nzbgetFinished = history => + list(history) + .filter(h => str(h.Status).startsWith('SUCCESS')) + .map(h => h.HistoryTime); + +module.exports = { + MAX_ITEMS, + arrange, + finishedTimes, + clockSeconds, + qbitItems, + qbitFinished, + transmissionItems, + transmissionFinished, + delugeItems, + delugeFinished, + sabItems, + sabFinished, + nzbgetItems, + nzbgetFinished, +}; diff --git a/ui/widgets/downloads/i18n/de.json b/ui/widgets/downloads/i18n/de.json new file mode 100644 index 00000000..8a15d0d3 --- /dev/null +++ b/ui/widgets/downloads/i18n/de.json @@ -0,0 +1,60 @@ +{ + "widget.label": "Downloads", + "widget.description": "Zeigt deine aktiven Downloads und wie lange jeder noch braucht.", + "service.label": "Download-Client", + "service.opt.qbittorrent": "qBittorrent", + "service.opt.transmission": "Transmission", + "service.opt.deluge": "Deluge", + "service.opt.sabnzbd": "SABnzbd", + "service.opt.nzbget": "NZBGet", + "url.label": "Server-URL", + "url.placeholder": "http://host:port", + "apiKey.label": "API-Schlüssel", + "apiKey.hint.qbittorrent": "Unter Optionen > Web-UI > API-Schlüssel. Braucht qBittorrent 5.2 oder neuer.", + "apiKey.hint.sabnzbd": "Unter Config > General > API Key. Mit dem NZB-Schlüssel lässt sich die Warteschlange nicht lesen.", + "username.label": "Benutzername", + "password.label": "Passwort", + "password.hint.nzbget": "Der eingeschränkte Benutzer kann die Warteschlange lesen, ohne Einstellungen zu ändern.", + "password.hint.deluge": "Das Passwort der Weboberfläche.", + "skipTls.label": "Selbstsigniertes Zertifikat zulassen", + "headerShows.label": "Überschrift zeigt", + "headerShows.opt.finish": "Fertig um", + "headerShows.opt.left": "Restzeit", + "href.label": "Link-URL", + "href.hint": "Die Adresse, unter der dein Browser diesen Dienst öffnet. Leer lassen, um die Adresse oben zu verwenden, sofern dein Browser sie erreicht.", + "ui.allDone": "Alles fertig", + "ui.nothing": "Keine Downloads", + "ui.finishedToday_one": "{count} heute", + "ui.finishedToday_other": "{count} heute", + "ui.finishedLast_one": "{count} heute fertig · um {time}", + "ui.finishedLast_other": "{count} heute fertig · zuletzt um {time}", + "ui.allDoneAt_one": "fertig", + "ui.allDoneAt_other": "alle {count} fertig", + "ui.untilDone_one": "bis fertig", + "ui.untilDone_other": "bis alle {count} fertig", + "ui.stalledCount_one": "{count} hängt", + "ui.stalledCount_other": "{count} hängen", + "ui.failedCount_one": "{count} fehlgeschlagen", + "ui.failedCount_other": "{count} fehlgeschlagen", + "ui.stalled": "Hängt", + "ui.error": "Fehler", + "ui.reason.noPeers": "keine Peers", + "ui.reason.missingFiles": "Dateien fehlen", + "ui.reason.error": "Fehler", + "ui.sayItem": "{name}, {percent}, noch {left}", + "ui.sayNoEta": "{name}, {percent}", + "ui.sayProblem": "{name}, {percent}, {reason}", + "ui.pageOf": "Download {n} von {total}", + "ui.pageGroup": "Seite {n} von {total}", + "ui.previous": "Vorheriger Download", + "ui.next": "Nächster Download", + "ui.openClient": "{text}. Aktivieren, um den Download-Client zu öffnen.", + "ui.sayHeading": "{title}, {detail}", + "ui.sayAll": "{heading}. {items}", + "ui.waiting": "Wartet", + "ui.noEstimateShort": "noch keine Schätzung", + "ui.noEstimateYet_one": "{count} Download · noch keine Schätzung", + "ui.noEstimateYet_other": "{count} Downloads · noch keine Schätzung", + "ui.am": "AM", + "ui.pm": "PM" +} diff --git a/ui/widgets/downloads/i18n/en.json b/ui/widgets/downloads/i18n/en.json new file mode 100644 index 00000000..16f22deb --- /dev/null +++ b/ui/widgets/downloads/i18n/en.json @@ -0,0 +1,60 @@ +{ + "widget.label": "Downloads", + "widget.description": "See your active downloads and how long each has left.", + "service.label": "Download client", + "service.opt.qbittorrent": "qBittorrent", + "service.opt.transmission": "Transmission", + "service.opt.deluge": "Deluge", + "service.opt.sabnzbd": "SABnzbd", + "service.opt.nzbget": "NZBGet", + "url.label": "Server URL", + "url.placeholder": "http://host:port", + "apiKey.label": "API key", + "apiKey.hint.qbittorrent": "From Options > Web UI > API key. Needs qBittorrent 5.2 or newer.", + "apiKey.hint.sabnzbd": "From Config > General > API Key. The NZB key cannot read the queue.", + "username.label": "Username", + "password.label": "Password", + "password.hint.nzbget": "The restricted user can read the queue without changing settings.", + "password.hint.deluge": "The Web UI password.", + "skipTls.label": "Allow Self-Signed Certificate", + "headerShows.label": "Heading shows", + "headerShows.opt.finish": "Finish time", + "headerShows.opt.left": "Time left", + "href.label": "Link URL", + "href.hint": "The address your browser uses for this service. Leave blank to use the one above, if your browser can reach it.", + "ui.allDone": "All done", + "ui.nothing": "Nothing downloading", + "ui.finishedToday_one": "{count} today", + "ui.finishedToday_other": "{count} today", + "ui.finishedLast_one": "{count} finished today · at {time}", + "ui.finishedLast_other": "{count} finished today · last at {time}", + "ui.allDoneAt_one": "done", + "ui.allDoneAt_other": "all {count} done", + "ui.untilDone_one": "until done", + "ui.untilDone_other": "until all {count} done", + "ui.stalledCount_one": "{count} stalled", + "ui.stalledCount_other": "{count} stalled", + "ui.failedCount_one": "{count} failed", + "ui.failedCount_other": "{count} failed", + "ui.stalled": "Stalled", + "ui.error": "Error", + "ui.reason.noPeers": "no peers", + "ui.reason.missingFiles": "files missing", + "ui.reason.error": "error", + "ui.sayItem": "{name}, {percent}, {left} left", + "ui.sayNoEta": "{name}, {percent}", + "ui.sayProblem": "{name}, {percent}, {reason}", + "ui.pageOf": "Download {n} of {total}", + "ui.pageGroup": "Page {n} of {total}", + "ui.previous": "Previous download", + "ui.next": "Next download", + "ui.openClient": "{text}. Activate to open the download client.", + "ui.sayHeading": "{title}, {detail}", + "ui.sayAll": "{heading}. {items}", + "ui.waiting": "Waiting", + "ui.noEstimateShort": "no estimate yet", + "ui.noEstimateYet_one": "{count} download · no estimate yet", + "ui.noEstimateYet_other": "{count} downloads · no estimate yet", + "ui.am": "AM", + "ui.pm": "PM" +} diff --git a/ui/widgets/downloads/i18n/es.json b/ui/widgets/downloads/i18n/es.json new file mode 100644 index 00000000..f0250498 --- /dev/null +++ b/ui/widgets/downloads/i18n/es.json @@ -0,0 +1,67 @@ +{ + "widget.label": "Descargas", + "widget.description": "Mira tus descargas activas y cuánto le falta a cada una.", + "service.label": "Cliente de descargas", + "service.opt.qbittorrent": "qBittorrent", + "service.opt.transmission": "Transmission", + "service.opt.deluge": "Deluge", + "service.opt.sabnzbd": "SABnzbd", + "service.opt.nzbget": "NZBGet", + "url.label": "URL del servidor", + "url.placeholder": "http://host:puerto", + "apiKey.label": "Clave de API", + "apiKey.hint.qbittorrent": "En Opciones > Interfaz web > Clave de API. Requiere qBittorrent 5.2 o posterior.", + "apiKey.hint.sabnzbd": "En Config > General > API Key. La clave NZB no puede leer la cola.", + "username.label": "Usuario", + "password.label": "Contraseña", + "password.hint.nzbget": "El usuario restringido puede leer la cola sin cambiar la configuración.", + "password.hint.deluge": "La contraseña de la interfaz web.", + "skipTls.label": "Permitir certificado autofirmado", + "headerShows.label": "El título muestra", + "headerShows.opt.finish": "Hora de fin", + "headerShows.opt.left": "Tiempo restante", + "href.label": "URL del enlace", + "href.hint": "La dirección con la que tu navegador abre este servicio. Déjalo en blanco para usar la de arriba, si tu navegador puede acceder a ella.", + "ui.allDone": "Todo listo", + "ui.nothing": "Nada descargando", + "ui.finishedToday_one": "{count} hoy", + "ui.finishedToday_other": "{count} hoy", + "ui.finishedToday_many": "{count} hoy", + "ui.finishedLast_one": "{count} terminada hoy · a las {time}", + "ui.finishedLast_other": "{count} terminadas hoy · la última a las {time}", + "ui.finishedLast_many": "{count} terminadas hoy · la última a las {time}", + "ui.allDoneAt_one": "lista", + "ui.allDoneAt_other": "las {count} listas", + "ui.allDoneAt_many": "las {count} listas", + "ui.untilDone_one": "para terminar", + "ui.untilDone_other": "para terminar las {count}", + "ui.untilDone_many": "para terminar las {count}", + "ui.stalledCount_one": "{count} detenida", + "ui.stalledCount_other": "{count} detenidas", + "ui.stalledCount_many": "{count} detenidas", + "ui.failedCount_one": "{count} con error", + "ui.failedCount_other": "{count} con error", + "ui.failedCount_many": "{count} con error", + "ui.stalled": "Detenida", + "ui.error": "Error", + "ui.reason.noPeers": "sin pares", + "ui.reason.missingFiles": "faltan archivos", + "ui.reason.error": "error", + "ui.sayItem": "{name}, {percent}, faltan {left}", + "ui.sayNoEta": "{name}, {percent}", + "ui.sayProblem": "{name}, {percent}, {reason}", + "ui.pageOf": "Descarga {n} de {total}", + "ui.pageGroup": "Página {n} de {total}", + "ui.previous": "Descarga anterior", + "ui.next": "Descarga siguiente", + "ui.openClient": "{text}. Actívalo para abrir el cliente de descargas.", + "ui.sayHeading": "{title}, {detail}", + "ui.sayAll": "{heading}. {items}", + "ui.waiting": "En espera", + "ui.noEstimateShort": "aún sin estimación", + "ui.noEstimateYet_one": "{count} descarga · aún sin estimación", + "ui.noEstimateYet_many": "{count} descargas · aún sin estimación", + "ui.noEstimateYet_other": "{count} descargas · aún sin estimación", + "ui.am": "a. m.", + "ui.pm": "p. m." +} diff --git a/ui/widgets/downloads/i18n/fa.json b/ui/widgets/downloads/i18n/fa.json new file mode 100644 index 00000000..986635d9 --- /dev/null +++ b/ui/widgets/downloads/i18n/fa.json @@ -0,0 +1,60 @@ +{ + "widget.label": "دانلودها", + "widget.description": "دانلودهای در حال انجام و زمان باقی‌مانده هر کدام را ببینید.", + "service.label": "برنامه دانلود", + "service.opt.qbittorrent": "qBittorrent", + "service.opt.transmission": "Transmission", + "service.opt.deluge": "Deluge", + "service.opt.sabnzbd": "SABnzbd", + "service.opt.nzbget": "NZBGet", + "url.label": "آدرس سرور", + "url.placeholder": "http://host:port", + "apiKey.label": "کلید API", + "apiKey.hint.qbittorrent": "از Options > Web UI > API key. به qBittorrent نسخه ۵٫۲ یا جدیدتر نیاز دارد.", + "apiKey.hint.sabnzbd": "از Config > General > API Key. کلید NZB نمی‌تواند صف را بخواند.", + "username.label": "نام کاربری", + "password.label": "رمز عبور", + "password.hint.nzbget": "کاربر محدود می‌تواند صف را بخواند، بدون اینکه تنظیمات را تغییر دهد.", + "password.hint.deluge": "رمز عبور رابط وب.", + "skipTls.label": "پذیرش گواهی خودامضا", + "headerShows.label": "عنوان نشان می‌دهد", + "headerShows.opt.finish": "زمان پایان", + "headerShows.opt.left": "زمان باقی‌مانده", + "href.label": "آدرس پیوند", + "href.hint": "آدرسی که مرورگرتان برای این سرویس استفاده می‌کند. اگر مرورگرتان به آدرس بالا دسترسی دارد، خالی بگذارید.", + "ui.allDone": "همه تمام شد", + "ui.nothing": "دانلودی در جریان نیست", + "ui.finishedToday_one": "{count} امروز", + "ui.finishedToday_other": "{count} امروز", + "ui.finishedLast_one": "{count} امروز · ساعت {time}", + "ui.finishedLast_other": "{count} امروز · آخرین {time}", + "ui.allDoneAt_one": "تمام", + "ui.allDoneAt_other": "هر {count} تمام", + "ui.untilDone_one": "تا پایان", + "ui.untilDone_other": "تا پایان هر {count}", + "ui.stalledCount_one": "{count} متوقف", + "ui.stalledCount_other": "{count} متوقف", + "ui.failedCount_one": "{count} ناموفق", + "ui.failedCount_other": "{count} ناموفق", + "ui.stalled": "متوقف", + "ui.error": "خطا", + "ui.reason.noPeers": "بدون همتا", + "ui.reason.missingFiles": "فایل‌ها پیدا نشد", + "ui.reason.error": "خطا", + "ui.sayItem": "{name}، {percent}، {left} مانده", + "ui.sayNoEta": "{name}، {percent}", + "ui.sayProblem": "{name}، {percent}، {reason}", + "ui.pageOf": "دانلود {n} از {total}", + "ui.pageGroup": "صفحه {n} از {total}", + "ui.previous": "دانلود قبلی", + "ui.next": "دانلود بعدی", + "ui.openClient": "{text}. برای باز کردن برنامه دانلود فعال کنید.", + "ui.sayHeading": "{title}، {detail}", + "ui.sayAll": "{heading}. {items}", + "ui.waiting": "در انتظار", + "ui.noEstimateShort": "هنوز تخمینی نیست", + "ui.noEstimateYet_one": "{count} دانلود · هنوز تخمینی نیست", + "ui.noEstimateYet_other": "{count} دانلود · هنوز تخمینی نیست", + "ui.am": "ق ظ", + "ui.pm": "ب ظ" +} diff --git a/ui/widgets/downloads/i18n/fr.json b/ui/widgets/downloads/i18n/fr.json new file mode 100644 index 00000000..4eb95a90 --- /dev/null +++ b/ui/widgets/downloads/i18n/fr.json @@ -0,0 +1,67 @@ +{ + "widget.label": "Téléchargements", + "widget.description": "Suivez vos téléchargements en cours et le temps restant pour chacun.", + "service.label": "Client de téléchargement", + "service.opt.qbittorrent": "qBittorrent", + "service.opt.transmission": "Transmission", + "service.opt.deluge": "Deluge", + "service.opt.sabnzbd": "SABnzbd", + "service.opt.nzbget": "NZBGet", + "url.label": "URL du serveur", + "url.placeholder": "http://hôte:port", + "apiKey.label": "Clé d’API", + "apiKey.hint.qbittorrent": "Dans Options > Interface web > Clé d’API. Nécessite qBittorrent 5.2 ou plus récent.", + "apiKey.hint.sabnzbd": "Dans Config > General > API Key. La clé NZB ne peut pas lire la file d’attente.", + "username.label": "Nom d’utilisateur", + "password.label": "Mot de passe", + "password.hint.nzbget": "L’utilisateur restreint peut lire la file d’attente sans modifier les réglages.", + "password.hint.deluge": "Le mot de passe de l’interface web.", + "skipTls.label": "Autoriser un certificat auto-signé", + "headerShows.label": "Le titre affiche", + "headerShows.opt.finish": "Heure de fin", + "headerShows.opt.left": "Temps restant", + "href.label": "URL du lien", + "href.hint": "L’adresse que votre navigateur utilise pour ce service. Laissez vide pour utiliser celle ci-dessus, si votre navigateur peut l’atteindre.", + "ui.allDone": "Tout est terminé", + "ui.nothing": "Aucun téléchargement", + "ui.finishedToday_one": "{count} aujourd’hui", + "ui.finishedToday_other": "{count} aujourd’hui", + "ui.finishedToday_many": "{count} aujourd’hui", + "ui.finishedLast_one": "{count} terminé aujourd’hui · à {time}", + "ui.finishedLast_other": "{count} terminés aujourd’hui · le dernier à {time}", + "ui.finishedLast_many": "{count} terminés aujourd’hui · le dernier à {time}", + "ui.allDoneAt_one": "terminé", + "ui.allDoneAt_other": "les {count} terminés", + "ui.allDoneAt_many": "les {count} terminés", + "ui.untilDone_one": "avant la fin", + "ui.untilDone_other": "avant la fin des {count}", + "ui.untilDone_many": "avant la fin des {count}", + "ui.stalledCount_one": "{count} bloqué", + "ui.stalledCount_other": "{count} bloqués", + "ui.stalledCount_many": "{count} bloqués", + "ui.failedCount_one": "{count} en échec", + "ui.failedCount_other": "{count} en échec", + "ui.failedCount_many": "{count} en échec", + "ui.stalled": "Bloqué", + "ui.error": "Erreur", + "ui.reason.noPeers": "aucun pair", + "ui.reason.missingFiles": "fichiers manquants", + "ui.reason.error": "erreur", + "ui.sayItem": "{name}, {percent}, encore {left}", + "ui.sayNoEta": "{name}, {percent}", + "ui.sayProblem": "{name}, {percent}, {reason}", + "ui.pageOf": "Téléchargement {n} sur {total}", + "ui.pageGroup": "Page {n} sur {total}", + "ui.previous": "Téléchargement précédent", + "ui.next": "Téléchargement suivant", + "ui.openClient": "{text}. Activez pour ouvrir le client de téléchargement.", + "ui.sayHeading": "{title}, {detail}", + "ui.sayAll": "{heading}. {items}", + "ui.waiting": "En attente", + "ui.noEstimateShort": "pas encore d’estimation", + "ui.noEstimateYet_one": "{count} téléchargement · pas encore d’estimation", + "ui.noEstimateYet_many": "{count} téléchargements · pas encore d’estimation", + "ui.noEstimateYet_other": "{count} téléchargements · pas encore d’estimation", + "ui.am": "AM", + "ui.pm": "PM" +} diff --git a/ui/widgets/downloads/i18n/zh-Hans.json b/ui/widgets/downloads/i18n/zh-Hans.json new file mode 100644 index 00000000..a5b26503 --- /dev/null +++ b/ui/widgets/downloads/i18n/zh-Hans.json @@ -0,0 +1,53 @@ +{ + "widget.label": "下载", + "widget.description": "查看正在进行的下载以及每项的剩余时间。", + "service.label": "下载客户端", + "service.opt.qbittorrent": "qBittorrent", + "service.opt.transmission": "Transmission", + "service.opt.deluge": "Deluge", + "service.opt.sabnzbd": "SABnzbd", + "service.opt.nzbget": "NZBGet", + "url.label": "服务器地址", + "url.placeholder": "http://主机:端口", + "apiKey.label": "API 密钥", + "apiKey.hint.qbittorrent": "在“选项 > Web UI > API 密钥”中获取。需要 qBittorrent 5.2 或更高版本。", + "apiKey.hint.sabnzbd": "在 Config > General > API Key 中获取。NZB 密钥无法读取队列。", + "username.label": "用户名", + "password.label": "密码", + "password.hint.nzbget": "受限用户可以读取队列,但不能更改设置。", + "password.hint.deluge": "Web 界面的密码。", + "skipTls.label": "允许自签名证书", + "headerShows.label": "标题显示", + "headerShows.opt.finish": "完成时间", + "headerShows.opt.left": "剩余时间", + "href.label": "链接地址", + "href.hint": "浏览器访问此服务所用的地址。留空则使用上方地址(前提是浏览器能访问它)。", + "ui.allDone": "全部完成", + "ui.nothing": "没有正在下载的项目", + "ui.finishedToday_other": "今天 {count} 项", + "ui.finishedLast_other": "今天完成 {count} 项 · 最近一项 {time}", + "ui.allDoneAt_other": "{count} 项全部完成", + "ui.untilDone_other": "后 {count} 项全部完成", + "ui.stalledCount_other": "{count} 项停滞", + "ui.failedCount_other": "{count} 项失败", + "ui.stalled": "已停滞", + "ui.error": "出错", + "ui.reason.noPeers": "没有对等方", + "ui.reason.missingFiles": "文件缺失", + "ui.reason.error": "出错", + "ui.sayItem": "{name},{percent},剩余 {left}", + "ui.sayNoEta": "{name},{percent}", + "ui.sayProblem": "{name},{percent},{reason}", + "ui.pageOf": "第 {n} 项,共 {total} 项", + "ui.pageGroup": "第 {n} 页,共 {total} 页", + "ui.previous": "上一个下载", + "ui.next": "下一个下载", + "ui.openClient": "{text}。激活以打开下载客户端。", + "ui.sayHeading": "{title},{detail}", + "ui.sayAll": "{heading}。{items}", + "ui.waiting": "等待中", + "ui.noEstimateShort": "暂无估计", + "ui.noEstimateYet_other": "{count} 项下载 · 暂无估计", + "ui.am": "上午", + "ui.pm": "下午" +} diff --git a/ui/widgets/downloads/index.html b/ui/widgets/downloads/index.html new file mode 100644 index 00000000..42e14f9a --- /dev/null +++ b/ui/widgets/downloads/index.html @@ -0,0 +1,611 @@ + + + + + +Downloads + + + + +
+
+
+
+
+ + +
+ + + diff --git a/ui/widgets/downloads/widget.json b/ui/widgets/downloads/widget.json new file mode 100644 index 00000000..65fa5a5c --- /dev/null +++ b/ui/widgets/downloads/widget.json @@ -0,0 +1,143 @@ +{ + "name": "downloads", + "label": "widget.label", + "description": "widget.description", + "glyph": "parcel", + "sizes": [ + "small", + "medium" + ], + "card": "dark", + "fields": [ + { + "key": "service", + "type": "select", + "label": "service.label", + "default": "qbittorrent", + "options": [ + { + "value": "qbittorrent", + "label": "service.opt.qbittorrent" + }, + { + "value": "transmission", + "label": "service.opt.transmission" + }, + { + "value": "deluge", + "label": "service.opt.deluge" + }, + { + "value": "sabnzbd", + "label": "service.opt.sabnzbd" + }, + { + "value": "nzbget", + "label": "service.opt.nzbget" + } + ] + }, + { + "key": "url", + "type": "text", + "label": "url.label", + "placeholder": "url.placeholder" + }, + { + "key": "apiKey", + "type": "secret", + "label": "apiKey.label", + "hint": "apiKey.hint.qbittorrent", + "showIf": { + "field": "service", + "equals": "qbittorrent" + } + }, + { + "key": "apiKey", + "type": "secret", + "label": "apiKey.label", + "hint": "apiKey.hint.sabnzbd", + "showIf": { + "field": "service", + "equals": "sabnzbd" + } + }, + { + "key": "username", + "type": "text", + "label": "username.label", + "optional": true, + "showIf": { + "field": "service", + "in": [ + "nzbget", + "transmission" + ] + } + }, + { + "key": "password", + "type": "secret", + "label": "password.label", + "hint": "password.hint.nzbget", + "optional": true, + "showIf": { + "field": "service", + "equals": "nzbget" + } + }, + { + "key": "password", + "type": "secret", + "label": "password.label", + "optional": true, + "showIf": { + "field": "service", + "equals": "transmission" + } + }, + { + "key": "password", + "type": "secret", + "label": "password.label", + "hint": "password.hint.deluge", + "showIf": { + "field": "service", + "equals": "deluge" + } + }, + { + "key": "skipTls", + "type": "toggle", + "label": "skipTls.label", + "default": false + }, + { + "key": "headerShows", + "type": "select", + "variant": "pills", + "label": "headerShows.label", + "default": "finish", + "cosmetic": true, + "options": [ + { + "value": "finish", + "label": "headerShows.opt.finish" + }, + { + "value": "left", + "label": "headerShows.opt.left" + } + ] + }, + { + "key": "href", + "type": "text", + "label": "href.label", + "hint": "href.hint", + "optional": true, + "cosmetic": true + } + ] +} diff --git a/ui/widgets/weather/index.html b/ui/widgets/weather/index.html index 22162ad0..4ea02f5b 100644 --- a/ui/widgets/weather/index.html +++ b/ui/widgets/weather/index.html @@ -74,7 +74,7 @@