0.16.1-1 release snapshot

This commit is contained in:
2026-04-29 08:35:24 +00:00
parent 6115090da1
commit 901a3b1ad7
269 changed files with 127336 additions and 1 deletions
+57
View File
@@ -0,0 +1,57 @@
function splitArgs(input) {
if (!input || typeof input !== 'string') {
return [];
}
const args = [];
let current = '';
let quote = null;
let escaping = false;
for (const ch of input) {
if (escaping) {
current += ch;
escaping = false;
continue;
}
if (ch === '\\') {
escaping = true;
continue;
}
if (quote) {
if (ch === quote) {
quote = null;
} else {
current += ch;
}
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current.length > 0) {
args.push(current);
current = '';
}
continue;
}
current += ch;
}
if (current.length > 0) {
args.push(current);
}
return args;
}
module.exports = {
splitArgs
};
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
function errorToMeta(error) {
if (!error) {
return {};
}
return {
name: error.name,
message: error.message,
stack: error.stack,
code: error.code,
signal: error.signal,
statusCode: error.statusCode
};
}
module.exports = {
errorToMeta
};
+146
View File
@@ -0,0 +1,146 @@
const fs = require('fs');
const path = require('path');
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function transliterateForFilename(input) {
return String(input || '')
// German — must come before NFD to get ae/oe/ue instead of just a/o/u
.replace(/ä/g, 'ae').replace(/Ä/g, 'Ae')
.replace(/ö/g, 'oe').replace(/Ö/g, 'Oe')
.replace(/ü/g, 'ue').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
// Danish / Norwegian / Swedish
.replace(/å/g, 'a').replace(/Å/g, 'A')
.replace(/æ/g, 'ae').replace(/Æ/g, 'Ae')
.replace(/ø/g, 'o').replace(/Ø/g, 'O')
// Icelandic
.replace(/ð/g, 'd').replace(/Ð/g, 'D')
.replace(/þ/g, 'th').replace(/Þ/g, 'Th')
// NFD decomposition + strip combining diacritical marks (handles é→e, ñ→n, ç→c, etc.)
.normalize('NFD')
.replace(/\p{M}+/gu, '')
// Drop any remaining non-ASCII characters
.replace(/[^\x00-\x7F]/g, '');
}
function sanitizeFileName(input) {
return transliterateForFilename(String(input || 'untitled'))
.replace(/[^a-zA-Z0-9 ()[\]_+-]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180);
}
function sanitizeFileNameWithExtension(input) {
const str = String(input || 'untitled');
const ext = path.extname(str);
const base = ext ? str.slice(0, -ext.length) : str;
const cleanBase = sanitizeFileName(base);
const cleanExt = ext.toLowerCase().replace(/[^a-z0-9.]/g, '');
return (cleanBase || 'untitled') + cleanExt;
}
function renderTemplate(template, values) {
const rawSource = String(template || '${title} (${year})');
const parseToken = (rawToken) => {
const token = String(rawToken || '').trim();
if (!token) {
return { format: '', key: '' };
}
const separatorIndex = token.indexOf(':');
if (separatorIndex <= 0) {
return { format: '', key: token };
}
return {
format: token.slice(0, separatorIndex).trim(),
key: token.slice(separatorIndex + 1).trim()
};
};
const applyFormat = (value, format) => {
const normalizedFormat = String(format || '').trim().toLowerCase();
if (!normalizedFormat) {
return String(value);
}
// {0:key} => number with leading zero (width 2), e.g. 2 -> 02
if (normalizedFormat === '0') {
const raw = String(value);
const match = raw.match(/^(-?)(\d+)$/);
if (match) {
const sign = match[1] || '';
const digits = String(match[2] || '');
return `${sign}${digits.padStart(2, '0')}`;
}
}
return String(value);
};
const resolveToken = (rawKey) => {
const { format, key } = parseToken(rawKey);
const val = values[key];
if (val === undefined || val === null || val === '') {
return 'unknown';
}
return applyFormat(val, format);
};
const source = (
/[{}]/.test(rawSource)
? rawSource
: rawSource.replace(/\b(trackNr|trackNumber|artist|album|title|year)\b/g, '{$1}')
);
// Support both ${key} and legacy {key} placeholders (+ recovered bare tokens).
return source
.replace(/\$\{([^}]+)\}/g, (_m, key) => resolveToken(key))
.replace(/\{([^{}$]+)\}/g, (_m, key) => resolveToken(key));
}
function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) {
const files = findMediaFiles(dirPath, extensions);
if (files.length === 0) {
return null;
}
return files.reduce((largest, file) => (largest === null || file.size > largest.size ? file : largest), null);
}
function findMediaFiles(dirPath, extensions = ['.mkv', '.mp4']) {
const results = [];
function walk(current) {
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const abs = path.join(current, entry.name);
if (entry.isDirectory()) {
walk(abs);
} else {
const ext = path.extname(entry.name).toLowerCase();
if (!extensions.includes(ext)) {
continue;
}
const stat = fs.statSync(abs);
results.push({
path: abs,
size: stat.size
});
}
}
}
walk(dirPath);
results.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path));
return results;
}
module.exports = {
ensureDir,
transliterateForFilename,
sanitizeFileName,
sanitizeFileNameWithExtension,
renderTemplate,
findLargestMediaFile,
findMediaFiles
};
+742
View File
@@ -0,0 +1,742 @@
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();
if (!text) {
return 0;
}
const hms = text.match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?$/);
if (hms) {
const h = Number(hms[1]);
const m = Number(hms[2]);
const s = Number(hms[3]);
return (h * 3600) + (m * 60) + s;
}
const hm = text.match(/^(\d{1,2}):(\d{2})(?:\.\d+)?$/);
if (hm) {
const m = Number(hm[1]);
const s = Number(hm[2]);
return (m * 60) + s;
}
const asNumber = Number(text);
if (Number.isFinite(asNumber) && asNumber > 0) {
return Math.round(asNumber);
}
return 0;
}
function formatDuration(seconds) {
const total = Number(seconds || 0);
if (!Number.isFinite(total) || total <= 0) {
return '-';
}
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function parseSizeBytes(raw) {
const text = String(raw || '').trim();
if (!text) {
return 0;
}
if (/^\d+$/.test(text)) {
const direct = Number(text);
return Number.isFinite(direct) ? Math.max(0, Math.round(direct)) : 0;
}
const match = text.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
if (!match) {
return 0;
}
const value = Number(match[1]);
if (!Number.isFinite(value)) {
return 0;
}
const unit = String(match[2] || '').toUpperCase();
const factorByUnit = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4
};
const factor = factorByUnit[unit] || 1;
return Math.max(0, Math.round(value * factor));
}
function normalizePlaylistId(raw) {
const value = String(raw || '').trim().toLowerCase();
if (!value) {
return null;
}
const match = value.match(/(\d{1,5})(?:\.mpls)?$/i);
if (!match) {
return null;
}
return String(match[1]).padStart(5, '0');
}
function toSegmentFile(segmentNumber) {
const value = Number(segmentNumber);
if (!Number.isFinite(value) || value < 0) {
return null;
}
return `${String(Math.trunc(value)).padStart(5, '0')}.m2ts`;
}
function parseSegmentNumbers(raw) {
const text = String(raw || '').trim();
if (!text) {
return [];
}
const matches = text.match(/\d{1,6}/g) || [];
return matches
.map((item) => Number(item))
.filter((value) => Number.isFinite(value) && value >= 0)
.map((value) => Math.trunc(value));
}
function extractPlaylistMapping(line) {
const raw = String(line || '');
// Robot message typically maps playlist to title id.
const msgMatch = raw.match(/MSG:3016.*,"(\d{5}\.mpls)","(\d+)"/i);
if (msgMatch) {
return {
playlistId: normalizePlaylistId(msgMatch[1]),
titleId: Number(msgMatch[2])
};
}
const textMatch = raw.match(/(?:file|datei)\s+(\d{5}\.mpls).*?(?:title\s*#|titel\s*#?\s*)(\d+)/i);
if (textMatch) {
return {
playlistId: normalizePlaylistId(textMatch[1]),
titleId: Number(textMatch[2])
};
}
return null;
}
function parseAnalyzeTitles(lines) {
const titleMap = new Map();
const ensureTitle = (titleId) => {
if (!titleMap.has(titleId)) {
titleMap.set(titleId, {
titleId,
playlistId: null,
playlistIdFromMap: null,
playlistIdFromField16: null,
playlistFile: null,
durationSeconds: 0,
durationLabel: null,
sizeBytes: 0,
sizeLabel: null,
chapters: 0,
segmentNumbers: [],
segmentFiles: [],
streams: {},
fields: {}
});
}
return titleMap.get(titleId);
};
for (const line of lines || []) {
const mapping = extractPlaylistMapping(line);
if (mapping && Number.isFinite(mapping.titleId) && mapping.titleId >= 0) {
const title = ensureTitle(mapping.titleId);
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;
}
const titleId = Number(tinfo[1]);
const fieldId = Number(tinfo[2]);
const value = String(tinfo[3] || '').trim();
if (!Number.isFinite(titleId) || titleId < 0) {
continue;
}
const title = ensureTitle(titleId);
title.fields[fieldId] = value;
if (fieldId === 16) {
const fromField = normalizePlaylistId(value);
if (fromField) {
title.playlistIdFromField16 = fromField;
}
continue;
}
if (fieldId === 26) {
const segmentNumbers = parseSegmentNumbers(value);
if (segmentNumbers.length > 0) {
title.segmentNumbers = segmentNumbers;
}
continue;
}
if (fieldId === 9) {
const seconds = parseDurationSeconds(value);
if (seconds > 0) {
title.durationSeconds = seconds;
title.durationLabel = formatDuration(seconds);
}
continue;
}
if (fieldId === 10 || fieldId === 11) {
const bytes = parseSizeBytes(value);
if (bytes > 0) {
title.sizeBytes = bytes;
title.sizeLabel = value;
}
continue;
}
if (fieldId === 8 || fieldId === 7) {
const chapters = Number(value);
if (Number.isFinite(chapters) && chapters >= 0) {
title.chapters = Math.trunc(chapters);
}
}
if (!title.durationSeconds && /\d+:\d{2}:\d{2}/.test(value)) {
const seconds = parseDurationSeconds(value);
if (seconds > 0) {
title.durationSeconds = seconds;
title.durationLabel = formatDuration(seconds);
}
}
if (!title.sizeBytes && /(kb|mb|gb|tb)\b/i.test(value)) {
const bytes = parseSizeBytes(value);
if (bytes > 0) {
title.sizeBytes = bytes;
title.sizeLabel = value;
}
}
}
return Array.from(titleMap.values())
.map((item) => {
const playlistId = normalizePlaylistId(item.playlistId);
const playlistIdFromMap = normalizePlaylistId(item.playlistIdFromMap);
const playlistIdFromField16 = normalizePlaylistId(item.playlistIdFromField16);
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 {
...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
};
})
.sort((a, b) => a.titleId - b.titleId);
}
function uniqueOrdered(values) {
const seen = new Set();
const output = [];
for (const value of values || []) {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
output.push(String(value).trim());
}
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)));
const groups = [];
const used = new Set();
for (let i = 0; i < list.length; i += 1) {
if (used.has(i)) {
continue;
}
const base = list[i];
const currentGroup = [base];
used.add(i);
for (let j = i + 1; j < list.length; j += 1) {
if (used.has(j)) {
continue;
}
const candidate = list[j];
if (Math.abs(Number(candidate.durationSeconds || 0) - Number(base.durationSeconds || 0)) <= tolerance) {
currentGroup.push(candidate);
used.add(j);
}
}
if (currentGroup.length > 1) {
const sortedTitles = currentGroup
.slice()
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
const referenceDuration = Number(sortedTitles[0]?.durationSeconds || 0);
groups.push({
durationSeconds: referenceDuration,
durationLabel: formatDuration(referenceDuration),
titles: sortedTitles
});
}
}
return groups.sort((a, b) =>
b.durationSeconds - a.durationSeconds || b.titles.length - a.titles.length
);
}
function computeSegmentMetrics(segmentNumbers) {
const numbers = Array.isArray(segmentNumbers)
? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
: [];
if (numbers.length === 0) {
return {
segmentCount: 0,
segmentNumbers: [],
directSequenceSteps: 0,
backwardJumps: 0,
largeJumps: 0,
alternatingJumps: 0,
alternatingPairs: 0,
alternatingRatio: 0,
sequenceCoherence: 0,
monotonicRatio: 0,
score: 0
};
}
let directSequenceSteps = 0;
let backwardJumps = 0;
let largeJumps = 0;
let alternatingJumps = 0;
let alternatingPairs = 0;
let prevDiff = null;
for (let i = 1; i < numbers.length; i += 1) {
const current = numbers[i - 1];
const next = numbers[i];
const diff = next - current;
if (next < current) {
backwardJumps += 1;
}
if (Math.abs(diff) > LARGE_JUMP_THRESHOLD) {
largeJumps += 1;
}
if (diff === 1) {
directSequenceSteps += 1;
}
if (prevDiff !== null) {
const largePair = Math.abs(prevDiff) > LARGE_JUMP_THRESHOLD && Math.abs(diff) > LARGE_JUMP_THRESHOLD;
if (largePair) {
alternatingPairs += 1;
const signChanged = (prevDiff < 0 && diff > 0) || (prevDiff > 0 && diff < 0);
if (signChanged) {
alternatingJumps += 1;
}
}
}
prevDiff = diff;
}
const transitions = Math.max(1, numbers.length - 1);
const sequenceCoherence = Number((directSequenceSteps / transitions).toFixed(4));
const alternatingRatio = alternatingPairs > 0
? Number((alternatingJumps / alternatingPairs).toFixed(4))
: 0;
const score = (directSequenceSteps * 2) - (backwardJumps * 3) - (largeJumps * 2);
return {
segmentCount: numbers.length,
segmentNumbers: numbers,
directSequenceSteps,
backwardJumps,
largeJumps,
alternatingJumps,
alternatingPairs,
alternatingRatio,
sequenceCoherence,
monotonicRatio: sequenceCoherence,
score
};
}
function buildEvaluationLabel(metrics) {
if (!metrics || metrics.segmentCount === 0) {
return 'Keine Segmentliste aus TINFO:26 verfügbar';
}
if (metrics.alternatingRatio >= 0.55 && metrics.alternatingPairs >= 3) {
return 'Fake-Struktur (alternierendes Sprungmuster)';
}
if (metrics.backwardJumps > 0 || metrics.largeJumps > 0) {
return 'Auffällige Segmentreihenfolge';
}
return 'wahrscheinlich korrekt (lineare Segmentfolge)';
}
function scoreCandidates(groupTitles) {
const titles = Array.isArray(groupTitles) ? groupTitles : [];
if (titles.length === 0) {
return [];
}
return titles
.map((title) => {
const metrics = computeSegmentMetrics(title.segmentNumbers);
const reasons = [
`sequence_steps=${metrics.directSequenceSteps}`,
`sequence_coherence=${metrics.sequenceCoherence.toFixed(3)}`,
`backward_jumps=${metrics.backwardJumps}`,
`large_jumps=${metrics.largeJumps}`,
`alternating_ratio=${metrics.alternatingRatio.toFixed(3)}`
];
return {
...title,
score: Number(metrics.score || 0),
reasons,
structuralMetrics: metrics,
evaluationLabel: buildEvaluationLabel(metrics)
};
})
.sort((a, b) =>
b.score - a.score
|| b.structuralMetrics.sequenceCoherence - a.structuralMetrics.sequenceCoherence
|| b.durationSeconds - a.durationSeconds
|| b.sizeBytes - a.sizeBytes
|| a.titleId - b.titleId
)
.map((item, index) => ({
...item,
recommended: index === 0
}));
}
function buildPlaylistSegmentMap(titles) {
const map = {};
for (const title of titles || []) {
const playlistId = normalizePlaylistId(title?.playlistId);
if (!playlistId || map[playlistId]) {
continue;
}
map[playlistId] = {
playlistId,
playlistFile: `${playlistId}.mpls`,
playlistPath: `BDMV/PLAYLIST/${playlistId}.mpls`,
segmentCommand: `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
segmentFiles: Array.isArray(title?.segmentFiles) ? title.segmentFiles : [],
segmentNumbers: Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : [],
fileExists: null,
source: 'makemkv_tinfo_26'
};
}
return map;
}
function buildPlaylistToTitleIdMap(titles) {
const map = {};
for (const title of titles || []) {
const playlistId = normalizePlaylistId(title?.playlistId || title?.playlistFile || null);
const titleId = Number(title?.titleId);
if (!playlistId || !Number.isFinite(titleId) || titleId < 0) {
continue;
}
const normalizedTitleId = Math.trunc(titleId);
if (map[playlistId] === undefined) {
map[playlistId] = normalizedTitleId;
}
const playlistFile = `${playlistId}.mpls`;
if (map[playlistFile] === undefined) {
map[playlistFile] = normalizedTitleId;
}
}
return map;
}
function extractWarningLines(lines) {
return (Array.isArray(lines) ? lines : [])
.filter((line) => /warn|warning|error|fehler|decode|decoder|timeout|corrupt/i.test(String(line || '')))
.slice(0, 40)
.map((line) => String(line || '').slice(0, 260));
}
function extractPlaylistMismatchWarnings(titles) {
return (Array.isArray(titles) ? titles : [])
.filter((title) => title?.playlistIdFromMap && title?.playlistIdFromField16)
.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 (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 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(playlistBackedCandidates, durationSimilaritySeconds);
const obfuscationDetected = similarityGroups.length > 0;
const multipleCandidatesDetected = candidatePlaylistsAll.length > 1;
const manualDecisionRequired = multipleCandidatesDetected;
const decisionPool = manualDecisionRequired ? playlistBackedCandidates : [];
const evaluatedCandidates = decisionPool.length > 0 ? scoreCandidates(decisionPool) : [];
const recommendation = evaluatedCandidates[0] || null;
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,
titles: parsedTitles,
candidates,
duplicateDurationGroups: similarityGroups,
obfuscationDetected,
manualDecisionRequired,
manualDecisionReason: manualDecisionRequired
? (obfuscationDetected ? 'multiple_similar_candidates' : 'multiple_candidates_after_min_length')
: null,
candidatePlaylists,
candidatePlaylistFiles: candidatePlaylists.map((item) => `${item}.mpls`),
playlistToTitleId,
recommendation: recommendation
? {
titleId: recommendation.titleId,
playlistId: recommendation.playlistId,
score: Number(recommendation.score || 0),
reason: Array.isArray(recommendation.reasons) && recommendation.reasons.length > 0
? recommendation.reasons.join('; ')
: 'höchster Struktur-Score'
}
: null,
evaluatedCandidates,
playlistSegments,
structuralAnalysis: {
method: 'makemkv_tinfo_26',
sourceCommand: 'makemkvcon -r info disc:0 --robot',
analyzedPlaylists: Object.keys(playlistSegments).length
},
warningLines: [
...extractWarningLines(lines),
...(reportedTitleCount !== null && reportedTitleCount !== parsedTitles.length
? [`Titel-Anzahl abweichend: TCOUNT=${reportedTitleCount}, geparst=${parsedTitles.length}`]
: []),
...extractPlaylistMismatchWarnings(parsedTitles)
].slice(0, 60)
};
}
module.exports = {
normalizePlaylistId,
analyzePlaylistObfuscation
};
+126
View File
@@ -0,0 +1,126 @@
function clampPercent(value) {
if (Number.isNaN(value) || value === Infinity || value === -Infinity) {
return null;
}
return Math.max(0, Math.min(100, Number(value.toFixed(2))));
}
function parseGenericPercent(line) {
const match = line.match(/(\d{1,3}(?:\.\d+)?)\s?%/);
if (!match) {
return null;
}
return clampPercent(Number(match[1]));
}
function parseEta(line) {
const etaMatch = line.match(/ETA\s+([0-9:.hms-]+)/i);
if (!etaMatch) {
return null;
}
const value = etaMatch[1].trim();
if (!value || value.includes('--')) {
return null;
}
return value.replace(/[),.;]+$/, '');
}
function parseMakeMkvProgress(line) {
const prgv = line.match(/PRGV:(\d+),(\d+),(\d+)/);
if (prgv) {
// Format: PRGV:current,total,max (official makemkv docs)
// 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((total / max) * 100), eta: null };
}
}
const percent = parseGenericPercent(line);
if (percent !== null) {
return { percent, eta: null };
}
return null;
}
function parseHandBrakeProgress(line) {
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
const match = normalized.match(/Encoding:\s*(?:task\s+\d+\s+of\s+\d+,\s*)?(\d+(?:\.\d+)?)\s?%/i);
if (match) {
return {
percent: clampPercent(Number(match[1])),
eta: parseEta(normalized)
};
}
return null;
}
function parseCdParanoiaProgress(line) {
// cdparanoia writes progress to stderr with \r overwrites.
// Formats seen in the wild:
// "Ripping track 1 of 12 progress: ( 34.21%)"
// "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])"
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
const progressMatch = normalized.match(/progress:\s*\(\s*(\d+(?:\.\d+)?)\s*%\s*\)/i);
if (progressMatch) {
const trackMatch = normalized.match(/track\s+(\d+)\s+of\s+(\d+)/i);
const currentTrack = trackMatch ? Number(trackMatch[1]) : null;
const totalTracks = trackMatch ? Number(trackMatch[2]) : null;
return {
percent: clampPercent(Number(progressMatch[1])),
currentTrack,
totalTracks,
eta: null
};
}
// "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])" style no clear percent here
// Fall back to generic percent match
const percent = parseGenericPercent(normalized);
if (percent !== null) {
return { percent, currentTrack: null, totalTracks: null, eta: null };
}
return null;
}
function parseMkvmergeProgress(line) {
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
if (!normalized) {
return null;
}
// Common mkvmerge output examples:
// "Progress: 42%"
// "#GUI#progress 42%"
const explicitMatch = normalized.match(/(?:^|\s)(?:progress:?|#GUI#progress)\s*(\d{1,3}(?:\.\d+)?)\s*%/i);
if (explicitMatch) {
return {
percent: clampPercent(Number(explicitMatch[1])),
eta: null
};
}
const percent = parseGenericPercent(normalized);
if (percent !== null) {
return { percent, eta: null };
}
return null;
}
module.exports = {
parseMakeMkvProgress,
parseHandBrakeProgress,
parseCdParanoiaProgress,
parseMkvmergeProgress
};
+112
View File
@@ -0,0 +1,112 @@
function parseJson(value, fallback = null) {
if (!value) {
return fallback;
}
try {
return JSON.parse(value);
} catch (error) {
return fallback;
}
}
function toBoolean(value) {
if (typeof value === 'boolean') {
return value;
}
if (value === 'true' || value === '1' || value === 1) {
return true;
}
if (value === 'false' || value === '0' || value === 0) {
return false;
}
return Boolean(value);
}
function normalizeValueByType(type, rawValue) {
if (rawValue === undefined || rawValue === null) {
return null;
}
switch (type) {
case 'number':
return Number(rawValue);
case 'boolean':
return toBoolean(rawValue);
case 'select':
case 'string':
case 'path':
default:
return String(rawValue);
}
}
function serializeValueByType(type, value) {
if (value === undefined || value === null) {
return null;
}
if (type === 'boolean') {
return value ? 'true' : 'false';
}
return String(value);
}
function validateSetting(schemaItem, value) {
const errors = [];
const normalized = normalizeValueByType(schemaItem.type, value);
if (schemaItem.required) {
const emptyString = typeof normalized === 'string' && normalized.trim().length === 0;
if (normalized === null || emptyString) {
errors.push('Wert ist erforderlich.');
}
}
if (schemaItem.type === 'number' && normalized !== null) {
if (Number.isNaN(normalized)) {
errors.push('Ungültige Zahl.');
} else {
const rules = parseJson(schemaItem.validation_json, {});
if (typeof rules.min === 'number' && normalized < rules.min) {
errors.push(`Wert muss >= ${rules.min} sein.`);
}
if (typeof rules.max === 'number' && normalized > rules.max) {
errors.push(`Wert muss <= ${rules.max} sein.`);
}
}
}
if (schemaItem.type === 'select' && normalized !== null) {
const options = parseJson(schemaItem.options_json, []);
const values = options.map((option) => option.value);
if (!values.includes(normalized)) {
errors.push('Ungültige Auswahl.');
}
}
if ((schemaItem.type === 'path' || schemaItem.type === 'string') && normalized !== null) {
const rules = parseJson(schemaItem.validation_json, {});
if (typeof rules.minLength === 'number' && normalized.length < rules.minLength) {
errors.push(`Wert muss mindestens ${rules.minLength} Zeichen haben.`);
}
}
return {
valid: errors.length === 0,
errors,
normalized
};
}
module.exports = {
parseJson,
normalizeValueByType,
serializeValueByType,
validateSetting,
toBoolean
};