Files
Multi-Debrid-Downloader/tests/download-manager.test.ts
T
Sucukdeluxe 58237bed0c fix: scope extraction follow-up work
Remove .rd-output staging and merge handling, parse validated native extractor output, and drive nested extraction, rename, audio, cleanup, residual handling, and library collection from package-owned output records. Preserve provenance migration, package pruning, and run-owned waiter isolation while allowing one bounded legacy adoption scan only for provably exclusive package roots.
2026-08-22 13:54:25 +02:00

15809 lines
563 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, resolveArchiveItemsFromList, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager";
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
import { DiskReservationCoordinator } from "../src/main/disk-space";
import { defaultSettings } from "../src/main/constants";
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 } from "../src/main/storage";
import { loadStatisticsLedger } from "../src/main/statistics-ledger";
import { getProviderRuntimeSnapshot, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests, primeRealDebridRuntimeCooldownForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
import { UnrestrictedLink } from "../src/main/realdebrid";
import { resetVideoToolingCache } from "../src/main/video-processor";
import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor";
import type { AppSettings, DownloadItem, 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("download live update cadence", () => {
it.each([69, 661, 2_470])("emits a running queue snapshot no sooner than 750 ms for %i items", async (itemCount) => {
vi.useFakeTimers();
try {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-live-cadence-"));
tempDirs.push(root);
const session = emptySession();
session.running = true;
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
const internal = manager as unknown as {
emitState: () => void;
itemCount: number;
session: typeof session;
stateEmitTimer: NodeJS.Timeout | null;
};
internal.itemCount = itemCount;
internal.session.running = true;
if (internal.stateEmitTimer) {
clearTimeout(internal.stateEmitTimer);
internal.stateEmitTimer = null;
}
let emitted = 0;
manager.on("state", () => {
emitted += 1;
});
internal.emitState();
await vi.advanceTimersByTimeAsync(749);
expect(emitted).toBe(0);
await vi.advanceTimersByTimeAsync(1);
expect(emitted).toBe(1);
} finally {
vi.useRealTimers();
}
});
});
describe("resolveUnrestrictTimeoutBudgetMs", () => {
it("covers the complete provider plan and each later Real-Debrid account attempt", () => {
const settings = {
...defaultSettings(),
debridLinkApiKeys: "dl-key",
bestToken: "best-token",
realDebridApiTokens: serializeRealDebridApiAccounts([
{ id: "rda_one", token: "token-one" },
{ id: "rda_two", token: "token-two" },
{ id: "rda_three", token: "token-three" }
]),
realDebridDisabledAccountIds: ["rda_three"],
providerOrder: ["debridlink", "realdebrid", "bestdebrid"] as const,
autoProviderFallback: true
};
expect(resolveUnrestrictTimeoutBudgetMs(5_000, "debridlink", settings, "https://hoster.example/file", 35_000)).toBe(80_000);
});
it("includes a Real-Debrid pool selected only by hoster routing", () => {
const settings = {
...defaultSettings(),
debridLinkApiKeys: "dl-key",
realDebridApiTokens: serializeRealDebridApiAccounts([
{ id: "rda_one", token: "token-one" },
{ id: "rda_two", token: "token-two" }
]),
providerOrder: ["debridlink"] as const,
hosterRouting: { rapidgator: "realdebrid" as const },
autoProviderFallback: true
};
expect(resolveUnrestrictTimeoutBudgetMs(
5_000,
null,
settings,
"https://rapidgator.net/file/abc123/file.rar.html",
35_000
)).toBeGreaterThanOrEqual(70_000);
});
});
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: 1_024,
retriesUsed: 0,
provider: "realdebrid",
providerLabel: "Real-Debrid"
});
globalThis.fetch = vi.fn(async () => new Response(Buffer.alloc(16, 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
}));
});
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);
});
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 outputScope = (manager as any).getPackageOutputScope(pkg);
outputScope.add({
version: 1,
archivePath: path.join(pkg.outputDir, "archive.rar"),
entryPath: path.basename(sourcePath),
outputPath: sourcePath,
state: "complete",
disposition: "written"
});
const processed = await (manager as any).keepGermanAudioOnlyImpl(extractDir, outputScope, 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);
expect(pkg.remuxOperations).toHaveLength(1);
expect(pkg.remuxOperations?.[0]).toMatchObject({
fileName: "Show.S01E01.German.DL.720p.mkv",
status: "failed",
errorCategory: "disk_full"
});
expect(pkg.remuxOperations?.[0].completedAt).toBeGreaterThanOrEqual(pkg.remuxOperations?.[0].startedAt || 0);
expect(pkg.remuxOperations?.[0].durationMs).toBeGreaterThanOrEqual(0);
});
});
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("does not revive the legacy Real-Debrid login when every concrete pool account is disabled", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-pool-gate-"));
tempDirs.push(root);
const firstId = "rdw_first";
const secondId = "rdw_second";
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridUseWebLogin: true,
realDebridWebAccountIds: [firstId, secondId],
realDebridDisabledAccountIds: [firstId, secondId],
providerOrder: ["realdebrid"]
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
expect(manager.getSnapshot().canStart).toBe(false);
await expect(manager.start()).rejects.toThrow("Kein aktiver Download-Account verfügbar");
});
it("makes an explicitly re-enabled Real-Debrid account immediately usable after an old cooldown", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-reenabled-cooldown-gate-"));
tempDirs.push(root);
const accountId = "rdw_reenabled";
const disabledSettings: AppSettings = {
...defaultSettings(),
realDebridUseWebLogin: true,
realDebridWebAccountIds: [accountId],
realDebridDisabledAccountIds: [accountId],
disabledProviders: ["realdebrid"],
providerOrder: ["realdebrid"]
};
const manager = new DownloadManager(
disabledSettings,
emptySession(),
createStoragePaths(path.join(root, "state"))
);
primeRealDebridRuntimeCooldownForTests(accountId, 60_000, "Vorheriger Accountfehler");
expect(manager.getSnapshot().canStart).toBe(false);
expect(getProviderRuntimeSnapshot().realDebrid.accounts.find((entry) => entry.accountId === accountId)?.cooldown).not.toBeNull();
manager.setSettings({
...disabledSettings,
realDebridDisabledAccountIds: [],
disabledProviders: []
});
expect(manager.getSnapshot().canStart).toBe(true);
expect(getProviderRuntimeSnapshot().realDebrid.accounts.find((entry) => entry.accountId === accountId)?.cooldown ?? null).toBeNull();
});
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("deterministic stop and restart lifecycle", () => {
it("does not let a start recovery revive a run after stop invalidated its generation", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-start-recovery-generation-"));
tempDirs.push(root);
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", autoExtract: false },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "generation", links: ["https://rapidgator.net/file/generation"] }]);
let finishRecovery!: (recovered: number) => void;
const internal = manager as unknown as {
recoverRetryableItems: () => Promise<number>;
};
internal.recoverRetryableItems = () => new Promise<number>((resolve) => {
finishRecovery = resolve;
});
const starting = manager.start();
expect(finishRecovery).toBeTypeOf("function");
manager.stop();
finishRecovery(0);
await starting;
expect(manager.getSnapshot()).toMatchObject({
session: { running: false },
lifecycle: { phase: "idle", pendingStart: false }
});
});
it("accepts one pending start during stopping and dispatches it after the old download drains", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pending-start-drain-"));
tempDirs.push(root);
const accountId = "rdw_pending_start";
const attempts: AbortSignal[] = [];
let finishFirstAbort!: () => void;
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridUseWebLogin: true,
realDebridWebAccountIds: [accountId],
providerOrder: ["realdebrid"],
autoExtract: false,
maxParallel: 1
},
emptySession(),
createStoragePaths(path.join(root, "state")),
{
realDebridWebUnrestrict: async (_requestedAccountId, _link, signal) => {
if (!signal) {
throw new Error("missing abort signal");
}
attempts.push(signal);
return new Promise<UnrestrictedLink | null>((_resolve, reject) => {
const rejectAborted = () => {
if (attempts.length === 1) {
finishFirstAbort = () => reject(new Error("aborted:test-web"));
}
};
if (signal.aborted) {
rejectAborted();
} else {
signal.addEventListener("abort", rejectAborted, { once: true });
}
});
}
}
);
manager.addPackages([{ name: "pending", links: ["https://rapidgator.net/file/pending"] }]);
await manager.start();
await waitFor(() => attempts.length === 1);
manager.stop();
await manager.start();
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "stopping", pendingStart: true });
manager.stop();
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "stopping", pendingStart: true });
finishFirstAbort();
await waitFor(() => attempts.length === 2);
expect(manager.getSnapshot()).toMatchObject({
session: { running: true },
lifecycle: { phase: "running", pendingStart: false }
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(attempts).toHaveLength(2);
manager.stop();
});
it.each(["packages", "items"] as const)("keeps a targeted %s start pending until the stopped run drains", async (scope) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-pending-${scope}-drain-`));
tempDirs.push(root);
const accountId = `rdw_pending_${scope}`;
const attempts: Array<{ link: string; signal: AbortSignal }> = [];
let finishFirstAbort!: () => void;
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridUseWebLogin: true,
realDebridWebAccountIds: [accountId],
providerOrder: ["realdebrid"],
autoExtract: false,
maxParallel: 1
},
emptySession(),
createStoragePaths(path.join(root, "state")),
{
realDebridWebUnrestrict: async (_requestedAccountId, link, signal) => {
if (!signal) {
throw new Error("missing abort signal");
}
attempts.push({ link, signal });
return new Promise<UnrestrictedLink | null>((_resolve, reject) => {
const rejectAborted = () => {
if (attempts.length === 1) {
finishFirstAbort = () => reject(new Error("aborted:test-web"));
}
};
if (signal.aborted) {
rejectAborted();
} else {
signal.addEventListener("abort", rejectAborted, { once: true });
}
});
}
}
);
manager.addPackages([
{ name: "old-run", links: ["https://rapidgator.net/file/old-run"] },
{ name: "target-run", links: ["https://rapidgator.net/file/target-run"] }
]);
const snapshot = manager.getSnapshot();
const oldPackageId = snapshot.session.packageOrder[0];
const targetPackageId = snapshot.session.packageOrder[1];
const targetItemId = snapshot.session.packages[targetPackageId].itemIds[0];
await manager.startPackages([oldPackageId]);
await waitFor(() => attempts.length === 1);
manager.stop();
if (scope === "packages") {
await manager.startPackages([targetPackageId]);
} else {
await manager.startItems([targetItemId]);
}
expect(manager.getSnapshot()).toMatchObject({
session: { running: false },
lifecycle: { phase: "stopping", pendingStart: true }
});
finishFirstAbort();
await waitFor(() => attempts.length === 2);
expect(attempts[1].link).toContain("target-run");
expect((manager as any).runPackageIds).toEqual(new Set([targetPackageId]));
manager.stop();
});
it("keeps a newer task owner when cleanup from the previous generation arrives late", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-owner-generation-"));
tempDirs.push(root);
const accountId = "rdw_owner_generation";
let rejectUnrestrict!: (error: Error) => void;
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridUseWebLogin: true,
realDebridWebAccountIds: [accountId],
providerOrder: ["realdebrid"],
autoExtract: false,
maxParallel: 1
},
emptySession(),
createStoragePaths(path.join(root, "state")),
{
realDebridWebUnrestrict: async () => new Promise<UnrestrictedLink | null>((_resolve, reject) => {
rejectUnrestrict = reject;
})
}
);
manager.addPackages([{ name: "owner", links: ["https://rapidgator.net/file/owner"] }]);
const snapshot = manager.getSnapshot();
const packageId = snapshot.session.packageOrder[0];
const itemId = snapshot.session.packages[packageId].itemIds[0];
const internal = manager as unknown as {
activeTasks: Map<string, { abortController: AbortController; abortReason: string }>;
startItem: (packageId: string, itemId: string) => void;
};
internal.startItem(packageId, itemId);
await waitFor(() => rejectUnrestrict !== undefined);
const oldOwner = internal.activeTasks.get(itemId)!;
const newOwner = { ...oldOwner, abortController: new AbortController(), abortReason: "none" };
internal.activeTasks.set(itemId, newOwner);
rejectUnrestrict(new Error("aborted:late-owner"));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(internal.activeTasks.get(itemId)).toBe(newOwner);
});
it("emits an idle snapshot when the earliest provider cooldown expires", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T08:00:00.000Z"));
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-provider-cooldown-event-"));
tempDirs.push(root);
const accountId = "rda_cooldown_event";
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
providerOrder: ["realdebrid"],
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "cooldown", links: ["https://rapidgator.net/file/cooldown"] }]);
const internal = manager as unknown as { stateEmitTimer: NodeJS.Timeout | null };
if (internal.stateEmitTimer) {
clearTimeout(internal.stateEmitTimer);
internal.stateEmitTimer = null;
}
primeRealDebridRuntimeCooldownForTests(accountId, 1_000);
const events: Array<ReturnType<typeof manager.getSnapshot>> = [];
manager.on("state", (snapshot) => events.push(snapshot));
const waiting = manager.getSnapshot();
expect(waiting).toMatchObject({
canStart: false,
lifecycle: {
phase: "waiting_provider",
retryAt: Date.parse("2026-08-22T08:00:01.000Z")
}
});
await vi.advanceTimersByTimeAsync(999);
expect(events.some((snapshot) => snapshot.canStart)).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(events.at(-1)).toMatchObject({
canStart: true,
lifecycle: { phase: "idle", retryAt: null }
});
vi.useRealTimers();
});
it("ignores cooldowns from disabled accounts when projecting provider wait state", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-cooldown-context-"));
tempDirs.push(root);
const accountId = "rda_disabled_cooldown";
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
realDebridDisabledAccountIds: [accountId],
providerOrder: ["realdebrid"],
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "disabled", links: ["https://rapidgator.net/file/disabled"] }]);
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
expect(manager.getSnapshot()).toMatchObject({
canStart: false,
lifecycle: { phase: "idle", retryAt: null }
});
});
it("ignores a primary provider cooldown when an alternative provider can start the queue", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alternative-provider-context-"));
tempDirs.push(root);
const accountId = "rda_alternative_provider";
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
allDebridToken: "all-debrid-token",
providerOrder: ["realdebrid", "alldebrid"],
autoProviderFallback: true,
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "alternative", links: ["https://rapidgator.net/file/alternative"] }]);
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
expect(manager.getSnapshot()).toMatchObject({
canStart: true,
lifecycle: { phase: "idle", retryAt: null }
});
});
it("uses an available secondary provider for cooldown projection even when automatic failure fallback is disabled", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-fallback-provider-context-"));
tempDirs.push(root);
const accountId = "rda_disabled_fallback_secondary";
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
allDebridToken: "all-debrid-token",
providerOrder: ["realdebrid", "alldebrid"],
autoProviderFallback: false,
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "secondary", links: ["https://rapidgator.net/file/secondary"] }]);
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
expect(manager.getSnapshot()).toMatchObject({
canStart: true,
lifecycle: { phase: "idle", retryAt: null }
});
});
it.each([
{
name: "1Fichier",
link: "https://1fichier.com/?direct123",
settings: { oneFichierApiKey: "onefichier-key" }
},
{
name: "DDownload",
link: "https://ddownload.com/abc12345/direct.bin",
settings: { ddownloadLogin: "user", ddownloadPassword: "password" }
}
])("does not project a Real-Debrid cooldown over the direct $name path", ({ link, settings: directSettings }) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-direct-provider-context-"));
tempDirs.push(root);
const accountId = `rda_direct_${path.basename(root)}`;
const manager = new DownloadManager(
{
...defaultSettings(),
...directSettings,
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
providerOrder: ["realdebrid"],
autoProviderFallback: false,
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "direct", links: [link] }]);
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
expect(manager.getSnapshot()).toMatchObject({
canStart: true,
lifecycle: { phase: "idle", retryAt: null }
});
});
it("does not project a Mega-Debrid retry when the shared Mega provider alias is disabled", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-mega-alias-context-"));
tempDirs.push(root);
const login = "disabled-mega@example.test";
const accountId = getMegaDebridAccountId(login);
const manager = new DownloadManager(
{
...defaultSettings(),
megaDebridApiCredentials: `${login}:password`,
megaDebridApiEnabled: true,
disabledProviders: ["megadebrid"],
providerOrder: ["megadebrid"],
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "mega-disabled", links: ["https://rapidgator.net/file/mega-disabled"] }]);
primeMegaDebridRuntimeCooldownForTests(`${accountId}:api`, 60_000);
expect(manager.getSnapshot()).toMatchObject({
canStart: false,
lifecycle: { phase: "idle", retryAt: null }
});
});
it("ignores cooldowns for another provider or hoster and for pure post-processing", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-irrelevant-cooldown-context-"));
tempDirs.push(root);
const manager = new DownloadManager(
{
...defaultSettings(),
oneFichierApiKey: "onefichier-key",
providerOrder: ["onefichier"],
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "other-hoster", links: ["https://1fichier.com/?other-hoster"] }]);
const internal = manager as unknown as {
packagePostProcessTasks: Map<string, Promise<void>>;
providerFailures: Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>;
session: ReturnType<typeof manager.getSession>;
};
internal.providerFailures.set("alldebrid:rapidgator", {
count: 20,
lastFailAt: Date.now(),
cooldownUntil: Date.now() + 60_000
});
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "idle", retryAt: null });
const itemId = Object.keys(internal.session.items)[0];
internal.session.items[itemId].status = "completed";
internal.packagePostProcessTasks.set("postprocess-only", new Promise<void>(() => {}));
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "postprocessing", retryAt: null });
});
it("keeps post-processing drain visible and starts the pending run after its abort settles", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-stop-drain-"));
tempDirs.push(root);
const packageId = "postprocess-drain";
const session = emptySession();
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "postprocess-drain",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
status: "extracting",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: Date.now(),
updatedAt: Date.now()
};
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", autoExtract: true, autoExtractWhenStopped: true },
session,
createStoragePaths(path.join(root, "state"))
);
let postProcessSignal!: AbortSignal;
let finishPostProcessing!: () => void;
const recoverRetryableItems = vi.fn().mockResolvedValue(0);
const internal = manager as unknown as {
handlePackagePostProcessing: (packageId: string, signal: AbortSignal) => Promise<void>;
recoverRetryableItems: () => Promise<number>;
runPackagePostProcessing: (packageId: string) => Promise<void>;
};
internal.handlePackagePostProcessing = async (_requestedPackageId, signal) => {
postProcessSignal = signal;
await new Promise<void>((resolve) => {
finishPostProcessing = resolve;
});
};
internal.recoverRetryableItems = recoverRetryableItems;
const processing = internal.runPackagePostProcessing(packageId);
await waitFor(() => postProcessSignal !== undefined);
manager.stop();
expect(postProcessSignal.aborted).toBe(true);
await manager.start();
expect(manager.getSnapshot().lifecycle).toMatchObject({
phase: "stopping",
activePostProcessing: 1,
pendingStart: true
});
finishPostProcessing();
await processing;
await waitFor(() => manager.getSnapshot().lifecycle?.phase === "idle");
expect(recoverRetryableItems).toHaveBeenCalledTimes(1);
expect(manager.getSnapshot().lifecycle).toMatchObject({ activePostProcessing: 0, pendingStart: false });
});
});
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_FFMPEG_BIN;
delete process.env.RD_FFPROBE_BIN;
resetVideoToolingCache();
resetDebridLinkRuntimeStateForTests();
resetMegaDebridRuntimeStateForTests();
resetRealDebridRuntimeStateForTests();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
await removeDirWithRetries(dir);
}
});
describe("download manager", () => {
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("finishes a RapidGator availability check after the download has started", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-availability-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "availability", links: ["https://example.com/file.bin"] }]);
const 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;
};
const item = internal.session.items[itemId];
item.status = "downloading";
item.fullStatus = "Download läuft";
item.onlineStatus = "checking";
internal.applyRapidgatorCheckResult(item, {
online: true,
fileName: "episode.part01.rar",
fileSizeBytes: 524_288_000
});
expect(item.status).toBe("downloading");
expect(item.fullStatus).toBe("Download läuft");
expect(item.onlineStatus).toBe("online");
});
it("resolves DDownload names, sizes and availability after import", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-ddownload-metadata-"));
tempDirs.push(root);
const link = "https://ddownload.com/ntwscdw62gyb";
let checkCalls = 0;
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === link) {
checkCalls += 1;
return new Response('<div class="dk-dl-icon" data-fn="Show.S01E02.German.DL.part02.rar"><h2 class="dk-dl-name">Show.S01E02.German.DL.part02.rar</h2><p class="dk-dl-size">502.00 MB</p>', {
status: 200,
headers: { "Content-Type": "text/html" }
});
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "ddownload", links: [link] }]);
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.onlineStatus === "online", 2_000);
const downloadedItem = Object.values(manager.getSnapshot().session.items)[0];
expect(checkCalls).toBe(1);
expect(downloadedItem.fileName).toBe("Show.S01E02.German.DL.part02.rar");
expect(downloadedItem.totalBytes).toBe(526_385_152);
expect(path.basename(downloadedItem.targetPath)).toBe("Show.S01E02.German.DL.part02.rar");
});
it("rechecks unresolved DDownload metadata when a queued session is restored", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-ddownload-startup-"));
tempDirs.push(root);
const link = "https://ddownload.com/startup1234";
const session = emptySession();
const paths = createStoragePaths(path.join(root, "state"));
let page = "<html><title>Just a moment...</title></html>";
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === link) return new Response(page, { status: 200, headers: { "Content-Type": "text/html" } });
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const firstManager = new DownloadManager(defaultSettings(), session, paths);
firstManager.addPackages([{ name: "startup", links: [link] }]);
await waitFor(() => Object.values(session.items)[0]?.onlineStatus !== "checking", 2_000);
const restoredItem = Object.values(session.items)[0];
restoredItem.fileName = "download.bin";
restoredItem.targetPath = path.join(session.packages[restoredItem.packageId].outputDir, restoredItem.fileName);
restoredItem.totalBytes = null;
restoredItem.onlineStatus = undefined;
page = '<h2 class="dk-dl-name">Restored.Show.S01E01.mkv</h2><p class="dk-dl-size">1.50 GB</p>';
const restoredManager = new DownloadManager(defaultSettings(), session, paths);
await waitFor(() => Object.values(restoredManager.getSnapshot().session.items)[0]?.onlineStatus === "online", 2_000);
const checkedItem = Object.values(restoredManager.getSnapshot().session.items)[0];
expect(checkedItem.fileName).toBe("Restored.Show.S01E01.mkv");
expect(checkedItem.totalBytes).toBe(1_610_612_736);
expect(path.basename(checkedItem.targetPath)).toBe("Restored.Show.S01E01.mkv");
});
it("fails removed DDownload files while keeping protected pages queued as unknown", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-ddownload-status-"));
tempDirs.push(root);
const missing = "https://ddownload.com/missing1234";
const protectedLink = "https://ddownload.com/protect1234";
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === missing) return new Response("<h1>File Not Found</h1>", { status: 200, headers: { "Content-Type": "text/html" } });
if (url === protectedLink) return new Response("<title>Just a moment...</title>", { status: 200, headers: { "Content-Type": "text/html" } });
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "ddownload-status", links: [missing, protectedLink] }]);
await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.onlineStatus !== "checking"), 2_000);
const items = Object.values(manager.getSnapshot().session.items);
expect(items[0]).toEqual(expect.objectContaining({
status: "failed",
fullStatus: "Offline",
onlineStatus: "offline",
lastError: "Datei nicht gefunden auf DDownload"
}));
expect(items[1]).toEqual(expect.objectContaining({
status: "queued",
onlineStatus: undefined,
totalBytes: null
}));
});
it("keeps DDownload metadata when a debrid response later returns download.bin", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-ddownload-preserve-"));
tempDirs.push(root);
const link = "https://ddownload.com/keep1234567";
const expectedName = "Series.S02E03.German.DL.part01.rar";
const binary = Buffer.alloc(192 * 1024, 31);
const server = http.createServer((_req, res) => {
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}/download`;
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 === link) {
return new Response(`<h2 class="dk-dl-name">${expectedName}</h2><p class="dk-dl-size">192 KB</p>`, {
status: 200,
headers: { "Content-Type": "text/html" }
});
}
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
return new Response(JSON.stringify({ download: directUrl, filename: "download.bin", filesize: binary.length }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
return originalFetch(input, init);
}) as typeof fetch;
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: "ddownload-preserve", links: [link] }]);
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.fileName === expectedName, 2_000);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 15_000);
const downloadedItem = Object.values(manager.getSnapshot().session.items)[0];
expect(downloadedItem.status).toBe("completed");
expect(downloadedItem.fileName).toBe(expectedName);
expect(path.basename(downloadedItem.targetPath)).toBe(expectedName);
} finally {
server.close();
await once(server, "close");
}
}, 20_000);
it("resolves 1Fichier names, sizes and availability in one batch after import", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-onefichier-metadata-"));
tempDirs.push(root);
const linkA = "https://1fichier.com/?abc12345";
const linkB = "https://desfichiers.net/?def67890";
let checkCalls = 0;
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "https://1fichier.com/check_links.pl") {
checkCalls += 1;
return new Response([
`${linkA};Show.S01E01.German.DL.part01.rar;1073741824`,
`${linkB};Show.S01E01.German.DL.part02.rar;2147483648`
].join("\n"), {
status: 200,
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "onefichier", links: [linkA, linkB] }]);
await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.onlineStatus === "online"), 2_000);
const items = Object.values(manager.getSnapshot().session.items);
expect(checkCalls).toBe(1);
expect(items.map((item) => item.fileName)).toEqual([
"Show.S01E01.German.DL.part01.rar",
"Show.S01E01.German.DL.part02.rar"
]);
expect(items.map((item) => item.totalBytes)).toEqual([1_073_741_824, 2_147_483_648]);
expect(items.map((item) => path.basename(item.targetPath))).toEqual([
"Show.S01E01.German.DL.part01.rar",
"Show.S01E01.German.DL.part02.rar"
]);
});
it("keeps resolved 1Fichier metadata when a debrid response only returns download.bin", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-onefichier-preserve-"));
tempDirs.push(root);
const link = "https://1fichier.com/?keep12345";
const expectedName = "Series.S02E03.German.DL.part01.rar";
const binary = Buffer.alloc(192 * 1024, 23);
const server = http.createServer((_req, res) => {
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}/download`;
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 === "https://1fichier.com/check_links.pl") {
return new Response(`${link};${expectedName};${binary.length}`, { status: 200 });
}
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
return new Response(JSON.stringify({
download: directUrl,
filename: "download.bin",
filesize: binary.length
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
return originalFetch(input, init);
}) as typeof fetch;
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: "onefichier-preserve", links: [link] }]);
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.fileName === expectedName, 2_000);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 15_000);
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");
}
}, 20_000);
it("finishes a delayed 1Fichier availability check after the download has started", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-onefichier-delayed-"));
tempDirs.push(root);
const link = "https://1fichier.com/?delay1234";
let resolveCheck: (response: Response) => void = () => undefined;
const delayedCheck = new Promise<Response>((resolve) => {
resolveCheck = resolve;
});
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "https://1fichier.com/check_links.pl") {
return delayedCheck;
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "onefichier-delayed", links: [link] }]);
const item = Object.values((manager as any).session.items)[0] as DownloadItem;
expect(item.onlineStatus).toBe("checking");
item.status = "validating";
item.fullStatus = "Link wird umgewandelt";
resolveCheck(new Response(`${link};Delayed.Episode.mkv;524288000`, { status: 200 }));
await waitFor(() => item.onlineStatus === "online", 2_000);
expect(item.status).toBe("validating");
expect(item.fullStatus).toBe("Link wird umgewandelt");
expect(item.fileName).toBe("Delayed.Episode.mkv");
expect(path.basename(item.targetPath)).toBe("Delayed.Episode.mkv");
expect(item.totalBytes).toBe(524_288_000);
});
it("marks missing 1Fichier files offline while keeping private files queued", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-onefichier-status-"));
tempDirs.push(root);
const missing = "https://1fichier.com/?gone12345";
const privateLink = "https://1fichier.com/?priv12345";
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "https://1fichier.com/check_links.pl") {
return new Response(`${missing};;;NOT FOUND\n${privateLink};;;PRIVATE`, { status: 200 });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "onefichier-status", links: [missing, privateLink] }]);
await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.onlineStatus !== "checking"), 2_000);
const items = Object.values(manager.getSnapshot().session.items);
expect(items[0]).toEqual(expect.objectContaining({
status: "failed",
fullStatus: "Offline",
onlineStatus: "offline",
lastError: "Datei nicht gefunden auf 1Fichier"
}));
expect(items[1]).toEqual(expect.objectContaining({
status: "queued",
onlineStatus: "online",
fileName: "download.bin",
totalBytes: null
}));
});
it("leaves 1Fichier availability unknown when the checker returns no usable result", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-onefichier-unknown-"));
tempDirs.push(root);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return new Response("invalid response", { status: url === "https://1fichier.com/check_links.pl" ? 200 : 404 });
}) as typeof fetch;
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "onefichier-unknown", links: ["https://1fichier.com/?unknown12"] }]);
const item = Object.values((manager as any).session.items)[0] as DownloadItem;
await waitFor(() => item.onlineStatus !== "checking", 2_000);
expect(item.status).toBe("queued");
expect(item.onlineStatus).toBeUndefined();
expect(item.fileName).toBe("download.bin");
});
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("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("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("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("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((manager as any).http416FreshRestartByItem.get(item.id)).toBeUndefined();
} 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("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 history: HistoryEntry[] = [];
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")),
{ onHistoryEntry: (entry) => history.push(entry) }
);
const blocker = new Promise<void>((resolve) => {
releaseBlockedPostProcess = resolve;
});
(manager as any).handlePackagePostProcessing = vi.fn(async () => 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);
const pendingPackage = manager.getSnapshot().session.packages[firstPackage];
expect(pendingPackage?.downloadStartedAt).toBeGreaterThan(0);
expect(pendingPackage?.downloadEndedAt).toBeGreaterThanOrEqual(pendingPackage?.downloadStartedAt || 0);
expect(pendingPackage?.downloadEndedAt).toBe(pendingPackage?.downloadCompletedAt);
const downloadEndedAt = pendingPackage?.downloadEndedAt || 0;
await new Promise((resolve) => setTimeout(resolve, 1_200));
expect(manager.getSnapshot().session.packages[firstPackage]?.downloadEndedAt).toBe(downloadEndedAt);
if (releaseBlockedPostProcess) {
releaseBlockedPostProcess();
}
await waitFor(() => !manager.getSnapshot().session.running, 25000);
await waitFor(() => history.some((entry) => entry.name === "first"), 6000);
const done = manager.getSnapshot();
expect(done.session.items[firstItem]?.status).toBe("completed");
expect(done.session.items[secondItem]?.status).toBe("completed");
const firstHistory = history.find((entry) => entry.name === "first");
expect(firstHistory?.downloadEndedAt).toBe(downloadEndedAt);
expect(firstHistory?.totalDurationSeconds).toBeGreaterThan(firstHistory?.downloadDurationSeconds || 0);
} 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("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;
const server = http.createServer((req, res) => {
if ((req.url || "") !== "/complete") {
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 >= 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: binary.length
}),
{
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: binary.length,
progressPercent: 100,
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(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("requeues failed HTTP 416 items automatically on startup", 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",
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 waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "queued", 12000);
const snapshot = manager.getSnapshot();
const item = snapshot.session.items[itemId];
expect(item?.status).toBe("queued");
expect(item?.attempts).toBe(0);
expect(item?.downloadedBytes).toBe(0);
expect(item?.progressPercent).toBe(0);
expect(item?.fullStatus).toContain("Auto-Retry");
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
expect(fs.existsSync(targetPath)).toBe(false);
});
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("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: undefined
}));
}
});
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("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");
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "foreign-package.txt"), "foreign");
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: false,
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);
expect(snapshot.session.packages[packageId]?.archiveOperations).toHaveLength(1);
expect(snapshot.session.packages[packageId]?.archiveOperations?.[0]).toMatchObject({
name: "episode.zip",
itemIds: [itemId],
partCount: 1,
status: "completed",
errorCategory: ""
});
expect(snapshot.session.packages[packageId]?.archiveOperations?.[0].durationMs).toBeGreaterThanOrEqual(0);
expect(snapshot.session.packages[packageId]?.outputCount).toBe(1);
}, 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("does not collect unscoped raw files outside the package extract directory", 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);
expect(fs.existsSync(path.join(outputDir, rawName))).toBe(true);
expect(fs.existsSync(path.join(outputDir, rawSrt))).toBe(true);
expect(fs.existsSync(mkvLibraryDir) ? fs.readdirSync(mkvLibraryDir) : []).toEqual([]);
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 adopt unscoped files from an extraction root shared by packages", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shared-adoption-"));
tempDirs.push(root);
const sharedExtractDir = path.join(root, "shared");
const libraryDir = path.join(root, "library");
fs.mkdirSync(sharedExtractDir, { recursive: true });
const foreignPath = path.join(sharedExtractDir, "foreign.mkv");
fs.writeFileSync(foreignPath, "foreign");
const session = emptySession();
for (const id of ["package-a", "package-b"]) {
session.packages[id] = {
id,
name: id,
outputDir: path.join(root, "downloads", id),
extractDir: sharedExtractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
outputProvenanceVersion: 1,
outputRecords: [],
createdAt: 1_000,
updatedAt: 1_000
};
}
session.packageOrder = ["package-a", "package-b"];
const manager = new DownloadManager(
{
...defaultSettings(),
autoExtract: true,
createExtractSubfolder: true,
collectMkvToLibrary: true,
mkvLibraryDir: libraryDir
},
session,
createStoragePaths(path.join(root, "state"))
);
await (manager as any).collectMkvFilesToLibrary("package-a", session.packages["package-a"]);
expect(fs.existsSync(foreignPath)).toBe(true);
expect(fs.existsSync(path.join(libraryDir, "foreign.mkv"))).toBe(false);
expect(session.packages["package-a"].outputRecords).toEqual([]);
});
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("tracks Real-Debrid traffic only on the account that produced the direct link", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
providerDailyUsageDay: getProviderUsageDayKey(),
realDebridApiTokens: serializeRealDebridApiAccounts([
{ id: "rda_one", token: "token-one" },
{ id: "rda_two", token: "token-two" }
]),
realDebridAccountDailyUsageBytes: { rda_one: 100, rda_two: 200 },
realDebridAccountTotalUsageBytes: { rda_one: 1000, rda_two: 2000 }
};
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
const internal = manager as unknown as {
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number, providerAccountId?: string) => void;
settings: typeof settings;
};
internal.recordProviderDownloadedBytes("realdebrid", 50, "rda_two");
expect(internal.settings.realDebridAccountDailyUsageBytes).toEqual({ rda_one: 100, rda_two: 250 });
expect(internal.settings.realDebridAccountTotalUsageBytes).toEqual({ rda_one: 1000, rda_two: 2050 });
});
it("projects rolling traffic for each concrete account without exposing minute buckets", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
providerDailyUsageDay: getProviderUsageDayKey(),
realDebridApiTokens: serializeRealDebridApiAccounts([
{ id: "rda_one", token: "token-one" },
{ id: "rda_two", token: "token-two" }
]),
debridAccountStatuses: {
rda_one: {
accountId: "rda_one",
provider: "realdebrid" as const,
label: "Real-Debrid API 1",
maskedLogin: "Geschützter API-Token",
valid: true,
isPremium: true,
premiumUntilMs: null,
username: "xSucukDE",
message: "Premium aktiv",
checkedAt: Date.now()
},
rda_two: {
accountId: "rda_two",
provider: "realdebrid" as const,
label: "Real-Debrid API 2",
maskedLogin: "Geschützter API-Token",
valid: true,
isPremium: true,
premiumUntilMs: null,
username: "Backup",
message: "Premium aktiv",
checkedAt: Date.now()
}
}
};
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
const internal = manager as unknown as {
recordProviderDownloadedBytes: (
provider: "realdebrid",
bytes: number,
providerAccountId?: string,
providerAccountLabel?: string
) => void;
statisticsLedger: { minutes: unknown[] };
};
internal.recordProviderDownloadedBytes("realdebrid", 50, "rda_one", "Primary");
internal.recordProviderDownloadedBytes("realdebrid", 75, "rda_two", "Secondary");
const stats = manager.getStats();
expect(stats.rolling24Hours).toMatchObject({ downloadedBytes: 125 });
expect(stats.rolling24Hours?.accounts.map((account) => [account.id, account.label, account.bytes])).toEqual([
["rda_two", "Backup", 75],
["rda_one", "xSucukDE", 50]
]);
expect(stats.statistics?.minutes).toEqual([]);
expect(internal.statisticsLedger.minutes).toHaveLength(1);
expect(manager.getStatisticsLedgerForBackup().minutes).toHaveLength(1);
});
it("keeps rolling traffic on session reset and clears it on total reset", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
providerDailyUsageDay: getProviderUsageDayKey(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_one", token: "token-one" }])
};
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
const internal = manager as unknown as {
recordProviderDownloadedBytes: (
provider: "realdebrid",
bytes: number,
providerAccountId?: string,
providerAccountLabel?: string
) => void;
};
internal.recordProviderDownloadedBytes("realdebrid", 50, "rda_one", "Primary");
manager.resetSessionStats();
expect(manager.getStats().rolling24Hours?.downloadedBytes).toBe(50);
manager.resetDownloadStats();
expect(manager.getStats().rolling24Hours).toMatchObject({ downloadedBytes: 0, accounts: [] });
expect(loadStatisticsLedger(createStoragePaths(path.join(root, "state")).statisticsFile).minutes).toEqual([]);
});
it("does not recreate account usage when the source account was removed during the download", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
providerDailyUsageDay: getProviderUsageDayKey(),
providerDailyUsageBytes: { realdebrid: 100 },
providerTotalUsageBytes: { realdebrid: 1000 },
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_one", token: "token-one" }]),
realDebridAccountDailyUsageBytes: {},
realDebridAccountTotalUsageBytes: {}
};
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
manager.setSettings({ ...settings, realDebridApiTokens: "" });
const internal = manager as unknown as {
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number, providerAccountId?: string) => void;
settings: typeof settings;
};
internal.recordProviderDownloadedBytes("realdebrid", 50, "rda_one");
expect(internal.settings.providerDailyUsageBytes.realdebrid).toBe(150);
expect(internal.settings.providerTotalUsageBytes.realdebrid).toBe(1050);
expect(internal.settings.realDebridAccountDailyUsageBytes).toEqual({});
expect(internal.settings.realDebridAccountTotalUsageBytes).toEqual({});
});
it("prunes removed Real-Debrid account runtime state on a live settings update", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_one", token: "token-one" }])
};
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
primeRealDebridRuntimeCooldownForTests("rda_one", 60_000);
expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(1);
manager.setSettings({ ...settings, realDebridApiTokens: "" });
expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(0);
});
it("exposes sanitized per-account runtime state in the renderer snapshot", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_one", token: "token-one" }]),
realDebridAccountDailyUsageBytes: { rda_one: 4_200_000_000 }
};
const session = emptySession();
session.items.runtime_item = {
id: "runtime_item",
packageId: "runtime_package",
url: "https://hoster.example/runtime.bin",
provider: "realdebrid",
providerAccountId: "rda_one",
status: "downloading",
retries: 0,
speedBps: 1024,
downloadedBytes: 100,
totalBytes: 1000,
progressPercent: 10,
fileName: "runtime.bin",
targetPath: path.join(root, "runtime.bin"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft",
createdAt: Date.now() - 1000,
updatedAt: Date.now()
};
session.packages.runtime_package = {
id: "runtime_package",
name: "runtime",
outputDir: root,
extractDir: root,
status: "downloading",
itemIds: ["runtime_item"],
cancelled: false,
enabled: true,
createdAt: Date.now() - 1000,
updatedAt: Date.now()
};
session.packageOrder = ["runtime_package"];
const manager = new DownloadManager(settings, session, createStoragePaths(path.join(root, "state")));
const activeItem = (manager as unknown as { session: typeof session }).session.items.runtime_item;
activeItem.status = "downloading";
activeItem.provider = "realdebrid";
activeItem.providerAccountId = "rda_one";
primeRealDebridRuntimeCooldownForTests("rda_one", 60_000, "HTTP 429 token-one", "rate_limit");
const runtime = (manager.getSnapshot() as unknown as {
accountRuntime: Array<{
accountId: string;
state: string;
reason: string;
cooldownUntil: number | null;
dailyUsageBytes: number;
}>;
}).accountRuntime;
expect(runtime).toEqual([
expect.objectContaining({
accountId: "rda_one",
state: "cooldown",
reason: "Rate-Limit aktiv",
cooldownUntil: expect.any(Number),
dailyUsageBytes: 4_200_000_000,
activeDownloads: 1,
lastUsedAt: expect.any(Number)
})
]);
expect(JSON.stringify(runtime)).not.toContain("token-one");
expect(JSON.stringify(runtime)).not.toContain("HTTP 429");
});
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).toBe("started");
await waitFor(() => manager.getSnapshot().lifecycle?.reason.includes("Kein aktiver Download-Account verfügbar") === true);
expect(manager.getSnapshot()).toMatchObject({
session: { running: false },
lifecycle: { phase: "idle", pendingStart: 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("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);
});
});
describe("package priority ordering", () => {
function buildPriorityManager(priorities: Array<[string, "high" | "normal" | "low"]>): {
manager: DownloadManager;
session: ReturnType<typeof emptySession>;
} {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-priority-order-"));
tempDirs.push(root);
const session = emptySession();
const createdAt = Date.now();
for (const [id, priority] of priorities) {
session.packageOrder.push(id);
session.packages[id] = {
id,
name: id,
outputDir: path.join(root, "downloads", id),
extractDir: path.join(root, "extract", id),
status: "queued",
itemIds: [],
cancelled: false,
enabled: true,
priority,
createdAt,
updatedAt: createdAt
};
}
return {
manager: new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state"))),
session
};
}
it("moves a package set to low priority behind every normal package", () => {
const { manager, session } = buildPriorityManager([
["high-a", "high"],
["target", "normal"],
["normal-b", "normal"],
["low-a", "low"]
]);
manager.setPackagePriority("target", "low");
expect(session.packageOrder).toEqual(["high-a", "normal-b", "low-a", "target"]);
});
it("moves a package set to high priority ahead of every normal package", () => {
const { manager, session } = buildPriorityManager([
["high-a", "high"],
["normal-a", "normal"],
["target", "normal"],
["low-a", "low"]
]);
manager.setPackagePriority("target", "high");
expect(session.packageOrder).toEqual(["high-a", "target", "normal-a", "low-a"]);
});
it("moves a package returned to normal between high and low priority groups", () => {
const { manager, session } = buildPriorityManager([
["high-a", "high"],
["normal-a", "normal"],
["low-a", "low"],
["target", "low"]
]);
manager.setPackagePriority("target", "normal");
expect(session.packageOrder).toEqual(["high-a", "normal-a", "target", "low-a"]);
});
it("keeps an unchanged priority in place", () => {
const { manager, session } = buildPriorityManager([
["high-a", "high"],
["high-b", "high"],
["normal-a", "normal"]
]);
manager.setPackagePriority("high-a", "high");
expect(session.packageOrder).toEqual(["high-a", "high-b", "normal-a"]);
});
});
describe("package lifecycle telemetry boundaries", () => {
it("captures direct package output scopes concurrently without scanning a shared root", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-provenance-lock-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
fs.mkdirSync(extractDir, { recursive: true });
for (let index = 0; index < 2_000; index += 1) {
fs.writeFileSync(path.join(extractDir, `foreign-${index}.txt`), "foreign");
}
const manager = new DownloadManager(
{ ...defaultSettings(), extractConflictMode: "overwrite" },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
const createPackage = (id: string): PackageEntry => ({
id,
name: id,
outputDir: path.join(root, "downloads", id),
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
});
const packageA = createPackage("package-a");
const packageB = createPackage("package-b");
let releaseA!: () => void;
const gateA = new Promise<void>((resolve) => {
releaseA = resolve;
});
let enteredB = false;
const state = manager as any;
const first = state.runWithPackageOutputProvenance(packageA, async (operationTarget: string, scope: any) => {
const outputPath = path.join(operationTarget, "package-a.mkv");
fs.writeFileSync(outputPath, "a");
scope.add({
version: 1,
archivePath: path.join(packageA.outputDir, "archive.rar"),
entryPath: "package-a.mkv",
outputPath,
state: "complete",
disposition: "written"
});
await gateA;
});
await vi.waitFor(() => expect(enteredB).toBe(false));
const second = state.runWithPackageOutputProvenance(packageB, async (operationTarget: string, scope: any) => {
enteredB = true;
const outputPath = path.join(operationTarget, "package-b.mkv");
fs.writeFileSync(outputPath, "b");
scope.add({
version: 1,
archivePath: path.join(packageB.outputDir, "archive.rar"),
entryPath: "package-b.mkv",
outputPath,
state: "complete",
disposition: "written"
});
});
await vi.waitFor(() => expect(enteredB).toBe(true));
releaseA();
await Promise.all([first, second]);
expect(packageA.outputCount).toBe(1);
expect(packageB.outputCount).toBe(1);
expect(packageA.outputRecords).toEqual([expect.objectContaining({ outputPath: path.join(extractDir, "package-a.mkv") })]);
expect(packageB.outputRecords).toEqual([expect.objectContaining({ outputPath: path.join(extractDir, "package-b.mkv") })]);
});
it("retains directly reported partial outputs when extraction aborts", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-abort-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
fs.mkdirSync(extractDir, { recursive: true });
const manager = new DownloadManager(
defaultSettings(),
emptySession(),
createStoragePaths(path.join(root, "state"))
);
const pkg: PackageEntry = {
id: "aborted-output",
name: "aborted-output",
outputDir: path.join(root, "downloads"),
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
const state = manager as any;
await expect(state.runWithPackageOutputProvenance(pkg, async (operationTarget: string, scope: any) => {
const outputPath = path.join(operationTarget, "partial.mkv");
fs.writeFileSync(outputPath, "partial");
scope.add({
version: 1,
archivePath: path.join(pkg.outputDir, "archive.rar"),
entryPath: "partial.mkv",
outputPath,
state: "partial",
disposition: "written"
});
throw new Error("aborted:extract");
})).rejects.toThrow("aborted:extract");
expect(fs.readFileSync(path.join(extractDir, "partial.mkv"), "utf8")).toBe("partial");
expect(pkg.outputCount).toBe(1);
expect(pkg.outputRecords).toEqual([expect.objectContaining({ state: "partial", outputPath: path.join(extractDir, "partial.mkv") })]);
});
it("uses normalized nested item paths for archive identity and leaves empty item provenance at zero", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-identity-"));
tempDirs.push(root);
const session = emptySession();
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
const pkg: PackageEntry = {
id: "archive-identity-package",
name: "Archive identity",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
status: "completed" as const,
itemIds: ["item-a", "item-b"],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
const item = (id: string, directory: string) => ({
id,
packageId: pkg.id,
url: `https://example.test/${id}`,
provider: "realdebrid" as const,
status: "completed" as const,
retries: 0,
speedBps: 0,
downloadedBytes: 1,
totalBytes: 1,
progressPercent: 100,
fileName: "episode.rar",
targetPath: path.join(pkg.outputDir, directory, "episode.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt: 1_000,
updatedAt: 1_000
});
const progress = (archivePath: string) => ({
current: 0,
total: 3,
percent: 100,
archiveName: "episode.rar",
archivePath,
archivePercent: 100,
elapsedMs: 1_000,
archiveDone: true,
archiveSuccess: true
});
const state = manager as any;
const itemA = item("item-a", "season-a");
const itemB = item("item-b", "season-b");
const archiveAPath = path.join(pkg.outputDir, "season-a", "episode.rar");
const archiveBPath = path.join(pkg.outputDir, "season-b", "episode.rar");
const archiveAItems = (resolveArchiveItemsFromList as any)("episode.rar", [itemA, itemB], archiveAPath);
const archiveBItems = (resolveArchiveItemsFromList as any)("episode.rar", [itemA, itemB], archiveBPath);
const unresolvedItem = { ...item("unresolved", "season-c"), targetPath: "" };
state.recordArchiveOperation(pkg, progress(archiveAPath), archiveAItems);
state.recordArchiveOperation(pkg, progress(archiveBPath), archiveBItems);
state.recordArchiveOperation(pkg, { ...progress(""), archiveName: "unresolved.rar" }, [unresolvedItem]);
const operations = pkg.archiveOperations || [];
expect(operations).toHaveLength(3);
expect(new Set(operations.map((operation) => operation.id))).toHaveLength(3);
expect(operations.map((operation) => operation.itemIds)).toEqual([["item-a"], ["item-b"], []]);
expect(operations.map((operation) => operation.partCount)).toEqual([1, 1, 0]);
});
it("keeps foreign post-process waiters reserved when stopping another run", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-run-owned-slots-"));
tempDirs.push(root);
const session = emptySession();
const createPackage = (id: string): PackageEntry => ({
id,
name: id,
outputDir: path.join(root, "downloads", id),
extractDir: path.join(root, "extract", id),
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
});
const packageA = createPackage("run-a-package");
const packageB = createPackage("run-b-package");
session.packages[packageA.id] = packageA;
session.packages[packageB.id] = packageB;
session.packageOrder = [packageA.id, packageB.id];
const manager = new DownloadManager(
{ ...defaultSettings(), maxParallelExtract: 1 },
session,
createStoragePaths(path.join(root, "state"))
);
const state = manager as any;
const runA = state.createRunContext([packageA.id], 1_000, false);
const runB = state.beginActiveRunContext([packageB.id], 2_000);
session.running = true;
session.runStartedAt = 2_000;
state.runPackageIds = new Set([packageB.id]);
state.runItemIds = new Set(["run-b-item"]);
let concurrent = 1;
let peak = concurrent;
let foreignResolved = false;
await state.acquirePostProcessSlot("active-a", runA.id);
const foreignWaiter = state.acquirePostProcessSlot("waiting-a", runA.id).then((acquired: boolean | undefined) => {
foreignResolved = true;
if (acquired !== false) {
concurrent += 1;
peak = Math.max(peak, concurrent);
}
return acquired;
});
const stoppedWaiter = state.acquirePostProcessSlot("waiting-b", runB.id);
manager.stop();
const stoppedResult = await stoppedWaiter;
await Promise.resolve();
expect(stoppedResult).toBe(false);
expect(foreignResolved).toBe(false);
expect(state.packagePostProcessActive).toBe(1);
concurrent -= 1;
state.releasePostProcessSlot();
const foreignResult = await foreignWaiter;
expect(foreignResult).toBe(true);
expect(state.packagePostProcessActive).toBe(1);
expect(peak).toBe(1);
concurrent -= 1;
state.releasePostProcessSlot();
expect(state.packagePostProcessActive).toBe(0);
});
it("records queued, slot start and terminal timestamps around real post-processing", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-lifecycle-boundaries-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "lifecycle-package";
const itemId = "lifecycle-item";
const createdAt = Date.now() - 5_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Lifecycle",
outputDir: path.join(root, "downloads", "lifecycle"),
extractDir: path.join(root, "extract", "lifecycle"),
status: "completed",
itemIds: [itemId],
cancelled: false,
enabled: true,
downloadStartedAt: createdAt,
downloadCompletedAt: createdAt + 1_000,
downloadEndedAt: createdAt + 1_000,
createdAt,
updatedAt: createdAt + 1_000
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/lifecycle",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 65_536,
totalBytes: 65_536,
progressPercent: 100,
fileName: "lifecycle.txt",
targetPath: path.join(root, "downloads", "lifecycle", "lifecycle.txt"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt,
updatedAt: createdAt + 1_000
};
fs.mkdirSync(path.dirname(session.items[itemId].targetPath), { recursive: true });
fs.writeFileSync(session.items[itemId].targetPath, Buffer.alloc(65_536, 1));
const manager = new DownloadManager(
{ ...defaultSettings(), autoExtract: false },
session,
createStoragePaths(path.join(root, "state"))
);
const state = manager as any;
state.runPackageIds.add(packageId);
state.handlePackagePostProcessing = vi.fn(async () => undefined);
await state.runPackagePostProcessing(packageId);
const pkg = session.packages[packageId];
expect(session.items[itemId].status).toBe("completed");
expect(pkg.postProcessQueuedAt).toBeGreaterThan(0);
expect(pkg.postProcessStartedAt).toBeGreaterThanOrEqual(pkg.postProcessQueuedAt || 0);
expect(pkg.postProcessCompletedAt).toBeGreaterThanOrEqual(pkg.postProcessStartedAt || 0);
expect(pkg.terminalAt).toBe(pkg.postProcessCompletedAt);
});
});
describe("download health snapshot", () => {
function createHealthManager(root: string, settings = defaultSettings()) {
const session = emptySession();
const packageId = "private-package-id";
const itemId = "private-item-id";
const now = 100_000;
session.running = true;
session.runStartedAt = 10_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Private package name",
outputDir: path.join(root, "private-output"),
extractDir: path.join(root, "private-extract"),
status: "downloading",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: now
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://private.example.test/file",
provider: "realdebrid",
providerLabel: "Private provider label",
providerAccountId: "private-account-id",
providerAccountLabel: "private@example.test",
status: "downloading",
retries: 0,
speedBps: 8192,
downloadedBytes: 4096,
totalBytes: 16384,
progressPercent: 25,
fileName: "private-file.bin",
targetPath: path.join(root, "private-output", "private-file.bin"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft",
createdAt: 2_000,
updatedAt: now
};
const manager = new DownloadManager(settings, session, createStoragePaths(path.join(root, "state")));
const state = manager as any;
session.running = true;
session.items[itemId].status = "downloading";
session.items[itemId].speedBps = 8192;
session.packages[packageId].status = "downloading";
state.runItemIds.add(itemId);
state.runPackageIds.add(packageId);
return { manager, session, state, packageId, itemId };
}
it("exposes only aggregate run-scope health data and opaque fingerprints", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-snapshot-"));
tempDirs.push(root);
const { manager, state, packageId, itemId } = createHealthManager(root);
state.lastSchedulerTickAt = 99_000;
state.activeTasks.set(itemId, {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
phase: "downloading",
phaseStartedAt: 90_000,
phaseDeadlineAt: 0,
blockedOnDiskWrite: false,
blockedOnDiskSince: 0,
blockedOnThrottleUntil: 0
});
const health = manager.getDownloadHealthSnapshot(100_000);
const serialized = JSON.stringify(health);
expect(health).toMatchObject({
runActive: true,
openItems: 1,
openPackages: 1,
knownDownloadedBytes: 4096,
activeTasks: 1,
startableItems: 0,
lastSchedulerTickAt: 99_000,
currentSpeedBps: 8192
});
expect(health.runFingerprint).toMatch(/^[a-f0-9]{64}$/);
expect(health.queueFingerprint).toMatch(/^[a-f0-9]{64}$/);
expect(serialized).not.toMatch(/private|example\.test|realdebrid|file\.bin/i);
});
it("keeps the run fingerprint stable across restart time changes for the same generation scope", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-fingerprint-"));
tempDirs.push(root);
const { manager, session } = createHealthManager(root);
session.packages[session.packageOrder[0]].resultGeneration = 4;
const before = manager.getDownloadHealthSnapshot(100_000);
session.runStartedAt = 200_000;
const after = manager.getDownloadHealthSnapshot(210_000);
expect(after.runFingerprint).toBe(before.runFingerprint);
expect(after.queueFingerprint).toBe(before.queueFingerprint);
});
it("advances progress sequences only for positive byte events and successful item completions", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-sequences-"));
tempDirs.push(root);
const { manager, session, state, packageId, itemId } = createHealthManager(root);
state.recordSpeed(1024, packageId);
state.recordSpeed(0, packageId);
state.recordSpeed(-512, packageId);
session.totalDownloadedBytes = 10_000_000;
session.totalDownloadedBytes = 0;
state.recordRunOutcome(itemId, "completed");
state.recordRunOutcome(itemId, "completed");
const health = manager.getDownloadHealthSnapshot(100_000);
expect(health.downloadProgressSequence).toBe(1);
expect(health.lastPositiveByteAt).toBeGreaterThan(0);
expect(health.itemCompletionSequence).toBe(1);
});
it("projects retry, cooldown, disk, throttle and valid phase waits without stale disk events", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-waits-"));
tempDirs.push(root);
const { manager, session, state, packageId, itemId } = createHealthManager(root);
const active = {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
phase: "validating",
phaseStartedAt: 90_000,
phaseDeadlineAt: 130_000,
blockedOnDiskWrite: true,
blockedOnDiskSince: 95_000,
blockedOnThrottleUntil: 120_000
};
state.activeTasks.set(itemId, active);
state.diskWaitEvents = [{
phase: "download",
targetPath: "C:\\private\\disk",
requiredBytes: 100,
availableBytes: 0,
reserveBytes: 0,
retryAt: 80_000,
itemId,
packageId
}];
const activeWait = manager.getDownloadHealthSnapshot(100_000);
expect(activeWait.blockedOnDisk).toBe(true);
expect(activeWait.blockedOnThrottleUntil).toBe(120_000);
expect(activeWait.activePhaseDeadlineAt).toBe(130_000);
state.activeTasks.clear();
session.items[itemId].status = "queued";
state.retryAfterByItem.set(itemId, 140_000);
state.providerFailures.set("realdebrid", { count: 20, lastFailAt: 99_000, cooldownUntil: 150_000 });
const queuedWait = manager.getDownloadHealthSnapshot(100_000);
expect(queuedWait.startableItems).toBe(0);
expect(queuedWait.nextRetryAt).toBe(140_000);
expect(queuedWait.providerCooldownUntil).toBe(150_000);
expect(queuedWait.blockedOnDisk).toBe(false);
});
it("does not project a partial retry as a global wait while another download is active", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-partial-wait-"));
tempDirs.push(root);
const { manager, session, state, packageId, itemId } = createHealthManager(root);
const queuedId = "private-queued-id";
session.items[queuedId] = {
...session.items[itemId],
id: queuedId,
status: "queued",
downloadedBytes: 0,
speedBps: 0
};
session.packages[packageId].itemIds.push(queuedId);
state.runItemIds.add(queuedId);
state.activeTasks.set(itemId, {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
phase: "downloading",
phaseStartedAt: 90_000,
phaseDeadlineAt: 0,
blockedOnDiskWrite: false,
blockedOnDiskSince: 0,
blockedOnThrottleUntil: 0
});
state.retryAfterByItem.set(queuedId, 140_000);
const health = manager.getDownloadHealthSnapshot(100_000);
expect(health.activeTasks).toBe(1);
expect(health.nextRetryAt).toBe(0);
});
it("freezes health while two active downloads wait in the real global speed-limit queue", async () => {
vi.useFakeTimers();
vi.setSystemTime(10_000);
try {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-global-throttle-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
speedLimitEnabled: true,
speedLimitMode: "global" as const,
speedLimitKbps: 1
};
const { manager, session, state, packageId, itemId } = createHealthManager(root, settings);
const secondItemId = "private-throttled-item-2";
session.items[secondItemId] = {
...session.items[itemId],
id: secondItemId
};
session.packages[packageId].itemIds.push(secondItemId);
state.runItemIds.add(secondItemId);
const firstActive = {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
phase: "downloading",
phaseStartedAt: 10_000,
phaseDeadlineAt: 0,
blockedOnDiskWrite: false,
blockedOnDiskSince: 0,
blockedOnThrottleUntil: 0
};
const secondActive = {
...firstActive,
itemId: secondItemId,
abortController: new AbortController()
};
state.activeTasks.set(itemId, firstActive);
state.activeTasks.set(secondItemId, secondActive);
await state.applySpeedLimit(100 * 1024, 0, 10_000, firstActive);
const secondWait = state.applySpeedLimit(100 * 1024, 0, 10_000, secondActive);
const firstWait = state.applySpeedLimit(100 * 1024, 0, 10_000, firstActive);
const waitsSettled = Promise.allSettled([secondWait, firstWait]);
await Promise.resolve();
let healthState = createDownloadHealthState();
const events = [];
for (const now of [10_000, 55_000, 105_000]) {
vi.setSystemTime(now);
const result = evaluateDownloadHealth(
healthState,
manager.getDownloadHealthSnapshot(now),
now,
{
stallAfterMs: 90_000,
cooldownMs: 600_000,
notifyOnStall: true,
notifyOnRecovery: true
}
);
healthState = result.state;
events.push(...result.events);
}
expect(firstActive.blockedOnThrottleUntil).toBeGreaterThan(105_000);
expect(secondActive.blockedOnThrottleUntil).toBeGreaterThan(105_000);
expect(manager.getDownloadHealthSnapshot(105_000).blockedOnThrottleUntil).toBeGreaterThan(105_000);
expect(healthState.status).toBe("expected_wait");
expect(healthState.suspiciousDurationMs).toBe(0);
expect(events).toEqual([]);
firstActive.abortController.abort("test-finished");
secondActive.abortController.abort("test-finished");
await vi.runAllTimersAsync();
await waitsSettled;
expect(firstActive.blockedOnThrottleUntil).toBe(0);
expect(secondActive.blockedOnThrottleUntil).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("counts one technical recovery when the existing global watchdog restarts stalled tasks", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-watchdog-"));
tempDirs.push(root);
const { manager, state, packageId, itemId } = createHealthManager(root);
const active = {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
phase: "downloading",
phaseStartedAt: 1,
phaseDeadlineAt: 0,
blockedOnDiskWrite: false,
blockedOnDiskSince: 0,
blockedOnThrottleUntil: 0
};
state.activeTasks.set(itemId, active);
state.lastGlobalProgressBytes = 0;
state.lastGlobalProgressAt = 1;
state.runGlobalStallWatchdog(90_000);
expect(active.abortController.signal.aborted).toBe(true);
expect(active.abortReason).toBe("stall");
expect(manager.getDownloadHealthSnapshot(90_000).technicalRecoveryCount).toBe(1);
});
});