fix(downloads): restore hardened output scope release gates

Detect stale or foreign files in package-specific extraction directories before starting fresh work while excluding current and atomic ownership markers.

Keep recursive directory inspection confined to the start-conflict boundary and let archive cleanup prune empty package directories directly.

Register German-audio, auto-rename, collection, hybrid, and legacy fixtures through real complete PackageOutputScope records with current method signatures.

Verify plain stops only persist after active work drains, while update-restart parking keeps both its immediate checkpoint and final queued state.
This commit is contained in:
Sucukdeluxe
2026-08-22 18:24:50 +02:00
parent ea904aff79
commit 0d73f7e074
6 changed files with 155 additions and 78 deletions
+15 -15
View File
@@ -3518,7 +3518,7 @@ export class DownloadManager extends EventEmitter {
const extractDirKey = pathKey(pkg.extractDir); const extractDirKey = pathKey(pkg.extractDir);
const hasExtractedFiles = hasFilesByExtractDir.has(extractDirKey) const hasExtractedFiles = hasFilesByExtractDir.has(extractDirKey)
? Boolean(hasFilesByExtractDir.get(extractDirKey)) ? Boolean(hasFilesByExtractDir.get(extractDirKey))
: this.getPackageOutputScope(pkg).completeFiles().some((filePath) => isPathInsideDir(filePath, pkg.extractDir)); : await this.directoryHasAnyFiles(pkg.extractDir);
if (!hasFilesByExtractDir.has(extractDirKey)) { if (!hasFilesByExtractDir.has(extractDirKey)) {
hasFilesByExtractDir.set(extractDirKey, hasExtractedFiles); hasFilesByExtractDir.set(extractDirKey, hasExtractedFiles);
} }
@@ -4333,14 +4333,12 @@ export class DownloadManager extends EventEmitter {
} }
} }
if (removed > 0) { if (removed > 0) {
logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: ${removed} Datei(en) gelöscht`); 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);
const removedDirs = await this.removeEmptyDirectoryTree(pkg.outputDir); if (removedDirs > 0) {
if (removedDirs > 0) { logger.info(`Nachträgliches Cleanup entfernte leere Download-Ordner für ${pkg.name}: ${removedDirs}`);
logger.info(`Nachträgliches Cleanup entfernte leere Download-Ordner für ${pkg.name}: ${removedDirs}`); }
}
}
} else { } else {
logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: keine Dateien entfernt`); 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 }); entries = await fs.promises.readdir(current, { withFileTypes: true });
} catch { } catch {
continue; continue;
} }
for (const entry of entries) { for (const entry of entries) {
if (entry.isFile() && !isIgnorableEmptyDirFileName(entry.name)) { const isOwnerMarker = entry.name === PACKAGE_OUTPUT_OWNER_MARKER
return true; || entry.name.startsWith(`.${PACKAGE_OUTPUT_OWNER_MARKER}.`);
} if (entry.isFile() && !isOwnerMarker && !isIgnorableEmptyDirFileName(entry.name)) {
return true;
}
if (entry.isDirectory()) { if (entry.isDirectory()) {
stack.push(path.join(current, entry.name)); stack.push(path.join(current, entry.name));
} }
+90 -46
View File
@@ -25,6 +25,7 @@ import { UnrestrictedLink } from "../src/main/realdebrid";
import { resetVideoToolingCache } from "../src/main/video-processor"; import { resetVideoToolingCache } from "../src/main/video-processor";
import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor"; import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor";
import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types"; import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types";
import { registerPackageCompleteOutputs } from "./helpers/package-output-scope";
const tempDirs: string[] = []; const tempDirs: string[] = [];
const originalFetch = globalThis.fetch; 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", () => { describe("runWithLimitedConcurrency", () => {
it("processes the full batch without exceeding the configured worker count", async () => { it("processes the full batch without exceeding the configured worker count", async () => {
let active = 0; let active = 0;
@@ -7885,7 +7873,8 @@ describe("download manager", () => {
fs.writeFileSync(part2, Buffer.alloc(123, 0x62)); fs.writeFileSync(part2, Buffer.alloc(123, 0x62));
fs.writeFileSync(part3, Buffer.alloc(123, 0x63)); fs.writeFileSync(part3, Buffer.alloc(123, 0x63));
fs.writeFileSync(keep, "keep", "utf8"); 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 session = emptySession();
const packageId = "legacy-old-pkg"; const packageId = "legacy-old-pkg";
@@ -7905,10 +7894,11 @@ describe("download manager", () => {
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
session.items[itemId] = { registerPackageCompleteOutputs(session.packages[packageId], [episodePath]);
id: itemId, session.items[itemId] = {
packageId, id: itemId,
url: "https://dummy/legacy-old", packageId,
url: "https://dummy/legacy-old",
provider: "realdebrid", provider: "realdebrid",
status: "completed", status: "completed",
retries: 0, retries: 0,
@@ -12077,7 +12067,7 @@ describe("download manager", () => {
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
setExtractOutputRecords(session.packages[packageId], [ registerPackageCompleteOutputs(session.packages[packageId], [
path.join(extractDir, "episode.links.txt"), path.join(extractDir, "episode.links.txt"),
path.join(extractDir, "sample", "sample.mkv") path.join(extractDir, "sample", "sample.mkv")
]); ]);
@@ -12166,7 +12156,7 @@ describe("download manager", () => {
updatedAt: createdAt updatedAt: createdAt
}; };
} }
setExtractOutputRecords(session.packages[packageId], [extractedPath]); registerPackageCompleteOutputs(session.packages[packageId], [extractedPath]);
const manager = new DownloadManager( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
@@ -13183,7 +13173,7 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
(manager as any).fileStabilizeMinAgeMs = 30_000; (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 expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`); const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`);
@@ -13249,7 +13239,7 @@ describe("download manager", () => {
(manager as any).fileStabilizeMinAgeMs = 30_000; (manager as any).fileStabilizeMinAgeMs = 30_000;
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; 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); await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1);
expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true); expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true);
@@ -14979,9 +14969,10 @@ describe("download manager", () => {
downloadCompletedAt: 0 downloadCompletedAt: 0
}; };
const [n1, n2] = await Promise.all([ const scope = registerPackageCompleteOutputs(pkg, episodes.map((ep) => path.join(extractDir, ep.folder, ep.file)));
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg), const [n1, n2] = await Promise.all([
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg) (manager as any).autoRenameExtractedVideoFiles(extractDir, scope, pkg),
(manager as any).autoRenameExtractedVideoFiles(extractDir, scope, pkg)
]); ]);
expect(typeof n1).toBe("number"); expect(typeof n1).toBe("number");
@@ -15054,7 +15045,7 @@ describe("download manager", () => {
emptySession(), emptySession(),
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
const pkg: any = { const pkg: any = {
id: "overlap-pkg", id: "overlap-pkg",
name: "Overlap.Test.S01.GERMAN.x264-aWake", name: "Overlap.Test.S01.GERMAN.x264-aWake",
outputDir: path.join(root, "out", "Overlap.Test"), outputDir: path.join(root, "out", "Overlap.Test"),
@@ -15107,10 +15098,15 @@ describe("download manager", () => {
outputDir: path.join(root, "out", "Show"), outputDir: path.join(root, "out", "Show"),
extractDir, extractDir,
status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal", status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal",
createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0
}; };
registerPackageCompleteOutputs(pkg, [
await (manager as any).collectMkvFilesToLibrary("movecomp-pkg", 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); const libFiles = fs.readdirSync(libDir);
expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.mkv"); expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.mkv");
@@ -15134,16 +15130,22 @@ describe("download manager", () => {
emptySession(), emptySession(),
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
const pkg: any = { const pkg: any = {
id: "companion-pkg", id: "companion-pkg",
name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake", name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake",
outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"), outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"),
extractDir, extractDir,
status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal", status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal",
createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0
}; };
const scope = registerPackageCompleteOutputs(pkg, [
const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, 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); expect(renamed).toBe(1);
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
const files = fs.readdirSync(epFolder); const files = fs.readdirSync(epFolder);
@@ -15171,16 +15173,20 @@ describe("download manager", () => {
emptySession(), emptySession(),
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
const pkg: any = { const pkg: any = {
id: "collision-pkg", id: "collision-pkg",
name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake", name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake",
outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"), outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"),
extractDir, extractDir,
status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal", status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal",
createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0 createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0
}; };
const scope = registerPackageCompleteOutputs(pkg, [
const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, 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); expect(renamed).toBe(2);
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
const files = fs.readdirSync(epFolder).sort(); 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); 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-fresh-"));
tempDirs.push(root); tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state")); const storagePaths = createStoragePaths(path.join(root, "state"));
@@ -15279,11 +15285,49 @@ describe("start conflict guard + selective resume", () => {
session, storagePaths session, storagePaths
); );
const conflicts = await manager.getStartConflicts(); const conflicts = await manager.getStartConflicts();
expect(conflicts.map((c) => c.packageId)).toContain(packageId); expect(conflicts.map((c) => c.packageId)).toContain(packageId);
}); });
it("start() holds excluded packages out of the run set and runs the rest", async () => { 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selective-resume-"));
tempDirs.push(root); tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state")); const storagePaths = createStoragePaths(path.join(root, "state"));
+19 -14
View File
@@ -18,6 +18,7 @@ import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log"; import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log"; import { shutdownRenameLog } from "../src/main/rename-log";
import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor"; import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor";
import { registerPackageCompleteOutputs } from "./helpers/package-output-scope";
const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>; const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>; const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>;
@@ -79,19 +80,23 @@ const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv";
const SAMPLE_DL = "Show.sample.DL.mkv"; const SAMPLE_DL = "Show.sample.DL.mkv";
const DL_AVI = "Show.S01E03.German.DL.avi"; 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]) { 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", () => { describe("keepGermanAudioOnly integration", () => {
it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => { it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => {
const { extractDir, manager, pkg } = setup(true); 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); 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).toHaveBeenCalledTimes(1);
expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV)); 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 () => { it("does nothing when the setting is off", async () => {
const { extractDir, manager, pkg } = setup(false); const { extractDir, manager, pkg } = setup(false);
stage(extractDir); const scope = stage(extractDir, pkg);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, scope, pkg);
expect(n).toBe(0); expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled(); expect(mockedProcess).not.toHaveBeenCalled();
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched 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 () => { it("leaves the file fully untouched (name included) when no German track is found", async () => {
const { extractDir, manager, pkg } = setup(true); 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); 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(mockedProcess).toHaveBeenCalledTimes(1);
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed 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 () => { it("still strips .DL. for a single-audio file (no remux needed)", async () => {
const { extractDir, manager, pkg } = setup(true); 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); 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(n).toBe(0); // not counted as a remux
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv"); 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 () => { it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => {
const { extractDir, manager, pkg } = setup(true); const { extractDir, manager, pkg } = setup(true);
stage(extractDir); const scope = stage(extractDir, pkg);
mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found 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(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file 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 () => { it("stores a per-package summary with counts and file details", async () => {
const { extractDir, manager, pkg } = setup(true); 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); 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).toMatchObject({ candidates: 1, skippedNoGerman: 1, remuxed: 0, failed: 0 });
expect(pkg.audioStripSummary.files).toHaveLength(1); expect(pkg.audioStripSummary.files).toHaveLength(1);
+24
View File
@@ -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;
}
+2
View File
@@ -9,6 +9,7 @@ import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log"; import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log"; import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log"; import { shutdownRenameLog } from "../src/main/rename-log";
import { registerPackageCompleteOutputs } from "./helpers/package-output-scope";
const tempDirs: string[] = []; const tempDirs: string[] = [];
@@ -65,6 +66,7 @@ function setup(keepGermanAudioOnly: boolean): { extractDir: string; libraryDir:
for (const f of [DL_MKV, PLAIN_MKV, DL_AVI]) { for (const f of [DL_MKV, PLAIN_MKV, DL_AVI]) {
fs.writeFileSync(path.join(extractDir, f), "x"); 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 }; return { extractDir, libraryDir, manager, pkg };
} }
+5 -3
View File
@@ -131,15 +131,15 @@ async function driveActiveDownload(root: string): Promise<{ manager: DownloadMan
} }
describe("update restart resume", () => { 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
tempDirs.push(root); tempDirs.push(root);
const { manager, paths, serverStop } = await driveActiveDownload(root); const { manager, paths, serverStop } = await driveActiveDownload(root);
try { try {
manager.stop(); manager.stop();
manager.persistNowSync();
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0); await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
manager.prepareForShutdown(); manager.prepareForShutdown();
manager.persistForShutdown();
const reloaded = loadSession(paths); const reloaded = loadSession(paths);
const item = Object.values(reloaded.items)[0]; 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
tempDirs.push(root); tempDirs.push(root);
const { manager, paths, serverStop } = await driveActiveDownload(root); const { manager, paths, serverStop } = await driveActiveDownload(root);
try { try {
manager.stop({ parkForRestart: true }); manager.stop({ parkForRestart: true });
manager.persistNowSync(); manager.persistNowSync();
expect(Object.values(loadSession(paths).items)[0]?.status).toBe("queued");
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0); await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
manager.prepareForShutdown(); manager.prepareForShutdown();
manager.persistForShutdown();
const reloaded = loadSession(paths); const reloaded = loadSession(paths);
const item = Object.values(reloaded.items)[0]; const item = Object.values(reloaded.items)[0];