Apply account and provider changes to active conversions without restarting, isolate API and Web state, and abort the exact fallback attempt when settings change. Bound resume recovery, make disk reservations abortable, preserve cleanup totals and history, stabilize compact UI state, and canonicalize RapidGator host aliases. Expand bounded support diagnostics while redacting account identities, local paths, package names, and file names from current and rotated logs. Add regression coverage for rotation, live settings, HTTP 416 recovery, disk waits, cleanup, context menus, history failures, and support bundle privacy.
15399 lines
545 KiB
TypeScript
15399 lines
545 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import http from "node:http";
|
|
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, getUnrestrictTimeoutMsForProviderPlan, isResumeHardResetReason, resolveArchiveItemsFromList, 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";
|
|
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
|
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
|
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
|
import { createStoragePaths, emptySession, loadSession, saveSession } from "../src/main/storage";
|
|
import { getMegaDebridAccountCooldownState, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
|
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
|
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
|
import { logAccountRotation } from "../src/main/account-rotation-log";
|
|
import { UnrestrictedLink } from "../src/main/realdebrid";
|
|
import { resetVideoToolingCache } from "../src/main/video-processor";
|
|
import type { AppSettings, DebridProvider, HistoryEntry, PackageEntry } from "../src/shared/types";
|
|
|
|
const tempDirs: string[] = [];
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
describe("runWithLimitedConcurrency", () => {
|
|
it("processes the full batch without exceeding the configured worker count", async () => {
|
|
let active = 0;
|
|
let peak = 0;
|
|
const completed: number[] = [];
|
|
|
|
await runWithLimitedConcurrency([1, 2, 3, 4, 5, 6], 3, async (value) => {
|
|
active += 1;
|
|
peak = Math.max(peak, active);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
completed.push(value);
|
|
active -= 1;
|
|
});
|
|
|
|
expect(peak).toBe(3);
|
|
expect(completed.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);
|
|
});
|
|
});
|
|
|
|
describe("disk write recovery", () => {
|
|
it("classifies retryable disk write stalls without treating permission errors as temporary", () => {
|
|
expect(getDiskWriteWaitReason(Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" }))).toMatch(/Festplatte voll/);
|
|
expect(getDiskWriteWaitReason(new Error("write_drain_timeout"))).toMatch(/Festplatte/);
|
|
expect(getDiskWriteWaitReason(Object.assign(new Error("write EACCES"), { code: "EACCES" }))).toBeNull();
|
|
});
|
|
|
|
it("parks a disk-full download for automatic retry without consuming its retry budget", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-recovery-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = "disk-recovery-package";
|
|
const itemId = "disk-recovery-item";
|
|
const outputDir = path.join(root, "downloads", "disk-recovery");
|
|
const extractDir = path.join(root, "extract", "disk-recovery");
|
|
const createdAt = Date.now();
|
|
session.running = true;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "disk-recovery",
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://rapidgator.net/file/disk-recovery",
|
|
provider: "realdebrid",
|
|
status: "downloading",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: 1024,
|
|
progressPercent: 0,
|
|
fileName: "disk-recovery.bin",
|
|
targetPath: "",
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Download läuft",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), autoExtract: false },
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
|
|
(manager as any).activeTasks.set(itemId, active);
|
|
(manager as any).debridService.unrestrictLink = async () => ({
|
|
fileName: "disk-recovery.bin",
|
|
directUrl: "https://dummy/disk-recovery",
|
|
fileSize: 1024,
|
|
retriesUsed: 0,
|
|
provider: "realdebrid",
|
|
providerLabel: "Real-Debrid"
|
|
});
|
|
(manager as any).downloadToFile = async () => {
|
|
throw Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" });
|
|
};
|
|
|
|
const before = Date.now();
|
|
await (manager as any).processItem(active);
|
|
|
|
expect(session.items[itemId]).toEqual(expect.objectContaining({
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
fullStatus: "Warte auf Festplatte"
|
|
}));
|
|
expect((manager as any).retryAfterByItem.get(itemId)).toBeGreaterThan(before);
|
|
expect(session.packages[packageId].status).toBe("queued");
|
|
});
|
|
|
|
it("parks downloads before opening the target file when the final known size does not fit", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-download-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = "reserve-download-package";
|
|
const itemId = "reserve-download-item";
|
|
const outputDir = path.join(root, "downloads", "reserve-download");
|
|
const extractDir = path.join(root, "extract", "reserve-download");
|
|
const createdAt = Date.now();
|
|
session.running = true;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "reserve-download",
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://rapidgator.net/file/reserve-download",
|
|
provider: "realdebrid",
|
|
status: "downloading",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "reserve-download.bin",
|
|
targetPath: "",
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Download läuft",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), autoExtract: false },
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
(manager as any).diskReservations = new DiskReservationCoordinator({
|
|
safetyBytes: 128,
|
|
retryDelayMs: 45_000,
|
|
statVolume: async (targetPath) => ({
|
|
path: targetPath,
|
|
volumeKey: "download-volume",
|
|
freeBytes: 512,
|
|
totalBytes: 2_048
|
|
})
|
|
});
|
|
(manager as any).debridService.unrestrictLink = async () => ({
|
|
fileName: "reserve-download.bin",
|
|
directUrl: "https://dummy/reserve-download",
|
|
fileSize: null,
|
|
retriesUsed: 0,
|
|
provider: "realdebrid",
|
|
providerLabel: "Real-Debrid"
|
|
});
|
|
globalThis.fetch = vi.fn(async () => new Response(Buffer.alloc(1_024, 1), {
|
|
status: 200,
|
|
headers: {
|
|
"content-length": "1024",
|
|
"accept-ranges": "bytes"
|
|
}
|
|
})) as typeof fetch;
|
|
const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
|
|
(manager as any).activeTasks.set(itemId, active);
|
|
|
|
const before = Date.now();
|
|
await (manager as any).processItem(active);
|
|
(manager as any).activeTasks.delete(itemId);
|
|
|
|
const item = session.items[itemId];
|
|
expect(item).toEqual(expect.objectContaining({
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
fullStatus: "Warte auf Festplatte"
|
|
}));
|
|
expect((manager as any).retryAfterByItem.get(itemId)).toBeGreaterThanOrEqual(before + 45_000);
|
|
expect(fs.existsSync(path.join(outputDir, "reserve-download.bin"))).toBe(false);
|
|
expect(manager.getSnapshot().diskWaitEvents?.[0]).toEqual(expect.objectContaining({
|
|
phase: "download",
|
|
ownerId: itemId,
|
|
itemId,
|
|
packageId,
|
|
volumeKey: "download-volume",
|
|
requiredBytes: 1_024,
|
|
availableBytes: 384,
|
|
deficitBytes: 640
|
|
}));
|
|
expect(manager.getSnapshot().diskWaitEvents?.[0]).toEqual(expect.objectContaining({
|
|
state: "waiting",
|
|
at: expect.any(Number)
|
|
}));
|
|
});
|
|
|
|
it("releases an active download when Stop aborts a blocked post-header disk reservation", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-abort-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = "reserve-abort-package";
|
|
const itemId = "reserve-abort-item";
|
|
const outputDir = path.join(root, "downloads", "reserve-abort");
|
|
const createdAt = Date.now();
|
|
session.running = true;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "reserve-abort",
|
|
outputDir,
|
|
extractDir: path.join(root, "extract", "reserve-abort"),
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://rapidgator.net/file/reserve-abort",
|
|
provider: "realdebrid",
|
|
status: "downloading",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "reserve-abort.bin",
|
|
targetPath: "",
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Download läuft",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
let reservationStarted = false;
|
|
let statCalls = 0;
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), autoExtract: false },
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
(manager as any).diskReservations = new DiskReservationCoordinator({
|
|
safetyBytes: 0,
|
|
statVolume: async (targetPath) => {
|
|
statCalls += 1;
|
|
reservationStarted = true;
|
|
if (statCalls === 1) {
|
|
return await new Promise(() => undefined);
|
|
}
|
|
return { path: targetPath, volumeKey: "follow-up-volume", freeBytes: 4_096, totalBytes: 8_192 };
|
|
}
|
|
});
|
|
(manager as any).debridService.unrestrictLink = async () => ({
|
|
fileName: "reserve-abort.bin",
|
|
directUrl: "https://dummy/reserve-abort",
|
|
fileSize: null,
|
|
retriesUsed: 0,
|
|
provider: "realdebrid",
|
|
providerLabel: "Real-Debrid"
|
|
});
|
|
const response = new Response(Buffer.alloc(1_024, 3), {
|
|
status: 200,
|
|
headers: { "content-length": "1024", "accept-ranges": "bytes" }
|
|
});
|
|
const cancelSpy = vi.spyOn(response.body!, "cancel");
|
|
globalThis.fetch = vi.fn(async () => response) as typeof fetch;
|
|
const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
|
|
(manager as any).activeTasks.set(itemId, active);
|
|
|
|
const processing = (manager as any).processItem(active) as Promise<void>;
|
|
await waitFor(() => reservationStarted, 2_000);
|
|
active.abortReason = "stop";
|
|
active.abortController.abort("stop");
|
|
|
|
await expect(Promise.race([
|
|
processing.then(() => "released"),
|
|
new Promise<string>((resolve) => setTimeout(() => resolve("blocked"), 500))
|
|
])).resolves.toBe("released");
|
|
expect(cancelSpy).toHaveBeenCalled();
|
|
const followUpLease = await Promise.race([
|
|
(manager as any).diskReservations.reserve({
|
|
phase: "download",
|
|
ownerId: "follow-up",
|
|
targetPath: path.join(root, "follow-up.bin"),
|
|
requiredBytes: 512,
|
|
alreadyPresentBytes: 0
|
|
}),
|
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), 500))
|
|
]);
|
|
expect(followUpLease).not.toBeNull();
|
|
followUpLease?.release();
|
|
});
|
|
|
|
it("keeps disk-wait downloads out of the scheduler until their capacity retry is due", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-resume-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = "reserve-resume-package";
|
|
const itemId = "reserve-resume-item";
|
|
const outputDir = path.join(root, "downloads", "reserve-resume");
|
|
const extractDir = path.join(root, "extract", "reserve-resume");
|
|
const createdAt = Date.now();
|
|
let freeBytes = 512;
|
|
session.running = true;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "reserve-resume",
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://rapidgator.net/file/reserve-resume",
|
|
provider: "realdebrid",
|
|
status: "downloading",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "reserve-resume.bin",
|
|
targetPath: "",
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Download läuft",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), autoExtract: false },
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
(manager as any).diskReservations = new DiskReservationCoordinator({
|
|
safetyBytes: 128,
|
|
retryDelayMs: 45_000,
|
|
statVolume: async (targetPath) => ({
|
|
path: targetPath,
|
|
volumeKey: "download-volume",
|
|
freeBytes,
|
|
totalBytes: 2_048
|
|
})
|
|
});
|
|
(manager as any).debridService.unrestrictLink = async () => ({
|
|
fileName: "reserve-resume.bin",
|
|
directUrl: "https://dummy/reserve-resume",
|
|
fileSize: 1_024,
|
|
retriesUsed: 0,
|
|
provider: "realdebrid",
|
|
providerLabel: "Real-Debrid"
|
|
});
|
|
globalThis.fetch = vi.fn(async () => new Response(Buffer.alloc(1_024, 2), {
|
|
status: 200,
|
|
headers: {
|
|
"content-length": "1024",
|
|
"accept-ranges": "bytes"
|
|
}
|
|
})) as typeof fetch;
|
|
const firstActive = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
|
|
(manager as any).activeTasks.set(itemId, firstActive);
|
|
await (manager as any).processItem(firstActive);
|
|
(manager as any).activeTasks.delete(itemId);
|
|
|
|
expect((manager as any).findNextQueuedItem()).toBeNull();
|
|
|
|
freeBytes = 2_048;
|
|
(manager as any).retryAfterByItem.set(itemId, Date.now() - 1);
|
|
expect((manager as any).findNextQueuedItem()).toEqual({ packageId, itemId });
|
|
const secondActive = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
|
|
(manager as any).activeTasks.set(itemId, secondActive);
|
|
await (manager as any).processItem(secondActive);
|
|
(manager as any).activeTasks.delete(itemId);
|
|
|
|
expect(session.items[itemId].status).toBe("completed");
|
|
expect(fs.statSync(path.join(outputDir, "reserve-resume.bin")).size).toBe(1_024);
|
|
expect((manager as any).diskReservations.getReservedBytesByVolume().get("download-volume")).toBe(0);
|
|
expect(manager.getSnapshot().diskWaitEvents).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ ownerId: itemId, state: "waiting" }),
|
|
expect.objectContaining({ ownerId: itemId, state: "resolved", resolvedAt: expect.any(Number) })
|
|
]));
|
|
});
|
|
|
|
it("keeps multiple disk-wait events instead of replacing the previous cause", () => {
|
|
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(os.tmpdir(), `rd-disk-ring-${Date.now()}`)));
|
|
const first = {
|
|
phase: "download" as const,
|
|
ownerId: "item-one",
|
|
volumeKey: "volume-one",
|
|
requiredBytes: 200,
|
|
availableBytes: 100,
|
|
deficitBytes: 100,
|
|
safetyBytes: 0,
|
|
retryAt: Date.now() + 1000
|
|
};
|
|
const second = { ...first, ownerId: "item-two", volumeKey: "volume-two" };
|
|
|
|
(manager as any).recordDiskWait(first, { itemId: "item-one", packageId: "package-one" });
|
|
(manager as any).recordDiskWait(second, { itemId: "item-two", packageId: "package-two" });
|
|
|
|
expect(manager.getSnapshot().diskWaitEvents).toEqual([
|
|
expect.objectContaining({ ownerId: "item-one", state: "waiting" }),
|
|
expect.objectContaining({ ownerId: "item-two", state: "waiting" })
|
|
]);
|
|
});
|
|
|
|
it("marks a fully downloaded package as failed when post-processing failed", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-status-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = "extract-status-package";
|
|
const itemId = "extract-status-item";
|
|
const createdAt = Date.now();
|
|
const outputDir = path.join(root, "downloads");
|
|
const targetPath = path.join(outputDir, "extract-status.part1.rar");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(targetPath, Buffer.alloc(1024, 1));
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "extract-status",
|
|
outputDir,
|
|
extractDir: path.join(root, "extract"),
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://rapidgator.net/file/extract-status",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 1024,
|
|
totalBytes: 1024,
|
|
progressPercent: 100,
|
|
fileName: "extract-status.part1.rar",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "Kein Speicherplatz",
|
|
fullStatus: "Entpack-Fehler [extract-status.part1.rar]: Kein Speicherplatz",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
|
|
|
|
(manager as any).refreshPackageStatus(session.packages[packageId]);
|
|
|
|
expect(session.packages[packageId].status).toBe("failed");
|
|
});
|
|
|
|
it("parks extraction before the extractor starts when the extract volume is short", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-extract-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = "reserve-extract-package";
|
|
const itemId = "reserve-extract-item";
|
|
const outputDir = path.join(root, "downloads", "reserve-extract");
|
|
const extractDir = path.join(root, "extract", "reserve-extract");
|
|
const archivePath = path.join(outputDir, "reserve-extract.zip");
|
|
const createdAt = Date.now();
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
const zip = new AdmZip();
|
|
zip.addFile("episode.mkv", Buffer.alloc(1_024, 3));
|
|
zip.writeZip(archivePath);
|
|
const archiveBytes = fs.statSync(archivePath).size;
|
|
session.running = true;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "reserve-extract",
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://rapidgator.net/file/reserve-extract",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: archiveBytes,
|
|
totalBytes: archiveBytes,
|
|
progressPercent: 100,
|
|
fileName: "reserve-extract.zip",
|
|
targetPath: archivePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpacken - Ausstehend",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), autoExtract: true },
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
(manager as any).diskReservations = new DiskReservationCoordinator({
|
|
safetyBytes: 0,
|
|
retryDelayMs: 60_000,
|
|
statVolume: async (targetPath) => ({
|
|
path: targetPath,
|
|
volumeKey: "extract-volume",
|
|
freeBytes: Math.max(0, archiveBytes - 1),
|
|
totalBytes: archiveBytes * 2
|
|
})
|
|
});
|
|
|
|
const before = Date.now();
|
|
await (manager as any).handlePackagePostProcessing(packageId);
|
|
|
|
expect(session.packages[packageId].status).toBe("queued");
|
|
expect(session.items[itemId]).toEqual(expect.objectContaining({
|
|
status: "completed",
|
|
fullStatus: "Warte auf Festplatte",
|
|
lastError: "Zu wenig Speicherplatz"
|
|
}));
|
|
expect((manager as any).packageDiskRetryAfterByPackage.get(packageId)).toBeGreaterThanOrEqual(before + 60_000);
|
|
expect(manager.getSnapshot().diskWaitEvents?.[0]).toEqual(expect.objectContaining({
|
|
phase: "extract",
|
|
ownerId: packageId,
|
|
packageId,
|
|
volumeKey: "extract-volume",
|
|
requiredBytes: archiveBytes,
|
|
availableBytes: archiveBytes - 1,
|
|
deficitBytes: 1
|
|
}));
|
|
expect(fs.existsSync(path.join(extractDir, "episode.mkv"))).toBe(false);
|
|
});
|
|
|
|
it("skips audio remux before processVideoFile when the source volume is short", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-remux-"));
|
|
tempDirs.push(root);
|
|
const extractDir = path.join(root, "extract", "reserve-remux");
|
|
const sourcePath = path.join(extractDir, "Show.S01E01.German.DL.720p.mkv");
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.writeFileSync(sourcePath, Buffer.alloc(1_024, 4));
|
|
process.env.RD_FFMPEG_BIN = "pwsh";
|
|
process.env.RD_FFPROBE_BIN = "pwsh";
|
|
resetVideoToolingCache();
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir,
|
|
keepGermanAudioOnly: true,
|
|
germanAudioMode: "tag"
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const pkg: PackageEntry = {
|
|
id: "reserve-remux-package",
|
|
name: "reserve-remux",
|
|
outputDir: path.join(root, "downloads", "reserve-remux"),
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now()
|
|
};
|
|
(manager as any).diskReservations = new DiskReservationCoordinator({
|
|
safetyBytes: 128,
|
|
retryDelayMs: 30_000,
|
|
statVolume: async (targetPath) => ({
|
|
path: targetPath,
|
|
volumeKey: "remux-volume",
|
|
freeBytes: 512,
|
|
totalBytes: 2_048
|
|
})
|
|
});
|
|
|
|
const processed = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
|
|
|
expect(processed).toBe(0);
|
|
expect(fs.existsSync(sourcePath)).toBe(true);
|
|
expect(fs.readdirSync(extractDir)).toEqual(["Show.S01E01.German.DL.720p.mkv"]);
|
|
expect(pkg.audioStripSummary).toMatchObject({
|
|
candidates: 1,
|
|
remuxed: 0,
|
|
failed: 1
|
|
});
|
|
expect(pkg.audioStripSummary!.files[0]).toMatchObject({
|
|
name: "Show.S01E01.German.DL.720p.mkv",
|
|
action: "skipped-no-space",
|
|
reason: "zu wenig freier Speicher fuer Remux"
|
|
});
|
|
expect(manager.getSnapshot().diskWaitEvents?.[0]).toEqual(expect.objectContaining({
|
|
phase: "remux",
|
|
ownerId: "reserve-remux-package",
|
|
packageId: "reserve-remux-package",
|
|
volumeKey: "remux-volume",
|
|
requiredBytes: 1_024,
|
|
availableBytes: 384,
|
|
deficitBytes: 640
|
|
}));
|
|
expect((manager as any).diskReservations.getReservedBytesByVolume().get("remux-volume") ?? 0).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("resume recovery", () => {
|
|
it("preserves a partial file for one renewed link and resets after repeated range rejection", () => {
|
|
expect(isResumeHardResetReason("range_ignored_on_resume:100/200", 0)).toBe(false);
|
|
expect(isResumeHardResetReason("range_ignored_on_resume:100/200", 1)).toBe(true);
|
|
expect(isResumeHardResetReason("range_mismatch_on_resume:100/0", 1)).toBe(true);
|
|
expect(isResumeHardResetReason("resume_download_underflow:100/200", 0)).toBe(true);
|
|
});
|
|
|
|
it("budgets a later Mega fallback across every active account without a fifteen minute cap", () => {
|
|
process.env.RD_UNRESTRICT_TIMEOUT_MS = "60000";
|
|
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "120000";
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-unrestrict-budget-"));
|
|
tempDirs.push(root);
|
|
const settings: AppSettings = {
|
|
...defaultSettings(),
|
|
bestToken: "best-token",
|
|
megaDebridWebCredentials: Array.from({ length: 8 }, (_, index) => `user-${index}:pass-${index}`).join("\n"),
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false,
|
|
providerOrder: ["megadebrid-web", "bestdebrid"],
|
|
autoProviderFallback: true,
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
};
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
|
manager.addPackages([{ name: "fallback-budget", links: ["https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] }]);
|
|
const snapshot = manager.getSnapshot();
|
|
const item = snapshot.session.items[snapshot.session.packages[snapshot.session.packageOrder[0]].itemIds[0]];
|
|
const providerPlan = (manager as unknown as {
|
|
getReachableUnrestrictProviderPlan: (candidate: typeof item, preferredLeadProvider: DebridProvider | null) => DebridProvider[];
|
|
}).getReachableUnrestrictProviderPlan(item, "bestdebrid");
|
|
|
|
expect(providerPlan).toEqual(["bestdebrid", "megadebrid-web"]);
|
|
expect(getUnrestrictTimeoutMsForProviderPlan(settings, providerPlan)).toBe(1_035_000);
|
|
});
|
|
});
|
|
|
|
describe("download start account gate", () => {
|
|
it("disables every start path when no usable account is active", async () => {
|
|
const createManager = () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-start-account-gate-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
manager.addPackages([{ name: "account-gate", links: ["https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] }]);
|
|
const snapshot = manager.getSnapshot();
|
|
const packageId = snapshot.session.packageOrder[0];
|
|
const itemId = snapshot.session.packages[packageId].itemIds[0];
|
|
return { manager, packageId, itemId };
|
|
};
|
|
|
|
const starts = [
|
|
({ manager }: ReturnType<typeof createManager>) => manager.start(),
|
|
({ manager, packageId }: ReturnType<typeof createManager>) => manager.startPackages([packageId]),
|
|
({ manager, itemId }: ReturnType<typeof createManager>) => manager.startItems([itemId])
|
|
];
|
|
|
|
for (const start of starts) {
|
|
const context = createManager();
|
|
expect(context.manager.getSnapshot().canStart).toBe(false);
|
|
try {
|
|
await expect(start(context)).rejects.toThrow("Kein aktiver Download-Account verfügbar");
|
|
expect(context.manager.getSnapshot().session.running).toBe(false);
|
|
} finally {
|
|
context.manager.stop();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("treats disabled providers and disabled Mega-Debrid accounts as unavailable", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-account-gate-"));
|
|
tempDirs.push(root);
|
|
const megaLogin = "disabled@example.test";
|
|
const disabledMegaId = getMegaDebridAccountId(megaLogin);
|
|
const disabledProviderManager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", disabledProviders: ["realdebrid"] },
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "provider"))
|
|
);
|
|
const disabledMegaManager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaDebridApiCredentials: `${megaLogin}:secret`,
|
|
megaDebridApiEnabled: true,
|
|
megaDebridApiDisabledAccountIds: [disabledMegaId],
|
|
providerOrder: ["megadebrid-api"]
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "mega"))
|
|
);
|
|
|
|
expect(disabledProviderManager.getSnapshot().canStart).toBe(false);
|
|
expect(disabledMegaManager.getSnapshot().canStart).toBe(false);
|
|
});
|
|
|
|
it("treats configured Mega-Debrid pools with both modes disabled as unavailable", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-mega-modes-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaLogin: "legacy@example.test",
|
|
megaPassword: "legacy-secret",
|
|
megaCredentials: "legacy@example.test:legacy-secret",
|
|
megaDebridApiCredentials: "api@example.test:api-secret",
|
|
megaDebridWebCredentials: "web@example.test:web-secret",
|
|
megaDebridApiEnabled: false,
|
|
megaDebridWebEnabled: false,
|
|
providerOrder: ["megadebrid-api", "megadebrid-web"]
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
expect(manager.getSnapshot().canStart).toBe(false);
|
|
});
|
|
|
|
it("allows start when an active account is available", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-account-gate-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token" },
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
expect(manager.getSnapshot().canStart).toBe(true);
|
|
|
|
manager.setSettings({ ...defaultSettings(), outputDir: manager.getSettings().outputDir });
|
|
|
|
expect(manager.getSnapshot().canStart).toBe(false);
|
|
});
|
|
|
|
it("blocks resume when the last active account is unavailable", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-account-gate-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
defaultSettings(),
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const session = (manager as any).session;
|
|
session.running = true;
|
|
session.paused = true;
|
|
|
|
expect(manager.getSnapshot().canStart).toBe(false);
|
|
expect(() => manager.togglePause()).toThrow("Kein aktiver Download-Account verfügbar");
|
|
expect(manager.getSnapshot().session.paused).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("extractArchiveNameFromExtractorLogMessage", () => {
|
|
it("detects archive names from extractor log variants", () => {
|
|
expect(extractArchiveNameFromExtractorLogMessage("Extract-Backend Start: archive=scn-dhanbs7-S02E008.rar, mode=legacy")).toBe("scn-dhanbs7-S02E008.rar");
|
|
expect(extractArchiveNameFromExtractorLogMessage("Entpacke Archiv: scn-dhanbs7-S02E008.rar -> C:\\target")).toBe("scn-dhanbs7-S02E008.rar");
|
|
expect(extractArchiveNameFromExtractorLogMessage("Entpack-Fehler scn-dhanbs7-S02E008.rar [missing_parts]: Error: boom")).toBe("scn-dhanbs7-S02E008.rar");
|
|
});
|
|
|
|
it("returns null when no archive name is present", () => {
|
|
expect(extractArchiveNameFromExtractorLogMessage("Post-Processing Entpacken Ende")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("resolveArchiveItemsFromList", () => {
|
|
it("includes duplicate-suffixed archive copies in multipart matches", () => {
|
|
const items = [
|
|
{ id: "dup-1", fileName: "show.s01e26.part1.rar" },
|
|
{ id: "dup-2", fileName: "show.s01e26.part1.rar", targetPath: "C:\\Downloads\\show.s01e26.part1 (1).rar" },
|
|
{ id: "dup-3", fileName: "show.s01e26.part2.rar" }
|
|
] as any[];
|
|
|
|
const resolved = resolveArchiveItemsFromList("show.s01e26.part1.rar", items);
|
|
|
|
expect(resolved.map((item) => item.id)).toEqual(["dup-1", "dup-2", "dup-3"]);
|
|
});
|
|
});
|
|
|
|
describe("download completion planning", () => {
|
|
it("does not allow early finish on provider metadata alone", () => {
|
|
expect(planDownloadCompletion({
|
|
existingBytes: 0,
|
|
responseStatus: 200,
|
|
contentLength: 0,
|
|
totalFromRange: null,
|
|
knownTotal: 192 * 1024,
|
|
correctedTotal: null
|
|
})).toEqual({
|
|
expectedTotal: 192 * 1024,
|
|
source: "provider-metadata",
|
|
canFinishEarly: false
|
|
});
|
|
});
|
|
|
|
it("accepts provider metadata mismatches after a clean stream end", () => {
|
|
expect(validateDownloadedFileCompletion({
|
|
actualBytes: 256 * 1024,
|
|
plan: {
|
|
expectedTotal: 192 * 1024,
|
|
source: "provider-metadata",
|
|
canFinishEarly: false
|
|
}
|
|
})).toEqual({
|
|
ok: true,
|
|
totalBytes: 256 * 1024,
|
|
acceptedMetadataMismatch: true
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("auto rename base selection", () => {
|
|
it("ignores raw episode-suffix folders like 4sf-amilllt...-s03e10 as scene targets", () => {
|
|
expect(buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
[
|
|
"4sf-amilllt.de.dl.web.7p-s03e10",
|
|
"A.Million.Little.Things.S03.GERMAN.DL.720p.WEB.H264-RWP",
|
|
"amilllt.de.dl.web.7p-s03e10"
|
|
],
|
|
"A.Million.Little.Things.S03E10.Vertraue.mir.GERMAN.DL.720p.WEB.H264-4SF",
|
|
{ forceEpisodeForSeasonFolder: true }
|
|
)).toBe("A.Million.Little.Things.S03E10.GERMAN.DL.720p.WEB.H264-RWP");
|
|
});
|
|
|
|
it("ignores compact archive folder stems like scn-alco7-S03E18 as scene targets", () => {
|
|
expect(buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
[
|
|
"scn-alco7-S03E18",
|
|
"Alex.und.Co.S03.GERMAN.DL.720p.WEB.H264-SunDry"
|
|
],
|
|
"alex.und.co.s03e18.720p.web.h264-sundry",
|
|
{ forceEpisodeForSeasonFolder: true }
|
|
)).toBe("Alex.und.Co.S03E18.GERMAN.DL.720p.WEB.H264-SunDry");
|
|
});
|
|
|
|
it("uses the episode's own scene group when a season folder mixes providers", () => {
|
|
expect(buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
[
|
|
"amilllt.web.de.7p-101",
|
|
"A.Million.Little.Things.S01.GERMAN.DUBBED.720p.WEB.h264-idTV\u2044GDR"
|
|
],
|
|
"A.Million.Little.Things.S01E01.GERMAN.DUBBED.720p.WEB.h264-idTV_iNT",
|
|
{ forceEpisodeForSeasonFolder: true }
|
|
)).toBe("A.Million.Little.Things.S01E01.GERMAN.DUBBED.720p.WEB.h264-idTV_iNT");
|
|
});
|
|
|
|
it("keeps per-episode GDR suffixes instead of inheriting a mixed package suffix", () => {
|
|
expect(buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
[
|
|
"amilllt.web.de.7p-110",
|
|
"A.Million.Little.Things.S01.GERMAN.DUBBED.720p.WEB.h264-idTV\u2044GDR"
|
|
],
|
|
"A.Million.Little.Things.S01E10.German.DL.Dubbed.720p.WEB.h264-GDR",
|
|
{ forceEpisodeForSeasonFolder: true }
|
|
)).toBe("A.Million.Little.Things.S01E10.GERMAN.DUBBED.720p.WEB.h264-GDR");
|
|
});
|
|
});
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 15000): Promise<void> {
|
|
const started = Date.now();
|
|
while (!predicate()) {
|
|
if (Date.now() - started > timeoutMs) {
|
|
throw new Error("waitFor timeout");
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
}
|
|
}
|
|
|
|
async function removeDirWithRetries(dir: string): Promise<void> {
|
|
let lastError: unknown = null;
|
|
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
try {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await new Promise((resolve) => setTimeout(resolve, attempt * 80));
|
|
}
|
|
}
|
|
if (lastError) {
|
|
throw lastError;
|
|
}
|
|
}
|
|
|
|
afterEach(async () => {
|
|
globalThis.fetch = originalFetch;
|
|
delete process.env.RD_UNRESTRICT_TIMEOUT_MS;
|
|
delete process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS;
|
|
delete process.env.RD_FFMPEG_BIN;
|
|
delete process.env.RD_FFPROBE_BIN;
|
|
resetVideoToolingCache();
|
|
resetDebridLinkRuntimeStateForTests();
|
|
resetMegaDebridRuntimeStateForTests();
|
|
shutdownItemLogs();
|
|
shutdownPackageLogs();
|
|
shutdownRenameLog();
|
|
for (const dir of tempDirs.splice(0)) {
|
|
await removeDirWithRetries(dir);
|
|
}
|
|
});
|
|
|
|
describe("download manager", () => {
|
|
it("writes the real unrestrict account trail into the active item log", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rotation-item-log-"));
|
|
tempDirs.push(root);
|
|
const stateDir = path.join(root, "state");
|
|
initItemLogs(stateDir);
|
|
const session = emptySession();
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
retryLimit: 0
|
|
},
|
|
session,
|
|
createStoragePaths(stateDir)
|
|
);
|
|
manager.addPackages([{ name: "rotation-proof", links: ["https://rapidgator.net/file/rotation-proof"] }]);
|
|
const packageId = session.packageOrder[0];
|
|
const itemId = session.packages[packageId].itemIds[0];
|
|
const active = {
|
|
itemId,
|
|
packageId,
|
|
abortController: new AbortController(),
|
|
abortReason: "none",
|
|
resumable: true,
|
|
nonResumableCounted: false,
|
|
blockedOnDiskWrite: false,
|
|
blockedOnDiskSince: 0
|
|
};
|
|
(manager as any).activeTasks.set(itemId, active);
|
|
(manager as any).debridService.unrestrictLink = async () => {
|
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (al***ha)", "TEST");
|
|
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (al***ha)", "FAILED", { reason: "aborted" });
|
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (be***ta)", "TEST");
|
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (be***ta)", "OK");
|
|
throw new Error("rotation-proof-stop");
|
|
};
|
|
|
|
await (manager as any).processItem(active);
|
|
const itemLogPath = getItemLogPath(itemId);
|
|
expect(itemLogPath).not.toBeNull();
|
|
shutdownItemLogs();
|
|
const content = fs.readFileSync(itemLogPath!, "utf8");
|
|
const firstTest = content.indexOf("accountLabel=Account 1/3");
|
|
const firstFailure = content.indexOf("event=FAILED");
|
|
const secondTest = content.indexOf("accountLabel=Account 2/3");
|
|
const secondSuccess = content.indexOf("event=OK");
|
|
expect(firstTest).toBeGreaterThanOrEqual(0);
|
|
expect(firstFailure).toBeGreaterThan(firstTest);
|
|
expect(secondTest).toBeGreaterThan(firstFailure);
|
|
expect(secondSuccess).toBeGreaterThan(secondTest);
|
|
expect(content).not.toContain("al***ha");
|
|
expect(content).not.toContain("be***ta");
|
|
});
|
|
|
|
it("stores RapidGator metadata before a download starts", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
|
|
manager.addPackages([{ name: "metadata", links: ["https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] }]);
|
|
const snapshot = manager.getSnapshot();
|
|
const itemId = snapshot.session.packages[snapshot.session.packageOrder[0]].itemIds[0];
|
|
const internal = manager as unknown as {
|
|
session: typeof snapshot.session;
|
|
applyRapidgatorCheckResult: (item: typeof snapshot.session.items[string], result: { online: boolean; fileName: string; fileSizeBytes: number | null }) => void;
|
|
};
|
|
|
|
internal.applyRapidgatorCheckResult(internal.session.items[itemId], {
|
|
online: true,
|
|
fileName: "episode.part01.rar",
|
|
fileSizeBytes: 1_610_612_736
|
|
});
|
|
|
|
expect(internal.session.items[itemId].totalBytes).toBe(1_610_612_736);
|
|
expect(internal.session.items[itemId].status).toBe("queued");
|
|
});
|
|
|
|
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);
|
|
const settings = { ...defaultSettings(), outputDir: path.join(root, "downloads") };
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
|
manager.addPackages([{ name: "queue", links: ["https://example.com/file.bin"] }]);
|
|
const item = Object.values((manager as any).session.items)[0] as { provider: string | null };
|
|
item.provider = "realdebrid";
|
|
const resolveSpy = vi.spyOn(manager as any, "resolveExistingQueuedOpaqueFilenames");
|
|
const cleanupSpy = vi.spyOn(manager as any, "cleanupExistingExtractedArchives");
|
|
const retroactiveCleanupSpy = vi.spyOn(manager as any, "applyRetroactiveCleanupPolicy");
|
|
|
|
manager.setSettings({
|
|
...settings,
|
|
providerOrder: ["bestdebrid", "realdebrid"],
|
|
hosterRouting: { "example.com": "bestdebrid" },
|
|
completedCleanupPolicy: "immediate"
|
|
}, { settingsOnlyImport: true });
|
|
|
|
expect(item.provider).toBe("realdebrid");
|
|
expect(resolveSpy).not.toHaveBeenCalled();
|
|
expect(cleanupSpy).not.toHaveBeenCalled();
|
|
expect(retroactiveCleanupSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears the BestDebrid circuit breaker when its token changes", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-best-token-"));
|
|
tempDirs.push(root);
|
|
const stateDir = path.join(root, "state");
|
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
const settings = { ...defaultSettings(), bestToken: "old-token" };
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(stateDir));
|
|
const failures = (manager as any).providerFailures as Map<string, unknown>;
|
|
failures.set("bestdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
|
failures.set("bestdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
|
failures.set("realdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
|
|
|
manager.setSettings({ ...settings, bestToken: "new-token" });
|
|
|
|
expect(failures.has("bestdebrid")).toBe(false);
|
|
expect(failures.has("bestdebrid:rapidgator.net")).toBe(false);
|
|
expect(failures.has("realdebrid")).toBe(true);
|
|
});
|
|
|
|
it("aborts active Real-Debrid validation when the provider is disabled live", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-real-live-disable-"));
|
|
tempDirs.push(root);
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
allDebridToken: "ad-token",
|
|
providerOrder: ["realdebrid", "alldebrid"] as const
|
|
};
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
|
manager.addPackages([{ name: "live-disable", links: ["https://rapidgator.net/file/live-disable"] }]);
|
|
const session = (manager as any).session;
|
|
const item = Object.values(session.items)[0] as any;
|
|
item.provider = null;
|
|
item.status = "validating";
|
|
session.running = true;
|
|
const active = {
|
|
itemId: item.id,
|
|
packageId: item.packageId,
|
|
abortController: new AbortController(),
|
|
abortReason: "none",
|
|
resumable: true,
|
|
nonResumableCounted: false
|
|
};
|
|
(manager as any).activeTasks.set(item.id, active);
|
|
const failures = (manager as any).providerFailures as Map<string, unknown>;
|
|
failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
|
failures.set("alldebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
|
|
|
manager.setSettings({
|
|
...settings,
|
|
disabledProviders: ["realdebrid"]
|
|
});
|
|
|
|
expect(active.abortController.signal.aborted).toBe(true);
|
|
expect(active.abortReason).toBe("settings_refresh");
|
|
expect(failures.has("realdebrid:rapidgator.net")).toBe(false);
|
|
expect(failures.has("alldebrid:rapidgator.net")).toBe(true);
|
|
});
|
|
|
|
it("aborts the active fallback provider when its settings change live", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-fallback-live-disable-"));
|
|
tempDirs.push(root);
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
allDebridToken: "ad-token",
|
|
providerOrder: ["realdebrid", "alldebrid"] as const,
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
};
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
|
let fallbackStarted = false;
|
|
let providerSignal: AbortSignal | undefined;
|
|
(manager as any).debridService.unrestrictLink = async (
|
|
_link: string,
|
|
signal?: AbortSignal,
|
|
_settingsSnapshot?: AppSettings,
|
|
_preferredLeadProvider?: DebridProvider | null,
|
|
onProviderAttempt?: (provider: DebridProvider) => void
|
|
) => {
|
|
providerSignal = signal;
|
|
onProviderAttempt?.("alldebrid");
|
|
fallbackStarted = true;
|
|
return await new Promise((_resolve, reject) => {
|
|
const onAbort = (): void => reject(new Error("aborted:settings-refresh"));
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
};
|
|
manager.addPackages([{ name: "fallback-live-disable", links: ["https://rapidgator.net/file/fallback-live-disable"] }]);
|
|
|
|
await manager.start();
|
|
await waitFor(() => fallbackStarted, 5_000);
|
|
manager.setSettings({ ...settings, disabledProviders: ["alldebrid"] });
|
|
|
|
expect(providerSignal?.aborted).toBe(true);
|
|
manager.stop();
|
|
});
|
|
|
|
it("clears a provider cooldown when the provider is re-enabled live", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-real-live-reenable-"));
|
|
tempDirs.push(root);
|
|
const settings: AppSettings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
disabledProviders: ["realdebrid"]
|
|
};
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
|
const failures = (manager as any).providerFailures as Map<string, unknown>;
|
|
failures.set("realdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
|
failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
|
|
|
manager.setSettings({
|
|
...settings,
|
|
disabledProviders: []
|
|
});
|
|
|
|
expect(failures.has("realdebrid")).toBe(false);
|
|
expect(failures.has("realdebrid:rapidgator.net")).toBe(false);
|
|
});
|
|
|
|
it("invalidates only the Mega-Debrid Web session when Web credentials change", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-web-session-refresh-"));
|
|
tempDirs.push(root);
|
|
const settings = {
|
|
...defaultSettings(),
|
|
megaCredentials: "api-user:api-pass\nweb-user:web-pass",
|
|
megaDebridApiCredentials: "api-user:api-pass",
|
|
megaDebridWebCredentials: "web-user:web-pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: true
|
|
};
|
|
const invalidateMegaSession = vi.fn();
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession });
|
|
|
|
manager.setSettings({ ...settings, megaDebridApiCredentials: "api-user:new-api-pass" });
|
|
expect(invalidateMegaSession).not.toHaveBeenCalled();
|
|
|
|
manager.setSettings({ ...settings, megaDebridApiCredentials: "api-user:new-api-pass", megaDebridWebCredentials: "web-user:new-web-pass" });
|
|
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("refreshes the Mega-Debrid runtime pool when a mode is toggled with unchanged credentials", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-mode-refresh-"));
|
|
tempDirs.push(root);
|
|
const settings = {
|
|
...defaultSettings(),
|
|
megaDebridWebCredentials: "web-user:web-pass",
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false
|
|
};
|
|
const invalidateMegaSession = vi.fn();
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession });
|
|
const accountId = getMegaDebridAccountId("web-user");
|
|
primeMegaDebridRuntimeCooldownForTests(`${accountId}:web`, 120_000);
|
|
|
|
manager.setSettings({ ...settings, megaDebridWebEnabled: false });
|
|
|
|
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
|
expect(getMegaDebridAccountCooldownState(`${accountId}:web`)).toBeNull();
|
|
});
|
|
|
|
it("refreshes an active Mega-Debrid account pool without restarting the application", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-live-pool-refresh-"));
|
|
tempDirs.push(root);
|
|
const firstId = getMegaDebridAccountId("first-user");
|
|
const settings = {
|
|
...defaultSettings(),
|
|
megaCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
|
megaDebridWebCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
|
megaDebridWebEnabled: true
|
|
};
|
|
const invalidateMegaSession = vi.fn();
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession });
|
|
manager.addPackages([{ name: "pool-refresh", links: ["https://rapidgator.net/file/pool-refresh"] }]);
|
|
const session = (manager as any).session;
|
|
const item = Object.values(session.items)[0] as any;
|
|
item.provider = "megadebrid-web";
|
|
item.status = "validating";
|
|
session.running = true;
|
|
const active = {
|
|
itemId: item.id,
|
|
packageId: item.packageId,
|
|
abortController: new AbortController(),
|
|
abortReason: "none",
|
|
resumable: true,
|
|
nonResumableCounted: false
|
|
};
|
|
(manager as any).activeTasks.set(item.id, active);
|
|
const failures = (manager as any).providerFailures as Map<string, unknown>;
|
|
primeMegaDebridRuntimeCooldownForTests(`${firstId}:web`, 120_000);
|
|
failures.set("megadebrid-web:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
|
failures.set("megadebrid-api:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
|
failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
|
|
|
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [firstId] });
|
|
|
|
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
|
expect(active.abortController.signal.aborted).toBe(true);
|
|
expect(active.abortReason).toBe("settings_refresh");
|
|
expect(getMegaDebridAccountCooldownState(`${firstId}:web`)).toBeNull();
|
|
expect(failures.has("megadebrid-web:rapidgator.net")).toBe(false);
|
|
expect(failures.has("megadebrid-api:rapidgator.net")).toBe(false);
|
|
expect(failures.has("realdebrid:rapidgator.net")).toBe(true);
|
|
});
|
|
|
|
it("refreshes an active Debrid-Link key pool without restarting the application", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debrid-link-live-pool-refresh-"));
|
|
tempDirs.push(root);
|
|
const keys = parseDebridLinkApiKeys("first-key\nsecond-key");
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "first-key\nsecond-key",
|
|
debridLinkDisabledKeyIds: [keys[1].id],
|
|
providerOrder: ["debridlink" as const]
|
|
};
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
|
manager.addPackages([{ name: "debrid-link-pool-refresh", links: ["https://rapidgator.net/file/debrid-link-pool-refresh"] }]);
|
|
const session = (manager as any).session;
|
|
const item = Object.values(session.items)[0] as any;
|
|
item.provider = "debridlink";
|
|
item.status = "validating";
|
|
session.running = true;
|
|
const active = {
|
|
itemId: item.id,
|
|
packageId: item.packageId,
|
|
abortController: new AbortController(),
|
|
abortReason: "none",
|
|
resumable: true,
|
|
nonResumableCounted: false
|
|
};
|
|
(manager as any).activeTasks.set(item.id, active);
|
|
|
|
manager.setSettings({ ...settings, debridLinkDisabledKeyIds: [keys[0].id] });
|
|
|
|
expect(active.abortController.signal.aborted).toBe(true);
|
|
expect(active.abortReason).toBe("settings_refresh");
|
|
});
|
|
|
|
it("pauses a running queue when the last account is disabled and resumes after reactivation", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-last-account-live-toggle-"));
|
|
tempDirs.push(root);
|
|
const login = "only-user";
|
|
const accountId = getMegaDebridAccountId(login);
|
|
const settings = {
|
|
...defaultSettings(),
|
|
megaDebridWebCredentials: `${login}:only-pass`,
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false,
|
|
megaDebridPreferApi: false,
|
|
providerOrder: ["megadebrid-web" as const]
|
|
};
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
|
manager.addPackages([{ name: "last-account-toggle", links: ["https://rapidgator.net/file/last-account-toggle"] }]);
|
|
const item = Object.values((manager as any).session.items)[0] as any;
|
|
item.provider = "megadebrid-web";
|
|
item.status = "validating";
|
|
(manager as any).session.running = true;
|
|
const active = {
|
|
itemId: item.id,
|
|
packageId: item.packageId,
|
|
abortController: new AbortController(),
|
|
abortReason: "none",
|
|
resumable: true,
|
|
nonResumableCounted: false
|
|
};
|
|
(manager as any).activeTasks.set(item.id, active);
|
|
const schedulerSpy = vi.spyOn(manager as any, "ensureScheduler").mockResolvedValue(undefined);
|
|
|
|
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [accountId] });
|
|
|
|
expect(manager.getSnapshot().session.paused).toBe(true);
|
|
expect(manager.getSnapshot().canStart).toBe(false);
|
|
expect(active.abortController.signal.aborted).toBe(true);
|
|
expect(active.abortReason).toBe("settings_refresh");
|
|
|
|
(manager as any).activeTasks.clear();
|
|
item.status = "queued";
|
|
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [] });
|
|
await Promise.resolve();
|
|
|
|
expect(manager.getSnapshot().session.paused).toBe(false);
|
|
expect(manager.getSnapshot().canStart).toBe(false);
|
|
expect(schedulerSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves a manual pause when the last account is disabled and reactivated", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-pause-account-toggle-"));
|
|
tempDirs.push(root);
|
|
const login = "manual-pause-user";
|
|
const accountId = getMegaDebridAccountId(login);
|
|
const settings = {
|
|
...defaultSettings(),
|
|
megaDebridWebCredentials: `${login}:only-pass`,
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false,
|
|
megaDebridPreferApi: false,
|
|
providerOrder: ["megadebrid-web" as const]
|
|
};
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
|
(manager as any).session.running = true;
|
|
(manager as any).session.paused = true;
|
|
const schedulerSpy = vi.spyOn(manager as any, "ensureScheduler").mockResolvedValue(undefined);
|
|
|
|
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [accountId] });
|
|
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [] });
|
|
await Promise.resolve();
|
|
|
|
expect(manager.getSnapshot().session.paused).toBe(true);
|
|
expect(schedulerSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("continues a running Mega-Web download with the next enabled account after a live settings change", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-live-account-switch-"));
|
|
tempDirs.push(root);
|
|
const payload = Buffer.alloc(256 * 1024, 0x5a);
|
|
const server = http.createServer((_req, res) => {
|
|
res.writeHead(200, {
|
|
"Content-Length": String(payload.length),
|
|
"Content-Type": "application/octet-stream"
|
|
});
|
|
res.end(payload);
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Testserver konnte nicht gestartet werden");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/account-two.bin`;
|
|
const firstAccountId = getMegaDebridAccountId("first-user");
|
|
const accountCalls: string[] = [];
|
|
const settings = {
|
|
...defaultSettings(),
|
|
megaCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
|
megaDebridWebCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false,
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [],
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 1
|
|
};
|
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), {
|
|
invalidateMegaSession: vi.fn(),
|
|
megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal, account?: { login: string; password: string }) => {
|
|
const login = account?.login || "";
|
|
accountCalls.push(login);
|
|
if (login === "first-user") {
|
|
return await new Promise<UnrestrictedLink | null>((_resolve, reject) => {
|
|
const onAbort = (): void => reject(new Error("aborted:settings-refresh"));
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
}
|
|
return {
|
|
fileName: "account-two.bin",
|
|
directUrl,
|
|
fileSize: payload.length,
|
|
retriesUsed: 0
|
|
};
|
|
})
|
|
});
|
|
manager.addPackages([{ name: "live-account-switch", links: ["https://rapidgator.net/file/live-account-switch"] }]);
|
|
|
|
try {
|
|
await manager.start();
|
|
await waitFor(() => accountCalls.includes("first-user"), 10_000);
|
|
|
|
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [firstAccountId] });
|
|
|
|
await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.status === "completed"), 15_000);
|
|
expect(accountCalls).toEqual(["first-user", "second-user"]);
|
|
expect(fs.readFileSync(path.join(root, "downloads", "live-account-switch", "account-two.bin"))).toEqual(payload);
|
|
} finally {
|
|
manager.stop();
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 20_000);
|
|
|
|
it("aborts active work immediately when downloads are paused", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pause-active-"));
|
|
tempDirs.push(root);
|
|
const invalidateMegaSession = vi.fn();
|
|
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession });
|
|
manager.addPackages([{ name: "pause-active", links: ["https://rapidgator.net/file/pause-active"] }]);
|
|
const session = (manager as any).session;
|
|
const item = Object.values(session.items)[0] as any;
|
|
item.status = "validating";
|
|
session.running = true;
|
|
const active = {
|
|
itemId: item.id,
|
|
packageId: item.packageId,
|
|
abortController: new AbortController(),
|
|
abortReason: "none",
|
|
resumable: true,
|
|
nonResumableCounted: false
|
|
};
|
|
(manager as any).activeTasks.set(item.id, active);
|
|
|
|
expect(manager.togglePause()).toBe(true);
|
|
|
|
expect(active.abortController.signal.aborted).toBe(true);
|
|
expect(active.abortReason).toBe("pause");
|
|
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("releases only Mega-Debrid reset parks when a newly usable account appears", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-account-refresh-"));
|
|
tempDirs.push(root);
|
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
|
const previousSettings = {
|
|
...defaultSettings(),
|
|
megaCredentials: "old@example.test:old-secret",
|
|
megaDebridApiEnabled: true
|
|
};
|
|
const session = emptySession();
|
|
const megaPackageId = "mega-refresh-package";
|
|
const otherPackageId = "other-refresh-package";
|
|
const megaItemId = "mega-refresh-item";
|
|
const otherItemId = "other-refresh-item";
|
|
const createdAt = Date.now();
|
|
session.packageOrder = [megaPackageId, otherPackageId];
|
|
session.packages[megaPackageId] = {
|
|
id: megaPackageId,
|
|
name: "Mega refresh",
|
|
status: "downloading",
|
|
itemIds: [megaItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
} as any;
|
|
session.packages[otherPackageId] = {
|
|
id: otherPackageId,
|
|
name: "Other refresh",
|
|
status: "queued",
|
|
itemIds: [otherItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
} as any;
|
|
session.items[megaItemId] = {
|
|
id: megaItemId,
|
|
packageId: megaPackageId,
|
|
url: "https://rapidgator.net/file/mega-refresh",
|
|
provider: "megadebrid-api",
|
|
status: "queued",
|
|
retries: 1,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "mega-refresh.rar",
|
|
targetPath: "",
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "limit",
|
|
fullStatus: "Mega-Debrid bis Tagesreset gesperrt, Pause 3600s",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
} as any;
|
|
session.items[otherItemId] = {
|
|
...session.items[megaItemId],
|
|
id: otherItemId,
|
|
packageId: otherPackageId,
|
|
url: "https://ddownload.com/file/other-refresh",
|
|
provider: "realdebrid",
|
|
fileName: "other-refresh.rar",
|
|
fullStatus: "Netzwerk-Retry in 60s"
|
|
} as any;
|
|
session.running = true;
|
|
const manager = new DownloadManager(previousSettings, session, storagePaths);
|
|
session.running = true;
|
|
session.packages[megaPackageId].status = "downloading";
|
|
session.items[megaItemId].status = "queued";
|
|
session.items[megaItemId].fullStatus = "Mega-Debrid bis Tagesreset gesperrt, Pause 3600s";
|
|
session.items[megaItemId].provider = "megadebrid-api";
|
|
session.items[otherItemId].status = "queued";
|
|
session.items[otherItemId].fullStatus = "Netzwerk-Retry in 60s";
|
|
session.items[otherItemId].provider = "realdebrid";
|
|
const retryAfter = (manager as any).retryAfterByItem as Map<string, number>;
|
|
const retryState = (manager as any).retryStateByItem as Map<string, unknown>;
|
|
retryAfter.set(megaItemId, Date.now() + 3_600_000);
|
|
retryAfter.set(otherItemId, Date.now() + 60_000);
|
|
retryState.set(megaItemId, { unrestrictRetries: 1 });
|
|
retryState.set(otherItemId, { genericErrorRetries: 1 });
|
|
const scheduler = vi.spyOn(manager as any, "ensureScheduler").mockResolvedValue(undefined);
|
|
vi.spyOn(manager as any, "cleanupExistingExtractedArchives").mockResolvedValue(0);
|
|
|
|
manager.setSettings({
|
|
...previousSettings,
|
|
megaCredentials: "old@example.test:old-secret\nnew@example.test:new-secret",
|
|
megaDebridDisabledAccountIds: [getMegaDebridAccountId("old@example.test")]
|
|
});
|
|
|
|
expect(retryAfter.has(megaItemId)).toBe(false);
|
|
expect(retryState.has(megaItemId)).toBe(false);
|
|
expect(session.items[megaItemId].fullStatus).toBe("Wartet");
|
|
expect(session.packages[megaPackageId].status).toBe("queued");
|
|
expect(retryAfter.has(otherItemId)).toBe(true);
|
|
expect(retryState.has(otherItemId)).toBe(true);
|
|
expect(session.items[otherItemId].fullStatus).toBe("Netzwerk-Retry in 60s");
|
|
expect(scheduler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("updates the package status atomically when an active item is queued for retry", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-package-status-"));
|
|
tempDirs.push(root);
|
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
|
initPackageLogs(storagePaths.baseDir);
|
|
initItemLogs(storagePaths.baseDir);
|
|
const session = emptySession();
|
|
const packageId = "retry-status-package";
|
|
const itemId = "retry-status-item";
|
|
const createdAt = Date.now();
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "Retry status",
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
} as any;
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://rapidgator.net/file/retry-status",
|
|
provider: "megadebrid-api",
|
|
status: "downloading",
|
|
retries: 1,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "retry-status.rar",
|
|
targetPath: "",
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Download läuft",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
} as any;
|
|
const manager = new DownloadManager(defaultSettings(), session, storagePaths);
|
|
session.packages[packageId].status = "downloading";
|
|
const active = {
|
|
itemId,
|
|
packageId,
|
|
abortController: new AbortController(),
|
|
abortReason: "none",
|
|
resumable: true,
|
|
nonResumableCounted: false,
|
|
blockedOnDiskWrite: false,
|
|
blockedOnDiskSince: 0
|
|
};
|
|
|
|
(manager as any).queueRetry(session.items[itemId], active, 60_000, "Mega-Debrid bis Tagesreset gesperrt, Pause 60s");
|
|
|
|
expect(session.items[itemId].status).toBe("queued");
|
|
expect(session.packages[packageId].status).toBe("queued");
|
|
});
|
|
|
|
it("records history duration from the first actual package start", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-"));
|
|
tempDirs.push(root);
|
|
const historyEntries: Array<{ durationSeconds: number; downloadedBytes: number }> = [];
|
|
const manager = new DownloadManager(
|
|
defaultSettings(),
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{
|
|
onHistoryEntry: (entry) => {
|
|
historyEntries.push({
|
|
durationSeconds: entry.durationSeconds,
|
|
downloadedBytes: entry.downloadedBytes
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
const packageId = "history-pkg";
|
|
const itemId = "history-item";
|
|
const pkg = {
|
|
id: packageId,
|
|
name: "History Test",
|
|
outputDir: path.join(root, "downloads", "History Test"),
|
|
extractDir: path.join(root, "extract", "History Test"),
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
priority: "normal",
|
|
createdAt: 1_000,
|
|
updatedAt: 61_000,
|
|
downloadStartedAt: 15_000,
|
|
downloadCompletedAt: 60_000
|
|
};
|
|
const item = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://example.com/history.rar",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 90 * 1024 * 1024,
|
|
totalBytes: 90 * 1024 * 1024,
|
|
progressPercent: 100,
|
|
fileName: "history.rar",
|
|
targetPath: path.join(pkg.outputDir, "history.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig",
|
|
createdAt: 15_000,
|
|
updatedAt: 60_000
|
|
};
|
|
|
|
(manager as any).recordPackageHistory(packageId, pkg, [item]);
|
|
|
|
expect(historyEntries).toHaveLength(1);
|
|
expect(historyEntries[0]?.durationSeconds).toBe(45);
|
|
expect(historyEntries[0]?.downloadedBytes).toBe(90 * 1024 * 1024);
|
|
});
|
|
|
|
it("findFallbackProviderNotInCooldown routes around cooled providers and returns null only when all are cooled", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-fallback-route-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
debridLinkApiKeys: "dl-token",
|
|
providerOrder: ["realdebrid", "debridlink"],
|
|
providerPrimary: "realdebrid",
|
|
providerSecondary: "debridlink",
|
|
providerTertiary: "none",
|
|
autoProviderFallback: true
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const item = { id: "fb-item", url: "https://hoster.example/file.bin", provider: "realdebrid" } as any;
|
|
|
|
expect((manager as any).findFallbackProviderNotInCooldown(item)).toBe("realdebrid");
|
|
|
|
(manager as any).applyProviderBusyBackoff("realdebrid", 60000);
|
|
expect((manager as any).findFallbackProviderNotInCooldown(item)).toBe("debridlink");
|
|
|
|
(manager as any).applyProviderBusyBackoff("debridlink", 60000);
|
|
expect((manager as any).findFallbackProviderNotInCooldown(item)).toBeNull();
|
|
});
|
|
|
|
it("fails an item terminally instead of looping forever when the shelve fires under a finite retryLimit", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shelve-loop-"));
|
|
tempDirs.push(root);
|
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
|
initPackageLogs(storagePaths.baseDir);
|
|
initItemLogs(storagePaths.baseDir);
|
|
|
|
const session = emptySession();
|
|
const packageId = "shelve-pkg";
|
|
const itemId = "shelve-item";
|
|
const outputDir = path.join(root, "downloads", "Shelve");
|
|
const extractDir = path.join(root, "extract", "Shelve");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId, name: "Shelve", outputDir, extractDir,
|
|
status: "downloading", itemIds: [itemId], cancelled: false, enabled: true,
|
|
createdAt: Date.now(), updatedAt: Date.now()
|
|
} as any;
|
|
session.items[itemId] = {
|
|
id: itemId, packageId, url: "https://hoster.example/flaky.bin", provider: "realdebrid",
|
|
status: "downloading", retries: 5, speedBps: 0, downloadedBytes: 0, totalBytes: null,
|
|
progressPercent: 0, fileName: "flaky.bin", targetPath: "", resumable: true,
|
|
attempts: 0, lastError: "", fullStatus: "", createdAt: Date.now(), updatedAt: Date.now()
|
|
} as any;
|
|
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", retryLimit: 5, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
|
session,
|
|
storagePaths
|
|
);
|
|
(manager as any).retryStateByItem.set(itemId, { freshRetryUsed: true, resumeHardResetUsed: true, stallRetries: 0, genericErrorRetries: 15, unrestrictRetries: 0 });
|
|
(manager as any).debridService.unrestrictLink = async () => { throw new Error("Download-Backend Fehler xyz"); };
|
|
|
|
const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
|
|
(manager as any).activeTasks.set(itemId, active);
|
|
|
|
await (manager as any).processItem(active);
|
|
|
|
expect(session.items[itemId].status).toBe("failed");
|
|
expect(session.items[itemId].retries).toBeLessThan(20);
|
|
});
|
|
|
|
it("prefers the short Mega cooldown over the until-reset park when an aggregate error carries both tokens", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-both-"));
|
|
tempDirs.push(root);
|
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
|
initPackageLogs(storagePaths.baseDir);
|
|
initItemLogs(storagePaths.baseDir);
|
|
|
|
const session = emptySession();
|
|
const packageId = "mega-both-pkg";
|
|
const itemId = "mega-both-item";
|
|
const outputDir = path.join(root, "downloads", "MegaBoth");
|
|
const extractDir = path.join(root, "extract", "MegaBoth");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId, name: "MegaBoth", outputDir, extractDir,
|
|
status: "downloading", itemIds: [itemId], cancelled: false, enabled: true,
|
|
createdAt: Date.now(), updatedAt: Date.now()
|
|
} as any;
|
|
session.items[itemId] = {
|
|
id: itemId, packageId, url: "https://rapidgator.net/file/mega-both.rar", provider: "megadebrid-api",
|
|
status: "downloading", retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null,
|
|
progressPercent: 0, fileName: "mega-both.rar", targetPath: "", resumable: true,
|
|
attempts: 0, lastError: "", fullStatus: "", createdAt: Date.now(), updatedAt: Date.now()
|
|
} as any;
|
|
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), megaLogin: "u", megaPassword: "p", megaCredentials: "u:p", megaDebridApiEnabled: true, megaDebridWebEnabled: true, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
|
session,
|
|
storagePaths
|
|
);
|
|
(manager as any).retryStateByItem.set(itemId, { freshRetryUsed: true, resumeHardResetUsed: true, stallRetries: 0, genericErrorRetries: 0, unrestrictRetries: 0 });
|
|
(manager as any).debridService.unrestrictLink = async () => {
|
|
throw new Error("Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_reset_park:86400000:Alle Accounts bis zum Tagesreset gesperrt | Mega-Debrid Web: mega_debrid_cooldown:30000:rate limit");
|
|
};
|
|
|
|
const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
|
|
(manager as any).activeTasks.set(itemId, active);
|
|
|
|
const before = Date.now();
|
|
await (manager as any).processItem(active);
|
|
|
|
const retryAfter = (manager as any).retryAfterByItem.get(itemId) as number;
|
|
expect(retryAfter).toBeGreaterThan(0);
|
|
const delayMs = retryAfter - before;
|
|
expect(delayMs).toBeLessThan(60000);
|
|
expect(session.items[itemId].fullStatus).toMatch(/Cooldown/i);
|
|
expect(session.items[itemId].fullStatus).not.toMatch(/Tagesreset/i);
|
|
});
|
|
|
|
it("keeps the quick post-process requeue once the final package items are finished", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-final-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "postprocess-final-pkg";
|
|
const firstItemId = "postprocess-final-item-1";
|
|
const secondItemId = "postprocess-final-item-2";
|
|
const createdAt = Date.now() - 20_000;
|
|
const packageOutputDir = path.join(root, "downloads", "PostProcess Final Round");
|
|
const packageExtractDir = path.join(root, "extract", "PostProcess Final Round");
|
|
fs.mkdirSync(packageOutputDir, { recursive: true });
|
|
fs.mkdirSync(packageExtractDir, { recursive: true });
|
|
fs.writeFileSync(path.join(packageOutputDir, "final-1.rar"), Buffer.alloc(100, 1));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "PostProcess Final Round",
|
|
outputDir: packageOutputDir,
|
|
extractDir: packageExtractDir,
|
|
status: "downloading",
|
|
itemIds: [firstItemId, secondItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[firstItemId] = {
|
|
id: firstItemId,
|
|
packageId,
|
|
url: "https://example.com/final-1.rar",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 100,
|
|
totalBytes: 100,
|
|
progressPercent: 100,
|
|
fileName: "final-1.rar",
|
|
targetPath: path.join(packageOutputDir, "final-1.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt - Done (<1s)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[secondItemId] = {
|
|
id: secondItemId,
|
|
packageId,
|
|
url: "https://example.com/final-2.rar",
|
|
provider: "realdebrid",
|
|
status: "downloading",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 90,
|
|
totalBytes: 100,
|
|
progressPercent: 90,
|
|
fileName: "final-2.rar",
|
|
targetPath: path.join(packageOutputDir, "final-2.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Download läuft",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
hybridExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
expect((manager as any).shouldCollapseQuickPostProcessRequeue(packageId)).toBe(true);
|
|
|
|
const item = (manager as any).session.items[secondItemId];
|
|
item.status = "completed";
|
|
item.downloadedBytes = item.totalBytes;
|
|
item.progressPercent = 100;
|
|
item.fullStatus = "Entpacken - Ausstehend";
|
|
item.updatedAt = Date.now();
|
|
|
|
expect((manager as any).session.items[firstItemId].status).toBe("completed");
|
|
expect((manager as any).session.items[secondItemId].status).toBe("completed");
|
|
expect((manager as any).session.packages[packageId].itemIds).toEqual([firstItemId, secondItemId]);
|
|
|
|
expect((manager as any).shouldCollapseQuickPostProcessRequeue(packageId)).toBe(false);
|
|
});
|
|
|
|
it("honors maxParallelExtract for concurrent post-process slots", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-slots-"));
|
|
tempDirs.push(root);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
maxParallelExtract: 4
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await (manager as any).acquirePostProcessSlot("pkg-1");
|
|
await (manager as any).acquirePostProcessSlot("pkg-2");
|
|
await (manager as any).acquirePostProcessSlot("pkg-3");
|
|
await (manager as any).acquirePostProcessSlot("pkg-4");
|
|
|
|
expect((manager as any).packagePostProcessActive).toBe(4);
|
|
|
|
let fifthResolved = false;
|
|
const fifth = (manager as any).acquirePostProcessSlot("pkg-5").then(() => {
|
|
fifthResolved = true;
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
expect(fifthResolved).toBe(false);
|
|
|
|
(manager as any).releasePostProcessSlot();
|
|
await fifth;
|
|
|
|
expect(fifthResolved).toBe(true);
|
|
expect((manager as any).packagePostProcessActive).toBe(4);
|
|
});
|
|
|
|
it("extractNow only re-arms completed items that are not already extracted", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-now-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "extract-now-pkg";
|
|
const createdAt = Date.now() - 20_000;
|
|
const outputDir = path.join(root, "downloads", "Extract Now Test");
|
|
const extractDir = path.join(root, "extract", "Extract Now Test");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
|
|
const specs = [
|
|
{ id: "extract-now-item-1", fileName: "show.e01.rar", fullStatus: "Entpackt - Done (<1s)" },
|
|
{ id: "extract-now-item-2", fileName: "show.e02.rar", fullStatus: "Entpackt - Done (1.2s)" },
|
|
{ id: "extract-now-item-3", fileName: "show.e03.rar", fullStatus: "Entpacken - Ausstehend" }
|
|
] as const;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "Extract Now Test",
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: specs.map((spec) => spec.id),
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
for (const spec of specs) {
|
|
const targetPath = path.join(outputDir, spec.fileName);
|
|
fs.writeFileSync(targetPath, Buffer.alloc(128, 1));
|
|
session.items[spec.id] = {
|
|
id: spec.id,
|
|
packageId,
|
|
url: `https://example.com/${spec.fileName}`,
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 128,
|
|
totalBytes: 128,
|
|
progressPercent: 100,
|
|
fileName: spec.fileName,
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: spec.fullStatus,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
}
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
hybridExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.extractNow(packageId);
|
|
|
|
expect((manager as any).session.items["extract-now-item-1"].fullStatus).toBe("Entpackt - Done (<1s)");
|
|
expect((manager as any).session.items["extract-now-item-2"].fullStatus).toBe("Entpackt - Done (1.2s)");
|
|
expect((manager as any).session.items["extract-now-item-3"].fullStatus).toBe("Entpacken - Ausstehend");
|
|
expect((manager as any).session.packages[packageId].status).toBe("queued");
|
|
});
|
|
|
|
it("merges duplicate-suffixed completed startup items back into the canonical queued item", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-merge-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "startup-dup-pkg";
|
|
const originalItemId = "startup-dup-original";
|
|
const duplicateItemId = "startup-dup-copy";
|
|
const createdAt = Date.now() - 20_000;
|
|
const outputDir = path.join(root, "downloads", "Startup Duplicate Merge");
|
|
const extractDir = path.join(root, "extract", "Startup Duplicate Merge");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
|
|
const canonicalPath = path.join(outputDir, "episode.part1.rar");
|
|
const duplicatePath = path.join(outputDir, "episode.part1 (1).rar");
|
|
fs.writeFileSync(duplicatePath, Buffer.alloc(128, 7));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "Startup Duplicate Merge",
|
|
outputDir,
|
|
extractDir,
|
|
status: "failed",
|
|
itemIds: [originalItemId, duplicateItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
priority: "normal",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[originalItemId] = {
|
|
id: originalItemId,
|
|
packageId,
|
|
url: "https://example.com/episode.part1.rar",
|
|
provider: "realdebrid",
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: 128,
|
|
progressPercent: 0,
|
|
fileName: "episode.part1.rar",
|
|
targetPath: canonicalPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[duplicateItemId] = {
|
|
id: duplicateItemId,
|
|
packageId,
|
|
url: "https://example.com/episode.part1.rar",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 128,
|
|
totalBytes: 128,
|
|
progressPercent: 100,
|
|
fileName: "episode.part1.rar",
|
|
targetPath: duplicatePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (128 B)",
|
|
createdAt,
|
|
updatedAt: createdAt + 5_000
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const current = (manager as any).session;
|
|
expect(current.packages[packageId].itemIds).toEqual([originalItemId]);
|
|
expect(current.items[duplicateItemId]).toBeUndefined();
|
|
expect(current.items[originalItemId].status).toBe("completed");
|
|
expect(current.items[originalItemId].fullStatus).toBe("Fertig (128 B)");
|
|
expect(current.items[originalItemId].targetPath).toBe(canonicalPath);
|
|
expect(fs.existsSync(canonicalPath)).toBe(true);
|
|
expect(fs.existsSync(duplicatePath)).toBe(false);
|
|
});
|
|
|
|
it("does not replace a larger good canonical file with a smaller duplicate on startup dedup", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-size-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "dup-size-pkg";
|
|
const originalItemId = "dup-size-original";
|
|
const duplicateItemId = "dup-size-copy";
|
|
const createdAt = Date.now() - 20_000;
|
|
const outputDir = path.join(root, "downloads", "Dup Size");
|
|
const extractDir = path.join(root, "extract", "Dup Size");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
|
|
const canonicalPath = path.join(outputDir, "movie.mkv");
|
|
const duplicatePath = path.join(outputDir, "movie (1).mkv");
|
|
fs.writeFileSync(canonicalPath, Buffer.alloc(1024, 5));
|
|
fs.writeFileSync(duplicatePath, Buffer.alloc(256, 9));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "Dup Size",
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [originalItemId, duplicateItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
priority: "normal",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[originalItemId] = {
|
|
id: originalItemId,
|
|
packageId,
|
|
url: "https://example.com/movie.mkv",
|
|
provider: "realdebrid",
|
|
status: "failed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: 1024,
|
|
progressPercent: 0,
|
|
fileName: "movie.mkv",
|
|
targetPath: canonicalPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "Fehlgeschlagen",
|
|
fullStatus: "",
|
|
createdAt,
|
|
updatedAt: createdAt + 10_000
|
|
};
|
|
session.items[duplicateItemId] = {
|
|
id: duplicateItemId,
|
|
packageId,
|
|
url: "https://example.com/movie.mkv",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 256,
|
|
totalBytes: 256,
|
|
progressPercent: 100,
|
|
fileName: "movie.mkv",
|
|
targetPath: duplicatePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig",
|
|
createdAt,
|
|
updatedAt: createdAt + 5_000
|
|
};
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
expect(fs.existsSync(canonicalPath)).toBe(true);
|
|
expect(fs.statSync(canonicalPath).size).toBe(1024);
|
|
});
|
|
|
|
it("keeps a stronger extracted canonical startup state when removing stale duplicate copies", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-keep-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "startup-dup-keep-pkg";
|
|
const originalItemId = "startup-dup-keep-original";
|
|
const duplicateItemId = "startup-dup-keep-copy";
|
|
const createdAt = Date.now() - 20_000;
|
|
const outputDir = path.join(root, "downloads", "Startup Duplicate Keep");
|
|
const extractDir = path.join(root, "extract", "Startup Duplicate Keep");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
|
|
const canonicalPath = path.join(outputDir, "episode.part1.rar");
|
|
const duplicatePath = path.join(outputDir, "episode.part1 (1).rar");
|
|
fs.writeFileSync(duplicatePath, Buffer.alloc(256, 9));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "Startup Duplicate Keep",
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [originalItemId, duplicateItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
priority: "normal",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[originalItemId] = {
|
|
id: originalItemId,
|
|
packageId,
|
|
url: "https://example.com/episode.part1.rar",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 256,
|
|
totalBytes: 256,
|
|
progressPercent: 100,
|
|
fileName: "episode.part1.rar",
|
|
targetPath: canonicalPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt - Done (1.0s)",
|
|
createdAt,
|
|
updatedAt: createdAt + 10_000
|
|
};
|
|
session.items[duplicateItemId] = {
|
|
id: duplicateItemId,
|
|
packageId,
|
|
url: "https://example.com/episode.part1.rar",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 256,
|
|
totalBytes: 256,
|
|
progressPercent: 100,
|
|
fileName: "episode.part1.rar",
|
|
targetPath: duplicatePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "Checksum error",
|
|
fullStatus: "Entpack-Fehler: Checksum error",
|
|
createdAt,
|
|
updatedAt: createdAt + 5_000
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const current = (manager as any).session;
|
|
expect(current.packages[packageId].itemIds).toEqual([originalItemId]);
|
|
expect(current.items[duplicateItemId]).toBeUndefined();
|
|
expect(current.items[originalItemId].status).toBe("completed");
|
|
expect(current.items[originalItemId].fullStatus).toBe("Entpackt - Done (1.0s)");
|
|
expect(current.items[originalItemId].targetPath).toBe(canonicalPath);
|
|
expect(fs.existsSync(canonicalPath)).toBe(true);
|
|
expect(fs.existsSync(duplicatePath)).toBe(false);
|
|
});
|
|
|
|
function createCompletedArchiveSession(root: string, packageName: string, extractedFileName: string): {
|
|
session: ReturnType<typeof emptySession>;
|
|
packageId: string;
|
|
itemId: string;
|
|
outputDir: string;
|
|
extractDir: string;
|
|
originalExtractedPath: string;
|
|
} {
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const zip = new AdmZip();
|
|
zip.addFile(extractedFileName, Buffer.from("video"));
|
|
const archivePath = path.join(outputDir, "episode.zip");
|
|
zip.writeZip(archivePath);
|
|
const archiveSize = fs.statSync(archivePath).size;
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const itemId = `${packageName}-item`;
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: `https://dummy/${packageName}`,
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: archiveSize,
|
|
totalBytes: archiveSize,
|
|
progressPercent: 100,
|
|
fileName: "episode.zip",
|
|
targetPath: archivePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (100 MB)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
return {
|
|
session,
|
|
packageId,
|
|
itemId,
|
|
outputDir,
|
|
extractDir,
|
|
originalExtractedPath: path.join(extractDir, extractedFileName)
|
|
};
|
|
}
|
|
|
|
function createCompletedArchiveSessionFromArchive(
|
|
root: string,
|
|
packageName: string,
|
|
archiveEntries: Array<{ name: string; data: Buffer | string }>
|
|
): {
|
|
session: ReturnType<typeof emptySession>;
|
|
packageId: string;
|
|
itemId: string;
|
|
outputDir: string;
|
|
extractDir: string;
|
|
archivePath: string;
|
|
} {
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const zip = new AdmZip();
|
|
for (const entry of archiveEntries) {
|
|
zip.addFile(entry.name, typeof entry.data === "string" ? Buffer.from(entry.data) : entry.data);
|
|
}
|
|
const archivePath = path.join(outputDir, "episode.zip");
|
|
zip.writeZip(archivePath);
|
|
const archiveSize = fs.statSync(archivePath).size;
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const itemId = `${packageName}-item`;
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: `https://dummy/${packageName}`,
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: archiveSize,
|
|
totalBytes: archiveSize,
|
|
progressPercent: 100,
|
|
fileName: "episode.zip",
|
|
targetPath: archivePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (100 MB)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
return {
|
|
session,
|
|
packageId,
|
|
itemId,
|
|
outputDir,
|
|
extractDir,
|
|
archivePath
|
|
};
|
|
}
|
|
|
|
it("retries interrupted streams and resumes download", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(512 * 1024, 11);
|
|
let directCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/direct") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
directCalls += 1;
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
|
|
if (directCalls === 1 && start === 0) {
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.write(binary.subarray(0, Math.floor(binary.length / 2)));
|
|
res.socket?.destroy();
|
|
return;
|
|
}
|
|
|
|
const chunk = binary.subarray(start);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/direct`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "episode.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
manager.addPackages([{ name: "retry", links: ["https://dummy/retry"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.retries).toBeGreaterThan(0);
|
|
expect(directCalls).toBeGreaterThan(1);
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("deletes the orphaned partial file when a downloading item is removed mid-stream", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(512 * 1024, 7);
|
|
|
|
let destroyHeld: () => void = () => {};
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/direct") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.write(binary.subarray(0, 64 * 1024));
|
|
destroyHeld = () => {
|
|
try { res.socket?.destroy(); } catch { }
|
|
};
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/direct`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({ download: directUrl, filename: "held.mkv", filesize: binary.length }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
retryLimit: 0
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "held", links: ["https://dummy/held"] }]);
|
|
await manager.start();
|
|
|
|
let targetPath = "";
|
|
await waitFor(() => {
|
|
const it = Object.values(manager.getSnapshot().session.items)[0];
|
|
if (it && it.status === "downloading" && it.targetPath && fs.existsSync(it.targetPath)) {
|
|
targetPath = it.targetPath;
|
|
return true;
|
|
}
|
|
return false;
|
|
}, 20000);
|
|
|
|
const itemId = Object.values(manager.getSnapshot().session.items)[0].id;
|
|
expect(fs.existsSync(targetPath)).toBe(true);
|
|
|
|
manager.removeItem(itemId);
|
|
destroyHeld();
|
|
|
|
await waitFor(() => !fs.existsSync(targetPath), 20000);
|
|
expect(fs.existsSync(targetPath)).toBe(false);
|
|
} finally {
|
|
destroyHeld();
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("rewinds resumed range after terminated streams so corrupted tail bytes are replaced", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(3 * 1024 * 1024, 41);
|
|
const injectedErrorChunk = Buffer.from(
|
|
"{\"error\":\"Missed session \\\"resume-tail\\\" after 2000 ms\",\"success\":false}",
|
|
"utf8"
|
|
);
|
|
const firstChunkBytes = 2 * 1024 * 1024;
|
|
const corruptedResumeStart = firstChunkBytes + injectedErrorChunk.length;
|
|
const starts: number[] = [];
|
|
let directCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/resume-rewind") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
directCalls += 1;
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
starts.push(start);
|
|
|
|
if (directCalls === 1 && start === 0) {
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.write(binary.subarray(0, firstChunkBytes));
|
|
res.write(injectedErrorChunk);
|
|
setTimeout(() => {
|
|
res.socket?.destroy();
|
|
}, 120);
|
|
return;
|
|
}
|
|
|
|
const chunk = binary.subarray(start);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/resume-rewind`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "resume-rewind.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "resume-rewind", links: ["https://dummy/resume-rewind"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(directCalls).toBeGreaterThanOrEqual(2);
|
|
expect(starts[0]).toBe(0);
|
|
expect(starts[1]).toBeGreaterThan(0);
|
|
expect(starts[1]).toBeLessThan(corruptedResumeStart);
|
|
expect(fs.readFileSync(item.targetPath).equals(binary)).toBe(true);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("does not embed a final-attempt garbage tail across a fresh-link resume (known-total content integrity)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-range1-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(1024 * 1024, 0);
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
binary[i] = (i * 31 + 7) & 0xff;
|
|
}
|
|
const firstChunkBytes = 128 * 1024;
|
|
const injectedErrorChunk = Buffer.from("{\"error\":\"Missed session after 2000 ms\",\"success\":false}", "utf8");
|
|
let unrestrictCalls = 0;
|
|
|
|
const serveRange = (req: http.IncomingMessage): { start: number } => {
|
|
const range = String(req.headers.range || "");
|
|
const m = range.match(/bytes=(\d+)-/i);
|
|
return { start: m ? Number(m[1]) : 0 };
|
|
};
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const route = req.url || "";
|
|
const { start } = serveRange(req);
|
|
if (route.startsWith("/corrupt")) {
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length - start));
|
|
res.write(binary.subarray(start, Math.min(binary.length, start + firstChunkBytes)));
|
|
res.write(injectedErrorChunk);
|
|
setTimeout(() => {
|
|
try { res.socket?.destroy(); } catch { }
|
|
}, 60);
|
|
return;
|
|
}
|
|
if (route.startsWith("/clean")) {
|
|
const chunk = binary.subarray(start);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
return;
|
|
}
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const base = `http://127.0.0.1:${address.port}`;
|
|
|
|
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("/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
const target = unrestrictCalls === 1 ? `${base}/corrupt` : `${base}/clean`;
|
|
return new Response(
|
|
JSON.stringify({ download: target, filename: "range1.mkv", filesize: binary.length }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "range1", links: ["https://dummy/range1"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(unrestrictCalls).toBeGreaterThanOrEqual(2);
|
|
const finalBytes = fs.readFileSync(item.targetPath);
|
|
expect(finalBytes.length).toBe(binary.length);
|
|
expect(finalBytes.equals(binary)).toBe(true);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("counts a file once in session and all-time totals across an integrity-fail retry (no telemetry double-count)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-byteacct-"));
|
|
tempDirs.push(root);
|
|
const size = 512 * 1024;
|
|
const correct = Buffer.alloc(size, 0);
|
|
for (let i = 0; i < size; i += 1) {
|
|
correct[i] = (i * 17 + 3) & 0xff;
|
|
}
|
|
const wrong = Buffer.alloc(size, 0);
|
|
for (let i = 0; i < size; i += 1) {
|
|
wrong[i] = (i * 17 + 99) & 0xff;
|
|
}
|
|
const md5 = crypto.createHash("md5").update(correct).digest("hex");
|
|
|
|
const pkgDir = path.join(root, "downloads", "byteacct");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
fs.writeFileSync(path.join(pkgDir, "byteacct.md5"), `${md5} *movie.mkv\n`, "utf8");
|
|
|
|
let fullServes = 0;
|
|
const server = http.createServer((req, res) => {
|
|
const range = String(req.headers.range || "");
|
|
const m = range.match(/bytes=(\d+)-/i);
|
|
const start = m ? Number(m[1]) : 0;
|
|
if (start === 0) {
|
|
fullServes += 1;
|
|
}
|
|
const body = fullServes <= 1 ? wrong : correct;
|
|
const chunk = body.subarray(start);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${body.length - 1}/${body.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const base = `http://127.0.0.1:${address.port}`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({ download: `${base}/file`, filename: "movie.mkv", filesize: size }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: true,
|
|
totalDownloadedAllTime: 0
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "byteacct", links: ["https://dummy/byteacct"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(fullServes).toBeGreaterThanOrEqual(2);
|
|
|
|
const internal = manager as unknown as {
|
|
session: { totalDownloadedBytes: number };
|
|
settings: { totalDownloadedAllTime: number };
|
|
};
|
|
expect(internal.session.totalDownloadedBytes).toBe(size);
|
|
expect(internal.settings.totalDownloadedAllTime).toBe(size);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 40000);
|
|
|
|
it("requests a fresh direct link after repeated same-link download failures", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(256 * 1024, 17);
|
|
let badCalls = 0;
|
|
let goodCalls = 0;
|
|
let unrestrictCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const route = req.url || "";
|
|
if (route === "/bad") {
|
|
badCalls += 1;
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
const end = Math.min(binary.length, start + 64 * 1024);
|
|
const chunk = binary.subarray(start, end);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.write(chunk);
|
|
res.socket?.destroy();
|
|
return;
|
|
}
|
|
|
|
if (route === "/good") {
|
|
goodCalls += 1;
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
const chunk = binary.subarray(start);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const badUrl = `http://127.0.0.1:${address.port}/bad`;
|
|
const goodUrl = `http://127.0.0.1:${address.port}/good`;
|
|
|
|
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("/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: unrestrictCalls === 1 ? badUrl : goodUrl,
|
|
filename: "refresh-link.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
retryLimit: 0
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "fresh-link", links: ["https://dummy/fresh-link"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 12000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(unrestrictCalls).toBeGreaterThanOrEqual(2);
|
|
expect(badCalls).toBe(3);
|
|
expect(goodCalls).toBeGreaterThanOrEqual(1);
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("preserves partial files and requests a fresh direct link when resume gets HTTP 200", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(256 * 1024, 21);
|
|
const pkgDir = path.join(root, "downloads", "resume-ignored");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
const existingTargetPath = path.join(pkgDir, "resume-ignored.mkv");
|
|
const partialSize = 96 * 1024;
|
|
fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize));
|
|
|
|
let unrestrictCalls = 0;
|
|
let ignoredRangeCalls = 0;
|
|
let resumeCalls = 0;
|
|
const resumeStarts: number[] = [];
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const route = req.url || "";
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
|
|
if (route === "/ignored-range") {
|
|
ignoredRangeCalls += 1;
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
return;
|
|
}
|
|
|
|
if (route === "/resume-ok") {
|
|
resumeCalls += 1;
|
|
resumeStarts.push(start);
|
|
const chunk = binary.subarray(start);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const ignoredRangeUrl = `http://127.0.0.1:${address.port}/ignored-range`;
|
|
const resumeUrl = `http://127.0.0.1:${address.port}/resume-ok`;
|
|
|
|
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("/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: unrestrictCalls === 1 ? ignoredRangeUrl : resumeUrl,
|
|
filename: "resume-ignored.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const session = emptySession();
|
|
const packageId = "resume-ignored-pkg";
|
|
const itemId = "resume-ignored-item";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "resume-ignored",
|
|
outputDir: pkgDir,
|
|
extractDir: path.join(root, "extract", "resume-ignored"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/resume-ignored",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: partialSize,
|
|
totalBytes: binary.length,
|
|
progressPercent: Math.floor((partialSize / binary.length) * 100),
|
|
fileName: "resume-ignored.mkv",
|
|
targetPath: existingTargetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 1,
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(unrestrictCalls).toBeGreaterThanOrEqual(2);
|
|
expect(ignoredRangeCalls).toBeGreaterThanOrEqual(1);
|
|
expect(resumeCalls).toBeGreaterThanOrEqual(1);
|
|
expect(resumeStarts).toContain(partialSize);
|
|
expect(fs.statSync(existingTargetPath).size).toBe(binary.length);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("restores a persisted Mega-Web resume failure and retries transient cleanup before the fresh restart", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-resume-restore-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(192 * 1024, 37);
|
|
const partialSize = 64 * 1024;
|
|
const packageId = "mega-resume-restore-package";
|
|
const itemId = "mega-resume-restore-item";
|
|
const outputDir = path.join(root, "downloads", "mega-resume-restore");
|
|
const targetPath = path.join(outputDir, "mega-resume-restore.mkv");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(targetPath, binary.subarray(0, partialSize));
|
|
const rangeHeaders: string[] = [];
|
|
const originalRmSync = fs.rmSync;
|
|
let targetRemovalAttempts = 0;
|
|
const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => {
|
|
if (path.resolve(String(candidate)) === path.resolve(targetPath)) {
|
|
targetRemovalAttempts += 1;
|
|
if (targetRemovalAttempts === 1) {
|
|
throw Object.assign(new Error("target busy"), { code: "EBUSY" });
|
|
}
|
|
}
|
|
return originalRmSync(candidate, options);
|
|
}) as typeof fs.rmSync);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if (req.method === "GET") {
|
|
rangeHeaders.push(String(req.headers.range || ""));
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/mega-resume-restore`;
|
|
|
|
try {
|
|
const session = emptySession();
|
|
const createdAt = Date.now() - 10_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "mega-resume-restore",
|
|
outputDir,
|
|
extractDir: path.join(root, "extract", "mega-resume-restore"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/mega-resume-restore",
|
|
provider: "megadebrid-web",
|
|
status: "queued",
|
|
retries: 1,
|
|
speedBps: 0,
|
|
downloadedBytes: partialSize,
|
|
totalBytes: binary.length,
|
|
progressPercent: Math.floor((partialSize / binary.length) * 100),
|
|
fileName: "mega-resume-restore.mkv",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: `range_ignored_on_resume:${partialSize}/${binary.length}`,
|
|
fullStatus: "Resume-Link erneuern, Retry 1/3",
|
|
onlineStatus: "online",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
|
saveSession(storagePaths, session);
|
|
const megaWebUnrestrict = vi.fn(async (): Promise<UnrestrictedLink> => ({
|
|
directUrl,
|
|
fileName: "mega-resume-restore.mkv",
|
|
fileSize: binary.length,
|
|
retriesUsed: 0
|
|
}));
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaDebridWebCredentials: "mega-user:mega-pass",
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false,
|
|
megaDebridPreferApi: false,
|
|
providerOrder: ["megadebrid-web"],
|
|
autoProviderFallback: false,
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 3,
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 1
|
|
},
|
|
loadSession(storagePaths),
|
|
storagePaths,
|
|
{ megaWebUnrestrict }
|
|
);
|
|
|
|
expect(manager.getSnapshot().session.items[itemId]).toEqual(expect.objectContaining({
|
|
status: "queued",
|
|
downloadedBytes: partialSize,
|
|
targetPath,
|
|
onlineStatus: "online"
|
|
}));
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const restoredItem = manager.getSnapshot().session.items[itemId];
|
|
expect(restoredItem).toEqual(expect.objectContaining({
|
|
status: "completed",
|
|
downloadedBytes: binary.length,
|
|
onlineStatus: "online"
|
|
}));
|
|
expect(megaWebUnrestrict).toHaveBeenCalledTimes(2);
|
|
expect(rangeHeaders).toEqual([
|
|
`bytes=${partialSize}-`,
|
|
""
|
|
]);
|
|
expect(targetRemovalAttempts).toBeGreaterThanOrEqual(2);
|
|
expect(fs.readFileSync(targetPath)).toEqual(binary);
|
|
} finally {
|
|
rmSpy.mockRestore();
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("treats tiny Real-Debrid resume size mismatches as completed instead of looping", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const actual = Buffer.alloc(192 * 1024, 17);
|
|
const advertisedSize = actual.length + 5000;
|
|
const pkgDir = path.join(root, "downloads", "rd-mismatch");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
const existingTargetPath = path.join(pkgDir, "rd-mismatch.part01.rar");
|
|
fs.writeFileSync(existingTargetPath, actual);
|
|
|
|
let unrestrictCalls = 0;
|
|
let resumeCalls = 0;
|
|
const resumeStarts: number[] = [];
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/rd-mismatch") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
resumeCalls += 1;
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
resumeStarts.push(start);
|
|
|
|
if (start >= actual.length) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Range", `bytes 0-${actual.length - 1}/${actual.length}`);
|
|
res.setHeader("Content-Length", "0");
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const chunk = actual.subarray(start);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${actual.length - 1}/${actual.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/rd-mismatch`;
|
|
|
|
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("/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "rd-mismatch.part01.rar",
|
|
filesize: advertisedSize
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const session = emptySession();
|
|
const packageId = "rd-mismatch-pkg";
|
|
const itemId = "rd-mismatch-item";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "rd-mismatch",
|
|
outputDir: pkgDir,
|
|
extractDir: path.join(root, "extract", "rd-mismatch"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/rd-mismatch",
|
|
provider: "realdebrid",
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: actual.length,
|
|
totalBytes: advertisedSize,
|
|
progressPercent: Math.floor((actual.length / advertisedSize) * 100),
|
|
fileName: "rd-mismatch.part01.rar",
|
|
targetPath: existingTargetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 1,
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 12000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.downloadedBytes).toBe(actual.length);
|
|
expect(item?.totalBytes).toBe(actual.length);
|
|
expect(unrestrictCalls).toBe(1);
|
|
expect(resumeCalls).toBeGreaterThanOrEqual(1);
|
|
expect(resumeStarts).toContain(actual.length);
|
|
expect(fs.statSync(existingTargetPath).size).toBe(actual.length);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("accepts the smaller Real-Debrid full response after a resume hard reset", () => {
|
|
const actualSize = 224 * 1024;
|
|
const advertisedSize = actualSize + 5000;
|
|
const partialSize = actualSize - 48 * 1024;
|
|
|
|
expect(
|
|
getAuthoritativeRealDebridTotal(
|
|
"realdebrid",
|
|
advertisedSize,
|
|
partialSize,
|
|
200,
|
|
actualSize,
|
|
null,
|
|
true
|
|
)
|
|
).toEqual({
|
|
totalBytes: actualSize,
|
|
source: "content-length",
|
|
mismatchBytes: 5000
|
|
});
|
|
|
|
expect(
|
|
getAuthoritativeRealDebridTotal(
|
|
"realdebrid",
|
|
advertisedSize,
|
|
partialSize,
|
|
200,
|
|
actualSize,
|
|
null,
|
|
false
|
|
)
|
|
).toBeNull();
|
|
});
|
|
|
|
it("does not renew direct links when the file is already complete on disk", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(256 * 1024, 31);
|
|
let unrestrictCalls = 0;
|
|
let downloadCalls = 0;
|
|
|
|
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: "https://dummy/direct-complete",
|
|
filename: "direct-complete.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 1,
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
|
|
downloadCalls += 1;
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(targetPath, binary);
|
|
throw new Error(`direct_link_retry_exhausted:range_ignored_on_resume:${binary.length}/${binary.length}`);
|
|
};
|
|
|
|
manager.addPackages([{ name: "direct-complete", links: ["https://dummy/direct-complete"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 12000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.progressPercent).toBe(100);
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(unrestrictCalls).toBe(1);
|
|
expect(downloadCalls).toBe(1);
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
|
});
|
|
|
|
it("completes queued full files during start preflight without unrestricting again", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(192 * 1024, 17);
|
|
const pkgDir = path.join(root, "downloads", "queued-complete");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
const targetPath = path.join(pkgDir, "queued-complete.rar");
|
|
fs.writeFileSync(targetPath, binary);
|
|
let unrestrictCalls = 0;
|
|
|
|
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("/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
throw new Error(`unexpected unrestrict ${url}`);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
const session = emptySession();
|
|
const packageId = "queued-complete-pkg";
|
|
const itemId = "queued-complete-item";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "queued-complete",
|
|
outputDir: pkgDir,
|
|
extractDir: path.join(root, "extract", "queued-complete"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/queued-complete",
|
|
provider: "megadebrid-web",
|
|
status: "queued",
|
|
retries: 2,
|
|
speedBps: 0,
|
|
downloadedBytes: binary.length,
|
|
totalBytes: binary.length,
|
|
progressPercent: 100,
|
|
fileName: "queued-complete.rar",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "direct_link_retry_exhausted:HTTP 416",
|
|
fullStatus: "Resume-Link erneuern, Retry 1/3",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 2,
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 12000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.progressPercent).toBe(100);
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(item?.fullStatus).toContain("Fertig");
|
|
expect(unrestrictCalls).toBe(0);
|
|
});
|
|
|
|
it("completes Debrid-Link direct-link retries from disk during start preflight", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(60 * 1024, 23);
|
|
const pkgDir = path.join(root, "downloads", "queued-directlink-complete");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
const targetPath = path.join(pkgDir, "queued-directlink-complete.part10.rar");
|
|
fs.writeFileSync(targetPath, binary);
|
|
let unrestrictCalls = 0;
|
|
|
|
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("debrid-link.com/api/v2/downloader/add")) {
|
|
unrestrictCalls += 1;
|
|
throw new Error(`unexpected debrid-link unrestrict ${url}`);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
const session = emptySession();
|
|
const packageId = "queued-directlink-complete-pkg";
|
|
const itemId = "queued-directlink-complete-item";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "queued-directlink-complete",
|
|
outputDir: pkgDir,
|
|
extractDir: path.join(root, "extract", "queued-directlink-complete"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/queued-directlink-complete",
|
|
provider: "debridlink",
|
|
status: "queued",
|
|
retries: 14,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: binary.length,
|
|
progressPercent: 0,
|
|
fileName: "queued-directlink-complete.part10.rar",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "direct_link_retry_exhausted:HTTP 416",
|
|
fullStatus: "Direktlink erneuern, Retry 15/inf",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-test-key",
|
|
providerOrder: ["debridlink"],
|
|
providerPrimary: "debridlink",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 0,
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 12000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.progressPercent).toBe(100);
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(item?.fullStatus).toContain("Fertig");
|
|
expect(unrestrictCalls).toBe(0);
|
|
});
|
|
|
|
it("retries direct-link exhaustion caused by HTTP 416 in-session and then completes", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(160 * 1024, 41);
|
|
let unrestrictCalls = 0;
|
|
let downloadCalls = 0;
|
|
|
|
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: `https://dummy/direct-416-${unrestrictCalls}`,
|
|
filename: "direct-416-retry.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 2,
|
|
autoExtract: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
|
|
downloadCalls += 1;
|
|
if (downloadCalls === 1) {
|
|
throw new Error("direct_link_retry_exhausted:HTTP 416");
|
|
}
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(targetPath, binary);
|
|
const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined;
|
|
if (item) {
|
|
item.downloadedBytes = binary.length;
|
|
item.totalBytes = binary.length;
|
|
item.progressPercent = 100;
|
|
}
|
|
return { resumable: true };
|
|
};
|
|
|
|
manager.addPackages([{ name: "direct-416-retry", links: ["https://dummy/direct-416-retry"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 12000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.progressPercent).toBe(100);
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(unrestrictCalls).toBe(2);
|
|
expect(downloadCalls).toBe(2);
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
|
});
|
|
|
|
it("keeps a locked in-session HTTP 416 partial until the clean reset can remove it", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-locked-session-"));
|
|
tempDirs.push(root);
|
|
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: "locked-session-416", links: ["https://dummy/locked-session-416"] }]);
|
|
const item = Object.values((manager as any).session.items)[0] as any;
|
|
const targetPath = path.join(root, "downloads", "locked-session-416.part01.rar");
|
|
const partialBytes = 96 * 1024;
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(targetPath, Buffer.alloc(partialBytes, 7));
|
|
item.targetPath = targetPath;
|
|
item.downloadedBytes = partialBytes;
|
|
item.totalBytes = partialBytes * 2;
|
|
item.progressPercent = 50;
|
|
item.http416FreshRestarts = 2;
|
|
const active = {
|
|
itemId: item.id,
|
|
packageId: item.packageId,
|
|
abortController: new AbortController(),
|
|
abortReason: "none",
|
|
resumable: true,
|
|
nonResumableCounted: false,
|
|
genericErrorRetries: 0
|
|
};
|
|
const originalRmSync = fs.rmSync;
|
|
const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => {
|
|
if (path.resolve(String(candidate)) === path.resolve(targetPath)) {
|
|
const error = new Error("locked") as NodeJS.ErrnoException;
|
|
error.code = "EBUSY";
|
|
throw error;
|
|
}
|
|
return originalRmSync(candidate as fs.PathLike, options as fs.RmDirOptions);
|
|
}) as typeof fs.rmSync);
|
|
|
|
try {
|
|
await (manager as any).scheduleHttp416Retry(item, active, "3", "HTTP 416", targetPath);
|
|
|
|
expect(item).toMatchObject({
|
|
status: "queued",
|
|
downloadedBytes: partialBytes,
|
|
totalBytes: partialBytes * 2,
|
|
progressPercent: 50,
|
|
resumeResetPending: true,
|
|
http416FreshRestarts: 2,
|
|
fullStatus: "Warte auf Teildatei-Freigabe"
|
|
});
|
|
expect(fs.existsSync(targetPath)).toBe(true);
|
|
expect(fs.statSync(targetPath).size).toBe(partialBytes);
|
|
} finally {
|
|
rmSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("keeps a locked HTTP 416 fresh-restart partial instead of reporting a false zero", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-locked-fresh-"));
|
|
tempDirs.push(root);
|
|
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: "locked-fresh-416", links: ["https://dummy/locked-fresh-416"] }]);
|
|
const item = Object.values((manager as any).session.items)[0] as any;
|
|
const targetPath = path.join(root, "downloads", "locked-fresh-416.part01.rar");
|
|
const partialBytes = 128 * 1024;
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(targetPath, Buffer.alloc(partialBytes, 11));
|
|
item.targetPath = targetPath;
|
|
item.downloadedBytes = partialBytes;
|
|
item.totalBytes = partialBytes * 2;
|
|
item.progressPercent = 50;
|
|
const active = {
|
|
itemId: item.id,
|
|
packageId: item.packageId,
|
|
abortController: new AbortController(),
|
|
abortReason: "none",
|
|
resumable: true,
|
|
nonResumableCounted: false,
|
|
genericErrorRetries: 3
|
|
};
|
|
const originalRmSync = fs.rmSync;
|
|
const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => {
|
|
if (path.resolve(String(candidate)) === path.resolve(targetPath)) {
|
|
const error = new Error("locked") as NodeJS.ErrnoException;
|
|
error.code = "EBUSY";
|
|
throw error;
|
|
}
|
|
return originalRmSync(candidate as fs.PathLike, options as fs.RmDirOptions);
|
|
}) as typeof fs.rmSync);
|
|
|
|
try {
|
|
await (manager as any).escalateHttp416OrFail(item, active, targetPath, "HTTP 416");
|
|
|
|
expect(item).toMatchObject({
|
|
status: "queued",
|
|
downloadedBytes: partialBytes,
|
|
totalBytes: partialBytes * 2,
|
|
progressPercent: 50,
|
|
resumeResetPending: true,
|
|
fullStatus: "Warte auf Teildatei-Freigabe"
|
|
});
|
|
expect(fs.existsSync(targetPath)).toBe(true);
|
|
expect(fs.statSync(targetPath).size).toBe(partialBytes);
|
|
} finally {
|
|
rmSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("recovers an HTTP 416 item with a clean fresh restart after the in-budget retries are exhausted", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-fresh-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(160 * 1024, 19);
|
|
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
|
|
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
|
|
let downloadCalls = 0;
|
|
|
|
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/unrestrict/link")) {
|
|
return new Response(JSON.stringify({ download: "https://dummy/direct-416-recover", filename: "fresh-416.mkv", filesize: binary.length }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 2, autoExtract: false, autoReconnect: false },
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
|
|
downloadCalls += 1;
|
|
if (downloadCalls <= 3) {
|
|
throw new Error("HTTP 416");
|
|
}
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(targetPath, binary);
|
|
const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined;
|
|
if (item) {
|
|
item.downloadedBytes = binary.length;
|
|
item.totalBytes = binary.length;
|
|
item.progressPercent = 100;
|
|
}
|
|
return { resumable: true };
|
|
};
|
|
|
|
manager.addPackages([{ name: "fresh-416", links: ["https://dummy/fresh-416"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 20000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(downloadCalls).toBeGreaterThan(3);
|
|
} finally {
|
|
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
|
}
|
|
}, 25000);
|
|
|
|
it("bounds HTTP 416 clean restarts and finally fails instead of looping forever or stalling permanently", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-cap-"));
|
|
tempDirs.push(root);
|
|
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
|
|
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
|
|
let downloadCalls = 0;
|
|
|
|
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/unrestrict/link")) {
|
|
return new Response(JSON.stringify({ download: "https://dummy/direct-416-forever", filename: "always-416.mkv", filesize: 1024 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 0, autoExtract: false, autoReconnect: false },
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
(manager as any).downloadToFile = async () => {
|
|
downloadCalls += 1;
|
|
throw new Error("direct_link_retry_exhausted:HTTP 416");
|
|
};
|
|
|
|
manager.addPackages([{ name: "always-416", links: ["https://dummy/always-416"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 20000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("failed");
|
|
expect(downloadCalls).toBeGreaterThan(4);
|
|
expect(downloadCalls).toBeLessThan(30);
|
|
expect(item.http416FreshRestarts).toBe(2);
|
|
} finally {
|
|
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
|
}
|
|
}, 25000);
|
|
|
|
it("honors a persisted HTTP 416 fresh-restart budget after manager recreation", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-persisted-cap-"));
|
|
tempDirs.push(root);
|
|
const paths = createStoragePaths(path.join(root, "state"));
|
|
const session = emptySession();
|
|
const createdAt = Date.now();
|
|
session.packageOrder = ["persisted-416-package"];
|
|
session.packages["persisted-416-package"] = {
|
|
id: "persisted-416-package",
|
|
name: "persisted-416",
|
|
outputDir: path.join(root, "downloads", "persisted-416"),
|
|
extractDir: path.join(root, "extract", "persisted-416"),
|
|
status: "queued",
|
|
itemIds: ["persisted-416-item"],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items["persisted-416-item"] = {
|
|
id: "persisted-416-item",
|
|
packageId: "persisted-416-package",
|
|
url: "https://rapidgator.net/file/persisted-416",
|
|
provider: "realdebrid",
|
|
status: "queued",
|
|
retries: 5,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "persisted-416.bin",
|
|
targetPath: "",
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "HTTP 416",
|
|
fullStatus: "Wartet",
|
|
http416FreshRestarts: 2,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
saveSession(paths, session);
|
|
const restored = loadSession(paths);
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), autoExtract: false },
|
|
restored,
|
|
paths
|
|
);
|
|
const item = (manager as any).session.items["persisted-416-item"];
|
|
const active = {
|
|
itemId: item.id,
|
|
packageId: item.packageId,
|
|
abortController: new AbortController(),
|
|
abortReason: "none",
|
|
resumable: true,
|
|
nonResumableCounted: false,
|
|
genericErrorRetries: 3
|
|
};
|
|
|
|
await (manager as any).escalateHttp416OrFail(item, active, "", "HTTP 416");
|
|
|
|
expect(item.status).toBe("failed");
|
|
expect(item.http416FreshRestarts).toBe(2);
|
|
});
|
|
|
|
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(160 * 1024, 57);
|
|
let unrestrictCalls = 0;
|
|
let downloadCalls = 0;
|
|
|
|
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("debrid-link.com/api/v2/downloader/add")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: `https://dummy/debridlink-direct-${unrestrictCalls}`,
|
|
name: "debridlink-416-retry.mkv",
|
|
size: binary.length
|
|
}
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-test-key",
|
|
providerOrder: ["debridlink"],
|
|
providerPrimary: "debridlink",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 2,
|
|
autoExtract: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
|
|
downloadCalls += 1;
|
|
if (downloadCalls === 1) {
|
|
throw new Error("direct_link_retry_exhausted:HTTP 416");
|
|
}
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(targetPath, binary);
|
|
const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined;
|
|
if (item) {
|
|
item.downloadedBytes = binary.length;
|
|
item.totalBytes = binary.length;
|
|
item.progressPercent = 100;
|
|
}
|
|
return { resumable: true };
|
|
};
|
|
|
|
manager.addPackages([{ name: "debridlink-416-retry", links: ["https://dummy/debridlink-416-retry"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 12000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.provider).toBe("debridlink");
|
|
expect(item?.progressPercent).toBe(100);
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(unrestrictCalls).toBe(2);
|
|
expect(downloadCalls).toBe(2);
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
|
});
|
|
|
|
it("finalizes Debrid-Link items in-session when the file is already complete on disk", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(60 * 1024, 57);
|
|
let unrestrictCalls = 0;
|
|
let downloadCalls = 0;
|
|
|
|
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("debrid-link.com/api/v2/downloader/add")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: `https://dummy/debridlink-direct-complete-${unrestrictCalls}`,
|
|
name: "debridlink-complete.part10.rar",
|
|
size: binary.length
|
|
}
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-test-key",
|
|
providerOrder: ["debridlink"],
|
|
providerPrimary: "debridlink",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 2,
|
|
autoExtract: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
|
|
downloadCalls += 1;
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(targetPath, binary);
|
|
throw new Error("direct_link_retry_exhausted:download_underflow:61440/61440");
|
|
};
|
|
|
|
manager.addPackages([{ name: "debridlink-complete", links: ["https://dummy/debridlink-complete"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 12000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.progressPercent).toBe(100);
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(unrestrictCalls).toBe(1);
|
|
expect(downloadCalls).toBe(1);
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
|
});
|
|
|
|
it("queues Debrid-Link cooldown retries when wrapped unrestrict errors carry the cooldown marker", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
let fetchCalls = 0;
|
|
|
|
globalThis.fetch = async (): Promise<Response> => {
|
|
fetchCalls += 1;
|
|
return new Response("not-found", { status: 404 });
|
|
};
|
|
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 2,
|
|
autoExtract: false
|
|
};
|
|
|
|
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
|
|
for (const key of keys) {
|
|
primeDebridLinkRuntimeCooldownForTests(key.id, 60_000, `${key.label} im Cooldown`);
|
|
}
|
|
|
|
const manager = new DownloadManager(
|
|
settings,
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "debridlink-cooldown", links: ["https://rapidgator.net/file/example.part1.rar.html"] }]);
|
|
await manager.start();
|
|
await waitFor(() => {
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
return Boolean(item && item.status === "queued" && /debrid-link cooldown/i.test(item.fullStatus || ""));
|
|
}, 12000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("queued");
|
|
expect(item?.fullStatus).toContain("Debrid-Link Cooldown");
|
|
expect(item?.lastError).toContain("im Cooldown");
|
|
expect(item?.retries).toBe(1);
|
|
expect(fetchCalls).toBe(0);
|
|
|
|
await manager.stop();
|
|
});
|
|
|
|
it("blocks start when Debrid-Link has no active api key left", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
|
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 0,
|
|
autoExtract: false,
|
|
debridLinkApiKeyDailyLimitBytes: {
|
|
[keys[0].id]: 100,
|
|
[keys[1].id]: 100
|
|
},
|
|
debridLinkApiKeyDailyUsageBytes: {
|
|
[keys[0].id]: 100,
|
|
[keys[1].id]: 100
|
|
},
|
|
providerDailyUsageDay: getProviderUsageDayKey()
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
settings,
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "debridlink-no-key", links: ["https://rapidgator.net/file/no-active-key.part1.rar.html"] }]);
|
|
expect(manager.getSnapshot().canStart).toBe(false);
|
|
await expect(manager.start()).rejects.toThrow("Kein aktiver Download-Account verfügbar");
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(manager.getSnapshot().session.running).toBe(false);
|
|
expect(item?.status).toBe("queued");
|
|
expect(item?.retries).toBe(0);
|
|
});
|
|
|
|
it("recovers from repeated resume underflow by restarting from zero", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(256 * 1024, 23);
|
|
const pkgDir = path.join(root, "downloads", "resume-underflow");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
const existingTargetPath = path.join(pkgDir, "resume-underflow.mkv");
|
|
const partialSize = 96 * 1024;
|
|
fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize));
|
|
|
|
let unrestrictCalls = 0;
|
|
const starts: number[] = [];
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
starts.push(start);
|
|
|
|
if (start > 0) {
|
|
const chunk = binary.subarray(start, Math.min(start + 8192, binary.length));
|
|
res.statusCode = 206;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Range", `bytes ${start}-${start + chunk.length - 1}/${binary.length}`);
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/resume-underflow`;
|
|
|
|
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("/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "resume-underflow.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const session = emptySession();
|
|
const packageId = "resume-underflow-pkg";
|
|
const itemId = "resume-underflow-item";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "resume-underflow",
|
|
outputDir: pkgDir,
|
|
extractDir: path.join(root, "extract", "resume-underflow"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/resume-underflow",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: partialSize,
|
|
totalBytes: binary.length,
|
|
progressPercent: Math.floor((partialSize / binary.length) * 100),
|
|
fileName: "resume-underflow.mkv",
|
|
targetPath: existingTargetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 4,
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
if (item?.status !== "completed") {
|
|
throw new Error(JSON.stringify({
|
|
status: item?.status,
|
|
downloadedBytes: item?.downloadedBytes,
|
|
totalBytes: item?.totalBytes,
|
|
retries: item?.retries,
|
|
lastError: item?.lastError,
|
|
fullStatus: item?.fullStatus,
|
|
starts,
|
|
unrestrictCalls
|
|
}));
|
|
}
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(unrestrictCalls).toBeGreaterThanOrEqual(1);
|
|
expect(starts).toContain(partialSize);
|
|
expect(starts).toContain(0);
|
|
expect(fs.readFileSync(existingTargetPath).equals(binary)).toBe(true);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("does not stop early on provider-only totals when the HTTP response is longer", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-provider-total-mismatch-"));
|
|
tempDirs.push(root);
|
|
const actual = Buffer.alloc(256 * 1024, 77);
|
|
const advertised = 192 * 1024;
|
|
let unrestrictCalls = 0;
|
|
|
|
const server = http.createServer((_req, res) => {
|
|
res.statusCode = 200;
|
|
res.setHeader("Transfer-Encoding", "chunked");
|
|
res.write(actual.subarray(0, 96 * 1024));
|
|
setTimeout(() => {
|
|
res.end(actual.subarray(96 * 1024));
|
|
}, 40);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/provider-mismatch`;
|
|
|
|
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("/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "provider-mismatch.rar",
|
|
filesize: advertised
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 1,
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "provider-mismatch", links: ["https://dummy/provider-mismatch"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.downloadedBytes).toBe(actual.length);
|
|
expect(item?.totalBytes).toBe(actual.length);
|
|
expect(unrestrictCalls).toBe(1);
|
|
expect(fs.readFileSync(item.targetPath).equals(actual)).toBe(true);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("does not double-count resumed bytes when deciding early completion", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-early-finish-"));
|
|
tempDirs.push(root);
|
|
const actual = Buffer.alloc(256 * 1024, 91);
|
|
const partialSize = 96 * 1024;
|
|
const pkgDir = path.join(root, "downloads", "resume-early-finish");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
const targetPath = path.join(pkgDir, "resume-early-finish.mkv");
|
|
fs.writeFileSync(targetPath, actual.subarray(0, partialSize));
|
|
let unrestrictCalls = 0;
|
|
const starts: number[] = [];
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
starts.push(start);
|
|
|
|
if (start <= 0) {
|
|
res.statusCode = 500;
|
|
res.end("expected resume");
|
|
return;
|
|
}
|
|
|
|
const remaining = actual.subarray(start);
|
|
const firstChunkBytes = 64 * 1024;
|
|
res.statusCode = 206;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Range", `bytes ${start}-${actual.length - 1}/${actual.length}`);
|
|
res.setHeader("Content-Length", String(remaining.length));
|
|
res.write(remaining.subarray(0, firstChunkBytes));
|
|
setTimeout(() => {
|
|
res.end(remaining.subarray(firstChunkBytes));
|
|
}, 50);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/resume-early-finish`;
|
|
|
|
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("/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "resume-early-finish.mkv",
|
|
filesize: actual.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const session = emptySession();
|
|
const packageId = "resume-early-finish-pkg";
|
|
const itemId = "resume-early-finish-item";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "resume-early-finish",
|
|
outputDir: pkgDir,
|
|
extractDir: path.join(root, "extract", "resume-early-finish"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/resume-early-finish",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: partialSize,
|
|
totalBytes: actual.length,
|
|
progressPercent: Math.floor((partialSize / actual.length) * 100),
|
|
fileName: "resume-early-finish.mkv",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 1,
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.downloadedBytes).toBe(actual.length);
|
|
expect(item?.totalBytes).toBe(actual.length);
|
|
expect(unrestrictCalls).toBe(1);
|
|
expect(starts).toEqual([partialSize]);
|
|
expect(fs.readFileSync(targetPath).equals(actual)).toBe(true);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("assigns unique target paths for same filenames in parallel", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(64 * 1024, 9);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/same") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
setTimeout(() => {
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
}, 260);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/same`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "same-release.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 2
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "same-name", links: ["https://dummy/first", "https://dummy/second"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
expect(items).toHaveLength(2);
|
|
expect(items.every((item) => item.status === "completed")).toBe(true);
|
|
const targetPaths = items.map((item) => item.targetPath);
|
|
expect(new Set(targetPaths).size).toBe(2);
|
|
for (const targetPath of targetPaths) {
|
|
expect(fs.existsSync(targetPath)).toBe(true);
|
|
}
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("does not mark truncated archive downloads as completed", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const advertised = Buffer.alloc(96 * 1024, 5);
|
|
const actual = advertised.subarray(0, advertised.length - 2048);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/short-archive") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(actual.length));
|
|
res.end(actual);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/short-archive`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "broken.part01.rar",
|
|
filesize: advertised.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
retryLimit: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "short-archive", links: ["https://dummy/short-archive"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.downloadedBytes).toBeGreaterThanOrEqual(actual.length);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("continues downloading while package post-processing is pending", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(80 * 1024, 7);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const route = req.url || "";
|
|
if (route !== "/first" && route !== "/second") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const firstUrl = `http://127.0.0.1:${address.port}/first`;
|
|
const secondUrl = `http://127.0.0.1:${address.port}/second`;
|
|
|
|
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("/unrestrict/link")) {
|
|
const body = init?.body;
|
|
const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || "");
|
|
const originalLink = new URLSearchParams(bodyText).get("link") || "";
|
|
const directUrl = originalLink.includes("second") ? secondUrl : firstUrl;
|
|
const filename = originalLink.includes("second") ? "second.bin" : "first.bin";
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename,
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
let releaseBlockedPostProcess: ((value?: void | PromiseLike<void>) => void) | undefined;
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const blocker = new Promise<void>((resolve) => {
|
|
releaseBlockedPostProcess = resolve;
|
|
});
|
|
(manager as unknown as { packagePostProcessQueue: Promise<void> }).packagePostProcessQueue = blocker;
|
|
|
|
manager.addPackages([
|
|
{ name: "first", links: ["https://dummy/first"] },
|
|
{ name: "second", links: ["https://dummy/second"] }
|
|
]);
|
|
|
|
const initial = manager.getSnapshot();
|
|
const firstPackage = initial.session.packageOrder[0];
|
|
const secondPackage = initial.session.packageOrder[1];
|
|
const firstItem = initial.session.packages[firstPackage]?.itemIds[0] || "";
|
|
const secondItem = initial.session.packages[secondPackage]?.itemIds[0] || "";
|
|
|
|
manager.start();
|
|
|
|
await waitFor(() => manager.getSnapshot().session.items[firstItem]?.status === "completed", 12000);
|
|
await waitFor(() => {
|
|
const state = manager.getSnapshot().session.items[secondItem]?.status;
|
|
return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed";
|
|
}, 6000);
|
|
|
|
if (releaseBlockedPostProcess) {
|
|
releaseBlockedPostProcess();
|
|
}
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const done = manager.getSnapshot();
|
|
expect(done.session.items[firstItem]?.status).toBe("completed");
|
|
expect(done.session.items[secondItem]?.status).toBe("completed");
|
|
} finally {
|
|
if (releaseBlockedPostProcess) {
|
|
releaseBlockedPostProcess();
|
|
}
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("recovers from stalled download streams without manual pause/resume", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(300 * 1024, 17);
|
|
const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS;
|
|
process.env.RD_STALL_TIMEOUT_MS = "2500";
|
|
let directCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/stall") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
directCalls += 1;
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
|
|
if (directCalls === 1 && start === 0) {
|
|
const firstChunk = Math.floor(binary.length / 3);
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.write(binary.subarray(0, firstChunk));
|
|
setTimeout(() => {
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
res.end(binary.subarray(firstChunk));
|
|
}
|
|
}, 5000);
|
|
return;
|
|
}
|
|
|
|
const chunk = binary.subarray(start);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/stall`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "stall.bin",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "stall", links: ["https://dummy/stall"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(directCalls).toBeGreaterThan(1);
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
|
} finally {
|
|
if (previousStallTimeout === undefined) {
|
|
delete process.env.RD_STALL_TIMEOUT_MS;
|
|
} else {
|
|
process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout;
|
|
}
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 35000);
|
|
|
|
it("recovers when direct download connection stalls before first byte", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(220 * 1024, 23);
|
|
const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS;
|
|
const previousConnectTimeout = process.env.RD_CONNECT_TIMEOUT_MS;
|
|
process.env.RD_STALL_TIMEOUT_MS = "2500";
|
|
process.env.RD_CONNECT_TIMEOUT_MS = "1800";
|
|
let directCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/connect-stall") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
directCalls += 1;
|
|
if (directCalls === 1) {
|
|
setTimeout(() => {
|
|
if (res.destroyed || res.writableEnded) {
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
}, 5200);
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/connect-stall`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "connect-stall.bin",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "connect-stall", links: ["https://dummy/connect-stall"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(directCalls).toBeGreaterThan(1);
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
|
} finally {
|
|
if (previousStallTimeout === undefined) {
|
|
delete process.env.RD_STALL_TIMEOUT_MS;
|
|
} else {
|
|
process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout;
|
|
}
|
|
if (previousConnectTimeout === undefined) {
|
|
delete process.env.RD_CONNECT_TIMEOUT_MS;
|
|
} else {
|
|
process.env.RD_CONNECT_TIMEOUT_MS = previousConnectTimeout;
|
|
}
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 35000);
|
|
|
|
it("recovers when direct download stalls before first response bytes", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(180 * 1024, 12);
|
|
const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS;
|
|
const previousConnectTimeout = process.env.RD_CONNECT_TIMEOUT_MS;
|
|
process.env.RD_STALL_TIMEOUT_MS = "2500";
|
|
process.env.RD_CONNECT_TIMEOUT_MS = "2000";
|
|
let directCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/stall-connect") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
directCalls += 1;
|
|
if (directCalls === 1) {
|
|
setTimeout(() => {
|
|
if (res.writableEnded || res.destroyed || res.headersSent) {
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
}, 5000);
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/stall-connect`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "stall-connect.bin",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "stall-connect", links: ["https://dummy/stall-connect"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(directCalls).toBeGreaterThan(1);
|
|
} finally {
|
|
if (previousStallTimeout === undefined) {
|
|
delete process.env.RD_STALL_TIMEOUT_MS;
|
|
} else {
|
|
process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout;
|
|
}
|
|
if (previousConnectTimeout === undefined) {
|
|
delete process.env.RD_CONNECT_TIMEOUT_MS;
|
|
} else {
|
|
process.env.RD_CONNECT_TIMEOUT_MS = previousConnectTimeout;
|
|
}
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 35000);
|
|
|
|
it("recovers via global watchdog when stream hangs without reader timeout", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(240 * 1024, 31);
|
|
const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS;
|
|
const previousConnectTimeout = process.env.RD_CONNECT_TIMEOUT_MS;
|
|
const previousGlobalWatchdog = process.env.RD_GLOBAL_STALL_TIMEOUT_MS;
|
|
process.env.RD_STALL_TIMEOUT_MS = "120000";
|
|
process.env.RD_CONNECT_TIMEOUT_MS = "120000";
|
|
process.env.RD_GLOBAL_STALL_TIMEOUT_MS = "2500";
|
|
let directCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/watchdog-stall") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
directCalls += 1;
|
|
if (directCalls === 1) {
|
|
const firstChunk = Math.floor(binary.length / 3);
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.write(binary.subarray(0, firstChunk));
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/watchdog-stall`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "watchdog-stall.bin",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "watchdog-stall", links: ["https://dummy/watchdog-stall"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(directCalls).toBeGreaterThan(1);
|
|
} finally {
|
|
if (previousStallTimeout === undefined) {
|
|
delete process.env.RD_STALL_TIMEOUT_MS;
|
|
} else {
|
|
process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout;
|
|
}
|
|
if (previousConnectTimeout === undefined) {
|
|
delete process.env.RD_CONNECT_TIMEOUT_MS;
|
|
} else {
|
|
process.env.RD_CONNECT_TIMEOUT_MS = previousConnectTimeout;
|
|
}
|
|
if (previousGlobalWatchdog === undefined) {
|
|
delete process.env.RD_GLOBAL_STALL_TIMEOUT_MS;
|
|
} else {
|
|
process.env.RD_GLOBAL_STALL_TIMEOUT_MS = previousGlobalWatchdog;
|
|
}
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 35000);
|
|
|
|
it("recovers when write stream backpressure never drains", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(180 * 1024, 19);
|
|
const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS;
|
|
process.env.RD_STALL_TIMEOUT_MS = "2200";
|
|
let directCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/drain-stall") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
directCalls += 1;
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/drain-stall`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "drain-stall.bin",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
const originalCreateWriteStream = fs.createWriteStream;
|
|
let writeStreamCalls = 0;
|
|
const fsMutable = fs as unknown as { createWriteStream: typeof fs.createWriteStream };
|
|
fsMutable.createWriteStream = ((...args: Parameters<typeof fs.createWriteStream>) => {
|
|
writeStreamCalls += 1;
|
|
if (writeStreamCalls === 1) {
|
|
class HangingWriteStream extends EventEmitter {
|
|
public closed = false;
|
|
|
|
public destroyed = false;
|
|
|
|
public write(): boolean {
|
|
return false;
|
|
}
|
|
|
|
public end(): void {
|
|
this.closed = true;
|
|
this.emit("close");
|
|
}
|
|
}
|
|
return new HangingWriteStream() as unknown as ReturnType<typeof fs.createWriteStream>;
|
|
}
|
|
return originalCreateWriteStream(...args);
|
|
}) as typeof fs.createWriteStream;
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "drain-stall", links: ["https://dummy/drain-stall"] }]);
|
|
manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 40000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.retries).toBeGreaterThan(0);
|
|
expect(directCalls).toBeGreaterThan(1);
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
|
} finally {
|
|
fsMutable.createWriteStream = originalCreateWriteStream;
|
|
if (previousStallTimeout === undefined) {
|
|
delete process.env.RD_STALL_TIMEOUT_MS;
|
|
} else {
|
|
process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout;
|
|
}
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 45000);
|
|
|
|
it.each(["write", "end"] as const)("recovers when a stream reports an asynchronous disk-full error during %s", async (failurePhase) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-async-write-error-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(192 * 1024, 23);
|
|
let directCalls = 0;
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/async-write-error") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
directCalls += 1;
|
|
res.writeHead(200, {
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": String(binary.length)
|
|
});
|
|
res.end(binary);
|
|
});
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/async-write-error`;
|
|
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("/unrestrict/link")) {
|
|
return new Response(JSON.stringify({
|
|
download: directUrl,
|
|
filename: "async-write-error.bin",
|
|
filesize: binary.length
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
const originalCreateWriteStream = fs.createWriteStream;
|
|
const fsMutable = fs as unknown as { createWriteStream: typeof fs.createWriteStream };
|
|
let injectedFailure = false;
|
|
fsMutable.createWriteStream = ((...args: Parameters<typeof fs.createWriteStream>) => {
|
|
const targetPath = String(args[0]);
|
|
if (!injectedFailure && targetPath.endsWith("async-write-error.bin")) {
|
|
injectedFailure = true;
|
|
class AsyncFailingWriteStream extends EventEmitter {
|
|
public closed = false;
|
|
public destroyed = false;
|
|
public writableLength = 0;
|
|
public bytesWritten = 0;
|
|
|
|
public write(): boolean {
|
|
if (failurePhase === "write") {
|
|
queueMicrotask(() => {
|
|
const error = Object.assign(new Error("disk full"), { code: "ENOSPC" });
|
|
this.emit("error", error);
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public end(): void {
|
|
if (failurePhase === "end") {
|
|
queueMicrotask(() => {
|
|
const error = Object.assign(new Error("disk full on close"), { code: "ENOSPC" });
|
|
this.emit("error", error);
|
|
});
|
|
return;
|
|
}
|
|
this.closed = true;
|
|
this.emit("close");
|
|
}
|
|
|
|
public destroy(): void {
|
|
this.destroyed = true;
|
|
}
|
|
}
|
|
return new AsyncFailingWriteStream() as unknown as ReturnType<typeof fs.createWriteStream>;
|
|
}
|
|
return originalCreateWriteStream(...args);
|
|
}) as typeof fs.createWriteStream;
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
manager.addPackages([{ name: "async-write-error", links: ["https://dummy/async-write-error"] }]);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30_000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(injectedFailure).toBe(true);
|
|
expect(directCalls).toBeGreaterThan(1);
|
|
expect(fs.readFileSync(item.targetPath)).toEqual(binary);
|
|
} finally {
|
|
fsMutable.createWriteStream = originalCreateWriteStream;
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 35_000);
|
|
|
|
it("uses content-disposition filename when provider filename is opaque", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(96 * 1024, 13);
|
|
const expectedName = "Banshee.S04E01.German.DL.720p.part01.rar";
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/content-name") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.setHeader("Content-Disposition", `attachment; filename="${expectedName}"`);
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/content-name`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "6f09df2984fe01378537c7cd8d7fa7ce",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 1,
|
|
autoExtract: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "content-name", links: ["https://rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.fileName).toBe(expectedName);
|
|
expect(path.basename(item?.targetPath || "")).toBe(expectedName);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 15000);
|
|
|
|
it("reuses stored partial target path when queued item resumes", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(256 * 1024, 7);
|
|
const partialSize = 64 * 1024;
|
|
const pkgDir = path.join(root, "downloads", "resume");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
const existingTargetPath = path.join(pkgDir, "resume.mkv");
|
|
fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize));
|
|
let sawResumeRange = false;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/resume") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
if (start === partialSize) {
|
|
sawResumeRange = true;
|
|
}
|
|
const chunk = binary.subarray(start);
|
|
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/resume`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "resume.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const session = emptySession();
|
|
const packageId = "resume-pkg";
|
|
const itemId = "resume-item";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "resume",
|
|
outputDir: pkgDir,
|
|
extractDir: path.join(root, "extract", "resume"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/resume",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: partialSize,
|
|
totalBytes: binary.length,
|
|
progressPercent: Math.floor((partialSize / binary.length) * 100),
|
|
fileName: "resume.mkv",
|
|
targetPath: existingTargetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 1,
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.targetPath).toBe(existingTargetPath);
|
|
expect(sawResumeRange).toBe(true);
|
|
const fileSize = fs.statSync(existingTargetPath).size;
|
|
expect(fileSize).toBeGreaterThanOrEqual(binary.length - 4096);
|
|
expect(fileSize).toBeLessThanOrEqual(binary.length);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("treats HTTP 416 on full range as completed resume", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(128 * 1024, 2);
|
|
const pkgDir = path.join(root, "downloads", "range-complete");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
const existingTargetPath = path.join(pkgDir, "complete.mkv");
|
|
fs.writeFileSync(existingTargetPath, binary);
|
|
let saw416 = false;
|
|
let directCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/complete") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
directCalls += 1;
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
if (start >= binary.length) {
|
|
saw416 = true;
|
|
res.statusCode = 416;
|
|
res.setHeader("Content-Range", `bytes */${binary.length}`);
|
|
res.end("");
|
|
return;
|
|
}
|
|
|
|
const chunk = binary.subarray(start);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/complete`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "complete.mkv",
|
|
filesize: null
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const session = emptySession();
|
|
const packageId = "complete-pkg";
|
|
const itemId = "complete-item";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "range-complete",
|
|
outputDir: pkgDir,
|
|
extractDir: path.join(root, "extract", "range-complete"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/complete",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: binary.length,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "complete.mkv",
|
|
targetPath: existingTargetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 1,
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(saw416).toBe(true);
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.targetPath).toBe(existingTargetPath);
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(item?.totalBytes).toBe(binary.length);
|
|
expect(directCalls).toBe(1);
|
|
expect(fs.statSync(existingTargetPath).size).toBe(binary.length);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("refreshes Debrid-Link API direct links immediately after HTTP 416 instead of retrying the same link", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(256 * 1024, 29);
|
|
const pkgDir = path.join(root, "downloads", "debridlink-range-reset");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
const existingTargetPath = path.join(pkgDir, "debridlink-range-reset.mkv");
|
|
const partialSize = 96 * 1024;
|
|
fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize));
|
|
|
|
let unrestrictCalls = 0;
|
|
let badCalls = 0;
|
|
let goodCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const route = req.url || "";
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
|
|
if (route === "/bad-416") {
|
|
badCalls += 1;
|
|
res.statusCode = 416;
|
|
res.setHeader("Content-Range", `bytes */${partialSize - 1024}`);
|
|
res.end("");
|
|
return;
|
|
}
|
|
|
|
if (route === "/good") {
|
|
goodCalls += 1;
|
|
const chunk = binary.subarray(start);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const badUrl = `http://127.0.0.1:${address.port}/bad-416`;
|
|
const goodUrl = `http://127.0.0.1:${address.port}/good`;
|
|
|
|
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("debrid-link.com/api/v2/downloader/add")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: unrestrictCalls === 1 ? badUrl : goodUrl,
|
|
name: "debridlink-range-reset.mkv",
|
|
size: binary.length
|
|
}
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const session = emptySession();
|
|
const packageId = "debridlink-range-reset-pkg";
|
|
const itemId = "debridlink-range-reset-item";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "debridlink-range-reset",
|
|
outputDir: pkgDir,
|
|
extractDir: path.join(root, "extract", "debridlink-range-reset"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/debridlink-range-reset",
|
|
provider: "debridlink",
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: partialSize,
|
|
totalBytes: binary.length,
|
|
progressPercent: Math.floor((partialSize / binary.length) * 100),
|
|
fileName: "debridlink-range-reset.mkv",
|
|
targetPath: existingTargetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-test-key",
|
|
providerOrder: ["debridlink"],
|
|
providerPrimary: "debridlink",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 2,
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.provider).toBe("debridlink");
|
|
expect(item?.downloadedBytes).toBeGreaterThanOrEqual(partialSize);
|
|
expect(item?.downloadedBytes).toBeLessThanOrEqual(binary.length);
|
|
expect(unrestrictCalls).toBeGreaterThanOrEqual(1);
|
|
expect(badCalls).toBeGreaterThanOrEqual(1);
|
|
const fileSize = fs.statSync(existingTargetPath).size;
|
|
expect(fileSize).toBeGreaterThanOrEqual(partialSize);
|
|
expect(fileSize).toBeLessThanOrEqual(binary.length);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("recovers from HTTP 416 by restarting download from zero", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(96 * 1024, 3);
|
|
const pkgDir = path.join(root, "downloads", "range-reset");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
const existingTargetPath = path.join(pkgDir, "reset.mkv");
|
|
const partialSize = 64 * 1024;
|
|
fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize));
|
|
|
|
let saw416 = false;
|
|
let fullRestarted = false;
|
|
let requestCount = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/range-reset") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
requestCount += 1;
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
|
|
if (requestCount === 1 && start === partialSize) {
|
|
saw416 = true;
|
|
res.statusCode = 416;
|
|
res.setHeader("Content-Range", "bytes */32768");
|
|
res.end("");
|
|
return;
|
|
}
|
|
|
|
if (start === 0) {
|
|
fullRestarted = true;
|
|
}
|
|
const chunk = binary.subarray(start);
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
res.end(chunk);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/range-reset`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "reset.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const session = emptySession();
|
|
const packageId = "range-reset-pkg";
|
|
const itemId = "range-reset-item";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "range-reset",
|
|
outputDir: pkgDir,
|
|
extractDir: path.join(root, "extract", "range-reset"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/range-reset",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: partialSize,
|
|
totalBytes: binary.length,
|
|
progressPercent: Math.floor((partialSize / binary.length) * 100),
|
|
fileName: "reset.mkv",
|
|
targetPath: existingTargetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
retryLimit: 1,
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("completed");
|
|
expect(saw416).toBe(true);
|
|
expect(fullRestarted).toBe(true);
|
|
expect(fs.statSync(existingTargetPath).size).toBe(binary.length);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("counts retries and resets stale 100% progress on persistent HTTP 416", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const staleBinary = Buffer.alloc(64 * 1024, 9);
|
|
const pkgDir = path.join(root, "downloads", "range-416-fail");
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
const existingTargetPath = path.join(pkgDir, "broken.part3.rar");
|
|
fs.writeFileSync(existingTargetPath, staleBinary);
|
|
let directCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/range-416-fail") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
directCalls += 1;
|
|
res.statusCode = 416;
|
|
res.setHeader("Content-Range", "bytes */32768");
|
|
res.end("");
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/range-416-fail`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "broken.part3.rar",
|
|
filesize: 32768
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const session = emptySession();
|
|
const packageId = "range-416-fail-pkg";
|
|
const itemId = "range-416-fail-item";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "range-416-fail",
|
|
outputDir: pkgDir,
|
|
extractDir: path.join(root, "extract", "range-416-fail"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/range-416-fail",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: staleBinary.length,
|
|
totalBytes: staleBinary.length,
|
|
progressPercent: 100,
|
|
fileName: "broken.part3.rar",
|
|
targetPath: existingTargetPath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 45000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("failed");
|
|
expect(item?.retries).toBeGreaterThan(0);
|
|
expect(item?.progressPercent).toBe(0);
|
|
expect(item?.downloadedBytes).toBe(0);
|
|
expect(item?.lastError).toContain("416");
|
|
expect(directCalls).toBeGreaterThanOrEqual(3);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 70000);
|
|
|
|
it("retries non-retriable HTTP statuses and eventually succeeds", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(96 * 1024, 5);
|
|
let directCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/status-retry") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
directCalls += 1;
|
|
if (directCalls <= 2) {
|
|
res.statusCode = 403;
|
|
res.end("forbidden");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/status-retry`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "status-retry.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
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: "status-retry", links: ["https://dummy/status-retry"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(directCalls).toBeGreaterThanOrEqual(3);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("normalizes stale running state on startup", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
session.running = true;
|
|
session.paused = true;
|
|
session.reconnectUntil = Date.now() + 30_000;
|
|
session.reconnectReason = "HTTP 429";
|
|
const packageId = "stale-pkg";
|
|
const itemId = "stale-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "stale",
|
|
outputDir: path.join(root, "downloads", "stale"),
|
|
extractDir: path.join(root, "extract", "stale"),
|
|
status: "reconnect_wait",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/stale",
|
|
provider: "realdebrid",
|
|
status: "paused",
|
|
retries: 0,
|
|
speedBps: 100,
|
|
downloadedBytes: 123,
|
|
totalBytes: 456,
|
|
progressPercent: 26,
|
|
fileName: "stale.mkv",
|
|
targetPath: path.join(root, "downloads", "stale", "stale.mkv"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Pausiert",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.running).toBe(false);
|
|
expect(snapshot.session.paused).toBe(false);
|
|
expect(snapshot.session.reconnectUntil).toBe(0);
|
|
expect(snapshot.session.reconnectReason).toBe("");
|
|
expect(snapshot.session.items[itemId]?.status).toBe("queued");
|
|
expect(snapshot.session.items[itemId]?.speedBps).toBe(0);
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
|
expect(snapshot.canStart).toBe(true);
|
|
});
|
|
|
|
it("does not requeue an exhausted HTTP 416 item after restart", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "retry-416-pkg";
|
|
const itemId = "retry-416-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
const outputDir = path.join(root, "downloads", "retry-416");
|
|
const targetPath = path.join(outputDir, "broken.part03.rar");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(targetPath, Buffer.alloc(12 * 1024, 1));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "retry-416",
|
|
outputDir,
|
|
extractDir: path.join(root, "extract", "retry-416"),
|
|
status: "failed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/retry-416",
|
|
provider: "megadebrid",
|
|
status: "failed",
|
|
retries: 4,
|
|
speedBps: 0,
|
|
downloadedBytes: 12 * 1024,
|
|
totalBytes: 8 * 1024,
|
|
progressPercent: 100,
|
|
fileName: "broken.part03.rar",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 3,
|
|
lastError: "Error: HTTP 416",
|
|
fullStatus: "Fehler: Error: HTTP 416",
|
|
http416FreshRestarts: 2,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.waitForStartupRecovery();
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
const item = snapshot.session.items[itemId];
|
|
expect(item?.status).toBe("failed");
|
|
expect(item?.attempts).toBe(3);
|
|
expect(item?.downloadedBytes).toBe(12 * 1024);
|
|
expect(item?.progressPercent).toBe(100);
|
|
expect(item?.fullStatus).toContain("Fehler");
|
|
expect(item?.http416FreshRestarts).toBe(2);
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("failed");
|
|
expect(fs.existsSync(targetPath)).toBe(true);
|
|
});
|
|
|
|
it("keeps an exhausted locked HTTP 416 partial intact without requeueing it after restart", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "retry-416-locked-pkg";
|
|
const itemId = "retry-416-locked-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
const outputDir = path.join(root, "downloads", "retry-416-locked");
|
|
const targetPath = path.join(outputDir, "locked.part03.rar");
|
|
const partialBytes = 12 * 1024;
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(targetPath, Buffer.alloc(partialBytes, 1));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "retry-416-locked",
|
|
outputDir,
|
|
extractDir: path.join(root, "extract", "retry-416-locked"),
|
|
status: "failed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/retry-416-locked",
|
|
provider: "megadebrid-web",
|
|
status: "failed",
|
|
retries: 4,
|
|
speedBps: 0,
|
|
downloadedBytes: partialBytes,
|
|
totalBytes: partialBytes * 2,
|
|
progressPercent: 50,
|
|
fileName: "locked.part03.rar",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 3,
|
|
lastError: "Error: HTTP 416",
|
|
fullStatus: "Fehler: Error: HTTP 416",
|
|
http416FreshRestarts: 2,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const originalRmSync = fs.rmSync;
|
|
const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => {
|
|
if (path.resolve(String(candidate)) === path.resolve(targetPath)) {
|
|
throw Object.assign(new Error("target busy"), { code: "EBUSY" });
|
|
}
|
|
return originalRmSync(candidate, options);
|
|
}) as typeof fs.rmSync);
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaDebridWebCredentials: "mega-user:mega-pass",
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false,
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.waitForStartupRecovery();
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item).toMatchObject({
|
|
status: "failed",
|
|
downloadedBytes: partialBytes,
|
|
totalBytes: partialBytes * 2,
|
|
progressPercent: 50,
|
|
http416FreshRestarts: 2,
|
|
fullStatus: "Fehler: Error: HTTP 416"
|
|
});
|
|
expect(item?.resumeResetPending).toBeUndefined();
|
|
expect(fs.existsSync(targetPath)).toBe(true);
|
|
expect(fs.statSync(targetPath).size).toBe(partialBytes);
|
|
} finally {
|
|
rmSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("requeues completed zero-byte archive items automatically on startup", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "zero-byte-pkg";
|
|
const itemId = "zero-byte-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
const outputDir = path.join(root, "downloads", "zero-byte");
|
|
const targetPath = path.join(outputDir, "archive.part01.rar");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(targetPath, Buffer.alloc(0));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "zero-byte",
|
|
outputDir,
|
|
extractDir: path.join(root, "extract", "zero-byte"),
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/zero-byte",
|
|
provider: "megadebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 100,
|
|
fileName: "archive.part01.rar",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (0 B)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
const item = snapshot.session.items[itemId];
|
|
expect(item?.status).toBe("queued");
|
|
expect(item?.downloadedBytes).toBe(0);
|
|
expect(item?.progressPercent).toBe(0);
|
|
expect(item?.fullStatus).toContain("0B-Datei");
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
|
expect(fs.existsSync(targetPath)).toBe(false);
|
|
});
|
|
|
|
it("requeues preallocated completed archive items automatically on startup", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "prealloc-pkg";
|
|
const itemId = "prealloc-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
const outputDir = path.join(root, "downloads", "prealloc");
|
|
const targetPath = path.join(outputDir, "archive.part01.rar");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(targetPath, Buffer.alloc(8192));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "prealloc",
|
|
outputDir,
|
|
extractDir: path.join(root, "extract", "prealloc"),
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/prealloc",
|
|
provider: "megadebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 1024,
|
|
totalBytes: 8192,
|
|
progressPercent: 100,
|
|
fileName: "archive.part01.rar",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (8 KB)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
const item = snapshot.session.items[itemId];
|
|
expect(item?.status).toBe("queued");
|
|
expect(item?.fullStatus).toContain("pre-alloc");
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
|
});
|
|
|
|
it("does not recover queued pre-allocated archive leftovers as completed during post-processing", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "postproc-prealloc-pkg";
|
|
const itemId = "postproc-prealloc-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
const outputDir = path.join(root, "downloads", "postproc-prealloc");
|
|
const targetPath = path.join(outputDir, "postproc-prealloc.part01.rar");
|
|
const totalBytes = 2 * 1024 * 1024;
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(targetPath, Buffer.alloc(totalBytes, 0));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "postproc-prealloc",
|
|
outputDir,
|
|
extractDir: path.join(root, "extract", "postproc-prealloc"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/postproc-prealloc",
|
|
provider: "realdebrid",
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes,
|
|
progressPercent: 0,
|
|
fileName: "postproc-prealloc.part01.rar",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await (manager as any).handlePackagePostProcessing(packageId);
|
|
const snapshot = manager.getSnapshot();
|
|
const item = snapshot.session.items[itemId];
|
|
expect(item?.status).toBe("queued");
|
|
expect(item?.fullStatus).toContain("pre-alloc");
|
|
expect(item?.downloadedBytes).toBe(0);
|
|
expect(item?.progressPercent).toBe(0);
|
|
expect(fs.existsSync(targetPath)).toBe(false);
|
|
});
|
|
|
|
it("requeues near-total non-binary pre-allocated leftovers during post-processing", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "postproc-near-total-prealloc-pkg";
|
|
const itemId = "postproc-near-total-prealloc-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
const outputDir = path.join(root, "downloads", "postproc-near-total-prealloc");
|
|
const targetPath = path.join(outputDir, "postproc-near-total-prealloc.data");
|
|
const totalBytes = 2 * 1024 * 1024;
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(targetPath, Buffer.alloc(totalBytes - 1, 0));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "postproc-near-total-prealloc",
|
|
outputDir,
|
|
extractDir: path.join(root, "extract", "postproc-near-total-prealloc"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/postproc-near-total-prealloc",
|
|
provider: "realdebrid",
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes,
|
|
progressPercent: 0,
|
|
fileName: "postproc-near-total-prealloc.data",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await (manager as any).handlePackagePostProcessing(packageId);
|
|
const snapshot = manager.getSnapshot();
|
|
const item = snapshot.session.items[itemId];
|
|
expect(item?.status).toBe("queued");
|
|
expect(item?.fullStatus).toContain("pre-alloc");
|
|
expect(item?.downloadedBytes).toBe(0);
|
|
expect(item?.progressPercent).toBe(0);
|
|
expect(fs.existsSync(targetPath)).toBe(false);
|
|
});
|
|
|
|
it("requeues completed archive parts after auto-recovery extraction failures", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "crc-pkg";
|
|
const createdAt = Date.now() - 10_000;
|
|
const outputDir = path.join(root, "downloads", "crc");
|
|
const extractDir = path.join(root, "extract", "crc");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"];
|
|
const itemIds = archiveNames.map((_, index) => `crc-item-${index}`);
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "crc",
|
|
outputDir,
|
|
extractDir,
|
|
status: "extracting",
|
|
itemIds,
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
for (const [index, archiveName] of archiveNames.entries()) {
|
|
const targetPath = path.join(outputDir, archiveName);
|
|
const partBytes = Buffer.alloc(4096, 0x41 + index);
|
|
fs.writeFileSync(targetPath, partBytes);
|
|
session.items[itemIds[index]!] = {
|
|
id: itemIds[index]!,
|
|
packageId,
|
|
url: `https://dummy/${archiveName}`,
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: partBytes.length,
|
|
totalBytes: partBytes.length,
|
|
progressPercent: 100,
|
|
fileName: archiveName,
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpacken - Ausstehend",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
}
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const changed = (manager as any).autoRecoverArchiveCrcFailure(
|
|
session.packages[packageId],
|
|
itemIds.map((itemId) => session.items[itemId]!),
|
|
{
|
|
archiveName: "show.s01e01.part1.rar",
|
|
errorText: "Checksum error in the encrypted file",
|
|
category: "crc_error",
|
|
suggestRedownload: true,
|
|
jvmFailureReason: "Can not open the file as archive"
|
|
},
|
|
"hybrid"
|
|
);
|
|
|
|
expect(changed).toBe(2);
|
|
for (const itemId of itemIds) {
|
|
const item = session.items[itemId]!;
|
|
expect(item.status).toBe("queued");
|
|
expect(item.targetPath).toBe("");
|
|
expect(item.downloadedBytes).toBe(0);
|
|
expect(item.attempts).toBe(0);
|
|
expect(item.fullStatus).toContain("Auto-Recovery");
|
|
}
|
|
expect(fs.existsSync(path.join(outputDir, archiveNames[0]!))).toBe(false);
|
|
expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false);
|
|
expect(session.packages[packageId]?.status).toBe("queued");
|
|
});
|
|
|
|
it("requeues archive parts on CRC error when file has invalid archive signature (corrupt content)", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "crc-corrupt-sig-pkg";
|
|
const createdAt = Date.now() - 10_000;
|
|
const outputDir = path.join(root, "downloads", "crc-corrupt-sig");
|
|
const extractDir = path.join(root, "extract", "crc-corrupt-sig");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"];
|
|
const itemIds = archiveNames.map((_, index) => `crc-corrupt-sig-item-${index}`);
|
|
const archiveSize = 64 * 1024;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "crc-corrupt-sig",
|
|
outputDir,
|
|
extractDir,
|
|
status: "extracting",
|
|
itemIds,
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
for (const [index, archiveName] of archiveNames.entries()) {
|
|
const targetPath = path.join(outputDir, archiveName);
|
|
fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, 0xAA));
|
|
session.items[itemIds[index]!] = {
|
|
id: itemIds[index]!,
|
|
packageId,
|
|
url: `https://dummy/${archiveName}`,
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: archiveSize,
|
|
totalBytes: archiveSize,
|
|
progressPercent: 100,
|
|
fileName: archiveName,
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpacken - Ausstehend",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
}
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const changed = (manager as any).autoRecoverArchiveCrcFailure(
|
|
session.packages[packageId],
|
|
itemIds.map((itemId) => session.items[itemId]!),
|
|
{
|
|
archiveName: "show.s01e01.part1.rar",
|
|
errorText: "Checksum error in the encrypted file",
|
|
category: "crc_error",
|
|
suggestRedownload: true,
|
|
jvmFailureReason: "Can not open the file as archive"
|
|
},
|
|
"hybrid"
|
|
);
|
|
|
|
expect(changed).toBe(2);
|
|
for (const itemId of itemIds) {
|
|
const item = session.items[itemId]!;
|
|
expect(item.status).toBe("queued");
|
|
expect(item.targetPath).toBe("");
|
|
expect(item.downloadedBytes).toBe(0);
|
|
expect(item.fullStatus).toContain("Auto-Recovery");
|
|
}
|
|
expect(fs.existsSync(path.join(outputDir, archiveNames[0]!))).toBe(false);
|
|
expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false);
|
|
});
|
|
|
|
it("does not requeue archive parts on CRC error when file has valid RAR signature (wrong password)", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "crc-valid-sig-pkg";
|
|
const createdAt = Date.now() - 10_000;
|
|
const outputDir = path.join(root, "downloads", "crc-valid-sig");
|
|
const extractDir = path.join(root, "extract", "crc-valid-sig");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"];
|
|
const itemIds = archiveNames.map((_, index) => `crc-valid-sig-item-${index}`);
|
|
const archiveSize = 64 * 1024;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "crc-valid-sig",
|
|
outputDir,
|
|
extractDir,
|
|
status: "extracting",
|
|
itemIds,
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
for (const [index, archiveName] of archiveNames.entries()) {
|
|
const targetPath = path.join(outputDir, archiveName);
|
|
const content = Buffer.alloc(archiveSize, 0);
|
|
Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]).copy(content);
|
|
fs.writeFileSync(targetPath, content);
|
|
session.items[itemIds[index]!] = {
|
|
id: itemIds[index]!,
|
|
packageId,
|
|
url: `https://dummy/${archiveName}`,
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: archiveSize,
|
|
totalBytes: archiveSize,
|
|
progressPercent: 100,
|
|
fileName: archiveName,
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpacken - Ausstehend",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
}
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const changed = (manager as any).autoRecoverArchiveCrcFailure(
|
|
session.packages[packageId],
|
|
itemIds.map((itemId) => session.items[itemId]!),
|
|
{
|
|
archiveName: "show.s01e01.part1.rar",
|
|
errorText: "Checksum error in the encrypted file",
|
|
category: "crc_error",
|
|
suggestRedownload: true,
|
|
jvmFailureReason: "Can not open the file as archive"
|
|
},
|
|
"hybrid"
|
|
);
|
|
|
|
expect(changed).toBe(0);
|
|
for (const itemId of itemIds) {
|
|
const item = session.items[itemId]!;
|
|
expect(item.status).toBe("completed");
|
|
expect(item.targetPath).toContain(".rar");
|
|
expect(item.downloadedBytes).toBe(archiveSize);
|
|
}
|
|
});
|
|
|
|
it("does not treat rev files as ready archive parts during disk fallback", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "disk-fallback-rev-pkg";
|
|
const itemIds = ["disk-fallback-rev-1", "disk-fallback-rev-2"];
|
|
const createdAt = Date.now() - 10_000;
|
|
const outputDir = path.join(root, "downloads", "disk-fallback-rev");
|
|
const extractDir = path.join(root, "extract", "disk-fallback-rev");
|
|
const part1Path = path.join(outputDir, "show.s01e01.part1.rar");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(part1Path, Buffer.alloc(64 * 1024, 1));
|
|
fs.writeFileSync(path.join(outputDir, "show.s01e01.rev"), Buffer.alloc(32 * 1024, 2));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "disk-fallback-rev",
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds,
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemIds[0]] = {
|
|
id: itemIds[0],
|
|
packageId,
|
|
url: "https://dummy/show.s01e01.part1.rar",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 64 * 1024,
|
|
totalBytes: 64 * 1024,
|
|
progressPercent: 100,
|
|
fileName: "show.s01e01.part1.rar",
|
|
targetPath: part1Path,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpacken - Ausstehend",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemIds[1]] = {
|
|
id: itemIds[1],
|
|
packageId,
|
|
url: "https://dummy/show.s01e01.part2.rar",
|
|
provider: "realdebrid",
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: 64 * 1024,
|
|
progressPercent: 0,
|
|
fileName: "show.s01e01.part2.rar",
|
|
targetPath: "",
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]);
|
|
expect(Array.from(ready)).toHaveLength(0);
|
|
});
|
|
|
|
it("allows disk fallback when queued archive parts are fully present on disk", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "disk-fallback-ready-pkg";
|
|
const itemIds = ["disk-fallback-ready-1", "disk-fallback-ready-2"];
|
|
const createdAt = Date.now() - 10_000;
|
|
const outputDir = path.join(root, "downloads", "disk-fallback-ready");
|
|
const extractDir = path.join(root, "extract", "disk-fallback-ready");
|
|
const part1Path = path.join(outputDir, "show.s01e01.part1.rar");
|
|
const part2Path = path.join(outputDir, "show.s01e01.part2.rar");
|
|
const archiveSize = 64 * 1024;
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(part1Path, Buffer.alloc(archiveSize, 1));
|
|
fs.writeFileSync(part2Path, Buffer.alloc(archiveSize, 2));
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "disk-fallback-ready",
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds,
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemIds[0]] = {
|
|
id: itemIds[0],
|
|
packageId,
|
|
url: "https://dummy/show.s01e01.part1.rar",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: archiveSize,
|
|
totalBytes: archiveSize,
|
|
progressPercent: 100,
|
|
fileName: "show.s01e01.part1.rar",
|
|
targetPath: part1Path,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpacken - Ausstehend",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemIds[1]] = {
|
|
id: itemIds[1],
|
|
packageId,
|
|
url: "https://dummy/show.s01e01.part2.rar",
|
|
provider: "realdebrid",
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: archiveSize,
|
|
totalBytes: archiveSize,
|
|
progressPercent: 100,
|
|
fileName: "show.s01e01.part2.rar",
|
|
targetPath: "",
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]);
|
|
expect(Array.from(ready)).toEqual([part1Path.toLowerCase()]);
|
|
});
|
|
|
|
it("skips unchanged hybrid archives after a previous extraction failure", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const {
|
|
session,
|
|
packageId,
|
|
itemId,
|
|
outputDir,
|
|
extractDir
|
|
} = createCompletedArchiveSession(root, "hybrid-failure-skip", "episode.mkv");
|
|
const item = session.items[itemId]!;
|
|
const archiveKey = item.targetPath.toLowerCase();
|
|
item.fullStatus = "Entpacken - Error";
|
|
session.packages[packageId]!.status = "queued";
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
hybridExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const pkg = (manager as any).session.packages[packageId];
|
|
const items = [((manager as any).session.items[itemId])];
|
|
const marker = (manager as any).buildHybridArchiveRetryMarker(pkg, items, archiveKey);
|
|
(manager as any).hybridFailedArchives.set(packageId, new Map([
|
|
[archiveKey, { marker, lastError: "Checksum error in the encrypted file", updatedAt: Date.now() }]
|
|
]));
|
|
|
|
const extracted = await (manager as any).runHybridExtraction(packageId, pkg, items);
|
|
|
|
expect(extracted).toBe(0);
|
|
expect(fs.existsSync(path.join(extractDir, "episode.mkv"))).toBe(false);
|
|
expect(((manager as any).session.items[itemId]).fullStatus).toBe("Entpacken - Error");
|
|
expect(((manager as any).session.packages[packageId]).status).not.toBe("extracting");
|
|
expect(fs.existsSync(path.join(outputDir, "episode.zip"))).toBe(true);
|
|
});
|
|
|
|
it("does not auto-reschedule extraction for completed items already marked as extract error", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const {
|
|
session,
|
|
packageId,
|
|
itemId
|
|
} = createCompletedArchiveSession(root, "hybrid-error-hold", "episode.mkv");
|
|
session.items[itemId]!.fullStatus = "Entpacken - Error";
|
|
session.packages[packageId]!.status = "queued";
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
hybridExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
(manager as any).triggerPendingExtractions();
|
|
|
|
expect((manager as any).packagePostProcessTasks.has(packageId)).toBe(false);
|
|
expect((manager as any).session.items[itemId].fullStatus).toBe("Entpacken - Error");
|
|
});
|
|
|
|
it("does not auto-reschedule extraction for completed items already marked as entpack-fehler", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const {
|
|
session,
|
|
packageId,
|
|
itemId
|
|
} = createCompletedArchiveSession(root, "hybrid-entpack-fehler-hold", "episode.mkv");
|
|
session.items[itemId]!.fullStatus = "Entpack-Fehler: Checksum error in encrypted file";
|
|
session.packages[packageId]!.status = "queued";
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
hybridExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
(manager as any).triggerPendingExtractions();
|
|
|
|
expect((manager as any).packagePostProcessTasks.has(packageId)).toBe(false);
|
|
expect((manager as any).session.items[itemId].fullStatus).toBe("Entpack-Fehler: Checksum error in encrypted file");
|
|
});
|
|
|
|
it("applies final extract errors only to the affected full-extract archive items", () => {
|
|
const createdAt = Date.now() - 10_000;
|
|
const completedItems = [
|
|
{
|
|
id: "full-fail-item-1",
|
|
status: "completed",
|
|
fileName: "show.s01e01.part1.rar",
|
|
downloadedBytes: 100 * 1024 * 1024,
|
|
fullStatus: "Fertig (100 MB)",
|
|
updatedAt: createdAt
|
|
},
|
|
{
|
|
id: "full-fail-item-2",
|
|
status: "completed",
|
|
fileName: "show.s01e01.part2.rar",
|
|
downloadedBytes: 100 * 1024 * 1024,
|
|
fullStatus: "Fertig (100 MB)",
|
|
updatedAt: createdAt
|
|
},
|
|
{
|
|
id: "full-fail-item-3",
|
|
status: "completed",
|
|
fileName: "show.s01e02.part1.rar",
|
|
downloadedBytes: 200 * 1024 * 1024,
|
|
fullStatus: "Fertig (200 MB)",
|
|
updatedAt: createdAt
|
|
},
|
|
{
|
|
id: "full-fail-item-4",
|
|
status: "completed",
|
|
fileName: "show.s01e02.part2.rar",
|
|
downloadedBytes: 200 * 1024 * 1024,
|
|
fullStatus: "Fertig (200 MB)",
|
|
updatedAt: createdAt
|
|
}
|
|
] as any[];
|
|
const previousStatuses = new Map<string, string>(completedItems.map((item: any) => [item.id, item.fullStatus]));
|
|
|
|
for (const item of completedItems) {
|
|
item.fullStatus = "Entpacken - Ausstehend";
|
|
}
|
|
completedItems[0].fullStatus = "Entpacken - Error";
|
|
completedItems[1].fullStatus = "Entpacken - Error";
|
|
const resolveArchiveItems = (archiveName: string) => {
|
|
const base = archiveName.replace(/\.part0*1\.rar$/i, "");
|
|
return completedItems.filter((item: any) => String(item.fileName || "").toLowerCase().startsWith(`${base}.part`));
|
|
};
|
|
|
|
(DownloadManager.prototype as any).applyPackageExtractFailureStatuses.call(
|
|
{},
|
|
completedItems,
|
|
resolveArchiveItems,
|
|
new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]),
|
|
"Checksum error in the encrypted file",
|
|
previousStatuses,
|
|
createdAt + 5_000
|
|
);
|
|
|
|
expect(completedItems[0].fullStatus).toBe("Entpack-Fehler [show.s01e01.part1.rar]: Checksum/CRC-Fehler im Archiv");
|
|
expect(completedItems[1].fullStatus).toBe("Entpack-Fehler [show.s01e01.part1.rar]: Checksum/CRC-Fehler im Archiv");
|
|
expect(completedItems[2].fullStatus).toBe("Fertig (200 MB)");
|
|
expect(completedItems[3].fullStatus).toBe("Fertig (200 MB)");
|
|
});
|
|
|
|
it("clears stale pending extraction labels for untouched items when another archive fails", () => {
|
|
const createdAt = Date.now() - 10_000;
|
|
const completedItems = [
|
|
{
|
|
id: "stale-fail-item-1",
|
|
status: "completed",
|
|
fileName: "show.s01e01.part1.rar",
|
|
downloadedBytes: 100 * 1024 * 1024,
|
|
fullStatus: "Fertig (100 MB)",
|
|
updatedAt: createdAt
|
|
},
|
|
{
|
|
id: "stale-fail-item-2",
|
|
status: "completed",
|
|
fileName: "show.s01e01.part2.rar",
|
|
downloadedBytes: 100 * 1024 * 1024,
|
|
fullStatus: "Fertig (100 MB)",
|
|
updatedAt: createdAt
|
|
},
|
|
{
|
|
id: "stale-fail-item-3",
|
|
status: "completed",
|
|
fileName: "show.s01e05.part2.rar",
|
|
downloadedBytes: 180 * 1024 * 1024,
|
|
fullStatus: "Entpacken - Ausstehend",
|
|
updatedAt: createdAt
|
|
}
|
|
] as any[];
|
|
const previousStatuses = new Map<string, string>(completedItems.map((item: any) => [item.id, item.fullStatus]));
|
|
|
|
completedItems[0].fullStatus = "Entpacken - Error";
|
|
completedItems[1].fullStatus = "Entpacken - Error";
|
|
|
|
(DownloadManager.prototype as any).applyPackageExtractFailureStatuses.call(
|
|
{},
|
|
completedItems,
|
|
(archiveName: string) => resolveArchiveItemsFromList(archiveName, completedItems),
|
|
new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]),
|
|
"Checksum error in the encrypted file",
|
|
previousStatuses,
|
|
createdAt + 5_000
|
|
);
|
|
|
|
expect(completedItems[0].fullStatus).toBe("Entpack-Fehler [show.s01e01.part1.rar]: Checksum/CRC-Fehler im Archiv");
|
|
expect(completedItems[1].fullStatus).toBe("Entpack-Fehler [show.s01e01.part1.rar]: Checksum/CRC-Fehler im Archiv");
|
|
expect(completedItems[2].fullStatus).toBe("Fertig (180 MB)");
|
|
});
|
|
|
|
it("detects start conflicts when extract output already exists", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageId = "conflict-pkg";
|
|
const itemId = "conflict-item";
|
|
const now = Date.now() - 5000;
|
|
const outputDir = path.join(root, "downloads", "conflict");
|
|
const extractDir = path.join(root, "extract", "conflict");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.writeFileSync(path.join(extractDir, "existing.mkv"), "x", "utf8");
|
|
|
|
const session = emptySession();
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "conflict",
|
|
outputDir,
|
|
extractDir,
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/conflict",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "conflict.part01.rar",
|
|
targetPath: path.join(outputDir, "conflict.part01.rar"),
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const conflicts = await manager.getStartConflicts();
|
|
expect(conflicts.length).toBe(1);
|
|
expect(conflicts[0]?.packageId).toBe(packageId);
|
|
});
|
|
|
|
it("resolves start conflict by skipping package", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageId = "skip-pkg";
|
|
const itemId = "skip-item";
|
|
const now = Date.now() - 5000;
|
|
const outputDir = path.join(root, "downloads", "skip");
|
|
const extractDir = path.join(root, "extract", "skip");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.writeFileSync(path.join(extractDir, "existing.mkv"), "x", "utf8");
|
|
|
|
const session = emptySession();
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "skip",
|
|
outputDir,
|
|
extractDir,
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/skip",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "skip.part01.rar",
|
|
targetPath: path.join(outputDir, "skip.part01.rar"),
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const result = await manager.resolveStartConflict(packageId, "skip");
|
|
expect(result.skipped).toBe(true);
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.packages[packageId]).toBeDefined();
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
|
expect(snapshot.session.items[itemId]).toBeDefined();
|
|
expect(snapshot.session.items[itemId]?.status).toBe("queued");
|
|
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Wartet");
|
|
});
|
|
|
|
it("keeps already completed items when skipping start conflict", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageId = "skip-partial-pkg";
|
|
const completedItemId = "skip-partial-completed";
|
|
const pendingItemId = "skip-partial-pending";
|
|
const now = Date.now() - 5000;
|
|
const outputDir = path.join(root, "downloads", "skip-partial");
|
|
const extractDir = path.join(root, "extract", "skip-partial");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.writeFileSync(path.join(extractDir, "existing.mkv"), "x", "utf8");
|
|
const completedTarget = path.join(outputDir, "skip-partial.part01.rar");
|
|
fs.writeFileSync(completedTarget, "part", "utf8");
|
|
|
|
const session = emptySession();
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "skip-partial",
|
|
outputDir,
|
|
extractDir,
|
|
status: "queued",
|
|
itemIds: [completedItemId, pendingItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
session.items[completedItemId] = {
|
|
id: completedItemId,
|
|
packageId,
|
|
url: "https://dummy/skip-partial/completed",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 123,
|
|
totalBytes: 123,
|
|
progressPercent: 100,
|
|
fileName: "skip-partial.part01.rar",
|
|
targetPath: completedTarget,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt",
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
session.items[pendingItemId] = {
|
|
id: pendingItemId,
|
|
packageId,
|
|
url: "https://dummy/skip-partial/pending",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "skip-partial.part02.rar",
|
|
targetPath: path.join(outputDir, "skip-partial.part02.rar"),
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const result = await manager.resolveStartConflict(packageId, "skip");
|
|
expect(result.skipped).toBe(true);
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.packages[packageId]).toBeDefined();
|
|
expect(snapshot.session.items[completedItemId]?.status).toBe("completed");
|
|
expect(snapshot.session.items[completedItemId]?.fullStatus).toBe("Entpackt");
|
|
expect(snapshot.session.items[pendingItemId]?.status).toBe("queued");
|
|
expect(snapshot.session.items[pendingItemId]?.fullStatus).toBe("Wartet");
|
|
});
|
|
|
|
it("resolves start conflict by overwriting and resetting queued package", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageId = "overwrite-pkg";
|
|
const itemId = "overwrite-item";
|
|
const now = Date.now() - 5000;
|
|
const outputDir = path.join(root, "downloads", "overwrite");
|
|
const extractDir = path.join(root, "extract", "overwrite");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.writeFileSync(path.join(outputDir, "overwrite.part01.rar"), "part", "utf8");
|
|
fs.writeFileSync(path.join(extractDir, "existing.mkv"), "x", "utf8");
|
|
|
|
const session = emptySession();
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "overwrite",
|
|
outputDir,
|
|
extractDir,
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/overwrite",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 1,
|
|
speedBps: 0,
|
|
downloadedBytes: 42,
|
|
totalBytes: 100,
|
|
progressPercent: 42,
|
|
fileName: "overwrite.part01.rar",
|
|
targetPath: path.join(outputDir, "overwrite.part01.rar"),
|
|
resumable: true,
|
|
attempts: 3,
|
|
lastError: "x",
|
|
fullStatus: "Wartet",
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const result = await manager.resolveStartConflict(packageId, "overwrite");
|
|
expect(result.overwritten).toBe(true);
|
|
const snapshot = manager.getSnapshot();
|
|
const item = snapshot.session.items[itemId];
|
|
expect(item?.status).toBe("queued");
|
|
expect(item?.downloadedBytes).toBe(0);
|
|
expect(item?.progressPercent).toBe(0);
|
|
expect(item?.attempts).toBe(0);
|
|
expect(item?.lastError).toBe("");
|
|
expect(item?.fullStatus).toBe("Wartet");
|
|
expect(fs.existsSync(outputDir)).toBe(false);
|
|
expect(fs.existsSync(extractDir)).toBe(false);
|
|
});
|
|
|
|
it("requeues legacy 'Gestoppt' items on startup", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "stopped-pkg";
|
|
const itemId = "stopped-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "stopped",
|
|
outputDir: path.join(root, "downloads", "stopped"),
|
|
extractDir: path.join(root, "extract", "stopped"),
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/stopped",
|
|
provider: "megadebrid",
|
|
status: "cancelled",
|
|
retries: 1,
|
|
speedBps: 0,
|
|
downloadedBytes: 512,
|
|
totalBytes: 2048,
|
|
progressPercent: 25,
|
|
fileName: "resume.part01.rar",
|
|
targetPath: path.join(root, "downloads", "stopped", "resume.part01.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Gestoppt",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.items[itemId]?.status).toBe("queued");
|
|
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Wartet");
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
|
});
|
|
|
|
it("cleans leftover split archives on startup for already extracted packages", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageDir = path.join(root, "downloads", "legacy");
|
|
fs.mkdirSync(packageDir, { recursive: true });
|
|
const part1 = path.join(packageDir, "legacy.release.part01.rar");
|
|
const part2 = path.join(packageDir, "legacy.release.part02.rar");
|
|
const part3 = path.join(packageDir, "legacy.release.part03.rar");
|
|
const keep = path.join(packageDir, "keep.txt");
|
|
fs.writeFileSync(part2, "part2", "utf8");
|
|
fs.writeFileSync(part3, "part3", "utf8");
|
|
fs.writeFileSync(keep, "keep", "utf8");
|
|
|
|
const session = emptySession();
|
|
const packageId = "legacy-pkg";
|
|
const itemId = "legacy-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "legacy",
|
|
outputDir: packageDir,
|
|
extractDir: path.join(root, "extract", "legacy"),
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/legacy",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 123,
|
|
totalBytes: 123,
|
|
progressPercent: 100,
|
|
fileName: path.basename(part1),
|
|
targetPath: part1,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
cleanupMode: "delete"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await waitFor(() => !fs.existsSync(part2) && !fs.existsSync(part3), 5000);
|
|
expect(fs.existsSync(keep)).toBe(true);
|
|
});
|
|
|
|
it("cleans legacy leftovers when package is extracted but marker is old", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageDir = path.join(root, "downloads", "legacy-old");
|
|
const extractDir = path.join(root, "extract", "legacy-old");
|
|
fs.mkdirSync(packageDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
|
|
const part1 = path.join(packageDir, "legacy.old.part01.rar");
|
|
const part2 = path.join(packageDir, "legacy.old.part02.rar");
|
|
const part3 = path.join(packageDir, "legacy.old.part03.rar");
|
|
const keep = path.join(packageDir, "keep.nfo");
|
|
fs.writeFileSync(part1, Buffer.alloc(123, 0x61));
|
|
fs.writeFileSync(part2, Buffer.alloc(123, 0x62));
|
|
fs.writeFileSync(part3, Buffer.alloc(123, 0x63));
|
|
fs.writeFileSync(keep, "keep", "utf8");
|
|
fs.writeFileSync(path.join(extractDir, "episode.mkv"), "video", "utf8");
|
|
|
|
const session = emptySession();
|
|
const packageId = "legacy-old-pkg";
|
|
const itemId = "legacy-old-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "legacy-old",
|
|
outputDir: packageDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/legacy-old",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 123,
|
|
totalBytes: 123,
|
|
progressPercent: 100,
|
|
fileName: path.basename(part1),
|
|
targetPath: part1,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (123 MB)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
cleanupMode: "delete"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await waitFor(() => !fs.existsSync(part1) && !fs.existsSync(part2) && !fs.existsSync(part3), 5000);
|
|
expect(fs.existsSync(keep)).toBe(true);
|
|
expect(fs.existsSync(path.join(extractDir, "episode.mkv"))).toBe(true);
|
|
});
|
|
|
|
it("removes empty download package directory after startup cleanup backfill", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageDir = path.join(root, "downloads", "legacy-empty");
|
|
fs.mkdirSync(packageDir, { recursive: true });
|
|
const part1 = path.join(packageDir, "legacy.empty.part01.rar");
|
|
const part2 = path.join(packageDir, "legacy.empty.part02.rar");
|
|
fs.writeFileSync(part1, "part1", "utf8");
|
|
fs.writeFileSync(part2, "part2", "utf8");
|
|
|
|
const session = emptySession();
|
|
const packageId = "legacy-empty-pkg";
|
|
const itemId = "legacy-empty-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "legacy-empty",
|
|
outputDir: packageDir,
|
|
extractDir: path.join(root, "extract", "legacy-empty"),
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/legacy-empty",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 123,
|
|
totalBytes: 123,
|
|
progressPercent: 100,
|
|
fileName: path.basename(part1),
|
|
targetPath: part1,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
cleanupMode: "delete"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await waitFor(() => !fs.existsSync(packageDir), 5000);
|
|
});
|
|
|
|
it("does not over-clean packages that share one extract directory", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const sharedExtractDir = path.join(root, "extract", "shared");
|
|
fs.mkdirSync(sharedExtractDir, { recursive: true });
|
|
fs.writeFileSync(path.join(sharedExtractDir, "already-extracted.mkv"), "ok", "utf8");
|
|
|
|
const pkg1Dir = path.join(root, "downloads", "pkg1");
|
|
const pkg2Dir = path.join(root, "downloads", "pkg2");
|
|
fs.mkdirSync(pkg1Dir, { recursive: true });
|
|
fs.mkdirSync(pkg2Dir, { recursive: true });
|
|
|
|
const pkg1Part1 = path.join(pkg1Dir, "show.one.part01.rar");
|
|
const pkg1Part2 = path.join(pkg1Dir, "show.one.part02.rar");
|
|
const pkg2Part1 = path.join(pkg2Dir, "show.two.part01.rar");
|
|
const pkg2Part2 = path.join(pkg2Dir, "show.two.part02.rar");
|
|
fs.writeFileSync(pkg1Part1, "a1", "utf8");
|
|
fs.writeFileSync(pkg1Part2, "a2", "utf8");
|
|
fs.writeFileSync(pkg2Part1, "b1", "utf8");
|
|
fs.writeFileSync(pkg2Part2, "b2", "utf8");
|
|
|
|
const session = emptySession();
|
|
const createdAt = Date.now() - 30_000;
|
|
|
|
session.packageOrder = ["pkg1", "pkg2"];
|
|
session.packages.pkg1 = {
|
|
id: "pkg1",
|
|
name: "pkg1",
|
|
outputDir: pkg1Dir,
|
|
extractDir: sharedExtractDir,
|
|
status: "completed",
|
|
itemIds: ["pkg1-item"],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.packages.pkg2 = {
|
|
id: "pkg2",
|
|
name: "pkg2",
|
|
outputDir: pkg2Dir,
|
|
extractDir: sharedExtractDir,
|
|
status: "completed",
|
|
itemIds: ["pkg2-item"],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
session.items["pkg1-item"] = {
|
|
id: "pkg1-item",
|
|
packageId: "pkg1",
|
|
url: "https://dummy/pkg1",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 1,
|
|
totalBytes: 1,
|
|
progressPercent: 100,
|
|
fileName: path.basename(pkg1Part1),
|
|
targetPath: pkg1Part1,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items["pkg2-item"] = {
|
|
id: "pkg2-item",
|
|
packageId: "pkg2",
|
|
url: "https://dummy/pkg2",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 1,
|
|
totalBytes: 1,
|
|
progressPercent: 100,
|
|
fileName: path.basename(pkg2Part1),
|
|
targetPath: pkg2Part1,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (100 MB)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
cleanupMode: "delete"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await waitFor(() => !fs.existsSync(pkg1Part1) && !fs.existsSync(pkg1Part2), 5000);
|
|
expect(fs.existsSync(pkg2Part1)).toBe(true);
|
|
expect(fs.existsSync(pkg2Part2)).toBe(true);
|
|
});
|
|
|
|
it("resets run counters and reconnect state on start", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
session.runStartedAt = Date.now() - 3600 * 1000;
|
|
session.totalDownloadedBytes = 9_999_999;
|
|
session.reconnectUntil = Date.now() + 120000;
|
|
session.reconnectReason = "HTTP 503";
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 5000);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
const summary = manager.getSummary();
|
|
expect(snapshot.session.totalDownloadedBytes).toBe(0);
|
|
expect(snapshot.session.reconnectUntil).toBe(0);
|
|
expect(snapshot.session.reconnectReason).toBe("");
|
|
expect(summary).toBeNull();
|
|
});
|
|
|
|
it("shows zero total when queue is empty despite stale persisted bytes", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
session.totalDownloadedBytes = 19.99 * 1024 * 1024 * 1024;
|
|
session.runStartedAt = Date.now() - 5 * 60 * 1000;
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.stats.totalPackages).toBe(0);
|
|
expect(snapshot.stats.totalFiles).toBe(0);
|
|
expect(snapshot.stats.totalDownloaded).toBe(0);
|
|
expect(snapshot.session.totalDownloadedBytes).toBe(0);
|
|
expect(snapshot.session.runStartedAt).toBe(0);
|
|
});
|
|
|
|
it("clearAll resets total bytes and stats", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "pkg-clear";
|
|
const itemId = "item-clear";
|
|
const now = Date.now() - 1000;
|
|
const outputDir = path.join(root, "downloads", "pkg-clear");
|
|
const extractDir = path.join(root, "extract", "pkg-clear");
|
|
const targetPath = path.join(outputDir, "episode.mkv");
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "pkg-clear",
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/item-clear",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 1024,
|
|
totalBytes: 1024,
|
|
progressPercent: 100,
|
|
fileName: "episode.mkv",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (1 KB)",
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
session.totalDownloadedBytes = 1024;
|
|
session.runStartedAt = now;
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.clearAll();
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.stats.totalPackages).toBe(0);
|
|
expect(snapshot.stats.totalFiles).toBe(0);
|
|
expect(snapshot.stats.totalDownloaded).toBe(0);
|
|
expect(snapshot.session.totalDownloadedBytes).toBe(0);
|
|
expect(snapshot.session.runStartedAt).toBe(0);
|
|
});
|
|
|
|
it("keeps cumulative session totals when completed items are removed from the queue", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "pkg-complete-remove";
|
|
const itemId = "item-complete-remove";
|
|
const now = Date.now() - 1000;
|
|
const outputDir = path.join(root, "downloads", "pkg-complete-remove");
|
|
const extractDir = path.join(root, "extract", "pkg-complete-remove");
|
|
const targetPath = path.join(outputDir, "episode.mkv");
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "pkg-complete-remove",
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/item-complete-remove",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 3 * 1024,
|
|
totalBytes: 3 * 1024,
|
|
progressPercent: 100,
|
|
fileName: "episode.mkv",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (3 KB)",
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const internal = manager as unknown as {
|
|
session: { totalDownloadedBytes: number };
|
|
sessionDownloadedBytes: number;
|
|
sessionCompletedFiles: number;
|
|
itemContributedBytes: Map<string, number>;
|
|
removePackageFromSession: (packageId: string, itemIds: string[], reason?: "completed" | "deleted") => void;
|
|
};
|
|
|
|
internal.session.totalDownloadedBytes = 16 * 1024 * 1024 * 1024;
|
|
internal.sessionDownloadedBytes = 16 * 1024 * 1024 * 1024;
|
|
internal.sessionCompletedFiles = 1;
|
|
internal.itemContributedBytes.set(itemId, 3 * 1024 * 1024 * 1024);
|
|
|
|
internal.removePackageFromSession(packageId, [itemId], "completed");
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.stats.totalPackages).toBe(0);
|
|
expect(snapshot.stats.totalDownloaded).toBe(16 * 1024 * 1024 * 1024);
|
|
expect(snapshot.stats.totalFilesSession).toBe(1);
|
|
expect(snapshot.session.totalDownloadedBytes).toBe(16 * 1024 * 1024 * 1024);
|
|
});
|
|
|
|
it("does not start a run when queue is empty", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
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.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.running).toBe(false);
|
|
expect(manager.getSummary()).toBeNull();
|
|
});
|
|
|
|
it("calculates ETA from current run items only", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(128 * 1024, 4);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/slow-eta") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
const half = Math.floor(binary.length / 2);
|
|
res.write(binary.subarray(0, half));
|
|
setTimeout(() => {
|
|
res.end(binary.subarray(half));
|
|
}, 700);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/slow-eta`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "new-episode.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const session = emptySession();
|
|
const oldPkgId = "old-pkg";
|
|
const oldItemId = "old-item";
|
|
const oldNow = Date.now() - 5000;
|
|
session.packageOrder = [oldPkgId];
|
|
session.packages[oldPkgId] = {
|
|
id: oldPkgId,
|
|
name: "old",
|
|
outputDir: path.join(root, "downloads", "old"),
|
|
extractDir: path.join(root, "extract", "old"),
|
|
status: "completed",
|
|
itemIds: [oldItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt: oldNow,
|
|
updatedAt: oldNow
|
|
};
|
|
session.items[oldItemId] = {
|
|
id: oldItemId,
|
|
packageId: oldPkgId,
|
|
url: "https://dummy/old",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 100,
|
|
totalBytes: 100,
|
|
progressPercent: 100,
|
|
fileName: "old.bin",
|
|
targetPath: path.join(root, "downloads", "old", "old.bin"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "done",
|
|
createdAt: oldNow,
|
|
updatedAt: oldNow
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "new", links: ["https://dummy/new"] }]);
|
|
await manager.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
|
|
const runningSnapshot = manager.getSnapshot();
|
|
expect(runningSnapshot.session.running).toBe(true);
|
|
expect(runningSnapshot.etaText).toBe("ETA: --");
|
|
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("performs one fresh retry after fetch failed during unrestrict", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(96 * 1024, 12);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/fresh-retry") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/fresh-retry`;
|
|
let unrestrictCalls = 0;
|
|
|
|
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("/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
if (unrestrictCalls <= 3) {
|
|
throw new TypeError("fetch failed");
|
|
}
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "fresh-retry.bin",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "fresh-retry", links: ["https://dummy/fresh"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(unrestrictCalls).toBeGreaterThan(3);
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.lastError || "").toBe("");
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("retries suspicious mini files under 100 KB until the full file arrives", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(2 * 1024 * 1024, 21);
|
|
let directCalls = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/mini-retry") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
directCalls += 1;
|
|
if (directCalls === 1) {
|
|
const tiny = Buffer.from("<html><body>temporary error</body></html>", "utf8");
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(tiny.length));
|
|
res.end(tiny);
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/mini-retry`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "mini-retry.part01.rar",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "mini-retry", links: ["https://dummy/mini-retry"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
if (process.platform !== "win32") {
|
|
expect(directCalls).toBeGreaterThan(1);
|
|
}
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("accepts small .sfv metadata files without rejecting them as suspicious", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const sfvContent = Buffer.from("archive.part1.rar 1A2B3C4D\narchive.part2.rar 5E6F7A8B\n", "utf8");
|
|
|
|
const server = http.createServer((req, res) => {
|
|
res.statusCode = 200;
|
|
res.setHeader("Content-Length", String(sfvContent.length));
|
|
res.end(sfvContent);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("server address unavailable");
|
|
const directUrl = `http://127.0.0.1:${address.port}/checksum.sfv`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({ download: directUrl, filename: "archive.sfv", filesize: sfvContent.length }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "sfv-test", links: ["https://dummy/sfv-file"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 15000);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.retries).toBe(0);
|
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
|
const onDisk = fs.readFileSync(item.targetPath);
|
|
expect(onDisk.length).toBe(sfvContent.length);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("limits AllDebrid rapidgator starts to one active task by default", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(2 * 1024 * 1024, 6);
|
|
let unlockInFlight = 0;
|
|
let maxUnlockInFlight = 0;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const route = req.url || "";
|
|
if (route !== "/rg-1" && route !== "/rg-2") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
setTimeout(() => {
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
}, 1500);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
|
|
const link1 = "https://rapidgator.net/file/12345678901234567890123456789012/file1.mkv.html";
|
|
const link2 = "https://rapidgator.net/file/abcdefabcdefabcdefabcdefabcdef12/file2.mkv.html";
|
|
const directUrl1 = `http://127.0.0.1:${address.port}/rg-1`;
|
|
const directUrl2 = `http://127.0.0.1:${address.port}/rg-2`;
|
|
|
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
const method = String(
|
|
init?.method
|
|
|| (typeof input === "string" || input instanceof URL ? "" : input.method || "")
|
|
).toUpperCase();
|
|
|
|
if (url.includes("/user/hosts")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "success",
|
|
data: {
|
|
hosts: {
|
|
rapidgator: {
|
|
name: "Rapidgator",
|
|
status: true,
|
|
quota: 50,
|
|
quotaMax: 100,
|
|
quotaType: "traffic",
|
|
limitSimuDl: 1
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
|
|
if (url.includes("/link/unlock")) {
|
|
unlockInFlight += 1;
|
|
maxUnlockInFlight = Math.max(maxUnlockInFlight, unlockInFlight);
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
const body = init?.body;
|
|
const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || "");
|
|
const originalLink = new URLSearchParams(bodyText).get("link") || "";
|
|
const directUrl = originalLink === link2 ? directUrl2 : directUrl1;
|
|
const fileName = originalLink === link2 ? "rg-2.mkv" : "rg-1.mkv";
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "success",
|
|
data: {
|
|
link: directUrl,
|
|
filename: fileName,
|
|
filesize: binary.length
|
|
}
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
} finally {
|
|
unlockInFlight = Math.max(0, unlockInFlight - 1);
|
|
}
|
|
}
|
|
|
|
if (url.startsWith("https://rapidgator.net/")) {
|
|
if (method === "HEAD") {
|
|
return new Response(null, { status: 200 });
|
|
}
|
|
return new Response("<html><title>Rapidgator</title></html>", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/html" }
|
|
});
|
|
}
|
|
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token",
|
|
providerOrder: [],
|
|
providerPrimary: "alldebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 2
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "rg-all-debrid", links: [link1, link2] }]);
|
|
await manager.start();
|
|
await waitFor(() => {
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
return items.some((item) => item.status === "downloading") && maxUnlockInFlight >= 1;
|
|
}, 15000);
|
|
await new Promise((resolve) => setTimeout(resolve, 3600));
|
|
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
expect(items).toHaveLength(2);
|
|
expect(items.filter((item) => item.status === "downloading" || item.status === "completed")).toHaveLength(1);
|
|
expect(items.filter((item) => item.status === "queued" || item.status === "validating" || item.status === "reconnect_wait")).toHaveLength(1);
|
|
expect(maxUnlockInFlight).toBe(1);
|
|
manager.stop();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 15000);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 35000);
|
|
|
|
it("allows concurrent AllDebrid Web Rapidgator starts up to configured parallelism", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const chunk = Buffer.alloc(256 * 1024, 9);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const route = req.url || "";
|
|
if (route !== "/ad-web-1" && route !== "/ad-web-2" && route !== "/ad-web-3") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
let sent = 0;
|
|
const totalChunks = 10;
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length * totalChunks));
|
|
const timer = setInterval(() => {
|
|
if (sent >= totalChunks) {
|
|
clearInterval(timer);
|
|
res.end();
|
|
return;
|
|
}
|
|
res.write(chunk);
|
|
sent += 1;
|
|
}, 700);
|
|
res.on("close", () => clearInterval(timer));
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
|
|
const link1 = "https://rapidgator.net/file/web-1/sample.part1.rar.html";
|
|
const link2 = "https://rapidgator.net/file/web-2/sample.part2.rar.html";
|
|
const link3 = "https://rapidgator.net/file/web-3/sample.part3.rar.html";
|
|
const directUrl1 = `http://127.0.0.1:${address.port}/ad-web-1`;
|
|
const directUrl2 = `http://127.0.0.1:${address.port}/ad-web-2`;
|
|
const directUrl3 = `http://127.0.0.1:${address.port}/ad-web-3`;
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token",
|
|
allDebridUseWebLogin: true,
|
|
providerOrder: [],
|
|
providerPrimary: "alldebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 3
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{
|
|
allDebridWebUnrestrict: async (link) => ({
|
|
fileName: link === link2 ? "ad-web-2.bin" : link === link3 ? "ad-web-3.bin" : "ad-web-1.bin",
|
|
directUrl: link === link2 ? directUrl2 : link === link3 ? directUrl3 : directUrl1,
|
|
fileSize: chunk.length * 10,
|
|
retriesUsed: 0
|
|
})
|
|
}
|
|
);
|
|
|
|
manager.addPackages([{ name: "ad-web-parallel", links: [link1, link2, link3] }]);
|
|
await manager.start();
|
|
|
|
await waitFor(() => {
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
return items.filter((item) => item.status === "downloading").length >= 2;
|
|
}, 10000);
|
|
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
expect(items.filter((item) => item.status === "downloading").length).toBeGreaterThanOrEqual(2);
|
|
|
|
manager.stop();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 15000);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 20000);
|
|
|
|
it.each(["items", "package"] as const)("restarts a reset item after a %s reset when its previous link conversion ignores abort", async (resetMode) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-reset-stuck-conversion-${resetMode}-`));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
manager.addPackages([{ name: "reset-stuck-conversion", links: ["https://rapidgator.net/file/reset-stuck-conversion"] }]);
|
|
const itemId = Object.keys((manager as any).session.items)[0];
|
|
let calls = 0;
|
|
let releaseFirst: (value: UnrestrictedLink) => void = () => undefined;
|
|
(manager as any).debridService.unrestrictLink = vi.fn(async (_link: string, signal?: AbortSignal) => {
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
return await new Promise<UnrestrictedLink>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
}
|
|
return await new Promise<UnrestrictedLink>((_resolve, reject) => {
|
|
const onAbort = (): void => reject(new Error("aborted"));
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
});
|
|
|
|
try {
|
|
await manager.start();
|
|
await waitFor(() => calls === 1, 2_000);
|
|
const firstActive = (manager as any).activeTasks.get(itemId);
|
|
|
|
if (resetMode === "items") {
|
|
await manager.resetItems([itemId]);
|
|
} else {
|
|
await manager.resetPackage((manager as any).session.items[itemId].packageId);
|
|
}
|
|
await waitFor(() => calls === 2, 2_000);
|
|
const replacementActive = (manager as any).activeTasks.get(itemId);
|
|
|
|
expect(replacementActive).toBeDefined();
|
|
expect(replacementActive).not.toBe(firstActive);
|
|
releaseFirst({
|
|
fileName: "ignored-first.bin",
|
|
directUrl: "https://dummy/ignored-first.bin",
|
|
fileSize: 1,
|
|
retriesUsed: 0
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
expect((manager as any).activeTasks.get(itemId)).toBe(replacementActive);
|
|
} finally {
|
|
manager.stop();
|
|
releaseFirst({
|
|
fileName: "ignored-first.bin",
|
|
directUrl: "https://dummy/ignored-first.bin",
|
|
fileSize: 1,
|
|
retriesUsed: 0
|
|
});
|
|
}
|
|
});
|
|
|
|
it("retries a transient Mega-Debrid resolve failure fast (no long cooldown) with a German reason", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
let unrestrictCalls = 0;
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaCredentials: "mega-user:mega-pass",
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false,
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [],
|
|
providerPrimary: "megadebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{
|
|
megaWebUnrestrict: vi.fn(async (_link: string, _signal?: AbortSignal) => {
|
|
unrestrictCalls += 1;
|
|
throw new Error("Fichier supprimé chez l'hébergeur");
|
|
})
|
|
}
|
|
);
|
|
|
|
manager.addPackages([{
|
|
name: "mega-transient",
|
|
links: ["https://rapidgator.net/file/mega-transient.part1.rar.html"]
|
|
}]);
|
|
|
|
await manager.start();
|
|
await waitFor(
|
|
() => String(Object.values(manager.getSnapshot().session.items)[0]?.fullStatus || "").includes("neuer Versuch"),
|
|
12000
|
|
);
|
|
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item.status).not.toBe("failed");
|
|
expect(unrestrictCalls).toBeGreaterThanOrEqual(1);
|
|
expect(item.lastError).toBe("Datei beim Hoster gerade nicht abrufbar");
|
|
expect(String(item.fullStatus || "")).toContain("Datei beim Hoster gerade nicht abrufbar");
|
|
expect(String(item.fullStatus || "")).toContain("neuer Versuch");
|
|
const countdownMatch = String(item.fullStatus || "").match(/\((\d+)s\)/);
|
|
expect(countdownMatch).not.toBeNull();
|
|
expect(Number(countdownMatch![1])).toBeLessThanOrEqual(10);
|
|
|
|
await waitFor(() => unrestrictCalls >= 2, 12000);
|
|
|
|
manager.stop();
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
}, 20000);
|
|
|
|
it("limits Mega-Debrid Web validating starts to one item at a time", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
let unrestrictCalls = 0;
|
|
const pendingRejectors = new Set<(error: Error) => void>();
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaLogin: "mega-user",
|
|
megaPassword: "mega-pass",
|
|
megaCredentials: "mega-user:mega-pass",
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false,
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [],
|
|
providerPrimary: "megadebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 3
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{
|
|
megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal) => {
|
|
unrestrictCalls += 1;
|
|
return await new Promise<UnrestrictedLink | null>((resolve, reject) => {
|
|
const rejector = (error: Error): void => {
|
|
signal?.removeEventListener("abort", onAbort);
|
|
pendingRejectors.delete(rejector);
|
|
reject(error);
|
|
};
|
|
const onAbort = (): void => {
|
|
rejector(new Error("aborted:test-mega-web"));
|
|
};
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
pendingRejectors.add(rejector);
|
|
});
|
|
})
|
|
}
|
|
);
|
|
|
|
manager.addPackages([{
|
|
name: "mega-web-serialized",
|
|
links: [
|
|
"https://rapidgator.net/file/mega-web-1.part1.rar.html",
|
|
"https://rapidgator.net/file/mega-web-2.part2.rar.html",
|
|
"https://rapidgator.net/file/mega-web-3.part3.rar.html"
|
|
]
|
|
}]);
|
|
|
|
await manager.start();
|
|
await waitFor(() => unrestrictCalls === 1, 10000);
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
expect(items.filter((item) => item.status === "validating")).toHaveLength(1);
|
|
expect(items.filter((item) => item.status === "queued")).toHaveLength(2);
|
|
expect(unrestrictCalls).toBe(1);
|
|
|
|
manager.stop();
|
|
for (const reject of Array.from(pendingRejectors)) {
|
|
reject(new Error("aborted:test-mega-web"));
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
it("runs one Mega-Debrid Web validation per available account in parallel", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
let unrestrictCalls = 0;
|
|
const pendingRejectors = new Set<(error: Error) => void>();
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaCredentials: "mega-user-a:pass-a\nmega-user-b:pass-b",
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false,
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [],
|
|
providerPrimary: "megadebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 4
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{
|
|
megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal) => {
|
|
unrestrictCalls += 1;
|
|
return await new Promise<UnrestrictedLink | null>((resolve, reject) => {
|
|
const rejector = (error: Error): void => {
|
|
signal?.removeEventListener("abort", onAbort);
|
|
pendingRejectors.delete(rejector);
|
|
reject(error);
|
|
};
|
|
const onAbort = (): void => {
|
|
rejector(new Error("aborted:test-mega-web"));
|
|
};
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
pendingRejectors.add(rejector);
|
|
});
|
|
})
|
|
}
|
|
);
|
|
|
|
manager.addPackages([{
|
|
name: "mega-web-parallel",
|
|
links: [
|
|
"https://rapidgator.net/file/mega-web-1.part1.rar.html",
|
|
"https://rapidgator.net/file/mega-web-2.part2.rar.html",
|
|
"https://rapidgator.net/file/mega-web-3.part3.rar.html"
|
|
]
|
|
}]);
|
|
|
|
await manager.start();
|
|
await waitFor(() => unrestrictCalls === 2, 10000);
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
expect(items.filter((item) => item.status === "validating")).toHaveLength(2);
|
|
expect(items.filter((item) => item.status === "queued")).toHaveLength(1);
|
|
expect(unrestrictCalls).toBe(2);
|
|
|
|
manager.stop();
|
|
for (const reject of Array.from(pendingRejectors)) {
|
|
reject(new Error("aborted:test-mega-web"));
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
it("does not over-admit Mega-Debrid Web validations when an account is in cooldown", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
let unrestrictCalls = 0;
|
|
const pendingRejectors = new Set<(error: Error) => void>();
|
|
|
|
primeMegaDebridRuntimeCooldownForTests(`${getMegaDebridAccountId("mega-user-b")}:web`, 120000);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaCredentials: "mega-user-a:pass-a\nmega-user-b:pass-b",
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false,
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [],
|
|
providerPrimary: "megadebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 4
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{
|
|
megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal) => {
|
|
unrestrictCalls += 1;
|
|
return await new Promise<UnrestrictedLink | null>((resolve, reject) => {
|
|
const rejector = (error: Error): void => {
|
|
signal?.removeEventListener("abort", onAbort);
|
|
pendingRejectors.delete(rejector);
|
|
reject(error);
|
|
};
|
|
const onAbort = (): void => {
|
|
rejector(new Error("aborted:test-mega-web"));
|
|
};
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
pendingRejectors.add(rejector);
|
|
});
|
|
})
|
|
}
|
|
);
|
|
|
|
manager.addPackages([{
|
|
name: "mega-web-cooldown",
|
|
links: [
|
|
"https://rapidgator.net/file/mega-web-1.part1.rar.html",
|
|
"https://rapidgator.net/file/mega-web-2.part2.rar.html",
|
|
"https://rapidgator.net/file/mega-web-3.part3.rar.html"
|
|
]
|
|
}]);
|
|
|
|
await manager.start();
|
|
await waitFor(() => unrestrictCalls === 1, 10000);
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
expect(items.filter((item) => item.status === "validating")).toHaveLength(1);
|
|
expect(items.filter((item) => item.status === "queued")).toHaveLength(2);
|
|
expect(unrestrictCalls).toBe(1);
|
|
|
|
manager.stop();
|
|
for (const reject of Array.from(pendingRejectors)) {
|
|
reject(new Error("aborted:test-mega-web"));
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
it("resets Mega-Debrid per-account daily usage at the day boundary (not just provider/debrid-link)", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const acctId = getMegaDebridAccountId("mega-user");
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaCredentials: "mega-user:mega-pass",
|
|
providerDailyUsageDay: "2000-01-01",
|
|
providerDailyUsageBytes: { megadebrid: 9_000_000_000 } as Record<string, number>,
|
|
megaDebridAccountDailyUsageBytes: { [acctId]: 9_000_000_000 } as Record<string, number>,
|
|
megaDebridAccountDailyLimitBytes: { [acctId]: 1_000_000_000 } as Record<string, number>
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{}
|
|
);
|
|
|
|
const snap = manager.getSnapshot();
|
|
expect(snap.settings.providerDailyUsageDay).not.toBe("2000-01-01");
|
|
expect(snap.settings.megaDebridAccountDailyUsageBytes || {}).toEqual({});
|
|
expect(snap.settings.providerDailyUsageBytes || {}).toEqual({});
|
|
});
|
|
|
|
it("resets extraction state atomically for selected package items", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = "reset-extraction-package";
|
|
const createdAt = Date.now();
|
|
const itemIds = ["reset-a", "reset-b", "reset-c"];
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "reset-extraction",
|
|
outputDir: path.join(root, "downloads", "reset-extraction"),
|
|
extractDir: path.join(root, "extract", "reset-extraction"),
|
|
status: "extracting",
|
|
itemIds,
|
|
cancelled: false,
|
|
enabled: true,
|
|
postProcessLabel: "release.part1.rar",
|
|
downloadCompletedAt: createdAt,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
for (const itemId of itemIds) {
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: `https://dummy/${itemId}`,
|
|
provider: "megadebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 1_000,
|
|
totalBytes: 1_000,
|
|
progressPercent: 100,
|
|
fileName: `${itemId}.part1.rar`,
|
|
targetPath: path.join(root, "downloads", "reset-extraction", `${itemId}.part1.rar`),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "Unerwartetes Dateiende",
|
|
fullStatus: `Entpack-Fehler [${itemId}.part1.rar]: Unerwartetes Dateiende`,
|
|
onlineStatus: "online",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
}
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.resetItems(itemIds);
|
|
|
|
const snapshot = manager.getSnapshot().session;
|
|
expect(snapshot.packages[packageId]).toEqual(expect.objectContaining({
|
|
status: "queued",
|
|
postProcessLabel: undefined,
|
|
downloadCompletedAt: 0
|
|
}));
|
|
for (const itemId of itemIds) {
|
|
expect(snapshot.items[itemId]).toEqual(expect.objectContaining({
|
|
status: "queued",
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
onlineStatus: "online"
|
|
}));
|
|
}
|
|
});
|
|
|
|
it("preserves a known online status when resetting a whole package", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-reset-package-online-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = "reset-package-online";
|
|
const itemId = "reset-package-online-item";
|
|
const createdAt = Date.now();
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "reset-package-online",
|
|
outputDir: path.join(root, "downloads", "reset-package-online"),
|
|
extractDir: path.join(root, "extract", "reset-package-online"),
|
|
status: "failed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/reset-package-online",
|
|
provider: "megadebrid-web",
|
|
status: "failed",
|
|
retries: 3,
|
|
speedBps: 0,
|
|
downloadedBytes: 512,
|
|
totalBytes: 1_024,
|
|
progressPercent: 50,
|
|
fileName: "reset-package-online.bin",
|
|
targetPath: "",
|
|
resumable: true,
|
|
attempts: 3,
|
|
lastError: "range_ignored_on_resume:512/1024",
|
|
fullStatus: "Fehler",
|
|
onlineStatus: "online",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.resetPackage(packageId);
|
|
|
|
expect(manager.getSnapshot().session.items[itemId]).toEqual(expect.objectContaining({
|
|
status: "queued",
|
|
downloadedBytes: 0,
|
|
fullStatus: "Wartet",
|
|
onlineStatus: "online"
|
|
}));
|
|
});
|
|
|
|
it("keeps a locked target attached and pending instead of reporting a successful item reset", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-reset-locked-target-"));
|
|
tempDirs.push(root);
|
|
const packageId = "reset-locked-target";
|
|
const itemId = "reset-locked-target-item";
|
|
const targetPath = path.join(root, "downloads", "reset-locked-target", "locked.part1.rar");
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(targetPath, Buffer.alloc(4_096, 7));
|
|
const session = emptySession();
|
|
const createdAt = Date.now();
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "reset-locked-target",
|
|
outputDir: path.dirname(targetPath),
|
|
extractDir: path.join(root, "extract", "reset-locked-target"),
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/reset-locked-target",
|
|
provider: "megadebrid-web",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 4_096,
|
|
totalBytes: 8_192,
|
|
progressPercent: 50,
|
|
fileName: "locked.part1.rar",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig",
|
|
onlineStatus: "online",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
await manager.waitForStartupRecovery();
|
|
const originalRmSync = fs.rmSync;
|
|
const fsMutable = fs as unknown as { rmSync: typeof fs.rmSync };
|
|
fsMutable.rmSync = ((candidate: fs.PathLike, options?: fs.RmDirOptions) => {
|
|
if (path.resolve(String(candidate)) === path.resolve(targetPath)) {
|
|
throw Object.assign(new Error("locked"), { code: "EBUSY" });
|
|
}
|
|
return originalRmSync(candidate, options as fs.RmDirOptions);
|
|
}) as typeof fs.rmSync;
|
|
|
|
try {
|
|
await expect(manager.resetItems([itemId])).rejects.toThrow("Teildatei");
|
|
} finally {
|
|
fsMutable.rmSync = originalRmSync;
|
|
}
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(fs.existsSync(targetPath)).toBe(true);
|
|
expect(item.targetPath).toBe(targetPath);
|
|
expect(item.downloadedBytes).toBe(4_096);
|
|
expect(item.resumeResetPending).toBe(true);
|
|
expect(item.fullStatus).toBe("Warte auf Teildatei-Freigabe");
|
|
expect(item.onlineStatus).toBe("online");
|
|
});
|
|
|
|
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
let convCalls = 0;
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaCredentials: "mega-user:mega-pass",
|
|
megaDebridWebEnabled: true,
|
|
megaDebridApiEnabled: false,
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [],
|
|
providerPrimary: "megadebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 4
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{
|
|
megaWebUnrestrict: vi.fn(async (): Promise<UnrestrictedLink | null> => {
|
|
convCalls += 1;
|
|
return await new Promise<UnrestrictedLink | null>(() => { /* never settles, ignores abort */ });
|
|
})
|
|
}
|
|
);
|
|
|
|
manager.addPackages([{
|
|
name: "freeze-repro",
|
|
links: [
|
|
"https://rapidgator.net/file/freeze-1.part1.rar.html",
|
|
"https://rapidgator.net/file/freeze-2.part2.rar.html",
|
|
"https://rapidgator.net/file/freeze-3.part3.rar.html"
|
|
]
|
|
}]);
|
|
|
|
await manager.start();
|
|
await waitFor(() => convCalls === 1, 10000);
|
|
|
|
const validatingItem = Object.values(manager.getSnapshot().session.items).find((i) => i.status === "validating");
|
|
expect(validatingItem).toBeTruthy();
|
|
|
|
manager.resetItems([validatingItem!.id]);
|
|
|
|
await waitFor(() => convCalls === 2, 5000);
|
|
expect(convCalls).toBe(2);
|
|
|
|
manager.stop();
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
}, 20000);
|
|
|
|
it("serializes Mega-Debrid API conversions to one per account (no single-token hammering)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
let getLinkCalls = 0;
|
|
const pendingRejectors = new Set<(error: Error) => void>();
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: `tok-${getLinkCalls}-${url.length}` }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
getLinkCalls += 1;
|
|
const signal = init?.signal;
|
|
return await new Promise<Response>((_resolve, reject) => {
|
|
const rejector = (error: Error): void => {
|
|
signal?.removeEventListener("abort", onAbort);
|
|
pendingRejectors.delete(rejector);
|
|
reject(error);
|
|
};
|
|
const onAbort = (): void => { rejector(new Error("aborted:test-api")); };
|
|
if (signal?.aborted) { onAbort(); return; }
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
pendingRejectors.add(rejector);
|
|
});
|
|
}
|
|
return originalFetch(input, init);
|
|
}) as typeof fetch;
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaCredentials: "mega-user-a:pass-a\nmega-user-b:pass-b",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
megaDebridPreferApi: true,
|
|
providerOrder: [],
|
|
providerPrimary: "megadebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 6
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{}
|
|
);
|
|
|
|
manager.addPackages([{
|
|
name: "mega-api-serialized",
|
|
links: [
|
|
"https://rapidgator.net/file/api-1.part1.rar.html",
|
|
"https://rapidgator.net/file/api-2.part2.rar.html",
|
|
"https://rapidgator.net/file/api-3.part3.rar.html"
|
|
]
|
|
}]);
|
|
|
|
await manager.start();
|
|
await waitFor(() => getLinkCalls === 2, 10000);
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
expect(items.filter((item) => item.status === "validating")).toHaveLength(2);
|
|
expect(items.filter((item) => item.status === "queued")).toHaveLength(1);
|
|
expect(getLinkCalls).toBe(2);
|
|
|
|
manager.stop();
|
|
for (const reject of Array.from(pendingRejectors)) {
|
|
reject(new Error("aborted:test-api"));
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
it("limits Mega-Debrid API conversions to one at a time with a single account", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
let getLinkCalls = 0;
|
|
const pendingRejectors = new Set<(error: Error) => void>();
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok-single" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
getLinkCalls += 1;
|
|
const signal = init?.signal;
|
|
return await new Promise<Response>((_resolve, reject) => {
|
|
const rejector = (error: Error): void => {
|
|
signal?.removeEventListener("abort", onAbort);
|
|
pendingRejectors.delete(rejector);
|
|
reject(error);
|
|
};
|
|
const onAbort = (): void => { rejector(new Error("aborted:test-api")); };
|
|
if (signal?.aborted) { onAbort(); return; }
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
pendingRejectors.add(rejector);
|
|
});
|
|
}
|
|
return originalFetch(input, init);
|
|
}) as typeof fetch;
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaCredentials: "mega-user:mega-pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
megaDebridPreferApi: true,
|
|
providerOrder: [],
|
|
providerPrimary: "megadebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 6
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{}
|
|
);
|
|
|
|
manager.addPackages([{
|
|
name: "mega-api-single",
|
|
links: [
|
|
"https://rapidgator.net/file/single-1.part1.rar.html",
|
|
"https://rapidgator.net/file/single-2.part2.rar.html",
|
|
"https://rapidgator.net/file/single-3.part3.rar.html"
|
|
]
|
|
}]);
|
|
|
|
await manager.start();
|
|
await waitFor(() => getLinkCalls === 1, 10000);
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
expect(items.filter((item) => item.status === "validating")).toHaveLength(1);
|
|
expect(items.filter((item) => item.status === "queued")).toHaveLength(2);
|
|
expect(getLinkCalls).toBe(1);
|
|
|
|
manager.stop();
|
|
for (const reject of Array.from(pendingRejectors)) {
|
|
reject(new Error("aborted:test-api"));
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
it("shows the same AllDebrid countdown for all immediately free slots", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const chunk = Buffer.alloc(256 * 1024, 9);
|
|
const totalLinks = 8;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const route = req.url || "";
|
|
if (!/^\/ad-web-\d+$/.test(route)) {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
let sent = 0;
|
|
const totalChunks = 10;
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length * totalChunks));
|
|
const timer = setInterval(() => {
|
|
if (sent >= totalChunks) {
|
|
clearInterval(timer);
|
|
res.end();
|
|
return;
|
|
}
|
|
res.write(chunk);
|
|
sent += 1;
|
|
}, 700);
|
|
res.on("close", () => clearInterval(timer));
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
|
|
const links = Array.from({ length: totalLinks }, (_, index) => `https://rapidgator.net/file/web-${index + 1}/sample.part${index + 1}.rar.html`);
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token",
|
|
allDebridUseWebLogin: true,
|
|
providerOrder: [],
|
|
providerPrimary: "alldebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 5
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{
|
|
allDebridWebUnrestrict: async (link) => {
|
|
const match = link.match(/web-(\d+)/);
|
|
const slot = Number(match?.[1] || 1);
|
|
return {
|
|
fileName: `ad-web-${slot}.bin`,
|
|
directUrl: `http://127.0.0.1:${address.port}/ad-web-${slot}`,
|
|
fileSize: chunk.length * 10,
|
|
retriesUsed: 0
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
manager.addPackages([{ name: "ad-web-visibility", links }]);
|
|
await manager.start();
|
|
|
|
await waitFor(() => {
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
const countdownItems = items.filter((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || ""));
|
|
return countdownItems.length === 5;
|
|
}, 10000);
|
|
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
const activeCount = items.filter((item) => item.status === "downloading" || item.status === "validating").length;
|
|
const countdownItems = items.filter((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || ""));
|
|
const uniqueCountdowns = new Set(countdownItems.map((item) => item.fullStatus || ""));
|
|
|
|
expect(activeCount).toBe(0);
|
|
expect(countdownItems.length).toBe(5);
|
|
expect(uniqueCountdowns.size).toBe(1);
|
|
|
|
manager.stop();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 15000);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 20000);
|
|
|
|
it("starts immediately free AllDebrid slots after the same 3 second delay", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(512 * 1024, 5);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const route = req.url || "";
|
|
if (route !== "/ad-1" && route !== "/ad-2" && route !== "/ad-3") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
setTimeout(() => {
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
}, 6500);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
|
|
const link1 = "https://host-a.example/file1.bin";
|
|
const link2 = "https://host-b.example/file2.bin";
|
|
const link3 = "https://host-c.example/file3.bin";
|
|
const directUrl1 = `http://127.0.0.1:${address.port}/ad-1`;
|
|
const directUrl2 = `http://127.0.0.1:${address.port}/ad-2`;
|
|
const directUrl3 = `http://127.0.0.1:${address.port}/ad-3`;
|
|
|
|
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("/link/unlock")) {
|
|
const body = init?.body;
|
|
const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || "");
|
|
const originalLink = new URLSearchParams(bodyText).get("link") || "";
|
|
const directUrl = originalLink === link2 ? directUrl2 : originalLink === link3 ? directUrl3 : directUrl1;
|
|
const fileName = originalLink === link2 ? "ad-2.bin" : originalLink === link3 ? "ad-3.bin" : "ad-1.bin";
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "success",
|
|
data: {
|
|
link: directUrl,
|
|
filename: fileName,
|
|
filesize: binary.length
|
|
}
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token",
|
|
providerOrder: [],
|
|
providerPrimary: "alldebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 3
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "ad-paced", links: [link1, link2, link3] }]);
|
|
await manager.start();
|
|
|
|
const managerInternals = manager as unknown as {
|
|
retryAfterByItem: Map<string, number>;
|
|
};
|
|
await waitFor(() => managerInternals.retryAfterByItem.size >= 3, 5000);
|
|
|
|
const now = Date.now();
|
|
const readyTimes = [...managerInternals.retryAfterByItem.values()].sort((a, b) => a - b);
|
|
expect(readyTimes.length).toBe(3);
|
|
const firstDelay = readyTimes[0] - now;
|
|
const lastDelay = readyTimes[readyTimes.length - 1] - now;
|
|
expect(firstDelay).toBeGreaterThan(2000);
|
|
expect(firstDelay).toBeLessThan(4500);
|
|
expect(lastDelay - firstDelay).toBeLessThan(500);
|
|
|
|
manager.stop();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 15000);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 20000);
|
|
|
|
it("tops up newly freed AllDebrid slots with a fresh 3 second countdown", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const shortBinary = Buffer.alloc(64 * 1024, 7);
|
|
const longBinary = Buffer.alloc(512 * 1024, 8);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const route = req.url || "";
|
|
if (route === "/ad-1") {
|
|
setTimeout(() => {
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(shortBinary.length));
|
|
res.end(shortBinary);
|
|
}, 150);
|
|
return;
|
|
}
|
|
if (route === "/ad-2" || route === "/ad-3" || route === "/ad-4") {
|
|
setTimeout(() => {
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(longBinary.length));
|
|
res.end(longBinary);
|
|
}, 6000);
|
|
return;
|
|
}
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
|
|
const links = [
|
|
"https://rapidgator.net/file/ad-topup-1",
|
|
"https://rapidgator.net/file/ad-topup-2",
|
|
"https://rapidgator.net/file/ad-topup-3",
|
|
"https://rapidgator.net/file/ad-topup-4"
|
|
];
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token",
|
|
allDebridUseWebLogin: true,
|
|
providerOrder: [],
|
|
providerPrimary: "alldebrid",
|
|
providerSecondary: "none",
|
|
providerTertiary: "none",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
enableIntegrityCheck: false,
|
|
maxParallel: 3
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state")),
|
|
{
|
|
allDebridWebUnrestrict: async (link) => {
|
|
const slot = links.indexOf(link) + 1;
|
|
return {
|
|
fileName: `ad-topup-${slot}.bin`,
|
|
directUrl: `http://127.0.0.1:${address.port}/ad-${slot}`,
|
|
fileSize: slot === 1 ? shortBinary.length : longBinary.length,
|
|
retriesUsed: 0
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
manager.addPackages([{ name: "ad-topup", links }]);
|
|
await manager.start();
|
|
|
|
await waitFor(() => {
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
return items.filter((item) => item.status === "downloading").length === 3;
|
|
}, 12000);
|
|
|
|
await waitFor(() => {
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
const completedCount = items.filter((item) => item.status === "completed").length;
|
|
const countdownItems = items.filter((item) => /^AllDebrid Start in [123]s$/.test(item.fullStatus || ""));
|
|
return completedCount >= 1 && countdownItems.length === 1;
|
|
}, 12000);
|
|
|
|
manager.stop();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 15000);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 25000);
|
|
|
|
it("creates extract directory only at extraction and marks items as Entpackt", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const zip = new AdmZip();
|
|
zip.addFile("inside.txt", Buffer.from("ok"));
|
|
const archive = zip.toBuffer();
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/archive") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
setTimeout(() => {
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(archive.length));
|
|
res.end(archive);
|
|
}, 450);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/archive`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "sample.zip",
|
|
filesize: archive.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
createExtractSubfolder: true,
|
|
autoExtract: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "zip-pack", links: ["https://dummy/archive"] }]);
|
|
const pkgId = manager.getSnapshot().session.packageOrder[0];
|
|
const completedPostProcessLabels: Array<string | undefined> = [];
|
|
manager.on("state", (state) => {
|
|
const emittedPackage = state.session.packages[pkgId];
|
|
if (emittedPackage?.status === "completed") completedPostProcessLabels.push(emittedPackage.postProcessLabel);
|
|
});
|
|
const extractDir = manager.getSnapshot().session.packages[pkgId]?.extractDir || "";
|
|
expect(extractDir).toBeTruthy();
|
|
expect(fs.existsSync(extractDir)).toBe(false);
|
|
|
|
await manager.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 140));
|
|
expect(fs.existsSync(extractDir)).toBe(false);
|
|
|
|
await waitFor(() => fs.existsSync(path.join(extractDir, "inside.txt")), 30000);
|
|
await waitFor(() => {
|
|
const current = manager.getSnapshot().session.packages[pkgId];
|
|
return current?.status === "completed" && current.postProcessLabel === undefined;
|
|
}, 30000);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
const item = Object.values(snapshot.session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
|
|
expect(snapshot.session.packages[pkgId]?.postProcessLabel).toBeUndefined();
|
|
expect(completedPostProcessLabels.every((label) => label === undefined)).toBe(true);
|
|
expect(fs.existsSync(extractDir)).toBe(true);
|
|
expect(fs.existsSync(path.join(extractDir, "inside.txt"))).toBe(true);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 35000);
|
|
|
|
it("keeps accurate summary when completed items are cleaned immediately", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(128 * 1024, 3);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/file") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/file`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "episode.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
completedCleanupPolicy: "immediate"
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "cleanup", links: ["https://dummy/cleanup"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
const summary = manager.getSummary();
|
|
expect(Object.keys(snapshot.session.items)).toHaveLength(0);
|
|
expect(summary).not.toBeNull();
|
|
expect(summary?.total).toBe(1);
|
|
expect(summary?.success).toBe(1);
|
|
expect(summary?.failed).toBe(0);
|
|
expect(summary?.cancelled).toBe(0);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("preserves completed package progress when immediate cleanup removes a finished item", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = "cleanup-progress-package";
|
|
const completedItemId = "cleanup-progress-completed";
|
|
const queuedItemId = "cleanup-progress-queued";
|
|
const createdAt = Date.now();
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "cleanup-progress",
|
|
outputDir: path.join(root, "downloads", "cleanup-progress"),
|
|
extractDir: path.join(root, "extract", "cleanup-progress"),
|
|
status: "downloading",
|
|
itemIds: [completedItemId, queuedItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[completedItemId] = {
|
|
id: completedItemId,
|
|
packageId,
|
|
url: "https://dummy/completed",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 1_000,
|
|
totalBytes: 1_000,
|
|
progressPercent: 100,
|
|
fileName: "completed.rar",
|
|
targetPath: path.join(root, "downloads", "cleanup-progress", "completed.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt - Fertig",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[queuedItemId] = {
|
|
...session.items[completedItemId],
|
|
id: queuedItemId,
|
|
url: "https://dummy/queued",
|
|
status: "queued",
|
|
downloadedBytes: 0,
|
|
progressPercent: 0,
|
|
fileName: "queued.rar",
|
|
targetPath: path.join(root, "downloads", "cleanup-progress", "queued.rar"),
|
|
fullStatus: "Wartet"
|
|
};
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
completedCleanupPolicy: "immediate"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
(manager as any).applyCompletedCleanupPolicy(packageId, completedItemId);
|
|
(manager as any).applyCompletedCleanupPolicy(packageId, completedItemId);
|
|
|
|
const packageEntry = manager.getSnapshot().session.packages[packageId];
|
|
expect(packageEntry.itemIds).toEqual([queuedItemId]);
|
|
expect(packageEntry.cleanedCompletedItemCount).toBe(1);
|
|
expect(packageEntry.cleanedExtractedItemCount).toBe(1);
|
|
expect(packageEntry.cleanedDownloadedBytes).toBe(1_000);
|
|
expect(packageEntry.cleanedTotalBytes).toBe(1_000);
|
|
});
|
|
|
|
it.each([
|
|
["on_start", "on_start"],
|
|
["retroactive immediate", "never"]
|
|
] as const)("preserves completed package progress during %s cleanup", (_name, initialPolicy) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = `cleanup-${initialPolicy}-package`;
|
|
const completedItemId = `cleanup-${initialPolicy}-completed`;
|
|
const queuedItemId = `cleanup-${initialPolicy}-queued`;
|
|
const createdAt = Date.now();
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: `cleanup-${initialPolicy}`,
|
|
outputDir: path.join(root, "downloads", `cleanup-${initialPolicy}`),
|
|
extractDir: path.join(root, "extract", `cleanup-${initialPolicy}`),
|
|
status: "queued",
|
|
itemIds: [completedItemId, queuedItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[completedItemId] = {
|
|
id: completedItemId,
|
|
packageId,
|
|
url: "https://dummy/completed",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 1_000,
|
|
totalBytes: 1_000,
|
|
progressPercent: 100,
|
|
fileName: "completed.rar",
|
|
targetPath: path.join(root, "downloads", `cleanup-${initialPolicy}`, "completed.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt - Fertig",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[queuedItemId] = {
|
|
...session.items[completedItemId],
|
|
id: queuedItemId,
|
|
url: "https://dummy/queued",
|
|
status: "queued",
|
|
downloadedBytes: 0,
|
|
totalBytes: 2_000,
|
|
progressPercent: 0,
|
|
fileName: "queued.rar",
|
|
targetPath: path.join(root, "downloads", `cleanup-${initialPolicy}`, "queued.rar"),
|
|
fullStatus: "Wartet"
|
|
};
|
|
const settings = {
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
completedCleanupPolicy: initialPolicy
|
|
};
|
|
const manager = new DownloadManager(settings, session, createStoragePaths(path.join(root, "state")));
|
|
|
|
if (initialPolicy === "never") {
|
|
manager.setSettings({ ...settings, completedCleanupPolicy: "immediate" });
|
|
}
|
|
|
|
const packageEntry = manager.getSnapshot().session.packages[packageId];
|
|
expect(packageEntry.itemIds).toEqual([queuedItemId]);
|
|
expect(packageEntry.cleanedCompletedItemCount).toBe(1);
|
|
expect(packageEntry.cleanedExtractedItemCount).toBe(1);
|
|
expect(packageEntry.cleanedDownloadedBytes).toBe(1_000);
|
|
expect(packageEntry.cleanedTotalBytes).toBe(1_000);
|
|
expect(packageEntry.cleanedUrls).toEqual(["https://dummy/completed"]);
|
|
expect(packageEntry.cleanedProviders).toEqual(["realdebrid"]);
|
|
});
|
|
|
|
it("includes immediately cleaned items in the final package history entry", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = "cleanup-history-package";
|
|
const firstItemId = "cleanup-history-first";
|
|
const secondItemId = "cleanup-history-second";
|
|
const createdAt = Date.now() - 5_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "cleanup-history",
|
|
outputDir: path.join(root, "downloads", "cleanup-history"),
|
|
extractDir: path.join(root, "extract", "cleanup-history"),
|
|
status: "downloading",
|
|
itemIds: [firstItemId, secondItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[firstItemId] = {
|
|
id: firstItemId,
|
|
packageId,
|
|
url: "https://dummy/first",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 1_000,
|
|
totalBytes: 1_000,
|
|
progressPercent: 100,
|
|
fileName: "first.rar",
|
|
targetPath: path.join(root, "downloads", "cleanup-history", "first.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt - Fertig",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[secondItemId] = {
|
|
...session.items[firstItemId],
|
|
id: secondItemId,
|
|
url: "https://dummy/second",
|
|
provider: "megadebrid-api",
|
|
status: "queued",
|
|
downloadedBytes: 2_000,
|
|
totalBytes: 2_000,
|
|
fileName: "second.rar",
|
|
targetPath: path.join(root, "downloads", "cleanup-history", "second.rar"),
|
|
fullStatus: "Wartet"
|
|
};
|
|
const history: HistoryEntry[] = [];
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
completedCleanupPolicy: "immediate"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state")),
|
|
{ onHistoryEntry: (entry) => history.push(entry) }
|
|
);
|
|
|
|
(manager as any).applyCompletedCleanupPolicy(packageId, firstItemId);
|
|
const pkg = (manager as any).session.packages[packageId];
|
|
(manager as any).session.items[secondItemId].status = "completed";
|
|
(manager as any).session.items[secondItemId].fullStatus = "Entpackt - Fertig";
|
|
pkg.status = "completed";
|
|
(manager as any).recordPackageHistory(packageId, pkg, [(manager as any).session.items[secondItemId]]);
|
|
|
|
expect(history).toHaveLength(1);
|
|
expect(history[0]).toMatchObject({
|
|
totalBytes: 3_000,
|
|
downloadedBytes: 3_000,
|
|
fileCount: 2,
|
|
provider: null,
|
|
urls: ["https://dummy/first", "https://dummy/second"]
|
|
});
|
|
|
|
history.length = 0;
|
|
(manager as any).historyRecordedPackages.delete(packageId);
|
|
(manager as any).removePackageFromSession(packageId, [secondItemId], "deleted");
|
|
expect(history).toHaveLength(1);
|
|
expect(history[0]).toMatchObject({
|
|
totalBytes: 3_000,
|
|
downloadedBytes: 3_000,
|
|
fileCount: 2,
|
|
provider: null,
|
|
status: "deleted",
|
|
urls: ["https://dummy/first", "https://dummy/second"]
|
|
});
|
|
});
|
|
|
|
it("waits for aborted package post-processing before restarting reset items", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const packageId = "reset-race-package";
|
|
const itemId = "reset-race-item";
|
|
const createdAt = Date.now() - 5_000;
|
|
session.running = true;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "reset-race",
|
|
outputDir: path.join(root, "downloads", "reset-race"),
|
|
extractDir: path.join(root, "extract", "reset-race"),
|
|
status: "failed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/reset-race",
|
|
provider: "realdebrid",
|
|
status: "failed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: 1_000,
|
|
progressPercent: 0,
|
|
fileName: "reset-race.rar",
|
|
targetPath: "",
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "extract failed",
|
|
fullStatus: "Entpack-Fehler",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
|
|
let releaseTask = (): void => {};
|
|
const task = new Promise<void>((resolve) => { releaseTask = resolve; });
|
|
let releaseHybridTask = (): void => {};
|
|
const hybridTask = new Promise<void>((resolve) => { releaseHybridTask = resolve; });
|
|
const internal = manager as any;
|
|
internal.session.running = true;
|
|
internal.packagePostProcessTasks.set(packageId, task);
|
|
internal.packagePostProcessAbortControllers.set(packageId, new AbortController());
|
|
internal.packageHybridPostProcessTasks.set(packageId, new Set([hybridTask]));
|
|
internal.packageHybridPostProcessControllers.set(packageId, new Set([new AbortController()]));
|
|
internal.ensureScheduler = vi.fn(async () => {});
|
|
|
|
const resetPromise = Promise.resolve(manager.resetItems([itemId]));
|
|
await Promise.resolve();
|
|
expect(internal.ensureScheduler).not.toHaveBeenCalled();
|
|
releaseTask();
|
|
await Promise.resolve();
|
|
expect(internal.ensureScheduler).not.toHaveBeenCalled();
|
|
releaseHybridTask();
|
|
await resetPromise;
|
|
expect(internal.ensureScheduler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("removes finished package when package_done cleanup policy is enabled", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const zip = new AdmZip();
|
|
zip.addFile("episode.txt", Buffer.from("ok"));
|
|
zip.addFile("padding.bin", crypto.randomBytes(8 * 1024));
|
|
const archiveBinary = zip.toBuffer();
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/cleanup-package") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(archiveBinary.length));
|
|
res.end(archiveBinary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/cleanup-package`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "cleanup-package.zip",
|
|
filesize: archiveBinary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none",
|
|
completedCleanupPolicy: "package_done"
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "cleanup-package", links: ["https://dummy/cleanup-package"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
const summary = manager.getSummary();
|
|
expect(snapshot.session.packageOrder).toHaveLength(0);
|
|
expect(Object.keys(snapshot.session.items)).toHaveLength(0);
|
|
expect(summary).not.toBeNull();
|
|
expect(summary?.success).toBe(1);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 35000);
|
|
|
|
it("keeps partially failed packages under package_done cleanup policy", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "package-done-partial-failure-pkg";
|
|
const completedItemId = "package-done-partial-failure-completed";
|
|
const failedItemId = "package-done-partial-failure-failed";
|
|
const createdAt = Date.now() - 10_000;
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "package-done-partial-failure",
|
|
outputDir: path.join(root, "downloads", "package-done-partial-failure"),
|
|
extractDir: path.join(root, "extract", "package-done-partial-failure"),
|
|
status: "failed",
|
|
itemIds: [completedItemId, failedItemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[completedItemId] = {
|
|
id: completedItemId,
|
|
packageId,
|
|
url: "https://dummy/package-done-partial-failure-completed",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 1024,
|
|
totalBytes: 1024,
|
|
progressPercent: 100,
|
|
fileName: "episode01.rar",
|
|
targetPath: path.join(root, "downloads", "package-done-partial-failure", "episode01.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt - Done (<1s)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[failedItemId] = {
|
|
id: failedItemId,
|
|
packageId,
|
|
url: "https://dummy/package-done-partial-failure-failed",
|
|
provider: "realdebrid",
|
|
status: "failed",
|
|
retries: 1,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: 1024,
|
|
progressPercent: 0,
|
|
fileName: "episode02.rar",
|
|
targetPath: path.join(root, "downloads", "package-done-partial-failure", "episode02.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "checksum",
|
|
fullStatus: "Entpack-Fehler: Checksum error",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "delete",
|
|
completedCleanupPolicy: "package_done"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
(manager as any).applyPackageDoneCleanup(packageId);
|
|
(manager as any).applyCompletedCleanupPolicy(packageId, completedItemId);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.packages[packageId]).toBeDefined();
|
|
expect(snapshot.session.items[completedItemId]).toBeDefined();
|
|
expect(snapshot.session.items[failedItemId]).toBeDefined();
|
|
expect(snapshot.session.packageOrder).toEqual([packageId]);
|
|
});
|
|
|
|
it("waits for deferred MKV collection before package_done cleanup removes the package", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const zip = new AdmZip();
|
|
zip.addFile("Season 1/Episode01.mkv", Buffer.from("video"));
|
|
zip.addFile("Season 1/sample.txt", Buffer.from("sample"));
|
|
zip.addFile("padding.bin", crypto.randomBytes(8 * 1024));
|
|
const archiveBinary = zip.toBuffer();
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/cleanup-package-mkv") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(archiveBinary.length));
|
|
res.end(archiveBinary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/cleanup-package-mkv`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "cleanup-package-mkv.zip",
|
|
filesize: archiveBinary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const extractRoot = path.join(root, "extract");
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: extractRoot,
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "delete",
|
|
completedCleanupPolicy: "package_done"
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "cleanup-package-mkv", links: ["https://dummy/cleanup-package-mkv"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000);
|
|
|
|
const flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv");
|
|
const extractDir = path.join(extractRoot, "cleanup-package-mkv");
|
|
expect(fs.existsSync(flattenedPath)).toBe(true);
|
|
expect(fs.existsSync(extractDir)).toBe(false);
|
|
expect(Object.keys(manager.getSnapshot().session.items)).toHaveLength(0);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 35000);
|
|
|
|
it("cleans link, sample and residual artifacts before package_done cleanup removes the package", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const zip = new AdmZip();
|
|
zip.addFile("Season 1/Episode01.mkv", Buffer.from("video"));
|
|
zip.addFile("Season 1/episode.links.txt", Buffer.from("https://example.com/file"));
|
|
zip.addFile("Season 1/cover.jpg", Buffer.from("cover"));
|
|
zip.addFile("Season 1/sample/sample.mkv", Buffer.from("sample-video"));
|
|
zip.addFile("Season 1/sample/readme.txt", Buffer.from("sample-text"));
|
|
zip.addFile("padding.bin", crypto.randomBytes(8 * 1024));
|
|
const archiveBinary = zip.toBuffer();
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/cleanup-package-full") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(archiveBinary.length));
|
|
res.end(archiveBinary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/cleanup-package-full`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "cleanup-package-full.zip",
|
|
filesize: archiveBinary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const extractRoot = path.join(root, "extract");
|
|
const outputRoot = path.join(root, "downloads");
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: outputRoot,
|
|
extractDir: extractRoot,
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
removeLinkFilesAfterExtract: true,
|
|
removeSamplesAfterExtract: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "delete",
|
|
completedCleanupPolicy: "package_done"
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "cleanup-package-full", links: ["https://dummy/cleanup-package-full"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000);
|
|
|
|
const flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv");
|
|
expect(fs.existsSync(flattenedPath)).toBe(true);
|
|
expect(fs.existsSync(path.join(extractRoot, "cleanup-package-full"))).toBe(false);
|
|
expect(fs.existsSync(path.join(outputRoot, "cleanup-package-full"))).toBe(false);
|
|
expect(Object.keys(manager.getSnapshot().session.items)).toHaveLength(0);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 35000);
|
|
|
|
it("counts queued package cancellations in run summary", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(256 * 1024, 5);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/slow") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
const mid = Math.floor(binary.length / 2);
|
|
res.write(binary.subarray(0, mid));
|
|
setTimeout(() => {
|
|
res.end(binary.subarray(mid));
|
|
}, 600);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/slow`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "episode.mkv",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "cancel-run", links: ["https://dummy/one", "https://dummy/two"] }]);
|
|
manager.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
const pkgId = manager.getSnapshot().session.packageOrder[0];
|
|
manager.cancelPackage(pkgId);
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const summary = manager.getSummary();
|
|
expect(summary).not.toBeNull();
|
|
expect(summary?.total).toBe(2);
|
|
expect(summary?.cancelled).toBe(2);
|
|
expect(summary?.success).toBe(0);
|
|
expect(summary?.failed).toBe(0);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("finishes run when remaining packages are disabled", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(96 * 1024, 8);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/enabled") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/enabled`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "enabled.bin",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([
|
|
{ name: "enabled", links: ["https://dummy/enabled"] },
|
|
{ name: "disabled", links: ["https://dummy/disabled"] }
|
|
]);
|
|
const initial = manager.getSnapshot();
|
|
const enabledPkgId = initial.session.packageOrder[0];
|
|
const disabledPkgId = initial.session.packageOrder[1];
|
|
const enabledItemId = initial.session.packages[enabledPkgId]?.itemIds[0] || "";
|
|
const disabledItemId = initial.session.packages[disabledPkgId]?.itemIds[0] || "";
|
|
|
|
manager.togglePackage(disabledPkgId);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.packages[disabledPkgId]?.enabled).toBe(false);
|
|
expect(snapshot.session.items[enabledItemId]?.status).toBe("completed");
|
|
expect(snapshot.session.items[disabledItemId]?.status).toBe("queued");
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("stops active package and keeps items queued", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(320 * 1024, 15);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/toggle") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
const half = Math.floor(binary.length / 2);
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.write(binary.subarray(0, half));
|
|
setTimeout(() => {
|
|
res.end(binary.subarray(half));
|
|
}, 1200);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/toggle`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "toggle.bin",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "toggle", links: ["https://dummy/toggle"] }]);
|
|
const initial = manager.getSnapshot();
|
|
const pkgId = initial.session.packageOrder[0];
|
|
const itemId = initial.session.packages[pkgId]?.itemIds[0] || "";
|
|
|
|
manager.start();
|
|
await waitFor(() => {
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
return item?.status === "downloading";
|
|
}, 12000);
|
|
|
|
manager.togglePackage(pkgId);
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
const item = snapshot.session.items[itemId];
|
|
expect(snapshot.session.packages[pkgId]?.enabled).toBe(false);
|
|
expect(item?.status).toBe("queued");
|
|
expect(item?.fullStatus).toBe("Paket gestoppt");
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("shows stable ETA while paused", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(320 * 1024, 10);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/pause") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
const chunk = Math.floor(binary.length / 2);
|
|
res.write(binary.subarray(0, chunk));
|
|
setTimeout(() => {
|
|
res.end(binary.subarray(chunk));
|
|
}, 900);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/pause`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "pause.bin",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "pause-case", links: ["https://dummy/pause"] }]);
|
|
manager.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
manager.togglePause();
|
|
const pausedSnapshot = manager.getSnapshot();
|
|
expect(pausedSnapshot.session.paused).toBe(true);
|
|
expect(pausedSnapshot.etaText).toBe("ETA: --");
|
|
|
|
manager.stop();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 15000);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("handles rapid pause/resume toggles without deadlock", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(320 * 1024, 11);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/toggle-stress") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
const range = String(req.headers.range || "");
|
|
const match = range.match(/bytes=(\d+)-/i);
|
|
const start = match ? Number(match[1]) : 0;
|
|
const chunk = binary.subarray(start);
|
|
|
|
if (start > 0) {
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
|
} else {
|
|
res.statusCode = 200;
|
|
}
|
|
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(chunk.length));
|
|
const split = Math.max(1, Math.floor(chunk.length / 4));
|
|
res.write(chunk.subarray(0, split));
|
|
setTimeout(() => {
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
res.end(chunk.subarray(split));
|
|
}
|
|
}, 260);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/toggle-stress`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "stress.part01.rar",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "toggle-stress", links: ["https://dummy/toggle-stress"] }]);
|
|
manager.start();
|
|
|
|
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.status === "downloading", 12000);
|
|
for (let i = 0; i < 6; i += 1) {
|
|
manager.togglePause();
|
|
await new Promise((resolve) => setTimeout(resolve, 90));
|
|
}
|
|
|
|
if (manager.getSnapshot().session.paused) {
|
|
manager.togglePause();
|
|
}
|
|
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
expect(item?.status).toBe("completed");
|
|
expect(fs.existsSync(item?.targetPath || "")).toBe(true);
|
|
expect(fs.statSync(item?.targetPath || "").size).toBe(binary.length);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("keeps active downloads resumable on shutdown preparation", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(480 * 1024, 5);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/shutdown") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.write(binary.subarray(0, Math.floor(binary.length / 3)));
|
|
setTimeout(() => {
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
res.end(binary.subarray(Math.floor(binary.length / 3)));
|
|
}
|
|
}, 2200);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/shutdown`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "shutdown.part01.rar",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "shutdown-case", links: ["https://dummy/shutdown"] }]);
|
|
const itemId = Object.values(manager.getSnapshot().session.items)[0]?.id || "";
|
|
manager.start();
|
|
await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "downloading", 12000);
|
|
|
|
manager.prepareForShutdown();
|
|
await waitFor(() => {
|
|
const state = manager.getSnapshot();
|
|
return !state.session.running && state.session.items[itemId]?.status === "queued";
|
|
}, 8000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("queued");
|
|
expect(item?.fullStatus).toBe("Wartet");
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("does not mark pre-allocated crash leftovers as 100% complete on resume", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = crypto.randomBytes(2 * 1024 * 1024);
|
|
const outputDir = path.join(root, "downloads", "resume-prealloc");
|
|
const targetPath = path.join(outputDir, "resume-prealloc.part01.rar");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(targetPath, Buffer.alloc(binary.length, 0));
|
|
|
|
const session = emptySession();
|
|
const packageId = "resume-prealloc-pkg";
|
|
const itemId = "resume-prealloc-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "resume-prealloc",
|
|
outputDir,
|
|
extractDir: path.join(root, "extract", "resume-prealloc"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/resume-prealloc",
|
|
provider: "realdebrid",
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: binary.length,
|
|
progressPercent: 0,
|
|
fileName: "resume-prealloc.part01.rar",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const rangeStarts: number[] = [];
|
|
let sawRangeAtFullSize = false;
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/resume-prealloc") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
const rangeHeader = String(req.headers.range || "");
|
|
const match = /bytes=(\d+)-/i.exec(rangeHeader);
|
|
if (match) {
|
|
const start = Number(match[1] || 0);
|
|
rangeStarts.push(start);
|
|
if (start >= binary.length) {
|
|
sawRangeAtFullSize = true;
|
|
res.statusCode = 416;
|
|
res.setHeader("Content-Range", `bytes */${binary.length}`);
|
|
res.end();
|
|
return;
|
|
}
|
|
const end = binary.length - 1;
|
|
res.statusCode = 206;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Range", `bytes ${start}-${end}/${binary.length}`);
|
|
res.setHeader("Content-Length", String(binary.length - start));
|
|
res.end(binary.subarray(start));
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/resume-prealloc`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "resume-prealloc.part01.rar",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(item?.progressPercent).toBe(100);
|
|
expect(sawRangeAtFullSize).toBe(false);
|
|
expect(rangeStarts).not.toContain(binary.length);
|
|
expect(fs.readFileSync(targetPath).equals(binary)).toBe(true);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("resumes archive tails when persisted bytes lag slightly behind pre-allocated file size", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = crypto.randomBytes(2 * 1024 * 1024);
|
|
const persistedBytes = binary.length - (256 * 1024);
|
|
const outputDir = path.join(root, "downloads", "resume-prealloc-tail");
|
|
const targetPath = path.join(outputDir, "resume-prealloc-tail.part01.rar");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
const preallocated = Buffer.concat([
|
|
binary.subarray(0, persistedBytes),
|
|
Buffer.alloc(binary.length - persistedBytes, 0)
|
|
]);
|
|
fs.writeFileSync(targetPath, preallocated);
|
|
|
|
const session = emptySession();
|
|
const packageId = "resume-prealloc-tail-pkg";
|
|
const itemId = "resume-prealloc-tail-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "resume-prealloc-tail",
|
|
outputDir,
|
|
extractDir: path.join(root, "extract", "resume-prealloc-tail"),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/resume-prealloc-tail",
|
|
provider: "realdebrid",
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: persistedBytes,
|
|
totalBytes: binary.length,
|
|
progressPercent: Math.floor((persistedBytes / binary.length) * 100),
|
|
fileName: "resume-prealloc-tail.part01.rar",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const rangeStarts: number[] = [];
|
|
let sawRangeAtFullSize = false;
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/resume-prealloc-tail") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
|
|
const rangeHeader = String(req.headers.range || "");
|
|
const match = /bytes=(\d+)-/i.exec(rangeHeader);
|
|
if (match) {
|
|
const start = Number(match[1] || 0);
|
|
rangeStarts.push(start);
|
|
if (start >= binary.length) {
|
|
sawRangeAtFullSize = true;
|
|
res.statusCode = 416;
|
|
res.setHeader("Content-Range", `bytes */${binary.length}`);
|
|
res.end();
|
|
return;
|
|
}
|
|
const end = binary.length - 1;
|
|
res.statusCode = 206;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Range", `bytes ${start}-${end}/${binary.length}`);
|
|
res.setHeader("Content-Length", String(binary.length - start));
|
|
res.end(binary.subarray(start));
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.end(binary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/resume-prealloc-tail`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "resume-prealloc-tail.part01.rar",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
expect(item?.status).toBe("completed");
|
|
expect(item?.downloadedBytes).toBe(binary.length);
|
|
expect(item?.progressPercent).toBe(100);
|
|
expect(sawRangeAtFullSize).toBe(false);
|
|
expect(rangeStarts).not.toContain(binary.length);
|
|
expect(rangeStarts.some((start) => start === persistedBytes)).toBe(true);
|
|
expect(fs.readFileSync(targetPath).equals(binary)).toBe(true);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("marks extracting items as resumable extraction on shutdown", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const session = emptySession();
|
|
const packageId = "extract-stop-pkg";
|
|
const itemId = "extract-stop-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
const outputDir = path.join(root, "downloads", "extract-stop");
|
|
const extractDir = path.join(root, "extract", "extract-stop");
|
|
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "extract-stop",
|
|
outputDir,
|
|
extractDir,
|
|
status: "extracting",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/extract-stop",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 123,
|
|
totalBytes: 123,
|
|
progressPercent: 100,
|
|
fileName: "extract-stop.part01.rar",
|
|
targetPath: path.join(outputDir, "extract-stop.part01.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpacken 40%",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.prepareForShutdown();
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
|
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpacken abgebrochen (wird fortgesetzt)");
|
|
});
|
|
|
|
it("recovers pending extraction on startup for completed package", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const outputDir = path.join(root, "downloads", "recovery");
|
|
const extractDir = path.join(root, "extract", "recovery");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const zip = new AdmZip();
|
|
zip.addFile("episode.txt", Buffer.from("ok"));
|
|
const archivePath = path.join(outputDir, "episode.zip");
|
|
zip.writeZip(archivePath);
|
|
|
|
const session = emptySession();
|
|
const packageId = "recover-pkg";
|
|
const itemId = "recover-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "recovery",
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/recover",
|
|
provider: "megadebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: fs.statSync(archivePath).size,
|
|
totalBytes: fs.statSync(archivePath).size,
|
|
progressPercent: 100,
|
|
fileName: "episode.zip",
|
|
targetPath: archivePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (100 MB)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
createExtractSubfolder: true,
|
|
autoExtract: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await waitFor(
|
|
() =>
|
|
fs.existsSync(path.join(extractDir, "episode.txt")) &&
|
|
manager.getSnapshot().session.packages[packageId]?.status === "completed",
|
|
25000
|
|
);
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
|
|
expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
|
|
}, 30000);
|
|
|
|
it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const outputDir = path.join(root, "downloads", "missing-source-ok");
|
|
const extractDir = path.join(root, "extract", "missing-source-ok");
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.writeFileSync(path.join(extractDir, "episode.mkv"), "ok", "utf8");
|
|
|
|
const session = emptySession();
|
|
const packageId = "missing-source-ok-pkg";
|
|
const itemId = "missing-source-ok-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "missing-source-ok",
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/missing-source-ok",
|
|
provider: "megadebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 123,
|
|
totalBytes: 123,
|
|
progressPercent: 100,
|
|
fileName: "missing-source-ok.part01.rar",
|
|
targetPath: path.join(outputDir, "missing-source-ok.part01.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (123 B)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000);
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
|
|
expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
|
|
}, 20000);
|
|
|
|
it("marks missing source package dir as extracted instead of failed", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const outputDir = path.join(root, "downloads", "missing-source-empty");
|
|
const extractDir = path.join(root, "extract", "missing-source-empty");
|
|
|
|
const session = emptySession();
|
|
const packageId = "missing-source-empty-pkg";
|
|
const itemId = "missing-source-empty-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "missing-source-empty",
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/missing-source-empty",
|
|
provider: "megadebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 123,
|
|
totalBytes: 123,
|
|
progressPercent: 100,
|
|
fileName: "missing-source-empty.part01.rar",
|
|
targetPath: path.join(outputDir, "missing-source-empty.part01.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (123 B)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000);
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
|
|
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt (Quelle fehlt)");
|
|
}, 20000);
|
|
|
|
it("resumes deferred startup cleanup for already extracted packages and removes them when package_done is active", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "startup-deferred-cleanup";
|
|
const {
|
|
session,
|
|
packageId,
|
|
itemId,
|
|
outputDir,
|
|
extractDir
|
|
} = createCompletedArchiveSessionFromArchive(root, packageName, [
|
|
{ name: "Season 1/Episode01.mkv", data: Buffer.from("video") },
|
|
{ name: "Season 1/episode.links.txt", data: Buffer.from("https://example.com/file") },
|
|
{ name: "Season 1/sample/sample.mkv", data: Buffer.from("sample-video") },
|
|
{ name: "Season 1/sample/readme.txt", data: Buffer.from("sample-text") }
|
|
]);
|
|
|
|
session.packages[packageId].status = "completed";
|
|
session.items[itemId].fullStatus = "Entpackt - Done (<1s)";
|
|
fs.mkdirSync(path.join(extractDir, "Season 1", "sample"), { recursive: true });
|
|
fs.writeFileSync(path.join(extractDir, "Season 1", "Episode01.mkv"), "video", "utf8");
|
|
fs.writeFileSync(path.join(extractDir, "Season 1", "episode.links.txt"), "https://example.com/file", "utf8");
|
|
fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "sample.mkv"), "sample-video", "utf8");
|
|
fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "readme.txt"), "sample-text", "utf8");
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
removeLinkFilesAfterExtract: true,
|
|
removeSamplesAfterExtract: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "delete",
|
|
completedCleanupPolicy: "package_done"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv");
|
|
await waitFor(() => fs.existsSync(flattenedPath), 12000);
|
|
await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000);
|
|
|
|
expect(fs.existsSync(flattenedPath)).toBe(true);
|
|
expect(fs.existsSync(extractDir)).toBe(false);
|
|
expect(fs.existsSync(outputDir)).toBe(false);
|
|
expect(manager.getSnapshot().session.items[itemId]).toBeUndefined();
|
|
}, 20000);
|
|
|
|
it("resumes deferred startup auto-rename for already extracted packages", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF";
|
|
const sourceFileName = "4sf-asbest.web.7p-s02e01.mkv";
|
|
const expectedFileName = "Asbest.S02E01.GERMAN.720p.WEB.AVC-4SF.mkv";
|
|
const {
|
|
session,
|
|
packageId,
|
|
itemId,
|
|
extractDir,
|
|
originalExtractedPath
|
|
} = createCompletedArchiveSession(root, packageName, sourceFileName);
|
|
|
|
session.packages[packageId].status = "completed";
|
|
session.items[itemId].fullStatus = "Entpackt - Done (<1s)";
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.writeFileSync(originalExtractedPath, "video", "utf8");
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const expectedPath = path.join(extractDir, expectedFileName);
|
|
await waitFor(() => fs.existsSync(expectedPath), 12000);
|
|
|
|
expect(fs.existsSync(expectedPath)).toBe(true);
|
|
expect(fs.existsSync(originalExtractedPath)).toBe(false);
|
|
expect(manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
|
|
}, 20000);
|
|
|
|
it("does not requeue already extracted items on startup when source archives were intentionally removed", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "startup-extracted-without-source";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.writeFileSync(path.join(extractDir, "Episode01.mkv"), "video", "utf8");
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const itemId = `${packageName}-item`;
|
|
const createdAt = Date.now() - 20_000;
|
|
const targetPath = path.join(outputDir, "episode.zip");
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: `https://dummy/${packageName}`,
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 12_345,
|
|
totalBytes: 12_345,
|
|
progressPercent: 100,
|
|
fileName: "episode.zip",
|
|
targetPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt - Done (<1s)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: false,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "delete",
|
|
completedCleanupPolicy: "never"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
|
|
expect(manager.getSnapshot().session.items[itemId]?.status).toBe("completed");
|
|
expect(manager.getSnapshot().session.items[itemId]?.fullStatus).toBe("Entpackt - Done (<1s)");
|
|
expect(manager.getSnapshot().session.packages[packageId]?.status).toBe("completed");
|
|
}, 20000);
|
|
|
|
it("stops deferred post-extraction cleanup after package reset", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const sharedDir = path.join(root, "shared");
|
|
fs.mkdirSync(sharedDir, { recursive: true });
|
|
fs.writeFileSync(path.join(sharedDir, "episode.part01.rar"), "archive", "utf8");
|
|
|
|
const session = emptySession();
|
|
const packageId = "deferred-reset-pkg";
|
|
const itemId = "deferred-reset-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "Deferred Reset",
|
|
outputDir: sharedDir,
|
|
extractDir: sharedDir,
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
downloadStartedAt: createdAt,
|
|
downloadCompletedAt: createdAt + 10_000,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/deferred-reset",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 123,
|
|
totalBytes: 123,
|
|
progressPercent: 100,
|
|
fileName: "episode.part01.rar",
|
|
targetPath: path.join(sharedDir, "episode.part01.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (123 B)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
cleanupMode: "delete"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
let renameStarted = false;
|
|
let releaseRename = (): void => {};
|
|
const renameGate = new Promise<void>((resolve) => {
|
|
releaseRename = resolve;
|
|
});
|
|
const internal = manager as any;
|
|
internal.autoRenameExtractedVideoFiles = vi.fn(async () => {
|
|
renameStarted = true;
|
|
await renameGate;
|
|
return 0;
|
|
});
|
|
const cleanupRemainingArchiveArtifacts = vi.fn(async () => 0);
|
|
internal.cleanupRemainingArchiveArtifacts = cleanupRemainingArchiveArtifacts;
|
|
|
|
const deferredPromise = internal.runDeferredPostExtraction(
|
|
packageId,
|
|
internal.session.packages[packageId],
|
|
1,
|
|
0,
|
|
true,
|
|
1
|
|
);
|
|
|
|
await waitFor(() => renameStarted, 4000);
|
|
const resetPromise = manager.resetPackage(packageId);
|
|
releaseRename();
|
|
await deferredPromise;
|
|
await resetPromise;
|
|
|
|
expect(cleanupRemainingArchiveArtifacts).not.toHaveBeenCalled();
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
|
expect(snapshot.session.packages[packageId]?.downloadStartedAt).toBe(0);
|
|
expect(snapshot.session.packages[packageId]?.downloadCompletedAt).toBe(0);
|
|
expect(snapshot.session.items[itemId]?.status).toBe("queued");
|
|
});
|
|
|
|
it("does not let cancelled cleanup delete archives for a re-added package in the same folder", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Cancel Cleanup";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
const archivePath = path.join(outputDir, "episode.part01.rar");
|
|
fs.writeFileSync(archivePath, "archive", "utf8");
|
|
|
|
const session = emptySession();
|
|
const packageId = "cancel-cleanup-pkg";
|
|
const itemId = "cancel-cleanup-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir: path.join(root, "extract", packageName),
|
|
status: "queued",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/episode.part01.rar",
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "episode.part01.rar",
|
|
targetPath: archivePath,
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.cancelPackage(packageId);
|
|
manager.addPackages([{ name: packageName, links: ["https://dummy/episode.part01.rar"] }]);
|
|
|
|
await waitFor(() => manager.getSnapshot().session.packageOrder.length === 1, 4000);
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
expect(fs.existsSync(archivePath)).toBe(true);
|
|
const snapshot = manager.getSnapshot();
|
|
const remainingPackage = snapshot.session.packages[snapshot.session.packageOrder[0]];
|
|
expect(remainingPackage?.outputDir).toBe(outputDir);
|
|
});
|
|
|
|
it("removes link and sample artifacts from extracted output even when deferred post-processing has failures", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Failed Cleanup";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(path.join(extractDir, "sample"), { recursive: true });
|
|
fs.writeFileSync(path.join(extractDir, "episode.links.txt"), "https://example.com/file", "utf8");
|
|
fs.writeFileSync(path.join(extractDir, "sample", "sample.mkv"), "sample-video", "utf8");
|
|
|
|
const session = emptySession();
|
|
const packageId = "failed-cleanup-pkg";
|
|
const itemId = "failed-cleanup-item";
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "failed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/failed-cleanup",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 100,
|
|
totalBytes: 100,
|
|
progressPercent: 100,
|
|
fileName: "episode.zip",
|
|
targetPath: path.join(outputDir, "episode.zip"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt - Done (1s)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: false,
|
|
removeLinkFilesAfterExtract: true,
|
|
removeSamplesAfterExtract: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "delete"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await (manager as any).runDeferredPostExtraction(
|
|
packageId,
|
|
(manager as any).session.packages[packageId],
|
|
1,
|
|
1,
|
|
true,
|
|
1
|
|
);
|
|
|
|
expect(fs.existsSync(path.join(extractDir, "episode.links.txt"))).toBe(false);
|
|
expect(fs.existsSync(path.join(extractDir, "sample", "sample.mkv"))).toBe(false);
|
|
});
|
|
|
|
it("does not delete startup archives when any completed item has an extract error", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const outputDir = path.join(root, "downloads", "keep-failed-archive");
|
|
const extractDir = path.join(root, "extract", "keep-failed-archive");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.writeFileSync(path.join(extractDir, "episode1.mkv"), "ok", "utf8");
|
|
|
|
const okArchivePath = path.join(outputDir, "episode1.part01.rar");
|
|
const failedArchivePath = path.join(outputDir, "episode2.part01.rar");
|
|
fs.writeFileSync(okArchivePath, Buffer.from("ok-archive"));
|
|
fs.writeFileSync(failedArchivePath, Buffer.from("failed-archive"));
|
|
|
|
const session = emptySession();
|
|
const packageId = "keep-failed-archive-pkg";
|
|
const itemOkId = "keep-failed-archive-item-ok";
|
|
const itemFailId = "keep-failed-archive-item-fail";
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: "keep-failed-archive",
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [itemOkId, itemFailId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemOkId] = {
|
|
id: itemOkId,
|
|
packageId,
|
|
url: "https://dummy/keep-failed-archive-1",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 10,
|
|
totalBytes: 10,
|
|
progressPercent: 100,
|
|
fileName: "episode1.part01.rar",
|
|
targetPath: okArchivePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpackt - Done",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemFailId] = {
|
|
id: itemFailId,
|
|
packageId,
|
|
url: "https://dummy/keep-failed-archive-2",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 14,
|
|
totalBytes: 14,
|
|
progressPercent: 100,
|
|
fileName: "episode2.part01.rar",
|
|
targetPath: failedArchivePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Entpack-Fehler: Unexpected end of archive",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "delete"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
|
|
expect(fs.existsSync(okArchivePath)).toBe(true);
|
|
expect(fs.existsSync(failedArchivePath)).toBe(true);
|
|
});
|
|
|
|
it("does not delete stale target file when stopping during unrestrict phase", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "stop-unrestrict", links: ["https://dummy/slow-unrestrict"] }]);
|
|
const initialSnapshot = manager.getSnapshot();
|
|
const pkgId = initialSnapshot.session.packageOrder[0];
|
|
const itemId = initialSnapshot.session.packages[pkgId]?.itemIds[0] || "";
|
|
if (!itemId) {
|
|
throw new Error("item missing");
|
|
}
|
|
|
|
const item = manager.getSnapshot().session.items[itemId];
|
|
const staleTargetPath = path.join(path.dirname(item.targetPath), "existing-before-start.mkv");
|
|
fs.mkdirSync(path.dirname(staleTargetPath), { recursive: true });
|
|
fs.writeFileSync(staleTargetPath, "keep", "utf8");
|
|
|
|
const mutableSession = manager.getSnapshot().session;
|
|
if (mutableSession.items[itemId]) {
|
|
mutableSession.items[itemId].targetPath = staleTargetPath;
|
|
mutableSession.items[itemId].fileName = path.basename(staleTargetPath);
|
|
mutableSession.items[itemId].downloadedBytes = 0;
|
|
mutableSession.items[itemId].progressPercent = 0;
|
|
}
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/unrestrict/link")) {
|
|
await new Promise((resolve) => setTimeout(resolve, 260));
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: "https://cdn.example/unused.bin",
|
|
filename: "new-file.mkv",
|
|
filesize: 1024
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
manager.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
manager.stop();
|
|
await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "cancelled", 12000);
|
|
expect(fs.existsSync(staleTargetPath)).toBe(true);
|
|
});
|
|
|
|
it("counts re-enabled package items in run summary totals", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const payload = Buffer.alloc(96 * 1024, 5);
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/slow") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
setTimeout(() => {
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(payload.length));
|
|
res.end(payload);
|
|
}, 180);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/slow`;
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "episode.mkv",
|
|
filesize: payload.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input);
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([
|
|
{ name: "pkg-a", links: ["https://dummy/a"] },
|
|
{ name: "pkg-b", links: ["https://dummy/b"] }
|
|
]);
|
|
|
|
const packageIds = manager.getSnapshot().session.packageOrder;
|
|
const packageToToggle = packageIds[0];
|
|
manager.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
manager.togglePackage(packageToToggle);
|
|
manager.togglePackage(packageToToggle);
|
|
|
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
|
const summary = manager.getSnapshot().summary;
|
|
expect(summary?.total).toBe(2);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
});
|
|
|
|
it("auto-renames extracted 4SF scene files to folder format", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF";
|
|
const sourceFileName = "4sf-asbest.web.7p-s02e01.mkv";
|
|
const expectedFileName = "Asbest.S02E01.GERMAN.720p.WEB.AVC-4SF.mkv";
|
|
const { session, packageId, itemId, extractDir, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const expectedPath = path.join(extractDir, expectedFileName);
|
|
await waitFor(() => fs.existsSync(expectedPath), 12000);
|
|
const snapshot = manager.getSnapshot();
|
|
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
|
|
expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
|
|
expect(fs.existsSync(expectedPath)).toBe(true);
|
|
expect(fs.existsSync(originalExtractedPath)).toBe(false);
|
|
}, 20000);
|
|
|
|
it("tracks app runtime for session and all-time statistics", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const stateDir = path.join(root, "state");
|
|
const storagePaths = createStoragePaths(stateDir);
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
totalRuntimeAllTimeMs: 2 * 60 * 60 * 1000
|
|
},
|
|
emptySession(),
|
|
storagePaths
|
|
);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
|
|
const stats = manager.getStats();
|
|
expect(stats.sessionRuntimeMs).toBeGreaterThanOrEqual(100);
|
|
expect(stats.totalRuntimeMs).toBeGreaterThanOrEqual(2 * 60 * 60 * 1000 + 100);
|
|
expect(stats.runtimeMeasuredAt).toBeGreaterThan(0);
|
|
|
|
manager.persistRuntimeStats(true);
|
|
const savedSettings = JSON.parse(fs.readFileSync(storagePaths.configFile, "utf8")) as { totalRuntimeAllTimeMs?: number };
|
|
expect(savedSettings.totalRuntimeAllTimeMs || 0).toBeGreaterThanOrEqual(2 * 60 * 60 * 1000 + 100);
|
|
}, 10000);
|
|
|
|
it("resets session runtime without affecting all-time runtime", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
totalRuntimeAllTimeMs: 90 * 60 * 1000
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
|
|
const beforeReset = manager.getStats();
|
|
expect(beforeReset.sessionRuntimeMs).toBeGreaterThanOrEqual(100);
|
|
|
|
manager.resetSessionStats();
|
|
|
|
const afterReset = manager.getStats();
|
|
expect(afterReset.sessionRuntimeMs).toBeLessThan(beforeReset.sessionRuntimeMs);
|
|
expect(afterReset.sessionRuntimeMs).toBeLessThan(100);
|
|
expect(afterReset.totalRuntimeMs).toBeGreaterThanOrEqual(90 * 60 * 1000);
|
|
}, 10000);
|
|
|
|
it("writes auto-rename details into rename and item logs", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF";
|
|
const sourceFileName = "4sf-asbest.web.7p-s02e01.mkv";
|
|
const expectedFileName = "Asbest.S02E01.GERMAN.720p.WEB.AVC-4SF.mkv";
|
|
const { session, itemId, extractDir } = createCompletedArchiveSession(root, packageName, sourceFileName);
|
|
const stateDir = path.join(root, "state");
|
|
initItemLogs(stateDir);
|
|
initRenameLog(stateDir);
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(stateDir)
|
|
);
|
|
|
|
const expectedPath = path.join(extractDir, expectedFileName);
|
|
await waitFor(() => fs.existsSync(expectedPath), 12000);
|
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
|
|
const renameLogPath = getRenameLogPath();
|
|
expect(renameLogPath).not.toBeNull();
|
|
const renameContent = fs.readFileSync(renameLogPath!, "utf8");
|
|
expect(renameContent).toContain("Auto-Rename durchgeführt");
|
|
expect(renameContent).toContain(`targetPath=${expectedPath}`);
|
|
|
|
const itemLogPath = getItemLogPath(itemId);
|
|
expect(itemLogPath).not.toBeNull();
|
|
const itemContent = fs.readFileSync(itemLogPath!, "utf8");
|
|
expect(itemContent).toContain("Auto-Rename durchgeführt");
|
|
expect(itemContent).toContain("stage=auto-rename");
|
|
}, 20000);
|
|
|
|
it("adds REPACK marker from rp token and supports 4SJ folders", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SJ";
|
|
const sourceFileName = "4sf-asbest.rp.web.7p-s02e01.mkv";
|
|
const expectedFileName = "Asbest.S02E01.GERMAN.REPACK.720p.WEB.AVC-4SJ.mkv";
|
|
const { session, itemId, extractDir, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName);
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const expectedPath = path.join(extractDir, expectedFileName);
|
|
await waitFor(() => fs.existsSync(expectedPath), 12000);
|
|
expect(fs.existsSync(expectedPath)).toBe(true);
|
|
expect(fs.existsSync(originalExtractedPath)).toBe(false);
|
|
}, 20000);
|
|
|
|
it("skips auto-rename when no SxxExx token exists in source filename", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF";
|
|
const sourceFileName = "4sf-asbest.rp.web.7p-episode.mkv";
|
|
const unexpectedName = "Asbest.S02.GERMAN.REPACK.720p.WEB.AVC-4SF.mkv";
|
|
const { session, itemId, extractDir, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000);
|
|
expect(fs.existsSync(originalExtractedPath)).toBe(true);
|
|
expect(fs.existsSync(path.join(extractDir, unexpectedName))).toBe(false);
|
|
}, 20000);
|
|
|
|
it("does not rename extracted scene files when auto-rename is disabled", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF";
|
|
const sourceFileName = "4sf-asbest.web.7p-s02e01.mkv";
|
|
const unexpectedName = "Asbest.S02E01.GERMAN.720p.WEB.AVC-4SF.mkv";
|
|
const { session, itemId, extractDir, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000);
|
|
expect(fs.existsSync(originalExtractedPath)).toBe(true);
|
|
expect(fs.existsSync(path.join(extractDir, unexpectedName))).toBe(false);
|
|
}, 20000);
|
|
|
|
it("moves extracted MKV files into a flat library folder per completed package", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Flat-Pack";
|
|
const sourceFileName = "Season 1/Episode01.mkv";
|
|
const { session, packageId, itemId, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName);
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv");
|
|
await waitFor(() => fs.existsSync(flattenedPath), 12000);
|
|
|
|
expect(manager.getSnapshot().session.packages[packageId]?.status).toBe("completed");
|
|
expect(manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
|
|
expect(fs.existsSync(flattenedPath)).toBe(true);
|
|
expect(fs.existsSync(originalExtractedPath)).toBe(false);
|
|
}, 20000);
|
|
|
|
it("moves extracted AVI files into a flat library folder per completed package", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Flat-Pack-AVI";
|
|
const sourceFileName = "Season 1/Episode01.avi";
|
|
const { session, packageId, itemId, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName);
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const flattenedPath = path.join(mkvLibraryDir, "Episode01.avi");
|
|
await waitFor(() => fs.existsSync(flattenedPath), 12000);
|
|
|
|
expect(manager.getSnapshot().session.packages[packageId]?.status).toBe("completed");
|
|
expect(manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
|
|
expect(fs.existsSync(flattenedPath)).toBe(true);
|
|
expect(fs.existsSync(originalExtractedPath)).toBe(false);
|
|
}, 20000);
|
|
|
|
it("hybrid collect defers fresh files instead of moving them unrenamed; final pass collects them", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Fresh.Defer.Test.S01.German.720p.BluRay.x264-GRP";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
|
|
const mkvName = "grp-freshshow.s01e07-720p.mkv";
|
|
const mkvPath = path.join(extractDir, mkvName);
|
|
fs.writeFileSync(mkvPath, Buffer.alloc(4096, 7));
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
|
|
|
const libPath = path.join(mkvLibraryDir, mkvName);
|
|
|
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, true);
|
|
expect(fs.existsSync(libPath)).toBe(false);
|
|
expect(fs.existsSync(mkvPath)).toBe(true);
|
|
|
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
|
expect(fs.existsSync(libPath)).toBe(true);
|
|
expect(fs.existsSync(mkvPath)).toBe(false);
|
|
|
|
void manager;
|
|
}, 20000);
|
|
|
|
it("collect CLEANS a raw scene file that auto-rename never processed (the 17-file library bug)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV";
|
|
const epDir = path.join(extractDir, episodeFolder);
|
|
fs.mkdirSync(epDir, { recursive: true });
|
|
const rawName = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
|
|
const rawPath = path.join(epDir, rawName);
|
|
fs.writeFileSync(rawPath, Buffer.alloc(4096, 7));
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const createdAt = Date.now() - 60_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
|
|
|
const cleanLibPath = path.join(mkvLibraryDir, `${episodeFolder}.mkv`);
|
|
const rawLibPath = path.join(mkvLibraryDir, rawName);
|
|
expect(fs.existsSync(cleanLibPath)).toBe(true);
|
|
expect(fs.existsSync(rawLibPath)).toBe(false);
|
|
expect(fs.existsSync(rawPath)).toBe(false);
|
|
|
|
void manager;
|
|
}, 20000);
|
|
|
|
it("collect cleans a raw file sitting OUTSIDE extractDir (Downloader-Unfertig case) AND its .srt follows the rename", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const rawName = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv";
|
|
const rawSrt = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.de.srt";
|
|
fs.writeFileSync(path.join(outputDir, rawName), Buffer.alloc(4096, 4));
|
|
fs.writeFileSync(path.join(outputDir, rawSrt), Buffer.from("1\n00:00:01,000 --> 00:00:02,000\nhi\n"));
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const createdAt = Date.now() - 60_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
|
|
|
const cleanBase = "Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF";
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.mkv`))).toBe(true);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, rawName))).toBe(false);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.de.srt`))).toBe(true);
|
|
|
|
void manager;
|
|
}, 20000);
|
|
|
|
it("collect MOVES a numbered episode whose TITLE is a bonus keyword (Revenge S04E19 'Interview')", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
|
|
const epDir = path.join(extractDir, episodeFolder);
|
|
fs.mkdirSync(epDir, { recursive: true });
|
|
const epName = `${episodeFolder}.mkv`;
|
|
fs.writeFileSync(path.join(epDir, epName), Buffer.alloc(4096, 9));
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const createdAt = Date.now() - 60_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
|
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true);
|
|
expect(fs.existsSync(path.join(epDir, epName))).toBe(false);
|
|
|
|
void manager;
|
|
}, 20000);
|
|
|
|
it("collect STILL skips genuine bonus/extras with NO episode token (Making.Of) — proves the filter isn't disabled", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Some.Show.S01.GERMAN.720p.WEB.x264-GRP";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv";
|
|
const bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv";
|
|
fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1));
|
|
fs.writeFileSync(path.join(extractDir, bonusName), Buffer.alloc(4096, 2));
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const createdAt = Date.now() - 60_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
|
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, bonusName))).toBe(false);
|
|
expect(fs.existsSync(path.join(extractDir, bonusName))).toBe(true);
|
|
|
|
void manager;
|
|
}, 20000);
|
|
|
|
it("collect CLEANS a raw .avi whose folder is a complete episode name WITHOUT a -GROUP suffix (safari S04E08a)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "c284d9d9072eaf3ac314d05f951dd115";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
const mk = (folder: string, raw: string) => {
|
|
const epDir = path.join(extractDir, folder);
|
|
fs.mkdirSync(epDir, { recursive: true });
|
|
fs.writeFileSync(path.join(epDir, raw), Buffer.alloc(4096, 8));
|
|
};
|
|
const folderA = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
|
|
const folderB = "Fluss-Monster.S04E08b.Am.Essequibo.Teil.2.German.DOKU.SATRiP.XviD";
|
|
mk(folderA, "safari-fm-s04e08a.avi");
|
|
mk(folderB, "safari-fm-s04e08b.avi");
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const createdAt = Date.now() - 60_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
|
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, `${folderA}.avi`))).toBe(true);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, `${folderB}.avi`))).toBe(true);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, "safari-fm-s04e08a.avi"))).toBe(false);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, "safari-fm-s04e08b.avi"))).toBe(false);
|
|
|
|
void manager;
|
|
}, 20000);
|
|
|
|
it("collect KEEPS the clean SxxExx name and does NOT mangle it via an episode-title folder (Taken S01E01)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG";
|
|
const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv";
|
|
const epDir = path.join(extractDir, epFolder);
|
|
fs.mkdirSync(epDir, { recursive: true });
|
|
fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5));
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const createdAt = Date.now() - 60_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
|
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, cleanName))).toBe(true);
|
|
const mangled = `${epFolder}.S01E01.mkv`;
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, mangled))).toBe(false);
|
|
const inLib = fs.readdirSync(mkvLibraryDir);
|
|
expect(inLib.some((n) => /Hinter\.dem\.Himmel/i.test(n))).toBe(false);
|
|
|
|
void manager;
|
|
}, 20000);
|
|
|
|
it("deferred final pass renames fresh files before collecting them (no scene names in library)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake");
|
|
fs.mkdirSync(epFolder, { recursive: true });
|
|
|
|
const sceneName = "awa-testshow02e05hd.mkv";
|
|
const scenePath = path.join(epFolder, sceneName);
|
|
fs.writeFileSync(scenePath, Buffer.alloc(4096, 5));
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
|
|
|
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
|
const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`);
|
|
const sceneLibPath = path.join(mkvLibraryDir, sceneName);
|
|
|
|
await (manager as any).autoRenameExtractedVideoFiles(extractDir, session.packages[packageId], undefined, true);
|
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
|
|
|
expect(fs.existsSync(renamedLibPath)).toBe(true);
|
|
expect(fs.existsSync(sceneLibPath)).toBe(false);
|
|
|
|
void manager;
|
|
}, 20000);
|
|
|
|
it("deferred post-extraction wiring renames fresh files end-to-end (treatFilesAsStable reaches the rename)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake");
|
|
fs.mkdirSync(epFolder, { recursive: true });
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const sceneName = "awa-testshow02e05hd.mkv";
|
|
fs.writeFileSync(path.join(epFolder, sceneName), Buffer.alloc(4096, 5));
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: true,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
|
|
|
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
|
await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1);
|
|
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, sceneName))).toBe(false);
|
|
|
|
void manager;
|
|
}, 20000);
|
|
|
|
it("moves direct MKV download from outputDir to library when no archive present (Mega-Debrid flow)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Mega-Direct-Pack";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const directMkvName = "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv";
|
|
const directMkvPath = path.join(outputDir, directMkvName);
|
|
fs.writeFileSync(directMkvPath, Buffer.alloc(2048, 1));
|
|
const directMkvSize = fs.statSync(directMkvPath).size;
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const itemId = `${packageName}-item`;
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: `https://mega.nz/file/${packageName}`,
|
|
provider: "megadebrid-api",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: directMkvSize,
|
|
totalBytes: directMkvSize,
|
|
progressPercent: 100,
|
|
fileName: directMkvName,
|
|
targetPath: directMkvPath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const libraryPath = path.join(mkvLibraryDir, directMkvName);
|
|
await waitFor(() => fs.existsSync(libraryPath), 12000);
|
|
|
|
expect(fs.existsSync(libraryPath)).toBe(true);
|
|
expect(fs.readFileSync(libraryPath).length).toBe(directMkvSize);
|
|
expect(fs.existsSync(directMkvPath)).toBe(false);
|
|
|
|
void manager;
|
|
}, 20000);
|
|
|
|
it("does NOT delete pending RAR archive sets in outputDir when collecting MKVs from extractDir", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Ugly.Americans.MultiSeason-Pack";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
|
|
const s02Parts = [
|
|
"Ugly.Americans.S02.COMPLETE.German.part1.rar",
|
|
"Ugly.Americans.S02.COMPLETE.German.part2.rar",
|
|
"Ugly.Americans.S02.COMPLETE.German.part3.rar"
|
|
];
|
|
for (const part of s02Parts) {
|
|
fs.writeFileSync(path.join(outputDir, part), Buffer.alloc(1024, 7));
|
|
}
|
|
fs.writeFileSync(path.join(outputDir, "info.nfo"), Buffer.from("nfo"));
|
|
|
|
const s01Mkvs = [
|
|
"Ugly.Americans.S01E01.German.mkv",
|
|
"Ugly.Americans.S01E02.German.mkv"
|
|
];
|
|
for (const mkv of s01Mkvs) {
|
|
fs.writeFileSync(path.join(extractDir, mkv), Buffer.alloc(4096, 9));
|
|
}
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "delete"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]);
|
|
|
|
for (const mkv of s01Mkvs) {
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, mkv))).toBe(true);
|
|
}
|
|
for (const part of s02Parts) {
|
|
expect(fs.existsSync(path.join(outputDir, part))).toBe(true);
|
|
}
|
|
|
|
void manager;
|
|
}, 20000);
|
|
|
|
it("does NOT move bonus files from Extras subdirectory to flat library", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Breaking.Bad.S04.GERMAN.5.1.DL.BluRay.720p.x264-TSCC";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const zip = new AdmZip();
|
|
zip.addFile("Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv", Buffer.from("episode-data"));
|
|
zip.addFile("Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC/Schrotflinte.mkv", Buffer.from("bonus-1"));
|
|
zip.addFile("Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC/Die.Autoexplosion.mkv", Buffer.from("bonus-2"));
|
|
zip.addFile("Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC/White.House.mkv", Buffer.from("bonus-3"));
|
|
const archivePath = path.join(outputDir, "episode.zip");
|
|
zip.writeZip(archivePath);
|
|
const archiveSize = fs.statSync(archivePath).size;
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const itemId = `${packageName}-item`;
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: `https://dummy/${packageName}`,
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: archiveSize,
|
|
totalBytes: archiveSize,
|
|
progressPercent: 100,
|
|
fileName: "episode.zip",
|
|
targetPath: archivePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (100 MB)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv");
|
|
await waitFor(() => fs.existsSync(flattenedEpisode), 12000);
|
|
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, "Schrotflinte.mkv"))).toBe(false);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, "Die.Autoexplosion.mkv"))).toBe(false);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, "White.House.mkv"))).toBe(false);
|
|
|
|
const extrasDir = path.join(extractDir, "Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC");
|
|
expect(fs.existsSync(path.join(extrasDir, "Schrotflinte.mkv"))).toBe(true);
|
|
expect(fs.existsSync(path.join(extrasDir, "Die.Autoexplosion.mkv"))).toBe(true);
|
|
expect(fs.existsSync(path.join(extrasDir, "White.House.mkv"))).toBe(true);
|
|
|
|
expect(fs.existsSync(flattenedEpisode)).toBe(true);
|
|
}, 20000);
|
|
|
|
it("detects dot-separated bonus subdirectories (Making.Of, Behind.The.Scenes)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Breaking.Bad.S05.GERMAN.DL.720p.BluRay.x264-TSCC";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const zip = new AdmZip();
|
|
zip.addFile("Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv", Buffer.from("real-episode"));
|
|
zip.addFile("Breaking.Bad.S05.Making.Of/SomeBonusClip.mkv", Buffer.from("bonus-1"));
|
|
zip.addFile("Breaking.Bad.S05.Behind.The.Scenes/AnotherClip.mkv", Buffer.from("bonus-2"));
|
|
zip.addFile("Breaking.Bad.S05.Deleted.Scenes/DeletedClip.mkv", Buffer.from("bonus-3"));
|
|
zip.addFile("Breaking.Bad.S05.Gag.Reel/GagClip.mkv", Buffer.from("bonus-4"));
|
|
const archivePath = path.join(outputDir, "episode.zip");
|
|
zip.writeZip(archivePath);
|
|
const archiveSize = fs.statSync(archivePath).size;
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const itemId = `${packageName}-item`;
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: `https://dummy/${packageName}`,
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: archiveSize,
|
|
totalBytes: archiveSize,
|
|
progressPercent: 100,
|
|
fileName: "episode.zip",
|
|
targetPath: archivePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig (100 MB)",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv");
|
|
await waitFor(() => fs.existsSync(flattenedEpisode), 12000);
|
|
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, "SomeBonusClip.mkv"))).toBe(false);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, "AnotherClip.mkv"))).toBe(false);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, "DeletedClip.mkv"))).toBe(false);
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, "GagClip.mkv"))).toBe(false);
|
|
|
|
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Making.Of", "SomeBonusClip.mkv"))).toBe(true);
|
|
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Behind.The.Scenes", "AnotherClip.mkv"))).toBe(true);
|
|
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Deleted.Scenes", "DeletedClip.mkv"))).toBe(true);
|
|
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Gag.Reel", "GagClip.mkv"))).toBe(true);
|
|
}, 20000);
|
|
|
|
it("keeps existing MKV names and appends a suffix while flattening", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Flat-Collision";
|
|
const sourceFileName = "Season 1/Episode01.mkv";
|
|
const { session } = createCompletedArchiveSession(root, packageName, sourceFileName);
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
fs.mkdirSync(mkvLibraryDir, { recursive: true });
|
|
const existingPath = path.join(mkvLibraryDir, "Episode01.mkv");
|
|
fs.writeFileSync(existingPath, "already-here", "utf8");
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const suffixedPath = path.join(mkvLibraryDir, "Episode01 (2).mkv");
|
|
await waitFor(() => fs.existsSync(suffixedPath), 12000);
|
|
|
|
expect(fs.existsSync(existingPath)).toBe(true);
|
|
expect(fs.readFileSync(existingPath, "utf8")).toBe("already-here");
|
|
expect(fs.existsSync(suffixedPath)).toBe(true);
|
|
}, 20000);
|
|
|
|
it("removes empty package folders after MKV flattening even with desktop.ini or thumbs.db", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Gotham.S03.GERMAN.5.1.DL.AC3.720p.BDRiP.x264-TvR";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const nestedFolder = "Gotham.S03E11.Ein.Ungeheuer.namens.Eifersucht.GERMAN.5.1.DL.AC3.720p.BDRiP.x264-TvR";
|
|
const sourceFileName = `${nestedFolder}/tvr-gotham-s03e11-720p.mkv`;
|
|
const zip = new AdmZip();
|
|
zip.addFile(sourceFileName, Buffer.from("video"));
|
|
zip.addFile(`${nestedFolder}/tvr-gotham-s03-720p.nfo`, Buffer.from("info"));
|
|
zip.addFile(`${nestedFolder}/Thumbs.db`, Buffer.from("thumbs"));
|
|
zip.addFile("desktop.ini", Buffer.from("system"));
|
|
const archivePath = path.join(outputDir, "episode.zip");
|
|
zip.writeZip(archivePath);
|
|
const archiveSize = fs.statSync(archivePath).size;
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const itemId = `${packageName}-item`;
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/gotham",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: archiveSize,
|
|
totalBytes: archiveSize,
|
|
progressPercent: 100,
|
|
fileName: "episode.zip",
|
|
targetPath: archivePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const flattenedPath = path.join(mkvLibraryDir, "tvr-gotham-s03e11-720p.mkv");
|
|
await waitFor(() => fs.existsSync(flattenedPath), 12000);
|
|
|
|
expect(fs.existsSync(flattenedPath)).toBe(true);
|
|
expect(fs.existsSync(extractDir)).toBe(false);
|
|
}, 20000);
|
|
|
|
it("cleans duplicate-skipped MKV source trees including leftover sample files", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Flat-Duplicate-Cleanup";
|
|
const outputDir = path.join(root, "downloads", packageName);
|
|
const extractDir = path.join(root, "extract", packageName);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const zip = new AdmZip();
|
|
zip.addFile("Season 1/Episode01.mkv", Buffer.from("video"));
|
|
zip.addFile("Season 1/sample.txt", Buffer.from("sample"));
|
|
const archivePath = path.join(outputDir, "episode.zip");
|
|
zip.writeZip(archivePath);
|
|
const archiveSize = fs.statSync(archivePath).size;
|
|
|
|
const session = emptySession();
|
|
const packageId = `${packageName}-pkg`;
|
|
const itemId = `${packageName}-item`;
|
|
const createdAt = Date.now() - 20_000;
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId,
|
|
name: packageName,
|
|
outputDir,
|
|
extractDir,
|
|
status: "downloading",
|
|
itemIds: [itemId],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
session.items[itemId] = {
|
|
id: itemId,
|
|
packageId,
|
|
url: "https://dummy/flat-duplicate-cleanup",
|
|
provider: "realdebrid",
|
|
status: "completed",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: archiveSize,
|
|
totalBytes: archiveSize,
|
|
progressPercent: 100,
|
|
fileName: "episode.zip",
|
|
targetPath: archivePath,
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Fertig",
|
|
createdAt,
|
|
updatedAt: createdAt
|
|
};
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
fs.mkdirSync(mkvLibraryDir, { recursive: true });
|
|
fs.writeFileSync(path.join(mkvLibraryDir, "Episode01.mkv"), Buffer.from("video"));
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "none"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await waitFor(() => !fs.existsSync(extractDir), 12000);
|
|
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, "Episode01.mkv"))).toBe(true);
|
|
expect(fs.existsSync(extractDir)).toBe(false);
|
|
}, 20000);
|
|
|
|
it("cleans duplicate-skipped MKV source trees with link and residual artifacts", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const packageName = "Flat-Duplicate-Cleanup-Extended";
|
|
const {
|
|
session,
|
|
extractDir
|
|
} = createCompletedArchiveSessionFromArchive(root, packageName, [
|
|
{ name: "Season 1/Episode01.mkv", data: Buffer.from("video") },
|
|
{ name: "Season 1/episode.links.txt", data: Buffer.from("https://example.com/file") },
|
|
{ name: "Season 1/info.nfo", data: Buffer.from("info") },
|
|
{ name: "Season 1/sample/sample.mkv", data: Buffer.from("sample-video") },
|
|
{ name: "Season 1/sample/readme.txt", data: Buffer.from("sample-text") }
|
|
]);
|
|
|
|
const mkvLibraryDir = path.join(root, "mkv-library");
|
|
fs.mkdirSync(mkvLibraryDir, { recursive: true });
|
|
fs.writeFileSync(path.join(mkvLibraryDir, "Episode01.mkv"), Buffer.from("video"));
|
|
|
|
new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir,
|
|
removeLinkFilesAfterExtract: true,
|
|
removeSamplesAfterExtract: true,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "delete"
|
|
},
|
|
session,
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
await waitFor(() => !fs.existsSync(extractDir), 12000);
|
|
|
|
expect(fs.existsSync(path.join(mkvLibraryDir, "Episode01.mkv"))).toBe(true);
|
|
expect(fs.existsSync(extractDir)).toBe(false);
|
|
}, 20000);
|
|
|
|
it("waits for deferred archive cleanup before package_done removal without MKV collection", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const zip = new AdmZip();
|
|
zip.addFile("Episode01.mkv", Buffer.from("video"));
|
|
zip.addFile("padding.bin", crypto.randomBytes(8 * 1024));
|
|
const archiveBinary = zip.toBuffer();
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/cleanup-archives-only") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(archiveBinary.length));
|
|
res.end(archiveBinary);
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/cleanup-archives-only`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "cleanup-archives-only.zip",
|
|
filesize: archiveBinary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const outputRoot = path.join(root, "downloads");
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: outputRoot,
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: true,
|
|
autoRename4sf4sj: false,
|
|
collectMkvToLibrary: false,
|
|
enableIntegrityCheck: false,
|
|
cleanupMode: "delete",
|
|
completedCleanupPolicy: "package_done"
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "cleanup-archives-only", links: ["https://dummy/cleanup-archives-only"] }]);
|
|
await manager.start();
|
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
|
await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000);
|
|
|
|
expect(fs.existsSync(path.join(outputRoot, "cleanup-archives-only", "cleanup-archives-only.zip"))).toBe(false);
|
|
expect(Object.keys(manager.getSnapshot().session.items)).toHaveLength(0);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 35000);
|
|
|
|
it("throws a controlled error for invalid queue import JSON", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
expect(() => manager.importQueue("{not-json")).toThrow(/Ungultige Queue-Datei/i);
|
|
});
|
|
|
|
it("imports structured text exports and preserves package names and file hints", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const result = manager.importQueue([
|
|
"# rd-link-export: 1",
|
|
"# package: Dave Staffel 1",
|
|
"# file: Dave.S01E01.rar",
|
|
"https://example.com/e01",
|
|
"# file: Dave.S01E02.rar",
|
|
"https://example.com/e02"
|
|
].join("\n"));
|
|
|
|
expect(result).toEqual({ addedPackages: 1, addedLinks: 2 });
|
|
|
|
const snapshot = manager.getSnapshot();
|
|
const packageId = snapshot.session.packageOrder[0];
|
|
const pkg = snapshot.session.packages[packageId];
|
|
expect(pkg?.name).toBe("Dave Staffel 1");
|
|
const importedItems = pkg?.itemIds.map((itemId) => snapshot.session.items[itemId]);
|
|
expect(importedItems?.map((item) => item?.fileName)).toEqual(["Dave.S01E01.rar", "Dave.S01E02.rar"]);
|
|
});
|
|
|
|
it("applies global speed limit path when global mode is enabled", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
speedLimitEnabled: true,
|
|
speedLimitMode: "global",
|
|
speedLimitKbps: 512
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const internal = manager as unknown as {
|
|
applySpeedLimit: (chunkBytes: number, localWindowBytes: number, localWindowStarted: number) => Promise<void>;
|
|
globalSpeedLimitNextAt: number;
|
|
};
|
|
|
|
const start = Date.now();
|
|
await internal.applySpeedLimit(1024, 0, start);
|
|
expect(internal.globalSpeedLimitNextAt).toBeGreaterThan(start);
|
|
});
|
|
|
|
it("resets speed window head when start finds no runnable items", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const internal = manager as unknown as {
|
|
speedEvents: Array<{ at: number; bytes: number }>;
|
|
speedEventsHead: number;
|
|
speedBytesLastWindow: number;
|
|
};
|
|
internal.speedEvents = [{ at: Date.now() - 10_000, bytes: 999 }];
|
|
internal.speedEventsHead = 5;
|
|
internal.speedBytesLastWindow = 999;
|
|
|
|
await manager.start();
|
|
expect(internal.speedEventsHead).toBe(0);
|
|
expect(internal.speedEvents.length).toBe(0);
|
|
expect(internal.speedBytesLastWindow).toBe(0);
|
|
});
|
|
|
|
it("does not trigger global stall abort while write-buffer is disk-blocked", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const previousGlobalWatchdog = process.env.RD_GLOBAL_STALL_TIMEOUT_MS;
|
|
process.env.RD_GLOBAL_STALL_TIMEOUT_MS = "2500";
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "disk-block-guard", links: ["https://dummy/disk-block-guard"] }]);
|
|
const snapshot = manager.getSnapshot();
|
|
const packageId = snapshot.session.packageOrder[0] || "";
|
|
const itemId = snapshot.session.packages[packageId]?.itemIds[0] || "";
|
|
|
|
const internal = manager as unknown as any;
|
|
internal.session.running = true;
|
|
internal.session.paused = false;
|
|
internal.session.reconnectUntil = 0;
|
|
internal.session.totalDownloadedBytes = 0;
|
|
internal.session.items[itemId].status = "downloading";
|
|
internal.lastGlobalProgressBytes = 0;
|
|
internal.lastGlobalProgressAt = Date.now() - 10000;
|
|
|
|
const abortController = new AbortController();
|
|
internal.activeTasks.set(itemId, {
|
|
itemId,
|
|
packageId,
|
|
abortController,
|
|
abortReason: "none",
|
|
resumable: true,
|
|
nonResumableCounted: false,
|
|
blockedOnDiskWrite: true,
|
|
blockedOnDiskSince: Date.now() - 5000
|
|
});
|
|
|
|
internal.runGlobalStallWatchdog(Date.now());
|
|
|
|
expect(abortController.signal.aborted).toBe(false);
|
|
expect(internal.lastGlobalProgressAt).toBeGreaterThan(Date.now() - 2000);
|
|
} finally {
|
|
if (previousGlobalWatchdog === undefined) {
|
|
delete process.env.RD_GLOBAL_STALL_TIMEOUT_MS;
|
|
} else {
|
|
process.env.RD_GLOBAL_STALL_TIMEOUT_MS = previousGlobalWatchdog;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("cleans run tracking when start conflict is skipped", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "conflict-skip", links: ["https://dummy/skip"] }]);
|
|
const snapshot = manager.getSnapshot();
|
|
const packageId = snapshot.session.packageOrder[0];
|
|
const itemId = snapshot.session.packages[packageId]?.itemIds[0] || "";
|
|
|
|
const internal = manager as unknown as {
|
|
runItemIds: Set<string>;
|
|
runPackageIds: Set<string>;
|
|
runOutcomes: Map<string, "completed" | "failed" | "cancelled">;
|
|
};
|
|
internal.runItemIds.add(itemId);
|
|
internal.runPackageIds.add(packageId);
|
|
internal.runOutcomes.set(itemId, "completed");
|
|
|
|
const result = await manager.resolveStartConflict(packageId, "skip");
|
|
expect(result.skipped).toBe(true);
|
|
expect(internal.runItemIds.has(itemId)).toBe(false);
|
|
expect(internal.runPackageIds.has(packageId)).toBe(false);
|
|
expect(internal.runOutcomes.has(itemId)).toBe(false);
|
|
});
|
|
|
|
it("clears stale run outcomes on overwrite conflict resolution", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "conflict-overwrite", links: ["https://dummy/overwrite"] }]);
|
|
const snapshot = manager.getSnapshot();
|
|
const packageId = snapshot.session.packageOrder[0];
|
|
const itemId = snapshot.session.packages[packageId]?.itemIds[0] || "";
|
|
|
|
const internal = manager as unknown as {
|
|
runOutcomes: Map<string, "completed" | "failed" | "cancelled">;
|
|
};
|
|
internal.runOutcomes.set(itemId, "failed");
|
|
|
|
const result = await manager.resolveStartConflict(packageId, "overwrite");
|
|
expect(result.overwritten).toBe(true);
|
|
expect(internal.runOutcomes.has(itemId)).toBe(false);
|
|
expect(manager.getSnapshot().session.items[itemId]?.status).toBe("queued");
|
|
});
|
|
|
|
it("clears speed display buffers when run finishes", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract")
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const internal = manager as unknown as {
|
|
runItemIds: Set<string>;
|
|
runOutcomes: Map<string, "completed" | "failed" | "cancelled">;
|
|
runCompletedPackages: Set<string>;
|
|
session: { runStartedAt: number; totalDownloadedBytes: number; running: boolean; paused: boolean };
|
|
speedEvents: Array<{ at: number; bytes: number }>;
|
|
speedEventsHead: number;
|
|
speedBytesLastWindow: number;
|
|
finishRun: () => void;
|
|
};
|
|
|
|
internal.session.running = true;
|
|
internal.session.paused = false;
|
|
internal.session.runStartedAt = Date.now() - 2000;
|
|
internal.session.totalDownloadedBytes = 4096;
|
|
internal.runItemIds = new Set(["x"]);
|
|
internal.runOutcomes = new Map([["x", "completed"]]);
|
|
internal.runCompletedPackages = new Set();
|
|
internal.speedEvents = [{ at: Date.now(), bytes: 4096 }];
|
|
internal.speedEventsHead = 1;
|
|
internal.speedBytesLastWindow = 4096;
|
|
|
|
internal.finishRun();
|
|
|
|
expect(internal.speedEvents.length).toBe(0);
|
|
expect(internal.speedEventsHead).toBe(0);
|
|
expect(internal.speedBytesLastWindow).toBe(0);
|
|
});
|
|
|
|
it("tracks daily usage on the actual provider key without touching other providers", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
megaLogin: "mega-user",
|
|
megaPassword: "mega-pass",
|
|
megaCredentials: "mega-user:mega-pass",
|
|
megaDebridApiEnabled: true,
|
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
|
providerDailyUsageBytes: { realdebrid: 512 },
|
|
providerTotalUsageBytes: { realdebrid: 2048 }
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const internal = manager as unknown as {
|
|
recordProviderDownloadedBytes: (provider: "megadebrid", bytes: number) => void;
|
|
settings: ReturnType<typeof defaultSettings>;
|
|
};
|
|
|
|
internal.recordProviderDownloadedBytes("megadebrid", 1024);
|
|
|
|
expect(internal.settings.providerDailyUsageBytes.realdebrid).toBe(512);
|
|
expect(internal.settings.providerDailyUsageBytes["megadebrid-api"]).toBe(1024);
|
|
expect((internal.settings.providerDailyUsageBytes as Record<string, number>).megadebrid).toBeUndefined();
|
|
expect(internal.settings.providerTotalUsageBytes.realdebrid).toBe(2048);
|
|
expect(internal.settings.providerTotalUsageBytes["megadebrid-api"]).toBe(1024);
|
|
expect((internal.settings.providerTotalUsageBytes as Record<string, number>).megadebrid).toBeUndefined();
|
|
});
|
|
|
|
it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const [firstKey, secondKey] = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
|
providerDailyUsageBytes: { debridlink: 256 },
|
|
providerTotalUsageBytes: { debridlink: 4096 },
|
|
debridLinkApiKeyDailyUsageBytes: { [secondKey.id]: 512 },
|
|
debridLinkApiKeyTotalUsageBytes: { [secondKey.id]: 2048 }
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
const internal = manager as unknown as {
|
|
recordProviderDownloadedBytes: (provider: "debridlink", bytes: number, providerAccountId?: string) => void;
|
|
settings: ReturnType<typeof defaultSettings>;
|
|
};
|
|
|
|
internal.recordProviderDownloadedBytes("debridlink", 1024, firstKey.id);
|
|
|
|
expect(internal.settings.providerDailyUsageBytes.debridlink).toBe(1280);
|
|
expect(internal.settings.providerTotalUsageBytes.debridlink).toBe(5120);
|
|
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[firstKey.id]).toBe(1024);
|
|
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512);
|
|
expect(internal.settings.debridLinkApiKeyTotalUsageBytes[firstKey.id]).toBe(1024);
|
|
expect(internal.settings.debridLinkApiKeyTotalUsageBytes[secondKey.id]).toBe(2048);
|
|
});
|
|
|
|
it("does not hang when rapid stop is followed by disabling the last provider", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
tempDirs.push(root);
|
|
const binary = Buffer.alloc(256 * 1024, 7);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/slow-dl") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(binary.length));
|
|
res.write(binary.subarray(0, Math.floor(binary.length / 4)));
|
|
const timer = setTimeout(() => {
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
res.end(binary.subarray(Math.floor(binary.length / 4)));
|
|
}
|
|
}, 5000);
|
|
res.on("close", () => clearTimeout(timer));
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/slow-dl`;
|
|
|
|
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("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
download: directUrl,
|
|
filename: "test-file.bin",
|
|
filesize: binary.length
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
maxParallel: 1,
|
|
autoReconnect: false,
|
|
retryLimit: 1
|
|
};
|
|
|
|
try {
|
|
const manager = new DownloadManager(
|
|
settings,
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
|
|
manager.addPackages([{ name: "hang-test", links: ["https://dummy/hang-test"] }]);
|
|
|
|
await manager.start();
|
|
await waitFor(() => {
|
|
const items = Object.values(manager.getSnapshot().session.items);
|
|
return items.some((item) => item.status === "downloading");
|
|
}, 12000);
|
|
|
|
manager.stop();
|
|
|
|
manager.setSettings({
|
|
...settings,
|
|
disabledProviders: ["realdebrid"]
|
|
});
|
|
|
|
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000));
|
|
const result = await Promise.race([
|
|
manager.start().then(() => "started" as const, (error) => String(error)),
|
|
timeout
|
|
]);
|
|
expect(result).toContain("Kein aktiver Download-Account verfügbar");
|
|
expect(manager.getSnapshot().session.running).toBe(false);
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
}
|
|
}, 30000);
|
|
|
|
it("bulk-adds large DLC containers without initializing per-item logs (avoids 1-2 min sync-FS freeze)", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bulk-add-"));
|
|
tempDirs.push(root);
|
|
const stateDir = path.join(root, "state");
|
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
initPackageLogs(stateDir);
|
|
initItemLogs(stateDir);
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(stateDir)
|
|
);
|
|
|
|
const packages = Array.from({ length: 60 }, (_, pkgIdx) => ({
|
|
name: `bulk-pkg-${pkgIdx}`,
|
|
links: Array.from({ length: 25 }, (_, linkIdx) => `https://dummy/bulk-${pkgIdx}-${linkIdx}.rar`)
|
|
}));
|
|
|
|
const tStart = Date.now();
|
|
const result = manager.addPackages(packages);
|
|
const elapsedMs = Date.now() - tStart;
|
|
|
|
expect(result.addedPackages).toBe(60);
|
|
expect(result.addedLinks).toBe(1500);
|
|
|
|
expect(elapsedMs).toBeLessThan(5000);
|
|
|
|
const itemLogsDir = path.join(stateDir, "item-logs");
|
|
const itemLogFiles = fs.existsSync(itemLogsDir)
|
|
? fs.readdirSync(itemLogsDir).filter((f) => f.startsWith("item_") && f.endsWith(".txt"))
|
|
: [];
|
|
expect(itemLogFiles.length).toBe(0);
|
|
|
|
const packageLogsDir = path.join(stateDir, "package-logs");
|
|
const pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt"));
|
|
expect(pkgLogFiles.length).toBe(60);
|
|
});
|
|
|
|
it("emits running large-queue state updates within 500 ms", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-state-cadence-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
defaultSettings(),
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const internal = manager as unknown as {
|
|
session: { running: boolean };
|
|
itemCount: number;
|
|
emitState: () => void;
|
|
};
|
|
internal.session.running = true;
|
|
internal.itemCount = 1_500;
|
|
let emitted = 0;
|
|
manager.on("state", () => {
|
|
emitted += 1;
|
|
});
|
|
|
|
internal.emitState();
|
|
await vi.advanceTimersByTimeAsync(499);
|
|
expect(emitted).toBe(0);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(emitted).toBe(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("limits running small-queue telemetry to one update per 500 ms", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-state-small-cadence-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
defaultSettings(),
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const internal = manager as unknown as {
|
|
session: { running: boolean };
|
|
itemCount: number;
|
|
emitState: () => void;
|
|
};
|
|
internal.session.running = true;
|
|
internal.itemCount = 12;
|
|
let emitted = 0;
|
|
manager.on("state", () => {
|
|
emitted += 1;
|
|
});
|
|
|
|
internal.emitState();
|
|
await vi.advanceTimersByTimeAsync(499);
|
|
expect(emitted).toBe(0);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(emitted).toBe(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("coalesces account-rotation state events into the running 500 ms cadence", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rotation-state-cadence-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
defaultSettings(),
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
(manager as any).session.running = true;
|
|
let emitted = 0;
|
|
manager.on("state", () => {
|
|
emitted += 1;
|
|
});
|
|
|
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3", "TEST");
|
|
await vi.advanceTimersByTimeAsync(120);
|
|
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3", "FAILED");
|
|
await vi.advanceTimersByTimeAsync(120);
|
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3", "TEST");
|
|
await vi.advanceTimersByTimeAsync(259);
|
|
expect(emitted).toBe(0);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(emitted).toBe(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("coalesces forced running state events into the same 500 ms cadence", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-forced-state-cadence-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
defaultSettings(),
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const internal = manager as unknown as {
|
|
session: { running: boolean };
|
|
emitState: (force?: boolean) => void;
|
|
};
|
|
internal.session.running = true;
|
|
let emitted = 0;
|
|
manager.on("state", () => {
|
|
emitted += 1;
|
|
});
|
|
|
|
internal.emitState(true);
|
|
expect(emitted).toBe(1);
|
|
await vi.advanceTimersByTimeAsync(120);
|
|
internal.emitState(true);
|
|
await vi.advanceTimersByTimeAsync(120);
|
|
internal.emitState(true);
|
|
await vi.advanceTimersByTimeAsync(259);
|
|
expect(emitted).toBe(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(emitted).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("refreshes running large-queue statistics after 500 ms", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-stats-cadence-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
defaultSettings(),
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const internal = manager as unknown as {
|
|
session: { running: boolean };
|
|
itemCount: number;
|
|
sessionDownloadedBytes: number;
|
|
};
|
|
internal.session.running = true;
|
|
internal.itemCount = 500;
|
|
internal.sessionDownloadedBytes = 100;
|
|
|
|
expect(manager.getStats(1_000).totalDownloaded).toBe(100);
|
|
internal.sessionDownloadedBytes = 200;
|
|
expect(manager.getStats(1_499).totalDownloaded).toBe(100);
|
|
expect(manager.getStats(1_500).totalDownloaded).toBe(200);
|
|
});
|
|
|
|
it("serializes parallel auto-rename invocations for the same package (no Ziel existiert / ENOENT race)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-race-"));
|
|
tempDirs.push(root);
|
|
const stateDir = path.join(root, "state");
|
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
initPackageLogs(stateDir);
|
|
initItemLogs(stateDir);
|
|
initRenameLog(stateDir);
|
|
|
|
const extractDir = path.join(root, "extracted");
|
|
const episodes = [
|
|
{ folder: "Test.Show.S02E01.Pilot.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e01hd.mkv" },
|
|
{ folder: "Test.Show.S02E02.Second.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e02hd.mkv" },
|
|
{ folder: "Test.Show.S02E03.Third.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e03hd.mkv" }
|
|
];
|
|
for (const ep of episodes) {
|
|
const dir = path.join(extractDir, ep.folder);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, ep.file), Buffer.alloc(1024, 0));
|
|
}
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir,
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
autoRename4sf4sj: true
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(stateDir)
|
|
);
|
|
|
|
const pkg: any = {
|
|
id: "race-pkg-1",
|
|
name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake",
|
|
outputDir: path.join(root, "downloads", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"),
|
|
extractDir,
|
|
status: "completed",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
priority: "normal",
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
downloadStartedAt: 0,
|
|
downloadCompletedAt: 0
|
|
};
|
|
|
|
const [n1, n2] = await Promise.all([
|
|
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg),
|
|
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg)
|
|
]);
|
|
|
|
expect(typeof n1).toBe("number");
|
|
expect(typeof n2).toBe("number");
|
|
expect(n1 + n2).toBe(3);
|
|
|
|
for (const ep of episodes) {
|
|
const dir = path.join(extractDir, ep.folder);
|
|
const files = fs.readdirSync(dir);
|
|
const renamedFile = `${ep.folder}.mkv`;
|
|
expect(files).toContain(renamedFile);
|
|
expect(files).not.toContain(ep.file);
|
|
}
|
|
});
|
|
|
|
it("serializes rename and mkvMove across hybrid + deferred pipes (no ENOENT from cross-pipe race)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-crosspipe-"));
|
|
tempDirs.push(root);
|
|
const stateDir = path.join(root, "state");
|
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "out"), extractDir: path.join(root, "extract") },
|
|
emptySession(),
|
|
createStoragePaths(stateDir)
|
|
);
|
|
|
|
const pkgId = "crosspipe-pkg-1";
|
|
let concurrent = 0;
|
|
let maxConcurrent = 0;
|
|
const op = async (ms: number): Promise<string> => {
|
|
concurrent += 1;
|
|
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
await new Promise((r) => setTimeout(r, ms));
|
|
concurrent -= 1;
|
|
return `done-${ms}`;
|
|
};
|
|
|
|
const [r1, r2, r3, r4] = await Promise.all([
|
|
(manager as any).chainPackageFileOp(pkgId, () => op(40)),
|
|
(manager as any).chainPackageFileOp(pkgId, () => op(20)),
|
|
(manager as any).chainPackageFileOp(pkgId, () => op(30)),
|
|
(manager as any).chainPackageFileOp(pkgId, () => op(10))
|
|
]);
|
|
|
|
expect(r1).toBe("done-40");
|
|
expect(r2).toBe("done-20");
|
|
expect(r3).toBe("done-30");
|
|
expect(r4).toBe("done-10");
|
|
expect(maxConcurrent).toBe(1);
|
|
expect((manager as any).packageFileOpChain.has(pkgId)).toBe(false);
|
|
});
|
|
|
|
it("auto-rename refuses to scan when extractDir overlaps with mkvLibraryDir", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-"));
|
|
tempDirs.push(root);
|
|
const sharedDir = path.join(root, "library");
|
|
fs.mkdirSync(path.join(sharedDir, "EpisodeFolder"), { recursive: true });
|
|
fs.writeFileSync(path.join(sharedDir, "EpisodeFolder", "obfus.mkv"), Buffer.alloc(1024, 0));
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "out"),
|
|
extractDir: sharedDir,
|
|
mkvLibraryDir: sharedDir,
|
|
autoRename4sf4sj: true
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const pkg: any = {
|
|
id: "overlap-pkg",
|
|
name: "Overlap.Test.S01.GERMAN.x264-aWake",
|
|
outputDir: path.join(root, "out", "Overlap.Test"),
|
|
extractDir: sharedDir,
|
|
status: "completed",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
priority: "normal",
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
downloadStartedAt: 0,
|
|
downloadCompletedAt: 0
|
|
};
|
|
|
|
const renamed = await (manager as any).autoRenameExtractedVideoFiles(sharedDir, pkg);
|
|
expect(renamed).toBe(0);
|
|
expect(fs.existsSync(path.join(sharedDir, "EpisodeFolder", "obfus.mkv"))).toBe(true);
|
|
});
|
|
|
|
it("mkv-move moves SUBTITLES to library but NOT .nfo metadata files", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mvcompanion-"));
|
|
tempDirs.push(root);
|
|
const extractDir = path.join(root, "extract");
|
|
const libDir = path.join(root, "library");
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.mkdirSync(libDir, { recursive: true });
|
|
const epFolder = path.join(extractDir, "Show.S01E01.GERMAN.x264-GROUP");
|
|
fs.mkdirSync(epFolder, { recursive: true });
|
|
fs.writeFileSync(path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.mkv"), Buffer.alloc(1024, 0));
|
|
fs.writeFileSync(path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.srt"), "subs");
|
|
fs.writeFileSync(path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.nfo"), "info");
|
|
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "out"),
|
|
extractDir,
|
|
autoExtract: true,
|
|
collectMkvToLibrary: true,
|
|
mkvLibraryDir: libDir
|
|
},
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const pkg: any = {
|
|
id: "movecomp-pkg",
|
|
name: "Show.S01.GERMAN.x264-GROUP",
|
|
outputDir: path.join(root, "out", "Show"),
|
|
extractDir,
|
|
status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal",
|
|
createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0
|
|
};
|
|
|
|
await (manager as any).collectMkvFilesToLibrary("movecomp-pkg", pkg);
|
|
|
|
const libFiles = fs.readdirSync(libDir);
|
|
expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.mkv");
|
|
expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.srt");
|
|
expect(libFiles).not.toContain("Show.S01E01.GERMAN.x264-GROUP.nfo");
|
|
});
|
|
|
|
it("auto-rename also renames matching subtitle / .nfo companion files", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-companion-"));
|
|
tempDirs.push(root);
|
|
const extractDir = path.join(root, "extract");
|
|
const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake");
|
|
fs.mkdirSync(epFolder, { recursive: true });
|
|
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.mkv"), Buffer.alloc(1024, 0));
|
|
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.srt"), "subtitle");
|
|
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.de.srt"), "german subtitle");
|
|
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.nfo"), "info");
|
|
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "out"), extractDir, autoRename4sf4sj: true },
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const pkg: any = {
|
|
id: "companion-pkg",
|
|
name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake",
|
|
outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"),
|
|
extractDir,
|
|
status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal",
|
|
createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0
|
|
};
|
|
|
|
const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg);
|
|
expect(renamed).toBe(1);
|
|
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
|
const files = fs.readdirSync(epFolder);
|
|
expect(files).toContain(`${expectedBase}.mkv`);
|
|
expect(files).not.toContain("awa-testshow02e05hd.mkv");
|
|
expect(files).toContain(`${expectedBase}.srt`);
|
|
expect(files).toContain(`${expectedBase}.de.srt`);
|
|
expect(files).toContain(`${expectedBase}.nfo`);
|
|
expect(files).not.toContain("awa-testshow02e05hd.srt");
|
|
expect(files).not.toContain("awa-testshow02e05hd.de.srt");
|
|
expect(files).not.toContain("awa-testshow02e05hd.nfo");
|
|
});
|
|
|
|
it("auto-rename appends a numeric suffix when target already exists (no silent skip)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-collision-"));
|
|
tempDirs.push(root);
|
|
const extractDir = path.join(root, "extract");
|
|
const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake");
|
|
fs.mkdirSync(epFolder, { recursive: true });
|
|
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.mkv"), Buffer.alloc(1024, 0));
|
|
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.alt.mkv"), Buffer.alloc(2048, 0));
|
|
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "out"), extractDir, autoRename4sf4sj: true },
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const pkg: any = {
|
|
id: "collision-pkg",
|
|
name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake",
|
|
outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"),
|
|
extractDir,
|
|
status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal",
|
|
createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0
|
|
};
|
|
|
|
const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg);
|
|
expect(renamed).toBe(2);
|
|
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
|
const files = fs.readdirSync(epFolder).sort();
|
|
expect(files).toContain(`${expectedBase}.mkv`);
|
|
expect(files).toContain(`${expectedBase}.2.mkv`);
|
|
expect(files).not.toContain("awa-testshow02e05hd.mkv");
|
|
expect(files).not.toContain("awa-testshow02e05hd.alt.mkv");
|
|
});
|
|
|
|
it("chainPackageFileOp recovers from a failed op so subsequent ops still run", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-chain-recover-"));
|
|
tempDirs.push(root);
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "out"), extractDir: path.join(root, "extract") },
|
|
emptySession(),
|
|
createStoragePaths(path.join(root, "state"))
|
|
);
|
|
const pkgId = "recover-pkg";
|
|
const [failed, nextResult] = await Promise.allSettled([
|
|
(manager as any).chainPackageFileOp(pkgId, async () => { throw new Error("boom"); }),
|
|
(manager as any).chainPackageFileOp(pkgId, async () => "ok")
|
|
]);
|
|
expect(failed.status).toBe("rejected");
|
|
expect(nextResult.status).toBe("fulfilled");
|
|
if (nextResult.status === "fulfilled") {
|
|
expect(nextResult.value).toBe("ok");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("start conflict guard + selective resume", () => {
|
|
function makeItem(id: string, packageId: string, status: string, fileName: string): any {
|
|
return {
|
|
id, packageId, url: `https://hoster.example/${id}`, provider: "realdebrid",
|
|
status, retries: 0, speedBps: 0, downloadedBytes: status === "completed" ? 100 : 0,
|
|
totalBytes: status === "completed" ? 100 : null, progressPercent: status === "completed" ? 100 : 0,
|
|
fileName, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
|
|
createdAt: Date.now(), updatedAt: Date.now()
|
|
};
|
|
}
|
|
|
|
it("does not flag a partially-downloaded package whose extract dir holds its own completed output", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-own-"));
|
|
tempDirs.push(root);
|
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
|
initPackageLogs(storagePaths.baseDir);
|
|
initItemLogs(storagePaths.baseDir);
|
|
|
|
const session = emptySession();
|
|
const packageId = "pkg-own-output";
|
|
const extractDir = path.join(root, "extract", "OwnOutput");
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.writeFileSync(path.join(extractDir, "episode01.mkv"), Buffer.alloc(64, 7));
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId, name: "OwnOutput",
|
|
outputDir: path.join(root, "downloads", "OwnOutput"), extractDir,
|
|
status: "queued", itemIds: ["own-done", "own-pending"], cancelled: false, enabled: true,
|
|
createdAt: Date.now(), updatedAt: Date.now()
|
|
} as any;
|
|
session.items["own-done"] = makeItem("own-done", packageId, "completed", "done.rar");
|
|
session.items["own-pending"] = makeItem("own-pending", packageId, "queued", "pending.rar");
|
|
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
|
session, storagePaths
|
|
);
|
|
|
|
const conflicts = await manager.getStartConflicts();
|
|
expect(conflicts.map((c) => c.packageId)).not.toContain(packageId);
|
|
});
|
|
|
|
it("flags a fresh package when its package-specific extract dir already holds files and it has no completed items", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-fresh-"));
|
|
tempDirs.push(root);
|
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
|
initPackageLogs(storagePaths.baseDir);
|
|
initItemLogs(storagePaths.baseDir);
|
|
|
|
const session = emptySession();
|
|
const packageId = "pkg-fresh-conflict";
|
|
const extractDir = path.join(root, "extract", "FreshConflict");
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
fs.writeFileSync(path.join(extractDir, "old-from-previous-run.mkv"), Buffer.alloc(64, 9));
|
|
session.packageOrder = [packageId];
|
|
session.packages[packageId] = {
|
|
id: packageId, name: "FreshConflict",
|
|
outputDir: path.join(root, "downloads", "FreshConflict"), extractDir,
|
|
status: "queued", itemIds: ["fresh-pending"], cancelled: false, enabled: true,
|
|
createdAt: Date.now(), updatedAt: Date.now()
|
|
} as any;
|
|
session.items["fresh-pending"] = makeItem("fresh-pending", packageId, "queued", "fresh.rar");
|
|
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
|
session, storagePaths
|
|
);
|
|
|
|
const conflicts = await manager.getStartConflicts();
|
|
expect(conflicts.map((c) => c.packageId)).toContain(packageId);
|
|
});
|
|
|
|
it("start() holds excluded packages out of the run set and runs the rest", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selective-resume-"));
|
|
tempDirs.push(root);
|
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
|
initPackageLogs(storagePaths.baseDir);
|
|
initItemLogs(storagePaths.baseDir);
|
|
|
|
const session = emptySession();
|
|
const runId = "pkg-run";
|
|
const holdId = "pkg-hold";
|
|
fs.mkdirSync(path.join(root, "downloads", "RunMe"), { recursive: true });
|
|
fs.mkdirSync(path.join(root, "downloads", "HoldMe"), { recursive: true });
|
|
session.packageOrder = [runId, holdId];
|
|
session.packages[runId] = {
|
|
id: runId, name: "RunMe",
|
|
outputDir: path.join(root, "downloads", "RunMe"), extractDir: path.join(root, "extract", "RunMe"),
|
|
status: "queued", itemIds: ["run-item"], cancelled: false, enabled: true,
|
|
createdAt: Date.now(), updatedAt: Date.now()
|
|
} as any;
|
|
session.packages[holdId] = {
|
|
id: holdId, name: "HoldMe",
|
|
outputDir: path.join(root, "downloads", "HoldMe"), extractDir: path.join(root, "extract", "HoldMe"),
|
|
status: "queued", itemIds: ["hold-item"], cancelled: false, enabled: true,
|
|
createdAt: Date.now(), updatedAt: Date.now()
|
|
} as any;
|
|
session.items["run-item"] = makeItem("run-item", runId, "queued", "run.rar");
|
|
session.items["hold-item"] = makeItem("hold-item", holdId, "queued", "hold.rar");
|
|
|
|
const manager = new DownloadManager(
|
|
{ ...defaultSettings(), token: "rd-token", maxParallel: 2, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
|
session, storagePaths
|
|
);
|
|
(manager as any).debridService.unrestrictLink = () => new Promise(() => {});
|
|
|
|
await manager.start({ excludePackageIds: new Set([holdId]) });
|
|
|
|
expect((manager as any).runPackageIds.has(runId)).toBe(true);
|
|
expect((manager as any).runPackageIds.has(holdId)).toBe(false);
|
|
expect((manager as any).runItemIds.has("run-item")).toBe(true);
|
|
expect((manager as any).runItemIds.has("hold-item")).toBe(false);
|
|
expect(session.items["hold-item"].status).toBe("queued");
|
|
|
|
manager.stop();
|
|
});
|
|
});
|
|
|
|
describe("mega-debrid api/web resolution overlap gate", () => {
|
|
function megaApiSettings(root: string): any {
|
|
return {
|
|
...defaultSettings(),
|
|
megaLogin: "u", megaPassword: "p", megaCredentials: "u:p",
|
|
megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true,
|
|
outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract")
|
|
};
|
|
}
|
|
|
|
function megaItem(id: string, status: string): any {
|
|
return {
|
|
id, packageId: "pkg", url: `https://rapidgator.net/file/${id}`, provider: "megadebrid-api",
|
|
status, retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null, progressPercent: 0,
|
|
fileName: `${id}.rar`, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
|
|
createdAt: Date.now(), updatedAt: Date.now()
|
|
};
|
|
}
|
|
|
|
function addValidating(manager: DownloadManager, session: any, ids: string[]): void {
|
|
for (const id of ids) {
|
|
session.items[id] = megaItem(id, "validating");
|
|
(manager as any).activeTasks.set(id, {
|
|
itemId: id, packageId: "pkg", abortController: new AbortController(), abortReason: "none",
|
|
resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0
|
|
});
|
|
}
|
|
}
|
|
|
|
function buildManager(root: string, session: any): DownloadManager {
|
|
return new DownloadManager(megaApiSettings(root), session, createStoragePaths(path.join(root, "state")));
|
|
}
|
|
|
|
it("lets the first mega resolve start when nothing is in flight", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-0-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const candidate = megaItem("cand", "queued");
|
|
session.items["cand"] = candidate;
|
|
const manager = buildManager(root, session);
|
|
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
|
|
});
|
|
|
|
it("serializes a second API resolve while the first is still in its API phase (no concurrent API)", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-1-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const candidate = megaItem("cand", "queued");
|
|
session.items["cand"] = candidate;
|
|
const manager = buildManager(root, session);
|
|
addValidating(manager, session, ["a"]);
|
|
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
|
|
});
|
|
|
|
it("allows one API resolve to overlap once the first has moved to its web phase", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-2-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const candidate = megaItem("cand", "queued");
|
|
session.items["cand"] = candidate;
|
|
const manager = buildManager(root, session);
|
|
addValidating(manager, session, ["a"]);
|
|
primeMegaDebridInFlightForTests("acc:web", 1);
|
|
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
|
|
});
|
|
|
|
it("caps the overlap at one API plus one web (no third concurrent resolve)", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-3-"));
|
|
tempDirs.push(root);
|
|
const session = emptySession();
|
|
const candidate = megaItem("cand", "queued");
|
|
session.items["cand"] = candidate;
|
|
const manager = buildManager(root, session);
|
|
addValidating(manager, session, ["a", "b"]);
|
|
primeMegaDebridInFlightForTests("acc:web", 1);
|
|
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
|
|
});
|
|
});
|