Bugfix and Docs
This commit is contained in:
@@ -21,6 +21,8 @@ function parseJsonSafe(raw, fallback = null) {
|
||||
const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
|
||||
const processLogStreams = new Map();
|
||||
const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'other'];
|
||||
const RAW_INCOMPLETE_PREFIX = 'Incomplete_';
|
||||
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
|
||||
|
||||
function inspectDirectory(dirPath) {
|
||||
if (!dirPath) {
|
||||
@@ -430,9 +432,29 @@ function normalizeComparablePath(inputPath) {
|
||||
return resolveSafe(String(inputPath || '')).replace(/[\\/]+$/, '');
|
||||
}
|
||||
|
||||
function stripRawFolderStatePrefix(folderName) {
|
||||
const rawName = String(folderName || '').trim();
|
||||
if (!rawName) {
|
||||
return '';
|
||||
}
|
||||
return rawName
|
||||
.replace(new RegExp(`^${RAW_INCOMPLETE_PREFIX}`, 'i'), '')
|
||||
.replace(new RegExp(`^${RAW_RIP_COMPLETE_PREFIX}`, 'i'), '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function applyRawFolderPrefix(folderName, prefix = '') {
|
||||
const normalized = stripRawFolderStatePrefix(folderName);
|
||||
if (!normalized) {
|
||||
return normalized;
|
||||
}
|
||||
const safePrefix = String(prefix || '').trim();
|
||||
return safePrefix ? `${safePrefix}${normalized}` : normalized;
|
||||
}
|
||||
|
||||
function parseRawFolderMetadata(folderName) {
|
||||
const rawName = String(folderName || '').trim();
|
||||
const normalizedRawName = rawName.replace(/^Incomplete_/i, '').trim();
|
||||
const normalizedRawName = stripRawFolderStatePrefix(rawName);
|
||||
const folderJobIdMatch = normalizedRawName.match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i);
|
||||
const folderJobId = folderJobIdMatch ? Number(folderJobIdMatch[1]) : null;
|
||||
let working = normalizedRawName.replace(/\s*-\s*RAW\s*-\s*job-\d+\s*$/i, '').trim();
|
||||
@@ -1053,6 +1075,7 @@ class HistoryService {
|
||||
detectedTitle: effectiveTitle
|
||||
});
|
||||
|
||||
const renameSteps = [];
|
||||
let finalRawPath = absRawPath;
|
||||
const renamedRawPath = buildRawPathForJobId(absRawPath, created.id);
|
||||
const shouldRenameRawFolder = normalizeComparablePath(renamedRawPath) !== absRawPath;
|
||||
@@ -1067,6 +1090,7 @@ class HistoryService {
|
||||
try {
|
||||
fs.renameSync(absRawPath, renamedRawPath);
|
||||
finalRawPath = normalizeComparablePath(renamedRawPath);
|
||||
renameSteps.push({ from: absRawPath, to: finalRawPath });
|
||||
} catch (error) {
|
||||
await db.run('DELETE FROM jobs WHERE id = ?', [created.id]);
|
||||
const wrapped = new Error(`RAW-Ordner konnte nicht auf neue Job-ID umbenannt werden: ${error.message}`);
|
||||
@@ -1075,6 +1099,30 @@ class HistoryService {
|
||||
}
|
||||
}
|
||||
|
||||
const ripCompleteFolderName = applyRawFolderPrefix(path.basename(finalRawPath), RAW_RIP_COMPLETE_PREFIX);
|
||||
const ripCompleteRawPath = path.join(path.dirname(finalRawPath), ripCompleteFolderName);
|
||||
const shouldMarkRipComplete = normalizeComparablePath(ripCompleteRawPath) !== normalizeComparablePath(finalRawPath);
|
||||
if (shouldMarkRipComplete) {
|
||||
if (fs.existsSync(ripCompleteRawPath)) {
|
||||
await db.run('DELETE FROM jobs WHERE id = ?', [created.id]);
|
||||
const error = new Error(`RAW-Ordner für Rip_Complete-Zustand existiert bereits: ${ripCompleteRawPath}`);
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const previousRawPath = finalRawPath;
|
||||
fs.renameSync(previousRawPath, ripCompleteRawPath);
|
||||
finalRawPath = normalizeComparablePath(ripCompleteRawPath);
|
||||
renameSteps.push({ from: previousRawPath, to: finalRawPath });
|
||||
} catch (error) {
|
||||
await db.run('DELETE FROM jobs WHERE id = ?', [created.id]);
|
||||
const wrapped = new Error(`RAW-Ordner konnte nicht als Rip_Complete markiert werden: ${error.message}`);
|
||||
wrapped.statusCode = 500;
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateJob(created.id, {
|
||||
status: 'FINISHED',
|
||||
last_state: 'FINISHED',
|
||||
@@ -1105,8 +1153,8 @@ class HistoryService {
|
||||
await this.appendLog(
|
||||
created.id,
|
||||
'SYSTEM',
|
||||
shouldRenameRawFolder
|
||||
? `Historieneintrag aus RAW erstellt. Ordner umbenannt: ${absRawPath} -> ${finalRawPath}`
|
||||
renameSteps.length > 0
|
||||
? `Historieneintrag aus RAW erstellt. Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}`
|
||||
: `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath}`
|
||||
);
|
||||
if (metadata.imdbId) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -716,7 +716,16 @@ class SettingsService {
|
||||
options?.mediaProfile || deviceInfo?.mediaProfile || null
|
||||
);
|
||||
const cmd = map.makemkv_command;
|
||||
const args = ['-r', 'info', this.resolveSourceArg(map, deviceInfo), ...splitArgs(map.makemkv_analyze_extra_args)];
|
||||
const extraArgs = splitArgs(map.makemkv_analyze_extra_args);
|
||||
const hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim()));
|
||||
const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0);
|
||||
const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0
|
||||
? Math.round(minLengthMinutes * 60)
|
||||
: 0;
|
||||
const minLengthArgs = (!hasExplicitMinLength && minLengthSeconds > 0)
|
||||
? [`--minlength=${minLengthSeconds}`]
|
||||
: [];
|
||||
const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', this.resolveSourceArg(map, deviceInfo)];
|
||||
logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo });
|
||||
return { cmd, args };
|
||||
}
|
||||
@@ -726,7 +735,16 @@ class SettingsService {
|
||||
const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null);
|
||||
const cmd = map.makemkv_command;
|
||||
const sourceArg = `file:${sourcePath}`;
|
||||
const args = ['-r', 'info', sourceArg, ...splitArgs(map.makemkv_analyze_extra_args)];
|
||||
const extraArgs = splitArgs(map.makemkv_analyze_extra_args);
|
||||
const hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim()));
|
||||
const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0);
|
||||
const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0
|
||||
? Math.round(minLengthMinutes * 60)
|
||||
: 0;
|
||||
const minLengthArgs = (!hasExplicitMinLength && minLengthSeconds > 0)
|
||||
? [`--minlength=${minLengthSeconds}`]
|
||||
: [];
|
||||
const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', sourceArg];
|
||||
const titleIdRaw = Number(options?.titleId);
|
||||
// "makemkvcon info" supports only <source>; title filtering is done in app parser.
|
||||
logger.debug('cli:makemkv:analyze:path', {
|
||||
|
||||
@@ -444,7 +444,9 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
|
||||
explicitIds: [],
|
||||
firstOnly: baseSubtitleMode === 'first',
|
||||
selectionSource: profile.source === 'preset-export' ? 'preset' : 'default',
|
||||
burnBehavior: normalizeBurnBehavior(profile.subtitleBurnBehavior),
|
||||
// Do not auto-burn subtitle tracks from exported preset metadata.
|
||||
// Burn-in should only be activated via explicit CLI args/selection.
|
||||
burnBehavior: 'none',
|
||||
burnedTrackId: null,
|
||||
defaultTrackId: null,
|
||||
forcedTrackId: null,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const LARGE_JUMP_THRESHOLD = 20;
|
||||
const DEFAULT_DURATION_SIMILARITY_SECONDS = 90;
|
||||
const RAW_MIRROR_DURATION_TOLERANCE_SECONDS = 2;
|
||||
const RAW_MIRROR_SIZE_TOLERANCE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
function parseDurationSeconds(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
@@ -151,6 +153,7 @@ function parseAnalyzeTitles(lines) {
|
||||
chapters: 0,
|
||||
segmentNumbers: [],
|
||||
segmentFiles: [],
|
||||
streams: {},
|
||||
fields: {}
|
||||
});
|
||||
}
|
||||
@@ -164,6 +167,57 @@ function parseAnalyzeTitles(lines) {
|
||||
title.playlistIdFromMap = normalizePlaylistId(mapping.playlistId);
|
||||
}
|
||||
|
||||
const sinfo = String(line || '').match(/^SINFO:(\d+),(\d+),(\d+),\d+,"([^"]*)"/i);
|
||||
if (sinfo) {
|
||||
const titleId = Number(sinfo[1]);
|
||||
const streamIndex = Number(sinfo[2]);
|
||||
const fieldId = Number(sinfo[3]);
|
||||
const value = String(sinfo[4] || '').trim();
|
||||
if (
|
||||
Number.isFinite(titleId) && titleId >= 0
|
||||
&& Number.isFinite(streamIndex) && streamIndex >= 0
|
||||
&& Number.isFinite(fieldId)
|
||||
) {
|
||||
const title = ensureTitle(titleId);
|
||||
const streamKey = String(Math.trunc(streamIndex));
|
||||
if (!title.streams[streamKey]) {
|
||||
title.streams[streamKey] = {
|
||||
index: Math.trunc(streamIndex),
|
||||
type: null,
|
||||
language: null,
|
||||
languageLabel: null,
|
||||
format: null,
|
||||
channels: null,
|
||||
description: null
|
||||
};
|
||||
}
|
||||
const stream = title.streams[streamKey];
|
||||
if (fieldId === 1) {
|
||||
const lowered = value.toLowerCase();
|
||||
if (lowered.includes('audio')) {
|
||||
stream.type = 'audio';
|
||||
} else if (lowered.includes('subtitle') || lowered.includes('untertitel') || lowered.includes('text')) {
|
||||
stream.type = 'subtitle';
|
||||
}
|
||||
} else if (fieldId === 3) {
|
||||
stream.language = value ? value.toLowerCase() : null;
|
||||
} else if (fieldId === 4) {
|
||||
stream.languageLabel = value || null;
|
||||
} else if (fieldId === 6 || fieldId === 7) {
|
||||
if (!stream.format || fieldId === 6) {
|
||||
stream.format = value || null;
|
||||
}
|
||||
} else if (fieldId === 14 || fieldId === 40) {
|
||||
if (!stream.channels || fieldId === 40) {
|
||||
stream.channels = value || null;
|
||||
}
|
||||
} else if (fieldId === 30) {
|
||||
stream.description = value || null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const tinfo = String(line || '').match(/^TINFO:(\d+),(\d+),\d+,"([^"]*)"/i);
|
||||
if (!tinfo) {
|
||||
continue;
|
||||
@@ -242,20 +296,64 @@ function parseAnalyzeTitles(lines) {
|
||||
const playlistId = normalizePlaylistId(item.playlistId);
|
||||
const playlistIdFromMap = normalizePlaylistId(item.playlistIdFromMap);
|
||||
const playlistIdFromField16 = normalizePlaylistId(item.playlistIdFromField16);
|
||||
// Prefer explicit title<->playlist map lines from MakeMKV (MSG:3016).
|
||||
const resolvedPlaylistId = playlistIdFromMap || playlistIdFromField16 || playlistId;
|
||||
const field16Raw = String(item?.fields?.[16] || '').trim();
|
||||
const hasField16 = field16Raw.length > 0;
|
||||
const field16LooksPlaylist = /\.mpls$/i.test(field16Raw) || /^\d{1,5}$/i.test(field16Raw);
|
||||
const field16LooksClip = /\.(?:m2ts|m2t|mts)$/i.test(field16Raw);
|
||||
let resolvedPlaylistId = null;
|
||||
|
||||
// TINFO:16 is part of the final title block and is more reliable than MSG:3307
|
||||
// lines, which can include pre-dedup title ids.
|
||||
if (field16LooksPlaylist && playlistIdFromField16) {
|
||||
resolvedPlaylistId = playlistIdFromField16;
|
||||
} else if (!hasField16) {
|
||||
resolvedPlaylistId = playlistIdFromField16 || playlistIdFromMap || playlistId;
|
||||
} else if (!field16LooksClip && playlistIdFromField16) {
|
||||
resolvedPlaylistId = playlistIdFromField16;
|
||||
}
|
||||
const segmentNumbers = Array.isArray(item.segmentNumbers) ? item.segmentNumbers : [];
|
||||
const segmentFiles = segmentNumbers
|
||||
.map((number) => toSegmentFile(number))
|
||||
.filter(Boolean);
|
||||
const streams = item?.streams && typeof item.streams === 'object' ? Object.values(item.streams) : [];
|
||||
const sortedStreams = streams
|
||||
.filter((stream) => Number.isFinite(Number(stream?.index)))
|
||||
.sort((a, b) => Number(a.index) - Number(b.index));
|
||||
const audioTracks = sortedStreams
|
||||
.filter((stream) => String(stream?.type || '').toLowerCase() === 'audio')
|
||||
.map((stream) => ({
|
||||
id: Number(stream.index) + 1,
|
||||
sourceTrackId: Number(stream.index) + 1,
|
||||
language: stream.language || 'und',
|
||||
languageLabel: stream.languageLabel || stream.language || 'und',
|
||||
title: stream.description || null,
|
||||
format: stream.format || null,
|
||||
channels: stream.channels || null
|
||||
}));
|
||||
const subtitleTracks = sortedStreams
|
||||
.filter((stream) => String(stream?.type || '').toLowerCase() === 'subtitle')
|
||||
.map((stream) => ({
|
||||
id: Number(stream.index) + 1,
|
||||
sourceTrackId: Number(stream.index) + 1,
|
||||
language: stream.language || 'und',
|
||||
languageLabel: stream.languageLabel || stream.language || 'und',
|
||||
title: stream.description || null,
|
||||
format: stream.format || null,
|
||||
channels: null
|
||||
}));
|
||||
|
||||
const { streams: _omitStreams, ...restItem } = item;
|
||||
return {
|
||||
...item,
|
||||
...restItem,
|
||||
playlistId: resolvedPlaylistId,
|
||||
playlistIdFromMap,
|
||||
playlistIdFromField16,
|
||||
playlistFile: resolvedPlaylistId ? `${resolvedPlaylistId}.mpls` : null,
|
||||
durationLabel: item.durationLabel || formatDuration(item.durationSeconds),
|
||||
audioTracks,
|
||||
subtitleTracks,
|
||||
audioTrackCount: audioTracks.length,
|
||||
subtitleTrackCount: subtitleTracks.length,
|
||||
segmentNumbers,
|
||||
segmentFiles
|
||||
};
|
||||
@@ -277,6 +375,58 @@ function uniqueOrdered(values) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseReportedTitleCount(lines) {
|
||||
for (let index = (Array.isArray(lines) ? lines.length : 0) - 1; index >= 0; index -= 1) {
|
||||
const line = String(lines[index] || '').trim();
|
||||
const match = line.match(/^TCOUNT:(\d+)/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const value = Number(match[1]);
|
||||
if (Number.isFinite(value) && value >= 0) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function likelyRawMirrorOfPlaylist(rawTitle, playlistTitle) {
|
||||
const rawDuration = Number(rawTitle?.durationSeconds || 0);
|
||||
const playlistDuration = Number(playlistTitle?.durationSeconds || 0);
|
||||
const rawSize = Number(rawTitle?.sizeBytes || 0);
|
||||
const playlistSize = Number(playlistTitle?.sizeBytes || 0);
|
||||
if (!Number.isFinite(rawDuration) || !Number.isFinite(playlistDuration) || rawDuration <= 0 || playlistDuration <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(rawDuration - playlistDuration) > RAW_MIRROR_DURATION_TOLERANCE_SECONDS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rawSize > 0 && playlistSize > 0) {
|
||||
return Math.abs(rawSize - playlistSize) <= RAW_MIRROR_SIZE_TOLERANCE_BYTES;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function suppressRawMirrorCandidates(candidates) {
|
||||
const rows = Array.isArray(candidates) ? candidates : [];
|
||||
if (rows.length <= 1) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const playlistRows = rows.filter((item) => normalizePlaylistId(item?.playlistId));
|
||||
if (playlistRows.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
return rows.filter((item) => {
|
||||
if (normalizePlaylistId(item?.playlistId)) {
|
||||
return true;
|
||||
}
|
||||
return !playlistRows.some((playlistRow) => likelyRawMirrorOfPlaylist(item, playlistRow));
|
||||
});
|
||||
}
|
||||
|
||||
function buildSimilarityGroups(candidates, durationSimilaritySeconds) {
|
||||
const list = Array.isArray(candidates) ? [...candidates] : [];
|
||||
const tolerance = Math.max(0, Math.round(Number(durationSimilaritySeconds || 0)));
|
||||
@@ -506,37 +656,45 @@ function extractPlaylistMismatchWarnings(titles) {
|
||||
.filter((title) => String(title.playlistIdFromMap) !== String(title.playlistIdFromField16))
|
||||
.slice(0, 25)
|
||||
.map((title) =>
|
||||
`Titel #${title.titleId}: MSG-Playlist=${title.playlistIdFromMap}.mpls, TINFO16=${title.playlistIdFromField16}.mpls (MSG bevorzugt)`
|
||||
`Titel #${title.titleId}: MSG-Playlist=${title.playlistIdFromMap}.mpls, TINFO16=${title.playlistIdFromField16}.mpls (TINFO16 bevorzugt)`
|
||||
);
|
||||
}
|
||||
|
||||
function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) {
|
||||
const parsedTitles = parseAnalyzeTitles(lines);
|
||||
const reportedTitleCount = parseReportedTitleCount(lines);
|
||||
const minSeconds = Math.max(0, Math.round(Number(minLengthMinutes || 0) * 60));
|
||||
const durationSimilaritySeconds = Math.max(
|
||||
0,
|
||||
Math.round(Number(options.durationSimilaritySeconds || DEFAULT_DURATION_SIMILARITY_SECONDS))
|
||||
);
|
||||
|
||||
const candidates = parsedTitles
|
||||
const candidatesRaw = parsedTitles
|
||||
.filter((item) => Number(item.durationSeconds || 0) >= minSeconds)
|
||||
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
|
||||
const candidates = suppressRawMirrorCandidates(candidatesRaw)
|
||||
.slice()
|
||||
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
|
||||
const playlistBackedCandidates = candidates
|
||||
.filter((item) => normalizePlaylistId(item?.playlistId));
|
||||
const candidatePlaylistsAll = uniqueOrdered(
|
||||
playlistBackedCandidates.map((item) => item.playlistId).filter(Boolean)
|
||||
);
|
||||
|
||||
const similarityGroups = buildSimilarityGroups(candidates, durationSimilaritySeconds);
|
||||
const similarityGroups = buildSimilarityGroups(playlistBackedCandidates, durationSimilaritySeconds);
|
||||
const obfuscationDetected = similarityGroups.length > 0;
|
||||
const multipleCandidatesDetected = candidates.length > 1;
|
||||
const multipleCandidatesDetected = candidatePlaylistsAll.length > 1;
|
||||
const manualDecisionRequired = multipleCandidatesDetected;
|
||||
const decisionPool = manualDecisionRequired ? candidates : [];
|
||||
const decisionPool = manualDecisionRequired ? playlistBackedCandidates : [];
|
||||
const evaluatedCandidates = decisionPool.length > 0 ? scoreCandidates(decisionPool) : [];
|
||||
const recommendation = evaluatedCandidates[0] || null;
|
||||
const candidatePlaylists = manualDecisionRequired
|
||||
? uniqueOrdered(decisionPool.map((item) => item.playlistId).filter(Boolean))
|
||||
: [];
|
||||
const candidatePlaylists = manualDecisionRequired ? candidatePlaylistsAll : [];
|
||||
const playlistSegments = buildPlaylistSegmentMap(decisionPool);
|
||||
const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitles);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
reportedTitleCount,
|
||||
minLengthMinutes: Number(minLengthMinutes || 0),
|
||||
minLengthSeconds: minSeconds,
|
||||
durationSimilaritySeconds,
|
||||
@@ -570,6 +728,9 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {})
|
||||
},
|
||||
warningLines: [
|
||||
...extractWarningLines(lines),
|
||||
...(reportedTitleCount !== null && reportedTitleCount !== parsedTitles.length
|
||||
? [`Titel-Anzahl abweichend: TCOUNT=${reportedTitleCount}, geparst=${parsedTitles.length}`]
|
||||
: []),
|
||||
...extractPlaylistMismatchWarnings(parsedTitles)
|
||||
].slice(0, 60)
|
||||
};
|
||||
|
||||
@@ -33,12 +33,12 @@ function parseMakeMkvProgress(line) {
|
||||
const prgv = line.match(/PRGV:(\d+),(\d+),(\d+)/);
|
||||
if (prgv) {
|
||||
// Format: PRGV:current,total,max (official makemkv docs)
|
||||
// progress = current / max
|
||||
const current = Number(prgv[1]);
|
||||
// current = per-file progress, total = overall progress across all files
|
||||
const total = Number(prgv[2]);
|
||||
const max = Number(prgv[3]);
|
||||
|
||||
if (max > 0) {
|
||||
return { percent: clampPercent((current / max) * 100), eta: null };
|
||||
return { percent: clampPercent((total / max) * 100), eta: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user