import { useEffect, useMemo, useRef, useState } from 'react'; import { Card } from 'primereact/card'; import { Tag } from 'primereact/tag'; import { ProgressBar } from 'primereact/progressbar'; import { Button } from 'primereact/button'; import { Dropdown } from 'primereact/dropdown'; import MediaInfoReviewPanel from './MediaInfoReviewPanel'; import { api } from '../api/client'; import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; import { confirmModal } from '../utils/confirmModal'; const CONVERTER_VIDEO_OUTPUT_FORMATS = [ { label: 'MKV', value: 'mkv' }, { label: 'MP4', value: 'mp4' }, { label: 'M4V', value: 'm4v' } ]; function normalizeTitleId(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function normalizeTitleIdList(values) { const list = Array.isArray(values) ? values : []; const seen = new Set(); const output = []; for (const value of list) { const normalized = normalizeTitleId(value); if (normalized === null) { continue; } const key = String(normalized); if (seen.has(key)) { continue; } seen.add(key); output.push(normalized); } return output; } function normalizeJobId(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function normalizePlaylistId(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw) { return null; } const match = raw.match(/(\d{1,5})(?:\.mpls)?$/i); return match ? String(match[1]).padStart(5, '0') : null; } function formatDurationClock(seconds) { const total = Number(seconds || 0); if (!Number.isFinite(total) || total <= 0) { return null; } const rounded = Math.max(0, Math.trunc(total)); const h = Math.floor(rounded / 3600); const m = Math.floor((rounded % 3600) / 60); const s = rounded % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } function normalizeTrackId(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function normalizeTrackIdList(values) { const list = Array.isArray(values) ? values : []; const seen = new Set(); const output = []; for (const value of list) { const normalized = normalizeTrackId(value); if (normalized === null) { continue; } const key = String(normalized); if (seen.has(key)) { continue; } seen.add(key); output.push(normalized); } return output; } function normalizeScriptId(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function normalizeScriptIdList(values) { const list = Array.isArray(values) ? values : []; const seen = new Set(); const output = []; for (const value of list) { const normalized = normalizeScriptId(value); if (normalized === null) { continue; } const key = String(normalized); if (seen.has(key)) { continue; } seen.add(key); output.push(normalized); } return output; } function normalizeChainId(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function normalizeMediaProfile(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw) { return null; } if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) { return 'bluray'; } if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) { return 'dvd'; } if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) { return 'audiobook'; } if (raw === 'converter') { return 'converter'; } if (['other', 'sonstiges', 'unknown'].includes(raw)) { return 'other'; } return null; } function resolvePipelineMediaProfile(pipeline, mediaInfoReview) { const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {}; const device = context?.device && typeof context.device === 'object' ? context.device : {}; const review = mediaInfoReview && typeof mediaInfoReview === 'object' ? mediaInfoReview : {}; const candidates = [ context?.mediaProfile, context?.media_profile, review?.mediaProfile, review?.media_profile, device?.mediaProfile, device?.media_profile, device?.profile, device?.type ]; for (const candidate of candidates) { const normalized = normalizeMediaProfile(candidate); if (normalized) { return normalized; } } return null; } function isBurnedSubtitleTrack(track) { const flags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : (Array.isArray(track?.flags) ? track.flags : []); const hasBurnedFlag = flags.some((flag) => String(flag || '').trim().toLowerCase() === 'burned'); const summary = `${track?.subtitlePreviewSummary || ''} ${track?.subtitleActionSummary || ''}`; return Boolean( track?.subtitlePreviewBurnIn || track?.burnIn || hasBurnedFlag || /burned/i.test(summary) ); } function isDuplicateSubtitleTrack(track) { return Boolean(track?.duplicate); } function normalizeSubtitleLanguage(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw) { return 'und'; } if (raw.length >= 3) { return raw.slice(0, 3); } return raw; } function normalizeSubtitleVariantSelectionMap(rawSelection) { const map = {}; if (!rawSelection || typeof rawSelection !== 'object') { return map; } for (const [language, value] of Object.entries(rawSelection)) { const normalizedLanguage = normalizeSubtitleLanguage(language); const full = Boolean(value?.full); const forced = Boolean(value?.forced); map[normalizedLanguage] = { full, forced }; } return map; } function normalizeSubtitleLanguageOrder(rawOrder = []) { const list = Array.isArray(rawOrder) ? rawOrder : []; const seen = new Set(); const output = []; for (const value of list) { const language = normalizeSubtitleLanguage(value); if (!language || seen.has(language)) { continue; } seen.add(language); output.push(language); } return output; } function resolveSubtitleTrackVariantFlags(track) { const subtitleType = String(track?.subtitleType || '').trim().toLowerCase(); const isForcedOnly = Boolean( track?.isForcedOnly ?? track?.subtitlePreviewForcedOnly ?? track?.forcedOnly ?? subtitleType === 'forced' ); const fullHasForced = !isForcedOnly && Boolean( track?.fullHasForced ?? track?.subtitleFullHasForced ?? track?.hasForcedVariant ); return { isForcedOnly, fullHasForced }; } function buildDefaultSubtitleVariantSelection(subtitleTracks, selectedTracks) { const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : []; const selected = Array.isArray(selectedTracks) ? selectedTracks : []; const orderedTracks = [...tracks].sort((a, b) => { const aId = normalizeTrackId(a?.id) ?? Number.MAX_SAFE_INTEGER; const bId = normalizeTrackId(b?.id) ?? Number.MAX_SAFE_INTEGER; if (aId !== bId) { return aId - bId; } return String(a?.language || a?.languageLabel || '').localeCompare(String(b?.language || b?.languageLabel || '')); }); const order = []; const orderSeen = new Set(); for (const track of orderedTracks) { const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und'); if (!orderSeen.has(language)) { orderSeen.add(language); order.push(language); } } const selectionMap = {}; for (const track of selected) { if (isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track)) { continue; } const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und'); if (!selectionMap[language]) { selectionMap[language] = { full: false, forced: false }; } const flags = resolveSubtitleTrackVariantFlags(track); if (flags.isForcedOnly) { selectionMap[language].forced = true; } else { selectionMap[language].full = true; } } return { subtitleVariantSelection: selectionMap, subtitleLanguageOrder: order }; } function buildDefaultTrackSelection(review) { const titles = Array.isArray(review?.titles) ? review.titles : []; const selection = {}; const reviewEncodeInputTitleId = normalizeTitleId(review?.encodeInputTitleId); const manualSelection = review?.manualTrackSelection && typeof review.manualTrackSelection === 'object' ? review.manualTrackSelection : null; const manualTitleId = normalizeTitleId(manualSelection?.titleId); for (const title of titles) { const titleId = normalizeTitleId(title?.id); if (!titleId) { continue; } const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : []; const subtitleTracks = Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []; const isEncodeInputTitle = Boolean( title?.selectedForEncode || title?.encodeInput || (reviewEncodeInputTitleId && reviewEncodeInputTitleId === titleId) ); const audioSelectionSource = isEncodeInputTitle ? audioTracks.filter((track) => Boolean(track?.selectedForEncode)) : audioTracks.filter((track) => Boolean(track?.selectedByRule)); const subtitleSelectionSource = isEncodeInputTitle ? subtitleTracks.filter((track) => Boolean(track?.selectedForEncode)) : subtitleTracks.filter((track) => Boolean(track?.selectedByRule)); const defaultSubtitleVariantSelection = buildDefaultSubtitleVariantSelection( subtitleTracks, subtitleSelectionSource ); const manualSelectionMatchesTitle = Boolean( isEncodeInputTitle && manualSelection && (!manualTitleId || manualTitleId === titleId) ); const manualAudioTrackIds = manualSelectionMatchesTitle ? normalizeTrackIdList(manualSelection?.audioTrackIds || []) : []; const manualSubtitleTrackIds = manualSelectionMatchesTitle ? normalizeTrackIdList( Array.isArray(manualSelection?.subtitleTrackIdsOrdered) ? manualSelection.subtitleTrackIdsOrdered : manualSelection?.subtitleTrackIds || [] ) : []; const manualSubtitleVariantSelection = manualSelectionMatchesTitle ? normalizeSubtitleVariantSelectionMap(manualSelection?.subtitleVariantSelection || {}) : {}; const manualSubtitleLanguageOrder = manualSelectionMatchesTitle ? normalizeSubtitleLanguageOrder(manualSelection?.subtitleLanguageOrder || []) : []; selection[titleId] = { audioTrackIds: manualAudioTrackIds.length > 0 ? manualAudioTrackIds : normalizeTrackIdList(audioSelectionSource.map((track) => track?.id)), subtitleTrackIds: manualSubtitleTrackIds.length > 0 ? manualSubtitleTrackIds : normalizeTrackIdList( subtitleSelectionSource .filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track)) .map((track) => track?.id) ), subtitleVariantSelection: Object.keys(manualSubtitleVariantSelection).length > 0 ? manualSubtitleVariantSelection : defaultSubtitleVariantSelection.subtitleVariantSelection, subtitleLanguageOrder: manualSubtitleLanguageOrder.length > 0 ? manualSubtitleLanguageOrder : defaultSubtitleVariantSelection.subtitleLanguageOrder }; } return selection; } function defaultTrackSelectionForTitle(review, titleId) { const defaults = buildDefaultTrackSelection(review); return defaults[titleId] || defaults[String(titleId)] || { audioTrackIds: [], subtitleTrackIds: [], subtitleVariantSelection: {}, subtitleLanguageOrder: [] }; } function buildSettingsMap(categories) { const map = {}; const list = Array.isArray(categories) ? categories : []; for (const category of list) { for (const setting of (Array.isArray(category?.settings) ? category.settings : [])) { map[setting.key] = setting.value; } } return map; } function buildPresetDisplayMap(options) { const map = {}; const list = Array.isArray(options) ? options : []; for (const option of list) { if (!option || option.disabled) { continue; } const value = String(option.value || '').trim(); if (!value) { continue; } const category = String(option.category || '').trim(); map[value] = category ? `${category}/${value}` : value; } return map; } function transliterateForFilename(input) { return String(input || '') .replace(/ä/g, 'ae').replace(/Ä/g, 'Ae') .replace(/ö/g, 'oe').replace(/Ö/g, 'Oe') .replace(/ü/g, 'ue').replace(/Ü/g, 'Ue') .replace(/ß/g, 'ss') .replace(/å/g, 'a').replace(/Å/g, 'A') .replace(/æ/g, 'ae').replace(/Æ/g, 'Ae') .replace(/ø/g, 'o').replace(/Ø/g, 'O') .replace(/ð/g, 'd').replace(/Ð/g, 'D') .replace(/þ/g, 'th').replace(/Þ/g, 'Th') .normalize('NFD') .replace(/\p{M}+/gu, '') .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 renderTemplate(template, values) { 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); } 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); }; return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => { const { format, key } = parseToken(keyA || keyB || ''); const value = values[key]; if (value === undefined || value === null || value === '') { return 'unknown'; } return applyFormat(value, format); }); } function normalizePositiveInt(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function hasSeriesMetadataShape(source = null) { const metadata = source && typeof source === 'object' ? source : {}; const seasonNumber = normalizePositiveInt(metadata?.seasonNumber ?? metadata?.season ?? null); const episodes = Array.isArray(metadata?.episodes) ? metadata.episodes : []; const metadataProvider = String(metadata?.metadataProvider || '').trim().toLowerCase(); const workflowKind = String(metadata?.workflowKind || metadata?.kind || '').trim().toLowerCase(); return Boolean( seasonNumber || episodes.length > 0 || metadataProvider === 'tmdb' || metadataProvider === 'themoviedb' || workflowKind === 'series' || workflowKind === 'season' ); } function hasSeriesEpisodeAssignmentHints(titles = []) { const reviewTitles = Array.isArray(titles) ? titles : []; return reviewTitles.some((title) => { const seasonNumber = normalizePositiveInt(title?.seasonNumber ?? title?.season ?? null); const episodeNumber = normalizePositiveInt(title?.episodeNumber ?? title?.number ?? null); const episodeId = normalizePositiveInt(title?.episodeId ?? null); const episodeTitle = String(title?.episodeTitle || '').trim(); const episodeTitleStart = String(title?.episodeTitleStart || '').trim(); return Boolean(seasonNumber || episodeNumber || episodeId || episodeTitle || episodeTitleStart); }); } function normalizeEpisodeReference(entry = {}) { const source = entry && typeof entry === 'object' ? entry : {}; const episodeId = normalizePositiveInt(source?.episodeId ?? source?.id ?? null); const episodeNumber = normalizePositiveInt(source?.episodeNumber ?? source?.number ?? null); const seasonNumber = normalizePositiveInt(source?.seasonNumber ?? source?.season ?? null); if (!episodeId && !episodeNumber) { return null; } return { episodeId, episodeNumber, seasonNumber }; } function buildEpisodeReferenceKey(entry = {}) { const normalized = normalizeEpisodeReference(entry); if (!normalized) { return null; } if (normalized.episodeId) { return `id:${normalized.episodeId}`; } if (normalized.seasonNumber && normalized.episodeNumber) { return `se:${normalized.seasonNumber}:${normalized.episodeNumber}`; } return normalized.episodeNumber ? `ep:${normalized.episodeNumber}` : null; } function collectEpisodeReferenceKeys(entry = {}) { const normalized = normalizeEpisodeReference(entry); if (!normalized) { return []; } const keys = []; if (normalized.episodeId) { keys.push(`id:${normalized.episodeId}`); } if (normalized.seasonNumber && normalized.episodeNumber) { keys.push(`se:${normalized.seasonNumber}:${normalized.episodeNumber}`); } if (normalized.episodeNumber) { keys.push(`ep:${normalized.episodeNumber}`); } return Array.from(new Set(keys)); } function isSeriesBatchEpisodePlan(plan = null) { const source = plan && typeof plan === 'object' ? plan : {}; if (Boolean(source?.seriesBatchChild) || Boolean(source?.seriesBatchVirtualEpisode)) { return true; } const parentJobId = normalizePositiveInt(source?.seriesBatchParentJobId); const childIndex = normalizePositiveInt(source?.seriesBatchChildIndex); const childCount = normalizePositiveInt(source?.seriesBatchChildCount); const titleId = normalizeTitleId(source?.seriesBatchTitleId); return Boolean(parentJobId && (childIndex || childCount || titleId)); } function collectEpisodeReferencesFromPlan(plan = null) { const source = plan && typeof plan === 'object' ? plan : {}; const refs = []; const assignments = source?.episodeAssignments && typeof source.episodeAssignments === 'object' ? Object.values(source.episodeAssignments) : []; for (const assignment of assignments) { const normalized = normalizeEpisodeReference(assignment); if (normalized) { refs.push(normalized); } const seasonNumber = normalizePositiveInt(assignment?.seasonNumber ?? assignment?.season ?? null); const episodeStart = normalizePositiveInt( assignment?.episodeNumberStart ?? assignment?.episodeNoStart ?? assignment?.episodeNumber ?? assignment?.number ?? null ); const episodeEnd = normalizePositiveInt( assignment?.episodeNumberEnd ?? assignment?.episodeNoEnd ?? null ); if (episodeStart && episodeEnd && episodeEnd > episodeStart && (episodeEnd - episodeStart) <= 64) { for (let number = episodeStart; number <= episodeEnd; number += 1) { refs.push({ episodeId: null, episodeNumber: number, seasonNumber }); } } } const titles = Array.isArray(source?.titles) ? source.titles : []; for (const title of titles) { if (!Boolean(title?.selectedForEncode) && !Boolean(title?.encodeInput)) { continue; } const normalized = normalizeEpisodeReference({ episodeId: title?.episodeId, episodeNumber: title?.episodeNumber, seasonNumber: title?.seasonNumber }); if (normalized) { refs.push(normalized); } } return refs; } function normalizeSeriesLanguage(value) { const raw = String(value || '').trim(); if (!raw) { return 'unknown'; } return raw; } function normalizeSeriesTitleForOutputPath(value, fallback = 'series') { const raw = String(value || '').trim(); if (!raw) { return String(fallback || 'series').trim() || 'series'; } const stripped = raw .replace(/\s*-\s*S\d{1,2}E\d{1,3}(?:-\d{1,3})?\b.*$/i, '') .trim(); if (stripped) { return stripped; } return raw; } function normalizeSeriesBatchStatus(value) { return String(value || '').trim().toUpperCase() || 'UNKNOWN'; } function normalizeSeriesBatchProgress(value, fallback = 0) { const parsed = Number(value); if (!Number.isFinite(parsed)) { const fallbackValue = Number(fallback); return Number.isFinite(fallbackValue) ? Math.max(0, Math.min(100, fallbackValue)) : 0; } return Math.max(0, Math.min(100, parsed)); } function formatPercentTwoDecimals(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return '0.00%'; } return `${parsed.toFixed(2)}%`; } function getSeriesBatchTagSeverity(status) { const normalized = normalizeSeriesBatchStatus(status); if (normalized === 'FINISHED') { return 'success'; } if (normalized === 'ERROR') { return 'danger'; } if (normalized === 'CANCELLED') { return 'warning'; } if (normalized === 'ENCODING' || normalized === 'RIPPING' || normalized === 'MEDIAINFO_CHECK') { return 'info'; } return 'secondary'; } function medianPositiveInt(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 formatTemplateTwoDigit(value) { const numeric = Number(value); if (!Number.isFinite(numeric)) { return String(value || '').trim() || '00'; } if (Number.isInteger(numeric)) { return String(Math.trunc(numeric)).padStart(2, '0'); } return String(value || '').trim() || String(numeric); } function resolveEpisodeAssignmentRange(assignment = null, title = null, fallbackEpisodeNumber = 1, options = {}) { const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {}; const titleSource = title && typeof title === 'object' ? title : {}; const allowTitleEpisodeNumber = options?.allowTitleEpisodeNumber !== false; const fallbackNumber = normalizePositiveInt(fallbackEpisodeNumber) || 1; const episodeStart = normalizePositiveInt( assignmentSource?.episodeNumberStart ?? assignmentSource?.episodeNoStart ?? assignmentSource?.episodeNumber ?? (allowTitleEpisodeNumber ? titleSource?.episodeNumber : null) ?? fallbackNumber ) || fallbackNumber; const explicitEpisodeEnd = normalizePositiveInt( assignmentSource?.episodeNumberEnd ?? assignmentSource?.episodeNoEnd ?? null ); const explicitSpan = normalizePositiveInt( assignmentSource?.episodeSpan ?? assignmentSource?.episodeCount ?? null ); let episodeEnd = explicitEpisodeEnd; if (!episodeEnd && explicitSpan && explicitSpan > 1) { episodeEnd = episodeStart + explicitSpan - 1; } if (episodeEnd && episodeEnd < episodeStart) { episodeEnd = episodeStart; } const normalizedEnd = episodeEnd || episodeStart; const episodeSpan = Math.max(1, normalizedEnd - episodeStart + 1); return { start: episodeStart, end: normalizedEnd, span: episodeSpan, isMulti: normalizedEnd > episodeStart }; } function hasExplicitEpisodeAssignmentRange(assignment = null) { const source = assignment && typeof assignment === 'object' ? assignment : null; if (!source) { return false; } const hasStart = normalizePositiveInt( source?.episodeNumberStart ?? source?.episodeNoStart ?? null ) !== null; const hasEnd = normalizePositiveInt( source?.episodeNumberEnd ?? source?.episodeNoEnd ?? null ) !== null; const hasSpan = normalizePositiveInt( source?.episodeSpan ?? source?.episodeCount ?? null ) !== null; const hasEpisodeNumber = normalizePositiveInt(source?.episodeNumber ?? null) !== null; const hasEpisodeIdStart = normalizePositiveInt(source?.episodeIdStart ?? null) !== null; const hasRangeToken = String(source?.episodeRange || '').trim().length > 0; return Boolean(hasStart || hasEnd || hasSpan || hasEpisodeNumber || hasEpisodeIdStart || hasRangeToken); } function buildEpisodeRangeToken(start, end) { const startToken = formatTemplateTwoDigit(start); const endToken = formatTemplateTwoDigit(end); if (Number(end) > Number(start)) { return `${startToken}-${endToken}`; } return startToken; } function buildEpisodePartsToken(episodeRange = null) { const start = normalizePositiveInt(episodeRange?.start) || 1; const end = normalizePositiveInt(episodeRange?.end) || start; const span = Math.max(1, end - start + 1); if (span <= 1) { return '1'; } if (span === 2) { return '1+2'; } return `1-${span}`; } function normalizeEpisodeTitleTokenForCompare(value) { return String(value || '') .toLowerCase() .normalize('NFD') .replace(/\p{M}+/gu, '') .replace(/[^a-z0-9]+/g, '') .trim(); } function findCommonEpisodeTitlePrefix(values = []) { const rows = (Array.isArray(values) ? values : []) .map((value) => String(value || '').trim()) .filter(Boolean); if (rows.length === 0) { return ''; } if (rows.length === 1) { return rows[0]; } const tokenRows = rows.map((value) => value.split(/\s+/).filter(Boolean)); const firstTokens = tokenRows[0]; if (firstTokens.length === 0) { return ''; } let prefixLength = 0; for (let index = 0; index < firstTokens.length; index += 1) { const tokenKey = normalizeEpisodeTitleTokenForCompare(firstTokens[index]); if (!tokenKey) { break; } const matchesAll = tokenRows.slice(1).every((tokens) => { const current = tokens[index]; return normalizeEpisodeTitleTokenForCompare(current) === tokenKey; }); if (!matchesAll) { break; } prefixLength += 1; } if (prefixLength <= 0) { return ''; } return firstTokens .slice(0, prefixLength) .join(' ') .replace(/[-–—:.,\s]+$/g, '') .trim(); } function stripEpisodePartSuffix(value) { const source = String(value || '').replace(/\s+/g, ' ').trim(); if (!source) { return ''; } // Output cleanup for multi-episode labels: // remove "Teil/Part" markers and pure numeric/roman suffixes in // parentheses like "(1)" / "(2)". const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)|\(\s*(?:\d{1,2}|[ivxlcdm]{1,6})\s*\))\s*$/i; let normalized = source; let changed = false; for (let i = 0; i < 2; i += 1) { const next = normalized.replace(suffixPattern, '').replace(/[-–—:.,\s]+$/g, '').trim(); if (!next || next === normalized) { break; } normalized = next; changed = true; } return changed ? (normalized || source) : source; } function normalizeEpisodeTitleForOutput(value, episodeRange = null) { const source = String(value || '').replace(/\s+/g, ' ').trim(); if (!source) { return ''; } if (!Boolean(episodeRange?.isMulti)) { return source; } const strippedSource = stripEpisodePartSuffix(source) || source; const rawSegments = source .split(/\s+(?:\+|\/|\||&)\s+/) .map((part) => String(part || '').trim()) .filter(Boolean); if (rawSegments.length < 2) { return strippedSource; } const cleanedSegments = rawSegments .map((part) => stripEpisodePartSuffix(part) || part) .map((part) => String(part || '').replace(/[-–—:.,\s]+$/g, '').trim()) .filter(Boolean); if (cleanedSegments.length === 0) { return strippedSource; } const uniqueSegments = []; const seen = new Set(); for (const segment of cleanedSegments) { const key = normalizeEpisodeTitleTokenForCompare(segment); if (!key || seen.has(key)) { continue; } seen.add(key); uniqueSegments.push(segment); } if (uniqueSegments.length === 1) { return uniqueSegments[0]; } const allSegmentsLookLikePartTitles = rawSegments.every((segment) => /\b(?:teil|part|pt)\b/i.test(segment)); if (allSegmentsLookLikePartTitles) { const commonPrefix = findCommonEpisodeTitlePrefix(cleanedSegments); if (commonPrefix && commonPrefix.length >= 6) { return commonPrefix; } return uniqueSegments[0] || cleanedSegments[0] || strippedSource; } return strippedSource; } function resolveTitleDurationSeconds(title = null) { const fromSeconds = Number(title?.durationSeconds || 0); if (Number.isFinite(fromSeconds) && fromSeconds > 0) { return fromSeconds; } const fromMinutes = Number(title?.durationMinutes || 0); if (Number.isFinite(fromMinutes) && fromMinutes > 0) { return fromMinutes * 60; } return 0; } function estimateSeriesEpisodeSpanForTitle(title = null, allTitles = []) { const titleDuration = resolveTitleDurationSeconds(title); if (!Number.isFinite(titleDuration) || titleDuration <= 0) { return 1; } const durations = (Array.isArray(allTitles) ? allTitles : []) .map((item) => resolveTitleDurationSeconds(item)) .filter((value) => Number.isFinite(value) && value > 0); if (durations.length === 0) { return 1; } const medianDuration = medianPositiveInt(durations); if (!medianDuration) { return 1; } const baselinePool = durations.filter((value) => value <= medianDuration * 1.35); const baselineDuration = medianPositiveInt(baselinePool.length > 0 ? baselinePool : durations) || medianDuration; if (!baselineDuration) { return 1; } const ratio = titleDuration / baselineDuration; const roundedSpan = Math.round(ratio); if (!Number.isFinite(roundedSpan) || roundedSpan < 2 || roundedSpan > 4) { return 1; } const minRatio = roundedSpan === 2 ? 1.55 : roundedSpan * 0.72; const maxRatio = roundedSpan * 1.32; if (ratio < minRatio || ratio > maxRatio) { return 1; } return roundedSpan; } function resolveEffectiveEpisodeRangeForTitle(assignment = null, title = null, allTitles = [], fallbackEpisodeNumber = 1) { const baseRange = resolveEpisodeAssignmentRange(assignment, title, fallbackEpisodeNumber); if (baseRange.isMulti) { return baseRange; } if (hasExplicitEpisodeAssignmentRange(assignment)) { return baseRange; } const spanHint = estimateSeriesEpisodeSpanForTitle(title, allTitles); if (spanHint <= 1) { return baseRange; } return { start: baseRange.start, end: baseRange.start + spanHint - 1, span: spanHint, isMulti: true }; } function resolveSeriesTitleOrderForPreview(allTitles = [], selectedTitleIds = [], targetTitleId = null) { const explicitSelectedIds = normalizeTitleIdList(selectedTitleIds); const fallbackSelectedIds = explicitSelectedIds.length > 0 ? explicitSelectedIds : normalizeTitleIdList( (Array.isArray(allTitles) ? allTitles : []) .filter((item) => Boolean(item?.selectedForEncode || item?.encodeInput)) .map((item) => item?.id) ); let ordered = fallbackSelectedIds; const normalizedTarget = normalizeTitleId(targetTitleId); if (normalizedTarget && !ordered.some((id) => Number(id) === Number(normalizedTarget))) { ordered = [...ordered, Number(normalizedTarget)]; } if (ordered.length === 0 && normalizedTarget) { ordered = [Number(normalizedTarget)]; } return ordered; } function resolveEpisodeRangeForTitleFromAssignments({ assignmentByTitle = {}, allTitles = [], selectedTitleIds = [], targetTitle = null, fallbackEpisodeNumber = 1 }) { const titles = Array.isArray(allTitles) ? allTitles : []; const titlesById = new Map(); for (const item of titles) { const id = normalizeTitleId(item?.id); if (!id) { continue; } titlesById.set(Number(id), item); } const targetTitleId = normalizeTitleId(targetTitle?.id || null); const orderedTitleIds = resolveSeriesTitleOrderForPreview(titles, selectedTitleIds, targetTitleId); const fallbackStart = normalizePositiveInt(fallbackEpisodeNumber) || 1; let cursor = fallbackStart; let targetRange = null; for (const rawId of orderedTitleIds) { const currentId = normalizeTitleId(rawId); if (!currentId) { continue; } const assignment = assignmentByTitle?.[currentId] || assignmentByTitle?.[String(currentId)] || null; const currentTitle = titlesById.get(Number(currentId)) || null; const hasExplicitRange = hasExplicitEpisodeAssignmentRange(assignment); const range = resolveEpisodeAssignmentRange( assignment, currentTitle, cursor, { allowTitleEpisodeNumber: hasExplicitRange } ); const normalizedEnd = normalizePositiveInt(range?.end); if (normalizedEnd !== null) { cursor = normalizedEnd + 1; } if (targetTitleId && Number(currentId) === Number(targetTitleId)) { targetRange = range; break; } } if (!targetRange) { const assignment = targetTitleId ? (assignmentByTitle?.[targetTitleId] || assignmentByTitle?.[String(targetTitleId)] || null) : null; const hasExplicitRange = hasExplicitEpisodeAssignmentRange(assignment); targetRange = resolveEpisodeAssignmentRange( assignment, targetTitle, cursor, { allowTitleEpisodeNumber: hasExplicitRange } ); } return targetRange; } function templateUsesLeadingZeroForToken(template, token) { const source = String(template || '').trim(); const normalizedToken = String(token || '').trim(); if (!source || !normalizedToken) { return false; } const pattern = new RegExp(`\\{\\s*0\\s*:\\s*${normalizedToken}\\s*\\}`, 'i'); return pattern.test(source); } function formatEpisodeNumberByTemplate(rawValue, template, token = 'episodeNr') { const numeric = Number(rawValue); if (!Number.isFinite(numeric) || numeric <= 0) { return String(rawValue || '').trim() || '0'; } if (!Number.isInteger(numeric)) { return String(numeric); } const normalized = String(Math.trunc(numeric)); if (templateUsesLeadingZeroForToken(template, token)) { return normalized.padStart(2, '0'); } return normalized; } function buildFallbackEpisodeTitleForOutput(episodeRange = null, template = '') { const start = normalizePositiveInt(episodeRange?.start) || 1; const end = normalizePositiveInt(episodeRange?.end) || start; if (end > start) { const startToken = formatEpisodeNumberByTemplate(start, template, 'episodeNr'); const endToken = formatEpisodeNumberByTemplate(end, template, 'episodeNr'); return `Folge ${startToken}-${endToken}`; } const token = formatEpisodeNumberByTemplate(start, template, 'episodeNr'); return `Folge ${token}`; } function buildDvdSeriesEpisodeOutputPathPreview( settings, mediaProfile, metadata, title, assignment = null, fallbackJobId = null, allTitles = [], assignmentByTitle = {}, selectedTitleIds = [] ) { const normalizedSeriesMediaProfile = String(mediaProfile || '').trim().toLowerCase() === 'bluray' ? 'bluray' : 'dvd'; const seriesRootDir = String( (normalizedSeriesMediaProfile === 'bluray' ? (settings?.series_dir_bluray || settings?.series_dir_dvd || settings?.movie_dir_bluray || settings?.movie_dir_dvd) : (settings?.series_dir_dvd || settings?.series_dir_bluray || settings?.movie_dir_dvd || settings?.movie_dir_bluray)) || settings?.movie_dir || '' ).trim(); if (!seriesRootDir) { return null; } const seasonNumber = normalizePositiveInt( assignment?.seasonNumber ?? metadata?.seasonNumber ?? title?.seasonNumber ?? 1 ) || 1; const normalizedSelectedIds = normalizeTitleIdList(selectedTitleIds); const targetTitleId = normalizeTitleId(title?.id); const fallbackEpisodeNumber = targetTitleId && normalizedSelectedIds.length > 0 ? (normalizedSelectedIds.findIndex((id) => Number(id) === Number(targetTitleId)) + 1) : 1; const inferredRange = resolveEpisodeRangeForTitleFromAssignments({ assignmentByTitle, allTitles, selectedTitleIds: normalizedSelectedIds, targetTitle: title, fallbackEpisodeNumber: fallbackEpisodeNumber > 0 ? fallbackEpisodeNumber : 1 }); const episodeRange = resolveEffectiveEpisodeRangeForTitle( assignment || {}, title, allTitles, inferredRange?.start || (fallbackEpisodeNumber > 0 ? fallbackEpisodeNumber : 1) ); const effectiveEpisodeRange = episodeRange; const episodeNumber = effectiveEpisodeRange.start; const episodeRangeToken = buildEpisodeRangeToken(effectiveEpisodeRange.start, effectiveEpisodeRange.end); const partsToken = buildEpisodePartsToken(effectiveEpisodeRange); const discNumber = normalizePositiveInt(metadata?.discNumber ?? assignment?.discNumber ?? null); const seriesTitle = normalizeSeriesTitleForOutputPath( metadata?.seriesTitle || metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'series'), fallbackJobId ? `job-${fallbackJobId}` : 'series' ); const year = String(metadata?.year || new Date().getFullYear()).trim(); const language = normalizeSeriesLanguage(settings?.dvd_series_language || metadata?.language || null); const singleTemplateRaw = String( (normalizedSeriesMediaProfile === 'bluray' ? settings?.output_template_bluray_series_episode : settings?.output_template_dvd_series_episode) || settings?.output_template_dvd_series_episode || settings?.output_template_bluray_series_episode || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}' ).trim(); const multiTemplateRaw = String( (normalizedSeriesMediaProfile === 'bluray' ? settings?.output_template_bluray_series_multi_episode : settings?.output_template_dvd_series_multi_episode) || settings?.output_template_dvd_series_multi_episode || settings?.output_template_bluray_series_multi_episode || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})' ).trim(); const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'; const template = effectiveEpisodeRange.isMulti ? multiTemplate : singleTemplate; const fallbackEpisodeTitle = buildFallbackEpisodeTitleForOutput(effectiveEpisodeRange, template); const episodeTitleRaw = String( assignment?.episodeTitle || title?.episodeTitle || fallbackEpisodeTitle ).trim(); const rendered = renderTemplate(template, { seriesTitle, seasonNr: seasonNumber, episodeNr: episodeNumber, episodeNoStart: effectiveEpisodeRange.start, episodeNoEnd: effectiveEpisodeRange.end, episodeNumberStart: effectiveEpisodeRange.start, episodeNumberEnd: effectiveEpisodeRange.end, episodeRange: episodeRangeToken, episodeSpan: effectiveEpisodeRange.span, parts: partsToken, episodeTitle: normalizeEpisodeTitleForOutput( episodeTitleRaw || fallbackEpisodeTitle, effectiveEpisodeRange ) || fallbackEpisodeTitle, discNr: discNumber || '', year, language }); const segments = rendered .replace(/\\/g, '/') .replace(/\/+/g, '/') .replace(/^\/+|\/+$/g, '') .split('/') .map((seg) => sanitizeFileName(seg)) .filter(Boolean); const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled'; const folderParts = segments.slice(0, -1); const ext = String( (normalizedSeriesMediaProfile === 'bluray' ? settings?.output_extension_bluray : settings?.output_extension_dvd) || settings?.output_extension_dvd || settings?.output_extension_bluray || settings?.output_extension || 'mkv' ).trim() || 'mkv'; const root = seriesRootDir.replace(/\/+$/g, ''); if (folderParts.length > 0) { return `${root}/${folderParts.join('/')}/${baseName}.${ext}`; } return `${root}/${baseName}.${ext}`; } function resolveProfiledSetting(settings, key, mediaProfile) { const profileKey = mediaProfile ? `${key}_${mediaProfile}` : null; if (profileKey && settings?.[profileKey] != null && settings[profileKey] !== '') { return settings[profileKey]; } const fallbackProfiles = mediaProfile === 'bluray' ? ['dvd'] : mediaProfile === 'dvd' ? ['bluray'] : []; for (const fb of fallbackProfiles) { const fbKey = `${key}_${fb}`; if (settings?.[fbKey] != null && settings[fbKey] !== '') { return settings[fbKey]; } } return settings?.[key] ?? null; } function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId = null) { const movieDir = String(resolveProfiledSetting(settings, 'movie_dir', mediaProfile) || '').trim(); if (!movieDir) { return null; } const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'); const author = metadata?.author || metadata?.artist || 'unknown'; const narrator = metadata?.narrator || 'unknown'; const year = metadata?.year || new Date().getFullYear(); const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb'); const DEFAULT_TEMPLATE = '${title} (${year})/${title} (${year})'; const rawTemplate = resolveProfiledSetting(settings, 'output_template', mediaProfile); const template = String(rawTemplate || DEFAULT_TEMPLATE).trim() || DEFAULT_TEMPLATE; const rendered = renderTemplate(template, { title, year, imdbId, author, narrator }); const segments = rendered .replace(/\\/g, '/') .replace(/\/+/g, '/') .replace(/^\/+|\/+$/g, '') .split('/') .map((seg) => sanitizeFileName(seg)) .filter(Boolean); const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled'; const folderParts = segments.slice(0, -1); const rawExt = resolveProfiledSetting(settings, 'output_extension', mediaProfile); const ext = String(rawExt || 'mkv').trim() || 'mkv'; const root = movieDir.replace(/\/+$/g, ''); if (folderParts.length > 0) { return `${root}/${folderParts.join('/')}/${baseName}.${ext}`; } return `${root}/${baseName}.${ext}`; } function buildConverterOutputPathPreview(settings, review, metadata, fallbackJobId = null) { const explicitPath = String(review?.outputPath || review?.outputDir || '').trim(); if (explicitPath) { return explicitPath; } const movieDir = String(settings?.converter_movie_dir || '').trim(); if (!movieDir) { return null; } const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'); const year = metadata?.year || new Date().getFullYear(); const template = String(settings?.converter_output_template_video || '{title}').trim() || '{title}'; const rendered = renderTemplate(template, { title, year }); const segments = rendered .replace(/\\/g, '/') .replace(/\/+/g, '/') .replace(/^\/+|\/+$/g, '') .split('/') .map((seg) => sanitizeFileName(seg)) .filter(Boolean); const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled'; const folderParts = segments.slice(0, -1); const ext = String(review?.outputFormat || 'mkv').trim() || 'mkv'; const root = movieDir.replace(/\/+$/g, ''); if (folderParts.length > 0) { return `${root}/${folderParts.join('/')}/${baseName}.${ext}`; } return `${root}/${baseName}.${ext}`; } export default function PipelineStatusCard({ jobId = null, pipeline, onAnalyze, onReanalyze, onOpenMetadata, onReassignOmdb, onStart, onRemoveFromQueue, onRestartEncode, onRestartReview, onConfirmReview, onSelectPlaylist, onSelectHandBrakeTitle, onSubmitRawDecision, onCancel, onRetry, onDeleteJob, onInputsChanged, jobRow, isQueued = false, busy }) { const state = pipeline?.state || 'IDLE'; const stateLabel = getStatusLabel(state); const progress = Number(pipeline?.progress || 0); const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK'; const explicitJobId = normalizeJobId(jobId); const contextJobId = normalizeJobId(pipeline?.context?.jobId); const retryJobId = explicitJobId || contextJobId; const queueLocked = Boolean(isQueued && retryJobId); const orphanSource = String( jobRow?.makemkvInfo?.source || pipeline?.context?.source || pipeline?.context?.importSource || '' ).trim().toLowerCase(); const isOrphanImport = orphanSource === 'orphan_raw_import'; const isOrphanCancelled = isOrphanImport && (state === 'ERROR' || state === 'CANCELLED'); const ripCompleted = Boolean( Number(jobRow?.rip_successful || jobRow?.ripSuccessful || pipeline?.context?.ripSuccessful || 0) === 1 || jobRow?.raw_path || pipeline?.context?.rawPath || jobRow?.makemkvInfo?.rawPath || String(jobRow?.makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS' ); const selectedMetadata = pipeline?.context?.selectedMetadata || null; const mediaInfoReview = pipeline?.context?.mediaInfoReview || null; const playlistAnalysis = pipeline?.context?.playlistAnalysis || null; const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null; const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase(); const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip); const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview); const discTypeLabel = jobMediaProfile === 'bluray' ? 'Blu-ray' : 'DVD'; const isDiscTitleSelectionRequired = Boolean( (jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray') && (mediaInfoReview?.titleSelectionRequired || mediaInfoReview?.handBrakeTitleDecisionRequired) ); const [liveJobLog, setLiveJobLog] = useState(''); const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null); const [selectedEncodeTitleIds, setSelectedEncodeTitleIds] = useState([]); const [selectedPlaylistId, setSelectedPlaylistId] = useState(null); const [selectedHandBrakeTitleIdsState, setSelectedHandBrakeTitleIdsState] = useState([]); const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({}); const [episodeAssignmentsByTitle, setEpisodeAssignmentsByTitle] = useState({}); const [selectedEpisodeFillStart, setSelectedEpisodeFillStart] = useState(null); const [containerUsedEpisodeKeys, setContainerUsedEpisodeKeys] = useState([]); const [containerSeasonEpisodes, setContainerSeasonEpisodes] = useState([]); const [settingsMap, setSettingsMap] = useState({}); const [presetDisplayMap, setPresetDisplayMap] = useState({}); const [scriptCatalog, setScriptCatalog] = useState([]); const [chainCatalog, setChainCatalog] = useState([]); // Unified ordered lists: [{type: 'script'|'chain', id: number}] const [preEncodeItems, setPreEncodeItems] = useState([]); const [postEncodeItems, setPostEncodeItems] = useState([]); const [userPresets, setUserPresets] = useState([]); const [selectedUserPresetId, setSelectedUserPresetId] = useState(null); const [handBrakePresetOptions, setHandBrakePresetOptions] = useState([]); const [selectedHandBrakePreset, setSelectedHandBrakePreset] = useState(''); const [selectedOutputFormat, setSelectedOutputFormat] = useState('mkv'); const [converterConfigSaving, setConverterConfigSaving] = useState(false); const [converterConfigError, setConverterConfigError] = useState(null); const converterConfigLatestRef = useRef(null); const converterConfigSaveTimeoutRef = useRef(null); const selectedMetadataEpisodes = useMemo(() => { const metadataEpisodes = Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0 ? selectedMetadata.episodes : (Array.isArray(containerSeasonEpisodes) ? containerSeasonEpisodes : []); const rows = Array.isArray(metadataEpisodes) ? metadataEpisodes : []; const normalizedRows = rows .map((item) => { const episodeId = Number(item?.id ?? item?.episodeId ?? 0); const episodeIdStart = Number(item?.episodeIdStart ?? item?.idStart ?? item?.id ?? item?.episodeId ?? 0); const episodeIdEnd = Number(item?.episodeIdEnd ?? item?.idEnd ?? 0); const episodeNumberStart = Number( item?.episodeNumberStart ?? item?.numberStart ?? item?.episodeNoStart ?? item?.number ?? item?.episodeNumber ?? 0 ); const episodeNumberEnd = Number( item?.episodeNumberEnd ?? item?.numberEnd ?? item?.episodeNoEnd ?? 0 ); const episodeSpan = Number(item?.episodeSpan ?? item?.episodeCount ?? 0); const episodeNumber = Number(item?.number ?? item?.episodeNumber ?? episodeNumberStart ?? 0); const seasonNumber = Number(item?.seasonNumber ?? item?.season ?? 0); const title = String(item?.name || item?.title || '').trim(); return { episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null, episodeIdStart: Number.isFinite(episodeIdStart) && episodeIdStart > 0 ? Math.trunc(episodeIdStart) : null, episodeIdEnd: Number.isFinite(episodeIdEnd) && episodeIdEnd > 0 ? Math.trunc(episodeIdEnd) : null, episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null, episodeNumberStart: Number.isFinite(episodeNumberStart) && episodeNumberStart > 0 ? Math.trunc(episodeNumberStart) : null, episodeNumberEnd: Number.isFinite(episodeNumberEnd) && episodeNumberEnd > 0 ? Math.trunc(episodeNumberEnd) : null, episodeSpan: Number.isFinite(episodeSpan) && episodeSpan > 0 ? Math.trunc(episodeSpan) : null, seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null, episodeTitle: title || null }; }) .filter((item) => item.episodeId || item.episodeNumber !== null) .sort((a, b) => { const aSeason = Number.isFinite(a.seasonNumber) ? a.seasonNumber : Number.MAX_SAFE_INTEGER; const bSeason = Number.isFinite(b.seasonNumber) ? b.seasonNumber : Number.MAX_SAFE_INTEGER; if (aSeason !== bSeason) { return aSeason - bSeason; } const aEpisode = Number.isFinite(a.episodeNumber) ? a.episodeNumber : Number.MAX_SAFE_INTEGER; const bEpisode = Number.isFinite(b.episodeNumber) ? b.episodeNumber : Number.MAX_SAFE_INTEGER; if (aEpisode !== bEpisode) { return aEpisode - bEpisode; } const aId = Number.isFinite(a.episodeId) ? a.episodeId : Number.MAX_SAFE_INTEGER; const bId = Number.isFinite(b.episodeId) ? b.episodeId : Number.MAX_SAFE_INTEGER; return aId - bId; }); if (!Array.isArray(containerUsedEpisodeKeys) || containerUsedEpisodeKeys.length === 0) { return normalizedRows; } const usedKeySet = new Set(containerUsedEpisodeKeys); return normalizedRows.filter((episode) => { const keys = collectEpisodeReferenceKeys(episode); if (keys.length === 0) { return true; } return keys.every((key) => !usedKeySet.has(key)); }); }, [selectedMetadata?.episodes, containerSeasonEpisodes, containerUsedEpisodeKeys]); useEffect(() => { let cancelled = false; const loadSettings = async () => { try { const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse, userPresetsResponse] = await Promise.allSettled([ api.getSettings(), api.getHandBrakePresets(), api.getScripts(), api.getScriptChains(), api.getUserPresets(null, { forceRefresh: true }) ]); if (!cancelled) { const categories = settingsResponse.status === 'fulfilled' ? (settingsResponse.value?.categories || []) : []; setSettingsMap(buildSettingsMap(categories)); const presetOptions = presetsResponse.status === 'fulfilled' ? (presetsResponse.value?.options || []) : []; setPresetDisplayMap(buildPresetDisplayMap(presetOptions)); setHandBrakePresetOptions(Array.isArray(presetOptions) ? presetOptions : []); const scripts = scriptsResponse.status === 'fulfilled' ? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []) : []; setScriptCatalog( scripts.map((item) => ({ id: item?.id, name: item?.name })) ); const chains = chainsResponse.status === 'fulfilled' ? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []) : []; setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name }))); const allUserPresets = userPresetsResponse.status === 'fulfilled' ? (Array.isArray(userPresetsResponse.value?.presets) ? userPresetsResponse.value.presets : []) : []; setUserPresets(allUserPresets); } } catch (_error) { if (!cancelled) { setSettingsMap({}); setPresetDisplayMap({}); setScriptCatalog([]); setChainCatalog([]); setUserPresets([]); setHandBrakePresetOptions([]); } } }; loadSettings(); return () => { cancelled = true; }; }, []); useEffect(() => { const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId); const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; const selectedFromReview = normalizeTitleIdList([ ...(Array.isArray(mediaInfoReview?.selectedTitleIds) ? mediaInfoReview.selectedTitleIds : []), ...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id), ...(fromReview ? [fromReview] : []) ]); const isSeriesVideoByMetadata = Boolean( (jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray') && ( hasSeriesMetadataShape(selectedMetadata) || hasSeriesMetadataShape(mediaInfoReview?.selectedMetadata) || hasSeriesEpisodeAssignmentHints(reviewTitles) || Boolean(mediaInfoReview?.seriesBatchParent) ) ); const defaultAllTitles = Boolean( ( mediaInfoReview?.titleSelectionRequired || mediaInfoReview?.handBrakeTitleDecisionRequired || (isSeriesVideoByMetadata && reviewTitles.length > 1) ) && reviewTitles.length > 0 ); const effectiveSelected = defaultAllTitles ? normalizeTitleIdList(reviewTitles.map((title) => title?.id)) : selectedFromReview; setSelectedEncodeTitleIds(effectiveSelected); setSelectedEncodeTitleId(effectiveSelected[0] || fromReview || null); setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview)); const initialEpisodeAssignments = {}; const allReviewTitles = Array.isArray(reviewTitles) ? reviewTitles : []; for (const title of reviewTitles) { const titleId = normalizeTitleId(title?.id); if (!titleId) { continue; } const episodeId = Number(title?.episodeId || 0); const episodeNumber = Number(title?.episodeNumber || 0); const seasonNumber = Number(title?.seasonNumber || 0); const rawEpisodeTitle = String(title?.episodeTitle || '').trim(); const titleEpisodeRange = resolveEffectiveEpisodeRangeForTitle( title, title, allReviewTitles, titleId ); const episodeTitle = rawEpisodeTitle ? normalizeEpisodeTitleForOutput(rawEpisodeTitle, titleEpisodeRange) : ''; if (!episodeTitle && !episodeId && !episodeNumber && !seasonNumber) { continue; } initialEpisodeAssignments[titleId] = { episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null, episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null, seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null, episodeTitle: episodeTitle || null }; } setEpisodeAssignmentsByTitle(initialEpisodeAssignments); setSelectedEpisodeFillStart(null); const normChain = (raw) => (Array.isArray(raw) ? raw : []).map(Number).filter((id) => Number.isFinite(id) && id > 0); setPreEncodeItems([ ...normalizeScriptIdList(mediaInfoReview?.preEncodeScriptIds || []).map((id) => ({ type: 'script', id })), ...normChain(mediaInfoReview?.preEncodeChainIds).map((id) => ({ type: 'chain', id })) ]); setPostEncodeItems([ ...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })), ...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id })) ]); const nextOutputFormat = String(mediaInfoReview?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv'; setSelectedOutputFormat(nextOutputFormat); const userPresetId = Number(mediaInfoReview?.userPreset?.id); if (Number.isFinite(userPresetId) && userPresetId > 0) { setSelectedUserPresetId(Math.trunc(userPresetId)); setSelectedHandBrakePreset(''); } else { setSelectedUserPresetId(null); const hbPresetName = String(mediaInfoReview?.userPreset?.handbrakePreset || '').trim(); setSelectedHandBrakePreset(hbPresetName); } }, [ mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, mediaInfoReview?.reviewConfirmedAt, mediaInfoReview?.prefilledFromPreviousRunAt, mediaInfoReview?.outputFormat, mediaInfoReview?.userPreset?.id, mediaInfoReview?.userPreset?.handbrakePreset, selectedMetadata?.seasonNumber, selectedMetadata?.episodes, selectedMetadata?.metadataProvider, jobMediaProfile, retryJobId ]); useEffect(() => { if (mediaInfoReview) { return; } setSelectedEncodeTitleIds([]); setSelectedEncodeTitleId(null); setTrackSelectionByTitle({}); setEpisodeAssignmentsByTitle({}); setSelectedEpisodeFillStart(null); setPreEncodeItems([]); setPostEncodeItems([]); setSelectedUserPresetId(null); setSelectedHandBrakePreset(''); }, [mediaInfoReview, retryJobId]); useEffect(() => { const normalizedSelectedIds = normalizeTitleIdList(selectedEncodeTitleIds); const primaryTitleId = normalizeTitleId(selectedEncodeTitleId); if (normalizedSelectedIds.length === 0) { if (primaryTitleId !== null) { setSelectedEncodeTitleId(null); } return; } if (!primaryTitleId || !normalizedSelectedIds.includes(primaryTitleId)) { setSelectedEncodeTitleId(normalizedSelectedIds[0]); } setTrackSelectionByTitle((prev) => { const defaults = buildDefaultTrackSelection(mediaInfoReview); let next = prev; let changed = false; for (const titleId of normalizedSelectedIds) { if (next?.[titleId] || next?.[String(titleId)]) { continue; } const fallback = defaults[titleId] || { audioTrackIds: [], subtitleTrackIds: [], subtitleVariantSelection: {}, subtitleLanguageOrder: [] }; if (!changed) { next = { ...(prev || {}) }; changed = true; } next[titleId] = fallback; } return changed ? next : prev; }); }, [selectedEncodeTitleId, selectedEncodeTitleIds, mediaInfoReview?.generatedAt]); const resolveEpisodeFillContext = () => { const episodeRows = Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : []; const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; const selectedTitleIds = normalizeTitleIdList([ ...normalizeTitleIdList(selectedEncodeTitleIds), ...(Array.isArray(mediaInfoReview?.selectedTitleIds) ? mediaInfoReview.selectedTitleIds : []), ...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id) ]); const orderedTitles = reviewTitles .map((title) => ({ id: normalizeTitleId(title?.id), title })) .filter((row) => row.id !== null); const orderedSelectedTitleRows = selectedTitleIds.length > 0 ? orderedTitles.filter((row) => selectedTitleIds.includes(row.id)) : []; const fallbackSelectedTitleRows = orderedSelectedTitleRows.length > 0 ? orderedSelectedTitleRows : orderedTitles.filter((row) => selectedTitleIds.includes(row.id)); return { episodeRows, reviewTitles, selectedTitleIds, fallbackSelectedTitleRows }; }; const buildGroupedEpisodeUnits = (episodeRows = [], selectedTitleRows = [], reviewTitles = []) => { const rows = Array.isArray(episodeRows) ? episodeRows : []; const selectedRows = Array.isArray(selectedTitleRows) ? selectedTitleRows : []; const allReviewTitles = Array.isArray(reviewTitles) ? reviewTitles : []; const normalizeEpisodeStart = (episode) => normalizePositiveInt( episode?.episodeNumberStart ?? episode?.episodeNoStart ?? episode?.episodeNumber ?? null ); const normalizeEpisodeEnd = (episode, fallbackStart = null) => { const start = normalizePositiveInt(fallbackStart); const explicitEnd = normalizePositiveInt( episode?.episodeNumberEnd ?? episode?.episodeNoEnd ?? null ); if (explicitEnd && (!start || explicitEnd >= start)) { return explicitEnd; } const explicitSpan = normalizePositiveInt( episode?.episodeSpan ?? episode?.episodeCount ?? null ); if (start && explicitSpan && explicitSpan > 1) { return start + explicitSpan - 1; } return start; }; const titleAnchorsByEpisodeIndex = new Map(); if (rows.length > 0) { let episodeAnchorCursor = 0; for (const selectedTitleRow of selectedRows) { if (episodeAnchorCursor >= rows.length) { break; } const explicitSpan = normalizePositiveInt( selectedTitleRow?.title?.episodeSpan ?? selectedTitleRow?.title?.episodeCount ?? null ); const estimatedSpan = estimateSeriesEpisodeSpanForTitle(selectedTitleRow?.title, allReviewTitles); const span = Math.max(1, explicitSpan || 1, estimatedSpan || 1); const safeSpan = Math.max(1, Math.min(span, rows.length - episodeAnchorCursor)); titleAnchorsByEpisodeIndex.set(episodeAnchorCursor, { span: safeSpan }); episodeAnchorCursor += safeSpan; } } const groupedEpisodeUnits = []; let sourceEpisodeIndex = 0; while (sourceEpisodeIndex < rows.length) { const startRow = rows[sourceEpisodeIndex]; const rangeStart = normalizeEpisodeStart(startRow) ?? normalizePositiveInt(startRow?.episodeNumber) ?? null; const explicitRangeEnd = normalizeEpisodeEnd(startRow, rangeStart); const explicitRangeSpan = ( rangeStart && explicitRangeEnd && explicitRangeEnd >= rangeStart ) ? Math.max(1, Math.trunc(explicitRangeEnd - rangeStart + 1)) : 1; if (explicitRangeSpan > 1) { groupedEpisodeUnits.push({ startRow, endRow: startRow, rangeStart, rangeEnd: explicitRangeEnd || rangeStart, consumedRows: 1 }); sourceEpisodeIndex += 1; continue; } const anchor = titleAnchorsByEpisodeIndex.get(sourceEpisodeIndex) || null; const anchorSpan = normalizePositiveInt(anchor?.span) || 1; if (anchorSpan > 1) { const endIndex = Math.min(rows.length - 1, sourceEpisodeIndex + anchorSpan - 1); const endRow = rows[endIndex] || startRow; const endRowStart = normalizeEpisodeStart(endRow) ?? normalizePositiveInt(endRow?.episodeNumber) ?? null; const rangeEnd = ( rangeStart && endRowStart && endRowStart >= rangeStart ) ? endRowStart : (rangeStart ? (rangeStart + anchorSpan - 1) : endRowStart); groupedEpisodeUnits.push({ startRow, endRow, rangeStart, rangeEnd, consumedRows: anchorSpan }); sourceEpisodeIndex += anchorSpan; continue; } groupedEpisodeUnits.push({ startRow, endRow: startRow, rangeStart, rangeEnd: rangeStart, consumedRows: 1 }); sourceEpisodeIndex += 1; } return groupedEpisodeUnits; }; const applyEpisodeFillFrom = (startEpisodeRef = null) => { const { episodeRows, reviewTitles, selectedTitleIds, fallbackSelectedTitleRows } = resolveEpisodeFillContext(); if (episodeRows.length === 0 || selectedTitleIds.length === 0) { return; } if (fallbackSelectedTitleRows.length === 0) { return; } const rawFillRef = String(startEpisodeRef || '').trim(); const normalizedRefMatch = rawFillRef.match(/^fill:([^:]+):title:\d+$/i); const normalizedRef = normalizedRefMatch && normalizedRefMatch[1] ? String(normalizedRefMatch[1]).trim() : rawFillRef; const groupedEpisodeUnits = buildGroupedEpisodeUnits( episodeRows, fallbackSelectedTitleRows, reviewTitles ); let startUnitIndex = 0; if (normalizedRef) { const foundUnitIndex = groupedEpisodeUnits.findIndex((unit) => String(unit?.startRow?.episodeIdStart || '') === normalizedRef || String(unit?.startRow?.episodeId || '') === normalizedRef || String(unit?.startRow?.episodeNumberStart || '') === normalizedRef || String(unit?.startRow?.episodeNumber || '') === normalizedRef ); if (foundUnitIndex >= 0) { startUnitIndex = foundUnitIndex; } } const nextAssignments = {}; let unitCursor = startUnitIndex; fallbackSelectedTitleRows.forEach((row) => { const titleId = row.id; const unit = groupedEpisodeUnits[unitCursor]; if (!unit || !titleId) { return; } const startRow = unit.startRow || null; const endRow = unit.endRow || startRow; const rangeStart = normalizePositiveInt(unit?.rangeStart) ?? normalizeEpisodeStart(startRow) ?? normalizePositiveInt(startRow?.episodeNumber) ?? null; let rangeEnd = normalizePositiveInt(unit?.rangeEnd) ?? normalizeEpisodeStart(endRow) ?? normalizePositiveInt(endRow?.episodeNumber) ?? rangeStart; if (rangeStart && rangeEnd && rangeEnd < rangeStart) { rangeEnd = rangeStart; } const normalizedSpan = ( rangeStart && rangeEnd && rangeEnd >= rangeStart ) ? Math.max(1, Math.trunc(rangeEnd - rangeStart + 1)) : Math.max(1, normalizePositiveInt(unit?.consumedRows) || 1); const rangeToken = buildEpisodeRangeToken( rangeStart, rangeEnd ); const assignmentRange = { start: rangeStart, end: rangeEnd, span: normalizedSpan, isMulti: normalizedSpan > 1 }; const episodeTitleRaw = String(startRow?.episodeTitle || '').trim(); const endEpisodeTitleRaw = String(endRow?.episodeTitle || '').trim(); const titleSource = episodeTitleRaw || endEpisodeTitleRaw; const mergedTitle = titleSource ? normalizeEpisodeTitleForOutput(titleSource, assignmentRange) : null; const episodeIdStart = normalizePositiveInt( startRow?.episodeIdStart ?? startRow?.episodeId ?? null ); const endRowEpisodeId = normalizePositiveInt( endRow?.episodeIdStart ?? endRow?.episodeId ?? null ); const explicitEpisodeIdEnd = normalizePositiveInt(startRow?.episodeIdEnd ?? null); const episodeIdEnd = endRowEpisodeId || explicitEpisodeIdEnd || episodeIdStart || null; nextAssignments[titleId] = { episodeId: episodeIdStart, episodeNumber: rangeStart, seasonNumber: startRow?.seasonNumber ?? null, episodeTitle: mergedTitle, episodeIdStart: episodeIdStart, episodeIdEnd, episodeNumberStart: rangeStart, episodeNumberEnd: rangeEnd, episodeSpan: normalizedSpan, episodeRange: rangeToken, episodeTitleStart: episodeTitleRaw || null, episodeTitleEnd: endEpisodeTitleRaw || episodeTitleRaw || null }; unitCursor += 1; }); setEpisodeAssignmentsByTitle((prev) => ({ ...(prev && typeof prev === 'object' ? prev : {}), ...nextAssignments })); }; const episodeFillOptions = useMemo(() => { const { episodeRows, reviewTitles, fallbackSelectedTitleRows } = resolveEpisodeFillContext(); if (!Array.isArray(episodeRows) || episodeRows.length === 0) { return []; } const groupedUnits = buildGroupedEpisodeUnits(episodeRows, fallbackSelectedTitleRows, reviewTitles); return groupedUnits .map((unit) => { const startRow = unit?.startRow || null; const value = startRow?.episodeIdStart || startRow?.episodeId || startRow?.episodeNumberStart || startRow?.episodeNumber || null; if (!value) { return null; } return { value: String(value), startRef: String(value) }; }) .filter(Boolean); }, [selectedMetadataEpisodes, selectedEncodeTitleIds, mediaInfoReview?.titles, mediaInfoReview?.selectedTitleIds]); useEffect(() => { if (!Array.isArray(selectedMetadataEpisodes) || selectedMetadataEpisodes.length === 0) { return; } if (!Array.isArray(episodeFillOptions) || episodeFillOptions.length === 0) { return; } const normalizedSelection = String(selectedEpisodeFillStart || '').trim(); const hasValidSelection = Boolean( normalizedSelection && episodeFillOptions.some((option) => String(option?.value || '').trim() === normalizedSelection) ); const defaultStartRef = hasValidSelection ? normalizedSelection : (episodeFillOptions[0]?.value || episodeFillOptions[0]?.startRef || null); if (!defaultStartRef) { return; } if (!hasValidSelection) { setSelectedEpisodeFillStart(String(defaultStartRef)); // Keep dropdown + episodentitel in sync when options change (e.g. Disc 2 // after already used episodes were loaded from the container). applyEpisodeFillFrom(defaultStartRef); return; } const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; const fallbackSelectedIds = normalizeTitleIdList([ ...selectedEncodeTitleIds, selectedEncodeTitleId, mediaInfoReview?.encodeInputTitleId, ...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id) ]); const selectedTitleIdsInReviewOrder = reviewTitles .map((title) => normalizeTitleId(title?.id)) .filter((id) => id !== null && fallbackSelectedIds.includes(id)); const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0 ? selectedTitleIdsInReviewOrder : fallbackSelectedIds; const hasEpisodeAssignmentPayload = (assignment) => { if (!assignment || typeof assignment !== 'object') { return false; } const episodeId = Number(assignment?.episodeId || assignment?.episodeIdStart || 0); const episodeNumber = Number(assignment?.episodeNumber || assignment?.episodeNumberStart || 0); const seasonNumber = Number(assignment?.seasonNumber || 0); const episodeTitle = String(assignment?.episodeTitle || '').trim(); return Boolean(episodeTitle || episodeId > 0 || episodeNumber > 0 || seasonNumber > 0); }; const hasAnyEpisodeAssignment = effectiveSelectedTitleIds.some((titleId) => { const assignment = episodeAssignmentsByTitle?.[titleId] || episodeAssignmentsByTitle?.[String(titleId)] || null; return hasEpisodeAssignmentPayload(assignment); }); const hasCompleteEpisodeAssignments = effectiveSelectedTitleIds.length > 0 && effectiveSelectedTitleIds.every((titleId) => { const assignment = episodeAssignmentsByTitle?.[titleId] || episodeAssignmentsByTitle?.[String(titleId)] || null; return hasEpisodeAssignmentPayload(assignment); }); if (hasCompleteEpisodeAssignments) { return; } if (hasAnyEpisodeAssignment && normalizedSelection) { return; } applyEpisodeFillFrom(defaultStartRef); }, [ selectedMetadataEpisodes, selectedEncodeTitleIds, selectedEncodeTitleId, mediaInfoReview?.titles, mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, episodeAssignmentsByTitle, episodeFillOptions, selectedEpisodeFillStart ]); const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired); const hasSelectedEncodeTitle = normalizeTitleIdList(selectedEncodeTitleIds).length > 0; const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle; const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim(); const hasSelectedPreset = Boolean(selectedUserPresetId) || Boolean(trimmedHandBrakePreset); const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video'; const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio'; const requiresPresetSelection = showConverterConfig; // Filter user presets by job media profile ('all' presets always shown) const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => { const presetMediaType = normalizeMediaProfile(p?.mediaType) || 'all'; if (!jobMediaProfile) { return true; } // Converter jobs may reuse any user preset; do not restrict by preset media type here. if (jobMediaProfile === 'converter') { return true; } return presetMediaType === 'all' || presetMediaType === jobMediaProfile; }); const effectiveUserPresets = filteredUserPresets; const canStartReadyJob = (isPreRipReview ? Boolean(retryJobId) : Boolean(retryJobId && encodeInputPath)) && (!requiresPresetSelection || hasSelectedPreset); const canRestartEncodeFromLastSettings = Boolean( (state === 'ERROR' || state === 'CANCELLED') && retryJobId && pipeline?.context?.canRestartEncodeFromLastSettings ); const canCancelReview = Boolean( !running && retryJobId && (state === 'READY_TO_ENCODE' || state === 'WAITING_FOR_USER_DECISION') ); const canRestartReviewFromRaw = Boolean( retryJobId && !running && (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath) ); const allowConverterConfigEdit = showConverterConfig && !queueLocked && state === 'READY_TO_ENCODE'; const converterConfigPayload = useMemo(() => { if (!showConverterConfig) { return null; } let userPreset = null; if (selectedUserPresetId) { const preset = (Array.isArray(userPresets) ? userPresets : []) .find((p) => Number(p?.id) === Number(selectedUserPresetId)); userPreset = preset ? { id: preset.id, handbrakePreset: preset.handbrake_preset || null, extraArgs: preset.extra_args || '' } : { id: selectedUserPresetId }; } else if (trimmedHandBrakePreset) { userPreset = { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' }; } return { converterMediaType, outputFormat: selectedOutputFormat, userPreset }; }, [ showConverterConfig, converterMediaType, selectedOutputFormat, selectedUserPresetId, trimmedHandBrakePreset, userPresets ]); const converterConfigSerialized = useMemo( () => (converterConfigPayload ? JSON.stringify(converterConfigPayload) : null), [converterConfigPayload] ); useEffect(() => { converterConfigLatestRef.current = null; setConverterConfigError(null); setConverterConfigSaving(false); }, [retryJobId]); useEffect(() => { if (!allowConverterConfigEdit || !converterConfigPayload || !retryJobId) { return; } if (converterConfigLatestRef.current === null) { converterConfigLatestRef.current = converterConfigSerialized; return; } if (converterConfigSerialized === converterConfigLatestRef.current) { return; } if (converterConfigSaveTimeoutRef.current) { clearTimeout(converterConfigSaveTimeoutRef.current); } converterConfigSaveTimeoutRef.current = setTimeout(async () => { setConverterConfigSaving(true); setConverterConfigError(null); try { await api.converterUpdateJobConfig(retryJobId, converterConfigPayload); converterConfigLatestRef.current = converterConfigSerialized; onInputsChanged?.(retryJobId, converterConfigPayload); } catch (error) { setConverterConfigError(error?.message || 'Konfiguration konnte nicht gespeichert werden.'); } finally { setConverterConfigSaving(false); } }, 650); return () => { if (converterConfigSaveTimeoutRef.current) { clearTimeout(converterConfigSaveTimeoutRef.current); converterConfigSaveTimeoutRef.current = null; } }; }, [ allowConverterConfigEdit, converterConfigPayload, converterConfigSerialized, retryJobId, onInputsChanged ]); const waitingPlaylistRows = useMemo(() => { const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []; const rows = evaluated.length > 0 ? evaluated : (Array.isArray(pipeline?.context?.playlistCandidates) ? pipeline.context.playlistCandidates : []); const normalized = rows .map((item) => { const playlistId = normalizePlaylistId(item?.playlistId || item?.playlistFile || item); if (!playlistId) { return null; } const playlistFile = `${playlistId}.mpls`; const score = Number(item?.score); const sequenceCoherence = Number( item?.structuralMetrics?.sequenceCoherence ?? item?.sequenceCoherence ); const handBrakeTitleId = Number(item?.handBrakeTitleId); const durationSecondsRaw = Number(item?.durationSeconds ?? item?.duration ?? 0); const durationSeconds = Number.isFinite(durationSecondsRaw) && durationSecondsRaw > 0 ? Math.trunc(durationSecondsRaw) : 0; const durationLabelRaw = String(item?.durationLabel || '').trim(); const durationLabel = durationLabelRaw || formatDurationClock(durationSeconds); return { playlistId, playlistFile, titleId: Number.isFinite(Number(item?.titleId)) ? Number(item.titleId) : null, durationSeconds, durationLabel: durationLabel || null, score: Number.isFinite(score) ? score : null, evaluationLabel: item?.evaluationLabel || null, segmentCommand: item?.segmentCommand || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`, segmentFiles: Array.isArray(item?.segmentFiles) ? item.segmentFiles : [], sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null, recommended: Boolean(item?.recommended), handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0 ? Math.trunc(handBrakeTitleId) : null, audioSummary: item?.audioSummary || null, audioTrackPreview: Array.isArray(item?.audioTrackPreview) ? item.audioTrackPreview : [] }; }) .filter(Boolean); const dedup = []; const seen = new Set(); for (const row of normalized) { if (seen.has(row.playlistId)) { continue; } seen.add(row.playlistId); dedup.push(row); } return dedup; }, [playlistAnalysis, pipeline?.context?.playlistCandidates]); const handBrakeTitleDecisionRequired = state === 'WAITING_FOR_USER_DECISION' && Boolean(pipeline?.context?.handBrakeTitleDecisionRequired); const handBrakeDecisionMode = String(pipeline?.context?.handBrakeDecisionMode || '').trim().toLowerCase(); const isSeriesPlayAllDecisionMode = handBrakeDecisionMode === 'series_playall_or_double'; const handBrakeDecisionSummary = pipeline?.context?.handBrakeDecisionSummary && typeof pipeline.context.handBrakeDecisionSummary === 'object' ? pipeline.context.handBrakeDecisionSummary : null; const ambiguousLongestTitleId = normalizeTitleId(handBrakeDecisionSummary?.longestCandidateTitleId); const handBrakeDecisionCandidateTitleIds = useMemo(() => normalizeTitleIdList( (Array.isArray(pipeline?.context?.handBrakeTitleCandidates) ? pipeline.context.handBrakeTitleCandidates : [] ).map((item) => item?.handBrakeTitleId) ), [pipeline?.context?.handBrakeTitleCandidates]); const handBrakeDecisionSelectableTitleIds = useMemo(() => { const explicit = normalizeTitleIdList(pipeline?.context?.handBrakeDecisionSelectableTitleIds); if (explicit.length > 0) { return explicit; } if (isSeriesPlayAllDecisionMode && ambiguousLongestTitleId) { return [ambiguousLongestTitleId]; } return handBrakeDecisionCandidateTitleIds; }, [ isSeriesPlayAllDecisionMode, ambiguousLongestTitleId, handBrakeDecisionCandidateTitleIds, pipeline?.context?.handBrakeDecisionSelectableTitleIds ]); const handBrakeDecisionSelectableTitleIdSet = useMemo( () => new Set(handBrakeDecisionSelectableTitleIds.map((id) => Number(id))), [handBrakeDecisionSelectableTitleIds] ); const waitingHandBrakeTitleRows = useMemo(() => { const rawCandidates = Array.isArray(pipeline?.context?.handBrakeTitleCandidates) ? pipeline.context.handBrakeTitleCandidates : []; const rawAllTitles = isSeriesPlayAllDecisionMode && Array.isArray(pipeline?.context?.handBrakeAllTitles) ? pipeline.context.handBrakeAllTitles : []; const sourceRows = rawAllTitles.length > 0 ? rawAllTitles : rawCandidates; const candidateIdSet = new Set( normalizeTitleIdList(rawCandidates.map((item) => item?.handBrakeTitleId)).map((id) => Number(id)) ); return sourceRows .map((item) => { const titleId = normalizeTitleId(item?.handBrakeTitleId); if (!titleId) { return null; } const durationSeconds = Number(item?.durationSeconds || 0); const minutes = Math.floor(durationSeconds / 60); const seconds = durationSeconds % 60; const durationLabel = `${minutes}:${String(seconds).padStart(2, '0')} min`; return { handBrakeTitleId: Math.trunc(titleId), durationSeconds, durationLabel, durationMinutes: Number(item?.durationMinutes || 0), audioTrackCount: Number(item?.audioTrackCount || 0), subtitleTrackCount: Number(item?.subtitleTrackCount || 0), sizeBytes: Number(item?.sizeBytes || 0), decisionCandidate: candidateIdSet.has(Number(titleId)) }; }) .filter(Boolean) .sort((a, b) => a.handBrakeTitleId - b.handBrakeTitleId); }, [ isSeriesPlayAllDecisionMode, pipeline?.context?.handBrakeAllTitles, pipeline?.context?.handBrakeTitleCandidates ]); const visibleWaitingHandBrakeTitleRows = useMemo(() => { if (!isSeriesPlayAllDecisionMode) { return waitingHandBrakeTitleRows; } return waitingHandBrakeTitleRows.filter((row) => row.decisionCandidate); }, [isSeriesPlayAllDecisionMode, waitingHandBrakeTitleRows]); useEffect(() => { if (!handBrakeTitleDecisionRequired) { setSelectedHandBrakeTitleIdsState([]); return; } const contextSelectedIds = Array.isArray(pipeline?.context?.selectedHandBrakeTitleIds) ? pipeline.context.selectedHandBrakeTitleIds : []; const normalizedContextIds = contextSelectedIds .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.trunc(value)); if (normalizedContextIds.length > 0) { setSelectedHandBrakeTitleIdsState(Array.from(new Set(normalizedContextIds))); return; } const singleContextId = Number(pipeline?.context?.selectedHandBrakeTitleId || 0); if (Number.isFinite(singleContextId) && singleContextId > 0) { setSelectedHandBrakeTitleIdsState([Math.trunc(singleContextId)]); return; } if (isSeriesPlayAllDecisionMode) { const lockedDefaultIds = waitingHandBrakeTitleRows .filter((row) => row.decisionCandidate && !handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId))) .map((row) => row.handBrakeTitleId); if (lockedDefaultIds.length > 0) { setSelectedHandBrakeTitleIdsState(normalizeTitleIdList(lockedDefaultIds)); return; } } const firstSelectable = waitingHandBrakeTitleRows.find((row) => ( !isSeriesPlayAllDecisionMode || handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId)) ))?.handBrakeTitleId || null; setSelectedHandBrakeTitleIdsState(firstSelectable ? [firstSelectable] : []); }, [ handBrakeTitleDecisionRequired, isSeriesPlayAllDecisionMode, retryJobId, handBrakeDecisionSelectableTitleIdSet, waitingHandBrakeTitleRows, pipeline?.context?.selectedHandBrakeTitleId, pipeline?.context?.selectedHandBrakeTitleIds ]); useEffect(() => { if (state !== 'WAITING_FOR_USER_DECISION') { setSelectedPlaylistId(null); return; } const current = normalizePlaylistId(pipeline?.context?.selectedPlaylist); if (current) { setSelectedPlaylistId(current); return; } const recommendedFromRows = waitingPlaylistRows.find((item) => item.recommended)?.playlistId || null; const recommendedFromAnalysis = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId); const fallback = waitingPlaylistRows[0]?.playlistId || null; setSelectedPlaylistId(recommendedFromRows || recommendedFromAnalysis || fallback); }, [ state, retryJobId, waitingPlaylistRows, playlistAnalysis?.recommendation?.playlistId, pipeline?.context?.selectedPlaylist ]); const seriesBatchRunningChildJobIdHint = useMemo(() => { const seriesBatch = pipeline?.context?.seriesBatch; if (!seriesBatch || typeof seriesBatch !== 'object') { return null; } const children = Array.isArray(seriesBatch?.children) ? seriesBatch.children : []; const runningChild = children.find((child) => normalizeSeriesBatchStatus(child?.status) === 'RUNNING') || null; return normalizeJobId(runningChild?.jobId); }, [pipeline?.context?.seriesBatch]); useEffect(() => { if (!running || !retryJobId) { setLiveJobLog(''); return undefined; } let cancelled = false; const refresh = async () => { try { const response = await api.getJob(retryJobId, { includeLiveLog: true, lite: true }); if (!cancelled) { const liveJob = response?.job && typeof response.job === 'object' ? response.job : null; const childRows = Array.isArray(liveJob?.children) ? liveJob.children : []; const liveHintChildId = normalizeJobId(seriesBatchRunningChildJobIdHint); const isSeriesBatchParentRun = Boolean( liveHintChildId || liveJob?.encodePlan?.seriesBatchParent || String(liveJob?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'series_batch' ); if (isSeriesBatchParentRun) { const activeChildStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'RUNNING']); let activeChild = liveHintChildId ? (childRows.find((child) => normalizeJobId(child?.id) === liveHintChildId) || null) : null; if (activeChild) { const hintChildStatus = String(activeChild?.status || activeChild?.last_state || '').trim().toUpperCase(); if (!activeChildStates.has(hintChildStatus)) { activeChild = null; } } if (!activeChild) { activeChild = childRows.find((child) => ( activeChildStates.has(String(child?.status || child?.last_state || '').trim().toUpperCase()) )) || null; } setLiveJobLog(String(activeChild?.log || '').trim()); return; } setLiveJobLog(String(liveJob?.log || '').trim()); } } catch (_err) { // ignore transient polling errors } }; void refresh(); const interval = setInterval(refresh, 2500); return () => { cancelled = true; clearInterval(interval); }; }, [running, retryJobId, seriesBatchRunningChildJobIdHint]); const manualDecisionState = pipeline?.context?.manualDecisionState || null; const rawDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && manualDecisionState === 'waiting_for_raw_decision'; const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired && !rawDecisionRequiredBeforeStart; const isSeriesDvdReview = useMemo(() => { if (jobMediaProfile !== 'dvd' && jobMediaProfile !== 'bluray') { return false; } return Boolean( hasSeriesMetadataShape(selectedMetadata) || hasSeriesMetadataShape(mediaInfoReview?.selectedMetadata) || hasSeriesEpisodeAssignmentHints(mediaInfoReview?.titles) || Boolean(mediaInfoReview?.seriesBatchParent) ); }, [ jobMediaProfile, selectedMetadata?.seasonNumber, selectedMetadata?.episodes, selectedMetadata?.metadataProvider, selectedMetadata?.workflowKind, mediaInfoReview?.selectedMetadata?.seasonNumber, mediaInfoReview?.selectedMetadata?.episodes, mediaInfoReview?.selectedMetadata?.metadataProvider, mediaInfoReview?.selectedMetadata?.workflowKind, mediaInfoReview?.titles, mediaInfoReview?.seriesBatchParent ]); useEffect(() => { let cancelled = false; const loadContainerUsedEpisodes = async () => { if (!isSeriesDvdReview || !retryJobId) { if (!cancelled) { setContainerUsedEpisodeKeys([]); setContainerSeasonEpisodes([]); } return; } try { const currentResponse = await api.getJob(retryJobId, { lite: true, forceRefresh: true }); if (cancelled) { return; } const currentJob = currentResponse?.job && typeof currentResponse.job === 'object' ? currentResponse.job : null; const currentJobId = normalizeJobId(currentJob?.id || retryJobId); const normalizedCurrentKind = String(currentJob?.job_kind || '').trim().toLowerCase(); const containerJobId = normalizedCurrentKind === 'dvd_series_container' ? normalizeJobId(currentJob?.id) : normalizeJobId(currentJob?.parent_job_id); if (!containerJobId) { if (!cancelled) { setContainerUsedEpisodeKeys([]); setContainerSeasonEpisodes([]); } return; } const containerResponse = await api.getJob(containerJobId, { lite: true, forceRefresh: true }); if (cancelled) { return; } const containerJob = containerResponse?.job && typeof containerResponse.job === 'object' ? containerResponse.job : null; const children = Array.isArray(containerJob?.children) ? containerJob.children : []; const usedKeys = new Set(); const extractEpisodesFromJob = (jobRow) => { const mkInfo = jobRow?.makemkvInfo && typeof jobRow.makemkvInfo === 'object' ? jobRow.makemkvInfo : {}; const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' ? mkInfo.analyzeContext : {}; const selectedFromAnalyze = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' ? analyzeContext.selectedMetadata : {}; const selectedFromRoot = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' ? mkInfo.selectedMetadata : {}; const episodes = Array.isArray(selectedFromAnalyze?.episodes) ? selectedFromAnalyze.episodes : (Array.isArray(selectedFromRoot?.episodes) ? selectedFromRoot.episodes : []); return Array.isArray(episodes) ? episodes : []; }; let fallbackEpisodes = extractEpisodesFromJob(containerJob); for (const child of children) { const childId = normalizeJobId(child?.id); if (currentJobId && childId && currentJobId === childId) { continue; } if (isSeriesBatchEpisodePlan(child?.encodePlan)) { continue; } const refs = collectEpisodeReferencesFromPlan(child?.encodePlan); for (const ref of refs) { const keys = collectEpisodeReferenceKeys(ref); for (const key of keys) { usedKeys.add(key); } } const batchEpisodes = Array.isArray(child?.handbrakeInfo?.seriesBatch?.episodes) ? child.handbrakeInfo.seriesBatch.episodes : []; for (const episode of batchEpisodes) { const keys = collectEpisodeReferenceKeys({ episodeId: episode?.episodeId, episodeNumber: episode?.episodeNumber, seasonNumber: episode?.seasonNumber }); for (const key of keys) { usedKeys.add(key); } } if ((!Array.isArray(fallbackEpisodes) || fallbackEpisodes.length === 0)) { const childEpisodes = extractEpisodesFromJob(child); if (Array.isArray(childEpisodes) && childEpisodes.length > 0) { fallbackEpisodes = childEpisodes; } } } if (!cancelled) { setContainerUsedEpisodeKeys(Array.from(usedKeys)); setContainerSeasonEpisodes(Array.isArray(fallbackEpisodes) ? fallbackEpisodes : []); } } catch (_error) { if (!cancelled) { setContainerUsedEpisodeKeys([]); setContainerSeasonEpisodes([]); } } }; void loadContainerUsedEpisodes(); return () => { cancelled = true; }; }, [ isSeriesDvdReview, retryJobId, mediaInfoReview?.generatedAt, mediaInfoReview?.reviewConfirmedAt, selectedMetadata?.tmdbId, selectedMetadata?.seasonNumber ]); const seriesBatchContext = useMemo(() => { const raw = pipeline?.context?.seriesBatch; if (!raw || typeof raw !== 'object') { return null; } const rawChildren = Array.isArray(raw?.children) ? raw.children : []; const children = rawChildren.map((child, index) => { const status = normalizeSeriesBatchStatus(child?.status); return { jobId: normalizeJobId(child?.jobId), title: String(child?.title || '').trim() || `Episode #${index + 1}`, status, progress: normalizeSeriesBatchProgress(child?.progress, status === 'FINISHED' ? 100 : 0), eta: child?.eta ?? null, episodeIndex: normalizePositiveInt(child?.episodeIndex), titleId: normalizeTitleId(child?.titleId) }; }); const totalCountRaw = Number(raw?.totalCount || 0); const totalCount = Number.isFinite(totalCountRaw) && totalCountRaw > 0 ? Math.trunc(totalCountRaw) : children.length; const finishedCountRaw = Number(raw?.finishedCount || 0); const cancelledCountRaw = Number(raw?.cancelledCount || 0); const errorCountRaw = Number(raw?.errorCount || 0); const runningCountRaw = Number(raw?.runningCount || 0); const finishedCount = Number.isFinite(finishedCountRaw) ? Math.max(0, Math.trunc(finishedCountRaw)) : 0; const cancelledCount = Number.isFinite(cancelledCountRaw) ? Math.max(0, Math.trunc(cancelledCountRaw)) : 0; const errorCount = Number.isFinite(errorCountRaw) ? Math.max(0, Math.trunc(errorCountRaw)) : 0; const runningCount = Number.isFinite(runningCountRaw) ? Math.max(0, Math.trunc(runningCountRaw)) : 0; const aggregateProgress = totalCount > 0 ? ( children.reduce((sum, child) => { const status = normalizeSeriesBatchStatus(child?.status); if (status === 'FINISHED') { return sum + 100; } return sum + normalizeSeriesBatchProgress(child?.progress, 0); }, 0) / totalCount ) : 0; const overallProgress = normalizeSeriesBatchProgress(raw?.overallProgress, aggregateProgress); return { totalCount, finishedCount, cancelledCount, errorCount, runningCount, overallProgress, children }; }, [pipeline?.context?.seriesBatch]); const hasSeriesBatchProgress = Boolean( seriesBatchContext && (seriesBatchContext.totalCount > 0 || seriesBatchContext.children.length > 0) ); const runningSeriesEpisode = hasSeriesBatchProgress ? (seriesBatchContext.children.find((child) => normalizeSeriesBatchStatus(child?.status) === 'RUNNING') || null) : null; const primaryProgressValue = hasSeriesBatchProgress ? normalizeSeriesBatchProgress(seriesBatchContext?.overallProgress, progress) : progress; const primaryEta = hasSeriesBatchProgress ? (runningSeriesEpisode?.eta || pipeline?.eta || null) : (pipeline?.eta || null); const primaryStatusText = hasSeriesBatchProgress ? ( `Serien-Encoding ${primaryProgressValue.toFixed(2)}%` + `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}` ) : (pipeline?.statusText || 'Bereit'); const isSeriesBatchParentReview = Boolean( mediaInfoReview?.seriesBatchParent || pipeline?.context?.mediaInfoReview?.seriesBatchParent || hasSeriesBatchProgress ); const commandOutputPath = useMemo(() => { if (jobMediaProfile === 'converter') { const reviewWithOutput = mediaInfoReview ? { ...mediaInfoReview, outputFormat: selectedOutputFormat } : { outputFormat: selectedOutputFormat }; return buildConverterOutputPathPreview(settingsMap, reviewWithOutput, selectedMetadata, retryJobId); } if (isSeriesDvdReview) { return null; } return buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId); }, [settingsMap, jobMediaProfile, mediaInfoReview, selectedMetadata, retryJobId, selectedOutputFormat, isSeriesDvdReview]); const commandOutputPathByTitle = useMemo(() => { if (!isSeriesDvdReview) { return {}; } const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; const map = {}; for (const title of reviewTitles) { const titleId = normalizeTitleId(title?.id); if (!titleId) { continue; } const assignment = episodeAssignmentsByTitle?.[titleId] || episodeAssignmentsByTitle?.[String(titleId)] || null; const path = buildDvdSeriesEpisodeOutputPathPreview( settingsMap, jobMediaProfile, selectedMetadata || {}, title, assignment, retryJobId, Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [], episodeAssignmentsByTitle, selectedEncodeTitleIds ); if (path) { map[titleId] = path; } } return map; }, [ isSeriesDvdReview, mediaInfoReview?.titles, episodeAssignmentsByTitle, settingsMap, jobMediaProfile, selectedMetadata, retryJobId, selectedEncodeTitleIds ]); const multiEpisodeReviewWarnings = useMemo(() => { if (!isSeriesDvdReview) { return []; } const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; if (reviewTitles.length === 0) { return []; } return reviewTitles .map((title) => { const titleId = normalizeTitleId(title?.id); if (!titleId) { return null; } const assignment = episodeAssignmentsByTitle?.[titleId] || episodeAssignmentsByTitle?.[String(titleId)] || null; const episodeRange = resolveEffectiveEpisodeRangeForTitle( assignment, title, reviewTitles, titleId ); if (!episodeRange.isMulti) { return null; } const episodeRangeToken = buildEpisodeRangeToken(episodeRange.start, episodeRange.end); const partsToken = buildEpisodePartsToken(episodeRange); const rawTitle = String( assignment?.episodeTitle || title?.episodeTitle || title?.fileName || `Episode ${episodeRangeToken}` ).trim() || `Episode ${episodeRangeToken}`; const normalizedTitle = normalizeEpisodeTitleForOutput(rawTitle, episodeRange); const normalizedByHeuristic = normalizedTitle !== rawTitle; const listTitleRaw = String(title?.episodeTitle || '').trim(); const normalizedListTitle = listTitleRaw ? normalizeEpisodeTitleForOutput(listTitleRaw, episodeRange) : ''; const mismatchWithTitleList = Boolean( normalizedListTitle && normalizeEpisodeTitleTokenForCompare(normalizedTitle) !== normalizeEpisodeTitleTokenForCompare(normalizedListTitle) ); return { titleId, episodeRangeToken, partsToken, rawTitle, normalizedTitle, normalizedByHeuristic, normalizedListTitle: normalizedListTitle || null, mismatchWithTitleList }; }) .filter(Boolean); }, [isSeriesDvdReview, mediaInfoReview?.titles, episodeAssignmentsByTitle]); const episodeRangeConflictWarnings = useMemo(() => { if (!isSeriesDvdReview) { return []; } const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; if (reviewTitles.length === 0) { return []; } const metadataRows = (Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : []) .map((episode) => { const episodeNumber = normalizePositiveInt( episode?.episodeNumberStart ?? episode?.episodeNoStart ?? episode?.episodeNumber ?? episode?.number ?? null ); const episodeTitle = String( episode?.episodeTitle || episode?.name || episode?.title || '' ).trim(); return { episodeNumber, episodeTitle }; }) .filter((row) => row.episodeNumber); const titleAnchorSuggestionById = new Map(); if (metadataRows.length > 0) { let cursor = 0; for (const title of reviewTitles) { const titleId = normalizeTitleId(title?.id); if (!titleId) { continue; } if (cursor >= metadataRows.length) { break; } const explicitSpan = normalizePositiveInt( title?.episodeSpan ?? title?.episodeCount ?? null ); const estimatedSpan = estimateSeriesEpisodeSpanForTitle(title, reviewTitles); const span = Math.max(1, explicitSpan || 1, estimatedSpan || 1); const safeSpan = Math.max(1, Math.min(span, metadataRows.length - cursor)); const spanRows = metadataRows.slice(cursor, cursor + safeSpan); const startNumber = normalizePositiveInt(spanRows[0]?.episodeNumber); const endNumber = normalizePositiveInt(spanRows[spanRows.length - 1]?.episodeNumber) || startNumber; const isMulti = Boolean(startNumber && endNumber && endNumber > startNumber); const sourceTitle = spanRows .map((row) => String(row?.episodeTitle || '').trim()) .filter(Boolean) .join(' + '); const normalizedTitle = sourceTitle ? normalizeEpisodeTitleForOutput( sourceTitle, { start: startNumber || 1, end: endNumber || startNumber || 1, span: safeSpan, isMulti } ) : ''; titleAnchorSuggestionById.set(titleId, { suggestedTitle: normalizedTitle || '(ohne Titel)', isMulti }); cursor += safeSpan; } } return reviewTitles .map((title) => { const titleId = normalizeTitleId(title?.id); if (!titleId) { return null; } const assignment = episodeAssignmentsByTitle?.[titleId] || episodeAssignmentsByTitle?.[String(titleId)] || null; if (!hasExplicitEpisodeAssignmentRange(assignment)) { return null; } const explicitRange = resolveEpisodeAssignmentRange(assignment, title, titleId); if (!explicitRange || explicitRange.isMulti) { return null; } const spanHint = estimateSeriesEpisodeSpanForTitle(title, reviewTitles); if (!Number.isFinite(spanHint) || spanHint <= 1) { return null; } const effectiveSpan = Math.max(1, Math.trunc(spanHint)); const suggestedRange = { start: explicitRange.start, end: explicitRange.start + effectiveSpan - 1, span: effectiveSpan, isMulti: effectiveSpan > 1 }; const suggestedTitle = String(titleAnchorSuggestionById.get(titleId)?.suggestedTitle || '').trim(); return { titleId, episodeTitle: String( assignment?.episodeTitle || title?.episodeTitle || title?.fileName || '' ).trim() || '(ohne Titel)', suggestedTitle: suggestedTitle || normalizeEpisodeTitleForOutput( String(assignment?.episodeTitle || title?.episodeTitle || '').trim() || '(ohne Titel)', suggestedRange ) }; }) .filter(Boolean); }, [isSeriesDvdReview, mediaInfoReview?.titles, episodeAssignmentsByTitle, selectedMetadataEpisodes]); const episodeAssignmentWarningLines = useMemo(() => ( episodeRangeConflictWarnings .map((item) => { const currentTitle = String(item?.episodeTitle || '').trim() || '(ohne Titel)'; const suggestionTitle = String(item?.suggestedTitle || '').trim() || '(ohne Titel)'; return `Titel ${item.titleId}: ${currentTitle} --> Vorschlag: ${suggestionTitle} (Heuristik vermutet Doppelfolge)`; }) .filter(Boolean) ), [episodeRangeConflictWarnings]); const hasMultiEpisodeInJob = multiEpisodeReviewWarnings.length > 0 || episodeRangeConflictWarnings.length > 0; const presetDisplayValue = useMemo(() => { const preset = String(mediaInfoReview?.selectors?.preset || '').trim(); if (!preset) { return ''; } return presetDisplayMap[preset] || preset; }, [mediaInfoReview?.selectors?.preset, presetDisplayMap]); const converterUserPresetOptions = useMemo(() => { if (!showConverterConfig) { return []; } const userOptions = (Array.isArray(effectiveUserPresets) ? effectiveUserPresets : []) .map((preset) => ({ label: String(preset?.name || '').trim(), value: Number(preset?.id) })) .filter((option) => option.label && Number.isFinite(option.value)); return [{ label: 'Kein User-Preset', value: null }, ...userOptions]; }, [showConverterConfig, effectiveUserPresets]); const converterHandBrakePresetOptions = useMemo(() => { if (!showConverterConfig) { return []; } const list = Array.isArray(handBrakePresetOptions) ? handBrakePresetOptions : []; const hbOptions = list .map((option) => { const label = String(option?.label || '').trim(); if (!label) { return null; } const disabled = Boolean(option?.disabled); const lowerLabel = label.toLowerCase(); if (lowerLabel.includes('handbrake cli') || lowerLabel.includes('cli default')) { return null; } if (!disabled && (lowerLabel.includes('kein preset') || lowerLabel.includes('cli-parameter'))) { return null; } const value = disabled ? String(option?.value || label) : String(option?.value || '').trim(); if (!disabled && !value) { return null; } return { label, value, disabled }; }) .filter(Boolean); return [{ label: 'Kein HandBrake-Preset', value: '' }, ...hbOptions]; }, [showConverterConfig, handBrakePresetOptions]); const buildSelectedTrackSelectionForCurrentTitle = () => { const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; const titlesById = new Map( reviewTitles .map((title) => [normalizeTitleId(title?.id), title]) .filter((entry) => entry[0] !== null) ); const fallbackSelectedIds = normalizeTitleIdList([ ...selectedEncodeTitleIds, selectedEncodeTitleId, mediaInfoReview?.encodeInputTitleId, ...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id) ]); const selectedTitleIdsInReviewOrder = reviewTitles .map((title) => normalizeTitleId(title?.id)) .filter((id) => id !== null && fallbackSelectedIds.includes(id)); const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0 ? selectedTitleIdsInReviewOrder : fallbackSelectedIds; const preferredPrimaryTitleId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(mediaInfoReview?.encodeInputTitleId) || null; const encodeTitleId = effectiveSelectedTitleIds.includes(preferredPrimaryTitleId) ? preferredPrimaryTitleId : (effectiveSelectedTitleIds[0] || null); const selectedTrackSelection = {}; for (const titleId of effectiveSelectedTitleIds) { const selectionEntry = trackSelectionByTitle?.[titleId] || trackSelectionByTitle?.[String(titleId)] || null; const fallbackSelection = defaultTrackSelectionForTitle(mediaInfoReview, titleId); const effectiveSelection = selectionEntry || fallbackSelection; const encodeTitle = titlesById.get(titleId) || null; const blockedSubtitleTrackIds = new Set( (Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : []) .filter((track) => isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track)) .map((track) => normalizeTrackId(track?.id)) .filter((id) => id !== null) .map((id) => String(id)) ); const availableSubtitleLanguages = new Set( (Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : []) .filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track)) .map((track) => normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und')) ); const normalizedVariantSelection = normalizeSubtitleVariantSelectionMap( effectiveSelection?.subtitleVariantSelection || {} ); const filteredVariantSelection = {}; for (const [language, value] of Object.entries(normalizedVariantSelection)) { if (!availableSubtitleLanguages.has(language)) { continue; } filteredVariantSelection[language] = { full: Boolean(value?.full), forced: Boolean(value?.forced) }; } const normalizedLanguageOrder = normalizeSubtitleLanguageOrder(effectiveSelection?.subtitleLanguageOrder || []) .filter((language) => availableSubtitleLanguages.has(language)); const hasExplicitVariantSelection = Object.keys(filteredVariantSelection).length > 0; const filteredSubtitleTrackIds = normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || []) .filter((id) => !blockedSubtitleTrackIds.has(String(id))); selectedTrackSelection[titleId] = { audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []), subtitleTrackIds: hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds, subtitleVariantSelection: filteredVariantSelection, subtitleLanguageOrder: normalizedLanguageOrder }; } const episodeAssignments = {}; for (const titleId of effectiveSelectedTitleIds) { const assignment = episodeAssignmentsByTitle?.[titleId] || episodeAssignmentsByTitle?.[String(titleId)] || null; const title = titlesById.get(titleId) || null; const episodeId = Number( assignment?.episodeId || assignment?.episodeIdStart || title?.episodeId || 0 ); const episodeNumber = Number( assignment?.episodeNumber || assignment?.episodeNumberStart || title?.episodeNumber || 0 ); const seasonNumber = Number(assignment?.seasonNumber || title?.seasonNumber || 0); const episodeIdStart = Number( assignment?.episodeIdStart || assignment?.episodeId || 0 ); const episodeIdEnd = Number( assignment?.episodeIdEnd || 0 ); const episodeNumberStart = Number( assignment?.episodeNumberStart || assignment?.episodeNoStart || assignment?.episodeNumber || title?.episodeNumber || 0 ); const episodeNumberEnd = Number( assignment?.episodeNumberEnd || assignment?.episodeNoEnd || 0 ); const episodeSpanRaw = Number(assignment?.episodeSpan || assignment?.episodeCount || 0); const rawEpisodeTitle = String(assignment?.episodeTitle || title?.episodeTitle || '').trim(); const normalizedEpisodeStart = Number.isFinite(episodeNumberStart) && episodeNumberStart > 0 ? Math.trunc(episodeNumberStart) : (Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null); const normalizedEpisodeEnd = Number.isFinite(episodeNumberEnd) && episodeNumberEnd > 0 ? Math.trunc(episodeNumberEnd) : normalizedEpisodeStart; const normalizedEpisodeSpan = Number.isFinite(episodeSpanRaw) && episodeSpanRaw > 0 ? Math.trunc(episodeSpanRaw) : ( normalizedEpisodeStart && normalizedEpisodeEnd && normalizedEpisodeEnd >= normalizedEpisodeStart ? (normalizedEpisodeEnd - normalizedEpisodeStart + 1) : 1 ); const normalizedEpisodeRange = { start: normalizedEpisodeStart || 1, end: normalizedEpisodeEnd || normalizedEpisodeStart || 1, span: normalizedEpisodeSpan, isMulti: normalizedEpisodeSpan > 1 }; const normalizedEpisodeTitle = rawEpisodeTitle ? normalizeEpisodeTitleForOutput(rawEpisodeTitle, normalizedEpisodeRange) : ''; if (!normalizedEpisodeTitle && !(episodeId > 0) && !(episodeNumber > 0) && !(seasonNumber > 0)) { continue; } const normalizedRangeToken = normalizedEpisodeStart ? buildEpisodeRangeToken(normalizedEpisodeStart, normalizedEpisodeEnd || normalizedEpisodeStart) : null; episodeAssignments[titleId] = { episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null, episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null, seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null, episodeTitle: normalizedEpisodeTitle || null, episodeIdStart: Number.isFinite(episodeIdStart) && episodeIdStart > 0 ? Math.trunc(episodeIdStart) : null, episodeIdEnd: Number.isFinite(episodeIdEnd) && episodeIdEnd > 0 ? Math.trunc(episodeIdEnd) : null, episodeNumberStart: normalizedEpisodeStart, episodeNumberEnd: normalizedEpisodeEnd || normalizedEpisodeStart, episodeSpan: normalizedEpisodeSpan, episodeRange: normalizedRangeToken, episodeTitleStart: String(assignment?.episodeTitleStart || '').trim() || null, episodeTitleEnd: String(assignment?.episodeTitleEnd || '').trim() || null }; } const selectedPostScriptIds = postEncodeItems.filter((i) => i.type === 'script').map((i) => i.id); const selectedPreScriptIds = preEncodeItems.filter((i) => i.type === 'script').map((i) => i.id); const selectedPostChainIds = postEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id); const selectedPreChainIds = preEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id); return { encodeTitleId, selectedEncodeTitleIds: effectiveSelectedTitleIds, selectedTrackSelection: Object.keys(selectedTrackSelection).length > 0 ? selectedTrackSelection : null, episodeAssignments: Object.keys(episodeAssignments).length > 0 ? episodeAssignments : null, selectedPostScriptIds, selectedPreScriptIds, selectedPostChainIds, selectedPreChainIds, selectedHandBrakePreset: trimmedHandBrakePreset }; }; return (
{primaryStatusText}
{running && (
formatPercentTwoDecimals(value)} /> {primaryEta ? (hasSeriesBatchProgress ? `ETA aktuelle Episode ${primaryEta}` : `ETA ${primaryEta}`) : 'ETA unbekannt'}
)} {state === 'FINISHED' && (
)} {hasSeriesBatchProgress ? (
Serien-Encode Fortschritt 0 ? 'danger' : (seriesBatchContext.cancelledCount > 0 ? 'warning' : 'success')} />
{`Laufend: ${seriesBatchContext.runningCount} | Fehler: ${seriesBatchContext.errorCount} | Abgebrochen: ${seriesBatchContext.cancelledCount}`}
{seriesBatchContext.children.map((child, index) => (
{child.title}
{child.eta ? `${Math.round(child.progress)}% | ETA ${child.eta}` : `${Math.round(child.progress)}%`}
))}
) : null}
{queueLocked ? (
{running ? (

Aktueller Job-Log

{liveJobLog || 'Noch keine Log-Ausgabe vorhanden.'}
) : null} {rawDecisionRequiredBeforeStart && !queueLocked ? (

RAW Entscheidung erforderlich

Vorhandenes Raw zur Disk gefunden. Wie soll weiter verfahren werden?
) : null} {playlistDecisionRequiredBeforeStart && !queueLocked ? (

Playlist-Auswahl erforderlich

Metadaten sind abgeschlossen. Vor Start muss ein Titel/Playlist manuell per Checkbox gewählt werden. {waitingPlaylistRows.length > 0 ? (
{waitingPlaylistRows.map((row) => (
{row.evaluationLabel ? {row.evaluationLabel} : null} {row.sequenceCoherence !== null ? ( Sequenz-Kohärenz: {row.sequenceCoherence.toFixed(3)} ) : null} {row.handBrakeTitleId !== null ? ( HandBrake Titel: -t {row.handBrakeTitleId} ) : null} {row.audioSummary ? ( Audio: {row.audioSummary} ) : null} {row.segmentCommand ? Info: {row.segmentCommand} : null} {Array.isArray(row.audioTrackPreview) && row.audioTrackPreview.length > 0 ? (
Audio-Spuren anzeigen ({row.audioTrackPreview.length})
{row.audioTrackPreview.join('\n')}
) : null} {Array.isArray(row.segmentFiles) && row.segmentFiles.length > 0 ? (
Segment-Dateien anzeigen ({row.segmentFiles.length})
{row.segmentFiles.join('\n')}
) : ( Keine Segmentliste aus TINFO:26 verfügbar. )}
))}
) : ( Keine Kandidaten gefunden. Bitte Analyse erneut ausführen. )}
) : null} {handBrakeTitleDecisionRequired && !queueLocked ? (

{isSeriesPlayAllDecisionMode ? 'Serien-Grenzfall: PlayAll oder Doppelfolge?' : `${discTypeLabel} Titelauswahl erforderlich`}

{isSeriesPlayAllDecisionMode ? 'Min. ein Titel passt zeitlich zur Summe der kürzeren Episoden. Bitte durch Auswahl entscheiden, ob PlayAll Multi-Episode (nicht anhaken) oder Doppelfolge (anhaken).' : `Mehrere ${discTypeLabel}-Titel gefunden, die die Mindestlänge erfüllen. Bitte einen oder mehrere Episodentitel auswählen.`} {visibleWaitingHandBrakeTitleRows.length > 0 ? (
{visibleWaitingHandBrakeTitleRows.map((row) => { const isDecisionSelectable = !isSeriesPlayAllDecisionMode || handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId)); const isAmbiguousDecisionTitle = isSeriesPlayAllDecisionMode && isDecisionSelectable && ( Number(row.handBrakeTitleId) === Number(ambiguousLongestTitleId) || handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId)) ); const isRecognizedLocked = isSeriesPlayAllDecisionMode && row.decisionCandidate && !isDecisionSelectable; const isOutOfDecisionScope = isSeriesPlayAllDecisionMode && !row.decisionCandidate; return (
); })}
) : ( Keine Kandidaten verfügbar. Bitte Analyse erneut ausführen. )}
) : null} {selectedMetadata && !isDiscTitleSelectionRequired ? (
{selectedMetadata.poster ? ( {selectedMetadata.title ) : (
Kein {jobMediaProfile === 'cd' || jobMediaProfile === 'audiobook' ? 'Cover' : 'Poster'}
)}
Titel: {selectedMetadata.title || '-'}
Jahr: {selectedMetadata.year || '-'}
IMDb: {selectedMetadata.imdbId || '-'}
Status: {stateLabel}
) : null} {(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
{!isDiscTitleSelectionRequired ?

Titel-/Spurprüfung

: null} {state === 'READY_TO_ENCODE' && !queueLocked && !isDiscTitleSelectionRequired ? ( {isPreRipReview ? 'Spurauswahl kann direkt übernommen werden. Beim Klick auf "Backup + Encoding starten" wird automatisch bestätigt und gestartet.' : 'Spurauswahl kann direkt übernommen werden. Beim Klick auf "Encoding starten" wird automatisch bestätigt und gestartet.'} {reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''} ) : null} {showConverterConfig ? (
setSelectedOutputFormat(e.value)} style={{ width: '100%' }} disabled={!allowConverterConfigEdit} />
{ const rawValue = e?.value && typeof e.value === 'object' ? e.value.value : e?.value; const nextValue = rawValue === null || rawValue === undefined || String(rawValue).trim() === '' ? null : Number(rawValue); const normalized = Number.isFinite(nextValue) && nextValue > 0 ? Math.trunc(nextValue) : null; setSelectedUserPresetId(normalized); if (normalized) { setSelectedHandBrakePreset(''); } }} style={{ width: '100%' }} placeholder="User-Preset auswählen …" showClear disabled={!allowConverterConfigEdit || Boolean(trimmedHandBrakePreset) || converterUserPresetOptions.length <= 1} /> {converterUserPresetOptions.length <= 1 ? ( Keine User-Presets vorhanden. ) : null}
{ const rawValue = e?.value && typeof e.value === 'object' ? e.value.value : e?.value; const next = String(rawValue || '').trim(); setSelectedHandBrakePreset(next); if (next) { setSelectedUserPresetId(null); } }} style={{ width: '100%' }} placeholder="HandBrake-Preset auswählen …" filter optionDisabled="disabled" showClear disabled={!allowConverterConfigEdit || Boolean(selectedUserPresetId) || converterHandBrakePresetOptions.length <= 1} /> {converterHandBrakePresetOptions.length <= 1 ? ( Keine HandBrake-Presets geladen. ) : null}
{allowConverterConfigEdit && !hasSelectedPreset ? ( Bitte wähle ein User- oder HandBrake-Preset aus, bevor das Encoding starten kann. ) : null} {allowConverterConfigEdit ? ( converterConfigError ? ( {converterConfigError} ) : ( {converterConfigSaving ? 'Speichere Konfiguration …' : 'Konfiguration gespeichert'} ) ) : null}
) : null} {isSeriesDvdReview && hasMultiEpisodeInJob ? (

Hinweis Episoden-Zuordnung

Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich prüfen, bevor das Encoding startet. {episodeAssignmentWarningLines.map((line, index) => ( {line} ))}
) : null} { const normalizedTitleId = normalizeTitleId(titleId); setSelectedEncodeTitleId(normalizedTitleId); if (!normalizedTitleId) { return; } setSelectedEncodeTitleIds((prev) => normalizeTitleIdList([...(Array.isArray(prev) ? prev : []), normalizedTitleId])); }} onToggleEncodeTitle={(titleId, checked) => { const normalizedTitleId = normalizeTitleId(titleId); if (!normalizedTitleId) { return; } setSelectedEncodeTitleIds((prev) => { const current = normalizeTitleIdList(prev); if (checked) { const next = normalizeTitleIdList([...current, normalizedTitleId]); setSelectedEncodeTitleId(normalizedTitleId); return next; } const next = current.filter((id) => id !== normalizedTitleId); setSelectedEncodeTitleId((prevPrimary) => { const normalizedPrimary = normalizeTitleId(prevPrimary); if (normalizedPrimary === normalizedTitleId) { return next[0] || null; } return normalizedPrimary; }); return next; }); }} allowTrackSelection={state === 'READY_TO_ENCODE' && !queueLocked} trackSelectionByTitle={trackSelectionByTitle} onTrackSelectionChange={(titleId, trackType, trackId, checked) => { const normalizedTitleId = normalizeTitleId(titleId); const normalizedTrackId = normalizeTrackId(trackId); if (!normalizedTitleId || normalizedTrackId === null) { return; } setTrackSelectionByTitle((prev) => { const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || { audioTrackIds: [], subtitleTrackIds: [], subtitleVariantSelection: {}, subtitleLanguageOrder: [] }; const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds'; const existing = normalizeTrackIdList(current?.[key] || []); const next = checked ? normalizeTrackIdList([...existing, normalizedTrackId]) : existing.filter((id) => id !== normalizedTrackId); return { ...prev, [normalizedTitleId]: { ...current, [key]: next } }; }); }} onSubtitleVariantSelectionChange={(titleId, language, variant, checked) => { const normalizedTitleId = normalizeTitleId(titleId); const normalizedLanguage = normalizeSubtitleLanguage(language); const normalizedVariant = String(variant || '').trim().toLowerCase() === 'forced' ? 'forced' : 'full'; if (!normalizedTitleId || !normalizedLanguage) { return; } setTrackSelectionByTitle((prev) => { const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || { audioTrackIds: [], subtitleTrackIds: [], subtitleVariantSelection: {}, subtitleLanguageOrder: [] }; const currentMap = normalizeSubtitleVariantSelectionMap(current?.subtitleVariantSelection || {}); const nextMap = { ...currentMap }; const currentEntry = nextMap[normalizedLanguage] || { full: false, forced: false }; nextMap[normalizedLanguage] = { ...currentEntry, [normalizedVariant]: Boolean(checked) }; const existingOrder = normalizeSubtitleLanguageOrder(current?.subtitleLanguageOrder || []); const fallbackOrder = normalizeSubtitleLanguageOrder( defaultTrackSelectionForTitle(mediaInfoReview, normalizedTitleId)?.subtitleLanguageOrder || [] ); let nextOrder = existingOrder.length > 0 ? existingOrder : fallbackOrder; if (nextOrder.length === 0) { nextOrder = [normalizedLanguage]; } else if (!nextOrder.includes(normalizedLanguage) && fallbackOrder.includes(normalizedLanguage)) { nextOrder = fallbackOrder; } else if (!nextOrder.includes(normalizedLanguage)) { nextOrder = [...nextOrder, normalizedLanguage]; } return { ...prev, [normalizedTitleId]: { ...current, subtitleVariantSelection: nextMap, subtitleLanguageOrder: nextOrder } }; }); }} selectedMetadataEpisodes={selectedMetadataEpisodes} selectedEpisodeFillStart={selectedEpisodeFillStart} onEpisodeFillStartChange={(episodeRef) => { const normalizedRef = String(episodeRef || '').trim(); setSelectedEpisodeFillStart(normalizedRef || null); if (normalizedRef) { applyEpisodeFillFrom(normalizedRef); } }} episodeAssignmentsByTitle={episodeAssignmentsByTitle} allowEpisodeAssignments={isSeriesDvdReview} onEpisodeAssignmentChange={(titleId, patch = {}) => { const normalizedTitleId = normalizeTitleId(titleId); if (!normalizedTitleId || !patch || typeof patch !== 'object') { return; } const toNumberOrNull = (value) => { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); }; setEpisodeAssignmentsByTitle((prev) => { const currentMap = prev && typeof prev === 'object' ? prev : {}; const current = currentMap?.[normalizedTitleId] || currentMap?.[String(normalizedTitleId)] || {}; const nextTitle = String( patch.episodeTitle !== undefined ? patch.episodeTitle : current.episodeTitle || '' ).trim(); const next = { ...current, episodeId: patch.episodeId !== undefined ? toNumberOrNull(patch.episodeId) : toNumberOrNull(current.episodeId), episodeNumber: patch.episodeNumber !== undefined ? toNumberOrNull(patch.episodeNumber) : toNumberOrNull(current.episodeNumber), seasonNumber: patch.seasonNumber !== undefined ? toNumberOrNull(patch.seasonNumber) : toNumberOrNull(current.seasonNumber), episodeTitle: nextTitle || null }; if (next.episodeNumber !== null && next.episodeNumberStart == null) { next.episodeNumberStart = next.episodeNumber; } if ( next.episodeNumberStart !== null && next.episodeNumberEnd !== null && Number(next.episodeNumberEnd) < Number(next.episodeNumberStart) ) { next.episodeNumberEnd = next.episodeNumberStart; } if (next.episodeNumberStart !== null && next.episodeNumberEnd == null) { next.episodeNumberEnd = next.episodeNumberStart; } if (next.episodeNumberStart !== null) { const start = Number(next.episodeNumberStart); const end = Number(next.episodeNumberEnd ?? start); if (Number.isFinite(start) && start > 0 && Number.isFinite(end) && end > 0) { next.episodeSpan = Math.max(1, Math.trunc(end - start + 1)); next.episodeRange = buildEpisodeRangeToken(start, end); } } return { ...currentMap, [normalizedTitleId]: next }; }); }} availableScripts={scriptCatalog} availableChains={chainCatalog} preEncodeItems={preEncodeItems} postEncodeItems={postEncodeItems} userPresets={effectiveUserPresets} selectedUserPresetId={selectedUserPresetId} onUserPresetChange={showConverterConfig ? null : (presetId) => setSelectedUserPresetId(presetId)} selectedHandBrakePreset={selectedHandBrakePreset} allowEncodeItemSelection={state === 'READY_TO_ENCODE' && !queueLocked} onAddPreEncodeItem={(itemType) => { setPreEncodeItems((prev) => { const current = Array.isArray(prev) ? prev : []; if (itemType === 'chain') { const selectedSet = new Set( current .filter((item) => item?.type === 'chain') .map((item) => normalizeChainId(item?.id)) .filter((id) => id !== null) .map((id) => String(id)) ); const nextCandidate = (Array.isArray(chainCatalog) ? chainCatalog : []) .map((item) => normalizeChainId(item?.id)) .find((id) => id !== null && !selectedSet.has(String(id))); if (nextCandidate === undefined || nextCandidate === null) { return current; } return [...current, { type: 'chain', id: nextCandidate }]; } const selectedSet = new Set( current .filter((item) => item?.type === 'script') .map((item) => normalizeScriptId(item?.id)) .filter((id) => id !== null) .map((id) => String(id)) ); const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : []) .map((item) => normalizeScriptId(item?.id)) .find((id) => id !== null && !selectedSet.has(String(id))); if (nextCandidate === undefined || nextCandidate === null) { return current; } return [...current, { type: 'script', id: nextCandidate }]; }); }} onChangePreEncodeItem={(rowIndex, itemType, nextId) => { setPreEncodeItems((prev) => { const current = Array.isArray(prev) ? prev : []; const index = Number(rowIndex); if (!Number.isInteger(index) || index < 0 || index >= current.length) { return current; } const type = itemType === 'chain' ? 'chain' : 'script'; if (type === 'chain') { const normalizedId = normalizeChainId(nextId); if (normalizedId === null) { return current; } const duplicate = current.some((item, idx) => idx !== index && item?.type === 'chain' && String(normalizeChainId(item?.id)) === String(normalizedId) ); if (duplicate) { return current; } const next = [...current]; next[index] = { type: 'chain', id: normalizedId }; return next; } const normalizedId = normalizeScriptId(nextId); if (normalizedId === null) { return current; } const duplicate = current.some((item, idx) => idx !== index && item?.type === 'script' && String(normalizeScriptId(item?.id)) === String(normalizedId) ); if (duplicate) { return current; } const next = [...current]; next[index] = { type: 'script', id: normalizedId }; return next; }); }} onRemovePreEncodeItem={(rowIndex) => { setPreEncodeItems((prev) => { const current = Array.isArray(prev) ? prev : []; const index = Number(rowIndex); if (!Number.isInteger(index) || index < 0 || index >= current.length) { return current; } return current.filter((_, idx) => idx !== index); }); }} onReorderPreEncodeItem={(fromIndex, toIndex) => { setPreEncodeItems((prev) => { const current = Array.isArray(prev) ? prev : []; const from = Number(fromIndex); const to = Number(toIndex); if (!Number.isInteger(from) || !Number.isInteger(to)) { return current; } if (from < 0 || to < 0 || from >= current.length || to >= current.length || from === to) { return current; } const next = [...current]; const [moved] = next.splice(from, 1); next.splice(to, 0, moved); return next; }); }} onAddPostEncodeItem={(itemType) => { setPostEncodeItems((prev) => { const current = Array.isArray(prev) ? prev : []; if (itemType === 'chain') { const selectedSet = new Set( current .filter((item) => item?.type === 'chain') .map((item) => normalizeChainId(item?.id)) .filter((id) => id !== null) .map((id) => String(id)) ); const nextCandidate = (Array.isArray(chainCatalog) ? chainCatalog : []) .map((item) => normalizeChainId(item?.id)) .find((id) => id !== null && !selectedSet.has(String(id))); if (nextCandidate === undefined || nextCandidate === null) { return current; } return [...current, { type: 'chain', id: nextCandidate }]; } const selectedSet = new Set( current .filter((item) => item?.type === 'script') .map((item) => normalizeScriptId(item?.id)) .filter((id) => id !== null) .map((id) => String(id)) ); const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : []) .map((item) => normalizeScriptId(item?.id)) .find((id) => id !== null && !selectedSet.has(String(id))); if (nextCandidate === undefined || nextCandidate === null) { return current; } return [...current, { type: 'script', id: nextCandidate }]; }); }} onChangePostEncodeItem={(rowIndex, itemType, nextId) => { setPostEncodeItems((prev) => { const current = Array.isArray(prev) ? prev : []; const index = Number(rowIndex); if (!Number.isInteger(index) || index < 0 || index >= current.length) { return current; } const type = itemType === 'chain' ? 'chain' : 'script'; if (type === 'chain') { const normalizedId = normalizeChainId(nextId); if (normalizedId === null) { return current; } const duplicate = current.some((item, idx) => idx !== index && item?.type === 'chain' && String(normalizeChainId(item?.id)) === String(normalizedId) ); if (duplicate) { return current; } const next = [...current]; next[index] = { type: 'chain', id: normalizedId }; return next; } const normalizedId = normalizeScriptId(nextId); if (normalizedId === null) { return current; } const duplicate = current.some((item, idx) => idx !== index && item?.type === 'script' && String(normalizeScriptId(item?.id)) === String(normalizedId) ); if (duplicate) { return current; } const next = [...current]; next[index] = { type: 'script', id: normalizedId }; return next; }); }} onRemovePostEncodeItem={(rowIndex) => { setPostEncodeItems((prev) => { const current = Array.isArray(prev) ? prev : []; const index = Number(rowIndex); if (!Number.isInteger(index) || index < 0 || index >= current.length) { return current; } return current.filter((_, idx) => idx !== index); }); }} onReorderPostEncodeItem={(fromIndex, toIndex) => { setPostEncodeItems((prev) => { const current = Array.isArray(prev) ? prev : []; const from = Number(fromIndex); const to = Number(toIndex); if (!Number.isInteger(from) || !Number.isInteger(to)) { return current; } if (from < 0 || to < 0 || from >= current.length || to >= current.length || from === to) { return current; } const next = [...current]; const [moved] = next.splice(from, 1); next.splice(to, 0, moved); return next; }); }} />
) : null}
); }