'use strict'; const TITLE_KIND = Object.freeze({ EPISODE_CANDIDATE: 'episode_candidate', PLAY_ALL: 'play_all', EXTRA: 'extra', DUPLICATE: 'duplicate', SHORT: 'short', UNKNOWN: 'unknown' }); const DEFAULTS = Object.freeze({ minEpisodeMinutes: 18, maxEpisodeMinutes: 75, minChapterCount: 3, shortTitleMinutes: 5 }); function nowIso() { return new Date().toISOString(); } function parseDurationToSeconds(rawValue) { const text = String(rawValue || '').trim(); const match = text.match(/^(\d{1,2}):(\d{2}):(\d{2})$/); if (!match) { return 0; } return (Number(match[1]) * 3600) + (Number(match[2]) * 60) + Number(match[3]); } function roundToStep(value, step) { const num = Number(value); if (!Number.isFinite(num) || step <= 0) { return 0; } return Math.round(num / step) * step; } function median(values = []) { const nums = (Array.isArray(values) ? values : []) .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && value > 0) .sort((left, right) => left - right); if (nums.length === 0) { return 0; } const middle = Math.floor(nums.length / 2); if (nums.length % 2 === 1) { return nums[middle]; } return (nums[middle - 1] + nums[middle]) / 2; } function normalizeLanguageCode(rawCode, fallbackLabel = null) { const raw = String(rawCode || '').trim().toLowerCase(); if (raw && raw.length === 3) { return raw; } const label = String(fallbackLabel || '').trim().toLowerCase(); if (label.startsWith('de')) return 'deu'; if (label.startsWith('en')) return 'eng'; if (label.startsWith('fr')) return 'fra'; if (label.startsWith('es')) return 'spa'; if (label.startsWith('nl')) return 'nld'; return raw || 'und'; } function normalizeSeriesLookupTitle(rawValue) { return String(rawValue || '') .replace(/[_./]+/g, ' ') .replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ') .replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ') .replace(/\b\d{1,2}x\b/gi, ' ') .replace(/\b(?:complete|collection|boxset)\b/gi, ' ') .replace(/\s+/g, ' ') .trim(); } function deriveSeriesLookupHint(inputs = []) { const normalizedInputs = (Array.isArray(inputs) ? inputs : [inputs]) .map((entry) => { if (entry && typeof entry === 'object' && !Array.isArray(entry)) { return { value: String(entry.value || '').trim(), source: String(entry.source || '').trim() || 'unknown' }; } return { value: String(entry || '').trim(), source: 'unknown' }; }) .filter((entry) => entry.value); for (const entry of normalizedInputs) { const compactValue = entry.value.replace(/[_./]+/g, ' ').replace(/\s+/g, ' ').trim(); if (!compactValue) { continue; } const seasonMatch = compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i) || compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i); if (!seasonMatch) { continue; } const seasonNumber = Number(seasonMatch[1] || 0); if (!Number.isFinite(seasonNumber) || seasonNumber <= 0) { continue; } const discMatch = compactValue.match(/(?:^|\s)(?:disc|dvd|cd)\s*(\d{1,2})(?=\s|$)/i); const discNumber = discMatch ? Number(discMatch[1] || 0) || null : null; const baseTitle = normalizeSeriesLookupTitle(compactValue); if (!baseTitle) { continue; } return { query: baseTitle, seriesTitle: baseTitle, seasonNumber, discNumber, source: entry.source, sourceLabel: entry.value, confidence: discNumber ? 'high' : 'medium' }; } return null; } function normalizeTitleRecord(title = {}) { const durationSeconds = Number(title.durationSeconds || 0); const chapterCount = Number(title.chapterCount || 0); const roundedDurationBucket = roundToStep(durationSeconds, 30); const audioLanguages = (Array.isArray(title.audioTracks) ? title.audioTracks : []) .map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel)) .filter(Boolean) .sort(); const subtitleLanguages = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : []) .map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel)) .filter(Boolean) .sort(); return { index: Number(title.index || 0), durationSeconds, durationLabel: String(title.durationLabel || '').trim() || null, chapterCount, chapterDurationsMs: Array.isArray(title.chapterDurationsMs) ? title.chapterDurationsMs : [], aspectRatio: String(title.aspectRatio || '').trim() || null, audioTracks: Array.isArray(title.audioTracks) ? title.audioTracks : [], subtitleTracks: Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [], flags: Array.isArray(title.flags) ? title.flags : [], roundedDurationBucket, signature: JSON.stringify({ duration: roundedDurationBucket, chapterCount, audioLanguages, subtitleLanguages }) }; } function buildBestEpisodeCluster(titles = [], options = {}) { const minEpisodeSeconds = Math.max(60, Math.round(Number(options.minEpisodeMinutes || DEFAULTS.minEpisodeMinutes) * 60)); const maxEpisodeSeconds = Math.max(minEpisodeSeconds, Math.round(Number(options.maxEpisodeMinutes || DEFAULTS.maxEpisodeMinutes) * 60)); const minChapterCount = Math.max(1, Number(options.minChapterCount || DEFAULTS.minChapterCount)); const candidates = titles.filter((title) => title.durationSeconds >= minEpisodeSeconds && title.durationSeconds <= maxEpisodeSeconds && title.chapterCount >= minChapterCount ); if (candidates.length === 0) { return { titles: [], medianDurationSeconds: 0, medianChapterCount: 0 }; } let bestCluster = []; for (const anchor of candidates) { const durationTolerance = Math.max(90, Math.round(anchor.durationSeconds * 0.12)); const cluster = candidates.filter((candidate) => { const durationGap = Math.abs(candidate.durationSeconds - anchor.durationSeconds); const chapterGap = Math.abs(candidate.chapterCount - anchor.chapterCount); return durationGap <= durationTolerance && chapterGap <= 2; }); if (cluster.length > bestCluster.length) { bestCluster = cluster; continue; } if (cluster.length === bestCluster.length) { const clusterMedian = median(cluster.map((item) => item.durationSeconds)); const bestMedian = median(bestCluster.map((item) => item.durationSeconds)); if (clusterMedian > bestMedian) { bestCluster = cluster; } } } return { titles: bestCluster, medianDurationSeconds: median(bestCluster.map((item) => item.durationSeconds)), medianChapterCount: median(bestCluster.map((item) => item.chapterCount)) }; } function classifyTitles(titles = [], cluster = {}, options = {}) { const shortTitleSeconds = Math.max(60, Math.round(Number(options.shortTitleMinutes || DEFAULTS.shortTitleMinutes) * 60)); const clusterTitleIds = new Set((cluster.titles || []).map((item) => Number(item.index))); const duplicateFirstBySignature = new Map(); for (const title of titles) { if (!duplicateFirstBySignature.has(title.signature)) { duplicateFirstBySignature.set(title.signature, title.index); } } const playAllCandidate = (() => { if (!Array.isArray(cluster.titles) || cluster.titles.length < 2 || !cluster.medianDurationSeconds) { return null; } const minPlayAllSeconds = cluster.medianDurationSeconds * Math.max(2, cluster.titles.length - 1); return titles .filter((title) => !clusterTitleIds.has(Number(title.index)) && title.durationSeconds >= minPlayAllSeconds * 0.75 && title.chapterCount >= Math.max(6, Math.round(cluster.medianChapterCount * cluster.titles.length * 0.6)) ) .sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null; })(); return titles.map((title) => { const isFirstWithSignature = duplicateFirstBySignature.get(title.signature) === title.index; const isShort = title.durationSeconds > 0 && title.durationSeconds <= shortTitleSeconds; const isEpisodeCandidate = clusterTitleIds.has(Number(title.index)); const isPlayAll = playAllCandidate && Number(playAllCandidate.index) === Number(title.index); const kind = isPlayAll ? TITLE_KIND.PLAY_ALL : isEpisodeCandidate ? TITLE_KIND.EPISODE_CANDIDATE : !isFirstWithSignature ? TITLE_KIND.DUPLICATE : isShort ? TITLE_KIND.SHORT : (title.durationSeconds > 0 ? TITLE_KIND.EXTRA : TITLE_KIND.UNKNOWN); let confidence = 0.2; if (kind === TITLE_KIND.EPISODE_CANDIDATE) confidence = 0.8; if (kind === TITLE_KIND.PLAY_ALL) confidence = 0.95; if (kind === TITLE_KIND.DUPLICATE) confidence = 0.85; if (kind === TITLE_KIND.SHORT) confidence = 0.9; if (kind === TITLE_KIND.EXTRA) confidence = 0.55; return { ...title, kind, confidence, duplicateOfTitleIndex: kind === TITLE_KIND.DUPLICATE ? duplicateFirstBySignature.get(title.signature) : null }; }); } function buildDiscSignature(parsedScan = {}) { return JSON.stringify({ discTitle: String(parsedScan.discTitle || '').trim() || null, discSerial: String(parsedScan.discSerial || '').trim() || null, titleCount: Number(parsedScan.titleCount || 0), durations: (Array.isArray(parsedScan.titles) ? parsedScan.titles : []) .map((title) => ({ index: Number(title.index || 0), durationBucket: roundToStep(title.durationSeconds || 0, 30), chapterCount: Number(title.chapterCount || 0) })) }); } function summarizeSeriesLikelihood(classifiedTitles = [], cluster = {}) { const episodeCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length; const playAllCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length; const duplicateCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length; const extrasCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length; const reasons = []; let confidence = 'low'; let seriesLike = false; if (episodeCount >= 3) { seriesLike = true; confidence = playAllCount > 0 ? 'high' : 'medium'; reasons.push(`${episodeCount} ähnlich lange Episodenkandidaten erkannt.`); } else if (episodeCount === 2) { seriesLike = true; confidence = 'medium'; reasons.push('Mindestens zwei ähnlich lange Episodenkandidaten erkannt.'); } if (playAllCount > 0) { reasons.push('Ein langer Play-All-Titel wurde erkannt.'); } if (duplicateCount > 0) { reasons.push(`${duplicateCount} alternative/duplizierte Titel gefunden.`); } if (cluster.medianDurationSeconds > 0) { reasons.push(`Typische Episodenlänge ca. ${Math.round(cluster.medianDurationSeconds / 60)} Minuten.`); } if (extrasCount > 0) { reasons.push(`${extrasCount} Titel als Extras oder Sonderfälle klassifiziert.`); } return { seriesLike, confidence, reasons }; } function parseHandBrakeScanText(rawOutput) { const lines = String(rawOutput || '').split(/\r?\n/); const parsed = { source: 'handbrake_scan_text', generatedAt: nowIso(), discTitle: null, discSerial: null, titleCount: 0, rawLineCount: lines.length, titles: [] }; let currentTitle = null; const ensureCurrentTitle = (index) => { const normalizedIndex = Number(index); if (!Number.isFinite(normalizedIndex) || normalizedIndex <= 0) { return null; } if (currentTitle && Number(currentTitle.index) === normalizedIndex) { return currentTitle; } const existing = parsed.titles.find((title) => Number(title.index) === normalizedIndex); if (existing) { currentTitle = existing; return currentTitle; } currentTitle = { index: normalizedIndex, durationLabel: null, durationSeconds: 0, chapterCount: 0, chapterDurationsMs: [], audioTracks: [], subtitleTracks: [], aspectRatio: null, flags: [] }; parsed.titles.push(currentTitle); return currentTitle; }; for (const line of lines) { let match = line.match(/libdvdnav:\s+DVD Title:\s+(.+)\s*$/i); if (match) { parsed.discTitle = String(match[1] || '').trim() || null; continue; } match = line.match(/libdvdnav:\s+DVD Serial Number:\s+(.+)\s*$/i); if (match) { parsed.discSerial = String(match[1] || '').trim() || null; continue; } match = line.match(/scan:\s+DVD has\s+(\d+)\s+title/i); if (match) { parsed.titleCount = Number(match[1] || 0); continue; } match = line.match(/scan:\s+scanning title\s+(\d+)/i); if (match) { ensureCurrentTitle(match[1]); continue; } match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i); if (match && currentTitle) { currentTitle.durationLabel = match[1]; currentTitle.durationSeconds = parseDurationToSeconds(match[1]); continue; } match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i); if (match) { const title = ensureCurrentTitle(match[1]); if (title) { title.chapterCount = Number(match[2] || 0); } continue; } match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i); if (match && currentTitle) { currentTitle.chapterDurationsMs.push(Number(match[1] || 0)); continue; } match = line.match(/scan:\s+id=0x[0-9a-f]+,\s+lang=(.+?)\s+\([^)]+\),\s+3cc=([a-z]{3})/i); if (match && currentTitle) { const track = { languageLabel: String(match[1] || '').trim() || null, languageCode: normalizeLanguageCode(match[2], match[1]) }; if (/\[VOBSUB\]/i.test(line)) { currentTitle.subtitleTracks.push(track); } else { currentTitle.audioTracks.push(track); } continue; } match = line.match(/scan:\s+aspect\s+=\s+(.+)$/i); if (match && currentTitle) { currentTitle.aspectRatio = String(match[1] || '').trim() || null; continue; } match = line.match(/scan:\s+ignoring title \(too short\)/i); if (match && currentTitle) { currentTitle.flags.push('ignored_too_short'); } } parsed.titles.sort((left, right) => Number(left.index || 0) - Number(right.index || 0)); if (!parsed.titleCount) { parsed.titleCount = parsed.titles.length; } return parsed; } function analyzeParsedScan(parsedScan = {}, options = {}) { const titles = (Array.isArray(parsedScan.titles) ? parsedScan.titles : []).map(normalizeTitleRecord); const cluster = buildBestEpisodeCluster(titles, options); const classifiedTitles = classifyTitles(titles, cluster, options); const summary = summarizeSeriesLikelihood(classifiedTitles, cluster); return { source: parsedScan.source || 'handbrake_scan_text', generatedAt: nowIso(), discTitle: parsedScan.discTitle || null, discSerial: parsedScan.discSerial || null, titleCount: Number(parsedScan.titleCount || titles.length || 0), discSignature: buildDiscSignature({ discTitle: parsedScan.discTitle, discSerial: parsedScan.discSerial, titleCount: parsedScan.titleCount, titles }), summary: { ...summary, titleCount: classifiedTitles.length, episodeCandidateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length, playAllCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length, duplicateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length, extraCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length, typicalEpisodeMinutes: cluster.medianDurationSeconds ? Number((cluster.medianDurationSeconds / 60).toFixed(2)) : 0 }, titles: classifiedTitles }; } function analyzeHandBrakeScan(rawOutput, options = {}) { const parsed = parseHandBrakeScanText(rawOutput); const analysis = analyzeParsedScan(parsed, options); return { parsed, analysis }; } module.exports = { TITLE_KIND, parseHandBrakeScanText, analyzeParsedScan, analyzeHandBrakeScan, deriveSeriesLookupHint };