import { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from 'primereact/button'; import { Dialog } from 'primereact/dialog'; import { Dropdown } from 'primereact/dropdown'; import { InputNumber } from 'primereact/inputnumber'; import { InputText } from 'primereact/inputtext'; import { ProgressBar } from 'primereact/progressbar'; import { Slider } from 'primereact/slider'; import { Tag } from 'primereact/tag'; import { Message } from 'primereact/message'; import { api } from '../api/client'; import MetadataSelectionDialog from './MetadataSelectionDialog'; import CdMetadataDialog from './CdMetadataDialog'; import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas'; import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; import { confirmModal } from '../utils/confirmModal'; const VIDEO_OUTPUT_FORMATS = [ { label: 'MKV', value: 'mkv' }, { label: 'MP4', value: 'mp4' }, { label: 'M4V', value: 'm4v' } ]; // ── Shared helpers (mirrored from CdRipConfigPanel) ─────────────────────────── function normalizeTrackText(value) { return String(value || '') .normalize('NFC') .replace(/[♥❤♡❥❣❦❧]/gu, ' ') .replace(/\p{C}+/gu, ' ') .replace(/\s+/g, ' ') .trim(); } function isFieldVisible(field, values) { if (!field.showWhen) return true; return values[field.showWhen.field] === field.showWhen.value; } function FormatField({ field, value, onChange }) { if (field.type === 'slider') { return (
{field.description ? {field.description} : null} onChange(field.key, e.value)} min={field.min} max={field.max} step={field.step || 1} />
); } if (field.type === 'select') { return (
{field.description ? {field.description} : null} onChange(field.key, e.value)} />
); } return null; } function normalizeScriptId(value) { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : null; } function normalizeChainId(value) { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : null; } function normalizeIdList(values, kind = 'script') { const list = Array.isArray(values) ? values : []; const seen = new Set(); const output = []; for (const value of list) { const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value); if (normalized === null) continue; const key = String(normalized); if (seen.has(key)) continue; seen.add(key); output.push(normalized); } return output; } function buildEncodeItemsFromConfig(config, phase) { const source = config && typeof config === 'object' ? config : {}; const prefix = phase === 'post' ? 'post' : 'pre'; const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : []; const fromExplicit = explicitItems .map((item) => { const type = String(item?.type || '').trim().toLowerCase(); if (type !== 'script' && type !== 'chain') return null; const id = type === 'chain' ? normalizeChainId(item?.id ?? item?.chainId) : normalizeScriptId(item?.id ?? item?.scriptId); if (!id) return null; return { type, id }; }) .filter(Boolean); if (fromExplicit.length > 0) return fromExplicit; const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script'); const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain'); return [ ...scriptIds.map((id) => ({ type: 'script', id })), ...chainIds.map((id) => ({ type: 'chain', id })) ]; } function normalizeTrackStageStatus(value) { const raw = String(value || '').trim().toLowerCase(); if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') return 'done'; if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') return 'in_progress'; if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') return 'error'; return 'pending'; } function trackStatusTagMeta(value) { const normalized = normalizeTrackStageStatus(value); if (normalized === 'done') return { label: 'Fertig', severity: 'success' }; if (normalized === 'in_progress') return { label: 'Läuft', severity: 'info' }; if (normalized === 'error') return { label: 'Fehler', severity: 'danger' }; return { label: 'Offen', severity: 'secondary' }; } // ── Helper: derive audio tracks from plan ───────────────────────────────────── function deriveAudioTracks(plan) { if (Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0) { return plan.inputPaths.map((p, i) => ({ position: i + 1, filePath: p, defaultTitle: p.split('/').pop().replace(/\.[^.]+$/, '') })); } const p = plan?.inputPath || ''; if (p && !plan?.isFolder) { return [{ position: 1, filePath: p, defaultTitle: p.split('/').pop().replace(/\.[^.]+$/, '') }]; } return []; } function normalizePositiveInt(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function buildVideoMetadataPayload(sourceMetadata, fallbackTitle = '') { const source = sourceMetadata && typeof sourceMetadata === 'object' ? sourceMetadata : {}; const normalized = { title: String(source?.title || fallbackTitle || '').trim() || null, year: Number.isFinite(Number(source?.year)) ? Math.trunc(Number(source.year)) : null, imdbId: String(source?.imdbId || '').trim() || null, poster: String(source?.poster || '').trim() || null }; const metadataProvider = String(source?.metadataProvider || '').trim() || null; const workflowKind = String(source?.workflowKind || '').trim() || null; const providerId = String(source?.providerId || '').trim() || null; const metadataKind = String(source?.metadataKind || '').trim() || null; const seasonName = String(source?.seasonName || '').trim() || null; const imdbRating = String(source?.imdbRating || '').trim() || null; const tmdbId = normalizePositiveInt(source?.tmdbId); const seasonNumber = normalizePositiveInt(source?.seasonNumber); const discNumber = normalizePositiveInt(source?.discNumber); const episodeCount = Number(source?.episodeCount || 0); const voteAverageRaw = Number(source?.voteAverage || 0); const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0 ? Number(voteAverageRaw.toFixed(1)) : null; const episodes = Array.isArray(source?.episodes) ? source.episodes : []; const tmdbDetails = source?.tmdbDetails && typeof source.tmdbDetails === 'object' && !Array.isArray(source.tmdbDetails) ? source.tmdbDetails : null; if (metadataProvider) normalized.metadataProvider = metadataProvider; if (workflowKind) normalized.workflowKind = workflowKind; if (providerId) normalized.providerId = providerId; if (metadataKind) normalized.metadataKind = metadataKind; if (tmdbId) normalized.tmdbId = tmdbId; if (seasonNumber) normalized.seasonNumber = seasonNumber; if (seasonName) normalized.seasonName = seasonName; if (discNumber) normalized.discNumber = discNumber; if (Number.isFinite(episodeCount) && episodeCount > 0) normalized.episodeCount = Math.trunc(episodeCount); if (episodes.length > 0) normalized.episodes = episodes; if (voteAverage !== null) normalized.voteAverage = voteAverage; if (imdbRating) normalized.imdbRating = imdbRating; if (tmdbDetails) normalized.tmdbDetails = tmdbDetails; return normalized; } // ── Status helpers ──────────────────────────────────────────────────────────── function mediaTypeBadge(type) { const map = { video: { label: 'Video', severity: 'info' }, audio: { label: 'Audio', severity: 'success' }, iso: { label: 'ISO', severity: 'warning' } }; const m = map[type] || { label: type || '?', severity: 'secondary' }; return ; } function isActiveStatus(status) { return ['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK'].includes(String(status || '').toUpperCase()); } function isTerminal(status) { return ['DONE', 'FINISHED', 'ERROR', 'CANCELLED'].includes(String(status || '').toUpperCase()); } // ── Inline-Konfiguration ────────────────────────────────────────────────────── function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsChanged }) { const converterMediaType = plan?.converterMediaType || 'video'; const isVideo = converterMediaType === 'video' || converterMediaType === 'iso'; const isAudio = converterMediaType === 'audio'; const planMetadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {}; const planTracks = Array.isArray(plan?.tracks) ? plan.tracks : []; const planMusicBrainz = plan?.musicBrainz && typeof plan.musicBrainz === 'object' ? plan.musicBrainz : {}; // ── Format state ──────────────────────────────────────────────────────────── const audioFormatValues = new Set(CD_FORMATS.map((e) => e.value)); const videoFormatValues = new Set(VIDEO_OUTPUT_FORMATS.map((e) => e.value)); const initialOutputFormat = String(plan?.outputFormat || (isAudio ? 'flac' : 'mkv')).trim().toLowerCase(); const [outputFormat, setOutputFormat] = useState( isAudio ? (audioFormatValues.has(initialOutputFormat) ? initialOutputFormat : 'flac') : (videoFormatValues.has(initialOutputFormat) ? initialOutputFormat : 'mkv') ); const [audioFormatOptions, setAudioFormatOptions] = useState(() => ({ ...getDefaultFormatOptions(initialOutputFormat), ...(plan?.audioFormatOptions && typeof plan.audioFormatOptions === 'object' ? plan.audioFormatOptions : {}) })); // Sync format options defaults when format changes useEffect(() => { if (!isAudio) return; setAudioFormatOptions((prev) => ({ ...getDefaultFormatOptions(outputFormat), ...prev })); }, [outputFormat, isAudio]); // ── Video presets ─────────────────────────────────────────────────────────── const [userPresets, setUserPresets] = useState([]); const [hbPresets, setHbPresets] = useState([]); const [selectedUserPreset, setSelectedUserPreset] = useState( Number.isFinite(Number(plan?.userPreset?.id)) ? Math.trunc(Number(plan.userPreset.id)) : null ); const [selectedHbPreset, setSelectedHbPreset] = useState( String(plan?.userPreset?.handbrakePreset || '').trim() ); // ── Audio tracks ──────────────────────────────────────────────────────────── const audioTrackInputPathsKey = Array.isArray(plan?.inputPaths) ? plan.inputPaths.join('|') : ''; const audioTracks = useMemo( () => deriveAudioTracks(plan), // eslint-disable-next-line react-hooks/exhaustive-deps [plan?.inputPath, plan?.isFolder, audioTrackInputPathsKey] ); // ── Audio metadata ────────────────────────────────────────────────────────── const [albumTitle, setAlbumTitle] = useState( String(planMetadata?.albumTitle || job.title || job.detected_title || '').trim() ); const [albumArtist, setAlbumArtist] = useState(String(planMetadata?.albumArtist || '').trim()); const [albumYear, setAlbumYear] = useState(() => { const y = Number(planMetadata?.albumYear); return Number.isFinite(y) ? Math.trunc(y) : null; }); const [coverUrl, setCoverUrl] = useState( String(planMetadata?.coverUrl || job?.poster_url || '').trim() || null ); const [trackFields, setTrackFields] = useState(() => { const map = {}; for (const { position, defaultTitle } of audioTracks) { const savedTrack = planTracks.find((t) => Number(t?.position) === Number(position)) || {}; map[position] = { title: String(savedTrack?.title || defaultTitle || '').trim(), artist: String(savedTrack?.artist || '').trim() }; } return map; }); // ── MusicBrainz ───────────────────────────────────────────────────────────── const [selectedMb, setSelectedMb] = useState( planMusicBrainz?.selected && typeof planMusicBrainz.selected === 'object' ? planMusicBrainz.selected : null ); const [mbQuery] = useState(String(planMusicBrainz?.query || '').trim()); const [mbResults] = useState(Array.isArray(planMusicBrainz?.results) ? planMusicBrainz.results : []); // ── Video metadata ────────────────────────────────────────────────────────── const [videoMetadata, setVideoMetadata] = useState(() => ({ ...buildVideoMetadataPayload( planMetadata, String(job.title || job.detected_title || '').trim() ) })); // ── Script / Chain catalog ────────────────────────────────────────────────── const [scriptCatalog, setScriptCatalog] = useState([]); const [chainCatalog, setChainCatalog] = useState([]); const [preItems, setPreItems] = useState(() => buildEncodeItemsFromConfig(plan, 'pre')); const [postItems, setPostItems] = useState(() => buildEncodeItemsFromConfig(plan, 'post')); useEffect(() => { if (!isAudio) return; let cancelled = false; const loadCatalog = async () => { try { const [scriptsRes, chainsRes] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]); if (cancelled) return; setScriptCatalog( scriptsRes.status === 'fulfilled' ? (Array.isArray(scriptsRes.value?.scripts) ? scriptsRes.value.scripts : []).map((s) => ({ id: s.id, name: s.name })) : [] ); setChainCatalog( chainsRes.status === 'fulfilled' ? (Array.isArray(chainsRes.value?.chains) ? chainsRes.value.chains : []).map((c) => ({ id: c.id, name: c.name })) : [] ); } catch { if (!cancelled) { setScriptCatalog([]); setChainCatalog([]); } } }; void loadCatalog(); return () => { cancelled = true; }; }, [isAudio]); // ── Misc state ────────────────────────────────────────────────────────────── const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false); const [searchDialogBusy, setSearchDialogBusy] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [draftSaving, setDraftSaving] = useState(false); const [draftError, setDraftError] = useState(null); const saveTimeoutRef = useRef(null); const initializedRef = useRef(false); const latestSavedRef = useRef(null); // ── Video presets loader ──────────────────────────────────────────────────── useEffect(() => { if (!isVideo) return; api.getUserPresets?.('video') .then((d) => setUserPresets(Array.isArray(d?.presets) ? d.presets : [])) .catch(() => setUserPresets([])); api.getHandBrakePresets?.() .then((d) => setHbPresets(Array.isArray(d?.presets) ? d.presets : [])) .catch(() => setHbPresets([])); }, [isVideo]); // ── Sync trackFields when audioTracks change ──────────────────────────────── useEffect(() => { if (!isAudio || audioTracks.length === 0) return; setTrackFields((prev) => { const next = {}; for (const { position, defaultTitle } of audioTracks) { const savedTrack = planTracks.find((t) => Number(t?.position) === Number(position)) || {}; const existing = prev[position] || null; next[position] = { title: String(existing?.title || savedTrack?.title || defaultTitle || '').trim(), artist: String(existing?.artist || savedTrack?.artist || '').trim() }; } const prevKeys = Object.keys(prev); const nextKeys = Object.keys(next); if (prevKeys.length === nextKeys.length) { const equal = nextKeys.every((key) => ( String(prev[key]?.title || '') === String(next[key]?.title || '') && String(prev[key]?.artist || '') === String(next[key]?.artist || '') )); if (equal) return prev; } return next; }); }, [isAudio, audioTracks, planTracks]); // ── Draft payload ─────────────────────────────────────────────────────────── const draftPayload = useMemo(() => { let userPreset = null; if (selectedUserPreset) { const preset = userPresets.find((p) => Number(p?.id) === Number(selectedUserPreset)); userPreset = preset ? { id: preset.id, handbrakePreset: preset.handbrake_preset || null, extraArgs: preset.extra_args || '' } : { id: selectedUserPreset }; } else if (selectedHbPreset) { userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' }; } const tracks = isAudio ? audioTracks.map(({ position, defaultTitle }) => { const f = trackFields[position] || {}; return { position, title: String(f.title || defaultTitle || '').trim() || defaultTitle || `Track ${position}`, artist: String(f.artist || '').trim() || null }; }) : null; const metadata = isAudio ? { albumTitle: String(albumTitle || '').trim() || job.detected_title || '', albumArtist: String(albumArtist || '').trim() || null, albumYear: Number.isFinite(Number(albumYear)) ? Math.trunc(Number(albumYear)) : null, mbId: selectedMb?.mbId || null, coverUrl: coverUrl || null } : { ...buildVideoMetadataPayload( videoMetadata, String(job.title || job.detected_title || '').trim() ) }; return { converterMediaType, outputFormat, userPreset, audioFormatOptions: isAudio ? audioFormatOptions : null, metadata, tracks, musicBrainz: isAudio ? { query: mbQuery, selected: selectedMb || null, results: Array.isArray(mbResults) ? mbResults.slice(0, 50) : [] } : null, preEncodeItems: isAudio ? preItems : null, postEncodeItems: isAudio ? postItems : null }; }, [ selectedUserPreset, selectedHbPreset, userPresets, isAudio, audioTracks, trackFields, albumTitle, albumArtist, albumYear, coverUrl, selectedMb, converterMediaType, outputFormat, audioFormatOptions, mbQuery, mbResults, job.detected_title, job.title, videoMetadata, preItems, postItems ]); const draftSerialized = useMemo(() => JSON.stringify(draftPayload), [draftPayload]); useEffect(() => { if (!initializedRef.current) { initializedRef.current = true; latestSavedRef.current = draftSerialized; return; } if (busy || draftSerialized === latestSavedRef.current) return; if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(async () => { setDraftSaving(true); setDraftError(null); try { await api.converterUpdateJobConfig(job.id, draftPayload); latestSavedRef.current = draftSerialized; } catch (err) { setDraftError(err?.message || 'Draft konnte nicht gespeichert werden.'); } finally { setDraftSaving(false); } }, 650); return () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; } }; }, [busy, draftPayload, draftSerialized, job.id]); // ── Dialog contexts ───────────────────────────────────────────────────────── const metadataDialogContext = useMemo(() => ({ jobId: job.id, detectedTitle: videoMetadata?.title || job.title || job.detected_title || '', selectedMetadata: { ...videoMetadata, title: videoMetadata?.title || job.title || job.detected_title || '', year: videoMetadata?.year || null, imdbId: videoMetadata?.imdbId || null, poster: videoMetadata?.poster || null }, metadataCandidates: [] }), [job.id, job.title, job.detected_title, videoMetadata]); const cdMetadataDialogContext = useMemo(() => ({ jobId: job.id, tracks: audioTracks.map(({ position, defaultTitle }) => ({ position, title: String(trackFields[position]?.title || defaultTitle || '').trim() || `Track ${position}`, artist: String(trackFields[position]?.artist || '').trim() || null })) }), [job.id, audioTracks, trackFields]); // ── Dialog handlers ───────────────────────────────────────────────────────── const handleMetadataSearch = async (query) => { try { const response = await api.searchTmdbMovie(query); return Array.isArray(response?.results) ? response.results : []; } catch { return []; } }; const handleMetadataSubmit = async (payload) => { setSearchDialogBusy(true); try { setVideoMetadata(buildVideoMetadataPayload(payload, String(job.title || job.detected_title || '').trim())); setMetadataDialogVisible(false); } finally { setSearchDialogBusy(false); } }; const handleMusicBrainzSearchDialog = async (query, options = {}) => { try { const response = await api.searchMusicBrainz(query, { trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null }); return Array.isArray(response?.results) ? response.results : []; } catch { return []; } }; const handleMusicBrainzReleaseFetch = async (mbId) => { try { const response = await api.getMusicBrainzRelease(mbId); return response?.release || null; } catch { return null; } }; const handleCdMetadataSubmit = async (payload) => { setSearchDialogBusy(true); try { const title = String(payload?.title || '').trim(); const artist = String(payload?.artist || '').trim(); const year = Number(payload?.year); const tracks = Array.isArray(payload?.tracks) ? payload.tracks : []; if (title) setAlbumTitle(title); setAlbumArtist(artist); setAlbumYear(Number.isFinite(year) ? Math.trunc(year) : null); setSelectedMb(payload?.mbId ? { mbId: payload.mbId, title: title || null, artist: artist || null, year: Number.isFinite(year) ? Math.trunc(year) : null } : null); setCoverUrl(String(payload?.coverUrl || '').trim() || null); if (tracks.length > 0) { setTrackFields((prev) => { const next = { ...prev }; tracks.forEach((track, index) => { const position = Number.isFinite(Number(track?.position)) && Number(track.position) > 0 ? Math.trunc(Number(track.position)) : (index + 1); next[position] = { title: String(track?.title || next[position]?.title || `Track ${position}`).trim(), artist: String(track?.artist || next[position]?.artist || '').trim() }; }); return next; }); } setCdMetadataDialogVisible(false); } finally { setSearchDialogBusy(false); } }; // ── Track field handlers ──────────────────────────────────────────────────── const handleTrackField = (position, key, value) => { setTrackFields((prev) => ({ ...prev, [position]: { ...(prev[position] || {}), [key]: value } })); }; const handleDeleteTrack = async (filePath) => { try { await api.converterRemoveInputFromJob(job.id, filePath); onInputsChanged?.(job.id); } catch (err) { // silently ignore — UI will refresh via onInputsChanged } }; // ── Encode item handlers ──────────────────────────────────────────────────── const moveItem = (phase, index, direction) => { const updater = phase === 'post' ? setPostItems : setPreItems; updater((prev) => { const list = Array.isArray(prev) ? [...prev] : []; const from = Number(index); const to = from + (direction === 'up' ? -1 : 1); if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) return list; const [moved] = list.splice(from, 1); list.splice(to, 0, moved); return list; }); }; const addItem = (phase, type) => { const normalizedType = type === 'chain' ? 'chain' : 'script'; const updater = phase === 'post' ? setPostItems : setPreItems; updater((prev) => { const current = Array.isArray(prev) ? prev : []; const selectedIds = new Set( current .filter((item) => item?.type === normalizedType) .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) .filter((id) => id !== null) .map((id) => String(id)) ); const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog; const candidate = (Array.isArray(catalog) ? catalog : []) .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) .find((id) => id !== null && !selectedIds.has(String(id))); if (candidate === undefined || candidate === null) return current; return [...current, { type: normalizedType, id: candidate }]; }); }; const changeItem = (phase, index, type, nextId) => { const normalizedType = type === 'chain' ? 'chain' : 'script'; const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId); if (normalizedId === null) return; const updater = phase === 'post' ? setPostItems : setPreItems; updater((prev) => { const current = Array.isArray(prev) ? prev : []; const rowIndex = Number(index); if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) return current; const duplicate = current.some((item, itemIndex) => { if (itemIndex === rowIndex) return false; if (item?.type !== normalizedType) return false; const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id); return existingId !== null && String(existingId) === String(normalizedId); }); if (duplicate) return current; const next = [...current]; next[rowIndex] = { type: normalizedType, id: normalizedId }; return next; }); }; const removeItem = (phase, index) => { const updater = phase === 'post' ? setPostItems : setPreItems; updater((prev) => { const current = Array.isArray(prev) ? prev : []; const rowIndex = Number(index); if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) return current; return current.filter((_, itemIndex) => itemIndex !== rowIndex); }); }; // ── Start handler ─────────────────────────────────────────────────────────── const handleStart = async () => { setBusy(true); setError(null); try { let userPreset = null; if (selectedUserPreset) { const found = userPresets.find((p) => p.id === selectedUserPreset); if (found) userPreset = { id: found.id, handbrakePreset: found.handbrake_preset || null, extraArgs: found.extra_args || '' }; } else if (selectedHbPreset) { userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' }; } const tracks = isAudio ? audioTracks.map(({ position, defaultTitle }) => { const f = trackFields[position] || {}; return { position, title: (f.title || '').trim() || defaultTitle, artist: (f.artist || '').trim() || albumArtist.trim() || null }; }) : undefined; const metadata = isAudio ? { albumTitle: albumTitle.trim() || job.detected_title || '', albumArtist: albumArtist.trim() || null, albumYear: albumYear || null, mbId: selectedMb?.mbId || null, coverUrl: coverUrl || null } : { ...buildVideoMetadataPayload( videoMetadata, String(job.title || job.detected_title || '').trim() ) }; await api.startConverterJob(job.id, { converterMediaType, outputFormat, userPreset, audioFormatOptions: isAudio ? audioFormatOptions : null, metadata, tracks, preEncodeItems: isAudio ? preItems : null, postEncodeItems: isAudio ? postItems : null }); onStarted?.(job.id); } catch (err) { setError(err.message || 'Fehler beim Starten.'); setBusy(false); } }; // ── Format fields (audio) ─────────────────────────────────────────────────── const audioSchema = CD_FORMAT_SCHEMAS[outputFormat] || { fields: [] }; const visibleAudioFields = audioSchema.fields.filter((f) => isFieldVisible(f, audioFormatOptions)); // ── Encode item section renderer ──────────────────────────────────────────── const renderEncodeSection = (phase, items) => { const label = phase === 'post' ? 'Post-Rip Ausführungen (optional)' : 'Pre-Rip Ausführungen (optional)'; const hint = phase === 'post' ? 'Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.' : 'Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.'; return (

