diff --git a/package.json b/package.json index af3a9df..8a2154e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.3.9", + "version": "1.3.10", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 6f00fcf..2fa849d 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -5,7 +5,7 @@ import { importDlcContainers } from "./container"; import { APP_VERSION, defaultSettings } from "./constants"; import { DownloadManager } from "./download-manager"; import { parseCollectorInput } from "./link-parser"; -import { configureLogger, logger } from "./logger"; +import { configureLogger, getLogFilePath, logger } from "./logger"; import { MegaWebFallback } from "./mega-web-fallback"; import { createStoragePaths, loadSession, loadSettings, normalizeSettings, saveSettings } from "./storage"; import { checkGitHubUpdate, installLatestUpdate } from "./update"; @@ -34,6 +34,7 @@ export class AppController { this.onState?.(snapshot); }); logger.info(`App gestartet v${APP_VERSION}`); + logger.info(`Log-Datei: ${getLogFilePath()}`); if (this.settings.autoResumeOnStart) { const snapshot = this.manager.getSnapshot(); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 661e191..81a6a03 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -174,6 +174,8 @@ export class DownloadManager extends EventEmitter { private runCompletedPackages = new Set(); + private lastSchedulerHeartbeatAt = 0; + public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { super(); this.settings = settings; @@ -592,6 +594,16 @@ export class DownloadManager extends EventEmitter { return; } + const extractDirUsage = new Map(); + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.extractDir) { + continue; + } + const key = pathKey(pkg.extractDir); + extractDirUsage.set(key, (extractDirUsage.get(key) || 0) + 1); + } + const cleanupTargetsByPackage = new Map>(); for (const packageId of this.session.packageOrder) { const pkg = this.session.packages[packageId]; @@ -607,7 +619,8 @@ export class DownloadManager extends EventEmitter { } const hasExtractMarker = items.some((item) => /entpack/i.test(item.fullStatus)); - const hasExtractedOutput = this.directoryHasAnyFiles(pkg.extractDir); + const extractDirIsUnique = (extractDirUsage.get(pathKey(pkg.extractDir)) || 0) === 1; + const hasExtractedOutput = extractDirIsUnique && this.directoryHasAnyFiles(pkg.extractDir); if (!hasExtractMarker && !hasExtractedOutput) { continue; } @@ -641,6 +654,8 @@ export class DownloadManager extends EventEmitter { continue; } + logger.info(`Nachtraegliches Cleanup geprueft: pkg=${pkg.name}, targets=${targets.size}, marker=${pkg.itemIds.some((id) => /entpack/i.test(this.session.items[id]?.fullStatus || ""))}`); + let removed = 0; for (const targetPath of targets) { if (!fs.existsSync(targetPath)) { @@ -656,6 +671,8 @@ export class DownloadManager extends EventEmitter { if (removed > 0) { logger.info(`Nachtraegliches Archive-Cleanup fuer ${pkg.name}: ${removed} Datei(en) geloescht`); + } else { + logger.info(`Nachtraegliches Archive-Cleanup fuer ${pkg.name}: keine Dateien entfernt`); } } }) @@ -793,11 +810,13 @@ export class DownloadManager extends EventEmitter { } public prepareForShutdown(): void { + logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`); this.session.running = false; this.session.paused = false; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; + let requeuedItems = 0; for (const active of this.activeTasks.values()) { const item = this.session.items[active.itemId]; if (item && !isFinishedStatus(item.status)) { @@ -806,6 +825,7 @@ export class DownloadManager extends EventEmitter { const pkg = this.session.packages[item.packageId]; item.fullStatus = pkg && !pkg.enabled ? "Paket gestoppt" : "Wartet"; item.updatedAt = nowMs(); + requeuedItems += 1; } active.abortReason = "shutdown"; active.abortController.abort("shutdown"); @@ -832,6 +852,7 @@ export class DownloadManager extends EventEmitter { this.session.summaryText = ""; this.persistNow(); this.emitState(true); + logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`); } public togglePause(): boolean { @@ -883,6 +904,39 @@ export class DownloadManager extends EventEmitter { || pkg.status === "reconnect_wait") { pkg.status = "queued"; } + + const items = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter(Boolean) as DownloadItem[]; + if (items.length === 0) { + continue; + } + + const hasPending = items.some((item) => ( + item.status === "queued" + || item.status === "reconnect_wait" + || item.status === "validating" + || item.status === "downloading" + || item.status === "paused" + || item.status === "extracting" + || item.status === "integrity_check" + )); + if (hasPending) { + pkg.status = pkg.enabled ? "queued" : "paused"; + continue; + } + + const success = items.filter((item) => item.status === "completed").length; + const failed = items.filter((item) => item.status === "failed").length; + const cancelled = items.filter((item) => item.status === "cancelled").length; + + if (failed > 0) { + pkg.status = "failed"; + } else if (cancelled > 0 && success === 0) { + pkg.status = "cancelled"; + } else if (success > 0) { + pkg.status = "completed"; + } } this.persistSoon(); } @@ -1101,8 +1155,15 @@ export class DownloadManager extends EventEmitter { return; } this.scheduleRunning = true; + logger.info("Scheduler gestartet"); try { while (this.session.running) { + const now = nowMs(); + if (now - this.lastSchedulerHeartbeatAt >= 60000) { + this.lastSchedulerHeartbeatAt = now; + logger.info(`Scheduler Heartbeat: active=${this.activeTasks.size}, queued=${this.countQueuedItems()}, reconnect=${this.reconnectActive()}, paused=${this.session.paused}, postProcess=${this.packagePostProcessTasks.size}`); + } + if (this.session.paused) { await sleep(120); continue; @@ -1131,6 +1192,7 @@ export class DownloadManager extends EventEmitter { } } finally { this.scheduleRunning = false; + logger.info("Scheduler beendet"); } } @@ -1211,6 +1273,26 @@ export class DownloadManager extends EventEmitter { return false; } + private countQueuedItems(): number { + let count = 0; + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + if (item.status === "queued" || item.status === "reconnect_wait") { + count += 1; + } + } + } + return count; + } + private startItem(packageId: string, itemId: string): void { const item = this.session.items[itemId]; const pkg = this.session.packages[packageId]; @@ -1758,9 +1840,11 @@ export class DownloadManager extends EventEmitter { const success = items.filter((item) => item.status === "completed").length; const failed = items.filter((item) => item.status === "failed").length; const cancelled = items.filter((item) => item.status === "cancelled").length; + logger.info(`Post-Processing Start: pkg=${pkg.name}, success=${success}, failed=${failed}, cancelled=${cancelled}, autoExtract=${this.settings.autoExtract}`); if (success + failed + cancelled < items.length) { pkg.status = "downloading"; + logger.info(`Post-Processing verschoben: pkg=${pkg.name}, noch offene items`); return; } @@ -1779,6 +1863,7 @@ export class DownloadManager extends EventEmitter { removeLinks: this.settings.removeLinkFilesAfterExtract, removeSamples: this.settings.removeSamplesAfterExtract }); + logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); if (result.failed > 0) { const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); for (const entry of completedItems) { @@ -1797,6 +1882,7 @@ export class DownloadManager extends EventEmitter { } } catch (error) { const reason = compactErrorText(error); + logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); for (const entry of completedItems) { entry.fullStatus = `Entpack-Fehler: ${reason}`; entry.updatedAt = nowMs(); @@ -1818,6 +1904,7 @@ export class DownloadManager extends EventEmitter { } } pkg.updatedAt = nowMs(); + logger.info(`Post-Processing Ende: pkg=${pkg.name}, status=${pkg.status}`); } private applyCompletedCleanupPolicy(packageId: string, itemId: string): void { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 447ccf9..4a1cc16 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -249,6 +249,56 @@ function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function captureDirFingerprint(rootDir: string): Map { + const fingerprint = new Map(); + 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, after: Map): 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[] { const targets = new Set([sourceArchivePath]); const dir = path.dirname(sourceArchivePath); @@ -302,9 +352,9 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string): string[ return Array.from(targets); } -function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): void { +function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): number { if (cleanupMode === "none") { - return; + return 0; } const targets = new Set(); @@ -314,26 +364,37 @@ function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): void } } + let removed = 0; for (const filePath of targets) { try { + if (!fs.existsSync(filePath)) { + continue; + } fs.rmSync(filePath, { force: true }); + removed += 1; } catch { // ignore } } + return removed; } export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> { const candidates = findArchiveCandidates(options.packageDir); + logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`); if (candidates.length === 0) { + logger.info(`Entpacken uebersprungen (keine Archive gefunden): ${options.packageDir}`); return { extracted: 0, failed: 0, lastError: "" }; } + const conflictMode = effectiveConflictMode(options.conflictMode); + const beforeFingerprint = captureDirFingerprint(options.targetDir); let extracted = 0; let failed = 0; let lastError = ""; const extractedArchives: string[] = []; for (const archivePath of candidates) { + logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}`); try { const ext = path.extname(archivePath).toLowerCase(); if (ext === ".zip") { @@ -347,6 +408,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } extracted += 1; extractedArchives.push(archivePath); + logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); } catch (error) { failed += 1; const errorText = String(error); @@ -363,12 +425,26 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } if (extracted > 0) { - cleanupArchives(extractedArchives, options.cleanupMode); - if (options.removeLinks) { - removeDownloadLinkArtifacts(options.targetDir); - } - if (options.removeSamples) { - removeSampleArtifacts(options.targetDir); + const afterFingerprint = captureDirFingerprint(options.targetDir); + const changedOutput = hasDirChanges(beforeFingerprint, afterFingerprint); + if (!changedOutput && conflictMode !== "skip") { + lastError = "Keine entpackten Dateien erkannt"; + failed += extracted; + extracted = 0; + logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgefuehrt.`); + } else { + const removedArchives = cleanupArchives(extractedArchives, options.cleanupMode); + if (options.cleanupMode !== "none") { + logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`); + } + if (options.removeLinks) { + const removedLinks = removeDownloadLinkArtifacts(options.targetDir); + logger.info(`Link-Artefakt-Cleanup: ${removedLinks} Datei(en) entfernt`); + } + if (options.removeSamples) { + const removedSamples = removeSampleArtifacts(options.targetDir); + logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`); + } } } else { try { @@ -380,5 +456,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } } + logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`); + return { extracted, failed, lastError }; } diff --git a/src/main/logger.ts b/src/main/logger.ts index 7b27737..2817251 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -2,18 +2,46 @@ import fs from "node:fs"; import path from "node:path"; let logFilePath = path.resolve(process.cwd(), "rd_downloader.log"); +let fallbackLogFilePath: string | null = null; export function configureLogger(baseDir: string): void { logFilePath = path.join(baseDir, "rd_downloader.log"); + const cwdLogPath = path.resolve(process.cwd(), "rd_downloader.log"); + fallbackLogFilePath = cwdLogPath === logFilePath ? null : cwdLogPath; +} + +function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, line, "utf8"); + return { ok: true, errorText: "" }; + } catch (error) { + return { ok: false, errorText: String(error) }; + } } function write(level: "INFO" | "WARN" | "ERROR", message: string): void { const line = `${new Date().toISOString()} [${level}] ${message}\n`; - try { - fs.mkdirSync(path.dirname(logFilePath), { recursive: true }); - fs.appendFileSync(logFilePath, line, "utf8"); - } catch { - // ignore logging failures + const primary = appendLine(logFilePath, line); + + if (fallbackLogFilePath) { + const fallback = appendLine(fallbackLogFilePath, line); + if (!primary.ok && !fallback.ok) { + try { + process.stderr.write(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); + } catch { + // ignore stderr failures + } + } + return; + } + + if (!primary.ok) { + try { + process.stderr.write(`LOGGER write failed: ${primary.errorText}\n`); + } catch { + // ignore stderr failures + } } } diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 77842be..35c2194 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -835,7 +835,7 @@ describe("download manager", () => { name: "stopped", outputDir: path.join(root, "downloads", "stopped"), extractDir: path.join(root, "extract", "stopped"), - status: "downloading", + status: "completed", itemIds: [itemId], cancelled: false, enabled: true, @@ -1027,6 +1027,116 @@ describe("download manager", () => { expect(fs.existsSync(path.join(extractDir, "episode.mkv"))).toBe(true); }); + it("does not over-clean packages that share one extract directory", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const sharedExtractDir = path.join(root, "extract", "shared"); + fs.mkdirSync(sharedExtractDir, { recursive: true }); + fs.writeFileSync(path.join(sharedExtractDir, "already-extracted.mkv"), "ok", "utf8"); + + const pkg1Dir = path.join(root, "downloads", "pkg1"); + const pkg2Dir = path.join(root, "downloads", "pkg2"); + fs.mkdirSync(pkg1Dir, { recursive: true }); + fs.mkdirSync(pkg2Dir, { recursive: true }); + + const pkg1Part1 = path.join(pkg1Dir, "show.one.part01.rar"); + const pkg1Part2 = path.join(pkg1Dir, "show.one.part02.rar"); + const pkg2Part1 = path.join(pkg2Dir, "show.two.part01.rar"); + const pkg2Part2 = path.join(pkg2Dir, "show.two.part02.rar"); + fs.writeFileSync(pkg1Part1, "a1", "utf8"); + fs.writeFileSync(pkg1Part2, "a2", "utf8"); + fs.writeFileSync(pkg2Part1, "b1", "utf8"); + fs.writeFileSync(pkg2Part2, "b2", "utf8"); + + const session = emptySession(); + const createdAt = Date.now() - 30_000; + + session.packageOrder = ["pkg1", "pkg2"]; + session.packages.pkg1 = { + id: "pkg1", + name: "pkg1", + outputDir: pkg1Dir, + extractDir: sharedExtractDir, + status: "completed", + itemIds: ["pkg1-item"], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.packages.pkg2 = { + id: "pkg2", + name: "pkg2", + outputDir: pkg2Dir, + extractDir: sharedExtractDir, + status: "completed", + itemIds: ["pkg2-item"], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + session.items["pkg1-item"] = { + id: "pkg1-item", + packageId: "pkg1", + url: "https://dummy/pkg1", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 1, + totalBytes: 1, + progressPercent: 100, + fileName: path.basename(pkg1Part1), + targetPath: pkg1Part1, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt", + createdAt, + updatedAt: createdAt + }; + session.items["pkg2-item"] = { + id: "pkg2-item", + packageId: "pkg2", + url: "https://dummy/pkg2", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 1, + totalBytes: 1, + progressPercent: 100, + fileName: path.basename(pkg2Part1), + targetPath: pkg2Part1, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (100 MB)", + createdAt, + updatedAt: createdAt + }; + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => !fs.existsSync(pkg1Part1) && !fs.existsSync(pkg1Part2), 5000); + expect(fs.existsSync(pkg2Part1)).toBe(true); + expect(fs.existsSync(pkg2Part2)).toBe(true); + }); + it("resets run counters and reconnect state on start", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);