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 <noreply@anthropic.com>
This commit is contained in:
parent
6d8ead8598
commit
cc887eb8a1
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.13",
|
"version": "1.4.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.13",
|
"version": "1.4.14",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.13",
|
"version": "1.4.14",
|
||||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -1349,6 +1349,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private lastSpeedPruneAt = 0;
|
||||||
|
|
||||||
private recordSpeed(bytes: number): void {
|
private recordSpeed(bytes: number): void {
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
const bucket = now - (now % 120);
|
const bucket = now - (now % 120);
|
||||||
@ -1359,7 +1361,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.speedEvents.push({ at: bucket, bytes });
|
this.speedEvents.push({ at: bucket, bytes });
|
||||||
}
|
}
|
||||||
this.speedBytesLastWindow += bytes;
|
this.speedBytesLastWindow += bytes;
|
||||||
|
if (now - this.lastSpeedPruneAt >= 1500) {
|
||||||
this.pruneSpeedEvents(now);
|
this.pruneSpeedEvents(now);
|
||||||
|
this.lastSpeedPruneAt = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordRunOutcome(itemId: string, status: "completed" | "failed" | "cancelled"): void {
|
private recordRunOutcome(itemId: string, status: "completed" | "failed" | "cancelled"): void {
|
||||||
@ -2213,18 +2218,69 @@ export class DownloadManager extends EventEmitter {
|
|||||||
: 170;
|
: 170;
|
||||||
let lastUiEmitAt = 0;
|
let lastUiEmitAt = 0;
|
||||||
let lastProgressPercent = item.progressPercent;
|
let lastProgressPercent = item.progressPercent;
|
||||||
|
const stallTimeoutMs = getDownloadStallTimeoutMs();
|
||||||
|
const drainTimeoutMs = Math.max(4000, Math.min(45000, stallTimeoutMs > 0 ? stallTimeoutMs : 15000));
|
||||||
|
|
||||||
const waitDrain = (): Promise<void> => new Promise((resolve, reject) => {
|
const waitDrain = (): Promise<void> => 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);
|
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();
|
resolve();
|
||||||
};
|
};
|
||||||
const onError = (streamError: Error): void => {
|
const onError = (streamError: Error): void => {
|
||||||
stream.off("drain", onDrain);
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
reject(streamError);
|
reject(streamError);
|
||||||
};
|
};
|
||||||
|
const onAbort = (): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`aborted:${active.abortReason}`));
|
||||||
|
};
|
||||||
|
|
||||||
stream.once("drain", onDrain);
|
stream.once("drain", onDrain);
|
||||||
stream.once("error", onError);
|
stream.once("error", onError);
|
||||||
|
active.abortController.signal.addEventListener("abort", onAbort, { once: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -2233,7 +2289,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
throw new Error("Leerer Response-Body");
|
throw new Error("Leerer Response-Body");
|
||||||
}
|
}
|
||||||
const reader = body.getReader();
|
const reader = body.getReader();
|
||||||
const stallTimeoutMs = getDownloadStallTimeoutMs();
|
|
||||||
let lastDataAt = nowMs();
|
let lastDataAt = nowMs();
|
||||||
let lastIdleEmitAt = 0;
|
let lastIdleEmitAt = 0;
|
||||||
const idlePulseMs = Math.max(1500, Math.min(3500, Math.floor(stallTimeoutMs / 4) || 2000));
|
const idlePulseMs = Math.max(1500, Math.min(3500, Math.floor(stallTimeoutMs / 4) || 2000));
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { CleanupMode, ConflictMode } from "../shared/types";
|
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 resolvedExtractorCommand: string | null = null;
|
||||||
let resolveFailureReason = "";
|
let resolveFailureReason = "";
|
||||||
|
let externalExtractorSupportsPerfFlags = true;
|
||||||
|
|
||||||
export interface ExtractOptions {
|
export interface ExtractOptions {
|
||||||
packageDir: string;
|
packageDir: string;
|
||||||
@ -37,8 +39,42 @@ export interface ExtractProgressUpdate {
|
|||||||
const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024;
|
const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024;
|
||||||
const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json";
|
const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json";
|
||||||
const EXTRACT_BASE_TIMEOUT_MS = 6 * 60 * 1000;
|
const EXTRACT_BASE_TIMEOUT_MS = 6 * 60 * 1000;
|
||||||
const EXTRACT_PER_GIB_TIMEOUT_MS = 7 * 60 * 1000;
|
const EXTRACT_PER_GIB_TIMEOUT_MS = 4 * 60 * 1000;
|
||||||
const EXTRACT_MAX_TIMEOUT_MS = 40 * 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 = {
|
type ExtractResumeState = {
|
||||||
completedArchives: string[];
|
completedArchives: string[];
|
||||||
@ -58,13 +94,50 @@ function findArchiveCandidates(packageDir: string): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferred = files.filter((file) => /\.part0*1\.rar$/i.test(file));
|
const fileNamesLower = new Set(files.map((filePath) => path.basename(filePath).toLowerCase()));
|
||||||
const zip = files.filter((file) => /\.zip$/i.test(file));
|
const multipartRar = files.filter((filePath) => /\.part0*1\.rar$/i.test(filePath));
|
||||||
const singleRar = files.filter((file) => /\.rar$/i.test(file) && !/\.part\d+\.rar$/i.test(file));
|
const singleRar = files.filter((filePath) => /\.rar$/i.test(filePath) && !/\.part\d+\.rar$/i.test(filePath));
|
||||||
const seven = files.filter((file) => /\.7z$/i.test(file));
|
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];
|
const unique: string[] = [];
|
||||||
return Array.from(new Set(ordered));
|
const seen = new Set<string>();
|
||||||
|
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" {
|
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 {
|
function parseProgressPercent(chunk: string): number | null {
|
||||||
const text = String(chunk || "");
|
const text = String(chunk || "");
|
||||||
const regex = /(?:^|\D)(\d{1,3})%/g;
|
const matches = text.match(/(?:^|\D)(\d{1,3})%/g);
|
||||||
let match: RegExpExecArray | null = regex.exec(text);
|
if (!matches) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
let latest: number | null = null;
|
let latest: number | null = null;
|
||||||
while (match) {
|
for (const raw of matches) {
|
||||||
const value = Number(match[1]);
|
const digits = raw.match(/(\d{1,3})%/);
|
||||||
|
if (!digits) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = Number(digits[1]);
|
||||||
if (Number.isFinite(value) && value >= 0 && value <= 100) {
|
if (Number.isFinite(value) && value >= 0 && value <= 100) {
|
||||||
latest = value;
|
latest = value;
|
||||||
}
|
}
|
||||||
match = regex.exec(text);
|
|
||||||
}
|
}
|
||||||
return latest;
|
return latest;
|
||||||
}
|
}
|
||||||
@ -115,8 +193,19 @@ function shouldPreferExternalZip(archivePath: string): boolean {
|
|||||||
|
|
||||||
function computeExtractTimeoutMs(archivePath: string): number {
|
function computeExtractTimeoutMs(archivePath: string): number {
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(archivePath);
|
const relatedFiles = collectArchiveCleanupTargets(archivePath);
|
||||||
const gib = stat.size / (1024 * 1024 * 1024);
|
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);
|
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));
|
return Math.max(EXTRACT_BASE_TIMEOUT_MS, Math.min(EXTRACT_MAX_TIMEOUT_MS, dynamicMs));
|
||||||
} catch {
|
} catch {
|
||||||
@ -225,6 +314,31 @@ function isNoExtractorError(errorText: string): boolean {
|
|||||||
return String(errorText || "").toLowerCase().includes("nicht gefunden");
|
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 = {
|
type ExtractSpawnResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
missingCommand: boolean;
|
missingCommand: boolean;
|
||||||
@ -340,14 +454,18 @@ export function buildExternalExtractArgs(
|
|||||||
archivePath: string,
|
archivePath: string,
|
||||||
targetDir: string,
|
targetDir: string,
|
||||||
conflictMode: ConflictMode,
|
conflictMode: ConflictMode,
|
||||||
password = ""
|
password = "",
|
||||||
|
usePerformanceFlags = true
|
||||||
): string[] {
|
): string[] {
|
||||||
const mode = effectiveConflictMode(conflictMode);
|
const mode = effectiveConflictMode(conflictMode);
|
||||||
const lower = command.toLowerCase();
|
const lower = command.toLowerCase();
|
||||||
if (lower.includes("unrar") || lower.includes("winrar")) {
|
if (lower.includes("unrar") || lower.includes("winrar")) {
|
||||||
const overwrite = mode === "overwrite" ? "-o+" : mode === "rename" ? "-or" : "-o-";
|
const overwrite = mode === "overwrite" ? "-o+" : mode === "rename" ? "-or" : "-o-";
|
||||||
const pass = password ? `-p${password}` : "-p-";
|
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";
|
const overwrite = mode === "overwrite" ? "-aoa" : mode === "rename" ? "-aou" : "-aos";
|
||||||
@ -399,6 +517,7 @@ async function runExternalExtract(
|
|||||||
|
|
||||||
let announcedStart = false;
|
let announcedStart = false;
|
||||||
let bestPercent = 0;
|
let bestPercent = 0;
|
||||||
|
let usePerformanceFlags = externalExtractorSupportsPerfFlags && shouldUseExtractorPerformanceFlags();
|
||||||
|
|
||||||
for (const password of passwords) {
|
for (const password of passwords) {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
@ -408,8 +527,8 @@ async function runExternalExtract(
|
|||||||
announcedStart = true;
|
announcedStart = true;
|
||||||
onArchiveProgress?.(0);
|
onArchiveProgress?.(0);
|
||||||
}
|
}
|
||||||
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password);
|
let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags);
|
||||||
const result = await runExtractCommand(command, args, (chunk) => {
|
let result = await runExtractCommand(command, args, (chunk) => {
|
||||||
const parsed = parseProgressPercent(chunk);
|
const parsed = parseProgressPercent(chunk);
|
||||||
if (parsed === null || parsed <= bestPercent) {
|
if (parsed === null || parsed <= bestPercent) {
|
||||||
return;
|
return;
|
||||||
@ -417,6 +536,22 @@ async function runExternalExtract(
|
|||||||
bestPercent = parsed;
|
bestPercent = parsed;
|
||||||
onArchiveProgress?.(bestPercent);
|
onArchiveProgress?.(bestPercent);
|
||||||
}, signal, timeoutMs);
|
}, 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) {
|
if (result.ok) {
|
||||||
onArchiveProgress?.(100);
|
onArchiveProgress?.(100);
|
||||||
return password;
|
return password;
|
||||||
@ -523,6 +658,14 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
|
|||||||
return Array.from(targets);
|
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)) {
|
if (/\.7z$/i.test(fileName)) {
|
||||||
const stem = escapeRegex(fileName.replace(/\.7z$/i, ""));
|
const stem = escapeRegex(fileName.replace(/\.7z$/i, ""));
|
||||||
addMatching(new RegExp(`^${stem}\\.7z$`, "i"));
|
addMatching(new RegExp(`^${stem}\\.7z$`, "i"));
|
||||||
@ -530,6 +673,14 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
|
|||||||
return Array.from(targets);
|
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);
|
return Array.from(targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import { once } from "node:events";
|
import { EventEmitter, once } from "node:events";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { DownloadManager } from "../src/main/download-manager";
|
import { DownloadManager } from "../src/main/download-manager";
|
||||||
@ -726,6 +726,115 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
}, 35000);
|
}, 35000);
|
||||||
|
|
||||||
|
it("recovers when write stream backpressure never drains", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const binary = Buffer.alloc(180 * 1024, 19);
|
||||||
|
const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS;
|
||||||
|
process.env.RD_STALL_TIMEOUT_MS = "2200";
|
||||||
|
let directCalls = 0;
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if ((req.url || "") !== "/drain-stall") {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not-found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
directCalls += 1;
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", String(binary.length));
|
||||||
|
res.end(binary);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("server address unavailable");
|
||||||
|
}
|
||||||
|
const directUrl = `http://127.0.0.1:${address.port}/drain-stall`;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
download: directUrl,
|
||||||
|
filename: "drain-stall.bin",
|
||||||
|
filesize: binary.length
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalCreateWriteStream = fs.createWriteStream;
|
||||||
|
let writeStreamCalls = 0;
|
||||||
|
const fsMutable = fs as unknown as { createWriteStream: typeof fs.createWriteStream };
|
||||||
|
fsMutable.createWriteStream = ((...args: Parameters<typeof fs.createWriteStream>) => {
|
||||||
|
writeStreamCalls += 1;
|
||||||
|
if (writeStreamCalls === 1) {
|
||||||
|
class HangingWriteStream extends EventEmitter {
|
||||||
|
public closed = false;
|
||||||
|
|
||||||
|
public destroyed = false;
|
||||||
|
|
||||||
|
public write(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public end(): void {
|
||||||
|
this.closed = true;
|
||||||
|
this.emit("close");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new HangingWriteStream() as unknown as ReturnType<typeof fs.createWriteStream>;
|
||||||
|
}
|
||||||
|
return originalCreateWriteStream(...args);
|
||||||
|
}) as typeof fs.createWriteStream;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: false
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "drain-stall", links: ["https://dummy/drain-stall"] }]);
|
||||||
|
manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 40000);
|
||||||
|
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
expect(item?.status).toBe("completed");
|
||||||
|
expect(item?.retries).toBeGreaterThan(0);
|
||||||
|
expect(directCalls).toBeGreaterThan(1);
|
||||||
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
||||||
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
||||||
|
} finally {
|
||||||
|
fsMutable.createWriteStream = originalCreateWriteStream;
|
||||||
|
if (previousStallTimeout === undefined) {
|
||||||
|
delete process.env.RD_STALL_TIMEOUT_MS;
|
||||||
|
} else {
|
||||||
|
process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout;
|
||||||
|
}
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
}, 45000);
|
||||||
|
|
||||||
it("uses content-disposition filename when provider filename is opaque", async () => {
|
it("uses content-disposition filename when provider filename is opaque", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
@ -15,29 +15,30 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe("extractor", () => {
|
describe("extractor", () => {
|
||||||
it("maps external extractor args by conflict mode", () => {
|
it("maps external extractor args by conflict mode", () => {
|
||||||
expect(buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite")).toEqual([
|
const overwriteArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite");
|
||||||
"x",
|
expect(overwriteArgs.slice(0, 4)).toEqual(["x", "-o+", "-p-", "-y"]);
|
||||||
"-o+",
|
expect(overwriteArgs).toContain("-idc");
|
||||||
"-p-",
|
expect(overwriteArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(true);
|
||||||
"-y",
|
expect(overwriteArgs[overwriteArgs.length - 2]).toBe("archive.rar");
|
||||||
"archive.rar",
|
expect(overwriteArgs[overwriteArgs.length - 1]).toBe("C:\\target\\");
|
||||||
"C:\\target\\"
|
|
||||||
]);
|
const askArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "ask", "serienfans.org");
|
||||||
expect(buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "ask", "serienfans.org")).toEqual([
|
expect(askArgs.slice(0, 4)).toEqual(["x", "-o-", "-pserienfans.org", "-y"]);
|
||||||
"x",
|
expect(askArgs).toContain("-idc");
|
||||||
"-o-",
|
expect(askArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(true);
|
||||||
"-pserienfans.org",
|
expect(askArgs[askArgs.length - 2]).toBe("archive.rar");
|
||||||
"-y",
|
expect(askArgs[askArgs.length - 1]).toBe("C:\\target\\");
|
||||||
"archive.rar",
|
|
||||||
"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");
|
const unrarRename = buildExternalExtractArgs("unrar", "archive.rar", "C:\\target", "rename");
|
||||||
expect(unrarRename[0]).toBe("x");
|
expect(unrarRename[0]).toBe("x");
|
||||||
expect(unrarRename[1]).toBe("-or");
|
expect(unrarRename[1]).toBe("-or");
|
||||||
expect(unrarRename[2]).toBe("-p-");
|
expect(unrarRename[2]).toBe("-p-");
|
||||||
expect(unrarRename[3]).toBe("-y");
|
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 () => {
|
it("deletes only successfully extracted archives", async () => {
|
||||||
@ -94,6 +95,74 @@ describe("extractor", () => {
|
|||||||
expect(targets.has(other)).toBe(false);
|
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 () => {
|
it("deletes split zip companion parts when cleanup is enabled", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user