diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1fb1e80..6a4a014 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to MakeMKV Auto Rip will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+
+- **Maximum title length** - New optional `ripping.max_title_length_minutes` setting skips titles longer than the limit, such as "play all" titles on TV discs
+ - With `rip_all_titles: true`, every title within the limit is ripped; otherwise the longest title within the limit is ripped
+ - Discs with no title within the limit are skipped
+ - Configurable from the web UI config page
+
## [1.0.0] - 2025-08-12
### Added
diff --git a/README.md b/README.md
index fe815bc..64fdcc7 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/config.yaml b/config.yaml
index 6ee46bf..335a022 100644
--- a/config.yaml
+++ b/config.yaml
@@ -40,6 +40,8 @@ mount_detection:
ripping:
# Rip all titles (over the MakeMKV minimum length) from disc instead of just the longest title (true/false)
rip_all_titles: false
+ # Optional maximum title duration in minutes; titles longer than this are skipped (uncomment to enable)
+ # max_title_length_minutes: 60
# Ripping mode - async for parallel processing, sync for sequential (async/sync)
mode: "async"
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
+
+ Maximum Title Length (minutes)
+
+
+ Skip titles longer than this, e.g. a "play all" title on a
+ TV disc (leave empty for no limit)
+
+
+
Ripping Mode
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);
+ }
});
}
diff --git a/src/web/routes/api.routes.js b/src/web/routes/api.routes.js
index e558cf6..fa0e90b 100644
--- a/src/web/routes/api.routes.js
+++ b/src/web/routes/api.routes.js
@@ -503,6 +503,17 @@ function updateYamlValues(yamlContent, config) {
}
}
+ // Comment out max_title_length_minutes if the field was cleared
+ if (
+ config.ripping &&
+ !config.ripping.hasOwnProperty("max_title_length_minutes")
+ ) {
+ updatedContent = updatedContent.replace(
+ /^(\s+)(max_title_length_minutes\s*:.*$)/m,
+ "$1# $2"
+ );
+ }
+
// Process all the config updates
processConfigObject(config);
diff --git a/src/web/static/js/config.js b/src/web/static/js/config.js
index 271e601..065287f 100644
--- a/src/web/static/js/config.js
+++ b/src/web/static/js/config.js
@@ -321,6 +321,10 @@ class ConfigEditor {
"ripping.rip_all_titles",
config.ripping?.rip_all_titles ?? false
);
+ this.setFieldValue(
+ "ripping.max_title_length_minutes",
+ config.ripping?.max_title_length_minutes ?? ""
+ );
this.setFieldValue("ripping.mode", config.ripping?.mode || "async");
// Interface section
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..940a74e 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,40 @@ TINFO:2,9,0,"2:15:30"`;
});
});
+ it("should return eligible title numbers when a maximum duration is configured", async () => {
+ 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 { 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"`;
+
+ exec.mockImplementation((command, callback) => {
+ callback(null, mockStdout, "");
+ });
+
+ const result = await Local.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 +671,54 @@ 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", 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"`;
+
+ expect(DiscService.getFileNumbers(mockOutput, 60, true)).toEqual([
+ "0",
+ "2",
+ ]);
+ });
+
+ 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"`;
+
+ 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..fd73e60 100644
--- a/tests/unit/rip.service.test.js
+++ b/tests/unit/rip.service.test.js
@@ -247,6 +247,24 @@ 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(
+ {
+ 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();