pre release

This commit is contained in:
2026-03-10 20:47:56 +00:00
parent 5703a8d00a
commit b93f5da8d7
4 changed files with 213 additions and 68 deletions

View File

@@ -99,6 +99,9 @@ router.post(
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds;
const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds;
const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds;
const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds;
const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate);
const selectedUserPresetId = req.body?.selectedUserPresetId ?? null;
logger.info('post:confirm-encode', {
@@ -110,12 +113,24 @@ router.post(
selectedUserPresetId,
selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds)
? selectedPostEncodeScriptIds.length
: 0,
selectedPreEncodeScriptIdsCount: Array.isArray(selectedPreEncodeScriptIds)
? selectedPreEncodeScriptIds.length
: 0,
selectedPostEncodeChainIdsCount: Array.isArray(selectedPostEncodeChainIds)
? selectedPostEncodeChainIds.length
: 0,
selectedPreEncodeChainIdsCount: Array.isArray(selectedPreEncodeChainIds)
? selectedPreEncodeChainIds.length
: 0
});
const job = await pipelineService.confirmEncodeReview(jobId, {
selectedEncodeTitleId,
selectedTrackSelection,
selectedPostEncodeScriptIds,
selectedPreEncodeScriptIds,
selectedPostEncodeChainIds,
selectedPreEncodeChainIds,
skipPipelineStateUpdate,
selectedUserPresetId
});

View File

