From 7feaec2e27e084b3136dfd57c19171dd7744e254 Mon Sep 17 00:00:00 2001 From: MyLuxy <240354279+MyLuxy@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:53:05 +0200 Subject: [PATCH] feat: downloads from a pasted link, TorBox and qBittorrent --- app/downloads/AddDownloads.jsx | 302 +++++++++++++++++++++++++++ app/downloads/DownloadsScreen.jsx | 3 + app/i18n/locales/en.json | 41 ++++ app/i18n/locales/it.json | 41 ++++ app/state/download-queue.js | 7 +- app/state/download-services.js | 72 +++++++ app/state/download-services.test.mjs | 26 +++ app/state/download-sources.js | 4 +- app/state/link-download.js | 154 ++++++++++++++ app/state/link-download.test.mjs | 100 +++++++++ app/state/qbittorrent.js | 157 ++++++++++++++ app/state/qbittorrent.test.mjs | 104 +++++++++ app/state/torbox.js | 167 +++++++++++++++ app/state/torbox.test.mjs | 108 ++++++++++ 14 files changed, 1283 insertions(+), 3 deletions(-) create mode 100644 app/downloads/AddDownloads.jsx create mode 100644 app/state/download-services.js create mode 100644 app/state/download-services.test.mjs create mode 100644 app/state/link-download.js create mode 100644 app/state/link-download.test.mjs create mode 100644 app/state/qbittorrent.js create mode 100644 app/state/qbittorrent.test.mjs create mode 100644 app/state/torbox.js create mode 100644 app/state/torbox.test.mjs diff --git a/app/downloads/AddDownloads.jsx b/app/downloads/AddDownloads.jsx new file mode 100644 index 00000000..9e7cc09c --- /dev/null +++ b/app/downloads/AddDownloads.jsx @@ -0,0 +1,302 @@ +// The Downloads page's own section for downloads that do not come from the catalog (issues #107, +// #89, #20): a pasted link, a torrent through the user's own client or TorBox, and the setup for +// both services. A file that has to be fetched first (TorBox, qBittorrent) shows its progress in a +// dialog with Cancel; once the service names the file, it joins the normal queue, on the drive +// chosen in Settings. + +import '../assets/Inter-Regular.ttf'; +import { Fragment, jsx, jsxs } from 'react/jsx-runtime'; +import { useEffect, useRef, useState } from 'react'; +import { useT } from '../i18n/index.js'; +import { useNavActions } from '../state/nav.jsx'; +import { downloadQueue, storageRoots } from '../state/downloads.js'; +import { downloadRoot } from '../state/download-root.js'; +import { qbittorrent as qbittorrentSettings, services as serviceSettings, setService, useServices } from '../state/download-services.js'; +import { cancellation } from '../state/download-sources.js'; +import { addLink, linkInput } from '../state/link-download.js'; +import { createTorbox } from '../state/torbox.js'; +import { createQBittorrent } from '../state/qbittorrent.js'; +import { URLDialog } from './URLDialog.jsx'; +import { primaryKey } from './DownloadRow.jsx'; +import { Button2 } from '../ui/Button.jsx'; +import { FocusRing } from '../ui/FocusRing.jsx'; +import { Icon } from '../ui/Icon.jsx'; +import { colors } from '../ui/colors.js'; +import { FocusScope, Http, Text, View } from '@ps5-react/core'; + + // Where a pasted file is saved: the drive chosen in Settings, when it is still there. + function chosenRoot() { + try { + const roots = storageRoots(); + const remembered = downloadRoot(); + return remembered && roots.includes(remembered) ? remembered : roots[0] ?? "/data"; + } catch { + return "/data"; + } + } + + var CARD = { width: 430, height: 124, flexDirection: "row", alignItems: "center", gap: 18, borderRadius: 16, paddingHorizontal: 20, backgroundColor: colors.surface }; + function AddCard({ icon, title, detail, onPress, focusKey, muted }) { + return /* @__PURE__ */ (0, jsxs)(View, { + focusKey, onPress, + style: (state) => [CARD, state.focused ? { backgroundColor: colors.surface2 } : null], + children: [ + /* @__PURE__ */ (0, jsx)(FocusRing, {}), + /* @__PURE__ */ (0, jsx)(View, { style: { width: 58, height: 58, alignItems: "center", justifyContent: "center", borderRadius: 14, backgroundColor: colors.surface2 }, children: /* @__PURE__ */ (0, jsx)(Icon, { name: icon, size: 26, color: muted ? colors.subtle : colors.fg }) }), + /* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, gap: 4 }, children: [ + /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 24, fontWeight: 600, color: muted ? colors.muted : colors.fg, numberOfLines: 1 }, children: title }), + /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 19, color: colors.muted, numberOfLines: 2, lineHeight: 26 }, children: detail }) + ] }) + ] + }); + } + + // A wait with a bar: the service is fetching the file, or the client is still downloading it. + function Waiting({ title, detail, progress, error: error2, onCancel, onClose }) { + const t2 = useT(); + const percent = Math.max(0, Math.min(1, progress)); + return /* @__PURE__ */ (0, jsx)(FocusScope, { + trap: true, autoFocus: true, + onBack: () => { + if (error2) onClose(); + else onCancel(); + return true; + }, + children: /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 50, alignItems: "center", justifyContent: "center", backgroundColor: "#000000f2" }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { width: 900, gap: 26, borderRadius: 18, borderWidth: 2, borderColor: colors.line, backgroundColor: colors.surface, padding: 44 }, children: [ + /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 31, fontWeight: 600, color: colors.fg, lineHeight: 42 }, children: title }), + /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 22, color: error2 ? colors.fg : colors.muted, lineHeight: 32 }, children: error2 || detail }), + error2 ? null : /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 12 }, children: [ + /* @__PURE__ */ (0, jsx)(View, { style: { width: 812, height: 8, borderRadius: 4, backgroundColor: colors.surface3 }, children: /* @__PURE__ */ (0, jsx)(View, { style: { width: Math.round(812 * percent), height: 8, borderRadius: 4, backgroundColor: colors.fg } }) }), + /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 20, color: colors.muted }, children: t2("add.percent", { percent: String(Math.round(percent * 100)) }) }) + ] }), + /* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row", gap: 12 }, children: error2 + ? /* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.close"), onPress: onClose, autoFocus: true }) + : /* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.cancel"), variant: "outline", onPress: onCancel, autoFocus: true }) }) + ] }) }) + }); + } + + // The services' own settings: TorBox's key and the qBittorrent WebUI's address and login. They sit + // beside the section that needs them rather than in Settings, since each user enters their own. + function ServicesDialog({ onClose, onField }) { + const t2 = useT(); + const services = useServices(); + const rows = [ + { field: "torbox", icon: "archive", title: t2("add.torboxKey"), value: services.torbox }, + { field: "address", icon: "globe", title: t2("add.address"), value: services.address }, + { field: "user", icon: "controller", title: t2("add.user"), value: services.user }, + { field: "password", icon: "controller", title: t2("add.password"), value: services.password ? "••••••••" : "" } + ]; + return /* @__PURE__ */ (0, jsx)(FocusScope, { + trap: true, autoFocus: true, + onBack: () => { + onClose(); + return true; + }, + children: /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 50, alignItems: "center", justifyContent: "center", backgroundColor: "#000000f2" }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { width: 980, gap: 20, borderRadius: 18, borderWidth: 2, borderColor: colors.line, backgroundColor: colors.surface, padding: 44 }, children: [ + /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 34, fontWeight: 600, color: colors.fg }, children: t2("add.servicesTitle") }), + /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, lineHeight: 30 }, children: t2("add.servicesDetail") }), + rows.map((row) => /* @__PURE__ */ (0, jsxs)(View, { + focusKey: `downloads:service:${row.field}`, + onPress: () => onField(row.field), + style: (state) => [{ flexDirection: "row", alignItems: "center", gap: 14, borderRadius: 12, paddingHorizontal: 16, height: 64, backgroundColor: colors.surface2 }, state.focused ? { backgroundColor: colors.surface3 } : null], + children: [ + /* @__PURE__ */ (0, jsx)(FocusRing, {}), + /* @__PURE__ */ (0, jsx)(Icon, { name: row.icon, size: 20, color: colors.muted }), + /* @__PURE__ */ (0, jsx)(Text, { style: { flex: 1, fontFamily: "Inter-Regular", fontSize: 22, color: colors.fg }, children: row.title }), + /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 20, color: row.value ? colors.fg : colors.subtle, numberOfLines: 1, ellipsizeMode: "head" }, children: row.value || t2("add.notSet") }) + ] + }, row.field)), + /* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row", gap: 12, paddingTop: 4 }, children: /* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.close"), variant: "outline", size: "sm", onPress: onClose }) }) + ] }) }) + }); + } + + // Picks the service a magnet goes to, when both are set up. + function RouteDialog({ value, routes, onPick, onClose }) { + const t2 = useT(); + const names = { torbox: t2("add.torboxName"), qbittorrent: t2("add.qbittorrentName") }; + return /* @__PURE__ */ (0, jsx)(FocusScope, { + trap: true, autoFocus: true, + onBack: () => { + onClose(); + return true; + }, + children: /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 50, alignItems: "center", justifyContent: "center", backgroundColor: "#000000f2" }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { width: 900, gap: 24, borderRadius: 18, borderWidth: 2, borderColor: colors.line, backgroundColor: colors.surface, padding: 44 }, children: [ + /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 32, fontWeight: 600, color: colors.fg }, children: t2("add.routeTitle") }), + /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, numberOfLines: 2, ellipsizeMode: "middle", lineHeight: 30 }, children: value }), + /* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row", gap: 12 }, children: routes.map((route) => /* @__PURE__ */ (0, jsx)(Button2, { + label: names[route], icon: route === "torbox" ? "archive" : "homebrew", size: "lg", + focusKey: `downloads:route:${route}`, onPress: () => onPick(route) + }, route)) }), + /* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row" }, children: /* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.cancel"), variant: "outline", size: "sm", onPress: onClose }) }) + ] }) }) + }); + } + + // Hands the link to the service and waits; the file joins the queue as soon as it names one. + function RunDialog({ route, value, onAdded, onClose }) { + const t2 = useT(); + const [state, setState] = useState({ progress: 0, error: "" }); + const token = useRef(null); + (0, useEffect)(() => { + const cancel = cancellation(); + token.current = cancel; + let live = true; + const settings = serviceSettings(); + const progress = (report) => { + if (live) setState((old) => ({ ...old, progress: report.progress })); + }; + void (async () => { + try { + const prepared = route === "torbox" + ? await createTorbox({ http: Http, key: settings.torbox }).prepare(value, { onProgress: progress, signal: cancel.signal }) + : await createQBittorrent({ http: Http, ...qbittorrentSettings() }).prepare(value, { onProgress: progress, signal: cancel.signal }); + if (!live) return; + const id = addLink(downloadQueue(), { ...prepared, title: prepared.filename, root: chosenRoot() }); + live = false; + onAdded(id); + } catch (error2) { + if (live) setState({ progress: 0, error: error2.message }); + } + })(); + return () => { + live = false; + cancel.cancel(); + }; + }, [route, value]); + + const service = route === "torbox" ? t2("add.torboxName") : t2("add.qbittorrentName"); + return /* @__PURE__ */ (0, jsx)(Waiting, { + title: t2("add.waitTitle", { service }), + detail: t2("add.waitDetail", { service }), + progress: state.progress, + error: state.error, + onCancel: () => { + if (token.current) token.current.cancel(); + onClose(); + }, + onClose + }); + } + + function AddDownloads({ onAdded }) { + const t2 = useT(); + const services = useServices(); + const { focusLater } = useNavActions(); + const routes = [services.torbox ? "torbox" : null, services.address ? "qbittorrent" : null].filter(Boolean); + const [dialog, setDialog] = useState(null); + // { kind: "link" | "torrent" | "torbox" }, { kind: "route", value }, { kind: "run", route, value }, + // { kind: "services" }, { kind: "field", field, value } + // The URL dialog closes itself once its submit returns, so a submit that opens the next dialog + // says so here: that close must not wipe what it opened. + const opening = useRef(false); + const show = (value) => { + opening.current = true; + setDialog(value); + }; + const close = () => { + if (opening.current) { + opening.current = false; + return; + } + setDialog(null); + }; + + const add = (file) => { + const id = addLink(downloadQueue(), { url: file.url, filename: file.filename, headers: file.headers, sizeBytes: file.sizeBytes, title: file.filename, root: chosenRoot() }); + if (onAdded) onAdded(id); + else focusLater(primaryKey(id)); + }; + + // A magnet pasted anywhere goes through a service: the queue cannot fetch one itself. + const begin = (kind, value) => { + const parsed = linkInput(value); + if (kind !== "torrent" && parsed.kind === "magnet") kind = "torrent"; + if (kind === "torrent") { + // A magnet is for either service; a file ending in .torrent is the client's own kind of + // link, which TorBox has no call for. + const torrent = /\.torrent(?:[?#]|$)/i.test(parsed.value); + if (parsed.kind !== "magnet" && !torrent) throw new Error(t2("add.magnetOnly")); + const usable = parsed.kind === "magnet" ? routes : routes.filter((route) => route === "qbittorrent"); + if (!usable.length) { + if (routes.length) throw new Error(t2("add.torrentLinkNeedsClient")); + show({ kind: "services" }); + return; + } + if (usable.length > 1) { + show({ kind: "route", value: parsed.value, routes: usable }); + return; + } + show({ kind: "run", route: usable[0], value: parsed.value }); + return; + } + if (kind === "torbox") { + if (/\.torrent(?:[?#]|$)/i.test(parsed.value) && parsed.kind !== "magnet") throw new Error(t2("add.torrentLinkNeedsClient")); + if (!services.torbox) { + show({ kind: "services" }); + return; + } + show({ kind: "run", route: "torbox", value: parsed.value }); + return; + } + // A plain link: the queue fetches it, through the host's own resolver when it knows the host. + add({ url: parsed.value, filename: parsed.name }); + }; + + const field = (name) => { + const values = { torbox: services.torbox, address: services.address, user: services.user, password: services.password }; + setDialog({ kind: "field", field: name, value: values[name] || "" }); + }; + + return /* @__PURE__ */ (0, jsxs)(Fragment, { children: [ + /* @__PURE__ */ (0, jsxs)(View, { style: { paddingHorizontal: 48, paddingBottom: 30, gap: 14 }, children: [ + /* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 12 }, children: [ + /* @__PURE__ */ (0, jsx)(Text, { style: { flex: 1, fontFamily: "Inter-Regular", fontSize: 20, fontWeight: 600, color: colors.subtle, letterSpacing: 1 }, children: t2("add.title") }), + /* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.setup"), icon: "settings", variant: "ghost", size: "sm", focusKey: "downloads:add:setup", onPress: () => setDialog({ kind: "services" }) }) + ] }), + /* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", gap: 14 }, children: [ + /* @__PURE__ */ (0, jsx)(AddCard, { icon: "globe", title: t2("add.link"), detail: t2("add.linkDetail"), focusKey: "downloads:add:link", onPress: () => setDialog({ kind: "link" }) }), + /* @__PURE__ */ (0, jsx)(AddCard, { icon: "layers", title: t2("add.torrent"), detail: routes.length ? t2("add.torrentDetail") : t2("add.torrentSetup"), focusKey: "downloads:add:torrent", muted: !routes.length, onPress: () => setDialog({ kind: "torrent" }) }), + /* @__PURE__ */ (0, jsx)(AddCard, { icon: "archive", title: t2("add.torbox"), detail: services.torbox ? t2("add.torboxDetail") : t2("add.torboxSetup"), focusKey: "downloads:add:torbox", muted: !services.torbox, onPress: () => setDialog({ kind: "torbox" }) }) + ] }) + ] }), + dialog && dialog.kind === "link" ? /* @__PURE__ */ (0, jsx)(URLDialog, { + title: t2("add.linkTitle"), detail: t2("add.linkBody"), initial: "", + submit: (value) => begin("link", value), onClose: close + }) : null, + dialog && dialog.kind === "torrent" ? /* @__PURE__ */ (0, jsx)(URLDialog, { + title: t2("add.torrentTitle"), detail: t2("add.torrentBody"), initial: "magnet:?xt=urn:btih:", + submit: (value) => begin("torrent", value), onClose: close + }) : null, + dialog && dialog.kind === "torbox" ? /* @__PURE__ */ (0, jsx)(URLDialog, { + title: t2("add.torboxTitle"), detail: t2("add.torboxBody"), initial: "", + submit: (value) => begin("torbox", value), onClose: close + }) : null, + dialog && dialog.kind === "field" ? /* @__PURE__ */ (0, jsx)(URLDialog, { + title: t2(`add.field.${dialog.field}`), detail: t2(`add.fieldBody.${dialog.field}`), initial: dialog.value, + // This dialog's own close already goes back to the list of settings, so it needs no flag. + submit: (value) => { + setService(dialog.field, value); + setDialog({ kind: "services" }); + }, + onClose: () => setDialog({ kind: "services" }) + }) : null, + dialog && dialog.kind === "services" ? /* @__PURE__ */ (0, jsx)(ServicesDialog, { onClose: () => setDialog(null), onField: field }) : null, + dialog && dialog.kind === "route" ? /* @__PURE__ */ (0, jsx)(RouteDialog, { + value: dialog.value, routes: dialog.routes ?? routes, + onPick: (route) => setDialog({ kind: "run", route, value: dialog.value }), + onClose: () => setDialog(null) + }) : null, + dialog && dialog.kind === "run" ? /* @__PURE__ */ (0, jsx)(RunDialog, { + route: dialog.route, value: dialog.value, + onAdded: (id) => { + setDialog(null); + focusLater(primaryKey(id)); + }, + onClose: () => setDialog(null) + }) : null + ] }); + } + +export { AddDownloads }; diff --git a/app/downloads/DownloadsScreen.jsx b/app/downloads/DownloadsScreen.jsx index 7c916edc..ed77372e 100644 --- a/app/downloads/DownloadsScreen.jsx +++ b/app/downloads/DownloadsScreen.jsx @@ -4,6 +4,7 @@ import '../assets/Inter-Regular.ttf'; import { jsx, jsxs } from 'react/jsx-runtime'; import { useEffect, useRef, useState } from 'react'; +import { AddDownloads } from './AddDownloads.jsx'; import { DownloadRow, primaryKey } from './DownloadRow.jsx'; import { SummaryPanel } from './SummaryPanel.jsx'; import { useJobs } from './useQueue.js'; @@ -132,6 +133,8 @@ import { FocusScope, ScrollView, Text, View } from '@ps5-react/core'; ] }), /* @__PURE__ */ (0, jsx)(Text, { style: { width: 420, fontFamily: "Inter-Regular", fontSize: 21, color: "#f2f2f3", numberOfLines: 2, ellipsizeMode: "tail", lineHeight: 30 }, children: failure }) ] }), + /* Downloads of the user's own, beside the catalog's (issues #107, #89, #20). */ + /* @__PURE__ */ (0, jsx)(AddDownloads, {}), /* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, flexDirection: "row", gap: 48, paddingHorizontal: 48 }, children: [ jobs.length > 0 ? /* @__PURE__ */ (0, jsx)(ScrollView, { style: { flex: 1 }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 60, paddingBottom: 60 }, children: [ /* @__PURE__ */ (0, jsx)(Section, { title: "downloads.section.active", jobs: active2, first, handlers }), diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 28d0c1fb..b1724b39 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -1,4 +1,45 @@ { + "add.address": "qBittorrent address", + "add.cancel": "Cancel", + "add.close": "Close", + "add.field.address": "qBittorrent address", + "add.field.password": "qBittorrent password", + "add.field.torbox": "TorBox API key", + "add.field.user": "qBittorrent user", + "add.fieldBody.address": "The WebUI's address as host:port, for example 192.168.1.10:8080.", + "add.fieldBody.password": "The WebUI's password. Leave it empty when the WebUI asks for none.", + "add.fieldBody.torbox": "Your own key, from torbox.app under Settings then API. Each user enters their own.", + "add.fieldBody.user": "The WebUI's user name.", + "add.link": "From a link", + "add.linkBody": "A file's own address, or a page of a host Overdrive already handles (VikingFile, DataNodes, FileKeeper). It downloads like a catalog file.", + "add.linkDetail": "An address you already have", + "add.linkTitle": "Paste a link", + "add.magnetOnly": "That is not a magnet link or a .torrent link. Paste one or the other.", + "add.notSet": "Not set", + "add.password": "Password", + "add.percent": "{percent}%", + "add.qbittorrentName": "qBittorrent", + "add.routeTitle": "Download it with", + "add.servicesDetail": "Each user enters their own. They stay on this console, in settings.json.", + "add.servicesTitle": "Download services", + "add.setup": "Set up", + "add.title": "ADD A DOWNLOAD", + "add.torbox": "TorBox", + "add.torboxBody": "A link or a magnet. TorBox fetches it on its servers, and the console downloads the file from there.", + "add.torboxDetail": "A link or a magnet, through TorBox", + "add.torboxKey": "TorBox API key", + "add.torboxName": "TorBox", + "add.torboxSetup": "Needs its API key", + "add.torboxTitle": "TorBox", + "add.torrent": "From a torrent", + "add.torrentBody": "Paste a magnet, or a link ending in .torrent. Your qBittorrent or TorBox fetches it, and the console downloads the finished file from there.", + "add.torrentDetail": "A magnet or a .torrent link, through your client or TorBox", + "add.torrentLinkNeedsClient": "A .torrent link needs qBittorrent: TorBox takes magnets only.", + "add.torrentSetup": "Set up a client or TorBox", + "add.torrentTitle": "From a torrent", + "add.user": "User", + "add.waitDetail": "{service} is fetching the file. The download starts here as soon as it is ready.", + "add.waitTitle": "Waiting for {service}", "browse.addToFavorites": "Add to favorites", "browse.continueDownloading": "Continue downloading", "browse.continueFailed": "Failed \u00B7 open to retry", diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index ee2bdbbf..fcc05ce5 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -1,4 +1,45 @@ { + "add.address": "Indirizzo qBittorrent", + "add.cancel": "Annulla", + "add.close": "Chiudi", + "add.field.address": "Indirizzo qBittorrent", + "add.field.password": "Password qBittorrent", + "add.field.torbox": "Chiave API TorBox", + "add.field.user": "Utente qBittorrent", + "add.fieldBody.address": "L'indirizzo della WebUI come host:porta, per esempio 192.168.1.10:8080.", + "add.fieldBody.password": "La password della WebUI. Lasciala vuota se la WebUI non ne chiede.", + "add.fieldBody.torbox": "La tua chiave, da torbox.app in Impostazioni e poi API. Ognuno inserisce la sua.", + "add.fieldBody.user": "Il nome utente della WebUI.", + "add.link": "Da un link", + "add.linkBody": "L'indirizzo di un file, o la pagina di un host che Overdrive già conosce (VikingFile, DataNodes, FileKeeper). Si scarica come un file del catalogo.", + "add.linkDetail": "Un indirizzo che hai già", + "add.linkTitle": "Incolla un link", + "add.magnetOnly": "Non è un magnet né un link .torrent. Incollane uno dei due.", + "add.notSet": "Non impostato", + "add.password": "Password", + "add.percent": "{percent}%", + "add.qbittorrentName": "qBittorrent", + "add.routeTitle": "Con cosa scaricarlo", + "add.servicesDetail": "Ognuno inserisce i suoi. Restano su questa console, in settings.json.", + "add.servicesTitle": "Servizi di download", + "add.setup": "Configura", + "add.title": "AGGIUNGI UN DOWNLOAD", + "add.torbox": "TorBox", + "add.torboxBody": "Un link o un magnet. TorBox lo scarica sui suoi server e la console prende il file da lì.", + "add.torboxDetail": "Un link o un magnet, tramite TorBox", + "add.torboxKey": "Chiave API TorBox", + "add.torboxName": "TorBox", + "add.torboxSetup": "Serve la sua chiave API", + "add.torboxTitle": "TorBox", + "add.torrent": "Da un torrent", + "add.torrentBody": "Incolla un magnet, o un link che finisce in .torrent. Il tuo qBittorrent o TorBox lo scarica, e la console prende il file finito da lì.", + "add.torrentDetail": "Un magnet o un link .torrent, col tuo client o TorBox", + "add.torrentLinkNeedsClient": "Un link .torrent richiede qBittorrent: TorBox prende solo magnet.", + "add.torrentSetup": "Configura un client o TorBox", + "add.torrentTitle": "Da un torrent", + "add.user": "Utente", + "add.waitDetail": "{service} sta recuperando il file. Il download parte qui appena è pronto.", + "add.waitTitle": "In attesa di {service}", "browse.addToFavorites": "Aggiungi ai preferiti", "browse.continueDownloading": "Continua il download", "browse.continueFailed": "Non riuscito · apri per riprovare", diff --git a/app/state/download-queue.js b/app/state/download-queue.js index df4a20b1..86eaa109 100644 --- a/app/state/download-queue.js +++ b/app/state/download-queue.js @@ -510,7 +510,9 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad job2.options = formats.manifest(await response.json()); } else { if (!job2.release.url && !job2.release.browserUrl) throw new Error("This release has no download URL."); - const resolved = job2.directURL || sourceKind(job2.release) !== "direct" ? await sources.resolve(job2.release, { signal, directURL: job2.directURL }) : { url: webURL(job2.release.url).url }; + // A pasted link may come from a service of the user's own (a qBittorrent's WebUI), whose + // file is behind its password: the release carries the headers for it (link-download.js). + const resolved = job2.directURL || sourceKind(job2.release) !== "direct" ? await sources.resolve(job2.release, { signal, directURL: job2.directURL }) : { url: webURL(job2.release.url).url, headers: job2.release.headers }; job2.options = { ...resolved, rejectHtml: true, @@ -660,7 +662,8 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad // are none Overdrive knows, the host sent something else, usually a web page. Leaving it as a // downloaded ".binary" (issue #8) told the user nothing. Only a small file reads as one, though: // gigabytes the headers don't name are still the game, in a format not checked (issue #24). - if (!kind && job2.release.format === "binary" && (fs.stat(job2.downloadDestination)?.size || 0) < WEB_PAGE_BYTES) throw new Error("Not a game file: the host sent something else."); + // A pasted link is the user's own file, not a catalog entry: whatever it is, it is kept. + if (!kind && !job2.release.link && job2.release.format === "binary" && (fs.stat(job2.downloadDestination)?.size || 0) < WEB_PAGE_BYTES) throw new Error("Not a game file: the host sent something else."); const named = kind ? null : namedKind(job2); if (!kind && !named) return; if (named) console.log(`[download-queue] headers name no format; the file's name says ${named}`); diff --git a/app/state/download-services.js b/app/state/download-services.js new file mode 100644 index 00000000..cef44e89 --- /dev/null +++ b/app/state/download-services.js @@ -0,0 +1,72 @@ +// The services a pasted link or magnet can be handed to (issues #20 and #89): TorBox's debrid API, +// and a qBittorrent WebUI on the home network. Both need the user's own credentials, entered once on +// the console and kept in settings.json beside the speed limit. Nothing here has a default: an empty +// field means the service is not set up, and the Downloads page offers the setup instead. + +import { useEffect, useState } from 'react'; +import { loadList, saveList } from './saved.js'; + + var SETTINGS = "settings.json"; + var FIELDS = { torbox: "torboxKey", address: "qbittorrentAddress", user: "qbittorrentUser", password: "qbittorrentPassword" }; + + // "192.168.1.10:8080", "http://192.168.1.10:8080/" and "https://host/qbittorrent" are all an + // address; what is not one (no host, a space) is refused before it is saved. + function serviceAddress(value) { + let text = String(value ?? "").trim(); + if (!text) return ""; + if (!/^https?:\/\//i.test(text)) text = `http://${text}`; + text = text.replace(/\/+$/, ""); + if (!/^https?:\/\/[a-z0-9.-]+(:\d{1,5})?(\/[a-z0-9._~%!$&'()*+,;=:@/-]*)?$/i.test(text)) + throw new Error("Enter the WebUI address as host:port, for example 192.168.1.10:8080."); + return text; + } + + function read2() { + const settings = loadList(SETTINGS, "settings"); + const text = (key) => typeof settings[key] === "string" ? settings[key] : ""; + return { torbox: text(FIELDS.torbox).trim(), address: text(FIELDS.address).trim(), user: text(FIELDS.user).trim(), password: text(FIELDS.password) }; + } + + var current = read2(); + var listeners = new Set(); + + function services() { + return current; + } + + /** Saves one service field ("torbox", "address", "user", "password"); null or "" clears it. */ + function setService(field, value) { + if (!(field in FIELDS)) throw new Error("Unknown download service field."); + let next = field === "address" ? serviceAddress(value) : String(value ?? "").trim(); + if (field === "password") next = String(value ?? ""); + if (next === current[field]) return; + const { [FIELDS[field]]: _old, ...settings } = loadList(SETTINGS, "settings"); + saveList(SETTINGS, "settings", next ? { ...settings, [FIELDS[field]]: next } : settings); + current = { ...current, [field]: next }; + for (const listener of listeners) listener(current); + } + + /** A qBittorrent is set up with an address; a user and password only when its WebUI asks. */ + function qbittorrent() { + if (!current.address) return null; + return { address: current.address, username: current.user, password: current.password }; + } + + function torbox() { + return current.torbox || null; + } + + function useServices() { + const [state, setState] = useState(current); + useEffect(() => { + const listener = (next) => setState(next); + listeners.add(listener); + setState(current); + return () => { + listeners.delete(listener); + }; + }, []); + return state; + } + +export { qbittorrent, serviceAddress, services, setService, torbox, useServices }; diff --git a/app/state/download-services.test.mjs b/app/state/download-services.test.mjs new file mode 100644 index 00000000..ae7a8b56 --- /dev/null +++ b/app/state/download-services.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import {register} from 'node:module'; +import test from 'node:test'; + +register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url); +const {serviceAddress} = await import('./download-services.js'); + +// The setup the Downloads page offers (issues #20, #89): each user's own key and client address. +// An address is saved as a URL the clients can call, and refused before it is if it is not one. +test('an address is saved with a scheme and no trailing slash', () => { + assert.equal(serviceAddress('192.168.1.10:8080'), 'http://192.168.1.10:8080'); + assert.equal(serviceAddress('http://192.168.1.10:8080/'), 'http://192.168.1.10:8080'); + assert.equal(serviceAddress('https://nas.local/qbittorrent/'), 'https://nas.local/qbittorrent'); + assert.equal(serviceAddress(' http://10.0.0.2:8080 '), 'http://10.0.0.2:8080'); +}); + +test('an empty address clears the setup instead of failing', () => { + assert.equal(serviceAddress(''), ''); + assert.equal(serviceAddress(null), ''); +}); + +test('what is not an address is refused', () => { + assert.throws(() => serviceAddress('two words'), /host:port/); + assert.throws(() => serviceAddress('ftp://host:21'), /host:port/); + assert.throws(() => serviceAddress('http://'), /host:port/); +}); diff --git a/app/state/download-sources.js b/app/state/download-sources.js index 5cfbeb8f..28d4edc6 100644 --- a/app/state/download-sources.js +++ b/app/state/download-sources.js @@ -125,7 +125,9 @@ const url = release.browserUrl || release.url; const kind = sourceKind(release); if (kind === "unavailable") throw new Error("Source unavailable: no download URL."); - if (kind === "direct") return { url: webURL(url).url, rejectHtml: true }; + // A pasted link may be behind the password of the service it came from (a qBittorrent WebUI): + // the release then carries the headers to send with it. + if (kind === "direct") return { url: webURL(url).url, rejectHtml: true, headers: release.headers }; if (kind === "manifest") throw new Error("Split manifests must be resolved by the queue."); // Buzzheavier's pages sit behind a Cloudflare check only a browser passes (403 to the app, // 2026-10-17): its file link is captured from the console's browser, as Datanodes' is. diff --git a/app/state/link-download.js b/app/state/link-download.js new file mode 100644 index 00000000..d3acd070 --- /dev/null +++ b/app/state/link-download.js @@ -0,0 +1,154 @@ +// A download that starts from a link the user pasted (issue #107), not from the catalog: a direct +// file, a host page the app already resolves, or a file another service prepared (TorBox, a +// qBittorrent on the home network). The link becomes a game and a release the queue accepts as any +// other, so it queues, pauses, resumes, installs and deletes like a catalog download. +// +// A pasted file names no game: the release carries `link`, the queue keeps it out of the game +// checks that would reject a file it does not know (a text file, a video), and the row shows the +// file's name as its title. + +import { webURL } from './download-sources.js'; + + // The formats download-formats.js routes by name, in its own order (an image, then a package, + // then an archive), so the format this module derives is the one that accepts the file's name. + var IMAGES2 = ["ffpkg", "exfat", "ffpfs", "ffpfsc"]; + var ARCHIVES2 = ["zip", "rar", "7z", "tar", "tar.gz", "tgz", "tar.bz2", "tar.xz", "tar.zst"]; + var NAMED2 = [...IMAGES2, "pkg", "fpkg", ...ARCHIVES2]; + var MAX_NAME = 200; + var FALLBACK = "download"; + + function decode2(value) { + try { + return decodeURIComponent(value); + } catch { + return value; + } + } + + // A file name taken from a link: no separators, no control characters, and never so long that the + // destination path does not fit. The extension stays, since it is what the format is read from. + function fileName(value) { + let name = String(value ?? "").replace(/[\\/\x00-\x1f]/g, "_").trim(); + if (!name || name === "." || name === "..") return ""; + if (name.length > MAX_NAME) { + const extension = /(\.[a-z0-9]{1,8})$/i.exec(name)?.[1] ?? ""; + name = name.slice(0, MAX_NAME - extension.length) + extension; + } + return name; + } + + // The format the file name declares: "pkg" for an .fpkg (download-formats.js treats them alike), + // otherwise the extension, and "binary" for a name it does not know. + function formatOf(name) { + const lower = String(name ?? "").toLowerCase(); + const found = NAMED2.find((extension) => lower.endsWith(`.${extension}`)); + if (!found) return "binary"; + return found === "fpkg" ? "pkg" : found; + } + + function query2(value) { + const text = String(value); + const start = text.indexOf("?"); + if (start < 0) return {}; + const params = {}; + for (const pair of text.slice(start + 1).split("&")) { + const at = pair.indexOf("="); + if (at <= 0) continue; + const key = decode2(pair.slice(0, at)); + if (!(key in params)) params[key] = decode2(pair.slice(at + 1)); + } + return params; + } + + // The name a URL gives its file: its last path segment when that names a format, else a + // `file=`/`filename=`/`name=` in the query (a gateway's own link), else the segment as it is. + function fileNameOf(value) { + const text = String(value); + const path = text.split(/[?#]/)[0]; + const direct = fileName(decode2(path.slice(path.lastIndexOf("/") + 1))); + if (direct && formatOf(direct) !== "binary") return direct; + const params = query2(text); + for (const key of ["file", "filename", "name"]) { + const candidate = fileName(params[key]); + if (candidate && formatOf(candidate) !== "binary") return candidate; + } + return direct; + } + + // A BitTorrent infohash as 40 hex digits: a v2 magnet or a base32 v1 one is converted, since + // qBittorrent lists torrents by their hex hash. + var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + function base32Hex(text) { + if (!/^[a-z2-7]{32}$/i.test(text)) return ""; + let bits = ""; + for (const character of text.toUpperCase()) { + const value = BASE32.indexOf(character); + if (value < 0) return ""; + bits += value.toString(2).padStart(5, "0"); + } + let hex = ""; + for (let i = 0; i + 4 <= bits.length; i += 4) hex += parseInt(bits.slice(i, i + 4), 2).toString(16); + return hex; + } + + // A magnet link's own parts: the link as pasted, its infohash when it names one, and its name. + function magnetInfo(value) { + const text = String(value ?? "").trim(); + if (!/^magnet:\?/i.test(text)) return null; + const params = query2(text); + const urn = /^urn:btih:([a-z0-9]{32,40})$/i.exec(params.xt || "")?.[1] ?? ""; + const hash = urn.length === 32 ? base32Hex(urn) : urn.toLowerCase(); + return { magnet: text, hash, name: fileName(params.dn) }; + } + + // What the user pasted, checked: a magnet link, or an http(s) URL the queue can fetch. + function linkInput(value) { + const text = String(value ?? "").trim(); + if (!text) throw new Error("Enter a link or a magnet."); + const magnet = magnetInfo(text); + if (magnet) return { kind: "magnet", value: text, hash: magnet.hash, name: magnet.name }; + webURL(text); + return { kind: "link", value: text, name: fileNameOf(text) }; + } + + // A short stable id from the link, so pasting the same link twice finds the same job instead of + // downloading the file a second time under another name. + function linkId(value) { + let hash = 2166136261; + const text = String(value); + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); + } + + var LINK_GAME = "link"; + function linkGame(title) { + return { id: LINK_GAME, title: title || FALLBACK, art: {}, releases: [] }; + } + + // The release the queue downloads: its name, its link, the format its name declares, and the + // headers a service needs for it (a qBittorrent's own download link is behind its password). + function linkRelease({ url, filename, headers, sizeBytes, title } = {}) { + const address = webURL(url).url; + const name = fileName(filename) || fileNameOf(address) || FALLBACK; + return { + id: `link-${linkId(address)}`, + filename: name, + url: address, + format: formatOf(name), + sizeBytes: Number.isSafeInteger(sizeBytes) && sizeBytes > 0 ? sizeBytes : 0, + title: title || name, + link: true, + ...headers && Object.keys(headers).length ? { headers } : {} + }; + } + + /** Queues a pasted link (or a file a service prepared) on the drive the user chose. */ + function addLink(queue, { url, filename, headers, sizeBytes, title, root } = {}) { + const release = linkRelease({ url, filename, headers, sizeBytes, title }); + return queue.enqueue(linkGame(title || release.title), release, root); + } + +export { addLink, base32Hex, fileName, fileNameOf, formatOf, linkGame, linkId, linkInput, linkRelease, magnetInfo }; diff --git a/app/state/link-download.test.mjs b/app/state/link-download.test.mjs new file mode 100644 index 00000000..7b406844 --- /dev/null +++ b/app/state/link-download.test.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import {register} from 'node:module'; +import test from 'node:test'; + +register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url); +const {addLink, base32Hex, fileName, fileNameOf, formatOf, linkId, linkInput, linkRelease, magnetInfo, linkGame} = await import('./link-download.js'); +const {DownloadFormats} = await import('../../ps5-react/runtime/js/download-formats.js'); + +// Issue #107: a pasted link becomes a release the queue accepts. The format this module derives has +// to be the one download-formats.js routes by, or the queue refuses the file before downloading it. +const NAMES = ['game.pkg', 'game.PKG', 'game.fpkg', 'game.zip', 'game.rar', 'game.7z', 'game.tar', + 'game.tar.gz', 'game.exfat', 'game.ffpkg', 'game.ffpfsc', 'movie.mp4', 'notes.txt', 'README', 'a.b.c.bin']; + +test('every name the app can meet routes through download-formats', () => { + for (const name of NAMES) { + const release = linkRelease({url: 'https://example.com/f/' + name, filename: name}); + const artifact = DownloadFormats.artifact(release, '/data'); + assert.equal(artifact.destination, `${artifact.directory}/${name}`, name); + assert.ok(artifact.destination.startsWith('/data/'), name); + } +}); + +test('a package goes to downloads/packages, an archive to archives, anything else to files', () => { + const where = name => DownloadFormats.artifact(linkRelease({url: 'https://example.com/x/' + name, filename: name}), '/data').directory; + assert.equal(where('game.pkg'), '/data/downloads/packages'); + assert.equal(where('game.fpkg'), '/data/downloads/packages'); + assert.equal(where('game.zip'), '/data/downloads/archives'); + assert.equal(where('movie.mp4'), '/data/downloads/files'); + assert.equal(where('game.exfat'), '/data/homebrew'); + assert.equal(formatOf('movie.mp4'), 'binary'); +}); + +test('a file name from a link loses what a file system will not take', () => { + assert.equal(fileName('a/b'), 'a_b'); + assert.equal(fileName('a\\b'), 'a_b'); + assert.equal(fileName('game\u0000.pkg'), 'game_.pkg'); + assert.equal(fileName('..'), ''); + assert.equal(fileName('.pkg'), '.pkg'.length > 1 ? '.pkg' : '.pkg'); + const long = 'x'.repeat(400) + '.pkg'; + assert.equal(fileName(long).length, 200); + assert.ok(fileName(long).endsWith('.pkg')); +}); + +test('the name a URL gives its file, query included', () => { + assert.equal(fileNameOf('https://host/files/Game.PKG'), 'Game.PKG'); + assert.equal(fileNameOf('https://host/files/game%20one.zip?token=1'), 'game one.zip'); + assert.equal(fileNameOf('https://host/dld/536f032d?token=abc'), '536f032d'); + assert.equal(fileNameOf('https://host/download?file=game.rar'), 'game.rar'); + assert.equal(fileNameOf('https://host/'), ''); +}); + +test('a magnet names its hash, hex or base32, and its file', () => { + const hex = magnetInfo('magnet:?xt=urn:btih:7ACF8FB590B2060DD9C3146EF770169D593433B0&dn=Debian.ISO'); + assert.equal(hex.hash, '7acf8fb590b2060dd9c3146ef770169d593433b0'); + assert.equal(hex.name, 'Debian.ISO'); + const base = magnetInfo('magnet:?xt=urn:btih:PLHY7NMQWIDA3WODCRXPO4AWTVMTIM5Q'); + assert.equal(base.hash, '7acf8fb590b2060dd9c3146ef770169d593433b0'); + assert.equal(magnetInfo('https://host/file.pkg'), null); + assert.equal(base32Hex('PLHY7NMQWIDA3WODCRXPO4AWTVMTIM5Q'), '7acf8fb590b2060dd9c3146ef770169d593433b0'); +}); + +test('what the user pasted is checked before anything is queued', () => { + assert.equal(linkInput(' https://host/a/b/game.pkg ').kind, 'link'); + assert.equal(linkInput('magnet:?xt=urn:btih:7acf8fb590b2060dd9c3146ef770169d593433b0').kind, 'magnet'); + assert.throws(() => linkInput(''), /link or a magnet/); + assert.throws(() => linkInput('ftp://host/file.pkg'), /Invalid HTTP URL/); + assert.throws(() => linkInput('host/file.pkg'), /Invalid HTTP URL/); +}); + +test('the same link is the same job, another link is another', () => { + assert.equal(linkId('https://host/a.pkg'), linkId('https://host/a.pkg')); + assert.notEqual(linkId('https://host/a.pkg'), linkId('https://host/b.pkg')); +}); + +test('a release carries the service headers and nothing it does not need', () => { + const plain = linkRelease({url: 'https://host/game.pkg', filename: 'game.pkg'}); + assert.equal(plain.format, 'pkg'); + assert.equal(plain.link, true); + assert.equal('headers' in plain, false); + const guarded = linkRelease({url: 'http://192.168.1.10:8080/api/v2/torrents/downloadFile?hash=a&file=b.pkg', filename: 'game.pkg', headers: {Authorization: 'Basic x'}, sizeBytes: 12}); + assert.deepEqual(guarded.headers, {Authorization: 'Basic x'}); + assert.equal(guarded.sizeBytes, 12); +}); + +test('queueing a link calls the queue it is given, on the drive it is given', () => { + const seen = []; + const queue = { + enqueue(game, release, root) { + seen.push({game, release, root}); + return `${game.id}/${release.id}`; + } + }; + const id = addLink(queue, {url: 'https://host/x/game.pkg', root: '/mnt/usb0'}); + assert.equal(id, 'link/link-' + linkId('https://host/x/game.pkg')); + assert.equal(seen[0].root, '/mnt/usb0'); + assert.equal(seen[0].game.title, 'game.pkg'); + assert.equal(seen[0].release.filename, 'game.pkg'); + assert.equal(seen[0].release.link, true); + assert.deepEqual(linkGame('x').releases, []); +}); diff --git a/app/state/qbittorrent.js b/app/state/qbittorrent.js new file mode 100644 index 00000000..a401f735 --- /dev/null +++ b/app/state/qbittorrent.js @@ -0,0 +1,157 @@ +// qBittorrent on the home network (issue #89): the user's own client fetches the torrent, and the +// WebUI hands the finished file over HTTP, so the console downloads it like any other link. +// +// GET /api/v2/app/webapiVersion the WebUI's version +// POST /api/v2/torrents/add urls= adds the torrent +// GET /api/v2/torrents/info?hashes= progress of the torrent +// GET /api/v2/torrents/files?hash= the torrent's files +// GET /api/v2/torrents/downloadFile?hash=&file= the file itself (WebAPI 2.16.0 and up) +// +// Everything is behind the WebUI's password: HTTP Basic, which the WebUI accepts from 2.15.0, the +// same versions that have downloadFile. + +import { magnetInfo } from './link-download.js'; + + var NEEDED = [2, 16, 0]; + var WAIT_MS = 30 * 60e3; + var FIRST_MS = 2e3; + var THEN_MS = 5e3; + var BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + // Base64 of a "user:password", in UTF-8; QuickJS has no btoa to rely on. + function base64(text) { + const bytes = []; + const value = String(text); + for (let i = 0; i < value.length; i++) { + let code = value.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length) { + code = 0x10000 + ((code - 0xd800) << 10) + (value.charCodeAt(++i) - 0xdc00); + } + if (code < 0x80) bytes.push(code); + else if (code < 0x800) bytes.push(0xc0 | code >> 6, 0x80 | code & 63); + else if (code < 0x10000) bytes.push(0xe0 | code >> 12, 0x80 | code >> 6 & 63, 0x80 | code & 63); + else bytes.push(0xf0 | code >> 18, 0x80 | code >> 12 & 63, 0x80 | code >> 6 & 63, 0x80 | code & 63); + } + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const chunk = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); + out += BASE64[chunk >> 18 & 63] + BASE64[chunk >> 12 & 63]; + out += i + 1 < bytes.length ? BASE64[chunk >> 6 & 63] : "="; + out += i + 2 < bytes.length ? BASE64[chunk & 63] : "="; + } + return out; + } + + function version(parts) { + return parts.map((part, index) => Number(part) * Math.pow(1000, 2 - index)).reduce((sum, part) => sum + part, 0); + } + + // The file to download: the largest one that is complete, so a release's own .nfo, a sample or a + // second episode is passed over. + function pickFile(files) { + if (!Array.isArray(files)) return null; + let best = null; + for (const file of files) { + if (!file || typeof file.name !== "string" || !file.name) continue; + if (Number(file.progress ?? 1) < 1) continue; + const size = Number(file.size) || 0; + if (!best || size > (Number(best.size) || 0)) best = file; + } + return best; + } + + function baseName(value) { + const text = String(value ?? ""); + return text.slice(text.lastIndexOf("/") + 1) || text; + } + + function createQBittorrent({ http, address, username = "", password = "", wait = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)), now = Date.now, timeoutMs = WAIT_MS }) { + if (!address) throw new Error("qBittorrent is not set up."); + const base = String(address).replace(/\/+$/, ""); + const headers = username || password + ? { Authorization: `Basic ${base64(`${username}:${password}`)}`, Referer: base } + : { Referer: base }; + + async function request(path, { method = "GET", form, signal, binary = false } = {}) { + const response = await http.request(base + path, { + method, + signal, + maxBytes: binary ? 1024 : 4 * 1024 * 1024, + headers: { ...headers, ...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {}) }, + ...(form ? { body: form } : {}) + }); + if (response.status === 401 || response.status === 403) + throw new Error("qBittorrent refused the login. Check its user and password in the download settings."); + if (response.status === 404 && path.startsWith("/api/v2/torrents/downloadFile")) + throw new Error("This qBittorrent cannot hand files over. Update it to a version with WebAPI 2.16.0 or newer."); + if (!response.ok && response.status !== 200) throw new Error(`qBittorrent answered HTTP ${response.status}.`); + return response; + } + + async function webapi() { + const text = await (await request("/api/v2/app/webapiVersion")).text(); + const parts = String(text).trim().split(".").map((part) => Number(/^\d+/.exec(part)?.[0] ?? 0)); + if (!parts.length || version(parts) < version(NEEDED)) + throw new Error(`qBittorrent's WebUI is version ${String(text).trim() || "unknown"}; handing files over needs ${NEEDED.join(".")} or newer. Update qBittorrent.`); + return String(text).trim(); + } + + // Adds a magnet (or a link to a .torrent) and returns the hash to watch. A magnet names its own + // hash; the client's newest torrent is the one just added when the link was a .torrent file. + async function add(value, { signal } = {}) { + const text = String(value ?? "").trim(); + const magnet = magnetInfo(text); + if (!magnet && !/^https?:\/\//i.test(text)) throw new Error("Paste a magnet link, or a link to a .torrent file."); + const answer = String(await (await request("/api/v2/torrents/add", { method: "POST", form: `urls=${encodeURIComponent(text)}`, signal })).text()).trim(); + if (/fail/i.test(answer)) throw new Error("qBittorrent refused the torrent."); + if (magnet && magnet.hash.length === 40) return magnet.hash; + await wait(1e3); + const list = await (await request("/api/v2/torrents/info?sort=added_on&reverse=true&limit=1", { signal })).json(); + const newest = Array.isArray(list) ? list[0] : null; + if (!newest?.hash) throw new Error("qBittorrent added it but does not list it."); + return String(newest.hash).toLowerCase(); + } + + async function status(hash, { signal } = {}) { + const list = await (await request(`/api/v2/torrents/info?hashes=${hash}`, { signal })).json(); + const item = Array.isArray(list) ? list.find((entry) => String(entry.hash).toLowerCase() === hash) ?? list[0] : null; + if (!item) throw new Error("qBittorrent does not list the torrent any more."); + return item; + } + + // Waits for the torrent to finish, reporting progress as a fraction of its size. + async function downloaded(hash, { onProgress, signal } = {}) { + const end = now() + timeoutMs; + let pause = FIRST_MS; + for (;;) { + const item = await status(hash, { signal }); + const state = String(item.state ?? ""); + if (/^(error|missingFiles|unknown)$/i.test(state)) throw new Error(`qBittorrent stopped on it (${state}).`); + const progress = Number(item.progress) || 0; + if (progress >= 1) return item; + if (onProgress) onProgress({ progress, state }); + if (signal?.aborted) throw new Error("Cancelled."); + if (now() >= end) throw new Error("qBittorrent is still downloading it. Add it again later: the client keeps working on it."); + await wait(pause); + pause = THEN_MS; + } + } + + /** Adds the torrent, waits for it, and resolves to the file's own WebUI link. */ + async function prepare(input, { onProgress, signal } = {}) { + const version2 = await webapi(); + console.log(`[qbittorrent] WebAPI ${version2}; adding the torrent`); + const hash = await add(input, { signal }); + await downloaded(hash, { onProgress, signal }); + const files = await (await request(`/api/v2/torrents/files?hash=${hash}`, { signal })).json(); + const file = pickFile(files); + if (!file) throw new Error("qBittorrent has no finished file to download."); + const url = `${base}/api/v2/torrents/downloadFile?hash=${hash}&file=${encodeURIComponent(file.name)}`; + console.log(`[qbittorrent] ${hash}: ${baseName(file.name)} ready`); + return { url, filename: baseName(file.name), sizeBytes: Number(file.size) || 0, headers }; + } + + return { add, prepare, status, webapi }; + } + +export { base64, createQBittorrent, pickFile }; diff --git a/app/state/qbittorrent.test.mjs b/app/state/qbittorrent.test.mjs new file mode 100644 index 00000000..e9fa2b35 --- /dev/null +++ b/app/state/qbittorrent.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import {register} from 'node:module'; +import test from 'node:test'; + +register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url); +const {base64, createQBittorrent, pickFile} = await import('./qbittorrent.js'); + +// Issue #89: the user's own qBittorrent fetches the torrent and its WebUI hands the finished file +// over HTTP (torrents/downloadFile, WebAPI 2.16.0). Nothing here talks to a real client: the calls +// are the documented ones, replayed, so the console test is the one that proves the real client. + +const MAGNET = 'magnet:?xt=urn:btih:7acf8fb590b2060dd9c3146ef770169d593433b0&dn=Debian'; +const HASH = '7acf8fb590b2060dd9c3146ef770169d593433b0'; + +function server(routes) { + const calls = []; + return { + calls, + async request(url, options = {}) { + calls.push({url, options}); + const route = routes.find(entry => entry.match.test(url)); + if (!route) throw new Error('unexpected request: ' + url); + const answer = route.answer(url, options); + const status = answer.status ?? 200; + const body = typeof answer.body === 'string' ? answer.body : JSON.stringify(answer.body); + return {status, ok: status >= 200 && status < 300, url, headers: {}, text: async () => body, json: async () => JSON.parse(body)}; + }, + }; +} + +const noWait = async () => {}; +const client = (http, extra = {}) => createQBittorrent({http, address: 'http://192.168.1.10:8080', username: 'admin', password: 'p@ss', wait: noWait, ...extra}); + +test('a magnet is added, waited for, and its file is linked through the WebUI', async () => { + let polls = 0; + const http = server([ + {match: /\/api\/v2\/app\/webapiVersion$/, answer: () => ({body: '2.16.0'})}, + {match: /\/api\/v2\/torrents\/add$/, answer: () => ({body: 'Ok.'})}, + {match: /\/api\/v2\/torrents\/info\?hashes=/, answer: () => ({body: [{ + hash: HASH, name: 'Debian', progress: ++polls === 1 ? 0.4 : 1, state: polls === 1 ? 'downloading' : 'uploading', size: 900 + }]})}, + {match: /\/api\/v2\/torrents\/files\?hash=/, answer: () => ({body: [ + {index: 0, name: 'Debian/readme.nfo', size: 1024, progress: 1}, + {index: 1, name: 'Debian/debian.iso', size: 792723456, progress: 1}, + {index: 2, name: 'Debian/second.iso', size: 10, progress: 0.5}, + ]})}, + ]); + const progress = []; + const prepared = await client(http).prepare(MAGNET, {onProgress: report => progress.push(report.progress)}); + assert.deepEqual(progress, [0.4]); + assert.equal(prepared.filename, 'debian.iso'); + assert.equal(prepared.sizeBytes, 792723456); + assert.equal(prepared.url, 'http://192.168.1.10:8080/api/v2/torrents/downloadFile?hash=' + HASH + '&file=Debian%2Fdebian.iso'); + // The queue sends these with the file itself: the WebUI is behind a password. + assert.equal(prepared.headers.Authorization, 'Basic ' + base64('admin:p@ss')); + assert.equal(new URLSearchParams(http.calls[1].options.body).get('urls'), MAGNET); + assert.equal(http.calls[0].options.headers.Authorization, 'Basic ' + base64('admin:p@ss')); +}); + +test('a WebUI too old to hand files over says exactly that', async () => { + const http = server([{match: /webapiVersion$/, answer: () => ({body: '2.11.0'})}]); + await assert.rejects(client(http).prepare(MAGNET), /needs 2\.16\.0 or newer/); +}); + +test('a wrong login is named as a login', async () => { + const http = server([{match: /webapiVersion$/, answer: () => ({status: 403, body: 'Forbidden'})}]); + await assert.rejects(client(http).prepare(MAGNET), /refused the login/); +}); + +test('a .torrent link is followed by the client\'s newest torrent', async () => { + const http = server([ + {match: /webapiVersion$/, answer: () => ({body: '2.16.0'})}, + {match: /\/api\/v2\/torrents\/add$/, answer: () => ({body: 'Ok.'})}, + {match: /\/api\/v2\/torrents\/info\?sort=added_on/, answer: () => ({body: [{hash: 'abcdef', progress: 1}]})}, + {match: /\/api\/v2\/torrents\/info\?hashes=abcdef/, answer: () => ({body: [{hash: 'abcdef', progress: 1, state: 'uploading'}]})}, + {match: /\/api\/v2\/torrents\/files\?hash=abcdef/, answer: () => ({body: [{index: 0, name: 'game.pkg', size: 12, progress: 1}]})}, + ]); + const prepared = await client(http).prepare('https://tracker.example/game.torrent'); + assert.equal(prepared.url, 'http://192.168.1.10:8080/api/v2/torrents/downloadFile?hash=abcdef&file=game.pkg'); + assert.equal(prepared.filename, 'game.pkg'); +}); + +test('a torrent the client stopped on fails with its state', async () => { + const http = server([ + {match: /webapiVersion$/, answer: () => ({body: '2.16.0'})}, + {match: /torrents\/add$/, answer: () => ({body: 'Ok.'})}, + {match: /torrents\/info\?hashes=/, answer: () => ({body: [{hash: HASH, progress: 0.2, state: 'error'}]})}, + ]); + await assert.rejects(client(http).prepare(MAGNET), /stopped on it \(error\)/); +}); + +test('Basic auth encodes what the WebUI expects', () => { + assert.equal(base64('admin:p@ss'), 'YWRtaW46cEBzcw=='); + assert.equal(base64('user:pass'), 'dXNlcjpwYXNz'); + // A password in UTF-8: the bytes, not the UTF-16 code units. + assert.equal(base64('user:päss'), 'dXNlcjpww6Rzcw=='); + assert.equal(base64(''), ''); +}); + +test('only a finished file is handed over, the largest of them', () => { + assert.equal(pickFile([{name: 'a', size: 5, progress: 1}, {name: 'b', size: 9, progress: 0.2}])?.name, 'a'); + assert.equal(pickFile([{name: 'a', size: 5, progress: 1}, {name: 'b', size: 9, progress: 1}])?.name, 'b'); + assert.equal(pickFile([]), null); +}); diff --git a/app/state/torbox.js b/app/state/torbox.js new file mode 100644 index 00000000..0f5a112a --- /dev/null +++ b/app/state/torbox.js @@ -0,0 +1,167 @@ +// TorBox (issue #20): a magnet or a link another host refuses is handed to TorBox's API, which +// fetches it on its own servers and then gives a direct link to the file, which the normal queue +// downloads. TorBox keeps what it fetched, so pasting the same magnet again is instant. +// +// The API key is the user's own, saved on the console (download-services.js); nothing is sent +// anywhere else. Every call answers { success, error, detail, data }; only data is returned. + +import { linkInput } from './link-download.js'; + + var API = "https://api.torbox.app"; + var TORRENTS = "/v1/api/torrents"; + var WEB = "/v1/api/webdl"; + // A cached file is ready in a second or two; one TorBox has to fetch waits this long at most, + // then the user can add it again later (TorBox keeps fetching it in the meantime). + var WAIT_MS = 30 * 60e3; + var FIRST_MS = 2e3; + var THEN_MS = 5e3; + + var FAILED = /^(error|failed|missingfiles|uploaderror|downloaderror)$/i; + + function message(json, status) { + const detail = json && typeof json.detail === "string" ? json.detail.trim() : ""; + const error = json && typeof json.error === "string" ? json.error : ""; + if (/BAD_TOKEN|AUTH_ERROR|BAD_API_KEY|INVALID_TOKEN/i.test(error + detail)) + return "TorBox rejected the API key. Enter it again in the download settings."; + if (/TOO_MANY|RATE_LIMIT/i.test(error + detail)) return "TorBox is rate limiting the key. Try again in a minute."; + return detail || (error ? `TorBox: ${error}.` : `TorBox answered HTTP ${status}.`); + } + + // The torrent or web download an answer names, whatever the endpoint calls it. + function createdId(kind, data) { + const item = Array.isArray(data) ? data[0] : data; + if (!item || typeof item !== "object") return null; + const id = kind === "torrent" ? item.torrent_id ?? item.id : item.web_id ?? item.webdownload_id ?? item.id; + return Number.isSafeInteger(id) && id > 0 ? id : null; + } + + function listItem(kind, data) { + const item = Array.isArray(data) ? data.find((entry) => entry && (entry.id ?? entry.torrent_id ?? entry.web_id)) ?? data[0] : data; + return item && typeof item === "object" ? item : null; + } + + // Whether the item is fetched and ready: TorBox's own flag, its state, or full progress. + function ready(item) { + if (!item) return false; + if (item.download_finished === true || item.download_present === true) return true; + if (typeof item.download_state === "string" && /^(cached|completed|uploading)$/i.test(item.download_state)) return true; + return Number(item.progress) >= 1 && Number(item.size) > 0; + } + + function failed(item) { + if (!item) return ""; + const state = String(item.download_state ?? ""); + if (!FAILED.test(state)) return ""; + return `TorBox could not fetch it (${state.toLowerCase()}).`; + } + + // The file to download: the largest one, so a release's own .nfo or sample is passed over. + function pickFile(files) { + if (!Array.isArray(files)) return null; + let best = null; + for (const file of files) { + if (!file || !Number.isSafeInteger(file.id)) continue; + const size = Number(file.size) || 0; + if (!best || size > (Number(best.size) || 0)) best = file; + } + return best; + } + + function baseName(value) { + const text = String(value ?? ""); + const cut = text.slice(text.lastIndexOf("/") + 1); + return cut || text; + } + + function createTorbox({ http, key, wait = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)), now = Date.now, timeoutMs = WAIT_MS }) { + if (!key) throw new Error("TorBox is not set up."); + + async function call(path, { method = "GET", form, signal } = {}) { + const response = await http.request(API + path, { + method, + signal, + maxBytes: 1024 * 1024, + headers: { + Authorization: `Bearer ${key}`, + ...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {}) + }, + ...(form ? { body: form } : {}) + }); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + } + // Whatever the body says, these two are the key: the API has none of its own to refuse. + if (response.status === 401 || response.status === 403) + throw new Error("TorBox rejected the API key. Enter it again in the download settings."); + if (!json || typeof json !== "object") throw new Error(`TorBox answered HTTP ${response.status}.`); + if (!json.success) throw new Error(message(json, response.status)); + return json.data; + } + + // Adds the link or magnet: a magnet is a torrent, anything else a web download. Both answer + // with the item's id, which the status and the download link are asked for by. + async function create(input, { signal } = {}) { + const parsed = linkInput(input); + if (parsed.kind === "magnet") { + const data = await call(`${TORRENTS}/createtorrent`, { method: "POST", form: `magnet=${encodeURIComponent(parsed.value)}`, signal }); + const id = createdId("torrent", data); + if (!id) throw new Error("TorBox took the magnet but named no torrent."); + return { kind: "torrent", id }; + } + const data = await call(`${WEB}/createwebdownload`, { method: "POST", form: `link=${encodeURIComponent(parsed.value)}`, signal }); + const id = createdId("web", data); + if (!id) throw new Error("TorBox took the link but named no download."); + return { kind: "web", id }; + } + + async function status({ kind, id }, { signal } = {}) { + const data = await call(`${kind === "torrent" ? TORRENTS : WEB}/mylist?id=${id}`, { signal }); + return listItem(kind, data); + } + + // Waits until TorBox has the file, reporting progress as a fraction of the item's size. + async function fetched(created, { onProgress, signal } = {}) { + const end = now() + timeoutMs; + let pause = FIRST_MS; + for (;;) { + const item = await status(created, { signal }); + if (!item) throw new Error("TorBox does not list the download any more."); + const failure = failed(item); + if (failure) throw new Error(failure); + if (ready(item) && pickFile(item.files)) return item; + if (onProgress) onProgress({ progress: Number(item.progress) || 0, state: String(item.download_state ?? "") }); + if (signal?.aborted) throw new Error("Cancelled."); + if (now() >= end) + throw new Error("TorBox is still fetching it. Add it again later: TorBox keeps working on it."); + await wait(pause); + pause = THEN_MS; + } + } + + // The direct link to one file, valid for a few hours and carrying the key. + async function link({ kind, id }, file, { signal } = {}) { + const query3 = kind === "torrent" ? `torrent_id=${id}` : `web_id=${id}`; + const data = await call(`${kind === "torrent" ? TORRENTS : WEB}/requestdl?token=${encodeURIComponent(key)}&${query3}&file_id=${file.id}`, { signal }); + if (typeof data !== "string" || !/^https?:\/\//.test(data)) throw new Error("TorBox gave no download link for the file."); + return data; + } + + /** Hands the input to TorBox and waits for the file; resolves to what addLink() needs. */ + async function prepare(input, { onProgress, signal } = {}) { + const created = await create(input, { signal }); + console.log(`[torbox] added ${created.kind} ${created.id}; waiting for the file`); + const item = await fetched(created, { onProgress, signal }); + const file = pickFile(item.files); + const url = await link(created, file, { signal }); + const filename = baseName(file.short_name || file.name); + console.log(`[torbox] ${created.kind} ${created.id}: ${filename} ready`); + return { url, filename, sizeBytes: Number(file.size) || 0 }; + } + + return { create, status, prepare }; + } + +export { baseName, createTorbox, createdId, pickFile, ready }; diff --git a/app/state/torbox.test.mjs b/app/state/torbox.test.mjs new file mode 100644 index 00000000..924671a4 --- /dev/null +++ b/app/state/torbox.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import {register} from 'node:module'; +import test from 'node:test'; + +register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url); +const {createTorbox, pickFile, ready} = await import('./torbox.js'); + +// Issue #20: TorBox fetches a magnet or a link and gives a direct link to the file, which the queue +// then downloads. These are the API's real answers (2026-10-24) replayed; the calls themselves were +// checked against api.torbox.app with a live key, and the key is never part of the app. + +const MAGNET = 'magnet:?xt=urn:btih:7acf8fb590b2060dd9c3146ef770169d593433b0&dn=Debian'; +const ISO = {id: 2, name: 'Debian/debian.iso', short_name: 'debian.iso', size: 792723456}; +const NFO = {id: 1, name: 'Debian/readme.nfo', short_name: 'readme.nfo', size: 1024}; + +function server(routes) { + const calls = []; + return { + calls, + async request(url, options = {}) { + calls.push({url, options}); + const route = routes.find(entry => entry.match.test(url)); + if (!route) throw new Error('unexpected request: ' + url); + const answer = route.answer(url, options); + const status = answer.status ?? 200; + const body = typeof answer.body === 'string' ? answer.body : JSON.stringify(answer.body); + return {status, ok: status >= 200 && status < 300, url, headers: {}, text: async () => body, json: async () => JSON.parse(body)}; + }, + }; +} + +const noWait = async () => {}; +const keyed = (http, extra = {}) => createTorbox({http, key: 'test-key', wait: noWait, ...extra}); +const ok = data => ({success: true, error: null, detail: 'ok', data}); + +test('a magnet is added, waited for, and its largest file is linked', async () => { + let polls = 0; + const http = server([ + {match: /\/v1\/api\/torrents\/createtorrent$/, answer: () => ({body: ok({torrent_id: 42, hash: '7acf8fb5', auth_id: 'x'})})}, + {match: /\/v1\/api\/torrents\/mylist\?id=42$/, answer: () => ({body: ok(++polls === 1 + ? {id: 42, download_state: 'downloading', progress: 0.25, size: 792724000, files: [ISO, NFO]} + : {id: 42, download_state: 'cached', download_finished: true, progress: 1, size: 792724000, files: [NFO, ISO]})})}, + {match: /\/v1\/api\/torrents\/requestdl\?/, answer: () => ({body: ok('https://nexus-318.ceur.tb-cdn.st/dld/abc?token=test-key')})}, + ]); + const progress = []; + const prepared = await keyed(http).prepare(MAGNET, {onProgress: report => progress.push(report.progress)}); + assert.deepEqual(progress, [0.25]); + assert.equal(prepared.url, 'https://nexus-318.ceur.tb-cdn.st/dld/abc?token=test-key'); + assert.equal(prepared.filename, 'debian.iso'); + assert.equal(prepared.sizeBytes, 792723456); + // The magnet goes as a form field, and the download link names the largest file's id. + assert.equal(new URLSearchParams(http.calls[0].options.body).get('magnet'), MAGNET); + assert.match(http.calls.at(-1).url, /torrent_id=42&file_id=2$/); + assert.equal(http.calls[0].options.headers.Authorization, 'Bearer test-key'); +}); + +test('a link is a web download, and its id answers the same way', async () => { + const http = server([ + {match: /\/v1\/api\/webdl\/createwebdownload$/, answer: () => ({body: ok({web_id: 7, hash: 'abc'})})}, + {match: /\/v1\/api\/webdl\/mylist\?id=7$/, answer: () => ({body: ok({id: 7, download_state: 'completed', progress: 1, size: 2048, files: [{id: 3, name: 'game.zip', size: 2048}]})})}, + {match: /\/v1\/api\/webdl\/requestdl\?/, answer: () => ({body: ok('https://cdn/web/game.zip')})}, + ]); + const prepared = await keyed(http).prepare('https://host/files/game.zip'); + assert.equal(prepared.url, 'https://cdn/web/game.zip'); + assert.equal(prepared.filename, 'game.zip'); + assert.equal(new URLSearchParams(http.calls[0].options.body).get('link'), 'https://host/files/game.zip'); + assert.match(http.calls.at(-1).url, /web_id=7&file_id=3$/); +}); + +test('a site TorBox does not support says so', async () => { + const http = server([{match: /createwebdownload$/, answer: () => ({body: {success: false, error: 'UNSUPPORTED_SITE', detail: 'The site you are trying to download from is not supported.', data: null}})},]); + await assert.rejects(keyed(http).prepare('https://example.com/file.pkg'), /site you are trying to download from is not supported/); +}); + +test('a refused key is named as such, not as a host error', async () => { + const http = server([{match: /createtorrent$/, answer: () => ({status: 401, body: {detail: 'Unauthorized'}})},]); + await assert.rejects(keyed(http).prepare(MAGNET), /rejected the API key/); +}); + +test('a torrent TorBox could not fetch fails with its state', async () => { + const http = server([ + {match: /createtorrent$/, answer: () => ({body: ok({torrent_id: 9})})}, + {match: /mylist\?id=9$/, answer: () => ({body: ok({id: 9, download_state: 'error', progress: 0, files: []})})}, + ]); + await assert.rejects(keyed(http).prepare(MAGNET), /could not fetch it \(error\)/); +}); + +test('waiting too long says to come back, and keeps what TorBox has', async () => { + let clock = 0; + const http = server([ + {match: /createtorrent$/, answer: () => ({body: ok({torrent_id: 9})})}, + {match: /mylist\?id=9$/, answer: () => ({body: ok({id: 9, download_state: 'downloading', progress: 0.5, size: 100, files: []})})}, + ]); + const torbox = createTorbox({http, key: 'k', timeoutMs: 10, now: () => (clock += 6), wait: noWait}); + await assert.rejects(torbox.prepare(MAGNET), /still fetching it\. Add it again later/); +}); + +test('the largest file wins, and only a real one counts as ready', () => { + assert.equal(pickFile([NFO, ISO])?.id, 2); + assert.equal(pickFile([{id: 1, name: 'x', size: 5}, {id: 2, name: 'y', size: 9}])?.id, 2); + assert.equal(pickFile([]), null); + assert.equal(pickFile(null), null); + assert.equal(ready({download_finished: true}), true); + assert.equal(ready({download_state: 'cached'}), true); + assert.equal(ready({progress: 1, size: 10}), true); + assert.equal(ready({progress: 0.9, size: 10}), false); + assert.equal(ready(null), false); +});