From 6d7b3686dc371bf3790f6388450c857d43b30b82 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Tue, 10 Mar 2026 00:43:51 +0100 Subject: [PATCH] Add AVI video-library support and startup recovery fixes --- src/main/download-manager.ts | 94 ++++++++++++--- src/renderer/App.tsx | 4 +- tests/download-manager.test.ts | 208 +++++++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 15 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 192a6b7..92b1ce2 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -3411,7 +3411,7 @@ export class DownloadManager extends EventEmitter { continue; } const extension = path.extname(entry.name).toLowerCase(); - if (extension === ".mkv") { + if (SAMPLE_VIDEO_EXTENSIONS.has(extension)) { continue; } try { @@ -3563,7 +3563,7 @@ export class DownloadManager extends EventEmitter { return; } - const allMkvFiles = await this.collectFilesByExtensions(sourceDir, new Set([".mkv"])); + const allMkvFiles = await this.collectFilesByExtensions(sourceDir, SAMPLE_VIDEO_EXTENSIONS); if (allMkvFiles.length === 0) { logger.info(`MKV-Sammelordner: pkg=${pkg.name}, keine MKV gefunden`); return; @@ -5127,9 +5127,35 @@ export class DownloadManager extends EventEmitter { const touchedPackageIds = new Set(); for (const item of Object.values(this.session.items)) { if (item.status !== "completed") continue; - if (!item.targetPath || !item.totalBytes || item.totalBytes <= 0) continue; + if (isExtractedLabel(item.fullStatus || "")) continue; + const targetPath = String(item.targetPath || "").trim(); + const archiveLike = isArchiveLikePath(targetPath || item.fileName || ""); + if (archiveLike) { + let statSize: number | null = null; + if (targetPath) { + try { + statSize = fs.statSync(targetPath).size; + } catch { + statSize = null; + } + } + const zeroByteArchive = statSize != null + ? statSize <= 0 + : (item.downloadedBytes <= 0 && item.progressPercent >= 100) || /\b0\s*B\b/i.test(item.fullStatus || ""); + if (zeroByteArchive) { + logger.warn(`revalidateCompleted: ${item.fileName} ist 0B/leer, setze auf queued`); + this.queueItemForRetry(item, { + hardReset: true, + reason: "Wartet (Auto-Retry: 0B-Datei)" + }); + fixed += 1; + touchedPackageIds.add(item.packageId); + continue; + } + } + if (!targetPath || !item.totalBytes || item.totalBytes <= 0) continue; try { - const stat = fs.statSync(item.targetPath); + const stat = fs.statSync(targetPath); const expectedMinSize = item.totalBytes - ALLOCATION_UNIT_SIZE; const persistedShortfall = item.downloadedBytes < expectedMinSize && stat.size >= expectedMinSize; if (stat.size < expectedMinSize) { @@ -5855,6 +5881,28 @@ export class DownloadManager extends EventEmitter { return task; } + private shouldRecoverDeferredPostProcessingOnStartup(pkg: PackageEntry, items: DownloadItem[]): boolean { + if (!this.settings.autoExtract) { + return false; + } + if (this.packagePostProcessTasks.has(pkg.id) || this.hasDeferredPostProcessPending(pkg.id)) { + return false; + } + const hasExtractedCompletedItem = items.some((item) => + item.status === "completed" && isExtractedLabel(item.fullStatus || "") + ); + if (!hasExtractedCompletedItem) { + return false; + } + return this.settings.autoRename4sf4sj + || this.settings.collectMkvToLibrary + || this.settings.removeLinkFilesAfterExtract + || this.settings.removeSamplesAfterExtract + || this.settings.cleanupMode !== "none" + || this.settings.completedCleanupPolicy === "package_done" + || this.settings.completedCleanupPolicy === "immediate"; + } + private recoverPostProcessingOnStartup(): void { const packageIds = [...this.session.packageOrder]; if (packageIds.length === 0) { @@ -5927,6 +5975,12 @@ export class DownloadManager extends EventEmitter { pkg.updatedAt = nowMs(); changed = true; } + if (!needsExtraction && this.shouldRecoverDeferredPostProcessingOnStartup(pkg, items)) { + logger.info(`Deferred Post-Processing via Startup ausgelöst: pkg=${pkg.name}`); + void this.runDeferredPostExtraction(packageId, pkg, success, failed, true, 0).catch((err) => + logger.warn(`runDeferredPostExtraction Fehler (recoverPostProcessing): ${compactErrorText(err)}`) + ); + } continue; } @@ -5940,6 +5994,12 @@ export class DownloadManager extends EventEmitter { pkg.updatedAt = nowMs(); changed = true; } + if (this.shouldRecoverDeferredPostProcessingOnStartup(pkg, items)) { + logger.info(`Deferred Post-Processing via Startup ausgelöst: pkg=${pkg.name}`); + void this.runDeferredPostExtraction(packageId, pkg, success, failed, true, 0).catch((err) => + logger.warn(`runDeferredPostExtraction Fehler (recoverPostProcessing): ${compactErrorText(err)}`) + ); + } } if (changed) { @@ -10286,6 +10346,10 @@ export class DownloadManager extends EventEmitter { const deferredVersion = this.getPackagePostProcessVersion(packageId); const shouldAbort = (): boolean => !this.isDeferredPostProcessStillCurrent(packageId, pkg, deferredVersion, deferredController.signal); const throwIfAborted = (): void => this.throwIfDeferredPostProcessAborted(packageId, pkg, deferredVersion, deferredController.signal); + const hasBlockingExtractError = pkg.itemIds.some((itemId) => { + const item = this.session.items[itemId]; + return Boolean(item && item.status === "completed" && isExtractErrorLabel(item.fullStatus || "")); + }); try { throwIfAborted(); @@ -10344,20 +10408,24 @@ export class DownloadManager extends EventEmitter { pkg.postProcessLabel = "Aufräumen..."; this.emitState(); throwIfAborted(); - const sourceAndTargetEqual = path.resolve(pkg.outputDir).toLowerCase() === path.resolve(pkg.extractDir).toLowerCase(); - if (!sourceAndTargetEqual) { - const candidates = await findArchiveCandidates(pkg.outputDir); - if (candidates.length > 0) { - const removed = await cleanupArchives(candidates, this.settings.cleanupMode, { shouldAbort }); - if (removed > 0) { - logger.info(`Deferred Archive-Cleanup: pkg=${pkg.name}, entfernt=${removed}`); + if (hasBlockingExtractError) { + logger.info(`Deferred Archive-Cleanup uebersprungen: pkg=${pkg.name}, reason=extract_error`); + } else { + const sourceAndTargetEqual = path.resolve(pkg.outputDir).toLowerCase() === path.resolve(pkg.extractDir).toLowerCase(); + if (!sourceAndTargetEqual) { + const candidates = await findArchiveCandidates(pkg.outputDir); + if (candidates.length > 0) { + const removed = await cleanupArchives(candidates, this.settings.cleanupMode, { shouldAbort }); + if (removed > 0) { + logger.info(`Deferred Archive-Cleanup: pkg=${pkg.name}, entfernt=${removed}`); + } } } } } // ── Hybrid archive cleanup (wenn bereits als extracted markiert) ── - if (this.settings.autoExtract && alreadyMarkedExtracted && failed === 0 && success > 0 && this.settings.cleanupMode !== "none") { + if (this.settings.autoExtract && alreadyMarkedExtracted && failed === 0 && success > 0 && this.settings.cleanupMode !== "none" && !hasBlockingExtractError) { throwIfAborted(); const removedArchives = await this.cleanupRemainingArchiveArtifacts(pkg.outputDir, shouldAbort); if (removedArchives > 0) { @@ -10404,7 +10472,7 @@ export class DownloadManager extends EventEmitter { // ── MKV collection ── if (success > 0 && (pkg.status === "completed" || pkg.status === "failed")) { throwIfAborted(); - pkg.postProcessLabel = "Verschiebe MKVs..."; + pkg.postProcessLabel = "Verschiebe Videos..."; this.emitState(); await this.collectMkvFilesToLibrary(packageId, pkg, shouldAbort); } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 49ac68c..8002662 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5090,8 +5090,8 @@ export function App(): ReactElement { - - + +
setText("mkvLibraryDir", e.target.value)} disabled={!settingsDraft.collectMkvToLibrary} /> diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 834cb87..ba7266d 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -7246,6 +7246,179 @@ describe("download manager", () => { expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt (Quelle fehlt)"); }); + it("resumes deferred startup cleanup for already extracted packages and removes them when package_done is active", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "startup-deferred-cleanup"; + const { + session, + packageId, + itemId, + outputDir, + extractDir + } = createCompletedArchiveSessionFromArchive(root, packageName, [ + { name: "Season 1/Episode01.mkv", data: Buffer.from("video") }, + { name: "Season 1/episode.links.txt", data: Buffer.from("https://example.com/file") }, + { name: "Season 1/sample/sample.mkv", data: Buffer.from("sample-video") }, + { name: "Season 1/sample/readme.txt", data: Buffer.from("sample-text") } + ]); + + session.packages[packageId].status = "completed"; + session.items[itemId].fullStatus = "Entpackt - Done (<1s)"; + fs.mkdirSync(path.join(extractDir, "Season 1", "sample"), { recursive: true }); + fs.writeFileSync(path.join(extractDir, "Season 1", "Episode01.mkv"), "video", "utf8"); + fs.writeFileSync(path.join(extractDir, "Season 1", "episode.links.txt"), "https://example.com/file", "utf8"); + fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "sample.mkv"), "sample-video", "utf8"); + fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "readme.txt"), "sample-text", "utf8"); + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + removeLinkFilesAfterExtract: true, + removeSamplesAfterExtract: true, + enableIntegrityCheck: false, + cleanupMode: "delete", + completedCleanupPolicy: "package_done" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv"); + await waitFor(() => fs.existsSync(flattenedPath), 12000); + await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000); + + expect(fs.existsSync(flattenedPath)).toBe(true); + expect(fs.existsSync(extractDir)).toBe(false); + expect(fs.existsSync(outputDir)).toBe(false); + expect(manager.getSnapshot().session.items[itemId]).toBeUndefined(); + }, 20000); + + it("resumes deferred startup auto-rename for already extracted packages", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF"; + const sourceFileName = "4sf-asbest.web.7p-s02e01.mkv"; + const expectedFileName = "Asbest.S02E01.GERMAN.720p.WEB.AVC-4SF.mkv"; + const { + session, + packageId, + itemId, + extractDir, + originalExtractedPath + } = createCompletedArchiveSession(root, packageName, sourceFileName); + + session.packages[packageId].status = "completed"; + session.items[itemId].fullStatus = "Entpackt - Done (<1s)"; + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(originalExtractedPath, "video", "utf8"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const expectedPath = path.join(extractDir, expectedFileName); + await waitFor(() => fs.existsSync(expectedPath), 12000); + + expect(fs.existsSync(expectedPath)).toBe(true); + expect(fs.existsSync(originalExtractedPath)).toBe(false); + expect(manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); + }, 20000); + + it("does not requeue already extracted items on startup when source archives were intentionally removed", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "startup-extracted-without-source"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "Episode01.mkv"), "video", "utf8"); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const itemId = `${packageName}-item`; + const createdAt = Date.now() - 20_000; + const targetPath = path.join(outputDir, "episode.zip"); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: `https://dummy/${packageName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 12_345, + totalBytes: 12_345, + progressPercent: 100, + fileName: "episode.zip", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt - Done (<1s)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: false, + enableIntegrityCheck: false, + cleanupMode: "delete", + completedCleanupPolicy: "never" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await new Promise((resolve) => setTimeout(resolve, 400)); + + expect(manager.getSnapshot().session.items[itemId]?.status).toBe("completed"); + expect(manager.getSnapshot().session.items[itemId]?.fullStatus).toBe("Entpackt - Done (<1s)"); + expect(manager.getSnapshot().session.packages[packageId]?.status).toBe("completed"); + }, 20000); + it("stops deferred post-extraction cleanup after package reset", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -7908,6 +8081,41 @@ describe("download manager", () => { expect(fs.existsSync(originalExtractedPath)).toBe(false); }, 20000); + it("moves extracted AVI files into a flat library folder per completed package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Flat-Pack-AVI"; + const sourceFileName = "Season 1/Episode01.avi"; + const { session, packageId, itemId, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName); + const mkvLibraryDir = path.join(root, "mkv-library"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const flattenedPath = path.join(mkvLibraryDir, "Episode01.avi"); + await waitFor(() => fs.existsSync(flattenedPath), 12000); + + expect(manager.getSnapshot().session.packages[packageId]?.status).toBe("completed"); + expect(manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); + expect(fs.existsSync(flattenedPath)).toBe(true); + expect(fs.existsSync(originalExtractedPath)).toBe(false); + }, 20000); + it("keeps existing MKV names and appends a suffix while flattening", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);