mirror of
https://github.com/MyLuxy/overdrive.git
synced 2026-10-26 17:04:10 +01:00
feat: downloads from a pasted link, TorBox and qBittorrent
This commit is contained in:
1 parent
5dd3154581
commit
7feaec2e27
14 files changed
+1283
-3
No files matched your search
@@ -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 };
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user