Release v1.4.7 with ENOENT extraction recovery and lag optimizations
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-02-27 19:12:40 +01:00
parent dbf1c34282
commit 3b9c4a4e88
9 changed files with 358 additions and 90 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.6", "version": "1.4.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.6", "version": "1.4.7",
"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.6", "version": "1.4.7",
"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

@ -113,6 +113,10 @@ function isFinishedStatus(status: DownloadStatus): boolean {
return status === "completed" || status === "failed" || status === "cancelled"; return status === "completed" || status === "failed" || status === "cancelled";
} }
function isExtractedLabel(statusText: string): boolean {
return /^entpackt\b/i.test(String(statusText || "").trim());
}
function providerLabel(provider: DownloadItem["provider"]): string { function providerLabel(provider: DownloadItem["provider"]): string {
if (provider === "realdebrid") { if (provider === "realdebrid") {
return "Real-Debrid"; return "Real-Debrid";
@ -169,6 +173,8 @@ export class DownloadManager extends EventEmitter {
private speedBytesLastWindow = 0; private speedBytesLastWindow = 0;
private lastPersistAt = 0;
private cleanupQueue: Promise<void> = Promise.resolve(); private cleanupQueue: Promise<void> = Promise.resolve();
private packagePostProcessQueue: Promise<void> = Promise.resolve(); private packagePostProcessQueue: Promise<void> = Promise.resolve();
@ -1203,13 +1209,28 @@ export class DownloadManager extends EventEmitter {
if (this.persistTimer) { if (this.persistTimer) {
return; return;
} }
const itemCount = Object.keys(this.session.items).length;
const minGapMs = this.session.running
? itemCount >= 1500
? 1300
: itemCount >= 700
? 950
: itemCount >= 250
? 700
: 450
: 250;
const sinceLastPersist = nowMs() - this.lastPersistAt;
const delay = Math.max(120, minGapMs - sinceLastPersist);
this.persistTimer = setTimeout(() => { this.persistTimer = setTimeout(() => {
this.persistTimer = null; this.persistTimer = null;
this.persistNow(); this.persistNow();
}, 250); }, delay);
} }
private persistNow(): void { private persistNow(): void {
this.lastPersistAt = nowMs();
saveSession(this.storagePaths, this.session); saveSession(this.storagePaths, this.session);
} }
@ -1397,7 +1418,7 @@ export class DownloadManager extends EventEmitter {
if (this.settings.autoExtract && failed === 0 && success > 0) { if (this.settings.autoExtract && failed === 0 && success > 0) {
const needsPostProcess = pkg.status !== "completed" const needsPostProcess = pkg.status !== "completed"
|| items.some((item) => item.status === "completed" && item.fullStatus !== "Entpackt"); || items.some((item) => item.status === "completed" && !isExtractedLabel(item.fullStatus));
if (needsPostProcess) { if (needsPostProcess) {
void this.runPackagePostProcessing(packageId); void this.runPackagePostProcessing(packageId);
} else if (pkg.status !== "completed") { } else if (pkg.status !== "completed") {
@ -1475,7 +1496,9 @@ export class DownloadManager extends EventEmitter {
break; break;
} }
await sleep(120); const maxParallel = Math.max(1, this.settings.maxParallel);
const schedulerSleepMs = this.activeTasks.size >= maxParallel ? 170 : 120;
await sleep(schedulerSleepMs);
} }
} finally { } finally {
this.scheduleRunning = false; this.scheduleRunning = false;
@ -2381,7 +2404,7 @@ export class DownloadManager extends EventEmitter {
} }
const completedItems = items.filter((item) => item.status === "completed"); const completedItems = items.filter((item) => item.status === "completed");
const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => item.fullStatus === "Entpackt"); const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus));
if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) {
pkg.status = "extracting"; pkg.status = "extracting";
@ -2436,11 +2459,22 @@ export class DownloadManager extends EventEmitter {
} }
pkg.status = "failed"; pkg.status = "failed";
} else { } else {
if (result.extracted > 0) { const hasExtractedOutput = this.directoryHasAnyFiles(pkg.extractDir);
for (const entry of completedItems) { const sourceExists = fs.existsSync(pkg.outputDir);
entry.fullStatus = "Entpackt"; let finalStatusText = "";
entry.updatedAt = nowMs();
} if (result.extracted > 0 || hasExtractedOutput) {
finalStatusText = "Entpackt";
} else if (!sourceExists) {
finalStatusText = "Entpackt (Quelle fehlt)";
logger.warn(`Post-Processing ohne Quellordner: pkg=${pkg.name}, outputDir fehlt`);
} else {
finalStatusText = "Entpackt (keine Archive)";
}
for (const entry of completedItems) {
entry.fullStatus = finalStatusText;
entry.updatedAt = nowMs();
} }
pkg.status = "completed"; pkg.status = "completed";
} }

