feat: downloads from a pasted link, TorBox and qBittorrent

This commit is contained in:
MyLuxy committed 2026-10-24 20:53:05 +02:00
1 parent 5dd3154581
commit 7feaec2e27
14 files changed
+1283 -3

No files matched your search

+302
View File
@@ -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 };
+3
View File
@@ -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 }),
+41
View File
@@ -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",
+41
View File
@@ -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",
+5 -2
View File
@@ -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}`);
+72
View File
@@ -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 };
+26
View File
@@ -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/);
});
+3 -1
View File
@@ -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.
+154
View File
@@ -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 };
+100
View File
@@ -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, []);
});
+157
View File
@@ -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=<magnet> 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 };
+104
View File
@@ -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);
});
+167
View File
@@ -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 };
+108
View File
@@ -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);
});