{label}

{scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. ) : null} {items.length === 0 ? ( Keine {phase === 'post' ? 'Post' : 'Pre'}-Rip Ausführungen ausgewählt. ) : null} {items.map((item, rowIndex) => { const isScript = item?.type === 'script'; const usedScriptIds = new Set( items.filter((e, i) => e?.type === 'script' && i !== rowIndex) .map((e) => normalizeScriptId(e?.id)).filter((id) => id !== null).map((id) => String(id)) ); const usedChainIds = new Set( items.filter((e, i) => e?.type === 'chain' && i !== rowIndex) .map((e) => normalizeChainId(e?.id)).filter((id) => id !== null).map((id) => String(id)) ); const scriptOptions = scriptCatalog.map((e) => ({ label: e?.name || `Skript #${e?.id}`, value: normalizeScriptId(e?.id), disabled: usedScriptIds.has(String(normalizeScriptId(e?.id))) })).filter((e) => e.value !== null); const chainOptions = chainCatalog.map((e) => ({ label: e?.name || `Kette #${e?.id}`, value: normalizeChainId(e?.id), disabled: usedChainIds.has(String(normalizeChainId(e?.id))) })).filter((e) => e.value !== null); return (
{isScript ? ( changeItem(phase, rowIndex, 'script', e.value)} className="full-width" disabled={busy} /> ) : ( changeItem(phase, rowIndex, 'chain', e.value)} className="full-width" disabled={busy} /> )}
); })}
{scriptCatalog.length > items.filter((e) => e?.type === 'script').length ? (
{hint}
); }; // ── Render ────────────────────────────────────────────────────────────────── if (isAudio) { return (
{error && }