View File

@ -42,9 +42,18 @@ type ExtractResumeState = {
}; };
function findArchiveCandidates(packageDir: string): string[] { function findArchiveCandidates(packageDir: string): string[] {
const files = fs.readdirSync(packageDir, { withFileTypes: true }) if (!packageDir || !fs.existsSync(packageDir)) {
.filter((entry) => entry.isFile()) return [];
.map((entry) => path.join(packageDir, entry.name)); }
let files: string[] = [];
try {
files = fs.readdirSync(packageDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => path.join(packageDir, entry.name));
} catch {
return [];
}
const preferred = files.filter((file) => /\.part0*1\.rar$/i.test(file)); const preferred = files.filter((file) => /\.part0*1\.rar$/i.test(file));
const zip = files.filter((file) => /\.zip$/i.test(file)); const zip = files.filter((file) => /\.zip$/i.test(file));
@ -408,56 +417,6 @@ function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
function captureDirFingerprint(rootDir: string): Map<string, string> {
const fingerprint = new Map<string, string>();
if (!fs.existsSync(rootDir)) {
return fingerprint;
}
const stack = [rootDir];
while (stack.length > 0) {
const current = stack.pop() as string;
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
continue;
}
if (!entry.isFile()) {
continue;
}
try {
const stat = fs.statSync(full);
const relative = path.relative(rootDir, full).toLowerCase();
fingerprint.set(relative, `${stat.size}:${stat.mtimeMs}`);
} catch {
// ignore
}
}
}
return fingerprint;
}
function hasDirChanges(before: Map<string, string>, after: Map<string, string>): boolean {
if (after.size > before.size) {
return true;
}
for (const [relative, meta] of after.entries()) {
if (before.get(relative) !== meta) {
return true;
}
}
return false;
}
export function collectArchiveCleanupTargets(sourceArchivePath: string): string[] { export function collectArchiveCleanupTargets(sourceArchivePath: string): string[] {
const targets = new Set<string>([sourceArchivePath]); const targets = new Set<string>([sourceArchivePath]);
const dir = path.dirname(sourceArchivePath); const dir = path.dirname(sourceArchivePath);
@ -619,7 +578,6 @@ 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 resumeCompleted = readExtractResumeState(options.packageDir); const resumeCompleted = readExtractResumeState(options.packageDir);
const resumeCompletedAtStart = resumeCompleted.size; const resumeCompletedAtStart = resumeCompleted.size;
const candidateNames = new Set(candidates.map((archivePath) => path.basename(archivePath))); const candidateNames = new Set(candidates.map((archivePath) => path.basename(archivePath)));
@ -751,10 +709,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} }
if (extracted > 0) { if (extracted > 0) {
const afterFingerprint = captureDirFingerprint(options.targetDir); const hasOutputAfter = hasAnyFilesRecursive(options.targetDir);
const changedOutput = hasDirChanges(beforeFingerprint, afterFingerprint);
const hadResumeProgress = resumeCompletedAtStart > 0; const hadResumeProgress = resumeCompletedAtStart > 0;
if (!changedOutput && conflictMode !== "skip" && !hadResumeProgress) { if (!hasOutputAfter && conflictMode !== "skip" && !hadResumeProgress) {
lastError = "Keine entpackten Dateien erkannt"; lastError = "Keine entpackten Dateien erkannt";
failed += extracted; failed += extracted;
extracted = 0; extracted = 0;

View File

@ -3,6 +3,14 @@ import path from "node:path";
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log"); let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
let fallbackLogFilePath: string | null = null; let fallbackLogFilePath: string | null = null;
const LOG_FLUSH_INTERVAL_MS = 120;
const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
let pendingLines: string[] = [];
let pendingChars = 0;
let flushTimer: NodeJS.Timeout | null = null;
let flushInFlight = false;
let exitHookAttached = false;
export function configureLogger(baseDir: string): void { export function configureLogger(baseDir: string): void {
logFilePath = path.join(baseDir, "rd_downloader.log"); logFilePath = path.join(baseDir, "rd_downloader.log");
@ -20,31 +28,126 @@ function appendLine(filePath: string, line: string): { ok: boolean; errorText: s
} }
} }
function write(level: "INFO" | "WARN" | "ERROR", message: string): void { async function appendChunk(filePath: string, chunk: string): Promise<{ ok: boolean; errorText: string }> {
const line = `${new Date().toISOString()} [${level}] ${message}\n`; try {
const primary = appendLine(logFilePath, line); await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.appendFile(filePath, chunk, "utf8");
return { ok: true, errorText: "" };
} catch (error) {
return { ok: false, errorText: String(error) };
}
}
function writeStderr(text: string): void {
try {
process.stderr.write(text);
} catch {
// ignore stderr failures
}
}
function flushSyncPending(): void {
if (pendingLines.length === 0) {
return;
}
const chunk = pendingLines.join("");
pendingLines = [];
pendingChars = 0;
const primary = appendLine(logFilePath, chunk);
if (fallbackLogFilePath) { if (fallbackLogFilePath) {
const fallback = appendLine(fallbackLogFilePath, line); const fallback = appendLine(fallbackLogFilePath, chunk);
if (!primary.ok && !fallback.ok) { if (!primary.ok && !fallback.ok) {
try { writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
process.stderr.write(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
} catch {
// ignore stderr failures
}
} }
return; return;
} }
if (!primary.ok) { if (!primary.ok) {
try { writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
process.stderr.write(`LOGGER write failed: ${primary.errorText}\n`); }
} catch { }
// ignore stderr failures
function scheduleFlush(immediate = false): void {
if (flushInFlight) {
return;
}
if (immediate) {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
void flushAsync();
return;
}
if (flushTimer) {
return;
}
flushTimer = setTimeout(() => {
flushTimer = null;
void flushAsync();
}, LOG_FLUSH_INTERVAL_MS);
}
async function flushAsync(): Promise<void> {
if (flushInFlight || pendingLines.length === 0) {
return;
}
flushInFlight = true;
const chunk = pendingLines.join("");
pendingLines = [];
pendingChars = 0;
try {
const primary = await appendChunk(logFilePath, chunk);
if (fallbackLogFilePath) {
const fallback = await appendChunk(fallbackLogFilePath, chunk);
if (!primary.ok && !fallback.ok) {
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
}
} else if (!primary.ok) {
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
}
} finally {
flushInFlight = false;
if (pendingLines.length > 0) {
scheduleFlush(true);
} }
} }
} }
function ensureExitHook(): void {
if (exitHookAttached) {
return;
}
exitHookAttached = true;
process.once("beforeExit", flushSyncPending);
process.once("exit", flushSyncPending);
}
function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
ensureExitHook();
const line = `${new Date().toISOString()} [${level}] ${message}\n`;
pendingLines.push(line);
pendingChars += line.length;
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
const removed = pendingLines.shift();
if (!removed) {
break;
}
pendingChars = Math.max(0, pendingChars - removed.length);
}
if (level === "ERROR") {
scheduleFlush(true);
return;
}
scheduleFlush();
}
export const logger = { export const logger = {
info: (msg: string): void => write("INFO", msg), info: (msg: string): void => write("INFO", msg),
warn: (msg: string): void => write("WARN", msg), warn: (msg: string): void => write("WARN", msg),

View File

@ -205,7 +205,7 @@ export function loadSession(paths: StoragePaths): SessionState {
export function saveSession(paths: StoragePaths, session: SessionState): void { export function saveSession(paths: StoragePaths, session: SessionState): void {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, null, 2); const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
const tempPath = `${paths.sessionFile}.tmp`; const tempPath = `${paths.sessionFile}.tmp`;
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
fs.renameSync(tempPath, paths.sessionFile); fs.renameSync(tempPath, paths.sessionFile);

View File

@ -201,21 +201,39 @@ export function App(): ReactElement {
}; };
}, []); }, []);
const packages = useMemo(() => snapshot.session.packageOrder const downloadsTabActive = tab === "downloads";
.map((id: string) => snapshot.session.packages[id])
.filter(Boolean), [snapshot.session.packageOrder, snapshot.session.packages]);
const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]); const packages = useMemo(() => {
if (!downloadsTabActive) {
return [] as PackageEntry[];
}
return snapshot.session.packageOrder
.map((id: string) => snapshot.session.packages[id])
.filter(Boolean);
}, [downloadsTabActive, snapshot.session.packageOrder, snapshot.session.packages]);
const packageOrderKey = useMemo(() => {
if (!downloadsTabActive) {
return "";
}
return snapshot.session.packageOrder.join("|");
}, [downloadsTabActive, snapshot.session.packageOrder]);
const packagePosition = useMemo(() => { const packagePosition = useMemo(() => {
if (!downloadsTabActive) {
return new Map<string, number>();
}
const map = new Map<string, number>(); const map = new Map<string, number>();
snapshot.session.packageOrder.forEach((id, index) => { snapshot.session.packageOrder.forEach((id, index) => {
map.set(id, index); map.set(id, index);
}); });
return map; return map;
}, [packageOrderKey]); }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder]);
const itemsByPackage = useMemo(() => { const itemsByPackage = useMemo(() => {
if (!downloadsTabActive) {
return new Map<string, DownloadItem[]>();
}
const map = new Map<string, DownloadItem[]>(); const map = new Map<string, DownloadItem[]>();
for (const packageId of snapshot.session.packageOrder) { for (const packageId of snapshot.session.packageOrder) {
const pkg = snapshot.session.packages[packageId]; const pkg = snapshot.session.packages[packageId];
@ -228,9 +246,12 @@ export function App(): ReactElement {
map.set(packageId, items); map.set(packageId, items);
} }
return map; return map;
}, [packageOrderKey, snapshot.session.items, snapshot.session.packages]); }, [downloadsTabActive, packageOrderKey, snapshot.session.items, snapshot.session.packages, snapshot.session.packageOrder]);
useEffect(() => { useEffect(() => {
if (!downloadsTabActive) {
return;
}
setCollapsedPackages((prev) => { setCollapsedPackages((prev) => {
const next: Record<string, boolean> = {}; const next: Record<string, boolean> = {};
const defaultCollapsed = snapshot.session.packageOrder.length >= 24; const defaultCollapsed = snapshot.session.packageOrder.length >= 24;
@ -239,7 +260,7 @@ export function App(): ReactElement {
} }
return next; return next;
}); });
}, [packageOrderKey, snapshot.session.packageOrder.length]); }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder.length]);
const deferredDownloadSearch = useDeferredValue(downloadSearch); const deferredDownloadSearch = useDeferredValue(downloadSearch);

