Handle source links going offline during downloads

This commit is contained in:
Sucukdeluxe
2026-09-02 11:32:06 +02:00
parent cd0464d658
commit ad75469854
15 changed files with 573 additions and 84 deletions
+211 -5
View File
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
import { EventEmitter, once } from "node:events";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager";
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveOfflineArchiveItemsFromList, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager";
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
import { DiskReservationCoordinator } from "../src/main/disk-space";
import { defaultSettings } from "../src/main/constants";
@@ -1143,7 +1143,7 @@ describe("extractArchiveNameFromExtractorLogMessage", () => {
});
});
describe("resolveArchiveItemsFromList", () => {
describe("resolveArchiveItemsFromList", () => {
it("includes duplicate-suffixed archive copies in multipart matches", () => {
const items = [
{ id: "dup-1", fileName: "show.s01e26.part1.rar" },
@@ -1153,9 +1153,37 @@ describe("resolveArchiveItemsFromList", () => {
const resolved = resolveArchiveItemsFromList("show.s01e26.part1.rar", items);
expect(resolved.map((item) => item.id)).toEqual(["dup-1", "dup-2", "dup-3"]);
});
});
expect(resolved.map((item) => item.id)).toEqual(["dup-1", "dup-2", "dup-3"]);
});
});
describe("resolveOfflineArchiveItemsFromList", () => {
it.each([
"show.s01e26.part2.rar",
"legacy.s01e26.r01",
"split.s01e26.zip.002",
"seven.s01e26.7z.003",
"generic.s01e26.002"
])("resolves every related archive part when the unavailable link is %s", (archiveName) => {
const stem = archiveName
.replace(/\.part\d+\.rar$/i, "")
.replace(/\.r\d+$/i, "")
.replace(/\.(zip|7z)\.\d+$/i, "")
.replace(/\.\d{3}$/i, "");
const fileNames = archiveName.includes(".part")
? [`${stem}.part1.rar`, `${stem}.part2.rar`, `${stem}.part3.rar`]
: archiveName.includes(".r01")
? [`${stem}.rar`, `${stem}.r00`, `${stem}.r01`]
: archiveName.includes(".zip.")
? [`${stem}.zip.001`, `${stem}.zip.002`, `${stem}.zip.003`]
: archiveName.includes(".7z.")
? [`${stem}.7z.001`, `${stem}.7z.002`, `${stem}.7z.003`]
: [`${stem}.001`, `${stem}.002`, `${stem}.003`];
const items = fileNames.map((fileName, index) => ({ id: String(index + 1), fileName })) as DownloadItem[];
expect(resolveOfflineArchiveItemsFromList(archiveName, items).map((item) => item.fileName)).toEqual(fileNames);
});
});
describe("download completion planning", () => {
it("does not allow early finish on provider metadata alone", () => {
@@ -1328,6 +1356,184 @@ describe("download manager", () => {
expect(item.onlineStatus).toBe("online");
});
it("does not let a stale availability response overwrite a terminal offline result", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-stale-availability-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "availability", links: ["https://example.com/file.bin"] }]);
const internal = manager as any;
const item = Object.values(internal.session.items)[0] as DownloadItem;
item.status = "failed";
item.fullStatus = "Offline";
item.onlineStatus = "offline";
internal.applyRapidgatorCheckResult(item, { online: true, fileName: "file.bin", fileSizeBytes: 16 });
expect(item.status).toBe("failed");
expect(item.onlineStatus).toBe("offline");
expect(item.fullStatus).toBe("Offline");
});
it("marks a previously online RapidGator link offline and skips its multipart archive after an ambiguous unrestrict failure", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-offline-recheck-"));
tempDirs.push(root);
const links = [
"https://rapidgator.net/file/22222222222222222222222222222222/show.s01e02.part2.rar.html",
"https://rapidgator.net/file/11111111111111111111111111111111/show.s01e02.part1.rar.html",
"https://rapidgator.net/file/33333333333333333333333333333333/show.s01e02.part3.rar.html",
"https://rapidgator.net/file/44444444444444444444444444444444/show.s01e03.part1.rar.html"
];
const sourceChecks = new Map<string, number>();
const unrestrictLinks: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
const sourceLink = String((init?.body as URLSearchParams)?.get("link") || "");
unrestrictLinks.push(sourceLink);
if (sourceLink === links[0]) {
return new Response(JSON.stringify({ error: "unknown_error", error_code: 99 }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
return new Response(JSON.stringify({
download: `https://direct.example/${path.basename(new URL(sourceLink).pathname).replace(/\.html$/i, "")}`,
filename: path.basename(new URL(sourceLink).pathname).replace(/\.html$/i, ""),
filesize: 16
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (links.includes(url)) {
const count = (sourceChecks.get(url) || 0) + 1;
sourceChecks.set(url, count);
if (url === links[0] && count > 1) {
return new Response("File not found", { status: 404 });
}
const fileName = path.basename(new URL(url).pathname).replace(/\.html$/i, "");
return new Response(`<html><title>${fileName}</title><div>File size: <strong>16 B</strong></div></html>`, {
status: 200,
headers: { "Content-Type": "text/html" }
});
}
throw new Error(`unexpected fetch ${url}`);
}) as typeof fetch;
const manager = new DownloadManager({
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
maxParallel: 1,
autoExtract: false,
enableIntegrityCheck: false,
offlineSkipScope: "archive"
}, emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "show-season", links }]);
await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.onlineStatus === "online"), 5_000);
(manager as any).downloadToFile = async (active: { itemId: string }, _url: string, targetPath: string) => {
const item = (manager as any).session.items[active.itemId] as DownloadItem;
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, Buffer.alloc(16, 7));
item.downloadedBytes = 16;
item.totalBytes = 16;
item.progressPercent = 100;
return { resumable: true };
};
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 10_000);
const items = Object.values(manager.getSnapshot().session.items);
const byName = new Map(items.map((item) => [item.fileName, item]));
expect(byName.get("show.s01e02.part2.rar")).toMatchObject({ status: "failed", onlineStatus: "offline", fullStatus: "Offline" });
expect(byName.get("show.s01e02.part1.rar")).toMatchObject({ status: "cancelled", fullStatus: "Übersprungen (Archivteil offline)" });
expect(byName.get("show.s01e02.part3.rar")).toMatchObject({ status: "cancelled", fullStatus: "Übersprungen (Archivteil offline)" });
expect(byName.get("show.s01e03.part1.rar")).toMatchObject({ status: "completed", onlineStatus: "online" });
expect(sourceChecks.get(links[0])).toBe(2);
expect(unrestrictLinks).toEqual([links[0], links[3]]);
});
it("uses a permanent Real-Debrid file_unavailable response without a redundant hoster recheck", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-offline-provider-"));
tempDirs.push(root);
const link = "https://rapidgator.net/file/55555555555555555555555555555555/missing.mkv.html";
let sourceChecks = 0;
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === link) {
sourceChecks += 1;
return new Response("<html><title>missing.mkv</title><div>File size: <strong>16 B</strong></div></html>", {
status: 200,
headers: { "Content-Type": "text/html" }
});
}
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
return new Response(JSON.stringify({ error: "file_unavailable", error_code: 22 }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
throw new Error(`unexpected fetch ${url}`);
}) as typeof fetch;
const manager = new DownloadManager({
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false
}, emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "missing", links: [link] }]);
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.onlineStatus === "online", 5_000);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 10_000);
expect(Object.values(manager.getSnapshot().session.items)[0]).toMatchObject({
status: "failed",
onlineStatus: "offline",
fullStatus: "Offline"
});
expect(sourceChecks).toBe(1);
});
it("skips every unfinished item but keeps completed downloads when the offline scope is the whole package", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-offline-package-"));
tempDirs.push(root);
const manager = new DownloadManager({
...defaultSettings(),
offlineSkipScope: "package",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
}, emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "package", links: [
"https://example.test/offline.part2.rar",
"https://example.test/pending.part1.rar",
"https://example.test/completed.mkv"
] }]);
const internal = manager as any;
const items = Object.values(internal.session.items) as DownloadItem[];
items[0].status = "validating";
items[0].onlineStatus = "online";
items[1].status = "downloading";
const relatedAbortController = new AbortController();
const relatedActive = { abortReason: "none", abortController: relatedAbortController };
internal.activeTasks.set(items[1].id, relatedActive);
items[2].status = "completed";
items[2].downloadedBytes = 16;
items[2].totalBytes = 16;
items[2].progressPercent = 100;
internal.markItemOfflineAndSkipRelated(internal.session.packages[items[0].packageId], items[0], "file_unavailable", "provider");
expect(items[0]).toMatchObject({ status: "failed", onlineStatus: "offline" });
expect(items[1]).toMatchObject({ status: "cancelled", fullStatus: "Übersprungen (Paket enthält Offline-Link)" });
expect(relatedActive.abortReason).toBe("offline_skip");
expect(relatedAbortController.signal.aborted).toBe(true);
expect(items[2]).toMatchObject({ status: "completed", downloadedBytes: 16 });
});
it("applies an imported settings snapshot without touching queued items or filesystem workflows", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-import-"));
tempDirs.push(root);
+8
View File
@@ -13,6 +13,14 @@ describe("renderer settings validation", () => {
.toEqual({ createWorkDirectoriesOnStartup: false });
});
it("projects and validates both offline skip scopes", () => {
const current = { ...defaultSettings(), offlineSkipScope: "archive" as const };
expect(createRendererSettings(current).offlineSkipScope).toBe("archive");
expect(validateRendererSettingsUpdate({ offlineSkipScope: "package" }, current)).toEqual({ offlineSkipScope: "package" });
expect(() => validateRendererSettingsUpdate({ offlineSkipScope: "item" }, current)).toThrow("Settings-Payload ist ungültig");
});
it("projects and validates the semantic theme preference independently from the resolved palette", () => {
const current = { ...defaultSettings(), theme: "dark" as const, themePreference: "system" as const };
const projected = createRendererSettings(current);
+23
View File
@@ -660,6 +660,29 @@ describe("settings views", () => {
}));
});
it("offers archive-set and whole-package handling for links that go offline", () => {
const form = buildSettingsFormViewModel({
settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" },
section: "allgemein",
speedLimitInput: "0",
scheduleSpeedInputs: {}
});
const field = form.groups.flatMap((group) => group.fields)
.find((entry) => entry.id === "offlineSkipScope");
expect(field).toEqual({
id: "offlineSkipScope",
kind: "select",
label: "Wenn ein Quelllink offline geht",
value: "archive",
options: [
{ value: "archive", label: "Betroffenen Archivsatz überspringen" },
{ value: "package", label: "Gesamtes Paket überspringen" }
],
help: "Beim Archivsatz werden alle noch offenen Teile desselben Mehrteil-Archivs übersprungen. Bereits abgeschlossene Downloads bleiben erhalten."
});
});
it("projects proxy segmentation controls with safe defaults and connection totals", () => {
const form = buildSettingsFormViewModel({
settings: {
+9 -2
View File
@@ -590,7 +590,8 @@ describe("settings storage", () => {
providerTertiary: "invalid-provider" as unknown as AppSettings["providerTertiary"],
cleanupMode: "broken" as unknown as AppSettings["cleanupMode"],
extractConflictMode: "broken" as unknown as AppSettings["extractConflictMode"],
completedCleanupPolicy: "broken" as unknown as AppSettings["completedCleanupPolicy"],
completedCleanupPolicy: "broken" as unknown as AppSettings["completedCleanupPolicy"],
offlineSkipScope: "broken" as unknown as AppSettings["offlineSkipScope"],
speedLimitMode: "broken" as unknown as AppSettings["speedLimitMode"],
maxParallel: 0,
retryLimit: 999,
@@ -611,7 +612,8 @@ describe("settings storage", () => {
expect(normalized.providerTertiary).toBe("none");
expect(normalized.cleanupMode).toBe("none");
expect(normalized.extractConflictMode).toBe("overwrite");
expect(normalized.completedCleanupPolicy).toBe("never");
expect(normalized.completedCleanupPolicy).toBe("never");
expect(normalized.offlineSkipScope).toBe("archive");
expect(normalized.speedLimitMode).toBe("global");
expect(normalized.maxParallel).toBe(1);
expect(normalized.retryLimit).toBe(99);
@@ -631,6 +633,11 @@ describe("settings storage", () => {
expect(defaultSettings().proxyConnectionsPerDownload).toBe(32);
});
it("uses archive-set skipping by default and preserves an explicit whole-package choice", () => {
expect(defaultSettings().offlineSkipScope).toBe("archive");
expect(normalizeSettings({ ...defaultSettings(), offlineSkipScope: "package" }).offlineSkipScope).toBe("package");
});
it("migrates the previous private update repository to the public release repository", () => {
for (const updateRepo of [
"legacy-owner/real-debrid-downloader",
+2 -1
View File
@@ -114,7 +114,8 @@ function createSettings(): AppSettings {
completedCleanupPolicy: "never",
maxParallel: 4,
maxParallelExtract: 2,
retryLimit: 3,
retryLimit: 3,
offlineSkipScope: "archive",
speedLimitEnabled: false,
speedLimitKbps: 0,
speedLimitMode: "global",