From cc887eb8a1df9b008f8c8fae7ae33b48bd6ef037 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 27 Feb 2026 21:28:03 +0100 Subject: [PATCH] Release v1.4.14 with extraction performance optimization and bug fixes - Add multi-threaded extraction via WinRAR -mt flag (uses all CPU cores) - Fix -idq flag suppressing progress output, replaced with -idc - Fix extraction timeout for multi-part archives (now calculates total size across all parts) - Raise extraction timeout cap from 40min to 2h for large archives (40GB+) - Add natural episode sorting (E1, E2, E10 instead of E1, E10, E2) - Add split archive support (.zip.001, .7z.001) with proper cleanup - Add write-stream drain timeout to prevent download freezes on backpressure - Fix regex global-state bug in progress percentage parsing - Optimize speed event pruning (every 1.5s instead of every chunk) - Add performance flag fallback for older WinRAR versions Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 4 +- package.json | 2 +- src/main/download-manager.ts | 63 ++++++++++- src/main/extractor.ts | 189 +++++++++++++++++++++++++++++---- tests/download-manager.test.ts | 111 ++++++++++++++++++- tests/extractor.test.ts | 103 +++++++++++++++--- 6 files changed, 428 insertions(+), 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e4e69f..b0dc84c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.13", + "version": "1.4.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.13", + "version": "1.4.14", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index adb6371..855d128 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.13", + "version": "1.4.14", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index cdce9fd..7af8707 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1349,6 +1349,8 @@ export class DownloadManager extends EventEmitter { } } + private lastSpeedPruneAt = 0; + private recordSpeed(bytes: number): void { const now = nowMs(); const bucket = now - (now % 120); @@ -1359,7 +1361,10 @@ export class DownloadManager extends EventEmitter { this.speedEvents.push({ at: bucket, bytes }); } this.speedBytesLastWindow += bytes; - this.pruneSpeedEvents(now); + if (now - this.lastSpeedPruneAt >= 1500) { + this.pruneSpeedEvents(now); + this.lastSpeedPruneAt = now; + } } private recordRunOutcome(itemId: string, status: "completed" | "failed" | "cancelled"): void { @@ -2213,18 +2218,69 @@ export class DownloadManager extends EventEmitter { : 170; let lastUiEmitAt = 0; let lastProgressPercent = item.progressPercent; + const stallTimeoutMs = getDownloadStallTimeoutMs(); + const drainTimeoutMs = Math.max(4000, Math.min(45000, stallTimeoutMs > 0 ? stallTimeoutMs : 15000)); const waitDrain = (): Promise => new Promise((resolve, reject) => { - const onDrain = (): void => { + if (active.abortController.signal.aborted) { + reject(new Error(`aborted:${active.abortReason}`)); + return; + } + + let settled = false; + let timeoutId: NodeJS.Timeout | null = setTimeout(() => { + if (settled) { + return; + } + settled = true; + stream.off("drain", onDrain); stream.off("error", onError); + active.abortController.signal.removeEventListener("abort", onAbort); + if (!active.abortController.signal.aborted) { + active.abortReason = "stall"; + active.abortController.abort("stall"); + } + reject(new Error("write_drain_timeout")); + }, drainTimeoutMs); + + const cleanup = (): void => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + stream.off("drain", onDrain); + stream.off("error", onError); + active.abortController.signal.removeEventListener("abort", onAbort); + }; + + const onDrain = (): void => { + if (settled) { + return; + } + settled = true; + cleanup(); resolve(); }; const onError = (streamError: Error): void => { - stream.off("drain", onDrain); + if (settled) { + return; + } + settled = true; + cleanup(); reject(streamError); }; + const onAbort = (): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(new Error(`aborted:${active.abortReason}`)); + }; + stream.once("drain", onDrain); stream.once("error", onError); + active.abortController.signal.addEventListener("abort", onAbort, { once: true }); }); try { @@ -2233,7 +2289,6 @@ export class DownloadManager extends EventEmitter { throw new Error("Leerer Response-Body"); } const reader = body.getReader(); - const stallTimeoutMs = getDownloadStallTimeoutMs(); let lastDataAt = nowMs(); let lastIdleEmitAt = 0; const idlePulseMs = Math.max(1500, Math.min(3500, Math.floor(stallTimeoutMs / 4) || 2000)); diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 725a731..77cf005 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import os from "node:os"; import { spawn } from "node:child_process"; import AdmZip from "adm-zip"; import { CleanupMode, ConflictMode } from "../shared/types"; @@ -11,6 +12,7 @@ const NO_EXTRACTOR_MESSAGE = "WinRAR/UnRAR nicht gefunden. Bitte WinRAR installi let resolvedExtractorCommand: string | null = null; let resolveFailureReason = ""; +let externalExtractorSupportsPerfFlags = true; export interface ExtractOptions { packageDir: string; @@ -37,8 +39,42 @@ export interface ExtractProgressUpdate { const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024; const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json"; const EXTRACT_BASE_TIMEOUT_MS = 6 * 60 * 1000; -const EXTRACT_PER_GIB_TIMEOUT_MS = 7 * 60 * 1000; -const EXTRACT_MAX_TIMEOUT_MS = 40 * 60 * 1000; +const EXTRACT_PER_GIB_TIMEOUT_MS = 4 * 60 * 1000; +const EXTRACT_MAX_TIMEOUT_MS = 120 * 60 * 1000; +const ARCHIVE_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +function pathSetKey(filePath: string): string { + return process.platform === "win32" ? filePath.toLowerCase() : filePath; +} + +function archiveSortKey(filePath: string): string { + const fileName = path.basename(filePath).toLowerCase(); + return fileName + .replace(/\.part0*1\.rar$/i, "") + .replace(/\.zip\.\d{3}$/i, "") + .replace(/\.7z\.\d{3}$/i, "") + .replace(/\.rar$/i, "") + .replace(/\.zip$/i, "") + .replace(/\.7z$/i, "") + .replace(/[._\-\s]+$/g, ""); +} + +function archiveTypeRank(filePath: string): number { + const fileName = path.basename(filePath).toLowerCase(); + if (/\.part0*1\.rar$/i.test(fileName)) { + return 0; + } + if (/\.rar$/i.test(fileName)) { + return 1; + } + if (/\.zip(?:\.\d{3})?$/i.test(fileName)) { + return 2; + } + if (/\.7z(?:\.\d{3})?$/i.test(fileName)) { + return 3; + } + return 9; +} type ExtractResumeState = { completedArchives: string[]; @@ -58,13 +94,50 @@ function findArchiveCandidates(packageDir: string): string[] { return []; } - const preferred = files.filter((file) => /\.part0*1\.rar$/i.test(file)); - const zip = files.filter((file) => /\.zip$/i.test(file)); - const singleRar = files.filter((file) => /\.rar$/i.test(file) && !/\.part\d+\.rar$/i.test(file)); - const seven = files.filter((file) => /\.7z$/i.test(file)); + const fileNamesLower = new Set(files.map((filePath) => path.basename(filePath).toLowerCase())); + const multipartRar = files.filter((filePath) => /\.part0*1\.rar$/i.test(filePath)); + const singleRar = files.filter((filePath) => /\.rar$/i.test(filePath) && !/\.part\d+\.rar$/i.test(filePath)); + const zipSplit = files.filter((filePath) => /\.zip\.001$/i.test(filePath)); + const zip = files.filter((filePath) => { + const fileName = path.basename(filePath); + if (!/\.zip$/i.test(fileName)) { + return false; + } + return !fileNamesLower.has(`${fileName}.001`.toLowerCase()); + }); + const sevenSplit = files.filter((filePath) => /\.7z\.001$/i.test(filePath)); + const seven = files.filter((filePath) => { + const fileName = path.basename(filePath); + if (!/\.7z$/i.test(fileName)) { + return false; + } + return !fileNamesLower.has(`${fileName}.001`.toLowerCase()); + }); - const ordered = [...preferred, ...zip, ...singleRar, ...seven]; - return Array.from(new Set(ordered)); + const unique: string[] = []; + const seen = new Set(); + for (const candidate of [...multipartRar, ...singleRar, ...zipSplit, ...zip, ...sevenSplit, ...seven]) { + const key = pathSetKey(candidate); + if (seen.has(key)) { + continue; + } + seen.add(key); + unique.push(candidate); + } + + unique.sort((left, right) => { + const keyCmp = ARCHIVE_SORT_COLLATOR.compare(archiveSortKey(left), archiveSortKey(right)); + if (keyCmp !== 0) { + return keyCmp; + } + const rankCmp = archiveTypeRank(left) - archiveTypeRank(right); + if (rankCmp !== 0) { + return rankCmp; + } + return ARCHIVE_SORT_COLLATOR.compare(path.basename(left), path.basename(right)); + }); + + return unique; } function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip" | "rename" { @@ -91,15 +164,20 @@ function appendLimited(base: string, chunk: string, maxLen = MAX_EXTRACT_OUTPUT_ function parseProgressPercent(chunk: string): number | null { const text = String(chunk || ""); - const regex = /(?:^|\D)(\d{1,3})%/g; - let match: RegExpExecArray | null = regex.exec(text); + const matches = text.match(/(?:^|\D)(\d{1,3})%/g); + if (!matches) { + return null; + } let latest: number | null = null; - while (match) { - const value = Number(match[1]); + for (const raw of matches) { + const digits = raw.match(/(\d{1,3})%/); + if (!digits) { + continue; + } + const value = Number(digits[1]); if (Number.isFinite(value) && value >= 0 && value <= 100) { latest = value; } - match = regex.exec(text); } return latest; } @@ -115,8 +193,19 @@ function shouldPreferExternalZip(archivePath: string): boolean { function computeExtractTimeoutMs(archivePath: string): number { try { - const stat = fs.statSync(archivePath); - const gib = stat.size / (1024 * 1024 * 1024); + const relatedFiles = collectArchiveCleanupTargets(archivePath); + let totalBytes = 0; + for (const filePath of relatedFiles) { + try { + totalBytes += fs.statSync(filePath).size; + } catch { + // ignore missing parts + } + } + if (totalBytes <= 0) { + totalBytes = fs.statSync(archivePath).size; + } + const gib = totalBytes / (1024 * 1024 * 1024); const dynamicMs = EXTRACT_BASE_TIMEOUT_MS + Math.floor(gib * EXTRACT_PER_GIB_TIMEOUT_MS); return Math.max(EXTRACT_BASE_TIMEOUT_MS, Math.min(EXTRACT_MAX_TIMEOUT_MS, dynamicMs)); } catch { @@ -225,6 +314,31 @@ function isNoExtractorError(errorText: string): boolean { return String(errorText || "").toLowerCase().includes("nicht gefunden"); } +function isUnsupportedExtractorSwitchError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("unknown switch") + || text.includes("unknown option") + || text.includes("invalid switch") + || text.includes("unsupported option") + || text.includes("unbekannter schalter") + || text.includes("falscher parameter"); +} + +function shouldUseExtractorPerformanceFlags(): boolean { + const raw = String(process.env.RD_EXTRACT_PERF_FLAGS || "").trim().toLowerCase(); + return raw !== "0" && raw !== "false" && raw !== "off" && raw !== "no"; +} + +function extractorThreadSwitch(): string { + const envValue = Number(process.env.RD_EXTRACT_THREADS ?? NaN); + if (Number.isFinite(envValue) && envValue >= 1 && envValue <= 32) { + return `-mt${Math.floor(envValue)}`; + } + const cpuCount = Math.max(1, os.cpus().length || 1); + const threadCount = Math.max(1, Math.min(16, cpuCount)); + return `-mt${threadCount}`; +} + type ExtractSpawnResult = { ok: boolean; missingCommand: boolean; @@ -340,14 +454,18 @@ export function buildExternalExtractArgs( archivePath: string, targetDir: string, conflictMode: ConflictMode, - password = "" + password = "", + usePerformanceFlags = true ): string[] { const mode = effectiveConflictMode(conflictMode); const lower = command.toLowerCase(); if (lower.includes("unrar") || lower.includes("winrar")) { const overwrite = mode === "overwrite" ? "-o+" : mode === "rename" ? "-or" : "-o-"; const pass = password ? `-p${password}` : "-p-"; - return ["x", overwrite, pass, "-y", archivePath, `${targetDir}${path.sep}`]; + const perfArgs = usePerformanceFlags && shouldUseExtractorPerformanceFlags() + ? ["-idc", extractorThreadSwitch()] + : []; + return ["x", overwrite, pass, "-y", ...perfArgs, archivePath, `${targetDir}${path.sep}`]; } const overwrite = mode === "overwrite" ? "-aoa" : mode === "rename" ? "-aou" : "-aos"; @@ -399,6 +517,7 @@ async function runExternalExtract( let announcedStart = false; let bestPercent = 0; + let usePerformanceFlags = externalExtractorSupportsPerfFlags && shouldUseExtractorPerformanceFlags(); for (const password of passwords) { if (signal?.aborted) { @@ -408,8 +527,8 @@ async function runExternalExtract( announcedStart = true; onArchiveProgress?.(0); } - const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password); - const result = await runExtractCommand(command, args, (chunk) => { + let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags); + let result = await runExtractCommand(command, args, (chunk) => { const parsed = parseProgressPercent(chunk); if (parsed === null || parsed <= bestPercent) { return; @@ -417,6 +536,22 @@ async function runExternalExtract( bestPercent = parsed; onArchiveProgress?.(bestPercent); }, signal, timeoutMs); + + if (!result.ok && usePerformanceFlags && isUnsupportedExtractorSwitchError(result.errorText)) { + usePerformanceFlags = false; + externalExtractorSupportsPerfFlags = false; + logger.warn(`Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`); + args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false); + result = await runExtractCommand(command, args, (chunk) => { + const parsed = parseProgressPercent(chunk); + if (parsed === null || parsed <= bestPercent) { + return; + } + bestPercent = parsed; + onArchiveProgress?.(bestPercent); + }, signal, timeoutMs); + } + if (result.ok) { onArchiveProgress?.(100); return password; @@ -523,6 +658,14 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director return Array.from(targets); } + const splitZip = fileName.match(/^(.*)\.zip\.\d{3}$/i); + if (splitZip) { + const stem = escapeRegex(splitZip[1]); + addMatching(new RegExp(`^${stem}\\.zip$`, "i")); + addMatching(new RegExp(`^${stem}\\.zip\\.\\d{3}$`, "i")); + return Array.from(targets); + } + if (/\.7z$/i.test(fileName)) { const stem = escapeRegex(fileName.replace(/\.7z$/i, "")); addMatching(new RegExp(`^${stem}\\.7z$`, "i")); @@ -530,6 +673,14 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director return Array.from(targets); } + const splitSeven = fileName.match(/^(.*)\.7z\.\d{3}$/i); + if (splitSeven) { + const stem = escapeRegex(splitSeven[1]); + addMatching(new RegExp(`^${stem}\\.7z$`, "i")); + addMatching(new RegExp(`^${stem}\\.7z\\.\\d{3}$`, "i")); + return Array.from(targets); + } + return Array.from(targets); } diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index c2d125f..8ecb88c 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import http from "node:http"; -import { once } from "node:events"; +import { EventEmitter, once } from "node:events"; import AdmZip from "adm-zip"; import { afterEach, describe, expect, it } from "vitest"; import { DownloadManager } from "../src/main/download-manager"; @@ -726,6 +726,115 @@ describe("download manager", () => { } }, 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); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 7ff6a00..190e604 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -15,29 +15,30 @@ afterEach(() => { describe("extractor", () => { it("maps external extractor args by conflict mode", () => { - expect(buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite")).toEqual([ - "x", - "-o+", - "-p-", - "-y", - "archive.rar", - "C:\\target\\" - ]); - expect(buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "ask", "serienfans.org")).toEqual([ - "x", - "-o-", - "-pserienfans.org", - "-y", - "archive.rar", - "C:\\target\\" - ]); + const overwriteArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite"); + expect(overwriteArgs.slice(0, 4)).toEqual(["x", "-o+", "-p-", "-y"]); + expect(overwriteArgs).toContain("-idc"); + expect(overwriteArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(true); + expect(overwriteArgs[overwriteArgs.length - 2]).toBe("archive.rar"); + expect(overwriteArgs[overwriteArgs.length - 1]).toBe("C:\\target\\"); + + const askArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "ask", "serienfans.org"); + expect(askArgs.slice(0, 4)).toEqual(["x", "-o-", "-pserienfans.org", "-y"]); + expect(askArgs).toContain("-idc"); + expect(askArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(true); + expect(askArgs[askArgs.length - 2]).toBe("archive.rar"); + expect(askArgs[askArgs.length - 1]).toBe("C:\\target\\"); + + const compatibilityArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite", "", false); + expect(compatibilityArgs).not.toContain("-idc"); + expect(compatibilityArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(false); const unrarRename = buildExternalExtractArgs("unrar", "archive.rar", "C:\\target", "rename"); expect(unrarRename[0]).toBe("x"); expect(unrarRename[1]).toBe("-or"); expect(unrarRename[2]).toBe("-p-"); expect(unrarRename[3]).toBe("-y"); - expect(unrarRename[4]).toBe("archive.rar"); + expect(unrarRename[unrarRename.length - 2]).toBe("archive.rar"); }); it("deletes only successfully extracted archives", async () => { @@ -94,6 +95,74 @@ describe("extractor", () => { expect(targets.has(other)).toBe(false); }); + it("collects split 7z companion parts for cleanup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + const part1 = path.join(packageDir, "release.7z.001"); + const part2 = path.join(packageDir, "release.7z.002"); + const part3 = path.join(packageDir, "release.7z.003"); + const other = path.join(packageDir, "other.7z.001"); + + fs.writeFileSync(part1, "a", "utf8"); + fs.writeFileSync(part2, "b", "utf8"); + fs.writeFileSync(part3, "c", "utf8"); + fs.writeFileSync(other, "x", "utf8"); + + const targets = new Set(collectArchiveCleanupTargets(part1)); + expect(targets.has(part1)).toBe(true); + expect(targets.has(part2)).toBe(true); + expect(targets.has(part3)).toBe(true); + expect(targets.has(other)).toBe(false); + }); + + it("extracts archives in natural episode order", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zip10 = new AdmZip(); + zip10.addFile("e10.txt", Buffer.from("10")); + zip10.writeZip(path.join(packageDir, "Show.S01E10.zip")); + + const zip2 = new AdmZip(); + zip2.addFile("e02.txt", Buffer.from("02")); + zip2.writeZip(path.join(packageDir, "Show.S01E02.zip")); + + const zip1 = new AdmZip(); + zip1.addFile("e01.txt", Buffer.from("01")); + zip1.writeZip(path.join(packageDir, "Show.S01E01.zip")); + + const seenOrder: string[] = []; + await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onProgress: (update) => { + if (update.phase !== "extracting" || !update.archiveName) { + return; + } + if (seenOrder[seenOrder.length - 1] === update.archiveName) { + return; + } + seenOrder.push(update.archiveName); + } + }); + + expect(seenOrder.slice(0, 3)).toEqual([ + "Show.S01E01.zip", + "Show.S01E02.zip", + "Show.S01E10.zip" + ]); + }); + it("deletes split zip companion parts when cleanup is enabled", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root);