@@ -98,8 +98,12 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
}
if (fstype.includes('udf')) {
// UDF is used by both DVDs (UDF 1.x) and Blu-rays (UDF 2.x).
// Drive model alone (hasBlurayModelMarker) is not reliable: a BD-ROM drive
// with a DVD inside would incorrectly be detected as Blu-ray.
// Return null so UDF version detection via blkid can decide.
if (hasBlurayModelMarker) {
return 'bluray';
return null;
}
if (hasDvdModelMarker) {
return 'dvd';
@@ -108,9 +112,8 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
}
if (fstype.includes('iso9660') || fstype.includes('cdfs')) {
if (hasBlurayModelMarker) {
return 'bluray';
}
// iso9660/cdfs is never used by Blu-ray discs (they use UDF 2.x).
// Ignore hasBlurayModelMarker it only reflects drive capability.
if (hasCdOnlyModelMarker) {
return 'other';
}
@@ -120,6 +123,30 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
return null;
}
function inferMediaProfileFromUdevProperties(properties = {}) {
const flags = Object.entries(properties).reduce((acc, [key, rawValue]) => {
const normalizedKey = String(key || '').trim().toUpperCase();
if (!normalizedKey) {
return acc;
}
acc[normalizedKey] = String(rawValue || '').trim();
return acc;
}, {});
const hasFlag = (prefix) => Object.entries(flags).some(([key, value]) => key.startsWith(prefix) && value === '1');
if (hasFlag('ID_CDROM_MEDIA_BD')) {
return 'bluray';
}
if (hasFlag('ID_CDROM_MEDIA_DVD')) {
return 'dvd';
}
if (hasFlag('ID_CDROM_MEDIA_CD')) {
return 'other';
}
return null;
}
class DiskDetectionService extends EventEmitter {
constructor() {
super();
@@ -496,31 +523,60 @@ class DiskDetectionService extends EventEmitter {
}
}
async inferMediaProfileFromUdev(devicePath) {
const normalizedPath = String(devicePath || '').trim();
if (!normalizedPath) {
return null;
}
try {
const { stdout } = await execFileAsync('udevadm', ['info', '--query=property', '--name', normalizedPath]);
const properties = {};
for (const line of String(stdout || '').split(/\r?\n/)) {
const idx = line.indexOf('=');
if (idx <= 0) {
continue;
}
const key = String(line.slice(0, idx)).trim();
const value = String(line.slice(idx + 1)).trim();
if (!key) {
continue;
}
properties[key] = value;
}
const inferred = inferMediaProfileFromUdevProperties(properties);
if (inferred) {
logger.debug('udev:media-profile', { devicePath: normalizedPath, inferred });
}
return inferred;
} catch (error) {
logger.debug('udev:media-profile:failed', {
devicePath: normalizedPath,
error: errorToMeta(error)
});
return null;
}
}
async inferMediaProfile(devicePath, hints = {}) {
const explicit = normalizeMediaProfile(hints?.mediaProfile);
if (isSpecificMediaProfile(explicit)) {
return explicit;
}
// Only pass disc-specific fields NOT hints?.model (drive model).
// Drive model (e.g. "BD-ROM") reflects drive capability, not disc type.
// A BD-ROM drive with a DVD would otherwise be detected as Blu-ray here.
const hinted = inferMediaProfileFromTextParts([
hints?.discLabel,
hints?.label,
hints?.fstype,
hints?.model
]);
if (hinted) {
return hinted;
}
const hintFstype = String(hints?.fstype || '').trim().toLowerCase();
const byFsTypeHint = inferMediaProfileFromFsTypeAndModel(hints?.fstype, hints?.model);
// UDF is used for both Blu-ray (UDF 2.x) and DVD (UDF 1.x). Without a clear model
// marker identifying it as Blu-ray, a 'dvd' result from UDF is ambiguous. Skip the
// early return and fall through to the blkid check which uses the UDF version number.
if (byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) {
return byFsTypeHint;
}
const mountpoint = String(hints?.mountpoint || '').trim();
if (mountpoint) {
try {
@@ -539,8 +595,25 @@ class DiskDetectionService extends EventEmitter {
}
}
const byUdev = await this.inferMediaProfileFromUdev(devicePath);
if (byUdev) {
return byUdev;
}
const hintFstype = String(hints?.fstype || '').trim().toLowerCase();
const byFsTypeHint = inferMediaProfileFromFsTypeAndModel(hints?.fstype, hints?.model);
const udfHintFallback = hintFstype.includes('udf')
? inferMediaProfileFromFsTypeAndModel(hints?.fstype, null)
: null;
// UDF is used for both Blu-ray (UDF 2.x) and DVD (UDF 1.x). Without a clear model
// marker identifying it as Blu-ray, a 'dvd' result from UDF is ambiguous. Skip the
// early return and fall through to the blkid check which uses the UDF version number.
if (byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) {
return byFsTypeHint;
}
try {
const { stdout } = await execFileAsync('blkid', ['-o', 'export', devicePath]);
const { stdout } = await execFileAsync('blkid', ['-p', '-o', 'export', devicePath]);
const payload = {};
for (const line of String(stdout || '').split(/\r?\n/)) {
const idx = line.indexOf('=');
@@ -555,28 +628,40 @@ class DiskDetectionService extends EventEmitter {
payload[key] = value;
}
// APPLICATION_ID contains disc-specific strings (e.g. "BDAV"/"BDMV" for Blu-ray,
// "DVD_VIDEO" for DVD). Drive model is excluded see reasoning above.
const byBlkidMarker = inferMediaProfileFromTextParts([
payload.LABEL,
payload.TYPE,
payload.VERSION,
payload.APPLICATION_ID,
hints?.model
]);
if (byBlkidMarker) {
return byBlkidMarker;
}
const type = String(payload.TYPE || '').trim().toLowerCase();
// For UDF, VERSION is the most reliable discriminator: 1.x → DVD, 2.x → Blu-ray.
// This check must run independently of inferMediaProfileFromFsTypeAndModel so it
// is not skipped when the drive model returns null (BD-ROM drive with DVD inside).
if (type.includes('udf')) {
const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.'));
if (Number.isFinite(version)) {
return version >= 2 ? 'bluray' : 'dvd';
}
}
const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model);
if (byBlkidFsType) {
if (type.includes('udf')) {
const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.'));
if (Number.isFinite(version)) {
return version >= 2 ? 'bluray' : 'dvd';
}
}
return byBlkidFsType;
}
// Last resort for drives that only expose TYPE=udf without VERSION/APPLICATION_ID:
// prefer DVD over "other" so DVDs in BD-capable drives do not fall back to Misc.
const byBlkidFsTypeWithoutModel = inferMediaProfileFromFsTypeAndModel(type, null);
if (byBlkidFsTypeWithoutModel) {
return byBlkidFsTypeWithoutModel;
}
} catch (error) {
logger.debug('infer-media-profile:blkid-failed', {
devicePath,
@@ -584,7 +669,11 @@ class DiskDetectionService extends EventEmitter {
});
}
return explicit === 'other' ? 'other' : null;
if (udfHintFallback) {
return udfHintFallback;
}
return 'other';
}
guessDiscIndex(name) {

View File

@@ -128,8 +128,12 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
}
if (fstype.includes('udf')) {
// UDF is used by both DVDs (UDF 1.02) and Blu-rays (UDF 2.5/2.6).
// Drive model alone (hasBlurayModelMarker) is not reliable: a BD-ROM drive
// with a DVD inside would incorrectly be detected as Blu-ray.
// Return null so the mountpoint BDMV/VIDEO_TS check can decide.
if (hasBlurayModelMarker) {
return 'bluray';
return null;
}
if (hasDvdModelMarker) {
return 'dvd';
@@ -138,9 +142,8 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
}
if (fstype.includes('iso9660') || fstype.includes('cdfs')) {
if (hasBlurayModelMarker) {
return 'bluray';
}
// iso9660/cdfs is never used by Blu-ray discs (they use UDF 2.5/2.6).
// Ignore hasBlurayModelMarker here it only reflects drive capability.
if (hasCdOnlyModelMarker) {
return 'other';
}
@@ -266,20 +269,22 @@ function inferMediaProfileFromDeviceInfo(deviceInfo = null) {
return explicit;
}
const markerText = [
// Only use disc-specific fields for keyword detection, NOT device.model.
// The drive model describes drive capability (e.g. "BD-ROM"), not disc type.
// A BD-ROM drive with a DVD inserted would otherwise be misdetected as Blu-ray.
const discMarkerText = [
device.discLabel,
device.label,
device.fstype,
device.model
]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ');
if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(markerText)) {
if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(discMarkerText)) {
return 'bluray';
}
if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(markerText)) {
if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(discMarkerText)) {
return 'dvd';
}
@@ -1166,10 +1171,14 @@ function parseMakeMkvDurationSeconds(rawValue) {
}
function buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo) {
const durationSeconds = Math.max(0, Math.trunc(Number(titleInfo?.durationSeconds || 0)));
const tracks = [];
tracks.push({
'@type': 'General',
Duration: String(Number(titleInfo?.durationSeconds || 0))
// MediaInfo reports numeric Duration as milliseconds. Keep this format so
// parseDurationSeconds() does not misinterpret long titles.
Duration: String(durationSeconds * 1000),
Duration_String3: formatDurationClock(durationSeconds) || null
});
const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : [];
@@ -3903,10 +3912,17 @@ class PipelineService extends EventEmitter {
eta: patch.eta ?? null,
statusText: patch.statusText ?? null
});
} else if (patch.activeJobId === null && previousActiveJobId != null) {
} else if (patch.activeJobId === null) {
// Job slot cleared remove the finished job's live entry so it falls
// back to DB data in the frontend.
this.jobProgress.delete(Number(previousActiveJobId));
// Use patch.finishingJobId when provided (parallel-safe); fall back to
// previousActiveJobId only when no parallel job has overwritten the slot.
const finishingJobId = patch.finishingJobId != null
? Number(patch.finishingJobId)
: (previousActiveJobId != null ? Number(previousActiveJobId) : null);
if (finishingJobId != null) {
this.jobProgress.delete(finishingJobId);
}
}
logger.info('state:changed', {
from: previous,
@@ -4917,7 +4933,8 @@ class PipelineService extends EventEmitter {
'MEDIAINFO_CHECK',
25,
null,
'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet'
'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet',
jobId
);
try {
const resolveScanLines = [];
@@ -5169,16 +5186,15 @@ class PipelineService extends EventEmitter {
);
}
if (this.isPrimaryJob(jobId)) {
await this.updateProgress(
'MEDIAINFO_CHECK',
30,
null,
hasCachedHandBrakeEntry
? `HandBrake Trackdaten aus Cache (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})`
: `HandBrake Titel-/Spurscan läuft (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})`
);
}
await this.updateProgress(
'MEDIAINFO_CHECK',
30,
null,
hasCachedHandBrakeEntry
? `HandBrake Trackdaten aus Cache (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})`
: `HandBrake Titel-/Spurscan läuft (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})`,
jobId
);
let handBrakeResolveRunInfo = null;
let handBrakeTitleRunInfo = null;
@@ -5371,6 +5387,8 @@ class PipelineService extends EventEmitter {
review = remapReviewTrackIdsToSourceIds(review);
const resolvedPlaylistInfo = resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId);
const minLengthMinutesForReview = Number(review?.minLengthMinutes ?? settings?.makemkv_min_length_minutes ?? 0);
const minLengthSecondsForReview = Math.max(0, Math.round(minLengthMinutesForReview * 60));
const subtitleTrackMetaBySourceId = new Map(
(Array.isArray(reviewTitleInfo?.subtitleTracks) ? reviewTitleInfo.subtitleTracks : [])
.map((track) => {
@@ -5382,6 +5400,8 @@ class PipelineService extends EventEmitter {
const normalizedTitles = (Array.isArray(review.titles) ? review.titles : [])
.slice(0, 1)
.map((title) => {
const durationSeconds = Number(reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0);
const eligibleForEncode = durationSeconds >= minLengthSecondsForReview;
const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null;
@@ -5403,8 +5423,10 @@ class PipelineService extends EventEmitter {
...title,
filePath: rawPath,
fileName: reviewTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`,
durationSeconds: Number(reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0),
durationMinutes: Number((((reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0) / 60)).toFixed(2)),
durationSeconds,
durationMinutes: Number(((durationSeconds / 60)).toFixed(2)),
selectedByMinLength: eligibleForEncode,
eligibleForEncode,
sizeBytes: Number(reviewTitleInfo?.sizeBytes || title?.sizeBytes || 0),
playlistId: resolvedPlaylistInfo.playlistId || title?.playlistId || null,
playlistFile: resolvedPlaylistInfo.playlistFile || title?.playlistFile || null,
@@ -6559,7 +6581,7 @@ class PipelineService extends EventEmitter {
for (let i = 0; i < mediaFiles.length; i += 1) {
const file = mediaFiles[i];
const percent = Number((((i + 1) / mediaFiles.length) * 100).toFixed(2));
await this.updateProgress('MEDIAINFO_CHECK', percent, null, `Mediainfo ${i + 1}/${mediaFiles.length}: ${path.basename(file.path)}`);
await this.updateProgress('MEDIAINFO_CHECK', percent, null, `Mediainfo ${i + 1}/${mediaFiles.length}: ${path.basename(file.path)}`, jobId);
const result = await this.runMediainfoForFile(jobId, file.path, {
mediaProfile,
@@ -7479,6 +7501,7 @@ class PipelineService extends EventEmitter {
setTimeout(async () => {
if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) {
await this.setState('IDLE', {
finishingJobId: jobId,
activeJobId: null,
progress: 0,
eta: null,