diff --git a/astro.config.mjs b/astro.config.mjs index 67bf229..c63ad73 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -102,6 +102,7 @@ export default defineConfig({ { label: 'Dashboard switch', link: '/docs/widgets/dashboard-switch/' }, { label: 'Disk health', link: '/docs/widgets/disk-health/' }, { label: 'DNS', link: '/docs/widgets/dns/' }, + { label: 'Downloads', link: '/docs/widgets/downloads/' }, { label: 'GitHub', link: '/docs/widgets/github/' }, { label: 'Now Playing', link: '/docs/widgets/now-playing/' }, { label: 'System summary', link: '/docs/widgets/system-summary/' }, diff --git a/public/api/widget-config/w-downloads b/public/api/widget-config/w-downloads new file mode 100644 index 0000000..d459887 --- /dev/null +++ b/public/api/widget-config/w-downloads @@ -0,0 +1 @@ +{"widgetSize":"small","widgetConfig":{"service":"qbittorrent","url":"https://qbittorrent.example.com","headerShows":"finish"}} diff --git a/public/api/widget-config/w-downloads-md b/public/api/widget-config/w-downloads-md new file mode 100644 index 0000000..37976b9 --- /dev/null +++ b/public/api/widget-config/w-downloads-md @@ -0,0 +1 @@ +{"widgetSize":"medium","widgetConfig":{"service":"transmission","url":"https://transmission.example.com","headerShows":"finish"}} diff --git a/public/api/widget-data/w-downloads b/public/api/widget-data/w-downloads new file mode 100644 index 0000000..826dbe3 --- /dev/null +++ b/public/api/widget-data/w-downloads @@ -0,0 +1,22 @@ +{ + "items": [ + { + "name": "Fedora-Workstation-Live-43.iso", + "size": 2400000000, + "progress": 0.81, + "speed": 9800000, + "eta": 47, + "state": "run" + } + ], + "summary": { + "kind": "time", + "seconds": 47, + "moving": 1, + "speed": 9800000, + "stalled": 0, + "failed": 0 + }, + "finished": [], + "link": "" +} diff --git a/public/api/widget-data/w-downloads-md b/public/api/widget-data/w-downloads-md new file mode 100644 index 0000000..ffcfc95 --- /dev/null +++ b/public/api/widget-data/w-downloads-md @@ -0,0 +1,47 @@ +{ + "items": [ + { + "name": "Fedora-Workstation-Live-43.iso", + "size": 2400000000, + "progress": 0.81, + "speed": 9800000, + "eta": 47, + "state": "run" + }, + { + "name": "Sintel.2010.mkv", + "size": 1100000000, + "progress": 0.52, + "speed": 6200000, + "eta": 85, + "state": "run" + }, + { + "name": "ubuntu-26.04-desktop-amd64.iso", + "size": 6100000000, + "progress": 0.34, + "speed": 11200000, + "eta": 359, + "state": "run" + }, + { + "name": "big-buck-bunny-4k-60fps.mkv", + "size": 3600000000, + "progress": 0.23, + "speed": 0, + "eta": null, + "state": "stall", + "reason": "noPeers" + } + ], + "summary": { + "kind": "time", + "seconds": 359, + "moving": 3, + "speed": 27200000, + "stalled": 1, + "failed": 0 + }, + "finished": [], + "link": "" +} diff --git a/public/icons/services/deluge-dark.svg b/public/icons/services/deluge-dark.svg new file mode 100644 index 0000000..a78248a --- /dev/null +++ b/public/icons/services/deluge-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/services/deluge-light.svg b/public/icons/services/deluge-light.svg new file mode 100644 index 0000000..37c45e9 --- /dev/null +++ b/public/icons/services/deluge-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/services/nzbget-dark.svg b/public/icons/services/nzbget-dark.svg new file mode 100644 index 0000000..d0ba384 --- /dev/null +++ b/public/icons/services/nzbget-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/services/nzbget-light.svg b/public/icons/services/nzbget-light.svg new file mode 100644 index 0000000..721eeb7 --- /dev/null +++ b/public/icons/services/nzbget-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/services/qbittorrent-dark.svg b/public/icons/services/qbittorrent-dark.svg new file mode 100644 index 0000000..26e514b --- /dev/null +++ b/public/icons/services/qbittorrent-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/services/qbittorrent-light.svg b/public/icons/services/qbittorrent-light.svg new file mode 100644 index 0000000..7c52361 --- /dev/null +++ b/public/icons/services/qbittorrent-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/services/sabnzbd-dark.svg b/public/icons/services/sabnzbd-dark.svg new file mode 100644 index 0000000..bfcb73b --- /dev/null +++ b/public/icons/services/sabnzbd-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/services/sabnzbd-light.svg b/public/icons/services/sabnzbd-light.svg new file mode 100644 index 0000000..f774204 --- /dev/null +++ b/public/icons/services/sabnzbd-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/services/transmission-dark.svg b/public/icons/services/transmission-dark.svg new file mode 100644 index 0000000..e1fdd64 --- /dev/null +++ b/public/icons/services/transmission-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/services/transmission-light.svg b/public/icons/services/transmission-light.svg new file mode 100644 index 0000000..e5513f9 --- /dev/null +++ b/public/icons/services/transmission-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/widgets/downloads/belt-logic.js b/public/widgets/downloads/belt-logic.js new file mode 100644 index 0000000..c4b9053 --- /dev/null +++ b/public/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/public/widgets/downloads/data.js b/public/widgets/downloads/data.js new file mode 100644 index 0000000..0426d8a --- /dev/null +++ b/public/widgets/downloads/data.js @@ -0,0 +1,316 @@ +/* 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, + }; +} + +/* qBittorrent 5.3 reports a paused session only in its sync feed. The first + read sends every torrent; later reads with the returned rid send changes + only, and `session_state` only when it changes. Kept per server. */ +const _qbitSync = new Map(); +/* A list too large to read once is retried after this long. */ +const QBIT_RETRY_MS = 24 * 3600 * 1000; + +async function qbitPaused(get, base) { + const known = _qbitSync.get(base) || { rid: 0, paused: false }; + if (known.tooLargeAt && Date.now() - known.tooLargeAt < QBIT_RETRY_MS) return false; + let d; + try { + d = await get(`/sync/maindata?rid=${known.rid}`, false); + } catch (e) { + if (e && e.responseTooLarge) remember(_qbitSync, base, { rid: 0, paused: false, tooLargeAt: Date.now() }); + return known.paused; + } + if (!d || typeof d !== 'object' || Array.isArray(d)) return false; + const next = { + rid: Number.isSafeInteger(d.rid) && d.rid >= 0 ? d.rid : 0, + paused: d.full_update === true ? false : known.paused, + }; + const state = d.server_state && d.server_state.session_state; + if (typeof state === 'boolean') next.paused = state; + remember(_qbitSync, base, next); + return next.paused; +} + +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, list = true) => { + 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 (list && !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, paused] = 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(() => []), + qbitPaused(get, base), + ]); + return { found: items.qbitItems([...active, ...failed]), finished: items.qbitFinished(done), paused }; +} + +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) : [], + paused: queue.paused === true, + }; +} + +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), + paused: !!status && status.DownloadPaused === true, + }; +} + +/* 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; + const paused = await authed('core.is_session_paused', []).catch(() => false); + return { found: items.delugeItems(torrents), finished: items.delugeFinished(torrents), paused: paused === true }; +} + +module.exports = async function (ctx) { + const out = await ctx.dispatchProvider( + { qbittorrent, sabnzbd, nzbget, transmission, deluge }, + { field: 'service', default: 'qbittorrent' }, + ); + return { + ...items.arrange(out.found, out.paused === true), + finished: items.finishedTimes(out.finished), + link: ctx.serviceLink(ctx.config.href, ctx.config.url), + }; +}; diff --git a/public/widgets/downloads/demo.js b/public/widgets/downloads/demo.js new file mode 100644 index 0000000..78662a3 --- /dev/null +++ b/public/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/public/widgets/downloads/download-items.js b/public/widgets/downloads/download-items.js new file mode 100644 index 0000000..853d9cb --- /dev/null +++ b/public/widgets/downloads/download-items.js @@ -0,0 +1,278 @@ +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, pause or error; `reason` names a stall, pause 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, pause: 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. + `paused`: the client is paused as a whole. */ +function summarize(items, paused) { + if (paused) return { kind: 'paused' }; + 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' }; +} + +/* A paused client reports its downloads as stalled or with a rate falling to + zero. Neither is a problem the owner has to fix. */ +const asPaused = it => (it.state === 'error' ? it : { ...it, state: 'pause', reason: 'paused', eta: null, speed: 0 }); + +/** Closest to done first, then stalled or paused, then failed; at most MAX_ITEMS. + + @param {any[]} items @param {boolean} [paused] the client is paused as a whole */ +function arrange(items, paused = false) { + const sorted = (paused ? items.map(asPaused) : 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, paused) }; +} + +/** 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/public/widgets/downloads/i18n/de.json b/public/widgets/downloads/i18n/de.json new file mode 100644 index 0000000..0004d56 --- /dev/null +++ b/public/widgets/downloads/i18n/de.json @@ -0,0 +1,62 @@ +{ + "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.paused": "Pausiert", + "ui.error": "Fehler", + "ui.reason.noPeers": "keine Peers", + "ui.reason.missingFiles": "Dateien fehlen", + "ui.reason.error": "Fehler", + "ui.reason.paused": "pausiert", + "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/public/widgets/downloads/i18n/en.json b/public/widgets/downloads/i18n/en.json new file mode 100644 index 0000000..b840d3c --- /dev/null +++ b/public/widgets/downloads/i18n/en.json @@ -0,0 +1,62 @@ +{ + "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.paused": "Paused", + "ui.error": "Error", + "ui.reason.noPeers": "no peers", + "ui.reason.missingFiles": "files missing", + "ui.reason.error": "error", + "ui.reason.paused": "paused", + "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/public/widgets/downloads/i18n/es.json b/public/widgets/downloads/i18n/es.json new file mode 100644 index 0000000..f00206b --- /dev/null +++ b/public/widgets/downloads/i18n/es.json @@ -0,0 +1,69 @@ +{ + "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.paused": "En pausa", + "ui.error": "Error", + "ui.reason.noPeers": "sin pares", + "ui.reason.missingFiles": "faltan archivos", + "ui.reason.error": "error", + "ui.reason.paused": "en pausa", + "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/public/widgets/downloads/i18n/fa.json b/public/widgets/downloads/i18n/fa.json new file mode 100644 index 0000000..af9582c --- /dev/null +++ b/public/widgets/downloads/i18n/fa.json @@ -0,0 +1,62 @@ +{ + "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.paused": "مکث", + "ui.error": "خطا", + "ui.reason.noPeers": "بدون همتا", + "ui.reason.missingFiles": "فایل‌ها پیدا نشد", + "ui.reason.error": "خطا", + "ui.reason.paused": "مکث", + "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/public/widgets/downloads/i18n/fr.json b/public/widgets/downloads/i18n/fr.json new file mode 100644 index 0000000..4d9cb78 --- /dev/null +++ b/public/widgets/downloads/i18n/fr.json @@ -0,0 +1,69 @@ +{ + "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.paused": "En pause", + "ui.error": "Erreur", + "ui.reason.noPeers": "aucun pair", + "ui.reason.missingFiles": "fichiers manquants", + "ui.reason.error": "erreur", + "ui.reason.paused": "en pause", + "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/public/widgets/downloads/i18n/zh-Hans.json b/public/widgets/downloads/i18n/zh-Hans.json new file mode 100644 index 0000000..d151e0c --- /dev/null +++ b/public/widgets/downloads/i18n/zh-Hans.json @@ -0,0 +1,55 @@ +{ + "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.paused": "已暂停", + "ui.error": "出错", + "ui.reason.noPeers": "没有对等方", + "ui.reason.missingFiles": "文件缺失", + "ui.reason.error": "出错", + "ui.reason.paused": "已暂停", + "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/public/widgets/downloads/index.html b/public/widgets/downloads/index.html new file mode 100644 index 0000000..39f3f93 --- /dev/null +++ b/public/widgets/downloads/index.html @@ -0,0 +1,623 @@ + + + + + + + +Downloads + + + + +
+
+
+
+
+ + +
+ + + diff --git a/public/widgets/downloads/widget.json b/public/widgets/downloads/widget.json new file mode 100644 index 0000000..65fa5a5 --- /dev/null +++ b/public/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/src/content/docs/docs/widgets/downloads.mdx b/src/content/docs/docs/widgets/downloads.mdx new file mode 100644 index 0000000..0b0badb --- /dev/null +++ b/src/content/docs/docs/widgets/downloads.mdx @@ -0,0 +1,90 @@ +--- +title: Downloads widget +description: Active downloads from qBittorrent, Transmission, Deluge, SABnzbd and NZBGet on your Stackyard dashboard, with time left for each. +--- + +import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import ServiceList from '../../../../components/ServiceList.astro'; +import SettingList from '../../../../components/SettingList.astro'; + +Shows each active download as a box on a conveyor belt. Tape over the lid is the progress. Each label shows the percent done and the time left. The heading shows when the last download finishes. + + + +The widget reads one download client. It shows up to 12 downloads, closest to done first. More than fit on the card page with the arrows on the belt. The heading counts every download, including ones not shown. + +States: + +- **Stalled:** a download with no peers. Its tape turns amber. +- **Error:** a download the client reports as failed. Its tape turns red. +- **Paused:** the whole client is paused. Listed downloads turn grey and show no time left. qBittorrent reports this from 5.3. Transmission has no such pause. +- **Waiting:** downloads are moving, but the client has no estimate yet. +- **All done:** nothing is downloading. The heading shows how many finished today. + +The widget only reads. One exception: if the Deluge web interface is not connected to a daemon and lists exactly one, the widget connects it. With several daemons listed, it shows an error instead. + + + + diff --git a/src/content/docs/docs/widgets/index.mdx b/src/content/docs/docs/widgets/index.mdx index 2b7c5bf..c519681 100644 --- a/src/content/docs/docs/widgets/index.mdx +++ b/src/content/docs/docs/widgets/index.mdx @@ -21,7 +21,7 @@ Widgets reorder in the same list as apps and folders. See [Apps and folders](/do ## The widgets -