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