View File

@ -2941,4 +2941,136 @@ describe("download manager", () => {
expect(snapshot.session.packages[packageId]?.status).toBe("completed"); expect(snapshot.session.packages[packageId]?.status).toBe("completed");
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt"); expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt");
}); });
it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const outputDir = path.join(root, "downloads", "missing-source-ok");
const extractDir = path.join(root, "extract", "missing-source-ok");
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "episode.mkv"), "ok", "utf8");
const session = emptySession();
const packageId = "missing-source-ok-pkg";
const itemId = "missing-source-ok-item";
const createdAt = Date.now() - 20_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "missing-source-ok",
outputDir,
extractDir,
status: "downloading",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/missing-source-ok",
provider: "megadebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 123,
totalBytes: 123,
progressPercent: 100,
fileName: "missing-source-ok.part01.rar",
targetPath: path.join(outputDir, "missing-source-ok.part01.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig (123 B)",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
enableIntegrityCheck: false,
cleanupMode: "none"
},
session,
createStoragePaths(path.join(root, "state"))
);
await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000);
const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt");
});
it("marks missing source package dir as extracted instead of failed", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const outputDir = path.join(root, "downloads", "missing-source-empty");
const extractDir = path.join(root, "extract", "missing-source-empty");
const session = emptySession();
const packageId = "missing-source-empty-pkg";
const itemId = "missing-source-empty-item";
const createdAt = Date.now() - 20_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "missing-source-empty",
outputDir,
extractDir,
status: "downloading",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/missing-source-empty",
provider: "megadebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 123,
totalBytes: 123,
progressPercent: 100,
fileName: "missing-source-empty.part01.rar",
targetPath: path.join(outputDir, "missing-source-empty.part01.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig (123 B)",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
enableIntegrityCheck: false,
cleanupMode: "none"
},
session,
createStoragePaths(path.join(root, "state"))
);
await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000);
const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt (Quelle fehlt)");
});
}); });

View File

@ -331,4 +331,25 @@ describe("extractor", () => {
signal: controller.signal signal: controller.signal
})).rejects.toThrow("aborted:extract"); })).rejects.toThrow("aborted:extract");
}); });
it("handles missing package source directory without throwing", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg-missing");
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(path.join(targetDir, "video.mkv"), "ok", "utf8");
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result.failed).toBe(0);
expect(result.extracted).toBe(0);
});
}); });