diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index efb83e4..1a3a784 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -3518,7 +3518,7 @@ export class DownloadManager extends EventEmitter { const extractDirKey = pathKey(pkg.extractDir); const hasExtractedFiles = hasFilesByExtractDir.has(extractDirKey) ? Boolean(hasFilesByExtractDir.get(extractDirKey)) - : this.getPackageOutputScope(pkg).completeFiles().some((filePath) => isPathInsideDir(filePath, pkg.extractDir)); + : await this.directoryHasAnyFiles(pkg.extractDir); if (!hasFilesByExtractDir.has(extractDirKey)) { hasFilesByExtractDir.set(extractDirKey, hasExtractedFiles); } @@ -4333,14 +4333,12 @@ export class DownloadManager extends EventEmitter { } } - if (removed > 0) { - logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: ${removed} Datei(en) gelöscht`); - if (!await this.directoryHasAnyFiles(pkg.outputDir)) { - const removedDirs = await this.removeEmptyDirectoryTree(pkg.outputDir); - if (removedDirs > 0) { - logger.info(`Nachträgliches Cleanup entfernte leere Download-Ordner für ${pkg.name}: ${removedDirs}`); - } - } + if (removed > 0) { + logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: ${removed} Datei(en) gelöscht`); + const removedDirs = await this.removeEmptyDirectoryTree(pkg.outputDir); + if (removedDirs > 0) { + logger.info(`Nachträgliches Cleanup entfernte leere Download-Ordner für ${pkg.name}: ${removedDirs}`); + } } else { logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: keine Dateien entfernt`); } @@ -4374,12 +4372,14 @@ export class DownloadManager extends EventEmitter { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; - } - - for (const entry of entries) { - if (entry.isFile() && !isIgnorableEmptyDirFileName(entry.name)) { - return true; - } + } + + for (const entry of entries) { + const isOwnerMarker = entry.name === PACKAGE_OUTPUT_OWNER_MARKER + || entry.name.startsWith(`.${PACKAGE_OUTPUT_OWNER_MARKER}.`); + if (entry.isFile() && !isOwnerMarker && !isIgnorableEmptyDirFileName(entry.name)) { + return true; + } if (entry.isDirectory()) { stack.push(path.join(current, entry.name)); } diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 9e0bcda..723a37e 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -25,6 +25,7 @@ import { UnrestrictedLink } from "../src/main/realdebrid"; import { resetVideoToolingCache } from "../src/main/video-processor"; import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor"; import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types"; +import { registerPackageCompleteOutputs } from "./helpers/package-output-scope"; const tempDirs: string[] = []; const originalFetch = globalThis.fetch; @@ -43,19 +44,6 @@ function writePackageOutputOwnerMarker(pkg: PackageEntry): void { })); } -function setExtractOutputRecords(pkg: PackageEntry, outputPaths: string[]): void { - pkg.outputProvenanceVersion = 1; - pkg.outputRecords = outputPaths.map((outputPath) => ({ - version: 1, - archivePath: path.join(pkg.outputDir, "source.zip"), - entryPath: path.relative(pkg.extractDir, outputPath).replace(/\\/g, "/"), - outputPath, - state: "complete", - disposition: "written" - })); - pkg.outputCount = outputPaths.length; -} - describe("runWithLimitedConcurrency", () => { it("processes the full batch without exceeding the configured worker count", async () => { let active = 0; @@ -7885,7 +7873,8 @@ describe("download manager", () => { fs.writeFileSync(part2, Buffer.alloc(123, 0x62)); fs.writeFileSync(part3, Buffer.alloc(123, 0x63)); fs.writeFileSync(keep, "keep", "utf8"); - fs.writeFileSync(path.join(extractDir, "episode.mkv"), "video", "utf8"); + const episodePath = path.join(extractDir, "episode.mkv"); + fs.writeFileSync(episodePath, "video", "utf8"); const session = emptySession(); const packageId = "legacy-old-pkg"; @@ -7905,10 +7894,11 @@ describe("download manager", () => { createdAt, updatedAt: createdAt }; - session.items[itemId] = { - id: itemId, - packageId, - url: "https://dummy/legacy-old", + registerPackageCompleteOutputs(session.packages[packageId], [episodePath]); + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/legacy-old", provider: "realdebrid", status: "completed", retries: 0, @@ -12077,7 +12067,7 @@ describe("download manager", () => { createdAt, updatedAt: createdAt }; - setExtractOutputRecords(session.packages[packageId], [ + registerPackageCompleteOutputs(session.packages[packageId], [ path.join(extractDir, "episode.links.txt"), path.join(extractDir, "sample", "sample.mkv") ]); @@ -12166,7 +12156,7 @@ describe("download manager", () => { updatedAt: createdAt }; } - setExtractOutputRecords(session.packages[packageId], [extractedPath]); + registerPackageCompleteOutputs(session.packages[packageId], [extractedPath]); const manager = new DownloadManager( { ...defaultSettings(), @@ -13183,7 +13173,7 @@ describe("download manager", () => { createStoragePaths(path.join(root, "state")) ); (manager as any).fileStabilizeMinAgeMs = 30_000; - setExtractOutputRecords(session.packages[packageId], [scenePath]); + registerPackageCompleteOutputs(session.packages[packageId], [scenePath]); const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`); @@ -13249,7 +13239,7 @@ describe("download manager", () => { (manager as any).fileStabilizeMinAgeMs = 30_000; const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; - setExtractOutputRecords(session.packages[packageId], [path.join(epFolder, sceneName)]); + registerPackageCompleteOutputs(session.packages[packageId], [path.join(epFolder, sceneName)]); await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1); expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true); @@ -14979,9 +14969,10 @@ describe("download manager", () => { downloadCompletedAt: 0 }; - const [n1, n2] = await Promise.all([ - (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg), - (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg) + const scope = registerPackageCompleteOutputs(pkg, episodes.map((ep) => path.join(extractDir, ep.folder, ep.file))); + const [n1, n2] = await Promise.all([ + (manager as any).autoRenameExtractedVideoFiles(extractDir, scope, pkg), + (manager as any).autoRenameExtractedVideoFiles(extractDir, scope, pkg) ]); expect(typeof n1).toBe("number"); @@ -15054,7 +15045,7 @@ describe("download manager", () => { emptySession(), createStoragePaths(path.join(root, "state")) ); - const pkg: any = { + const pkg: any = { id: "overlap-pkg", name: "Overlap.Test.S01.GERMAN.x264-aWake", outputDir: path.join(root, "out", "Overlap.Test"), @@ -15107,10 +15098,15 @@ describe("download manager", () => { outputDir: path.join(root, "out", "Show"), extractDir, status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal", - createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 - }; - - await (manager as any).collectMkvFilesToLibrary("movecomp-pkg", pkg); + createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 + }; + registerPackageCompleteOutputs(pkg, [ + path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.mkv"), + path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.srt"), + path.join(epFolder, "Show.S01E01.GERMAN.x264-GROUP.nfo") + ]); + + await (manager as any).collectMkvFilesToLibrary("movecomp-pkg", pkg); const libFiles = fs.readdirSync(libDir); expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.mkv"); @@ -15134,16 +15130,22 @@ describe("download manager", () => { emptySession(), createStoragePaths(path.join(root, "state")) ); - const pkg: any = { + const pkg: any = { id: "companion-pkg", name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake", outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"), extractDir, status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal", - createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 - }; - - const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg); + createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 + }; + const scope = registerPackageCompleteOutputs(pkg, [ + path.join(epFolder, "awa-testshow02e05hd.mkv"), + path.join(epFolder, "awa-testshow02e05hd.srt"), + path.join(epFolder, "awa-testshow02e05hd.de.srt"), + path.join(epFolder, "awa-testshow02e05hd.nfo") + ]); + + const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, scope, pkg); expect(renamed).toBe(1); const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; const files = fs.readdirSync(epFolder); @@ -15171,16 +15173,20 @@ describe("download manager", () => { emptySession(), createStoragePaths(path.join(root, "state")) ); - const pkg: any = { + const pkg: any = { id: "collision-pkg", name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake", outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"), extractDir, status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal", - createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 - }; - - const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg); + createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 + }; + const scope = registerPackageCompleteOutputs(pkg, [ + path.join(epFolder, "awa-testshow02e05hd.mkv"), + path.join(epFolder, "awa-testshow02e05hd.alt.mkv") + ]); + + const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, scope, pkg); expect(renamed).toBe(2); const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; const files = fs.readdirSync(epFolder).sort(); @@ -15253,7 +15259,7 @@ describe("start conflict guard + selective resume", () => { expect(conflicts.map((c) => c.packageId)).not.toContain(packageId); }); - it("flags a fresh package when its package-specific extract dir already holds files and it has no completed items", async () => { + it("flags a fresh package when its package-specific extract dir already holds files and it has no completed items", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-fresh-")); tempDirs.push(root); const storagePaths = createStoragePaths(path.join(root, "state")); @@ -15279,11 +15285,49 @@ describe("start conflict guard + selective resume", () => { session, storagePaths ); - const conflicts = await manager.getStartConflicts(); - expect(conflicts.map((c) => c.packageId)).toContain(packageId); - }); - - it("start() holds excluded packages out of the run set and runs the rest", async () => { + const conflicts = await manager.getStartConflicts(); + expect(conflicts.map((c) => c.packageId)).toContain(packageId); + }); + + it("does not flag a fresh package when its extract dir contains only current or atomic owner markers", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-markers-")); + tempDirs.push(root); + const storagePaths = createStoragePaths(path.join(root, "state")); + initPackageLogs(storagePaths.baseDir); + initItemLogs(storagePaths.baseDir); + + const session = emptySession(); + const packageId = "pkg-marker-only"; + const extractDir = path.join(root, "extract", "MarkerOnly"); + const ownerId = crypto.randomUUID().toLowerCase(); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, ".rd-package-output-owner-v1.json"), JSON.stringify({ + version: 1, + packageId, + generation: 1, + ownerId + })); + fs.writeFileSync(path.join(extractDir, `..rd-package-output-owner-v1.json.${crypto.randomUUID()}.tmp`), "{}"); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, name: "MarkerOnly", + outputDir: path.join(root, "downloads", "MarkerOnly"), extractDir, + status: "queued", itemIds: ["marker-pending"], cancelled: false, enabled: true, + outputOwnerId: ownerId, outputOwnerGeneration: 1, resultGeneration: 1, + createdAt: Date.now(), updatedAt: Date.now() + } as any; + session.items["marker-pending"] = makeItem("marker-pending", packageId, "queued", "marker.rar"); + + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") }, + session, storagePaths + ); + + const conflicts = await manager.getStartConflicts(); + expect(conflicts.map((c) => c.packageId)).not.toContain(packageId); + }); + + it("start() holds excluded packages out of the run set and runs the rest", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selective-resume-")); tempDirs.push(root); const storagePaths = createStoragePaths(path.join(root, "state")); diff --git a/tests/german-audio-integration.test.ts b/tests/german-audio-integration.test.ts index ce23d88..c355e65 100644 --- a/tests/german-audio-integration.test.ts +++ b/tests/german-audio-integration.test.ts @@ -18,6 +18,7 @@ import { shutdownItemLogs } from "../src/main/item-log"; import { shutdownPackageLogs } from "../src/main/package-log"; import { shutdownRenameLog } from "../src/main/rename-log"; import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor"; +import { registerPackageCompleteOutputs } from "./helpers/package-output-scope"; const mockedProcess = processVideoFile as unknown as ReturnType; const mockedTooling = resolveVideoTooling as unknown as ReturnType; @@ -79,19 +80,23 @@ const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv"; const SAMPLE_DL = "Show.sample.DL.mkv"; const DL_AVI = "Show.S01E03.German.DL.avi"; -function stage(extractDir: string): void { +function stage(extractDir: string, pkg: any) { + const outputPaths: string[] = []; for (const f of [DL_MKV, PLAIN_MKV, SAMPLE_DL, DL_AVI]) { - fs.writeFileSync(path.join(extractDir, f), "x"); + const outputPath = path.join(extractDir, f); + fs.writeFileSync(outputPath, "x"); + outputPaths.push(outputPath); } + return registerPackageCompleteOutputs(pkg, outputPaths); } describe("keepGermanAudioOnly integration", () => { it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => { const { extractDir, manager, pkg } = setup(true); - stage(extractDir); + const scope = stage(extractDir, pkg); mockedProcess.mockResolvedValue({ action: "remuxed", reason: "german-tag", totalAudioTracks: 2, keptTrackIndex: 0 } as VideoProcessResult); - const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, scope, pkg); expect(mockedProcess).toHaveBeenCalledTimes(1); expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV)); @@ -107,8 +112,8 @@ describe("keepGermanAudioOnly integration", () => { it("does nothing when the setting is off", async () => { const { extractDir, manager, pkg } = setup(false); - stage(extractDir); - const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + const scope = stage(extractDir, pkg); + const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, scope, pkg); expect(n).toBe(0); expect(mockedProcess).not.toHaveBeenCalled(); expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched @@ -116,10 +121,10 @@ describe("keepGermanAudioOnly integration", () => { it("leaves the file fully untouched (name included) when no German track is found", async () => { const { extractDir, manager, pkg } = setup(true); - stage(extractDir); + const scope = stage(extractDir, pkg); mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2 } as VideoProcessResult); - await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + await (manager as any).keepGermanAudioOnlyImpl(extractDir, scope, pkg); expect(mockedProcess).toHaveBeenCalledTimes(1); expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed @@ -127,10 +132,10 @@ describe("keepGermanAudioOnly integration", () => { it("still strips .DL. for a single-audio file (no remux needed)", async () => { const { extractDir, manager, pkg } = setup(true); - stage(extractDir); + const scope = stage(extractDir, pkg); mockedProcess.mockResolvedValue({ action: "kept-single", reason: "single-german", totalAudioTracks: 1, keptTrackIndex: 0 } as VideoProcessResult); - const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, scope, pkg); expect(n).toBe(0); // not counted as a remux expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv"); @@ -138,10 +143,10 @@ describe("keepGermanAudioOnly integration", () => { it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => { const { extractDir, manager, pkg } = setup(true); - stage(extractDir); + const scope = stage(extractDir, pkg); mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found - const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, scope, pkg); expect(n).toBe(0); expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file @@ -152,10 +157,10 @@ describe("keepGermanAudioOnly integration", () => { it("stores a per-package summary with counts and file details", async () => { const { extractDir, manager, pkg } = setup(true); - stage(extractDir); + const scope = stage(extractDir, pkg); mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2, audioLanguages: ["eng", "fre"] } as VideoProcessResult); - await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + await (manager as any).keepGermanAudioOnlyImpl(extractDir, scope, pkg); expect(pkg.audioStripSummary).toMatchObject({ candidates: 1, skippedNoGerman: 1, remuxed: 0, failed: 0 }); expect(pkg.audioStripSummary.files).toHaveLength(1); diff --git a/tests/helpers/package-output-scope.ts b/tests/helpers/package-output-scope.ts new file mode 100644 index 0000000..5b6ad29 --- /dev/null +++ b/tests/helpers/package-output-scope.ts @@ -0,0 +1,24 @@ +import path from "node:path"; +import { PackageOutputScope } from "../../src/main/package-output-scope"; +import type { PackageEntry } from "../../src/shared/types"; + +export function registerPackageCompleteOutputs(pkg: PackageEntry, outputPaths: readonly string[]): PackageOutputScope { + const roots = [pkg.outputDir, pkg.extractDir].filter((root): root is string => Boolean(String(root || "").trim())); + const scope = new PackageOutputScope(roots); + const archivePath = path.resolve(pkg.outputDir || pkg.extractDir, "fixture-source.archive"); + for (const outputPath of outputPaths) { + const relativePath = path.relative(pkg.extractDir, outputPath).replace(/\\/g, "/"); + scope.add({ + version: 1, + archivePath, + entryPath: relativePath && !relativePath.startsWith("../") ? relativePath : path.basename(outputPath), + outputPath, + state: "complete", + disposition: "written" + }); + } + pkg.outputProvenanceVersion = 1; + pkg.outputRecords = scope.records(); + pkg.outputCount = scope.records().length; + return scope; +} diff --git a/tests/hybrid-collect-race.test.ts b/tests/hybrid-collect-race.test.ts index 036532e..b9dc536 100644 --- a/tests/hybrid-collect-race.test.ts +++ b/tests/hybrid-collect-race.test.ts @@ -9,6 +9,7 @@ import { createStoragePaths, emptySession } from "../src/main/storage"; import { shutdownItemLogs } from "../src/main/item-log"; import { shutdownPackageLogs } from "../src/main/package-log"; import { shutdownRenameLog } from "../src/main/rename-log"; +import { registerPackageCompleteOutputs } from "./helpers/package-output-scope"; const tempDirs: string[] = []; @@ -65,6 +66,7 @@ function setup(keepGermanAudioOnly: boolean): { extractDir: string; libraryDir: for (const f of [DL_MKV, PLAIN_MKV, DL_AVI]) { fs.writeFileSync(path.join(extractDir, f), "x"); } + registerPackageCompleteOutputs(pkg, [DL_MKV, PLAIN_MKV, DL_AVI].map((fileName) => path.join(extractDir, fileName))); return { extractDir, libraryDir, manager, pkg }; } diff --git a/tests/update-restart-resume.test.ts b/tests/update-restart-resume.test.ts index 161cb99..ecc2732 100644 --- a/tests/update-restart-resume.test.ts +++ b/tests/update-restart-resume.test.ts @@ -131,15 +131,15 @@ async function driveActiveDownload(root: string): Promise<{ manager: DownloadMan } describe("update restart resume", () => { - it("characterization: a plain stop() leaves an in-flight item cancelled across a restart", async () => { + it("persists a drained plain stop as cancelled across a restart", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-")); tempDirs.push(root); const { manager, paths, serverStop } = await driveActiveDownload(root); try { manager.stop(); - manager.persistNowSync(); await waitFor(() => (manager as unknown as { activeTasks: Map }).activeTasks.size === 0); manager.prepareForShutdown(); + manager.persistForShutdown(); const reloaded = loadSession(paths); const item = Object.values(reloaded.items)[0]; @@ -150,15 +150,17 @@ describe("update restart resume", () => { } }); - it("parks an in-flight item as queued for an update restart so it auto-resumes", async () => { + it("checkpoints and finally persists an update-parked item as queued so it auto-resumes", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-")); tempDirs.push(root); const { manager, paths, serverStop } = await driveActiveDownload(root); try { manager.stop({ parkForRestart: true }); manager.persistNowSync(); + expect(Object.values(loadSession(paths).items)[0]?.status).toBe("queued"); await waitFor(() => (manager as unknown as { activeTasks: Map }).activeTasks.size === 0); manager.prepareForShutdown(); + manager.persistForShutdown(); const reloaded = loadSession(paths); const item = Object.values(reloaded.items)[0];