Initial commit mit MkDocs-Dokumentation
This commit is contained in:
57
backend/src/utils/commandLine.js
Normal file
57
backend/src/utils/commandLine.js
Normal 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
|
||||
};
|
||||
1017
backend/src/utils/encodePlan.js
Normal file
1017
backend/src/utils/encodePlan.js
Normal file
File diff suppressed because it is too large
Load Diff
18
backend/src/utils/errorMeta.js
Normal file
18
backend/src/utils/errorMeta.js
Normal 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
|
||||
};
|
||||
70
backend/src/utils/files.js
Normal file
70
backend/src/utils/files.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeFileName(input) {
|
||||
return String(input || 'untitled')
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 180);
|
||||
}
|
||||
|
||||
function renderTemplate(template, values) {
|
||||
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
|
||||
const val = values[key.trim()];
|
||||
if (val === undefined || val === null || val === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
return String(val);
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
sanitizeFileName,
|
||||
renderTemplate,
|
||||
findLargestMediaFile,
|
||||
findMediaFiles
|
||||
};
|
||||
576
backend/src/utils/playlistAnalysis.js
Normal file
576
backend/src/utils/playlistAnalysis.js
Normal file
@@ -0,0 +1,576 @@
|
||||
const LARGE_JUMP_THRESHOLD = 20;
|
||||
const DEFAULT_DURATION_SIMILARITY_SECONDS = 90;
|
||||
|
||||
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: [],
|
||||
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 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);
|
||||
// Prefer explicit title<->playlist map lines from MakeMKV (MSG:3016).
|
||||
const resolvedPlaylistId = playlistIdFromMap || playlistIdFromField16 || playlistId;
|
||||
const segmentNumbers = Array.isArray(item.segmentNumbers) ? item.segmentNumbers : [];
|
||||
const segmentFiles = segmentNumbers
|
||||
.map((number) => toSegmentFile(number))
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
...item,
|
||||
playlistId: resolvedPlaylistId,
|
||||
playlistIdFromMap,
|
||||
playlistIdFromField16,
|
||||
playlistFile: resolvedPlaylistId ? `${resolvedPlaylistId}.mpls` : null,
|
||||
durationLabel: item.durationLabel || formatDuration(item.durationSeconds),
|
||||
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 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 (MSG bevorzugt)`
|
||||
);
|
||||
}
|
||||
|
||||
function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) {
|
||||
const parsedTitles = parseAnalyzeTitles(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
|
||||
.filter((item) => Number(item.durationSeconds || 0) >= minSeconds)
|
||||
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
|
||||
|
||||
const similarityGroups = buildSimilarityGroups(candidates, durationSimilaritySeconds);
|
||||
const obfuscationDetected = similarityGroups.length > 0;
|
||||
const primaryGroup = similarityGroups[0] || null;
|
||||
const evaluatedCandidates = primaryGroup ? scoreCandidates(primaryGroup.titles) : [];
|
||||
const recommendation = evaluatedCandidates[0] || null;
|
||||
const candidatePlaylists = primaryGroup
|
||||
? uniqueOrdered(primaryGroup.titles.map((item) => item.playlistId).filter(Boolean))
|
||||
: [];
|
||||
const playlistSegments = buildPlaylistSegmentMap(primaryGroup ? primaryGroup.titles : []);
|
||||
const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitles);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
minLengthMinutes: Number(minLengthMinutes || 0),
|
||||
minLengthSeconds: minSeconds,
|
||||
durationSimilaritySeconds,
|
||||
titles: parsedTitles,
|
||||
candidates,
|
||||
duplicateDurationGroups: similarityGroups,
|
||||
obfuscationDetected,
|
||||
manualDecisionRequired: obfuscationDetected,
|
||||
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),
|
||||
...extractPlaylistMismatchWarnings(parsedTitles)
|
||||
].slice(0, 60)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizePlaylistId,
|
||||
analyzePlaylistObfuscation
|
||||
};
|
||||
72
backend/src/utils/progressParsers.js
Normal file
72
backend/src/utils/progressParsers.js
Normal file
@@ -0,0 +1,72 @@
|
||||
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) {
|
||||
const a = Number(prgv[1]);
|
||||
const b = Number(prgv[2]);
|
||||
const c = Number(prgv[3]);
|
||||
|
||||
if (c > 0) {
|
||||
return { percent: clampPercent((a / c) * 100), eta: null };
|
||||
}
|
||||
|
||||
if (b > 0) {
|
||||
return { percent: clampPercent((a / b) * 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;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseMakeMkvProgress,
|
||||
parseHandBrakeProgress
|
||||
};
|
||||
112
backend/src/utils/validators.js
Normal file
112
backend/src/utils/validators.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user