Skip to content
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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`)
Expand Down
2 changes: 2 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
18 changes: 18 additions & 0 deletions public/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,24 @@ <h2>🎯 Ripping Behavior</h2>
</small>
</div>

<div class="form-group">
<label for="max_title_length_minutes"
>Maximum Title Length (minutes)</label
>
<input
type="number"
id="max_title_length_minutes"
name="ripping.max_title_length_minutes"
min="1"
placeholder="No limit"
class="form-control"
/>
<small class="help-text">
Skip titles longer than this, e.g. a "play all" title on a
TV disc (leave empty for no limit)
</small>
</div>

<div class="form-group">
<label for="ripping_mode">Ripping Mode</label>
<select
Expand Down
6 changes: 6 additions & 0 deletions src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
70 changes: 66 additions & 4 deletions src/services/disc.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export class DiscService {
this.getDiscFileInfo(drive)
);

return Promise.all(drivePromises);
return Promise.all(drivePromises).then((discs) => discs.filter(Boolean));
}

/**
Expand Down Expand Up @@ -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}.`
Expand All @@ -334,6 +348,7 @@ export class DiscService {
driveNumber: driveInfo.driveNumber,
title: driveInfo.title,
fileNumber: fileNumber,
fileNumbers: fileNumbers || undefined,
mediaType: driveInfo.mediaType,
});
} catch (error) {
Expand Down Expand Up @@ -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<string>} - 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] : [];
}
}
75 changes: 44 additions & 31 deletions src/services/rip.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
}

Expand Down
11 changes: 11 additions & 0 deletions src/web/routes/api.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 4 additions & 0 deletions src/web/static/js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mount_detection:

ripping:
rip_all_titles: false
max_title_length_minutes: 60
mode: "async"

interface:
Expand Down
6 changes: 6 additions & 0 deletions tests/unit/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading