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, findCrcImplicatedArchiveItems, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveSelectedArchiveSetsFromCandidates, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager"; import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion"; import { DiskReservationCoordinator } from "../src/main/disk-space"; import { ExtractionCoordinator } from "../src/main/extraction-coordinator"; 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"; import { registerPackageCompleteOutputs } from "./helpers/package-output-scope"; const tempDirs: string[] = []; const originalFetch = globalThis.fetch; function writePackageOutputOwnerMarker(pkg: PackageEntry): void { const ownerId = crypto.randomUUID().toLowerCase(); const generation = Math.max(1, Number(pkg.resultGeneration || 1)); pkg.outputOwnerId = ownerId; pkg.outputOwnerGeneration = generation; fs.mkdirSync(pkg.extractDir, { recursive: true }); fs.writeFileSync(path.join(pkg.extractDir, ".rd-package-output-owner-v1.json"), JSON.stringify({ version: 1, packageId: pkg.id, generation, ownerId })); } 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("selected item run scope", () => { function createSelectedItemManager(root: string): { manager: DownloadManager; packageId: string; itemIds: string[] } { const manager = new DownloadManager( { ...defaultSettings(), token: "rd-token", autoExtract: true, hybridExtract: true, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") }, emptySession(), createStoragePaths(path.join(root, "state")) ); manager.addPackages([{ name: "selected-items", links: ["https://dummy/first", "https://dummy/second"] }]); const snapshot = manager.getSnapshot().session; const packageId = snapshot.packageOrder[0]; return { manager, packageId, itemIds: snapshot.packages[packageId].itemIds }; } it("creates the active run context before triggering pending hybrid extraction", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-owner-")); tempDirs.push(root); const { manager, itemIds } = createSelectedItemManager(root); const internal = manager as any; const owners: Array = []; internal.ensureScheduler = async () => {}; internal.triggerPendingExtractions = () => owners.push(internal.activeRunContextId); await internal.startItemsNow([itemIds[1]]); expect(owners).toHaveLength(1); expect(owners[0]).toBeTypeOf("string"); expect(owners[0]).toBe(internal.activeRunContextId); }); it("never schedules an unselected queued sibling from the same package", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-scope-")); tempDirs.push(root); const { manager, itemIds } = createSelectedItemManager(root); const internal = manager as any; internal.ensureScheduler = async () => {}; internal.triggerPendingExtractions = () => {}; await internal.startItemsNow([itemIds[0]]); expect(internal.findNextQueuedItem()).toEqual(expect.objectContaining({ itemId: itemIds[0] })); internal.session.items[itemIds[0]].status = "downloading"; expect(internal.findNextQueuedItem()).toBeNull(); expect(internal.getQueuePresence()).toEqual({ hasImmediate: false, hasDelayed: false }); internal.session.items[itemIds[1]].status = "reconnect_wait"; internal.session.items[itemIds[1]].fullStatus = "FREMDER_BACKOFF"; internal.retryAfterByItem.set(itemIds[1], 99_000); manager.stop(); expect(internal.session.items[itemIds[1]]).toEqual(expect.objectContaining({ status: "reconnect_wait", fullStatus: "FREMDER_BACKOFF" })); expect(internal.retryAfterByItem.get(itemIds[1])).toBe(99_000); }); it("stops only selected run items without erasing sibling wait state", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-stop-")); tempDirs.push(root); const { manager, packageId, itemIds } = createSelectedItemManager(root); const internal = manager as any; internal.ensureScheduler = async () => {}; internal.triggerPendingExtractions = () => {}; await internal.startItemsNow([itemIds[0]]); internal.session.items[itemIds[0]].status = "downloading"; internal.session.items[itemIds[0]].fullStatus = "Download läuft"; internal.session.items[itemIds[1]].status = "reconnect_wait"; internal.session.items[itemIds[1]].fullStatus = "Unselektierter Backoff"; internal.session.packages[packageId].status = "downloading"; internal.retryAfterByItem.set(itemIds[0], 100); internal.retryAfterByItem.set(itemIds[1], 200); internal.retryStateByItem.set(itemIds[0], { freshRetryUsed: true, resumeHardResetUsed: false }); internal.retryStateByItem.set(itemIds[1], { freshRetryUsed: false, resumeHardResetUsed: true }); internal.pacedStartReservationByItem.set(itemIds[0], 100); internal.pacedStartReservationByItem.set(itemIds[1], 200); internal.standalonePackageResults.add("foreign-package:1"); manager.stop(); expect(internal.session.items[itemIds[0]]).toEqual(expect.objectContaining({ status: "queued", fullStatus: "Wartet" })); expect(internal.session.items[itemIds[1]]).toEqual(expect.objectContaining({ status: "reconnect_wait", fullStatus: "Unselektierter Backoff" })); expect(internal.retryAfterByItem.has(itemIds[0])).toBe(false); expect(internal.retryAfterByItem.get(itemIds[1])).toBe(200); expect(internal.retryStateByItem.has(itemIds[0])).toBe(false); expect(internal.retryStateByItem.has(itemIds[1])).toBe(true); expect(internal.pacedStartReservationByItem.has(itemIds[0])).toBe(false); expect(internal.pacedStartReservationByItem.get(itemIds[1])).toBe(200); expect(internal.standalonePackageResults.has("foreign-package:1")).toBe(true); expect(internal.suppressedPackageResults.has("foreign-package:1")).toBe(false); }); it("does not widen an exhausted selected scope to an unselected sibling", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-exhausted-")); tempDirs.push(root); const { manager, itemIds } = createSelectedItemManager(root); const internal = manager as any; internal.ensureScheduler = async () => {}; internal.triggerPendingExtractions = () => {}; await internal.startItemsNow([itemIds[0]]); internal.runItemIds.delete(itemIds[0]); internal.runPackageIds.clear(); expect(internal.findNextQueuedItem()).toBeNull(); expect(internal.getQueuePresence()).toEqual({ hasImmediate: false, hasDelayed: false }); internal.session.items[itemIds[1]].status = "reconnect_wait"; internal.session.items[itemIds[1]].fullStatus = "FREMDER_BACKOFF"; internal.retryAfterByItem.set(itemIds[1], Date.now() + 60_000); manager.stop(); expect(internal.session.items[itemIds[1]]).toEqual(expect.objectContaining({ status: "reconnect_wait", fullStatus: "FREMDER_BACKOFF" })); expect(internal.retryAfterByItem.has(itemIds[1])).toBe(true); }); it("resuming preserves item, disk and provider cooldown state", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-cooldowns-")); tempDirs.push(root); const { manager, itemIds } = createSelectedItemManager(root); const internal = manager as any; internal.ensureScheduler = async () => {}; internal.triggerPendingExtractions = () => {}; await internal.startItemsNow([itemIds[0]]); internal.session.paused = true; const now = Date.now(); internal.retryAfterByItem.set(itemIds[0], now + 11_000); internal.providerStartReservations.set("provider-key", now + 12_000); internal.pacedStartReservationByItem.set(itemIds[0], now + 13_000); internal.providerFailures.set("provider-key", { count: 1, lastFailAt: now, cooldownUntil: now + 14_000 }); manager.togglePause(); expect(internal.retryAfterByItem.get(itemIds[0])).toBe(now + 11_000); expect(internal.providerStartReservations.get("provider-key")).toBe(now + 12_000); expect(internal.pacedStartReservationByItem.get(itemIds[0])).toBe(now + 13_000); expect(internal.providerFailures.get("provider-key")?.cooldownUntil).toBe(now + 14_000); expect(internal.findNextQueuedItem()).toBeNull(); }); it("keeps a newly added package outside a selected running scope", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-add-package-")); tempDirs.push(root); const { manager, itemIds } = createSelectedItemManager(root); const internal = manager as any; internal.ensureScheduler = async () => {}; internal.triggerPendingExtractions = () => {}; await internal.startItemsNow([itemIds[0]]); manager.addPackages([{ name: "added-later", links: ["https://dummy/added-later"] }]); const addedPackageId = internal.session.packageOrder.at(-1); const addedItemId = internal.session.packages[addedPackageId].itemIds[0]; expect(internal.runPackageIds.has(addedPackageId)).toBe(false); expect(internal.runItemIds.has(addedItemId)).toBe(false); expect(internal.findNextQueuedItem()?.itemId).not.toBe(addedItemId); }); it("computes provider retry only from selected run items", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-provider-retry-")); tempDirs.push(root); const { manager, itemIds } = createSelectedItemManager(root); const internal = manager as any; internal.ensureScheduler = async () => {}; internal.triggerPendingExtractions = () => {}; await internal.startItemsNow([itemIds[0]]); const deadline = Date.now() + 30_000; internal.debridService.getBlockingProviderRetryAt = (url: string) => url.endsWith("/first") ? deadline : null; expect(internal.getEarliestProviderRetryAt(Date.now())).toBe(deadline); }); it.each(["items", "packages"] as const)("does not trigger foreign post-processing from a selected %s start", async (mode) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-selected-postprocess-${mode}-`)); tempDirs.push(root); const { manager, packageId, itemIds } = createSelectedItemManager(root); manager.addPackages([{ name: "foreign-completed", links: ["https://dummy/foreign.rar"] }]); const internal = manager as any; const foreignPackageId = internal.session.packageOrder.find((id: string) => id !== packageId); const foreignItemId = internal.session.packages[foreignPackageId].itemIds[0]; internal.session.items[foreignItemId].status = "completed"; internal.session.items[foreignItemId].downloadedBytes = 100; internal.session.items[foreignItemId].totalBytes = 100; internal.session.items[foreignItemId].progressPercent = 100; internal.session.items[foreignItemId].fullStatus = "Entpacken - Ausstehend"; internal.session.packages[foreignPackageId].status = "completed"; internal.ensureScheduler = async () => {}; const postProcess = vi.fn(async () => {}); internal.runPackagePostProcessing = postProcess; if (mode === "items") { await internal.startItemsNow([itemIds[0]]); } else { await internal.startPackagesNow([packageId]); } expect(postProcess).not.toHaveBeenCalledWith(foreignPackageId); expect(internal.runPackageIds.has(foreignPackageId)).toBe(false); expect(internal.runItemIds.has(foreignItemId)).toBe(false); }); }); 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) => manager.start(), ({ manager, packageId }: ReturnType) => manager.startPackages([packageId]), ({ manager, itemId }: ReturnType) => 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; }; internal.recoverRetryableItems = () => new Promise((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((_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((_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((_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; 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("keeps Start available while a configured account is temporarily cooling down", 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> = []; manager.on("state", (snapshot) => events.push(snapshot)); const waiting = manager.getSnapshot(); expect(waiting).toMatchObject({ canStart: true, lifecycle: { phase: "idle", retryAt: Date.parse("2026-08-22T08:00:01.000Z") } }); await vi.advanceTimersByTimeAsync(999); 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>; providerFailures: Map; session: ReturnType; }; 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(() => {})); 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; recoverRetryableItems: () => Promise; runPackagePostProcessing: (packageId: string) => Promise; }; internal.handlePackagePostProcessing = async (_requestedPackageId, signal) => { postProcessSignal = signal; await new Promise((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 { 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 { 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 => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; if (url === link) { checkCalls += 1; return new Response('

Show.S01E02.German.DL.part02.rar

502.00 MB

', { 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 = "Just a moment..."; globalThis.fetch = (async (input: RequestInfo | URL): Promise => { 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 = '

Restored.Show.S01E01.mkv

1.50 GB

'; 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 => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; if (url === missing) return new Response("

File Not Found

", { status: 200, headers: { "Content-Type": "text/html" } }); if (url === protectedLink) return new Response("Just a moment...", { 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 => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; if (url === link) { return new Response(`

${expectedName}

192 KB

`, { 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 => { 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 => { 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((resolve) => { resolveCheck = resolve; }); globalThis.fetch = (async (input: RequestInfo | URL): Promise => { 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 => { 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 => { 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; 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; const retryState = (manager as any).retryStateByItem as Map; 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("extractNow only re-arms completed items that are not already extracted", async () => { 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")) ); const staleController = new AbortController(); const restartPostProcessing = vi.fn(() => Promise.resolve()); (manager as any).packagePostProcessTasks.set(packageId, Promise.resolve()); (manager as any).packagePostProcessAbortControllers.set(packageId, staleController); (manager as any).runPackagePostProcessing = restartPostProcessing; session.packages[packageId].status = "paused"; await manager.extractNow(packageId); expect(staleController.signal.aborted).toBe(true); expect(restartPostProcessing).toHaveBeenCalledTimes(1); 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("extractNow on one multipart child arms only its complete archive set", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-child-")); tempDirs.push(root); const session = emptySession(); const packageId = "extract-child-pkg"; const outputDir = path.join(root, "downloads", "Extract Child"); const extractDir = path.join(root, "extract", "Extract Child"); fs.mkdirSync(outputDir, { recursive: true }); const createdAt = Date.now(); const specs = [ ["e01-1", "Episode.E01.part1.rar"], ["e01-2", "Episode.E01.part2.rar"], ["e02-1", "Episode.E02.part1.rar"], ["e02-2", "Episode.E02.part2.rar"] ] as const; session.packageOrder = [packageId]; session.packages[packageId] = { id: packageId, name: "Extract Child", outputDir, extractDir, status: "failed", itemIds: specs.map(([id]) => id), cancelled: false, enabled: true, createdAt, updatedAt: createdAt }; for (const [id, fileName] of specs) { const targetPath = path.join(outputDir, fileName); fs.writeFileSync(targetPath, Buffer.alloc(128, 3)); session.items[id] = { id, packageId, url: `https://example.invalid/${fileName}`, provider: "realdebrid", status: "completed", retries: 0, speedBps: 0, downloadedBytes: 128, totalBytes: 128, progressPercent: 100, fileName, targetPath, resumable: true, attempts: 1, lastError: "Keine entpackten Dateien erkannt", fullStatus: "Entpack-Fehler: Keine entpackten Dateien erkannt", createdAt, updatedAt: createdAt }; } const manager = new DownloadManager( { ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: true, hybridExtract: true }, session, createStoragePaths(path.join(root, "state")) ); const postProcess = vi.fn(async () => {}); (manager as any).runPackagePostProcessing = postProcess; manager.extractNow({ packageIds: [], itemIds: ["e01-2"] }); await waitFor(() => postProcess.mock.calls.length === 1); expect((manager as any).session.items["e01-1"].fullStatus).toBe("Entpacken - Ausstehend"); expect((manager as any).session.items["e01-2"].fullStatus).toBe("Entpacken - Ausstehend"); expect((manager as any).session.items["e02-1"].fullStatus).toMatch(/^Entpack-Fehler/); expect((manager as any).session.items["e02-2"].fullStatus).toMatch(/^Entpack-Fehler/); const filter = (manager as any).manualExtractArchiveFilters.get(packageId) as Set; expect([...filter].map((filePath) => path.basename(filePath).toLowerCase())).toEqual(["episode.e01.part1.rar"]); }); it("extractNow item selection runs only the selected archive through real post-processing", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-selected-real-")); tempDirs.push(root); const outputDir = path.join(root, "downloads", "Selected"); const extractDir = path.join(root, "extract", "Selected"); fs.mkdirSync(outputDir, { recursive: true }); const firstArchive = path.join(outputDir, "Episode.E01.zip"); const secondArchive = path.join(outputDir, "Episode.E02.zip"); const firstZip = new AdmZip(); firstZip.addFile("Episode.E01.mkv", Buffer.from("episode-one")); firstZip.writeZip(firstArchive); const secondZip = new AdmZip(); secondZip.addFile("Episode.E02.mkv", Buffer.from("episode-two")); secondZip.writeZip(secondArchive); const createdAt = Date.now(); const session = emptySession(); const packageId = "selected-real-package"; session.packageOrder = [packageId]; session.packages[packageId] = { id: packageId, name: "Selected", outputDir, extractDir, status: "failed", itemIds: ["selected-e01", "selected-e02"], cancelled: false, enabled: true, createdAt, updatedAt: createdAt }; for (const [id, archivePath] of [["selected-e01", firstArchive], ["selected-e02", secondArchive]] as const) { const size = fs.statSync(archivePath).size; session.items[id] = { id, packageId, url: `https://example.invalid/${path.basename(archivePath)}`, provider: "realdebrid", status: "completed", retries: 0, speedBps: 0, downloadedBytes: size, totalBytes: size, progressPercent: 100, fileName: path.basename(archivePath), targetPath: archivePath, resumable: true, attempts: 1, lastError: "Keine entpackten Dateien erkannt", fullStatus: "Entpack-Fehler: Keine entpackten Dateien erkannt", createdAt, updatedAt: createdAt }; } const manager = new DownloadManager( { ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: false, hybridExtract: true, cleanupMode: "none", removeLinkFilesAfterExtract: false, removeSamplesAfterExtract: false, autoRename4sf4sj: false, keepGermanAudioOnly: false }, session, createStoragePaths(path.join(root, "state")) ); manager.extractNow({ packageIds: [], itemIds: ["selected-e01"] }); await waitFor(() => fs.existsSync(path.join(extractDir, "Episode.E01.mkv")), 10_000); await waitFor(() => !(manager as any).packagePostProcessTasks.has(packageId), 10_000); await waitFor(() => !(manager as any).packageDeferredPostProcessTasks.has(packageId), 10_000); const snapshot = manager.getSnapshot().session; expect(snapshot.items["selected-e01"].fullStatus).toMatch(/^Entpackt/); expect(snapshot.items["selected-e02"].fullStatus).toMatch(/^Entpack-Fehler/); expect(snapshot.packages[packageId].status).toBe("failed"); expect(fs.existsSync(path.join(extractDir, "Episode.E02.mkv"))).toBe(false); }, 15_000); it.each([ ["package", false], ["package", true], ["item", false], ["item", true] ] as const)("extractNow %s runs with an open sibling while session paused=%s", async (scope, paused) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-extract-open-${scope}-${paused}-`)); tempDirs.push(root); const outputDir = path.join(root, "downloads", "Open sibling"); const extractDir = path.join(root, "extract", "Open sibling"); fs.mkdirSync(outputDir, { recursive: true }); const archivePath = path.join(outputDir, "Episode.E01.zip"); const zip = new AdmZip(); zip.addFile("Episode.E01.mkv", Buffer.from("episode-one")); zip.writeZip(archivePath); const archiveSize = fs.statSync(archivePath).size; const session = emptySession(); const packageId = `open-${scope}-${paused}`; const archiveItemId = `${packageId}-archive`; const queuedItemId = `${packageId}-queued`; const createdAt = Date.now() - 1000; session.packageOrder = [packageId]; session.packages[packageId] = { id: packageId, name: "Open sibling", outputDir, extractDir, status: paused ? "paused" : "queued", itemIds: [archiveItemId, queuedItemId], cancelled: false, enabled: true, createdAt, updatedAt: createdAt }; session.items[archiveItemId] = { id: archiveItemId, packageId, url: "https://dummy/Episode.E01.zip", provider: "realdebrid", status: "completed", retries: 0, speedBps: 0, downloadedBytes: archiveSize, totalBytes: archiveSize, progressPercent: 100, fileName: "Episode.E01.zip", targetPath: archivePath, resumable: true, attempts: 1, lastError: "", fullStatus: "Entpacken - Ausstehend", createdAt, updatedAt: createdAt }; session.items[queuedItemId] = { id: queuedItemId, packageId, url: "https://dummy/Episode.E02.zip", provider: "realdebrid", status: "queued", retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null, progressPercent: 0, fileName: "Episode.E02.zip", targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "Wartet", createdAt, updatedAt: createdAt }; const manager = new DownloadManager( { ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: false, hybridExtract: false, cleanupMode: "none", removeLinkFilesAfterExtract: false, removeSamplesAfterExtract: false, autoRename4sf4sj: false, keepGermanAudioOnly: false }, session, createStoragePaths(path.join(root, "state")) ); session.running = paused; session.paused = paused; manager.extractNow(scope === "package" ? { packageIds: [packageId], itemIds: [] } : { packageIds: [], itemIds: [archiveItemId] }); await waitFor(() => fs.existsSync(path.join(extractDir, "Episode.E01.mkv")), 10_000); await waitFor(() => !(manager as any).packagePostProcessTasks.has(packageId), 10_000); expect(session.items[archiveItemId].fullStatus).toMatch(/^Entpackt/); expect(session.items[queuedItemId]).toEqual(expect.objectContaining({ status: "queued", fullStatus: "Wartet" })); expect(session.packages[packageId].status).toBe(paused ? "paused" : "queued"); }, 15_000); it("assigns same-named archive failures only to the matching directory", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-scope-")); tempDirs.push(root); const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); const firstPath = path.join(root, "Season 01", "release.part1.rar"); const secondPath = path.join(root, "Season 02", "release.part1.rar"); const items = [ { id: "season-1", status: "completed", fullStatus: "Entpacken - Error", fileName: "release.part1.rar", targetPath: firstPath, downloadedBytes: 100 }, { id: "season-2", status: "completed", fullStatus: "Entpack-Fehler: Previous", fileName: "release.part1.rar", targetPath: secondPath, downloadedBytes: 100 } ] as unknown as DownloadItem[]; const failures = new Map([[firstPath.toLowerCase(), { archiveName: "release.part1.rar", archivePath: firstPath, errorText: "CRC failed" }]]); (manager as any).applyPackageExtractFailureStatuses( items, (archiveName: string, archivePath: string) => resolveArchiveItemsFromList(archiveName, items, archivePath), failures, "Entpacken fehlgeschlagen", new Map(items.map((item) => [item.id, item.fullStatus])), Date.now() ); expect(items[0].fullStatus).toMatch(/^Entpack-Fehler/); expect(items[1].fullStatus).toBe("Entpack-Fehler: Previous"); }); 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; 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; 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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) | 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((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 => { 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 => { 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 => { 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 => { 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 => { 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) => { 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; } 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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")) ); session.running = true; (manager as any).runItemIds.add("selected-item"); (manager as any).runPackageIds.add(packageId); for (const itemId of itemIds) { (manager as any).runOutcomes.set(itemId, "completed"); } const changed = (manager as any).autoRecoverArchiveCrcFailure( session.packages[packageId], itemIds.map((itemId) => session.items[itemId]!), { archiveName: "show.s01e01.part1.rar", archivePath: path.join(outputDir, "show.s01e01.part1.rar"), errorText: "Checksum error in the encrypted file", category: "crc_error", suggestRedownload: true, jvmFailureReason: "7z-Fehler: CRCERROR" }, "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("downloading"); for (const itemId of itemIds) { expect((manager as any).runItemIds.has(itemId)).toBe(true); expect((manager as any).runOutcomes.has(itemId)).toBe(false); } }); 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", archivePath: path.join(outputDir, "show.s01e01.part1.rar"), errorText: "Checksum error in the encrypted file", category: "crc_error", suggestRedownload: true, jvmFailureReason: "7z-Fehler: CRCERROR" }, "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", archivePath: path.join(outputDir, "show.s01e01.part1.rar"), errorText: "Checksum error in the encrypted file", category: "crc_error", suggestRedownload: true, jvmFailureReason: "7z-Fehler: CRCERROR" }, "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); } for (const archiveName of archiveNames) { fs.writeFileSync(path.join(outputDir, archiveName), Buffer.alloc(archiveSize, 0x7f)); } const wrongPasswordChanged = (manager as any).autoRecoverArchiveCrcFailure( session.packages[packageId], itemIds.map((itemId) => session.items[itemId]!), { archiveName: "show.s01e01.part1.rar", archivePath: path.join(outputDir, "show.s01e01.part1.rar"), errorText: "Wrong password", category: "wrong_password", suggestRedownload: true, jvmFailureReason: "WRONG_PASSWORD" }, "hybrid" ); expect(wrongPasswordChanged).toBe(0); expect(itemIds.map((itemId) => session.items[itemId]!.status)).toEqual(["completed", "completed"]); expect(archiveNames.map((archiveName) => fs.existsSync(path.join(outputDir, archiveName)))).toEqual([true, true]); }); 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")) ); expect((manager as any).looksLikeArchivePart("show.s01e01.part2.rar", "show.s01e01.part1.rar")).toBe(true); const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]); expect(Array.from(ready)).toEqual([]); }); 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("retries a complete archive that was marked attempted without a terminal extraction result", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-hybrid-stale-attempt-")); tempDirs.push(root); const outputDir = path.join(root, "downloads", "stale-attempt"); const extractDir = path.join(root, "extract", "stale-attempt"); fs.mkdirSync(outputDir, { recursive: true }); const archivePath = path.join(outputDir, "Episode.E01.zip"); const zip = new AdmZip(); zip.addFile("Episode.E01.mkv", Buffer.from("episode")); zip.writeZip(archivePath); const archiveSize = fs.statSync(archivePath).size; const session = emptySession(); const packageId = "stale-attempt-pkg"; const itemId = "stale-attempt-item"; const createdAt = Date.now() - 1000; session.packageOrder = [packageId]; session.packages[packageId] = { id: packageId, name: "stale-attempt", outputDir, extractDir, status: "queued", itemIds: [itemId], cancelled: false, enabled: true, createdAt, updatedAt: createdAt }; session.items[itemId] = { id: itemId, packageId, url: "https://dummy/Episode.E01.zip", provider: "realdebrid", status: "completed", retries: 0, speedBps: 0, downloadedBytes: archiveSize, totalBytes: archiveSize, progressPercent: 100, fileName: "Episode.E01.zip", targetPath: archivePath, resumable: true, attempts: 1, lastError: "", fullStatus: "Entpacken - Warten auf Parts", createdAt, updatedAt: createdAt }; const manager = new DownloadManager( { ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: true, hybridExtract: true, cleanupMode: "none", autoRename4sf4sj: false, keepGermanAudioOnly: false }, session, createStoragePaths(path.join(root, "state")) ); (manager as any).hybridExtractedPaths.set(packageId, new Set([archivePath.toLowerCase()])); const extracted = await (manager as any).runHybridExtraction(packageId, session.packages[packageId], [session.items[itemId]]); expect(extracted).toBe(1); expect(fs.existsSync(path.join(extractDir, "Episode.E01.mkv"))).toBe(true); expect(session.items[itemId].fullStatus).toMatch(/^Entpackt/); }, 10_000); 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(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, _archivePath?: 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", { archiveName: "show.s01e01.part1.rar", archivePath: path.resolve("show.s01e01.part1.rar"), errorText: "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(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, archivePath: string) => resolveArchiveItemsFromList(archiveName, completedItems, archivePath), new Map([["show.s01e01.part1.rar", { archiveName: "show.s01e01.part1.rar", archivePath: path.resolve("show.s01e01.part1.rar"), errorText: "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"); const episodePath = path.join(extractDir, "episode.mkv"); fs.writeFileSync(episodePath, "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 }; registerPackageCompleteOutputs(session.packages[packageId], [episodePath]); 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 as any).manualExtractArchiveFilters.set(packageId, new Set([targetPath])); (manager as any).manualExtractPackages.add(packageId); 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); expect((manager as any).manualExtractArchiveFilters.size).toBe(0); expect((manager as any).manualExtractPackages.size).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; 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 => { 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 => { 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("temporary error", "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 => { 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 => { 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 => { 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("Rapidgator", { 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((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((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((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, megaDebridAccountDailyUsageBytes: { [acctId]: 9_000_000_000 } as Record, megaDebridAccountDailyLimitBytes: { [acctId]: 1_000_000_000 } as Record }, 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 without discarding definitive link availability", async () => { 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 as any).manualExtractArchiveFilters.set(packageId, new Set(["stale-archive"])); await manager.resetItems(itemIds); expect((manager as any).manualExtractArchiveFilters.has(packageId)).toBe(false); const snapshot = manager.getSnapshot().session; expect(snapshot.packages[packageId]).toEqual(expect.objectContaining({ status: "queued", postProcessLabel: undefined, downloadCompletedAt: 0 })); for (const itemId of itemIds) { expect(snapshot.items[itemId]).toEqual(expect.objectContaining({ status: "queued", downloadedBytes: 0, totalBytes: null, progressPercent: 0, lastError: "", fullStatus: "Wartet", onlineStatus: "online" })); } await manager.resetPackage(packageId); const packageSnapshot = manager.getSnapshot().session; for (const itemId of itemIds) { expect(packageSnapshot.items[itemId].onlineStatus).toBe("online"); } }); it("requeues confirmed corrupt volumes independently when one part is locked", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-crc-specific-volume-")); tempDirs.push(root); const session = emptySession(); const packageId = "crc-specific-volume-pkg"; const outputDir = path.join(root, "downloads", "crc-specific-volume"); const extractDir = path.join(root, "extract", "crc-specific-volume"); fs.mkdirSync(outputDir, { recursive: true }); const archiveNames = ["show.part1.rar", "show.part2.rar", "show.part3.rar"]; const itemIds = archiveNames.map((_, index) => `crc-specific-${index + 1}`); const createdAt = Date.now() - 1000; session.packageOrder = [packageId]; session.packages[packageId] = { id: packageId, name: "crc-specific-volume", outputDir, extractDir, status: "extracting", itemIds, cancelled: false, enabled: true, resultGeneration: 4, createdAt, updatedAt: createdAt }; for (const [index, archiveName] of archiveNames.entries()) { const targetPath = path.join(outputDir, archiveName); const bytes = Buffer.alloc(64 * 1024, index + 1); Buffer.from("526172211a070100", "hex").copy(bytes, 0); fs.writeFileSync(targetPath, bytes); session.items[itemIds[index]] = { id: itemIds[index], packageId, url: `https://dummy/${archiveName}`, provider: "realdebrid", status: "completed", retries: 0, speedBps: 0, downloadedBytes: bytes.length, totalBytes: bytes.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, extractDir, autoExtract: true }, session, createStoragePaths(path.join(root, "state")) ); const failure = { archiveName: "show.part1.rar", archivePath: path.join(outputDir, "show.part1.rar"), errorText: `Prüfsummenfehler der gepackten Daten in Volume ${path.join(outputDir, "show.part3.rar")} Corrupt file or wrong password`, category: "crc_error", suggestRedownload: true, jvmFailureReason: "7z-Fehler: CRCERROR" } as const; session.items[itemIds[0]].downloadedBytes = 0; const lockedPath = path.join(outputDir, "show.part1.rar"); const implicatedPath = path.join(outputDir, "show.part3.rar"); const originalRmSync = fs.rmSync.bind(fs); const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((targetPath: fs.PathLike, options?: fs.RmDirOptions) => { if (path.resolve(String(targetPath)) === path.resolve(lockedPath)) { throw Object.assign(new Error("locked"), { code: "EPERM" }); } return originalRmSync(targetPath, options as never); }) as typeof fs.rmSync); const blockedChanged = (manager as any).autoRecoverArchiveCrcFailure( session.packages[packageId], itemIds.map((itemId) => session.items[itemId]), failure, "full" ); rmSpy.mockRestore(); expect(blockedChanged).toBe(1); expect(session.items[itemIds[0]]).toEqual(expect.objectContaining({ status: "completed", targetPath: lockedPath })); expect(session.items[itemIds[2]]).toEqual(expect.objectContaining({ status: "queued", targetPath: "" })); expect(fs.existsSync(lockedPath)).toBe(true); expect(fs.existsSync(implicatedPath)).toBe(false); fs.writeFileSync(implicatedPath, Buffer.alloc(64 * 1024, 3)); Object.assign(session.items[itemIds[2]], { status: "completed", targetPath: implicatedPath, downloadedBytes: 64 * 1024, totalBytes: 64 * 1024, progressPercent: 100, fullStatus: "Entpacken - Ausstehend", updatedAt: Date.now() }); const secondChanged = (manager as any).autoRecoverArchiveCrcFailure( session.packages[packageId], itemIds.map((itemId) => session.items[itemId]), failure, "full" ); expect(secondChanged).toBe(1); expect(session.items[itemIds[0]]).toEqual(expect.objectContaining({ status: "queued", targetPath: "" })); expect(session.items[itemIds[1]].status).toBe("completed"); expect(session.items[itemIds[2]]).toEqual(expect.objectContaining({ status: "completed", targetPath: implicatedPath })); expect(fs.existsSync(path.join(outputDir, archiveNames[0]))).toBe(false); expect(fs.existsSync(path.join(outputDir, archiveNames[1]))).toBe(true); expect(fs.existsSync(path.join(outputDir, archiveNames[2]))).toBe(true); fs.writeFileSync(lockedPath, Buffer.alloc(64 * 1024, 1)); Object.assign(session.items[itemIds[0]], { status: "completed", targetPath: lockedPath, downloadedBytes: 64 * 1024, totalBytes: 64 * 1024, progressPercent: 100, fullStatus: "Entpacken - Ausstehend", updatedAt: Date.now() }); const repeatedChanged = (manager as any).autoRecoverArchiveCrcFailure( session.packages[packageId], itemIds.map((itemId) => session.items[itemId]), failure, "full" ); expect(repeatedChanged).toBe(0); expect(fs.existsSync(lockedPath)).toBe(true); expect(fs.existsSync(implicatedPath)).toBe(true); }); 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 => { convCalls += 1; return await new Promise(() => { /* 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 => { 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((_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 => { 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((_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 => { 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; }; 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 => { 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 = []; 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 => { 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((resolve) => { releaseTask = resolve; }); let releaseHybridTask = (): void => {}; const hybridTask = new Promise((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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 }; writePackageOutputOwnerMarker(session.packages[packageId]); 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"); writePackageOutputOwnerMarker(session.packages[packageId]); 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"); writePackageOutputOwnerMarker(session.packages[packageId]); 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((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 }; registerPackageCompleteOutputs(session.packages[packageId], [ path.join(extractDir, "episode.links.txt"), path.join(extractDir, "sample", "sample.mkv") ]); 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("deletes only package-owned archive members from a shared download root", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shared-cleanup-")); tempDirs.push(root); const outputDir = path.join(root, "downloads"); const extractDir = path.join(root, "extract", "owned-package"); fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(extractDir, { recursive: true }); const ownedPaths = [path.join(outputDir, "owned.part1.rar"), path.join(outputDir, "owned.part2.rar")]; const foreignPaths = [path.join(outputDir, "foreign.part1.rar"), path.join(outputDir, "foreign.part2.rar")]; for (const archivePath of [...ownedPaths, ...foreignPaths]) { fs.writeFileSync(archivePath, archivePath); } const extractedPath = path.join(extractDir, "episode.mkv"); fs.writeFileSync(extractedPath, "video"); const session = emptySession(); const packageId = "owned-package"; const createdAt = Date.now() - 20_000; session.packageOrder = [packageId]; session.packages[packageId] = { id: packageId, name: packageId, outputDir, extractDir, status: "completed", itemIds: ["owned-part-1", "owned-part-2"], cancelled: false, enabled: true, createdAt, updatedAt: createdAt }; for (let index = 0; index < ownedPaths.length; index += 1) { const itemId = `owned-part-${index + 1}`; session.items[itemId] = { id: itemId, packageId, url: `https://example.com/${path.basename(ownedPaths[index])}`, provider: "realdebrid", status: "completed", retries: 0, speedBps: 0, downloadedBytes: fs.statSync(ownedPaths[index]).size, totalBytes: fs.statSync(ownedPaths[index]).size, progressPercent: 100, fileName: path.basename(ownedPaths[index]), targetPath: ownedPaths[index], resumable: true, attempts: 1, lastError: "", fullStatus: "Entpackt - Done (1s)", createdAt, updatedAt: createdAt }; } registerPackageCompleteOutputs(session.packages[packageId], [extractedPath]); const manager = new DownloadManager( { ...defaultSettings(), outputDir, extractDir: path.join(root, "extract"), autoExtract: true, autoRename4sf4sj: false, collectMkvToLibrary: false, removeLinkFilesAfterExtract: false, removeSamplesAfterExtract: false, enableIntegrityCheck: false, cleanupMode: "delete" }, session, createStoragePaths(path.join(root, "state")) ); await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1); expect(ownedPaths.map((archivePath) => fs.existsSync(archivePath))).toEqual([false, false]); expect(foreignPaths.map((archivePath) => fs.existsSync(archivePath))).toEqual([true, true]); }); 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 => { 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 => { 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 }; writePackageOutputOwnerMarker(session.packages[packageId]); 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 }; writePackageOutputOwnerMarker(session.packages[packageId]); 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 }; writePackageOutputOwnerMarker(session.packages[packageId]); 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 }; writePackageOutputOwnerMarker(session.packages[packageId]); 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 }; writePackageOutputOwnerMarker(session.packages[packageId]); 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 }; writePackageOutputOwnerMarker(session.packages[packageId]); 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; registerPackageCompleteOutputs(session.packages[packageId], [scenePath]); 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); const outputScope = (manager as any).getPackageOutputScope(session.packages[packageId]); await (manager as any).autoRenameExtractedVideoFiles(extractDir, outputScope, 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"; registerPackageCompleteOutputs(session.packages[packageId], [path.join(epFolder, sceneName)]); 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" ]; 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")) ); expect(await (manager as any).ensurePackageOutputOwnerMarker(session.packages[packageId])).toBe(true); const ownerMarkerPath = path.join(extractDir, ".rd-package-output-owner-v1.json"); const ownerMarker = JSON.parse(fs.readFileSync(ownerMarkerPath, "utf8")); expect(ownerMarker).toEqual(expect.objectContaining({ version: 1, packageId, generation: 1, ownerId: session.packages[packageId].outputOwnerId })); for (const mkv of s01Mkvs) { fs.writeFileSync(path.join(extractDir, mkv), Buffer.alloc(4096, 9)); } 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 adopt foreign files from a reused package-name directory without its generation owner marker", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-reused-owner-")); tempDirs.push(root); const packageName = "reused-package"; const extractDir = path.join(root, "extract", packageName); const libraryDir = path.join(root, "library"); fs.mkdirSync(extractDir, { recursive: true }); const foreignPath = path.join(extractDir, "foreign.mkv"); fs.writeFileSync(foreignPath, "foreign"); const session = emptySession(); const packageId = "reused-package-id"; session.packageOrder = [packageId]; session.packages[packageId] = { id: packageId, name: packageName, outputDir: path.join(root, "downloads", packageName), extractDir, status: "completed", itemIds: [], cancelled: false, enabled: true, outputProvenanceVersion: 1, outputRecords: [], createdAt: 1_000, updatedAt: 1_000 }; const manager = new DownloadManager( { ...defaultSettings(), autoExtract: true, createExtractSubfolder: true, collectMkvToLibrary: true, mkvLibraryDir: libraryDir }, session, createStoragePaths(path.join(root, "state")) ); await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]); expect(fs.existsSync(foreignPath)).toBe(true); expect(fs.existsSync(path.join(libraryDir, "foreign.mkv"))).toBe(false); expect(session.packages[packageId].outputRecords).toEqual([]); expect(fs.existsSync(path.join(extractDir, ".rd-package-output-owner-v1.json"))).toBe(false); }); it("creates a package owner marker without hardlink support", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-no-link-")); tempDirs.push(root); const packageName = "no-link-package"; const session = emptySession(); const packageId = "no-link-package-id"; session.packageOrder = [packageId]; session.packages[packageId] = { id: packageId, name: packageName, outputDir: path.join(root, "downloads", packageName), extractDir: path.join(root, "extract", packageName), status: "completed", itemIds: [], cancelled: false, enabled: true, createdAt: 1_000, updatedAt: 1_000 }; const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state"))); const linkSpy = vi.spyOn(fs.promises, "link").mockRejectedValue(Object.assign(new Error("link unsupported"), { code: "ENOTSUP" })); try { await expect((manager as any).ensurePackageOutputOwnerMarker(session.packages[packageId])).resolves.toBe(true); expect(fs.existsSync(path.join(session.packages[packageId].extractDir, ".rd-package-output-owner-v1.json"))).toBe(true); } finally { linkSpy.mockRestore(); } }); it("continues direct scoped output when owner marker creation is denied", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-denied-")); tempDirs.push(root); const packageName = "denied-marker-package"; const session = emptySession(); const packageId = "denied-marker-package-id"; const pkg: PackageEntry = { id: packageId, name: packageName, outputDir: path.join(root, "downloads", packageName), extractDir: path.join(root, "extract", packageName), status: "completed", itemIds: [], cancelled: false, enabled: true, createdAt: 1_000, updatedAt: 1_000 }; session.packageOrder = [packageId]; session.packages[packageId] = pkg; const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state"))); const originalOpen = fs.promises.open.bind(fs.promises); const openSpy = vi.spyOn(fs.promises, "open").mockImplementation(async (filePath: any, ...args: any[]) => { if (String(filePath).includes("rd-package-output-owner-v1")) { throw Object.assign(new Error("marker denied"), { code: "EPERM" }); } return originalOpen(filePath, ...(args as [any, any])); }); try { await expect((manager as any).runWithPackageOutputProvenance(pkg, async (targetDir: string, scope: any) => { const outputPath = path.join(targetDir, "owned.mkv"); fs.writeFileSync(outputPath, "owned"); scope.add({ version: 1, archivePath: path.join(pkg.outputDir, "archive.rar"), entryPath: "owned.mkv", outputPath, state: "complete", disposition: "written" }); })).resolves.toBeUndefined(); } finally { openSpy.mockRestore(); } expect(fs.readFileSync(path.join(pkg.extractDir, "owned.mkv"), "utf8")).toBe("owned"); expect(pkg.outputRecords).toEqual([expect.objectContaining({ entryPath: "owned.mkv" })]); expect(fs.existsSync(path.join(pkg.extractDir, ".rd-package-output-owner-v1.json"))).toBe(false); }); it("rejects a replayed owner marker from an older package generation", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-replay-")); tempDirs.push(root); const packageName = "replay-package"; const extractDir = path.join(root, "extract", packageName); const libraryDir = path.join(root, "library"); fs.mkdirSync(extractDir, { recursive: true }); const foreignPath = path.join(extractDir, "foreign.mkv"); fs.writeFileSync(foreignPath, "foreign"); const ownerId = crypto.randomUUID().toLowerCase(); fs.writeFileSync(path.join(extractDir, ".rd-package-output-owner-v1.json"), JSON.stringify({ version: 1, packageId: "replay-package-id", generation: 1, ownerId })); const session = emptySession(); const packageId = "replay-package-id"; session.packageOrder = [packageId]; session.packages[packageId] = { id: packageId, name: packageName, outputDir: path.join(root, "downloads", packageName), extractDir, status: "completed", itemIds: [], cancelled: false, enabled: true, outputProvenanceVersion: 1, outputRecords: [], outputOwnerId: ownerId, outputOwnerGeneration: 1, resultGeneration: 2, createdAt: 1_000, updatedAt: 1_000 }; const manager = new DownloadManager( { ...defaultSettings(), autoExtract: true, collectMkvToLibrary: true, mkvLibraryDir: libraryDir }, session, createStoragePaths(path.join(root, "state")) ); await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]); expect(fs.existsSync(foreignPath)).toBe(true); expect(fs.existsSync(path.join(libraryDir, "foreign.mkv"))).toBe(false); expect(session.packages[packageId].outputRecords).toEqual([]); }); it.each([ ["stale-generation", 2, 1, "11111111-1111-4111-8111-111111111111", "11111111-1111-4111-8111-111111111111"], ["stale-owner", 1, 1, "22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-333333333333"] ] as const)("keeps a %s marker unchanged while direct scoped output continues", async (_label, currentGeneration, markerGeneration, currentOwner, markerOwner) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-stale-empty-")); tempDirs.push(root); const packageName = "stale-empty-package"; const extractDir = path.join(root, "extract", packageName); fs.mkdirSync(extractDir, { recursive: true }); const packageId = "stale-empty-package-id"; const markerPath = path.join(extractDir, ".rd-package-output-owner-v1.json"); const markerContent = JSON.stringify({ version: 1, packageId, generation: markerGeneration, ownerId: markerOwner }); fs.writeFileSync(markerPath, markerContent); const session = emptySession(); const pkg: PackageEntry = { id: packageId, name: packageName, outputDir: path.join(root, "downloads", packageName), extractDir, status: "completed", itemIds: [], cancelled: false, enabled: true, outputOwnerId: currentOwner, outputOwnerGeneration: currentGeneration, resultGeneration: currentGeneration, createdAt: 1_000, updatedAt: 1_000 }; session.packageOrder = [packageId]; session.packages[packageId] = pkg; const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state"))); await (manager as any).runWithPackageOutputProvenance(pkg, async (targetDir: string, scope: any) => { const outputPath = path.join(targetDir, "owned.mkv"); fs.writeFileSync(outputPath, "owned"); scope.add({ version: 1, archivePath: path.join(pkg.outputDir, "archive.rar"), entryPath: "owned.mkv", outputPath, state: "complete", disposition: "written" }); }); expect(fs.readFileSync(markerPath, "utf8")).toBe(markerContent); expect(fs.readFileSync(path.join(extractDir, "owned.mkv"), "utf8")).toBe("owned"); expect(pkg.outputRecords).toEqual([expect.objectContaining({ entryPath: "owned.mkv" })]); }); 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 => { 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; 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; runPackageIds: Set; runOutcomes: Map; }; 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; }; 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; runOutcomes: Map; runCompletedPackages: Set; 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; }; 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).megadebrid).toBeUndefined(); expect(internal.settings.providerTotalUsageBytes.realdebrid).toBe(2048); expect(internal.settings.providerTotalUsageBytes["megadebrid-api"]).toBe(1024); expect((internal.settings.providerTotalUsageBytes as Record).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; }; 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 => { 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 scope = registerPackageCompleteOutputs(pkg, episodes.map((ep) => path.join(extractDir, ep.folder, ep.file))); const [n1, n2] = await Promise.all([ (manager as any).autoRenameExtractedVideoFiles(extractDir, scope, pkg), (manager as any).autoRenameExtractedVideoFiles(extractDir, scope, 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 => { 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 }; registerPackageCompleteOutputs(pkg, [ path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.mkv"), path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.srt"), path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.nfo") ]); 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 scope = registerPackageCompleteOutputs(pkg, [ path.join(epFolder, "awa-testshow02e05hd.mkv"), path.join(epFolder, "awa-testshow02e05hd.srt"), path.join(epFolder, "awa-testshow02e05hd.de.srt"), path.join(epFolder, "awa-testshow02e05hd.nfo") ]); const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, scope, 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 scope = registerPackageCompleteOutputs(pkg, [ path.join(epFolder, "awa-testshow02e05hd.mkv"), path.join(epFolder, "awa-testshow02e05hd.alt.mkv") ]); const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, scope, 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("does not flag a fresh package when its extract dir contains only current or atomic owner markers", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-markers-")); tempDirs.push(root); const storagePaths = createStoragePaths(path.join(root, "state")); initPackageLogs(storagePaths.baseDir); initItemLogs(storagePaths.baseDir); const session = emptySession(); const packageId = "pkg-marker-only"; const extractDir = path.join(root, "extract", "MarkerOnly"); const ownerId = crypto.randomUUID().toLowerCase(); fs.mkdirSync(extractDir, { recursive: true }); fs.writeFileSync(path.join(extractDir, ".rd-package-output-owner-v1.json"), JSON.stringify({ version: 1, packageId, generation: 1, ownerId })); fs.writeFileSync(path.join(extractDir, `..rd-package-output-owner-v1.json.${crypto.randomUUID()}.tmp`), "{}"); session.packageOrder = [packageId]; session.packages[packageId] = { id: packageId, name: "MarkerOnly", outputDir: path.join(root, "downloads", "MarkerOnly"), extractDir, status: "queued", itemIds: ["marker-pending"], cancelled: false, enabled: true, outputOwnerId: ownerId, outputOwnerGeneration: 1, resultGeneration: 1, createdAt: Date.now(), updatedAt: Date.now() } as any; session.items["marker-pending"] = makeItem("marker-pending", packageId, "queued", "marker.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("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; } { 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("selective extraction cancellation", () => { it("stops only the selected run when two generations share one package id", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-run-cancel-")); tempDirs.push(root); const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); const state = manager as any; const coordinator = new ExtractionCoordinator(2); state.extractionCoordinator = coordinator; const packageId = "shared-package"; const operationA = await coordinator.beginOperation({ context: { operationId: "operation-a", packageId, generation: 1, runOwnerId: "run-a" }, targetPath: path.join(root, "out"), members: [] }); const operationB = await coordinator.beginOperation({ context: { operationId: "operation-b", packageId, generation: 2, runOwnerId: "run-b" }, targetPath: path.join(root, "out"), members: [] }); const started = new Set(); let completeRunA = (): void => {}; const jobA = coordinator.scheduleArchive(operationA, "archive-a", (signal) => new Promise((resolve, reject) => { started.add("run-a"); completeRunA = resolve; signal.addEventListener("abort", () => reject(new Error(String(signal.reason || "aborted"))), { once: true }); })); const jobB = coordinator.scheduleArchive(operationB, "archive-b", (signal) => new Promise((_resolve, reject) => { started.add("run-b"); signal.addEventListener("abort", () => reject(new Error(String(signal.reason || "aborted"))), { once: true }); })); const outcomeA = jobA.then(() => "completed", (error) => `cancelled:${String(error)}`); const outcomeB = jobB.then(() => "completed", (error) => `cancelled:${String(error)}`); await vi.waitFor(() => expect(started).toEqual(new Set(["run-a", "run-b"]))); const controllerA = new AbortController(); const controllerB = new AbortController(); state.packageHybridPostProcessControllers.set(packageId, new Set([controllerA, controllerB])); state.packageHybridRunOwnerByController.set(controllerA, "run-a"); state.packageHybridRunOwnerByController.set(controllerB, "run-b"); state.abortPostProcessing("stop", "run-b"); await vi.waitFor(() => expect(controllerB.signal.aborted).toBe(true)); expect(controllerA.signal.aborted).toBe(false); expect(await outcomeB).toContain("stop"); completeRunA(); expect(await outcomeA).toBe("completed"); await Promise.all([operationA.finalize(), operationB.finalize()]); }); }); 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((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("holds one deduplicated multipart lease through child close and output-scope finalization", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-coordinated-lease-")); tempDirs.push(root); const outputDir = path.join(root, "downloads"); const extractDir = path.join(root, "extract"); fs.mkdirSync(outputDir, { recursive: true }); const firstPart = path.join(outputDir, "release.part1.rar"); const secondPart = path.join(outputDir, "release.part2.rar"); fs.writeFileSync(firstPart, Buffer.alloc(100)); fs.writeFileSync(secondPart, Buffer.alloc(200)); const session = emptySession(); const pkg: PackageEntry = { id: "coordinated-lease", name: "coordinated-lease", outputDir, extractDir, status: "completed", itemIds: [], cancelled: false, enabled: true, resultGeneration: 3, createdAt: 1_000, updatedAt: 1_000 }; session.packages[pkg.id] = pkg; session.packageOrder = [pkg.id]; const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state"))); const state = manager as any; state.extractionCoordinator = new ExtractionCoordinator(1); state.diskReservations = new DiskReservationCoordinator({ safetyBytes: 0, statVolume: async () => ({ path: extractDir, volumeKey: "extract-volume", freeBytes: 10_000, totalBytes: 20_000 }) }); const originalSync = state.syncPackageOutputScope.bind(state); const reservationsDuringFinalization: number[] = []; state.syncPackageOutputScope = (entry: PackageEntry, scope: unknown) => { reservationsDuringFinalization.push(state.diskReservations.getReservedBytesByVolume().get("extract-volume") || 0); return originalSync(entry, scope); }; let closeChild = () => {}; const childClosed = new Promise((resolve) => { closeChild = resolve; }); let completed = false; const extraction = state.runCoordinatedExtraction( pkg, [firstPart, firstPart.toUpperCase(), secondPart], undefined, async (_targetDir: string, _scope: unknown, scheduleArchive: (archivePath: string, execute: (signal: AbortSignal) => Promise) => Promise) => { await scheduleArchive(firstPart, async () => childClosed); } ).then(() => { completed = true; }); await vi.waitFor(() => expect(state.diskReservations.getReservedBytesByVolume().get("extract-volume")).toBe(300)); expect(completed).toBe(false); closeChild(); await extraction; expect(reservationsDuringFinalization).toContain(300); expect(state.diskReservations.getReservedBytesByVolume().get("extract-volume")).toBe(0); }); 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("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); }); }); describe("post-processing lifecycle audit", () => { function createCompletedFileManager( root: string, entries: Array<{ packageId: string; itemId: string; fileName: string; url?: string; content?: Buffer }>, settings: Partial = {} ): { manager: DownloadManager; session: ReturnType } { const session = emptySession(); const createdAt = Date.now() - 10_000; for (const entry of entries) { const outputDir = path.join(root, "downloads", entry.packageId); const extractDir = path.join(root, "extract", entry.packageId); fs.mkdirSync(outputDir, { recursive: true }); const targetPath = path.join(outputDir, entry.fileName); fs.writeFileSync(targetPath, entry.content ?? Buffer.alloc(256, 1)); if (!session.packages[entry.packageId]) { session.packageOrder.push(entry.packageId); session.packages[entry.packageId] = { id: entry.packageId, name: entry.packageId, outputDir, extractDir, status: "completed", itemIds: [], cancelled: false, enabled: true, createdAt, updatedAt: createdAt }; } session.packages[entry.packageId].itemIds.push(entry.itemId); const bytes = fs.statSync(targetPath).size; session.items[entry.itemId] = { id: entry.itemId, packageId: entry.packageId, url: entry.url ?? `https://example.test/${entry.itemId}`, provider: "realdebrid", status: "completed", retries: 0, speedBps: 0, downloadedBytes: bytes, totalBytes: bytes, progressPercent: 100, fileName: entry.fileName, 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: false, autoRename4sf4sj: false, collectMkvToLibrary: false, enableIntegrityCheck: false, cleanupMode: "none", ...settings }, session, createStoragePaths(path.join(root, "state")) ); return { manager, session }; } it("requeues an interrupted integrity check and clears transient package progress on restart", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-restart-integrity-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [{ packageId: "integrity-package", itemId: "integrity-item", fileName: "episode.mkv" }]); const internal = manager as any; session.items["integrity-item"].status = "integrity_check"; session.items["integrity-item"].fullStatus = "CRC-Check läuft"; session.packages["integrity-package"].status = "integrity_check"; session.packages["integrity-package"].postProcessLabel = "Finalisieren (1/1)"; internal.normalizeSessionStatuses(); expect(session.items["integrity-item"]).toEqual(expect.objectContaining({ status: "queued", fullStatus: "Wartet" })); expect(session.packages["integrity-package"].status).toBe("queued"); expect(session.packages["integrity-package"].postProcessLabel).toBeUndefined(); }); it("keeps duplicate-suffixed items with independently existing files separate on startup", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-distinct-urls-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [ { packageId: "distinct-package", itemId: "first-url", fileName: "episode.rar", url: "https://same.test/file" }, { packageId: "distinct-package", itemId: "second-url", fileName: "episode (1).rar", url: "https://same.test/file" } ]); const snapshot = manager.getSnapshot().session; expect(snapshot.packages["distinct-package"].itemIds).toEqual(["first-url", "second-url"]); expect(snapshot.items["first-url"]).toBeDefined(); expect(snapshot.items["second-url"]).toBeDefined(); expect(fs.existsSync(session.items["second-url"].targetPath)).toBe(true); }); it("moves startup cleanup archives to the recoverable trash in trash mode", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-trash-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [{ packageId: "trash-package", itemId: "trash-item", fileName: "episode.part1.rar" }]); const internal = manager as any; session.items["trash-item"].fullStatus = "Entpackt - Fertig"; internal.settings.cleanupMode = "trash"; const archivePath = session.items["trash-item"].targetPath; await internal.cleanupExistingExtractedArchives(); expect(fs.existsSync(archivePath)).toBe(false); expect(fs.readdirSync(path.join(path.dirname(archivePath), ".rd-trash"))).toHaveLength(1); }); it("does not let queued startup cleanup remove a newly replaced archive", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-cleanup-race-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [{ packageId: "cleanup-race-package", itemId: "cleanup-race-item", fileName: "episode.rar" }]); const internal = manager as any; const archivePath = session.items["cleanup-race-item"].targetPath; session.items["cleanup-race-item"].fullStatus = "Entpackt - Fertig"; internal.settings.cleanupMode = "delete"; let releaseQueue = (): void => {}; const queueGate = new Promise((resolve) => { releaseQueue = resolve; }); internal.cleanupQueue = queueGate; const cleanup = internal.cleanupExistingExtractedArchives(); await waitFor(() => internal.cleanupQueue !== queueGate, 2_000); fs.writeFileSync(archivePath, Buffer.from("replacement")); releaseQueue(); await cleanup; expect(fs.readFileSync(archivePath, "utf8")).toBe("replacement"); }); it("never cleans an archive part claimed by another package in a shared output directory", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cleanup-shared-claim-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [ { packageId: "cleanup-owner-a", itemId: "cleanup-owner-a-item", fileName: "show.part1.rar" }, { packageId: "cleanup-owner-b", itemId: "cleanup-owner-b-item", fileName: "show.part2.rar" }, { packageId: "cleanup-owner-b", itemId: "cleanup-owner-b-nfo", fileName: "show.nfo" } ], { cleanupMode: "delete" }); const internal = manager as any; const sharedDir = session.packages["cleanup-owner-a"].outputDir; const foreignOldPath = session.items["cleanup-owner-b-item"].targetPath; const foreignNfoOldPath = session.items["cleanup-owner-b-nfo"].targetPath; const foreignSharedPath = path.join(sharedDir, "show.part2.rar"); const foreignNfoSharedPath = path.join(sharedDir, "show.nfo"); fs.renameSync(foreignOldPath, foreignSharedPath); fs.renameSync(foreignNfoOldPath, foreignNfoSharedPath); session.packages["cleanup-owner-b"].outputDir = sharedDir; session.items["cleanup-owner-b-item"].targetPath = foreignSharedPath; session.items["cleanup-owner-b-nfo"].targetPath = foreignNfoSharedPath; const removed = await internal.cleanupRemainingArchiveArtifacts(session.packages["cleanup-owner-a"]); expect(removed).toBe(1); expect(fs.existsSync(session.items["cleanup-owner-a-item"].targetPath)).toBe(false); expect(fs.existsSync(foreignSharedPath)).toBe(true); expect(fs.existsSync(foreignNfoSharedPath)).toBe(true); }); it("rejects an ambiguous pathless selection shared by multiple archive directories", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ambiguous-pathless-")); tempDirs.push(root); const item = { id: "pathless-item", packageId: "pathless-package", url: "https://example.test/pathless", provider: "realdebrid", status: "completed", retries: 0, speedBps: 0, downloadedBytes: 1, totalBytes: 1, progressPercent: 100, fileName: "episode.part1.rar", targetPath: "", resumable: true, attempts: 1, lastError: "", fullStatus: "Entpacken - Ausstehend", createdAt: Date.now(), updatedAt: Date.now() } satisfies DownloadItem; const selection = resolveSelectedArchiveSetsFromCandidates( [path.join(root, "set-a", "episode.part1.rar"), path.join(root, "set-b", "episode.part1.rar")], [item], new Set([item.id]) ); expect(selection.archivePaths.size).toBe(0); expect(selection.itemIds.size).toBe(0); }); it("accepts an explicitly named first multipart volume as the CRC target", () => { const items = [ { id: "crc-first", fileName: "show.part1.rar", targetPath: "C:\\Downloads\\show.part1.rar" }, { id: "crc-second", fileName: "show.part2.rar", targetPath: "C:\\Downloads\\show.part2.rar" } ] as DownloadItem[]; expect(findCrcImplicatedArchiveItems("C:\\Downloads\\show.part1.rar - checksum error", items).map((item) => item.id)).toEqual(["crc-first"]); }); it("marks an unexpected package post-process exception as failure but not an abort", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-exception-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [{ packageId: "exception-package", itemId: "exception-item", fileName: "episode.rar" }]); const internal = manager as any; internal.handlePackagePostProcessing = vi.fn(async () => { throw new Error("unexpected-postprocess-failure"); }); await internal.runPackagePostProcessing("exception-package"); expect(session.packages["exception-package"].status).toBe("failed"); expect(session.items["exception-item"].fullStatus).toMatch(/^Entpack-Fehler/); session.packages["exception-package"].status = "queued"; session.items["exception-item"].fullStatus = "Entpacken - Ausstehend"; internal.handlePackagePostProcessing = vi.fn(async (_packageId: string, signal: AbortSignal) => { await new Promise((_resolve, reject) => { signal.addEventListener("abort", () => reject(new Error("aborted:extract")), { once: true }); queueMicrotask(() => internal.abortPackagePostProcessing("exception-package", "stop")); }); }); await internal.runPackagePostProcessing("exception-package"); expect(session.packages["exception-package"].status).not.toBe("failed"); expect(session.items["exception-item"].fullStatus).toBe("Entpacken - Ausstehend"); session.packages["exception-package"].status = "queued"; internal.handlePackagePostProcessing = vi.fn(async () => { throw new Error("aborted:extract"); }); await internal.runPackagePostProcessing("exception-package"); expect(session.packages["exception-package"].status).toBe("failed"); }); it("preserves a manual extraction plan across an automatic disk-capacity retry", async () => { vi.useFakeTimers(); try { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-disk-retry-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [{ packageId: "manual-disk-package", itemId: "manual-disk-item", fileName: "episode.zip" }]); const internal = manager as any; const archivePath = session.items["manual-disk-item"].targetPath; const archiveKey = path.resolve(archivePath).toLowerCase(); internal.manualExtractPackages.add("manual-disk-package"); internal.manualExtractArchiveFilters.set("manual-disk-package", new Set([archiveKey])); internal.findFullExtractArchiveSet = vi.fn(async () => new Set([archivePath])); internal.diskReservations = new DiskReservationCoordinator({ safetyBytes: 0, retryDelayMs: 1_000, statVolume: async (targetPath) => ({ path: targetPath, volumeKey: "manual-volume", freeBytes: 0, totalBytes: 1_024 }) }); const rerun = vi.fn(async () => {}); internal.runPackagePostProcessing = rerun; await internal.handlePackagePostProcessing("manual-disk-package"); expect(manager.getSnapshot().canStop).toBe(true); manager.togglePackage("manual-disk-package"); expect(internal.packageDiskRetryPlans.has("manual-disk-package")).toBe(true); manager.togglePackage("manual-disk-package"); internal.manualExtractPackages.clear(); internal.manualExtractArchiveFilters.clear(); await vi.advanceTimersByTimeAsync(1_000); expect(rerun).toHaveBeenCalledWith("manual-disk-package"); expect(internal.manualExtractPackages.has("manual-disk-package")).toBe(true); expect(internal.manualExtractArchiveFilters.get("manual-disk-package")).toEqual(new Set([archiveKey])); expect(internal.packageDiskRetryAfterByPackage.has("manual-disk-package")).toBe(false); expect(manager.getSnapshot().diskWaitEvents?.some((entry) => entry.packageId === "manual-disk-package")).toBe(false); } finally { vi.useRealTimers(); } }); it("keeps disk retry generations and stale timer callbacks isolated", async () => { vi.useFakeTimers(); try { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-identity-")); tempDirs.push(root); const { manager } = createCompletedFileManager(root, [{ packageId: "disk-identity-package", itemId: "disk-identity-item", fileName: "episode.zip" }]); const internal = manager as any; const rerun = vi.fn(async () => {}); internal.runPackagePostProcessing = rerun; const request = { retryAt: Date.now() + 60_000, manualRequested: true, postProcessVersion: 0, runOwnerId: null }; internal.schedulePackageDiskRetry("disk-identity-package", request); const firstId = internal.packageDiskRetryPlans.get("disk-identity-package").id; internal.schedulePackageDiskRetry("disk-identity-package", request); const secondId = internal.packageDiskRetryPlans.get("disk-identity-package").id; internal.packagePostProcessVersions.set("disk-identity-package", 1); expect(internal.schedulePackageDiskRetry("disk-identity-package", request)).toBe(false); expect(internal.packageDiskRetryPlans.get("disk-identity-package").id).toBe(secondId); internal.packagePostProcessVersions.set("disk-identity-package", 0); internal.executePackageDiskRetry("disk-identity-package", firstId); expect(internal.packageDiskRetryPlans.get("disk-identity-package").id).toBe(secondId); expect(rerun).not.toHaveBeenCalled(); const getRunOwner = internal.getPackageResultRunOwner.bind(internal); internal.getPackageResultRunOwner = () => "new-owner"; internal.executePackageDiskRetry("disk-identity-package", secondId); expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false); expect(rerun).not.toHaveBeenCalled(); internal.getPackageResultRunOwner = getRunOwner; internal.schedulePackageDiskRetry("disk-identity-package", request); const thirdId = internal.packageDiskRetryPlans.get("disk-identity-package").id; internal.session.packages["disk-identity-package"].resultGeneration += 1; internal.executePackageDiskRetry("disk-identity-package", thirdId); expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false); expect(rerun).not.toHaveBeenCalled(); manager.stop(); internal.schedulePackageDiskRetry("disk-identity-package", request); expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false); internal.healthManualStop = false; manager.prepareForShutdown(); internal.schedulePackageDiskRetry("disk-identity-package", request); expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false); await vi.runAllTimersAsync(); expect(rerun).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it("clears a paused run-owned disk retry when re-enable has no matching owner", async () => { vi.useFakeTimers(); try { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-owner-rebind-")); tempDirs.push(root); const { manager } = createCompletedFileManager(root, [{ packageId: "disk-owner-package", itemId: "disk-owner-item", fileName: "episode.zip" }]); const internal = manager as any; const rerun = vi.fn(async () => {}); internal.runPackagePostProcessing = rerun; internal.session.running = true; internal.runScopeKind = "selected"; internal.runPackageIds.add("disk-owner-package"); const context = internal.beginActiveRunContext(new Set(["disk-owner-package"]), Date.now()); expect(internal.schedulePackageDiskRetry("disk-owner-package", { retryAt: Date.now() + 1_000, manualRequested: true, postProcessVersion: 0, runOwnerId: context.id })).toBe(true); manager.togglePackage("disk-owner-package"); expect(internal.packageDiskRetryPlans.has("disk-owner-package")).toBe(true); expect(internal.packageDiskRetryTimers.has("disk-owner-package")).toBe(false); manager.togglePackage("disk-owner-package"); expect(internal.packageDiskRetryPlans.has("disk-owner-package")).toBe(false); expect(internal.packageDiskRetryTimers.has("disk-owner-package")).toBe(false); expect(internal.packageDiskRetryAfterByPackage.has("disk-owner-package")).toBe(false); expect(internal.getActivePostProcessingCount()).toBe(0); await vi.advanceTimersByTimeAsync(1_000); expect(rerun).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it("cancels a due disk retry when stop wins the race", async () => { vi.useFakeTimers(); try { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-stop-race-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [{ packageId: "disk-stop-package", itemId: "disk-stop-item", fileName: "episode.zip" }]); const internal = manager as any; const rerun = vi.fn(async () => {}); internal.runPackagePostProcessing = rerun; internal.session.running = true; internal.runScopeKind = "selected"; internal.runPackageIds.add("disk-stop-package"); internal.runItemIds.add("disk-stop-item"); expect(internal.schedulePackageDiskRetry("disk-stop-package", { retryAt: Date.now() + 1_000, manualRequested: true, postProcessVersion: 0, runOwnerId: null })).toBe(true); manager.stop(); await vi.advanceTimersByTimeAsync(1_000); expect(rerun).not.toHaveBeenCalled(); expect(internal.packageDiskRetryPlans.size).toBe(0); expect(internal.packageDiskRetryTimers.size).toBe(0); expect(manager.getSnapshot().lifecycle?.phase).toBe("idle"); expect(session.running).toBe(false); } finally { vi.useRealTimers(); } }); it("passes separate manual and partial flags to deferred extraction", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-cleanup-scope-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [{ packageId: "manual-cleanup-package", itemId: "manual-cleanup-item", fileName: "episode.rar" }]); const internal = manager as any; const archivePath = session.items["manual-cleanup-item"].targetPath; internal.findFullExtractArchiveSet = vi.fn(async () => new Set([archivePath])); internal.runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" })); const deferred = vi.fn(async () => {}); internal.runDeferredPostExtraction = deferred; internal.manualExtractPackages.add("manual-cleanup-package"); await internal.handlePackagePostProcessing("manual-cleanup-package"); expect(deferred.mock.calls.at(-1)?.slice(6)).toEqual([true, false]); session.packages["manual-cleanup-package"].status = "queued"; session.items["manual-cleanup-item"].fullStatus = "Entpacken - Ausstehend"; internal.manualExtractArchiveFilters.set("manual-cleanup-package", new Set([path.resolve(archivePath).toLowerCase()])); await internal.handlePackagePostProcessing("manual-cleanup-package"); expect(deferred.mock.calls.at(-1)?.slice(6)).toEqual([true, true]); }); it("cleans full manual package archives but preserves partial manual state", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-cleanup-behavior-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [ { packageId: "manual-cleanup-behavior", itemId: "cleanup-a", fileName: "episode-a.rar" }, { packageId: "manual-cleanup-behavior", itemId: "cleanup-b", fileName: "episode-b.rar" } ], { cleanupMode: "delete" }); const internal = manager as any; const firstPath = session.items["cleanup-a"].targetPath; const secondPath = session.items["cleanup-b"].targetPath; await internal.runDeferredPostExtraction("manual-cleanup-behavior", session.packages["manual-cleanup-behavior"], 2, 0, true, 2, true, true); expect(fs.existsSync(firstPath)).toBe(true); expect(fs.existsSync(secondPath)).toBe(true); await internal.runDeferredPostExtraction("manual-cleanup-behavior", session.packages["manual-cleanup-behavior"], 2, 0, true, 2, true, false); expect(fs.existsSync(firstPath)).toBe(false); expect(fs.existsSync(secondPath)).toBe(false); }); it("rejects package extraction when no archive candidate exists", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-no-archive-")); tempDirs.push(root); const { manager } = createCompletedFileManager(root, [{ packageId: "no-archive-package", itemId: "no-archive-item", fileName: "episode.mkv" }]); const internal = manager as any; const postProcess = vi.fn(async () => {}); internal.runPackagePostProcessing = postProcess; await expect(manager.extractNow("no-archive-package")).rejects.toThrow("Kein entpackbarer Archivsatz ausgewählt"); expect(manager.getSnapshot().session.items["no-archive-item"].fullStatus).toBe("Entpacken - Ausstehend"); expect(manager.getSnapshot().session.packages["no-archive-package"].status).toBe("completed"); expect(internal.manualExtractPackages.has("no-archive-package")).toBe(false); expect(postProcess).not.toHaveBeenCalled(); }); it("rejects retryExtraction when the failed package has no complete archive", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-no-archive-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [{ packageId: "retry-no-archive", itemId: "retry-no-archive-item", fileName: "episode.mkv" }]); const internal = manager as any; session.items["retry-no-archive-item"].fullStatus = "Entpack-Fehler: vorheriger Fehler"; internal.runPackagePostProcessing = vi.fn(async () => {}); await expect(manager.retryExtraction("retry-no-archive")).rejects.toThrow(/Kein .*entpackbarer Archivsatz ausgewählt/); expect(internal.runPackagePostProcessing).not.toHaveBeenCalled(); expect(session.items["retry-no-archive-item"].fullStatus).toBe("Entpack-Fehler: vorheriger Fehler"); }); it("recognizes and arms an opaque archive file by signature", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-opaque-rar-")); tempDirs.push(root); const zip = new AdmZip(); zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024)); const content = zip.toBuffer(); const { manager } = createCompletedFileManager(root, [{ packageId: "opaque-package", itemId: "opaque-item", fileName: "download.bin", content }]); const internal = manager as any; await manager.extractNow("opaque-package"); const task = internal.packagePostProcessTasks.get("opaque-package"); expect(task).toBeDefined(); await task; expect(manager.getSnapshot().session.items["opaque-item"].fileName).toMatch(/\.zip$/i); }); it("extracts a uniquely resolvable pathless legacy item through the public API", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-pathless-public-")); tempDirs.push(root); const zip = new AdmZip(); zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024)); const { manager, session } = createCompletedFileManager(root, [{ packageId: "pathless-public", itemId: "pathless-public-item", fileName: "episode.zip", content: zip.toBuffer() }]); const internal = manager as any; const archivePath = session.items["pathless-public-item"].targetPath; internal.releaseTargetPath("pathless-public-item"); session.items["pathless-public-item"].targetPath = ""; const postProcess = vi.fn(async () => {}); internal.runPackagePostProcessing = postProcess; await manager.extractNow("pathless-public"); expect(session.items["pathless-public-item"].targetPath).toBe(archivePath); expect(session.items["pathless-public-item"].fullStatus).toBe("Entpacken - Ausstehend"); expect(postProcess).toHaveBeenCalledWith("pathless-public"); }); it("rejects an incomplete multipart package before starting extraction", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-incomplete-multipart-")); tempDirs.push(root); const { manager, session } = createCompletedFileManager(root, [{ packageId: "incomplete-package", itemId: "incomplete-part1", fileName: "show.part1.rar" }]); const internal = manager as any; const part2Id = "incomplete-part2"; session.packages["incomplete-package"].itemIds.push(part2Id); session.items[part2Id] = { ...session.items["incomplete-part1"], id: part2Id, status: "queued", fileName: "show.part2.rar", targetPath: "", downloadedBytes: 0, totalBytes: null, progressPercent: 0, fullStatus: "Wartet" }; internal.runPackagePostProcessing = vi.fn(async () => {}); await expect(manager.extractNow("incomplete-package")).rejects.toThrow("Kein entpackbarer Archivsatz ausgewählt"); expect(internal.runPackagePostProcessing).not.toHaveBeenCalled(); }); it("rejects a mixed extraction batch before starting any package", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-mixed-batch-")); tempDirs.push(root); const zip = new AdmZip(); zip.addFile("episode.mkv", Buffer.from("video")); const { manager } = createCompletedFileManager(root, [ { packageId: "valid-package", itemId: "valid-item", fileName: "episode.zip", content: zip.toBuffer() }, { packageId: "invalid-package", itemId: "invalid-item", fileName: "episode.mkv" } ]); const internal = manager as any; internal.runPackagePostProcessing = vi.fn(async () => {}); await expect(manager.extractNow({ packageIds: ["valid-package", "invalid-package"], itemIds: [] })).rejects.toThrow(/1.*nicht gestartet/i); expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("valid-package"); expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("invalid-package"); }); it("keeps opaque archive files untouched when batch preflight rejects another target", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-atomic-opaque-")); tempDirs.push(root); const zip = new AdmZip(); zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024)); const { manager, session } = createCompletedFileManager(root, [ { packageId: "atomic-opaque", itemId: "atomic-opaque-item", fileName: "download.bin", content: zip.toBuffer() }, { packageId: "atomic-invalid", itemId: "atomic-invalid-item", fileName: "episode.mkv" } ]); const internal = manager as any; internal.runPackagePostProcessing = vi.fn(async () => {}); const opaquePath = session.items["atomic-opaque-item"].targetPath; await expect(manager.extractNow({ packageIds: ["atomic-opaque", "atomic-invalid"], itemIds: [] })).rejects.toThrow(/nicht gestartet/i); expect(session.items["atomic-opaque-item"].fileName).toBe("download.bin"); expect(session.items["atomic-opaque-item"].targetPath).toBe(opaquePath); expect(fs.existsSync(opaquePath)).toBe(true); expect(fs.existsSync(path.join(path.dirname(opaquePath), "download.zip"))).toBe(false); expect(internal.runPackagePostProcessing).not.toHaveBeenCalled(); }); it("exposes and drains stop for standalone manual extraction without starting a download run", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-standalone-stop-")); tempDirs.push(root); const zip = new AdmZip(); zip.addFile("episode.mkv", Buffer.from("video")); const { manager } = createCompletedFileManager(root, [{ packageId: "standalone-package", itemId: "standalone-item", fileName: "episode.zip", content: zip.toBuffer() }]); const internal = manager as any; internal.handlePackagePostProcessing = vi.fn(async (_packageId: string, signal: AbortSignal) => { await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })); }); await manager.extractNow("standalone-package"); await waitFor(() => internal.packagePostProcessTasks.size === 1, 2_000); expect(manager.getSnapshot().canStop).toBe(true); expect(manager.getSnapshot().session.running).toBe(false); manager.stop(); await waitFor(() => internal.packagePostProcessTasks.size === 0, 2_000); expect(manager.getSnapshot().lifecycle?.phase).toBe("idle"); expect(manager.getSnapshot().session.running).toBe(false); }); });