import { useEffect, useMemo, useState } from 'react'; import { Card } from 'primereact/card'; import { Tag } from 'primereact/tag'; import { ProgressBar } from 'primereact/progressbar'; import { Button } from 'primereact/button'; import MediaInfoReviewPanel from './MediaInfoReviewPanel'; import { api } from '../api/client'; import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; function normalizeTitleId(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 (['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 buildDefaultTrackSelection(review) { const titles = Array.isArray(review?.titles) ? review.titles : []; const selection = {}; const reviewEncodeInputTitleId = normalizeTitleId(review?.encodeInputTitleId); 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)); selection[titleId] = { audioTrackIds: normalizeTrackIdList( audioSelectionSource.map((track) => track?.id) ), subtitleTrackIds: normalizeTrackIdList( subtitleSelectionSource .filter((track) => !isBurnedSubtitleTrack(track)) .map((track) => track?.id) ) }; } return selection; } function defaultTrackSelectionForTitle(review, titleId) { const defaults = buildDefaultTrackSelection(review); return defaults[titleId] || defaults[String(titleId)] || { audioTrackIds: [], subtitleTrackIds: [] }; } 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 sanitizeFileName(input) { return String(input || 'untitled') .replace(/[\\/:*?"<>|]/g, '_') .replace(/\s+/g, ' ') .trim() .slice(0, 180); } function renderTemplate(template, values) { return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => { const value = values[key.trim()]; if (value === undefined || value === null || value === '') { return 'unknown'; } return String(value); }); } 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'] : ['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 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 }); 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}`; } export default function PipelineStatusCard({ pipeline, onAnalyze, onReanalyze, onOpenMetadata, onReassignOmdb, onStart, onRemoveFromQueue, onRestartEncode, onRestartReview, onConfirmReview, onSelectPlaylist, onCancel, onRetry, isQueued = false, busy, liveJobLog = '' }) { 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 retryJobId = pipeline?.context?.jobId; const queueLocked = Boolean(isQueued && retryJobId); 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 [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null); const [selectedPlaylistId, setSelectedPlaylistId] = useState(null); const [trackSelectionByTitle, setTrackSelectionByTitle] = 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); 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() ]); if (!cancelled) { const categories = settingsResponse.status === 'fulfilled' ? (settingsResponse.value?.categories || []) : []; setSettingsMap(buildSettingsMap(categories)); const presetOptions = presetsResponse.status === 'fulfilled' ? (presetsResponse.value?.options || []) : []; setPresetDisplayMap(buildPresetDisplayMap(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([]); } } }; loadSettings(); return () => { cancelled = true; }; }, []); useEffect(() => { const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId); setSelectedEncodeTitleId(fromReview); setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview)); 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 userPresetId = Number(mediaInfoReview?.userPreset?.id); setSelectedUserPresetId(Number.isFinite(userPresetId) && userPresetId > 0 ? Math.trunc(userPresetId) : null); }, [ mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, mediaInfoReview?.reviewConfirmedAt, mediaInfoReview?.prefilledFromPreviousRunAt, mediaInfoReview?.userPreset?.id, retryJobId ]); useEffect(() => { const currentTitleId = normalizeTitleId(selectedEncodeTitleId); if (!currentTitleId) { return; } setTrackSelectionByTitle((prev) => { if (prev?.[currentTitleId] || prev?.[String(currentTitleId)]) { return prev; } const defaults = buildDefaultTrackSelection(mediaInfoReview); const fallback = defaults[currentTitleId] || { audioTrackIds: [], subtitleTrackIds: [] }; return { ...prev, [currentTitleId]: fallback }; }); }, [selectedEncodeTitleId, mediaInfoReview?.generatedAt]); const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired); const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId)); const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle; // 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; } return presetMediaType === 'all' || presetMediaType === jobMediaProfile; }); const canStartReadyJob = isPreRipReview ? Boolean(retryJobId) : Boolean(retryJobId && encodeInputPath); const canRestartEncodeFromLastSettings = Boolean( (state === 'ERROR' || state === 'CANCELLED') && retryJobId && pipeline?.context?.canRestartEncodeFromLastSettings ); const canRestartReviewFromRaw = Boolean( retryJobId && !running && (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath) ); 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]); 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 playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION'; const commandOutputPath = useMemo( () => buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId), [settingsMap, jobMediaProfile, selectedMetadata, retryJobId] ); const presetDisplayValue = useMemo(() => { const preset = String(mediaInfoReview?.selectors?.preset || '').trim(); if (!preset) { return ''; } return presetDisplayMap[preset] || preset; }, [mediaInfoReview?.selectors?.preset, presetDisplayMap]); const buildSelectedTrackSelectionForCurrentTitle = () => { const encodeTitleId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(mediaInfoReview?.encodeInputTitleId) || normalizeTitleId( (Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []).find((t) => t?.selectedForEncode)?.id ); const selectionEntry = encodeTitleId ? (trackSelectionByTitle?.[encodeTitleId] || trackSelectionByTitle?.[String(encodeTitleId)] || null) : null; const fallbackSelection = encodeTitleId ? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId) : { audioTrackIds: [], subtitleTrackIds: [] }; const effectiveSelection = selectionEntry || fallbackSelection; const encodeTitle = encodeTitleId ? (Array.isArray(mediaInfoReview?.titles) ? (mediaInfoReview.titles.find((title) => normalizeTitleId(title?.id) === encodeTitleId) || null) : null) : null; const blockedSubtitleTrackIds = new Set( (Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : []) .filter((track) => isBurnedSubtitleTrack(track)) .map((track) => normalizeTrackId(track?.id)) .filter((id) => id !== null) .map((id) => String(id)) ); const selectedTrackSelection = encodeTitleId ? { [encodeTitleId]: { audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []), subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || []) .filter((id) => !blockedSubtitleTrackIds.has(String(id))) } } : 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, selectedTrackSelection, selectedPostScriptIds, selectedPreScriptIds, selectedPostChainIds, selectedPreChainIds }; }; return (
{pipeline?.statusText || 'Bereit'}
{running && (
{pipeline?.eta ? `ETA ${pipeline.eta}` : 'ETA unbekannt'}
)} {state === 'FINISHED' && (
)}
{queueLocked ? (
{running ? (

Aktueller Job-Log

{liveJobLog || 'Noch keine Log-Ausgabe vorhanden.'}
) : 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} {selectedMetadata ? (
{selectedMetadata.poster ? ( {selectedMetadata.title ) : (
Kein Poster
)}
Titel: {selectedMetadata.title || '-'}
Jahr: {selectedMetadata.year || '-'}
IMDb: {selectedMetadata.imdbId || '-'}
Status: {stateLabel}
) : null} {(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) ? (

Titel-/Spurprüfung

{state === 'READY_TO_ENCODE' && !queueLocked ? ( {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} setSelectedEncodeTitleId(normalizeTitleId(titleId))} 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: [] }; 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 } }; }); }} availableScripts={scriptCatalog} availableChains={chainCatalog} preEncodeItems={preEncodeItems} postEncodeItems={postEncodeItems} userPresets={filteredUserPresets} selectedUserPresetId={selectedUserPresetId} onUserPresetChange={(presetId) => setSelectedUserPresetId(presetId)} 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}
); }