const path = require('path'); const { splitArgs } = require('./commandLine'); const DEFAULT_AUDIO_COPY_MASK = ['aac', 'ac3', 'eac3', 'truehd', 'dts', 'dtshd', 'mp3', 'flac']; const DEFAULT_AUDIO_FALLBACK = 'av_aac'; const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({ low: 1, medium: 2, high: 3 }); const FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD = 0.35; const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35; const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220; const FORCED_SUBTITLE_MIN_EVENT_GAP = 12; const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024; const ISO2_TO_3_LANGUAGE = { de: 'deu', en: 'eng', fr: 'fra', es: 'spa', it: 'ita', tr: 'tur', pt: 'por', ru: 'rus', pl: 'pol', nl: 'nld', sv: 'swe', no: 'nor', da: 'dan', fi: 'fin', cs: 'ces', hu: 'hun', ro: 'ron', uk: 'ukr', ja: 'jpn', ko: 'kor', zh: 'zho', ar: 'ara' }; function clampNumber(value, fallback = 0) { const num = Number(value); if (Number.isFinite(num)) { return num; } return fallback; } function normalizeLanguage(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw || raw === 'und' || raw === 'unknown') { return 'und'; } if (raw.length === 2 && ISO2_TO_3_LANGUAGE[raw]) { return ISO2_TO_3_LANGUAGE[raw]; } if (raw.length === 3) { return raw; } if (raw.startsWith('de')) { return 'deu'; } if (raw.startsWith('en')) { return 'eng'; } if (raw.startsWith('fr')) { return 'fra'; } if (raw.startsWith('es')) { return 'spa'; } if (raw.startsWith('it')) { return 'ita'; } if (raw.length === 2) { return raw; } return raw.slice(0, 3); } function normalizeSelectionLanguage(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw) { return null; } if (raw === 'any' || raw === 'none') { return raw; } return normalizeLanguage(raw); } function parseDurationSeconds(raw) { if (raw === null || raw === undefined) { return 0; } const numeric = Number(raw); if (Number.isFinite(numeric) && numeric > 0) { if (numeric > 10000) { return Math.round(numeric / 1000); } return Math.round(numeric); } const text = String(raw).trim(); if (!text) { return 0; } let seconds = 0; const hourMatch = text.match(/(\d+(?:\.\d+)?)\s*h/i); const minuteMatch = text.match(/(\d+(?:\.\d+)?)\s*mn?/i); const secondMatch = text.match(/(\d+(?:\.\d+)?)\s*s/i); if (hourMatch || minuteMatch || secondMatch) { seconds += hourMatch ? Number(hourMatch[1]) * 3600 : 0; seconds += minuteMatch ? Number(minuteMatch[1]) * 60 : 0; seconds += secondMatch ? Number(secondMatch[1]) : 0; return Math.round(seconds); } const colonMatch = text.match(/(\d{1,2}):(\d{2}):(\d{2})/); if (colonMatch) { const h = Number(colonMatch[1]); const m = Number(colonMatch[2]); const s = Number(colonMatch[3]); return (h * 3600) + (m * 60) + s; } return 0; } function pickTrackId(track, fallbackIndex) { const rawId = track?.ID ?? track?.ID_String ?? track?.StreamOrder ?? track?.StreamOrder_String; if (rawId === undefined || rawId === null || rawId === '') { return fallbackIndex + 1; } const match = String(rawId).match(/\d+/); if (!match) { return fallbackIndex + 1; } return Number(match[0]); } function mapAudioFormatToCopyCodec(format) { const raw = String(format || '').toLowerCase(); if (!raw) { return null; } if (raw.includes('e-ac-3') || raw.includes('eac3') || raw.includes('dd+')) { return 'eac3'; } if (raw.includes('ac-3') || raw.includes('ac3') || raw.includes('dolby digital')) { return 'ac3'; } if (raw.includes('truehd')) { return 'truehd'; } if (raw.includes('dts-hd') || raw.includes('dtshd')) { return 'dtshd'; } if (raw.includes('dca')) { return 'dts'; } if (raw.includes('dts')) { return 'dts'; } if (raw.includes('aac')) { return 'aac'; } if (raw.includes('flac')) { return 'flac'; } if (raw.includes('mp3') || raw.includes('mpeg audio')) { return 'mp3'; } if (raw.includes('opus')) { return 'opus'; } if (raw.includes('pcm') || raw.includes('lpcm')) { return 'lpcm'; } return null; } 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 parseMakemkvTitleIdFromFileName(fileName) { const match = String(fileName || '').match(/_t(\d{1,3})\./i); if (!match) { return null; } const value = Number(match[1]); if (!Number.isFinite(value) || value < 0) { return null; } return value; } function emptyPlaylistMatch() { return { playlistId: null, playlistFile: null, recommended: false, evaluationLabel: null, segmentCommand: null, segmentFiles: [] }; } function resolvePlaylistMatchByPlaylistId(analysis, rawPlaylistId) { const playlistId = normalizePlaylistId(rawPlaylistId); if (!analysis || !playlistId) { return emptyPlaylistMatch(); } const recommendation = analysis.recommendation || null; const recommended = normalizePlaylistId(recommendation?.playlistId) === playlistId; const evaluated = (Array.isArray(analysis.evaluatedCandidates) ? analysis.evaluatedCandidates : []) .find((item) => normalizePlaylistId(item?.playlistId) === playlistId) || null; const segmentMap = (analysis.playlistSegments && typeof analysis.playlistSegments === 'object') ? analysis.playlistSegments : {}; const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null; const segmentFiles = Array.isArray(segmentEntry?.segmentFiles) ? segmentEntry.segmentFiles.filter((item) => String(item || '').trim().length > 0) : []; return { playlistId, playlistFile: `${playlistId}.mpls`, recommended, evaluationLabel: evaluated?.evaluationLabel || (recommended ? 'wahrscheinlich korrekt (Heuristik)' : null), segmentCommand: segmentEntry?.segmentCommand || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`, segmentFiles }; } function findPlaylistMatchForTitle(playlistAnalysis, makemkvTitleId) { const analysis = playlistAnalysis && typeof playlistAnalysis === 'object' ? playlistAnalysis : null; if (!analysis || makemkvTitleId === null || makemkvTitleId === undefined) { return emptyPlaylistMatch(); } const titles = Array.isArray(analysis.titles) ? analysis.titles : []; const mapping = titles.find((item) => Number(item?.titleId) === Number(makemkvTitleId)) || null; return resolvePlaylistMatchByPlaylistId(analysis, mapping?.playlistId || null); } function parseMediaInfoFile(mediaInfoJson, fileInfo, index) { const tracks = Array.isArray(mediaInfoJson?.media?.track) ? mediaInfoJson.media.track : []; const general = tracks.find((item) => String(item?.['@type'] || '').toLowerCase() === 'general') || {}; const durationSeconds = parseDurationSeconds(general?.Duration || general?.Duration_String3 || general?.Duration_String); const durationMinutes = Number((durationSeconds / 60).toFixed(2)); const fileName = path.basename(fileInfo.path); const audioTracks = tracks .filter((item) => String(item?.['@type'] || '').toLowerCase() === 'audio') .map((item, idx) => ({ id: idx + 1, sourceTrackId: pickTrackId(item, idx), language: normalizeLanguage(item?.Language || item?.Language_String3 || item?.Language_String || 'und'), languageLabel: item?.Language_String3 || item?.Language || item?.Language_String || 'und', title: item?.Title || null, format: item?.Format || null, codecToken: mapAudioFormatToCopyCodec(item?.Format || null), channels: item?.Channels || item?.Channel_s_ || null })); const subtitleTracksRaw = tracks .filter((item) => { const type = String(item?.['@type'] || '').toLowerCase(); return type === 'text' || type === 'subtitle'; }) .map((item, idx) => ({ id: idx + 1, sourceTrackId: pickTrackId(item, idx), language: normalizeLanguage(item?.Language || item?.Language_String3 || item?.Language_String || 'und'), languageLabel: item?.Language_String3 || item?.Language || item?.Language_String || 'und', title: item?.Title || null, format: item?.Format || null, defaultFlag: parseBooleanFlag(item?.Default ?? item?.Default_String ?? item?.IsDefault ?? item?.isDefault), forcedFlag: parseBooleanFlagNullable(item?.Forced ?? item?.Forced_String ?? item?.IsForced ?? item?.isForced), sdhFlag: parseSubtitleSdhFlag(item), eventCount: parseSubtitleEventCount(item), streamSizeBytes: parseSubtitleStreamSizeBytes(item) })); const subtitleTracks = annotateSubtitleTracks(subtitleTracksRaw); const videoTracks = tracks .filter((item) => String(item?.['@type'] || '').toLowerCase() === 'video') .map((item, idx) => ({ id: idx + 1, sourceTrackId: pickTrackId(item, idx), format: item?.Format || null, codecId: item?.CodecID || null, width: item?.Width || null, height: item?.Height || null, frameRate: item?.FrameRate || null })); return { id: index + 1, filePath: fileInfo.path, fileName, makemkvTitleId: parseMakemkvTitleIdFromFileName(fileName), sizeBytes: clampNumber(fileInfo.size, 0), durationSeconds, durationMinutes, audioTracks, subtitleTracks, videoTracks }; } function parseArgValue(args, index) { const token = args[index]; if (!token) { return { value: null, consumed: 0 }; } if (token.includes('=')) { return { value: token.slice(token.indexOf('=') + 1), consumed: 0 }; } if (index + 1 < args.length && !String(args[index + 1]).startsWith('-')) { return { value: args[index + 1], consumed: 1 }; } return { value: null, consumed: 0 }; } function parseList(raw, mapper = normalizeSelectionLanguage) { return String(raw || '') .split(',') .map((item) => mapper(item)) .filter(Boolean); } function parseTrackIdList(raw) { return String(raw || '') .split(',') .map((item) => item.trim()) .filter(Boolean) .map((item) => Number(item)) .filter((item) => Number.isFinite(item)); } function parseBooleanFlag(value) { return parseBooleanFlagNullable(value) === true; } function parseBooleanFlagNullable(value) { if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { return value === 1; } const raw = String(value || '').trim().toLowerCase(); if (!raw) { return null; } if (raw === 'yes' || raw === 'true' || raw === '1') { return true; } if (raw === 'no' || raw === 'false' || raw === '0') { return false; } return null; } function parseSubtitleForcedFlag(track) { if (!track || typeof track !== 'object') { return null; } const candidates = [ track?.forcedFlag, track?.forced, track?.Forced, track?.isForced, track?.IsForced, track?.forced_only, track?.forcedOnly, track?.Attributes?.Forced ]; for (const candidate of candidates) { const parsed = parseBooleanFlagNullable(candidate); if (parsed === true || parsed === false) { return parsed; } } return null; } function parseSubtitleSdhFlag(track) { if (!track || typeof track !== 'object') { return null; } const candidates = [ track?.sdhFlag, track?.hearingImpaired, track?.HearingImpaired, track?.isHearingImpaired, track?.IsHearingImpaired, track?.Hearing_Impaired, track?.closedCaptions, track?.ClosedCaptions, track?.Attributes?.HearingImpaired, track?.Attributes?.ClosedCaptions ]; for (const candidate of candidates) { const parsed = parseBooleanFlagNullable(candidate); if (parsed === true || parsed === false) { return parsed; } } const serviceKind = String(track?.serviceKind || track?.ServiceKind || '').trim().toLowerCase(); if (!serviceKind) { return null; } if (serviceKind.includes('sdh') || serviceKind.includes('cc') || serviceKind.includes('hearing')) { return true; } return null; } function parseSubtitleEventCount(track) { const candidates = [ track?.CountOfEvents, track?.countOfEvents, track?.EventCount, track?.eventCount, track?.ElementCount, track?.elementCount ]; for (const candidate of candidates) { const numeric = Number(candidate); if (Number.isFinite(numeric) && numeric >= 0) { return Math.trunc(numeric); } } return null; } function parseSubtitleStreamSizeBytes(track) { const numericCandidates = [ track?.StreamSize, track?.streamSize, track?.StreamSize_Original, track?.streamSizeOriginal, track?.Bytes, track?.bytes ]; for (const candidate of numericCandidates) { const numeric = Number(candidate); if (Number.isFinite(numeric) && numeric > 0) { return Math.trunc(numeric); } } const textCandidates = [ track?.StreamSize_String, track?.streamSizeString, track?.StreamSize_Original_String, track?.streamSizeOriginalString, track?.Size_String, track?.sizeString ]; for (const candidate of textCandidates) { const text = String(candidate || '').trim(); if (!text) { continue; } const match = text.match(/([0-9]+(?:[.,][0-9]+)?)\s*([kmgt]?b)/i); if (!match) { continue; } const value = Number(String(match[1]).replace(',', '.')); if (!Number.isFinite(value) || value <= 0) { continue; } const unit = String(match[2] || 'b').toLowerCase(); 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.trunc(value * factor)); } return null; } function normalizeSubtitleConfidence(raw) { const value = String(raw || '').trim().toLowerCase(); if (value === 'high' || value === 'medium' || value === 'low') { return value; } return 'low'; } function subtitleConfidenceScore(raw) { return SUBTITLE_CONFIDENCE_SCORES[normalizeSubtitleConfidence(raw)] || 0; } function collectSubtitleText(track) { return [ track?.title, track?.description, track?.name, track?.format, track?.label ] .map((value) => String(value || '').trim().toLowerCase()) .filter(Boolean) .join(' '); } function isLikelyBitmapSubtitleFormat(track) { const text = [ track?.format, track?.codec, track?.codecName, track?.title, track?.description, track?.name, track?.label ] .map((value) => String(value || '').trim().toLowerCase()) .filter(Boolean) .join(' '); if (!text) { return false; } return ( /\bpgs\b/.test(text) || /\bhdmv\b/.test(text) || /\bsup\b/.test(text) || /\bvobsub\b/.test(text) || /\bdvd[-_\s]?sub/.test(text) || /\bdvb[-_\s]?sub/.test(text) ); } function isLikelySdhSubtitleTrack(track) { const text = collectSubtitleText(track); if (!text) { return false; } return ( /\bsdh\b/.test(text) || /\bcc\b/.test(text) || /\bhoh\b/.test(text) || /\bcaptions?\b/.test(text) || /hard[-\s]?of[-\s]?hearing/.test(text) || /hearing\s+impaired/.test(text) || /(?:gehörlos|hoergeschaedigt|hörgeschädigt)/.test(text) ); } function isLikelyForcedSubtitleTrack(track) { const text = collectSubtitleText(track); if (!text) { return false; } if (isLikelySdhSubtitleTrack(track) || /\bnot forced\b/.test(text)) { return false; } return ( /\bforced(?:\s+only)?\b/.test(text) || /nur\s+erzwungen/.test(text) || /\berzwungen\b/.test(text) ); } function compareSubtitleTracksByForcedHeuristic(a, b) { const aSdh = Number(Boolean(a?.sdhLikely)); const bSdh = Number(Boolean(b?.sdhLikely)); if (aSdh !== bSdh) { return aSdh - bSdh; } const aDefault = Number(Boolean(a?.defaultFlag)); const bDefault = Number(Boolean(b?.defaultFlag)); if (aDefault !== bDefault) { return aDefault - bDefault; } const aEventKnown = Number.isFinite(a?.eventCount) ? 0 : 1; const bEventKnown = Number.isFinite(b?.eventCount) ? 0 : 1; if (aEventKnown !== bEventKnown) { return aEventKnown - bEventKnown; } if (aEventKnown === 0 && a.eventCount !== b.eventCount) { return a.eventCount - b.eventCount; } const aSizeKnown = Number.isFinite(a?.streamSizeBytes) ? 0 : 1; const bSizeKnown = Number.isFinite(b?.streamSizeBytes) ? 0 : 1; if (aSizeKnown !== bSizeKnown) { return aSizeKnown - bSizeKnown; } if (aSizeKnown === 0 && a.streamSizeBytes !== b.streamSizeBytes) { return a.streamSizeBytes - b.streamSizeBytes; } const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER; const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER; if (aTrackId !== bTrackId) { return aTrackId - bTrackId; } return a.originalIndex - b.originalIndex; } function compareSubtitleTracksForDedup(a, b) { const aSdh = Number(Boolean(a?.sdhLikely)); const bSdh = Number(Boolean(b?.sdhLikely)); if (aSdh !== bSdh) { return aSdh - bSdh; } const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag)); if (defaultDiff !== 0) { return defaultDiff; } const confidenceDiff = subtitleConfidenceScore(b?.sourceConfidence) - subtitleConfidenceScore(a?.sourceConfidence); if (confidenceDiff !== 0) { return confidenceDiff; } const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER; const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER; if (aTrackId !== bTrackId) { return aTrackId - bTrackId; } return a.originalIndex - b.originalIndex; } function isHeuristicForcedSubtitleCandidate(languageEntries, candidate) { if (!candidate) { return false; } if (!isLikelyBitmapSubtitleFormat(candidate)) { return false; } const comparable = (Array.isArray(languageEntries) ? languageEntries : []) .filter((entry) => entry !== candidate && !entry.sdhLikely && isLikelyBitmapSubtitleFormat(entry)); if (comparable.length === 0) { return false; } const candidateEventCount = Number(candidate?.eventCount); const comparableEventCounts = comparable .map((entry) => Number(entry?.eventCount)) .filter((value) => Number.isFinite(value) && value > 0); const maxComparableEventCount = comparableEventCounts.length > 0 ? Math.max(...comparableEventCounts) : null; const hasEventSignal = Number.isFinite(candidateEventCount) && candidateEventCount >= 0 && Number.isFinite(maxComparableEventCount) && maxComparableEventCount > 0 && candidateEventCount <= FORCED_SUBTITLE_MAX_EVENT_COUNT && (candidateEventCount / maxComparableEventCount) <= FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD && (maxComparableEventCount - candidateEventCount) >= FORCED_SUBTITLE_MIN_EVENT_GAP; const candidateStreamSize = Number(candidate?.streamSizeBytes); const comparableSizes = comparable .map((entry) => Number(entry?.streamSizeBytes)) .filter((value) => Number.isFinite(value) && value > 0); const maxComparableSize = comparableSizes.length > 0 ? Math.max(...comparableSizes) : null; const hasSizeSignal = Number.isFinite(candidateStreamSize) && candidateStreamSize > 0 && Number.isFinite(maxComparableSize) && maxComparableSize > 0 && (candidateStreamSize / maxComparableSize) <= FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD && (maxComparableSize - candidateStreamSize) >= FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES; const signalCount = Number(hasEventSignal) + Number(hasSizeSignal); if (signalCount === 0) { return false; } if (candidate.defaultFlag && signalCount < 2) { return false; } return true; } function annotateSubtitleTracks(subtitleTracks) { const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : []; if (tracks.length === 0) { return []; } const entries = tracks.map((track, index) => ({ ...track, language: normalizeLanguage(track?.language || track?.languageLabel || 'und'), defaultFlag: Boolean(track?.defaultFlag), forcedFlag: parseSubtitleForcedFlag(track), forcedTrack: false, sourceConfidence: null, confidenceSource: 'heuristic', duplicate: false, selected: false, subtitleType: 'full', forcedAvailable: false, forcedSourceTrackIds: [], sdhLikely: (parseSubtitleSdhFlag(track) === true) || Boolean(track?.sdhLikely) || isLikelySdhSubtitleTrack(track), originalIndex: index })); const byLanguage = new Map(); for (const entry of entries) { if (!byLanguage.has(entry.language)) { byLanguage.set(entry.language, []); } byLanguage.get(entry.language).push(entry); } for (const languageEntries of byLanguage.values()) { for (const entry of languageEntries) { const forcedByFlag = entry.forcedFlag === true; const forcedBlockedByFlag = entry.forcedFlag === false; const forcedByTitle = !entry.sdhLikely && !forcedBlockedByFlag && isLikelyForcedSubtitleTrack(entry); if (forcedByFlag) { entry.forcedTrack = true; entry.sourceConfidence = 'high'; entry.confidenceSource = 'explicit_flag'; } else if (forcedByTitle) { entry.forcedTrack = true; entry.sourceConfidence = 'medium'; entry.confidenceSource = 'title'; } } if (!languageEntries.some((entry) => entry.forcedTrack)) { const candidates = languageEntries .filter((entry) => !entry.sdhLikely && entry.forcedFlag !== false) .sort(compareSubtitleTracksByForcedHeuristic); const forcedCandidate = candidates[0] || null; if (forcedCandidate && isHeuristicForcedSubtitleCandidate(languageEntries, forcedCandidate)) { forcedCandidate.forcedTrack = true; forcedCandidate.sourceConfidence = 'low'; forcedCandidate.confidenceSource = 'heuristic'; } } for (const entry of languageEntries) { if (!entry.forcedTrack) { continue; } entry.sourceConfidence = normalizeSubtitleConfidence(entry.sourceConfidence || 'low'); } const forcedEntries = languageEntries.filter((entry) => entry.forcedTrack); const fullEntries = languageEntries.filter((entry) => !entry.forcedTrack && !entry.sdhLikely); const sdhEntries = languageEntries.filter((entry) => !entry.forcedTrack && entry.sdhLikely); const forcedWinner = forcedEntries.length > 0 ? [...forcedEntries].sort(compareSubtitleTracksForDedup)[0] : null; const fullWinner = fullEntries.length > 0 ? [...fullEntries].sort(compareSubtitleTracksForDedup)[0] : null; const sdhWinner = sdhEntries.length > 0 ? [...sdhEntries].sort(compareSubtitleTracksForDedup)[0] : null; const forcedSourceTrackIds = forcedEntries .map((entry) => Number(entry?.sourceTrackId ?? entry?.id)) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.trunc(value)); const forcedAvailable = Boolean(forcedWinner); for (const entry of languageEntries) { const typeWinner = entry.forcedTrack ? forcedWinner : (entry.sdhLikely ? sdhWinner : fullWinner); entry.duplicate = Boolean(typeWinner && typeWinner !== entry); if (entry.forcedTrack) { entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate); } else if (entry.sdhLikely) { entry.selected = Boolean(!fullWinner && typeWinner && typeWinner === entry && !entry.duplicate); } else { entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate); } entry.subtitleType = entry.forcedTrack ? 'forced' : 'full'; entry.forcedAvailable = forcedAvailable; entry.forcedSourceTrackIds = forcedSourceTrackIds; const fullHasForced = !entry.forcedTrack && Boolean( entry.fullHasForced ?? entry.subtitleFullHasForced ?? entry.hasForcedVariant ); const explicitForcedOnly = entry.isForcedOnly ?? entry.forcedOnly ?? entry.subtitlePreviewForcedOnly; entry.isForcedOnly = typeof explicitForcedOnly === 'boolean' ? explicitForcedOnly : (entry.forcedTrack && !fullHasForced); entry.fullHasForced = fullHasForced; } } return entries .sort((a, b) => a.originalIndex - b.originalIndex) .map((entry) => { const { originalIndex, ...rest } = entry; return rest; }); } function parseEncoderList(raw) { return String(raw || '') .split(',') .map((item) => item.trim().toLowerCase()) .filter(Boolean); } function parseCopyMaskList(raw) { return String(raw || '') .split(',') .map((item) => String(item || '').trim().toLowerCase()) .map((item) => item.replace(/^copy:/, '')) .filter(Boolean); } function normalizeTrackSelectionMode(raw, trackType) { const value = String(raw || '').trim().toLowerCase(); if (value === 'all') { return 'all'; } if (value === 'first') { return 'first'; } if (value === 'none') { return 'none'; } if (value === 'language') { return 'language'; } return trackType === 'audio' ? 'first' : 'none'; } function normalizeBurnBehavior(raw) { const value = String(raw || '').trim().toLowerCase(); if (!value || value === 'none') { return 'none'; } if (value === 'foreign' || value === 'foreign_first') { return 'first'; } if (value === 'first') { return 'first'; } return 'none'; } function buildBaseTrackSelectors(settings, presetProfile = null) { const profile = presetProfile && typeof presetProfile === 'object' ? presetProfile : {}; const audioLanguages = Array.isArray(profile.audioLanguages) ? profile.audioLanguages.map((item) => normalizeSelectionLanguage(item)).filter(Boolean) : []; const subtitleLanguages = Array.isArray(profile.subtitleLanguages) ? profile.subtitleLanguages.map((item) => normalizeSelectionLanguage(item)).filter(Boolean) : []; const audioEncoders = Array.isArray(profile.audioEncoders) ? profile.audioEncoders.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) : []; const rawCopyMask = Array.isArray(profile.audioCopyMask) ? profile.audioCopyMask : []; const normalizedCopyMask = rawCopyMask .map((item) => String(item || '').trim().toLowerCase()) .map((item) => item.replace(/^copy:/, '')) .filter(Boolean); const baseAudioMode = normalizeTrackSelectionMode(profile.audioTrackSelectionBehavior, 'audio'); const baseSubtitleMode = normalizeTrackSelectionMode(profile.subtitleTrackSelectionBehavior, 'subtitle'); return { preset: settings?.handbrake_preset || null, extraArgs: settings?.handbrake_extra_args || '', presetProfileSource: profile.source || 'fallback', presetProfileMessage: profile.message || null, audio: { mode: baseAudioMode, languages: audioLanguages.filter((item) => item !== 'none'), explicitIds: [], firstOnly: baseAudioMode === 'first', selectionSource: profile.source === 'preset-export' ? 'preset' : 'default', encoders: audioEncoders, encoderSource: audioEncoders.length > 0 ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default', copyMask: normalizedCopyMask.length > 0 ? normalizedCopyMask : [...DEFAULT_AUDIO_COPY_MASK], copyMaskSource: normalizedCopyMask.length > 0 ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default', fallbackEncoder: String(profile.audioFallback || DEFAULT_AUDIO_FALLBACK).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK, fallbackSource: profile.audioFallback ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default' }, subtitle: { mode: baseSubtitleMode, languages: subtitleLanguages.filter((item) => item !== 'none'), explicitIds: [], firstOnly: baseSubtitleMode === 'first', selectionSource: profile.source === 'preset-export' ? 'preset' : 'default', // 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, forcedOnly: false } }; } function applyArgOverrides(selectors, args) { const audio = selectors.audio; const subtitle = selectors.subtitle; for (let i = 0; i < args.length; i += 1) { const token = args[i]; if (token === '--all-audio') { audio.mode = 'all'; audio.firstOnly = false; audio.selectionSource = 'args'; continue; } if (token === '--first-audio') { audio.firstOnly = true; if (audio.mode !== 'explicit' && audio.mode !== 'language') { audio.mode = 'first'; } audio.selectionSource = 'args'; continue; } if (token === '--audio' || token.startsWith('--audio=') || token === '-a' || token.startsWith('-a=')) { const parsed = parseArgValue(args, i); const raw = String(parsed.value || '').trim().toLowerCase(); if (raw === 'none') { audio.mode = 'none'; audio.explicitIds = []; } else { audio.explicitIds = parseTrackIdList(parsed.value); audio.mode = 'explicit'; } audio.firstOnly = false; audio.selectionSource = 'args'; i += parsed.consumed; continue; } if (token === '--audio-lang-list' || token.startsWith('--audio-lang-list=')) { const parsed = parseArgValue(args, i); const langs = parseList(parsed.value, normalizeSelectionLanguage).filter((item) => item !== 'none'); if (langs.includes('any')) { audio.mode = 'all'; audio.languages = []; } else { audio.mode = 'language'; audio.languages = langs; } audio.selectionSource = 'args'; i += parsed.consumed; continue; } if (token === '--aencoder' || token.startsWith('--aencoder=') || token === '-E' || token.startsWith('-E=')) { const parsed = parseArgValue(args, i); const encoders = parseEncoderList(parsed.value); if (encoders.length > 0) { audio.encoders = encoders; audio.encoderSource = 'args'; } i += parsed.consumed; continue; } if (token === '--audio-copy-mask' || token.startsWith('--audio-copy-mask=')) { const parsed = parseArgValue(args, i); audio.copyMask = parseCopyMaskList(parsed.value); audio.copyMaskSource = 'args'; i += parsed.consumed; continue; } if (token === '--audio-fallback' || token.startsWith('--audio-fallback=')) { const parsed = parseArgValue(args, i); const fallback = String(parsed.value || '').trim().toLowerCase(); if (fallback) { audio.fallbackEncoder = fallback; audio.fallbackSource = 'args'; } i += parsed.consumed; continue; } if (token === '--all-subtitles') { subtitle.mode = 'all'; subtitle.firstOnly = false; subtitle.selectionSource = 'args'; continue; } if (token === '--first-subtitle') { subtitle.firstOnly = true; if (subtitle.mode !== 'explicit' && subtitle.mode !== 'language') { subtitle.mode = 'first'; } subtitle.selectionSource = 'args'; continue; } if (token === '--subtitle' || token.startsWith('--subtitle=') || token === '-s' || token.startsWith('-s=')) { const parsed = parseArgValue(args, i); const raw = String(parsed.value || '').trim().toLowerCase(); if (raw === 'none') { subtitle.mode = 'none'; subtitle.explicitIds = []; } else { subtitle.explicitIds = parseTrackIdList(parsed.value); subtitle.mode = 'explicit'; } subtitle.firstOnly = false; subtitle.selectionSource = 'args'; i += parsed.consumed; continue; } if (token === '--subtitle-lang-list' || token.startsWith('--subtitle-lang-list=')) { const parsed = parseArgValue(args, i); const langs = parseList(parsed.value, normalizeSelectionLanguage).filter((item) => item !== 'none'); if (langs.includes('any')) { subtitle.mode = 'all'; subtitle.languages = []; } else { subtitle.mode = 'language'; subtitle.languages = langs; } subtitle.selectionSource = 'args'; i += parsed.consumed; continue; } if (token === '--subtitle-burned' || token.startsWith('--subtitle-burned=')) { const parsed = parseArgValue(args, i); const specificTrackId = parsed.value ? Number(parsed.value) : null; if (Number.isFinite(specificTrackId) && specificTrackId > 0) { subtitle.burnedTrackId = specificTrackId; } else { subtitle.burnBehavior = 'first'; } i += parsed.consumed; continue; } if (token === '--subtitle-default' || token.startsWith('--subtitle-default=')) { const parsed = parseArgValue(args, i); const specificTrackId = parsed.value ? Number(parsed.value) : null; if (Number.isFinite(specificTrackId) && specificTrackId > 0) { subtitle.defaultTrackId = specificTrackId; } i += parsed.consumed; continue; } if (token === '--subtitle-forced' || token.startsWith('--subtitle-forced=')) { subtitle.forcedOnly = true; const parsed = parseArgValue(args, i); const specificTrackId = parsed.value ? Number(parsed.value) : null; if (Number.isFinite(specificTrackId) && specificTrackId > 0) { subtitle.forcedTrackId = specificTrackId; } i += parsed.consumed; } } } function buildTrackSelectors(settings, presetProfile) { const selectors = buildBaseTrackSelectors(settings || {}, presetProfile || null); const args = splitArgs(settings?.handbrake_extra_args || ''); applyArgOverrides(selectors, args); if (selectors.audio.mode === 'language' && selectors.audio.languages.length === 0) { selectors.audio.mode = selectors.audio.firstOnly ? 'first' : 'all'; } if (selectors.subtitle.mode === 'language' && selectors.subtitle.languages.length === 0) { selectors.subtitle.mode = selectors.subtitle.firstOnly ? 'first' : 'none'; } return selectors; } function selectTrackIds(tracks, selector, trackType) { const available = Array.isArray(tracks) ? tracks : []; const selectable = trackType === 'subtitle' ? available.filter((track) => !Boolean(track?.duplicate)) : available; if (selectable.length === 0) { return []; } if (selector.mode === 'none') { return []; } if (selector.mode === 'all') { if (selector.firstOnly) { return [selectable[0].id]; } return selectable.map((track) => track.id); } if (selector.mode === 'explicit') { const explicit = selectable .filter((track) => selector.explicitIds.includes(track.id)) .map((track) => track.id); if (selector.firstOnly) { return explicit.length > 0 ? [explicit[0]] : []; } return explicit; } if (selector.mode === 'language') { const matches = selectable.filter((track) => selector.languages.includes(track.language)); if (selector.firstOnly) { return matches.length > 0 ? [matches[0].id] : []; } return matches.map((track) => track.id); } if (selector.mode === 'first') { return [selectable[0].id]; } if (trackType === 'audio') { return [selectable[0].id]; } return []; } function resolveAudioEncoderAction(track, encoderToken, copyMask, fallbackEncoder) { const normalizedToken = String(encoderToken || '').trim().toLowerCase(); const sourceCodec = track?.codecToken || null; if (!normalizedToken || normalizedToken === 'preset-default') { return { type: 'preset-default', encoder: 'preset-default', label: 'Preset-Default (HandBrake)' }; } if (normalizedToken.startsWith('copy')) { const explicitCopyCodec = normalizedToken.includes(':') ? normalizedToken.split(':').slice(1).join(':').trim().toLowerCase() : null; const normalizedMask = Array.isArray(copyMask) ? copyMask : []; let canCopy = false; let effectiveCodec = sourceCodec; if (explicitCopyCodec) { canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec); } else if (sourceCodec && normalizedMask.length > 0) { canCopy = normalizedMask.includes(sourceCodec); // DTS-HD MA contains an embedded DTS core track. When dtshd is not in // the copy mask but dts is, HandBrake will extract and copy the DTS core. if (!canCopy && sourceCodec === 'dtshd' && normalizedMask.includes('dts')) { canCopy = true; effectiveCodec = 'dts'; } } if (canCopy) { return { type: 'copy', encoder: normalizedToken, label: `Copy (${effectiveCodec || track?.format || 'Quelle'})` }; } const fallback = String(fallbackEncoder || DEFAULT_AUDIO_FALLBACK).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK; return { type: 'fallback', encoder: fallback, label: `Fallback Transcode (${fallback})` }; } return { type: 'transcode', encoder: normalizedToken, label: `Transcode (${normalizedToken})` }; } function computeAudioTrackActions(track, selectedIndex, selector) { const availableEncoders = Array.isArray(selector.encoders) ? selector.encoders : []; let encoderPlan = []; if (selector.encoderSource === 'args' && availableEncoders.length > 0) { const chosen = availableEncoders[Math.min(selectedIndex, availableEncoders.length - 1)]; encoderPlan = [chosen]; } else if (availableEncoders.length > 0) { encoderPlan = [...availableEncoders]; } else { encoderPlan = ['preset-default']; } const actions = encoderPlan.map((encoderToken) => resolveAudioEncoderAction( track, encoderToken, selector.copyMask, selector.fallbackEncoder )); return { actions, summary: actions.map((item) => item.label).join(' + ') }; } function refreshAudioTrackActionsForPlanTitles(titles, settings = {}, presetProfile = null) { const sourceTitles = Array.isArray(titles) ? titles : []; if (sourceTitles.length === 0) { return sourceTitles; } const selectors = buildTrackSelectors(settings || {}, presetProfile || null); const audioSelector = selectors?.audio && typeof selectors.audio === 'object' ? selectors.audio : {}; return sourceTitles.map((title) => { const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : []; if (audioTracks.length === 0) { return title; } const selectedTracks = audioTracks.filter((track) => Boolean(track?.selectedForEncode)); const selectedIndexById = new Map( selectedTracks .map((track, index) => { const trackId = Number(track?.id); if (!Number.isFinite(trackId)) { return null; } return [Math.trunc(trackId), index]; }) .filter(Boolean) ); const nextAudioTracks = audioTracks.map((track) => { if (!track?.selectedForEncode) { return { ...track, encodeActions: [], encodeActionSummary: 'Nicht übernommen' }; } const numericTrackId = Number(track?.id); const normalizedTrackId = Number.isFinite(numericTrackId) ? Math.trunc(numericTrackId) : null; const selectedIndex = (normalizedTrackId !== null && selectedIndexById.has(normalizedTrackId)) ? selectedIndexById.get(normalizedTrackId) : 0; const actions = computeAudioTrackActions(track, selectedIndex, audioSelector); return { ...track, encodePreviewActions: actions.actions, encodePreviewSummary: actions.summary, encodeActions: actions.actions, encodeActionSummary: actions.summary }; }); return { ...title, audioTracks: nextAudioTracks }; }); } function computeSubtitleFlags(trackId, selectedTrackIds, selector) { const selected = selectedTrackIds.includes(trackId); if (!selected) { return { burned: false, forced: false, forcedOnly: false, default: false, flags: [] }; } const firstSelectedId = selectedTrackIds[0] || null; const burned = selector.burnedTrackId ? trackId === selector.burnedTrackId : selector.burnBehavior === 'first' && trackId === firstSelectedId; const forced = selector.forcedTrackId ? trackId === selector.forcedTrackId : false; const forcedOnly = Boolean(selector.forcedOnly); const isDefault = selector.defaultTrackId ? trackId === selector.defaultTrackId : false; const flags = []; if (burned) { flags.push('burned'); } if (forced) { flags.push('forced'); } if (forcedOnly) { flags.push('forced-only'); } if (isDefault) { flags.push('default'); } return { burned, forced, forcedOnly, default: isDefault, flags }; } function buildMediainfoReview({ mediaFiles, mediaInfoByPath, settings, presetProfile, playlistAnalysis = null, preferredEncodeTitleId = null, selectedPlaylistId = null, selectedMakemkvTitleId = null }) { const minLengthMinutes = clampNumber(settings?.makemkv_min_length_minutes, 0); const minDurationSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); const trackSelectors = buildTrackSelectors(settings || {}, presetProfile || null); const lockedPlaylistId = normalizePlaylistId(selectedPlaylistId); const manualSelectionMakemkvTitle = Number(selectedMakemkvTitleId); const selectedPlaylistMatch = lockedPlaylistId ? resolvePlaylistMatchByPlaylistId(playlistAnalysis, lockedPlaylistId) : null; const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired && !lockedPlaylistId); const titles = (mediaFiles || []).map((file, index) => { const parsed = parseMediaInfoFile(mediaInfoByPath[file.path] || {}, file, index); let playlistMatch = findPlaylistMatchForTitle(playlistAnalysis, parsed.makemkvTitleId); if (lockedPlaylistId) { const hasMappedPlaylist = Boolean(normalizePlaylistId(playlistMatch?.playlistId)); if (!hasMappedPlaylist || selectedPlaylistMatch?.playlistId) { playlistMatch = selectedPlaylistMatch || { ...emptyPlaylistMatch(), playlistId: lockedPlaylistId, playlistFile: `${lockedPlaylistId}.mpls`, segmentCommand: `strings BDMV/PLAYLIST/${lockedPlaylistId}.mpls | grep m2ts` }; } } return { ...parsed, selectedByMinLength: parsed.durationSeconds >= minDurationSeconds, playlistMatch }; }); const selectedTitleIds = titles .filter((title) => title.selectedByMinLength) .map((title) => title.id); const candidateTitles = titles.filter((title) => selectedTitleIds.includes(title.id)); const lockedCandidates = lockedPlaylistId ? candidateTitles.filter((item) => normalizePlaylistId(item?.playlistMatch?.playlistId) === lockedPlaylistId) : []; const preferredTitleId = Number(preferredEncodeTitleId); const preferredTitle = Number.isFinite(preferredTitleId) && preferredTitleId >= 0 ? candidateTitles.find((item) => Number(item.makemkvTitleId) === preferredTitleId) || null : null; const preferredByManualSelection = Number.isFinite(manualSelectionMakemkvTitle) && manualSelectionMakemkvTitle >= 0 ? candidateTitles.find((item) => Number(item.makemkvTitleId) === manualSelectionMakemkvTitle) || null : null; let encodeInputTitle = null; if (preferredByManualSelection && (!lockedPlaylistId || lockedCandidates.includes(preferredByManualSelection))) { encodeInputTitle = preferredByManualSelection; } else if (preferredTitle && (!lockedPlaylistId || lockedCandidates.includes(preferredTitle))) { encodeInputTitle = preferredTitle; } else if (lockedPlaylistId && lockedCandidates.length > 0) { encodeInputTitle = lockedCandidates.reduce((best, current) => ( !best || current.sizeBytes > best.sizeBytes ? current : best ), null); } else if (!playlistDecisionRequired) { encodeInputTitle = candidateTitles.reduce((best, current) => ( !best || current.sizeBytes > best.sizeBytes ? current : best ), null); } let normalizedTitles = titles.map((title) => { const isEncodeInput = encodeInputTitle ? title.id === encodeInputTitle.id : false; const selectedAudioIds = selectTrackIds(title.audioTracks, trackSelectors.audio, 'audio'); const selectedSubtitleIds = selectTrackIds(title.subtitleTracks, trackSelectors.subtitle, 'subtitle'); const audioIndexById = new Map(selectedAudioIds.map((id, index) => [id, index])); const normalizedAudio = title.audioTracks.map((track) => { const selectedByRule = selectedAudioIds.includes(track.id); if (!selectedByRule) { return { ...track, selectedByRule: false, encodePreviewActions: [], encodePreviewSummary: 'Nicht übernommen' }; } const selectedIndex = audioIndexById.get(track.id) || 0; const actions = computeAudioTrackActions(track, selectedIndex, trackSelectors.audio); return { ...track, selectedByRule: true, encodePreviewActions: actions.actions, encodePreviewSummary: actions.summary }; }); const normalizedSubtitle = title.subtitleTracks.map((track) => { const selectedByRule = selectedSubtitleIds.includes(track.id); const subtitleFlags = computeSubtitleFlags(track.id, selectedSubtitleIds, trackSelectors.subtitle); const inferredForced = Boolean( track?.forcedTrack || String(track?.subtitleType || '').trim().toLowerCase() === 'forced' ); const inferredForcedOnly = Boolean( track?.isForcedOnly ?? track?.forcedOnly ?? track?.subtitlePreviewForcedOnly ?? inferredForced ); const inferredDefault = Boolean(track?.defaultFlag); const subtitlePreviewFlags = []; if (subtitleFlags.burned) { subtitlePreviewFlags.push('burned'); } if (subtitleFlags.forced || inferredForced) { subtitlePreviewFlags.push('forced'); } if (subtitleFlags.forcedOnly || inferredForcedOnly) { subtitlePreviewFlags.push('forced-only'); } if (subtitleFlags.default || inferredDefault) { subtitlePreviewFlags.push('default'); } const subtitlePreviewSummary = !selectedByRule ? 'Nicht übernommen' : (subtitlePreviewFlags.length > 0 ? `Übernehmen (${subtitlePreviewFlags.join(', ')})` : 'Übernehmen'); return { ...track, selectedByRule, subtitlePreviewSummary, subtitlePreviewFlags: selectedByRule ? subtitlePreviewFlags : [], subtitlePreviewBurnIn: subtitleFlags.burned, subtitlePreviewForced: selectedByRule ? (subtitleFlags.forced || inferredForced) : false, subtitlePreviewForcedOnly: selectedByRule ? (subtitleFlags.forcedOnly || inferredForcedOnly) : false, subtitlePreviewDefaultTrack: selectedByRule ? (subtitleFlags.default || inferredDefault) : false }; }); return { ...title, selectedForEncode: isEncodeInput, encodeInput: isEncodeInput, eligibleForEncode: title.selectedByMinLength, playlistId: title.playlistMatch?.playlistId || null, playlistFile: title.playlistMatch?.playlistFile || null, playlistRecommended: Boolean(title.playlistMatch?.recommended), playlistEvaluationLabel: title.playlistMatch?.evaluationLabel || null, playlistSegmentCommand: title.playlistMatch?.segmentCommand || null, playlistSegmentFiles: Array.isArray(title.playlistMatch?.segmentFiles) ? title.playlistMatch.segmentFiles : [], audioTracks: normalizedAudio.map((track) => { const selectedForEncode = isEncodeInput && track.selectedByRule; return { ...track, selectedForEncode, encodeActions: selectedForEncode ? track.encodePreviewActions : [], encodeActionSummary: selectedForEncode ? track.encodePreviewSummary : 'Nicht übernommen' }; }), subtitleTracks: normalizedSubtitle.map((track) => { const selectedForEncode = isEncodeInput && track.selectedByRule; return { ...track, selectedForEncode, burnIn: selectedForEncode ? track.subtitlePreviewBurnIn : false, forced: selectedForEncode ? track.subtitlePreviewForced : false, forcedOnly: selectedForEncode ? track.subtitlePreviewForcedOnly : false, defaultTrack: selectedForEncode ? track.subtitlePreviewDefaultTrack : false, flags: selectedForEncode ? track.subtitlePreviewFlags : [], subtitleActionSummary: selectedForEncode ? track.subtitlePreviewSummary : 'Nicht übernommen' }; }) }; }); if (lockedPlaylistId && encodeInputTitle) { normalizedTitles = normalizedTitles.filter((item) => item.id === encodeInputTitle.id); } const encodeInputPath = encodeInputTitle ? encodeInputTitle.filePath : null; const notes = [ `Preset: ${trackSelectors.preset || '-'}`, `Extra Args: ${trackSelectors.extraArgs || '(keine)'}`, `Preset-Quelle: ${trackSelectors.presetProfileSource}`, 'Preset-Defaults werden als Basis genutzt. HB_ARGS überschreibt diese, sobald Optionen gesetzt sind.' ]; if (trackSelectors.presetProfileMessage) { notes.push(`Preset-Hinweis: ${trackSelectors.presetProfileMessage}`); } if (lockedPlaylistId) { notes.push(`Manuelle Playlist-Auswahl aktiv: ${lockedPlaylistId}.mpls`); } const recommendedPlaylistId = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId || null); const recommendedMakemkvTitleId = Number(playlistAnalysis?.recommendation?.titleId); const recommendedReviewTitle = normalizedTitles.find((item) => item.playlistId === recommendedPlaylistId) || (Number.isFinite(recommendedMakemkvTitleId) ? normalizedTitles.find((item) => Number(item.makemkvTitleId) === recommendedMakemkvTitleId) : null); return { generatedAt: new Date().toISOString(), minLengthMinutes, selectors: trackSelectors, playlistDecisionRequired, playlistRecommendation: recommendedPlaylistId ? { playlistId: recommendedPlaylistId, playlistFile: `${recommendedPlaylistId}.mpls`, makemkvTitleId: Number.isFinite(recommendedMakemkvTitleId) ? recommendedMakemkvTitleId : null, reviewTitleId: recommendedReviewTitle?.id || null, reason: playlistAnalysis?.recommendation?.reason || null } : null, titles: normalizedTitles, selectedTitleIds, encodeInputTitleId: encodeInputTitle?.id || null, encodeInputPath, titleSelectionRequired: Boolean(playlistDecisionRequired && !encodeInputPath), notes }; } module.exports = { parseDurationSeconds, buildMediainfoReview, refreshAudioTrackActionsForPlanTitles };