From 2ae506735aa5188cf73aa08d14f1ec68dfea8c84 Mon Sep 17 00:00:00 2001 From: shaunmower Date: Sat, 3 Oct 2026 20:36:58 +1000 Subject: [PATCH 1/9] test: cover maximum title duration selection --- tests/fixtures/config.yaml | 1 + tests/unit/config.test.js | 6 ++++ tests/unit/disc.service.test.js | 61 +++++++++++++++++++++++++++++++++ tests/unit/rip.service.test.js | 17 +++++++++ 4 files changed, 85 insertions(+) diff --git a/tests/fixtures/config.yaml b/tests/fixtures/config.yaml index 79a7ea5..a4a995f 100644 --- a/tests/fixtures/config.yaml +++ b/tests/fixtures/config.yaml @@ -18,6 +18,7 @@ mount_detection: ripping: rip_all_titles: false + max_title_length_minutes: 60 mode: "async" interface: diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js index bd14da1..6ef3543 100644 --- a/tests/unit/config.test.js +++ b/tests/unit/config.test.js @@ -106,6 +106,12 @@ describe("AppConfig", () => { expect(result).toBe(false); }); + it("should return the configured maximum title length", async () => { + const { AppConfig } = await import("../../src/config/index.js"); + + expect(AppConfig.maxTitleLengthMinutes).toBe(60); + }); + it("should return correct ripping mode", async () => { const { AppConfig } = await import("../../src/config/index.js"); diff --git a/tests/unit/disc.service.test.js b/tests/unit/disc.service.test.js index 43687fd..005f1b6 100644 --- a/tests/unit/disc.service.test.js +++ b/tests/unit/disc.service.test.js @@ -14,6 +14,7 @@ const mockAppConfig = { .fn() .mockResolvedValue('"C:\\Program Files (x86)\\MakeMKV\\makemkvcon.exe"'), isRipAllEnabled: false, + maxTitleLengthMinutes: null, }; // Mock the config with async methods @@ -76,6 +77,7 @@ describe("DiscService", () => { '"C:\\Program Files (x86)\\MakeMKV\\makemkvcon.exe"' ); mockAppConfig.isRipAllEnabled = false; + mockAppConfig.maxTitleLengthMinutes = null; // Import DiscService after mocks are set up const module = await import("../../src/services/disc.service.js"); @@ -513,6 +515,27 @@ TINFO:2,9,0,"2:15:30"`; }); }); + it("should return eligible title numbers when a maximum duration is configured", async () => { + mockAppConfig.maxTitleLengthMinutes = 60; + mockAppConfig.isRipAllEnabled = true; + const mockStdout = `TINFO:0,9,0,"0:22:00" +TINFO:1,9,0,"2:00:00" +TINFO:2,9,0,"0:23:30"`; + + exec.mockImplementation((command, callback) => { + callback(null, mockStdout, ""); + }); + + const result = await DiscService.getDiscFileInfo({ + driveNumber: "0", + title: "Test Disc", + mediaType: "dvd", + }); + + expect(result.fileNumbers).toEqual(["0", "2"]); + expect(result.fileNumber).toBeUndefined(); + }); + it("should return 'all' when rip all is enabled", async () => { vi.resetModules(); vi.doMock("../../src/config/index.js", () => ({ @@ -635,6 +658,44 @@ TINFO:2,9,0,"malformed"`; }); }); + describe("getCompleteDiscInfo", () => { + it("should omit discs skipped by title-duration filtering", async () => { + vi.spyOn(DiscService, "getDiscFileInfo") + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ driveNumber: "1", title: "Eligible Disc" }); + + const result = await DiscService.getCompleteDiscInfo([ + { driveNumber: "0", title: "Skipped Disc" }, + { driveNumber: "1", title: "Eligible Disc" }, + ]); + + expect(result).toEqual([ + { driveNumber: "1", title: "Eligible Disc" }, + ]); + }); + }); + + describe("getFileNumbers", () => { + it("should select every title within the maximum when rip-all is enabled", () => { + const mockOutput = `TINFO:0,9,0,"0:22:00" +TINFO:1,9,0,"2:00:00" +TINFO:2,9,0,"0:23:30"`; + + expect(DiscService.getFileNumbers(mockOutput, 60, true)).toEqual([ + "0", + "2", + ]); + }); + + it("should select the longest eligible title when rip-all is disabled", () => { + const mockOutput = `TINFO:0,9,0,"0:22:00" +TINFO:1,9,0,"2:00:00" +TINFO:2,9,0,"0:23:30"`; + + expect(DiscService.getFileNumbers(mockOutput, 60, false)).toEqual(["2"]); + }); + }); + describe("detectAvailableDiscs", () => { it("should detect discs without processing file information", async () => { const mockStdout = `DRV:0,2,999,1,"BD-ROM HL-DT-ST","Test Movie","/dev/sr0" diff --git a/tests/unit/rip.service.test.js b/tests/unit/rip.service.test.js index c18c908..7f4660d 100644 --- a/tests/unit/rip.service.test.js +++ b/tests/unit/rip.service.test.js @@ -247,6 +247,23 @@ describe("RipService", () => { }); describe("Ripping Process", () => { + it("should rip each title selected by the duration filter separately", async () => { + const { exec } = await import("child_process"); + + await ripService.ripSingleDisc( + { + driveNumber: "0", + title: "Simpsons Disc", + fileNumbers: ["0", "2"], + }, + "./test-output" + ); + + expect(exec).toHaveBeenCalledTimes(2); + expect(exec.mock.calls[0][0]).toContain("disc:0 0"); + expect(exec.mock.calls[1][0]).toContain("disc:0 2"); + }); + it("should create unique folders for each disc", async () => { await ripService.startRipping(); From eb6249ec5e16e66ea310a9eec9f33e4edee101b0 Mon Sep 17 00:00:00 2001 From: shaunmower Date: Sat, 3 Oct 2026 20:45:13 +1000 Subject: [PATCH 2/9] test: isolate duration rip command setup --- tests/unit/rip.service.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/rip.service.test.js b/tests/unit/rip.service.test.js index 7f4660d..fd73e60 100644 --- a/tests/unit/rip.service.test.js +++ b/tests/unit/rip.service.test.js @@ -249,6 +249,7 @@ describe("RipService", () => { describe("Ripping Process", () => { it("should rip each title selected by the duration filter separately", async () => { const { exec } = await import("child_process"); + mockAppConfig.getMakeMKVExecutable.mockResolvedValue("makemkvcon"); await ripService.ripSingleDisc( { From 581e6c0429fe634e2a629bf05a7d5ea5c4058693 Mon Sep 17 00:00:00 2001 From: shaunmower Date: Sat, 3 Oct 2026 20:47:29 +1000 Subject: [PATCH 3/9] test: make duration selection deterministic --- tests/unit/disc.service.test.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/unit/disc.service.test.js b/tests/unit/disc.service.test.js index 005f1b6..40ab405 100644 --- a/tests/unit/disc.service.test.js +++ b/tests/unit/disc.service.test.js @@ -676,7 +676,12 @@ TINFO:2,9,0,"malformed"`; }); describe("getFileNumbers", () => { - it("should select every title within the maximum when rip-all is enabled", () => { + it("should select every title within the maximum when rip-all is enabled", async () => { + const { ValidationUtils } = await import("../../src/utils/validation.js"); + const { getTimeInSeconds } = ValidationUtils; + getTimeInSeconds.mockImplementation((timeArray) => + +timeArray[0] * 3600 + +timeArray[1] * 60 + +timeArray[2] + ); const mockOutput = `TINFO:0,9,0,"0:22:00" TINFO:1,9,0,"2:00:00" TINFO:2,9,0,"0:23:30"`; @@ -687,7 +692,12 @@ TINFO:2,9,0,"0:23:30"`; ]); }); - it("should select the longest eligible title when rip-all is disabled", () => { + it("should select the longest eligible title when rip-all is disabled", async () => { + const { ValidationUtils } = await import("../../src/utils/validation.js"); + const { getTimeInSeconds } = ValidationUtils; + getTimeInSeconds.mockImplementation((timeArray) => + +timeArray[0] * 3600 + +timeArray[1] * 60 + +timeArray[2] + ); const mockOutput = `TINFO:0,9,0,"0:22:00" TINFO:1,9,0,"2:00:00" TINFO:2,9,0,"0:23:30"`; From 50236bdebe4c1be978b78bc9fc7b04b45cd66e08 Mon Sep 17 00:00:00 2001 From: shaunmower Date: Sat, 3 Oct 2026 20:49:35 +1000 Subject: [PATCH 4/9] test: isolate duration config from module mocks --- tests/unit/disc.service.test.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/unit/disc.service.test.js b/tests/unit/disc.service.test.js index 40ab405..6eb2504 100644 --- a/tests/unit/disc.service.test.js +++ b/tests/unit/disc.service.test.js @@ -516,8 +516,17 @@ TINFO:2,9,0,"2:15:30"`; }); it("should return eligible title numbers when a maximum duration is configured", async () => { - mockAppConfig.maxTitleLengthMinutes = 60; - mockAppConfig.isRipAllEnabled = true; + vi.resetModules(); + vi.doMock("../../src/config/index.js", () => ({ + AppConfig: { + ...mockAppConfig, + maxTitleLengthMinutes: 60, + isRipAllEnabled: true, + }, + })); + const { DiscService: Local } = await import( + "../../src/services/disc.service.js" + ); const mockStdout = `TINFO:0,9,0,"0:22:00" TINFO:1,9,0,"2:00:00" TINFO:2,9,0,"0:23:30"`; @@ -526,7 +535,7 @@ TINFO:2,9,0,"0:23:30"`; callback(null, mockStdout, ""); }); - const result = await DiscService.getDiscFileInfo({ + const result = await Local.getDiscFileInfo({ driveNumber: "0", title: "Test Disc", mediaType: "dvd", From f46b0e286808c53ee57fbb076bf02deda8328e90 Mon Sep 17 00:00:00 2001 From: shaunmower Date: Sat, 3 Oct 2026 20:50:15 +1000 Subject: [PATCH 5/9] feat: filter disc titles by maximum duration --- README.md | 4 ++ src/config/index.js | 6 +++ src/services/disc.service.js | 70 +++++++++++++++++++++++++++++++-- src/services/rip.service.js | 75 +++++++++++++++++++++--------------- 4 files changed, 120 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index fe815bc..ebd72a2 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,9 @@ ripping: # Rip all titles from disc instead of just the main title (true/false) rip_all_titles: false + # Optional maximum title duration in minutes; titles longer than this are skipped + max_title_length_minutes: 60 + # Ripping mode - async for parallel processing, sync for sequential (async/sync) mode: "async" @@ -279,6 +282,7 @@ makemkv: - **`drives.auto_eject`** - Auto-eject drives after ripping completion (`true` or `false`) - **`drives.load_delay`** - Delay time (in seconds) when loading drives, allows time for manual drive closing (`0` to disable, default: `0`) - **`ripping.rip_all_titles`** - Rip all titles that are above MakeMKV min length (`true`) or longest title only (`false`) +- **`ripping.max_title_length_minutes`** - Optional maximum duration for selected titles; titles over this limit are skipped. Omit or comment out to disable. - **`ripping.mode`** - Ripping mode (`"async"` for parallel processing or `"sync"` for sequential processing) - **`mount_detection.wait_timeout`** - Maximum time (in seconds) to wait for drives to mount media before starting rip (`0` to disable, default: `10`) - **`mount_detection.poll_interval`** - Polling interval (in seconds) to check for newly mounted drives (default: `1`) diff --git a/src/config/index.js b/src/config/index.js index 9769fdd..1b0bc00 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -121,6 +121,12 @@ export class AppConfig { return Boolean(config.ripping?.rip_all_titles); } + static get maxTitleLengthMinutes() { + const config = this.#loadConfig(); + const maximum = config.ripping?.max_title_length_minutes; + return typeof maximum === "number" && maximum > 0 ? maximum : null; + } + static get rippingMode() { const config = this.#loadConfig(); const mode = config.ripping?.mode; diff --git a/src/services/disc.service.js b/src/services/disc.service.js index d46594b..57c6c0d 100644 --- a/src/services/disc.service.js +++ b/src/services/disc.service.js @@ -143,7 +143,7 @@ export class DiscService { this.getDiscFileInfo(drive) ); - return Promise.all(drivePromises); + return Promise.all(drivePromises).then((discs) => discs.filter(Boolean)); } /** @@ -322,9 +322,23 @@ export class DiscService { } try { - const fileNumber = AppConfig.isRipAllEnabled - ? "all" - : this.getFileNumber(stdout); + const maxTitleLength = AppConfig.maxTitleLengthMinutes; + const ripAll = AppConfig.isRipAllEnabled; + const fileNumbers = maxTitleLength + ? this.getFileNumbers(stdout, maxTitleLength, ripAll) + : null; + if (fileNumbers && fileNumbers.length === 0) { + Logger.info( + `No titles at or below ${maxTitleLength} minutes found on ${driveInfo.title}; skipping disc.` + ); + resolve(null); + return; + } + const fileNumber = fileNumbers + ? undefined + : ripAll + ? "all" + : this.getFileNumber(stdout); Logger.info( `Got file info for ${driveInfo.driveNumber}-${driveInfo.title}.` @@ -334,6 +348,7 @@ export class DiscService { driveNumber: driveInfo.driveNumber, title: driveInfo.title, fileNumber: fileNumber, + fileNumbers: fileNumbers || undefined, mediaType: driveInfo.mediaType, }); } catch (error) { @@ -379,4 +394,51 @@ export class DiscService { return myTitleSectionValue || "0"; } + + /** + * Get title IDs at or below a configured duration, respecting rip-all mode. + * @param {string} data - Raw MakeMKV output + * @param {number} maximumMinutes - Maximum title duration in minutes + * @param {boolean} ripAll - Whether to select every eligible title + * @returns {Array} - Selected title IDs + */ + static getFileNumbers( + data, + maximumMinutes, + ripAll = AppConfig.isRipAllEnabled + ) { + const validationMessage = ValidationUtils.validateFileData(data); + if (validationMessage) { + throw new Error(validationMessage); + } + + const eligibleTitles = data.split("\n").flatMap((line) => { + const lineArray = line.split(","); + if ( + !lineArray[0].startsWith("TINFO:") || + lineArray[1] != VALIDATION_CONSTANTS.TITLE_LENGTH_CODE + ) { + return []; + } + + const duration = lineArray[3]?.replace(/[\"']+/g, "").split(":"); + const durationSeconds = ValidationUtils.getTimeInSeconds(duration); + return durationSeconds > 0 && durationSeconds <= maximumMinutes * 60 + ? [{ id: lineArray[0].replace("TINFO:", ""), durationSeconds }] + : []; + }); + + if (ripAll) { + return eligibleTitles.map((title) => title.id); + } + + const longestTitle = eligibleTitles.reduce( + (longest, title) => + !longest || title.durationSeconds > longest.durationSeconds + ? title + : longest, + null + ); + return longestTitle ? [longestTitle.id] : []; + } } diff --git a/src/services/rip.service.js b/src/services/rip.service.js index a07162b..c73f820 100644 --- a/src/services/rip.service.js +++ b/src/services/rip.service.js @@ -127,43 +127,56 @@ export class RipService { return; } - const makeMKVCommand = `${makeMKVExecutable} -r mkv disc:${commandDataItem.driveNumber} ${commandDataItem.fileNumber} "${dir}"`; + const selectors = commandDataItem.fileNumbers?.length + ? commandDataItem.fileNumbers + : [commandDataItem.fileNumber]; - exec(makeMKVCommand, async (err, stdout, stderr) => { - // Check for critical MakeMKV messages (not first call, so only check for errors) - const shouldContinue = MakeMKVMessages.checkOutput( - stdout + (stderr || ""), - false - ); + try { + const outputs = []; + for (const selector of selectors) { + outputs.push( + await new Promise((resolveCommand, rejectCommand) => { + const makeMKVCommand = `${makeMKVExecutable} -r mkv disc:${commandDataItem.driveNumber} ${selector} "${dir}"`; - if (!shouldContinue) { - Logger.error( - "MakeMKV version is too old, please update to the latest version" - ); - reject( - new Error( - "MakeMKV version is too old, please update to the latest version" - ) - ); - return; - } + exec(makeMKVCommand, (err, stdout, stderr) => { + // Check for critical MakeMKV messages (not first call, so only check for errors) + const shouldContinue = MakeMKVMessages.checkOutput( + stdout + (stderr || ""), + false + ); + + if (!shouldContinue) { + Logger.error( + "MakeMKV version is too old, please update to the latest version" + ); + rejectCommand( + new Error( + "MakeMKV version is too old, please update to the latest version" + ) + ); + return; + } + + if (err || stderr) { + Logger.error( + `Critical Error Ripping ${commandDataItem.title}`, + err || stderr + ); + rejectCommand(err || stderr); + return; + } - if (err || stderr) { - Logger.error( - `Critical Error Ripping ${commandDataItem.title}`, - err || stderr + resolveCommand(stdout); + }); + }) ); - reject(err || stderr); - return; } - try { - await this.handleRipCompletion(stdout, commandDataItem); - resolve(commandDataItem.title); - } catch (error) { - reject(error); - } - }); + await this.handleRipCompletion(outputs.join("\n"), commandDataItem); + resolve(commandDataItem.title); + } catch (error) { + reject(error); + } }); } From 044628f84d389cb06afd83a62187218f30f3297e Mon Sep 17 00:00:00 2001 From: shaunmower Date: Mon, 5 Oct 2026 18:50:11 +1100 Subject: [PATCH 6/9] test: stub title duration parsing in getDiscFileInfo filter test --- tests/unit/disc.service.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/disc.service.test.js b/tests/unit/disc.service.test.js index 6eb2504..940a74e 100644 --- a/tests/unit/disc.service.test.js +++ b/tests/unit/disc.service.test.js @@ -527,6 +527,10 @@ TINFO:2,9,0,"2:15:30"`; const { DiscService: Local } = await import( "../../src/services/disc.service.js" ); + const { ValidationUtils } = await import("../../src/utils/validation.js"); + ValidationUtils.getTimeInSeconds.mockImplementation((timeArray) => + +timeArray[0] * 3600 + +timeArray[1] * 60 + +timeArray[2] + ); const mockStdout = `TINFO:0,9,0,"0:22:00" TINFO:1,9,0,"2:00:00" TINFO:2,9,0,"0:23:30"`; From 2a0efa65d8c094ed3b39ff07a3052020cfc794e5 Mon Sep 17 00:00:00 2001 From: shaunmower Date: Mon, 5 Oct 2026 18:50:11 +1100 Subject: [PATCH 7/9] feat: add maximum title length to web UI config --- public/config.html | 18 ++++++++++++++++++ src/web/routes/api.routes.js | 11 +++++++++++ src/web/static/js/config.js | 4 ++++ 3 files changed, 33 insertions(+) diff --git a/public/config.html b/public/config.html index 79cdc33..4c49aba 100644 --- a/public/config.html +++ b/public/config.html @@ -337,6 +337,24 @@

🎯 Ripping Behavior

+
+ + + + Skip titles longer than this, e.g. a "play all" title on a + TV disc (leave empty for no limit) + +
+