Converter Konfiguration

{/* Album-Metadaten */}
Album-Metadaten {coverUrl && (
{albumTitle
)}
setAlbumTitle(e.target.value)} placeholder="Album" disabled={busy} /> setAlbumArtist(e.target.value)} placeholder="Interpret" disabled={busy} /> setAlbumYear(e.value || null)} placeholder="Jahr" useGrouping={false} min={1900} max={2100} disabled={busy} />
{/* Ausgabeformat */}
setOutputFormat(e.value)} disabled={busy} />
{/* Format-spezifische Felder */} {visibleAudioFields.map((field) => ( setAudioFormatOptions((prev) => ({ ...prev, [key]: value }))} /> ))} {/* Track-Auswahl */} {audioTracks.length > 0 && (
Tracks ({audioTracks.length})
{audioTracks.map(({ position, filePath, defaultTitle }) => { const f = trackFields[position] || {}; return ( ); })}
Nr Interpret Titel Länge
{String(position).padStart(2, '0')} handleTrackField(position, 'artist', e.target.value)} placeholder={normalizeTrackText(albumArtist) || 'Interpret'} disabled={busy} /> handleTrackField(position, 'title', e.target.value)} placeholder={defaultTitle} disabled={busy} /> -
)} {plan?.isFolder && audioTracks.length === 0 && (

Track-Liste wird beim Start aus dem Ordner gelesen.

)} {/* Pre / Post Ausführungen */}
{renderEncodeSection('pre', preItems)} {renderEncodeSection('post', postItems)}
{/* Aktionen */}
{draftError && ( {draftError} )} {!draftError && ( {draftSaving ? 'Speichere Entwurf …' : 'Entwurf gespeichert'} )} setCdMetadataDialogVisible(false)} onSubmit={handleCdMetadataSubmit} onSearch={handleMusicBrainzSearchDialog} onFetchRelease={handleMusicBrainzReleaseFetch} busy={searchDialogBusy} />
); } // ── Video render (unchanged layout) ───────────────────────────────────────── return (
{error && } {isVideo && (
)}
{mediaTypeBadge(converterMediaType)} {converterMediaType === 'iso' && ( MakeMKV → HandBrake )}
setOutputFormat(e.value)} style={{ width: '100%' }} />
{isVideo && (
({ label: p.name, value: p.id })) ]} onChange={(e) => { setSelectedUserPreset(e.value); if (e.value) setSelectedHbPreset(''); }} style={{ width: '100%' }} />
{!selectedUserPreset && (
({ label: p.category ? `[${p.category}] ${p.name}` : p.name, value: p.name })) ]} onChange={(e) => setSelectedHbPreset(e.value)} filter placeholder="Preset auswählen …" style={{ width: '100%' }} />
)}
)}
{draftError ? ( {draftError} ) : ( {draftSaving ? 'Speichere Entwurf …' : 'Entwurf gespeichert'} )}
setMetadataDialogVisible(false)} onSubmit={handleMetadataSubmit} onSearch={handleMetadataSearch} busy={searchDialogBusy} />
); } // ── Job-Karte ───────────────────────────────────────────────────────────────── export default function ConverterJobCard({ job, jobProgress, isExpanded, onExpand, onCollapse, onStarted, onDeleted, onCancelled, onOpenDetails, onInputsChanged }) { const [cancelBusy, setCancelBusy] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false); const [deleteConfirmVisible, setDeleteConfirmVisible] = useState(false); const plan = (() => { try { return JSON.parse(job.encode_plan_json || '{}'); } catch { return {}; } })(); const jobIdLabel = Number.isFinite(Number(job?.id)) ? `Job #${Math.trunc(Number(job.id))}` : 'Job'; const ripperTitleId = Number.isFinite(Number(job?.id)) ? `#${Math.trunc(Number(job.id))}` : '#-'; const customTitle = String(job?.title || '').trim(); const title = customTitle ? `${ripperTitleId} | ${customTitle}` : ripperTitleId; const status = job.status || 'UNKNOWN'; const statusLabel = getStatusLabel(status); const statusSeverity = getStatusSeverity(status); const converterMediaType = plan?.converterMediaType; const isAudio = converterMediaType === 'audio'; const active = isActiveStatus(status); const terminal = isTerminal(status); const isReady = String(status).toUpperCase() === 'READY_TO_START'; const liveProgress = jobProgress?.progress ?? null; const liveEta = jobProgress?.eta || null; const liveStatusText = jobProgress?.statusText || null; const progressValue = liveProgress !== null ? Math.trunc(liveProgress) : null; // ── Audio details (for active/terminal state) ───────────────────────────── const audioTracks = isAudio ? deriveAudioTracks(plan) : []; const planTracks = Array.isArray(plan?.tracks) ? plan.tracks : []; const planMetadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {}; const coverUrl = String(planMetadata?.coverUrl || plan?.musicBrainz?.selected?.coverUrl || job?.poster_url || '').trim() || null; const albumTitle = String(planMetadata?.albumTitle || job.title || job.detected_title || '').trim() || '-'; const albumArtist = String(planMetadata?.albumArtist || '').trim() || '-'; const albumYear = planMetadata?.albumYear || '-'; const mbId = String(planMetadata?.mbId || '').trim() || '-'; const outputFormatLabel = (CD_FORMATS.find((f) => f.value === plan?.outputFormat)?.label) || (plan?.outputFormat ? String(plan.outputFormat).toUpperCase() : '-'); const outputPath = String(job.output_path || '').trim() || '-'; // Derive pre/post script names for summary const preItems = buildEncodeItemsFromConfig(plan, 'pre'); const postItems = buildEncodeItemsFromConfig(plan, 'post'); const preNames = [ ...(Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts.map((s) => String(s?.name || '').trim()).filter(Boolean) : []), ...preItems.filter((i) => i.type === 'script').map((i) => `Skript #${i.id}`) ]; const postNames = [ ...(Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts.map((s) => String(s?.name || '').trim()).filter(Boolean) : []), ...postItems.filter((i) => i.type === 'script').map((i) => `Skript #${i.id}`) ]; const preChainNames = [ ...(Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains.map((c) => String(c?.name || '').trim()).filter(Boolean) : []), ...preItems.filter((i) => i.type === 'chain').map((i) => `Kette #${i.id}`) ]; const postChainNames = [ ...(Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains.map((c) => String(c?.name || '').trim()).filter(Boolean) : []), ...postItems.filter((i) => i.type === 'chain').map((i) => `Kette #${i.id}`) ]; const isFinishedOk = terminal && !['ERROR', 'CANCELLED'].includes(String(status).toUpperCase()); const completedEncodeCount = (() => { if (isFinishedOk) return audioTracks.length; if (!active || audioTracks.length === 0) return 0; // "Audio: N/M — filename" is emitted by the backend at the START of track N. // N-1 tracks are done, track N is in progress. const statusMatch = liveStatusText?.match(/^Audio:\s*(\d+)\s*\//); if (statusMatch) { const pos = parseInt(statusMatch[1], 10); return Math.max(0, Math.min(audioTracks.length, pos - 1)); } // Fallback: use progress percentage (Math.round avoids floor precision loss) if (progressValue !== null) { return Math.min(audioTracks.length, Math.round((progressValue / 100) * audioTracks.length)); } return 0; })(); // ── Action handlers ─────────────────────────────────────────────────────── const handleCancel = async () => { const statusUpper = String(status || '').toUpperCase(); const confirmText = statusUpper === 'READY_TO_START' ? `${jobIdLabel} wirklich abbrechen?\nDer Job wird auf "Abgebrochen" gesetzt.` : `${jobIdLabel} wirklich abbrechen?`; const confirmed = await confirmModal({ header: 'Job abbrechen', message: confirmText, acceptLabel: 'Abbrechen', rejectLabel: 'Zurück', danger: true }); if (!confirmed) return; setCancelBusy(true); try { await api.cancelConverterJob(job.id); onCancelled?.(job.id); } catch (err) { console.error('Cancel error:', err); } finally { setCancelBusy(false); } }; const handleDelete = () => setDeleteConfirmVisible(true); const handleDeleteConfirmed = async () => { setDeleteConfirmVisible(false); setDeleteBusy(true); try { await api.deleteConverterJob(job.id); onDeleted?.(job.id); } catch (err) { console.error('Delete error:', err); window.alert(`Fehler beim Löschen:\n${err?.message || 'Unbekannter Fehler'}`); } finally { setDeleteBusy(false); } }; // ── Eingeklappt ─────────────────────────────────────────────────────────── if (!isExpanded) { const fileCount = Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0 ? plan.inputPaths.length : null; return (