Release v1.4.6 with extraction resume safety and smoother runtime
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-02-27 18:59:04 +01:00
parent 05a75d0ac5
commit dbf1c34282
7 changed files with 588 additions and 56 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.5", "version": "1.4.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.5", "version": "1.4.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.5", "version": "1.4.6",
"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",

View File

@ -175,6 +175,8 @@ export class DownloadManager extends EventEmitter {
private packagePostProcessTasks = new Map<string, Promise<void>>(); private packagePostProcessTasks = new Map<string, Promise<void>>();
private packagePostProcessAbortControllers = new Map<string, AbortController>();
private reservedTargetPaths = new Map<string, string>(); private reservedTargetPaths = new Map<string, string>();
private claimedTargetPathByItem = new Map<string, string>(); private claimedTargetPathByItem = new Map<string, string>();
@ -189,6 +191,8 @@ export class DownloadManager extends EventEmitter {
private lastSchedulerHeartbeatAt = 0; private lastSchedulerHeartbeatAt = 0;
private lastReconnectMarkAt = 0;
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
super(); super();
this.settings = settings; this.settings = settings;
@ -435,6 +439,7 @@ export class DownloadManager extends EventEmitter {
public clearAll(): void { public clearAll(): void {
this.stop(); this.stop();
this.abortPostProcessing("clear_all");
this.session.packageOrder = []; this.session.packageOrder = [];
this.session.packages = {}; this.session.packages = {};
this.session.items = {}; this.session.items = {};
@ -446,6 +451,7 @@ export class DownloadManager extends EventEmitter {
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
this.packagePostProcessTasks.clear(); this.packagePostProcessTasks.clear();
this.packagePostProcessAbortControllers.clear();
this.packagePostProcessQueue = Promise.resolve(); this.packagePostProcessQueue = Promise.resolve();
this.summary = null; this.summary = null;
this.persistNow(); this.persistNow();
@ -764,6 +770,9 @@ export class DownloadManager extends EventEmitter {
if (!pkg || pkg.cancelled || pkg.status !== "completed") { if (!pkg || pkg.cancelled || pkg.status !== "completed") {
continue; continue;
} }
if (this.packagePostProcessTasks.has(packageId)) {
continue;
}
const items = pkg.itemIds const items = pkg.itemIds
.map((itemId) => this.session.items[itemId]) .map((itemId) => this.session.items[itemId])
@ -995,6 +1004,7 @@ export class DownloadManager extends EventEmitter {
this.session.summaryText = ""; this.session.summaryText = "";
this.session.reconnectUntil = 0; this.session.reconnectUntil = 0;
this.session.reconnectReason = ""; this.session.reconnectReason = "";
this.lastReconnectMarkAt = 0;
this.speedEvents = []; this.speedEvents = [];
this.speedBytesLastWindow = 0; this.speedBytesLastWindow = 0;
this.summary = null; this.summary = null;
@ -1008,6 +1018,7 @@ export class DownloadManager extends EventEmitter {
this.session.paused = false; this.session.paused = false;
this.session.reconnectUntil = 0; this.session.reconnectUntil = 0;
this.session.reconnectReason = ""; this.session.reconnectReason = "";
this.abortPostProcessing("stop");
for (const active of this.activeTasks.values()) { for (const active of this.activeTasks.values()) {
active.abortReason = "stop"; active.abortReason = "stop";
active.abortController.abort("stop"); active.abortController.abort("stop");
@ -1022,6 +1033,7 @@ export class DownloadManager extends EventEmitter {
this.session.paused = false; this.session.paused = false;
this.session.reconnectUntil = 0; this.session.reconnectUntil = 0;
this.session.reconnectReason = ""; this.session.reconnectReason = "";
this.abortPostProcessing("shutdown");
let requeuedItems = 0; let requeuedItems = 0;
for (const active of this.activeTasks.values()) { for (const active of this.activeTasks.values()) {
@ -1050,6 +1062,18 @@ export class DownloadManager extends EventEmitter {
} }
} }
for (const item of Object.values(this.session.items)) {
if (item.status === "completed" && /^Entpacken/i.test(item.fullStatus || "")) {
item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
item.updatedAt = nowMs();
const pkg = this.session.packages[item.packageId];
if (pkg) {
pkg.status = pkg.enabled ? "queued" : "paused";
pkg.updatedAt = nowMs();
}
}
}
this.speedEvents = []; this.speedEvents = [];
this.speedBytesLastWindow = 0; this.speedBytesLastWindow = 0;
this.runItemIds.clear(); this.runItemIds.clear();
@ -1288,22 +1312,55 @@ export class DownloadManager extends EventEmitter {
this.claimedTargetPathByItem.delete(itemId); this.claimedTargetPathByItem.delete(itemId);
} }
private abortPostProcessing(reason: string): void {
for (const [packageId, controller] of this.packagePostProcessAbortControllers.entries()) {
if (!controller.signal.aborted) {
controller.abort(reason);
}
const pkg = this.session.packages[packageId];
if (!pkg) {
continue;
}
if (pkg.status === "extracting" || pkg.status === "integrity_check") {
pkg.status = pkg.enabled ? "queued" : "paused";
pkg.updatedAt = nowMs();
}
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item || item.status !== "completed") {
continue;
}
if (/^Entpacken/i.test(item.fullStatus || "")) {
item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
item.updatedAt = nowMs();
}
}
}
}
private runPackagePostProcessing(packageId: string): Promise<void> { private runPackagePostProcessing(packageId: string): Promise<void> {
const existing = this.packagePostProcessTasks.get(packageId); const existing = this.packagePostProcessTasks.get(packageId);
if (existing) { if (existing) {
return existing; return existing;
} }
const abortController = new AbortController();
this.packagePostProcessAbortControllers.set(packageId, abortController);
const task = this.packagePostProcessQueue const task = this.packagePostProcessQueue
.catch(() => undefined) .catch(() => undefined)
.then(async () => { .then(async () => {
await this.handlePackagePostProcessing(packageId); await this.handlePackagePostProcessing(packageId, abortController.signal);
}) })
.catch((error) => { .catch((error) => {
logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`); logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`);
}) })
.finally(() => { .finally(() => {
this.packagePostProcessTasks.delete(packageId); this.packagePostProcessTasks.delete(packageId);
this.packagePostProcessAbortControllers.delete(packageId);
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
}); });
@ -1393,8 +1450,15 @@ export class DownloadManager extends EventEmitter {
} }
if (this.reconnectActive() && (this.nonResumableActive > 0 || this.activeTasks.size === 0)) { if (this.reconnectActive() && (this.nonResumableActive > 0 || this.activeTasks.size === 0)) {
this.markQueuedAsReconnectWait(); const markNow = nowMs();
await sleep(200); if (markNow - this.lastReconnectMarkAt >= 900) {
this.lastReconnectMarkAt = markNow;
const changed = this.markQueuedAsReconnectWait();
if (!changed) {
this.emitState();
}
}
await sleep(220);
continue; continue;
} }
@ -1431,6 +1495,7 @@ export class DownloadManager extends EventEmitter {
const until = nowMs() + this.settings.reconnectWaitSeconds * 1000; const until = nowMs() + this.settings.reconnectWaitSeconds * 1000;
this.session.reconnectUntil = Math.max(this.session.reconnectUntil, until); this.session.reconnectUntil = Math.max(this.session.reconnectUntil, until);
this.session.reconnectReason = reason; this.session.reconnectReason = reason;
this.lastReconnectMarkAt = 0;
for (const active of this.activeTasks.values()) { for (const active of this.activeTasks.values()) {
if (active.resumable) { if (active.resumable) {
@ -1443,7 +1508,8 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
} }
private markQueuedAsReconnectWait(): void { private markQueuedAsReconnectWait(): boolean {
let changed = false;
for (const item of Object.values(this.session.items)) { for (const item of Object.values(this.session.items)) {
const pkg = this.session.packages[item.packageId]; const pkg = this.session.packages[item.packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) { if (!pkg || pkg.cancelled || !pkg.enabled) {
@ -1453,10 +1519,14 @@ export class DownloadManager extends EventEmitter {
item.status = "reconnect_wait"; item.status = "reconnect_wait";
item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`; item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
changed = true;
} }
} }
if (changed) {
this.emitState(); this.emitState();
} }
return changed;
}
private findNextQueuedItem(): { packageId: string; itemId: string } | null { private findNextQueuedItem(): { packageId: string; itemId: string } | null {
for (const packageId of this.session.packageOrder) { for (const packageId of this.session.packageOrder) {
@ -1923,6 +1993,16 @@ export class DownloadManager extends EventEmitter {
let written = writeMode === "a" ? existingBytes : 0; let written = writeMode === "a" ? existingBytes : 0;
let windowBytes = 0; let windowBytes = 0;
let windowStarted = nowMs(); let windowStarted = nowMs();
const itemCount = Object.keys(this.session.items).length;
const uiUpdateIntervalMs = itemCount >= 1500
? 650
: itemCount >= 700
? 420
: itemCount >= 250
? 280
: 170;
let lastUiEmitAt = 0;
let lastProgressPercent = item.progressPercent;
const waitDrain = (): Promise<void> => new Promise((resolve, reject) => { const waitDrain = (): Promise<void> => new Promise((resolve, reject) => {
const onDrain = (): void => { const onDrain = (): void => {
@ -2027,8 +2107,14 @@ export class DownloadManager extends EventEmitter {
item.downloadedBytes = written; item.downloadedBytes = written;
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0; item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0;
item.fullStatus = `Download läuft (${providerLabel(item.provider)})`; item.fullStatus = `Download läuft (${providerLabel(item.provider)})`;
item.updatedAt = nowMs(); const nowTick = nowMs();
const progressChanged = item.progressPercent !== lastProgressPercent;
if (progressChanged || nowTick - lastUiEmitAt >= uiUpdateIntervalMs) {
item.updatedAt = nowTick;
this.emitState(); this.emitState();
lastUiEmitAt = nowTick;
lastProgressPercent = item.progressPercent;
}
} }
} finally { } finally {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@ -2274,11 +2360,14 @@ export class DownloadManager extends EventEmitter {
} }
} }
private async handlePackagePostProcessing(packageId: string): Promise<void> { private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise<void> {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled) { if (!pkg || pkg.cancelled) {
return; return;
} }
if (signal?.aborted) {
return;
}
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
const success = items.filter((item) => item.status === "completed").length; const success = items.filter((item) => item.status === "completed").length;
const failed = items.filter((item) => item.status === "failed").length; const failed = items.filter((item) => item.status === "failed").length;
@ -2299,9 +2388,13 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
const updateExtractingStatus = (text: string): void => { const updateExtractingStatus = (text: string): void => {
const updatedAt = nowMs();
for (const entry of completedItems) { for (const entry of completedItems) {
if (entry.fullStatus === text) {
continue;
}
entry.fullStatus = text; entry.fullStatus = text;
entry.updatedAt = nowMs(); entry.updatedAt = updatedAt;
} }
}; };
@ -2317,10 +2410,19 @@ export class DownloadManager extends EventEmitter {
removeLinks: this.settings.removeLinkFilesAfterExtract, removeLinks: this.settings.removeLinkFilesAfterExtract,
removeSamples: this.settings.removeSamplesAfterExtract, removeSamples: this.settings.removeSamplesAfterExtract,
passwordList: this.settings.archivePasswordList, passwordList: this.settings.archivePasswordList,
signal,
onProgress: (progress) => { onProgress: (progress) => {
const label = progress.phase === "done" const label = progress.phase === "done"
? "Entpacken 100%" ? "Entpacken 100%"
: `Entpacken ${progress.percent}% (${progress.current}/${progress.total})`; : (() => {
const archive = progress.archiveName ? ` · ${progress.archiveName}` : "";
const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
: "";
const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
return `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`;
})();
updateExtractingStatus(label); updateExtractingStatus(label);
this.emitState(); this.emitState();
} }
@ -2343,6 +2445,19 @@ export class DownloadManager extends EventEmitter {
pkg.status = "completed"; pkg.status = "completed";
} }
} catch (error) { } catch (error) {
const reasonRaw = String(error || "");
if (reasonRaw.includes("aborted:extract")) {
for (const entry of completedItems) {
if (/^Entpacken/i.test(entry.fullStatus || "")) {
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
}
entry.updatedAt = nowMs();
}
pkg.status = pkg.enabled ? "queued" : "paused";
pkg.updatedAt = nowMs();
logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`);
return;
}
const reason = compactErrorText(error); const reason = compactErrorText(error);
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
for (const entry of completedItems) { for (const entry of completedItems) {

View File

@ -20,6 +20,7 @@ export interface ExtractOptions {
removeLinks: boolean; removeLinks: boolean;
removeSamples: boolean; removeSamples: boolean;
passwordList?: string; passwordList?: string;
signal?: AbortSignal;
onProgress?: (update: ExtractProgressUpdate) => void; onProgress?: (update: ExtractProgressUpdate) => void;
} }
@ -28,9 +29,18 @@ export interface ExtractProgressUpdate {
total: number; total: number;
percent: number; percent: number;
archiveName: string; archiveName: string;
archivePercent?: number;
elapsedMs?: number;
phase: "extracting" | "done"; phase: "extracting" | "done";
} }
const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024;
const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json";
type ExtractResumeState = {
completedArchives: string[];
};
function findArchiveCandidates(packageDir: string): string[] { function findArchiveCandidates(packageDir: string): string[] {
const files = fs.readdirSync(packageDir, { withFileTypes: true }) const files = fs.readdirSync(packageDir, { withFileTypes: true })
.filter((entry) => entry.isFile()) .filter((entry) => entry.isFile())
@ -59,6 +69,77 @@ function cleanErrorText(text: string): string {
return String(text || "").replace(/\s+/g, " ").trim().slice(0, 240); return String(text || "").replace(/\s+/g, " ").trim().slice(0, 240);
} }
function appendLimited(base: string, chunk: string, maxLen = MAX_EXTRACT_OUTPUT_BUFFER): string {
const next = `${base}${chunk}`;
if (next.length <= maxLen) {
return next;
}
return next.slice(next.length - maxLen);
}
function parseProgressPercent(chunk: string): number | null {
const text = String(chunk || "");
const regex = /(?:^|\D)(\d{1,3})%/g;
let match: RegExpExecArray | null = regex.exec(text);
let latest: number | null = null;
while (match) {
const value = Number(match[1]);
if (Number.isFinite(value) && value >= 0 && value <= 100) {
latest = value;
}
match = regex.exec(text);
}
return latest;
}
function shouldPreferExternalZip(archivePath: string): boolean {
try {
const stat = fs.statSync(archivePath);
return stat.size >= 64 * 1024 * 1024;
} catch {
return true;
}
}
function extractProgressFilePath(packageDir: string): string {
return path.join(packageDir, EXTRACT_PROGRESS_FILE);
}
function readExtractResumeState(packageDir: string): Set<string> {
const progressPath = extractProgressFilePath(packageDir);
if (!fs.existsSync(progressPath)) {
return new Set<string>();
}
try {
const payload = JSON.parse(fs.readFileSync(progressPath, "utf8")) as Partial<ExtractResumeState>;
const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : [];
return new Set(names.map((value) => String(value || "").trim()).filter(Boolean));
} catch {
return new Set<string>();
}
}
function writeExtractResumeState(packageDir: string, completedArchives: Set<string>): void {
const progressPath = extractProgressFilePath(packageDir);
const payload: ExtractResumeState = {
completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b))
};
fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8");
}
function clearExtractResumeState(packageDir: string): void {
try {
fs.rmSync(extractProgressFilePath(packageDir), { force: true });
} catch {
// ignore
}
}
function isExtractAbortError(errorText: string): boolean {
const text = String(errorText || "").toLowerCase();
return text.includes("aborted:extract") || text.includes("extract_aborted");
}
function archivePasswords(listInput: string): string[] { function archivePasswords(listInput: string): string[] {
const custom = String(listInput || "") const custom = String(listInput || "")
.split(/\r?\n/g) .split(/\r?\n/g)
@ -109,48 +190,81 @@ function isNoExtractorError(errorText: string): boolean {
type ExtractSpawnResult = { type ExtractSpawnResult = {
ok: boolean; ok: boolean;
missingCommand: boolean; missingCommand: boolean;
aborted: boolean;
errorText: string; errorText: string;
}; };
function runExtractCommand(command: string, args: string[]): Promise<ExtractSpawnResult> { function runExtractCommand(
command: string,
args: string[],
onChunk?: (chunk: string) => void,
signal?: AbortSignal
): Promise<ExtractSpawnResult> {
if (signal?.aborted) {
return Promise.resolve({ ok: false, missingCommand: false, aborted: true, errorText: "aborted:extract" });
}
return new Promise((resolve) => { return new Promise((resolve) => {
let settled = false; let settled = false;
let output = ""; let output = "";
const child = spawn(command, args, { windowsHide: true }); const child = spawn(command, args, { windowsHide: true });
child.stdout.on("data", (chunk) => { const finish = (result: ExtractSpawnResult): void => {
output += String(chunk || "");
});
child.stderr.on("data", (chunk) => {
output += String(chunk || "");
});
child.on("error", (error) => {
if (settled) { if (settled) {
return; return;
} }
settled = true; settled = true;
if (signal && onAbort) {
signal.removeEventListener("abort", onAbort);
}
resolve(result);
};
const onAbort = signal
? (): void => {
try {
child.kill();
} catch {
// ignore
}
finish({ ok: false, missingCommand: false, aborted: true, errorText: "aborted:extract" });
}
: null;
if (signal && onAbort) {
signal.addEventListener("abort", onAbort, { once: true });
}
child.stdout.on("data", (chunk) => {
const text = String(chunk || "");
output = appendLimited(output, text);
onChunk?.(text);
});
child.stderr.on("data", (chunk) => {
const text = String(chunk || "");
output = appendLimited(output, text);
onChunk?.(text);
});
child.on("error", (error) => {
const text = cleanErrorText(String(error)); const text = cleanErrorText(String(error));
resolve({ finish({
ok: false, ok: false,
missingCommand: text.toLowerCase().includes("enoent"), missingCommand: text.toLowerCase().includes("enoent"),
aborted: false,
errorText: text errorText: text
}); });
}); });
child.on("close", (code) => { child.on("close", (code) => {
if (settled) {
return;
}
settled = true;
if (code === 0 || code === 1) { if (code === 0 || code === 1) {
resolve({ ok: true, missingCommand: false, errorText: "" }); finish({ ok: true, missingCommand: false, aborted: false, errorText: "" });
return; return;
} }
const cleaned = cleanErrorText(output); const cleaned = cleanErrorText(output);
resolve({ finish({
ok: false, ok: false,
missingCommand: false, missingCommand: false,
aborted: false,
errorText: cleaned || `Exit Code ${String(code ?? "?")}` errorText: cleaned || `Exit Code ${String(code ?? "?")}`
}); });
}); });
@ -208,7 +322,9 @@ async function runExternalExtract(
archivePath: string, archivePath: string,
targetDir: string, targetDir: string,
conflictMode: ConflictMode, conflictMode: ConflictMode,
passwordCandidates: string[] passwordCandidates: string[],
onArchiveProgress?: (percent: number) => void,
signal?: AbortSignal
): Promise<void> { ): Promise<void> {
const command = await resolveExtractorCommand(); const command = await resolveExtractorCommand();
const passwords = passwordCandidates; const passwords = passwordCandidates;
@ -216,12 +332,34 @@ async function runExternalExtract(
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
let announcedStart = false;
let bestPercent = 0;
for (const password of passwords) { for (const password of passwords) {
if (signal?.aborted) {
throw new Error("aborted:extract");
}
if (!announcedStart) {
announcedStart = true;
onArchiveProgress?.(0);
}
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password); const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password);
const result = await runExtractCommand(command, args); const result = await runExtractCommand(command, args, (chunk) => {
if (result.ok) { const parsed = parseProgressPercent(chunk);
if (parsed === null || parsed <= bestPercent) {
return; return;
} }
bestPercent = parsed;
onArchiveProgress?.(bestPercent);
}, signal);
if (result.ok) {
onArchiveProgress?.(100);
return;
}
if (result.aborted) {
throw new Error("aborted:extract");
}
if (result.missingCommand) { if (result.missingCommand) {
resolvedExtractorCommand = null; resolvedExtractorCommand = null;
@ -467,9 +605,14 @@ function removeEmptyDirectoryTree(rootDir: string): number {
} }
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> { export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
if (options.signal?.aborted) {
throw new Error("aborted:extract");
}
const candidates = findArchiveCandidates(options.packageDir); const candidates = findArchiveCandidates(options.packageDir);
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`); logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
if (candidates.length === 0) { if (candidates.length === 0) {
clearExtractResumeState(options.packageDir);
logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`); logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`);
return { extracted: 0, failed: 0, lastError: "" }; return { extracted: 0, failed: 0, lastError: "" };
} }
@ -477,68 +620,148 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
const conflictMode = effectiveConflictMode(options.conflictMode); const conflictMode = effectiveConflictMode(options.conflictMode);
const passwordCandidates = archivePasswords(options.passwordList || ""); const passwordCandidates = archivePasswords(options.passwordList || "");
const beforeFingerprint = captureDirFingerprint(options.targetDir); const beforeFingerprint = captureDirFingerprint(options.targetDir);
let extracted = 0; const resumeCompleted = readExtractResumeState(options.packageDir);
const resumeCompletedAtStart = resumeCompleted.size;
const candidateNames = new Set(candidates.map((archivePath) => path.basename(archivePath)));
for (const archiveName of Array.from(resumeCompleted.values())) {
if (!candidateNames.has(archiveName)) {
resumeCompleted.delete(archiveName);
}
}
if (resumeCompleted.size > 0) {
writeExtractResumeState(options.packageDir, resumeCompleted);
} else {
clearExtractResumeState(options.packageDir);
}
const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(path.basename(archivePath)));
let extracted = resumeCompleted.size;
let failed = 0; let failed = 0;
let lastError = ""; let lastError = "";
const extractedArchives: string[] = []; const extractedArchives = new Set<string>();
for (const archivePath of candidates) {
if (resumeCompleted.has(path.basename(archivePath))) {
extractedArchives.add(archivePath);
}
}
const emitProgress = (current: number, archiveName: string, phase: "extracting" | "done"): void => { const emitProgress = (
current: number,
archiveName: string,
phase: "extracting" | "done",
archivePercent?: number,
elapsedMs?: number
): void => {
if (!options.onProgress) { if (!options.onProgress) {
return; return;
} }
const total = Math.max(1, candidates.length); const total = Math.max(1, candidates.length);
const percent = Math.max(0, Math.min(100, Math.floor((current / total) * 100))); let percent = Math.max(0, Math.min(100, Math.floor((current / total) * 100)));
options.onProgress({ current, total, percent, archiveName, phase }); if (phase !== "done") {
const boundedCurrent = Math.max(0, Math.min(total, current));
const boundedArchivePercent = Math.max(0, Math.min(100, Number(archivePercent ?? 0)));
percent = Math.max(0, Math.min(100, Math.floor(((boundedCurrent + (boundedArchivePercent / 100)) / total) * 100)));
}
options.onProgress({
current,
total,
percent,
archiveName,
archivePercent,
elapsedMs,
phase
});
}; };
emitProgress(0, "", "extracting"); emitProgress(extracted, "", "extracting");
for (const archivePath of candidates) { for (const archivePath of pendingCandidates) {
if (options.signal?.aborted) {
throw new Error("aborted:extract");
}
const archiveName = path.basename(archivePath); const archiveName = path.basename(archivePath);
emitProgress(extracted + failed, archiveName, "extracting"); const archiveStartedAt = Date.now();
let archivePercent = 0;
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0);
const pulseTimer = setInterval(() => {
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, 1100);
logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}`); logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}`);
try { try {
const ext = path.extname(archivePath).toLowerCase(); const ext = path.extname(archivePath).toLowerCase();
if (ext === ".zip") { if (ext === ".zip") {
const preferExternal = shouldPreferExternalZip(archivePath);
if (preferExternal) {
try { try {
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal);
} catch (error) {
if (isNoExtractorError(String(error))) {
extractZipArchive(archivePath, options.targetDir, options.conflictMode); extractZipArchive(archivePath, options.targetDir, options.conflictMode);
} catch { } else {
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates); throw error;
}
} }
} else { } else {
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates); try {
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
archivePercent = 100;
} catch {
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal);
}
}
} else {
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal);
} }
extracted += 1; extracted += 1;
extractedArchives.push(archivePath); extractedArchives.add(archivePath);
resumeCompleted.add(archiveName);
writeExtractResumeState(options.packageDir, resumeCompleted);
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
emitProgress(extracted + failed, archiveName, "extracting"); archivePercent = 100;
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
} catch (error) { } catch (error) {
failed += 1; failed += 1;
const errorText = String(error); const errorText = String(error);
if (isExtractAbortError(errorText)) {
throw new Error("aborted:extract");
}
lastError = errorText; lastError = errorText;
logger.error(`Entpack-Fehler ${path.basename(archivePath)}: ${errorText}`); logger.error(`Entpack-Fehler ${path.basename(archivePath)}: ${errorText}`);
emitProgress(extracted + failed, archiveName, "extracting"); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
if (isNoExtractorError(errorText)) { if (isNoExtractorError(errorText)) {
const remaining = candidates.length - (extracted + failed); const remaining = candidates.length - (extracted + failed);
if (remaining > 0) { if (remaining > 0) {
failed += remaining; failed += remaining;
emitProgress(candidates.length, archiveName, "extracting"); emitProgress(candidates.length, archiveName, "extracting", 0, Date.now() - archiveStartedAt);
} }
break; break;
} }
} finally {
clearInterval(pulseTimer);
} }
} }
if (extracted > 0) { if (extracted > 0) {
const afterFingerprint = captureDirFingerprint(options.targetDir); const afterFingerprint = captureDirFingerprint(options.targetDir);
const changedOutput = hasDirChanges(beforeFingerprint, afterFingerprint); const changedOutput = hasDirChanges(beforeFingerprint, afterFingerprint);
if (!changedOutput && conflictMode !== "skip") { const hadResumeProgress = resumeCompletedAtStart > 0;
if (!changedOutput && conflictMode !== "skip" && !hadResumeProgress) {
lastError = "Keine entpackten Dateien erkannt"; lastError = "Keine entpackten Dateien erkannt";
failed += extracted; failed += extracted;
extracted = 0; extracted = 0;
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`); logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`);
} else { } else {
const removedArchives = cleanupArchives(extractedArchives, options.cleanupMode); const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values());
const removedArchives = cleanupArchives(cleanupSources, options.cleanupMode);
if (options.cleanupMode !== "none") { if (options.cleanupMode !== "none") {
logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`); logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`);
} }
@ -551,6 +774,10 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`); logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`);
} }
if (failed === 0 && resumeCompleted.size >= candidates.length) {
clearExtractResumeState(options.packageDir);
}
if (options.cleanupMode === "delete" && !hasAnyFilesRecursive(options.packageDir)) { if (options.cleanupMode === "delete" && !hasAnyFilesRecursive(options.packageDir)) {
const removedDirs = removeEmptyDirectoryTree(options.packageDir); const removedDirs = removeEmptyDirectoryTree(options.packageDir);
if (removedDirs > 0) { if (removedDirs > 0) {
@ -568,6 +795,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} }
} }
if (failed > 0) {
if (resumeCompleted.size > 0) {
writeExtractResumeState(options.packageDir, resumeCompleted);
} else {
clearExtractResumeState(options.packageDir);
}
}
emitProgress(candidates.length, "", "done"); emitProgress(candidates.length, "", "done");
logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`); logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`);

View File

@ -1,4 +1,4 @@
import { DragEvent, KeyboardEvent, ReactElement, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import type { import type {
AppSettings, AppSettings,
AppTheme, AppTheme,
@ -97,6 +97,7 @@ export function App(): ReactElement {
]); ]);
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
const activeCollectorTabRef = useRef(activeCollectorTab); const activeCollectorTabRef = useRef(activeCollectorTab);
const activeTabRef = useRef<Tab>(tab);
const draggedPackageIdRef = useRef<string | null>(null); const draggedPackageIdRef = useRef<string | null>(null);
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({}); const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
const [downloadSearch, setDownloadSearch] = useState(""); const [downloadSearch, setDownloadSearch] = useState("");
@ -114,6 +115,10 @@ export function App(): ReactElement {
activeCollectorTabRef.current = activeCollectorTab; activeCollectorTabRef.current = activeCollectorTab;
}, [activeCollectorTab]); }, [activeCollectorTab]);
useEffect(() => {
activeTabRef.current = tab;
}, [tab]);
useEffect(() => { useEffect(() => {
settingsDirtyRef.current = settingsDirty; settingsDirtyRef.current = settingsDirty;
}, [settingsDirty]); }, [settingsDirty]);
@ -146,6 +151,22 @@ export function App(): ReactElement {
unsubscribe = window.rd.onStateUpdate((state) => { unsubscribe = window.rd.onStateUpdate((state) => {
latestStateRef.current = state; latestStateRef.current = state;
if (stateFlushTimerRef.current) { return; } if (stateFlushTimerRef.current) { return; }
const itemCount = Object.keys(state.session.items).length;
let flushDelay = itemCount >= 1500
? 850
: itemCount >= 700
? 620
: itemCount >= 250
? 420
: 180;
if (!state.session.running) {
flushDelay = Math.min(flushDelay, 260);
}
if (activeTabRef.current !== "downloads") {
flushDelay = Math.max(flushDelay, 320);
}
stateFlushTimerRef.current = setTimeout(() => { stateFlushTimerRef.current = setTimeout(() => {
stateFlushTimerRef.current = null; stateFlushTimerRef.current = null;
if (latestStateRef.current) { if (latestStateRef.current) {
@ -156,7 +177,7 @@ export function App(): ReactElement {
} }
latestStateRef.current = null; latestStateRef.current = null;
} }
}, 220); }, flushDelay);
}); });
unsubClipboard = window.rd.onClipboardDetected((links) => { unsubClipboard = window.rd.onClipboardDetected((links) => {
showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000); showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000);
@ -182,7 +203,7 @@ export function App(): ReactElement {
const packages = useMemo(() => snapshot.session.packageOrder const packages = useMemo(() => snapshot.session.packageOrder
.map((id: string) => snapshot.session.packages[id]) .map((id: string) => snapshot.session.packages[id])
.filter(Boolean), [snapshot]); .filter(Boolean), [snapshot.session.packageOrder, snapshot.session.packages]);
const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]); const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]);
@ -643,7 +664,7 @@ export function App(): ReactElement {
} }
} }
return map; return map;
}, [snapshot]); }, [snapshot.session.items]);
return ( return (
<div <div
@ -1060,7 +1081,7 @@ interface PackageCardProps {
onDragEnd: () => void; onDragEnd: () => void;
} }
function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement { const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
const done = items.filter((item) => item.status === "completed").length; const done = items.filter((item) => item.status === "completed").length;
const failed = items.filter((item) => item.status === "failed").length; const failed = items.filter((item) => item.status === "failed").length;
const cancelled = items.filter((item) => item.status === "cancelled").length; const cancelled = items.filter((item) => item.status === "cancelled").length;
@ -1130,4 +1151,45 @@ function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, edi
</table>} </table>}
</article> </article>
); );
}, (prev, next) => {
if (prev.pkg.id !== next.pkg.id) {
return false;
} }
if (prev.pkg.updatedAt !== next.pkg.updatedAt
|| prev.pkg.status !== next.pkg.status
|| prev.pkg.enabled !== next.pkg.enabled
|| prev.pkg.name !== next.pkg.name) {
return false;
}
if (prev.packageSpeed !== next.packageSpeed
|| prev.isFirst !== next.isFirst
|| prev.isLast !== next.isLast
|| prev.isEditing !== next.isEditing
|| prev.collapsed !== next.collapsed) {
return false;
}
if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) {
return false;
}
if (prev.items.length !== next.items.length) {
return false;
}
for (let index = 0; index < prev.items.length; index += 1) {
const a = prev.items[index];
const b = next.items[index];
if (!a || !b) {
return false;
}
if (a.id !== b.id
|| a.updatedAt !== b.updatedAt
|| a.status !== b.status
|| a.progressPercent !== b.progressPercent
|| a.speedBps !== b.speedBps
|| a.retries !== b.retries
|| a.provider !== b.provider
|| a.fullStatus !== b.fullStatus) {
return false;
}
}
return true;
});

View File

@ -2807,6 +2807,69 @@ describe("download manager", () => {
} }
}); });
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 () => { it("recovers pending extraction on startup for completed package", 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);

View File

@ -274,4 +274,61 @@ describe("extractor", () => {
expect(result.failed).toBe(1); expect(result.failed).toBe(1);
expect(fs.existsSync(targetDir)).toBe(false); expect(fs.existsSync(targetDir)).toBe(false);
}); });
it("resumes extraction from persisted progress file", 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 zipA = new AdmZip();
zipA.addFile("a.txt", Buffer.from("a"));
zipA.writeZip(path.join(packageDir, "a.zip"));
const zipB = new AdmZip();
zipB.addFile("b.txt", Buffer.from("b"));
zipB.writeZip(path.join(packageDir, "b.zip"));
fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({ completedArchives: ["a.zip"] }), "utf8");
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result.extracted).toBe(2);
expect(result.failed).toBe(0);
expect(fs.existsSync(path.join(targetDir, "b.txt"))).toBe(true);
expect(fs.existsSync(path.join(packageDir, ".rd_extract_progress.json"))).toBe(false);
});
it("aborts extraction immediately when abort signal is set", 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 zip = new AdmZip();
zip.addFile("file.txt", Buffer.from("x"));
zip.writeZip(path.join(packageDir, "file.zip"));
const controller = new AbortController();
controller.abort();
await expect(extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
signal: controller.signal
})).rejects.toThrow("aborted:extract");
});
}); });