import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from 'primereact/button'; import { Card } from 'primereact/card'; import { Toast } from 'primereact/toast'; import { Badge } from 'primereact/badge'; import { Tag } from 'primereact/tag'; import { ProgressBar } from 'primereact/progressbar'; import { Divider } from 'primereact/divider'; import { Dialog } from 'primereact/dialog'; import { Dropdown } from 'primereact/dropdown'; import { RadioButton } from 'primereact/radiobutton'; import { api } from '../api/client'; import { useWebSocket } from '../hooks/useWebSocket'; import ConverterFileExplorer from '../components/ConverterFileExplorer'; import ConverterUploadPanel from '../components/ConverterUploadPanel'; import ConverterJobCard from '../components/ConverterJobCard'; import JobDetailDialog from '../components/JobDetailDialog'; import PipelineStatusCard from '../components/PipelineStatusCard'; import MetadataSelectionDialog from '../components/MetadataSelectionDialog'; import otherIndicatorIcon from '../assets/media-other.svg'; import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation'; import { confirmModal } from '../utils/confirmModal'; const AUDIO_EXTS = new Set(['.flac', '.mp3', '.wav', '.m4a', '.ogg', '.opus']); const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.m2ts', '.iso', '.avi', '.mov']); const TERMINAL_JOB_STATUSES = new Set(['DONE', 'FINISHED', 'ERROR', 'CANCELLED']); const DEFAULT_CONVERTER_SCAN_EXTENSIONS = [ 'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov', 'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus' ]; const VIDEO_OUTPUT_FORMATS = [ { label: 'MKV', value: 'mkv' }, { label: 'MP4', value: 'mp4' }, { label: 'M4V', value: 'm4v' } ]; function parseConverterScanExtensions(value) { const seen = new Set(); const parsed = String(value || '') .split(',') .map((item) => String(item || '').trim().toLowerCase()) .filter((item) => { if (!item || !DEFAULT_CONVERTER_SCAN_EXTENSIONS.includes(item) || seen.has(item)) { return false; } seen.add(item); return true; }); if (parsed.length === 0) { return [...DEFAULT_CONVERTER_SCAN_EXTENSIONS]; } return parsed; } function isAudioEntry(e) { if (e.detectedMediaType === 'audio') return true; const p = (e.relPath || '').toLowerCase(); const dot = p.lastIndexOf('.'); if (dot === -1) return false; return AUDIO_EXTS.has(p.slice(dot)); } function isVideoEntry(e) { if (e.detectedMediaType === 'video' || e.detectedMediaType === 'iso') return true; const p = (e.relPath || '').toLowerCase(); const dot = p.lastIndexOf('.'); if (dot === -1) return false; return VIDEO_EXTS.has(p.slice(dot)); } function normalizeJobId(value) { const n = Number(value); return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null; } function parseConverterPlan(job) { try { return JSON.parse(job?.encode_plan_json || '{}'); } catch (_err) { return {}; } } function buildConverterPipeline(job, plan, progress = null) { const metadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {}; const review = plan && typeof plan === 'object' ? plan : {}; const selectedMetadata = { title: String(job?.title || metadata?.title || job?.detected_title || '').trim() || null, year: Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : (Number.isFinite(Number(metadata?.year)) ? Math.trunc(Number(metadata.year)) : null), imdbId: String(job?.imdb_id || metadata?.imdbId || '').trim() || null, poster: String(job?.poster_url || metadata?.poster || '').trim() || null }; return { state: String(progress?.state || job?.status || '').trim().toUpperCase() || 'UNKNOWN', activeJobId: Number(job?.id) || null, progress: Number.isFinite(Number(progress?.progress)) ? Number(progress.progress) : 0, eta: progress?.eta || null, statusText: progress?.statusText || null, context: { jobId: Number(job?.id) || null, mode: 'converter', mediaProfile: 'converter', selectedMetadata, mediaInfoReview: review, playlistAnalysis: review?.playlistAnalysis || null, playlistCandidates: Array.isArray(review?.playlistCandidates) ? review.playlistCandidates : [], handBrakeTitleDecisionRequired: Boolean(review?.handBrakeTitleDecisionRequired), handBrakeTitleCandidates: Array.isArray(review?.handBrakeTitleCandidates) ? review.handBrakeTitleCandidates : [] } }; } export default function ConverterPage() { const toastRef = useRef(null); const [jobs, setJobs] = useState([]); const [queuedJobIds, setQueuedJobIds] = useState([]); const [jobProgress, setJobProgress] = useState({}); const [loadingJobs, setLoadingJobs] = useState(false); const [explorerRefreshToken, setExplorerRefreshToken] = useState(0); const [explorerNavigateTo, setExplorerNavigateTo] = useState(null); const [selectedEntries, setSelectedEntries] = useState([]); const [selectionResetToken, setSelectionResetToken] = useState(0); const [jobModeVisible, setJobModeVisible] = useState(false); const [audioMode, setAudioMode] = useState('individual'); const [jobModalAction, setJobModalAction] = useState('create'); const [assignTargetJobId, setAssignTargetJobId] = useState(null); const [creatingJobs, setCreatingJobs] = useState(false); const [jobEntries, setJobEntries] = useState([]); const [detailVisible, setDetailVisible] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [selectedJob, setSelectedJob] = useState(null); const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); const [metadataDialogContext, setMetadataDialogContext] = useState(null); const [metadataDialogBusy, setMetadataDialogBusy] = useState(false); const [actionBusyJobIds, setActionBusyJobIds] = useState(new Set()); const [logLoadingMode, setLogLoadingMode] = useState(null); const [deleteEntryBusy, setDeleteEntryBusy] = useState(false); const [cancelDetailBusy, setCancelDetailBusy] = useState(false); const [expandedJobId, setExpandedJobId] = useState(undefined); const [videoUserPresets, setVideoUserPresets] = useState([]); const [videoHbPresets, setVideoHbPresets] = useState([]); const [uploadExtensions, setUploadExtensions] = useState(() => [...DEFAULT_CONVERTER_SCAN_EXTENSIONS]); // Entries werden beim Öffnen des Modals gesichert, damit ein außerhalb-Click // die Selection nicht löscht bevor die Jobs erstellt werden const jobEntriesRef = useRef([]); const previousJobStatusesRef = useRef(new Map()); const loadUploadExtensions = useCallback(async () => { try { const response = await api.getSettings({ forceRefresh: true }); const allSettings = (response?.categories || []).flatMap((category) => category?.settings || []); const setting = allSettings.find((item) => String(item?.key || '').trim() === 'converter_scan_extensions'); const value = setting?.value ?? setting?.default_value ?? ''; setUploadExtensions(parseConverterScanExtensions(value)); } catch (_error) { setUploadExtensions([...DEFAULT_CONVERTER_SCAN_EXTENSIONS]); } }, []); useEffect(() => { let cancelled = false; const loadPresets = async () => { try { const [userResponse, hbResponse] = await Promise.allSettled([ api.getUserPresets('video'), api.getHandBrakePresets() ]); if (cancelled) return; const userPresets = userResponse.status === 'fulfilled' ? (Array.isArray(userResponse.value?.presets) ? userResponse.value.presets : []) : []; const hbPresets = hbResponse.status === 'fulfilled' ? (Array.isArray(hbResponse.value?.presets) ? hbResponse.value.presets : []) : []; setVideoUserPresets(userPresets); setVideoHbPresets(hbPresets); } catch (_error) { if (!cancelled) { setVideoUserPresets([]); setVideoHbPresets([]); } } }; loadPresets(); return () => { cancelled = true; }; }, []); useEffect(() => { void loadUploadExtensions(); }, [loadUploadExtensions]); const loadJobs = useCallback(async () => { setLoadingJobs(true); try { const [jobsResponse, queueResponse] = await Promise.allSettled([ api.getConverterJobs(), api.getPipelineQueue() ]); const nextJobs = jobsResponse.status === 'fulfilled' && Array.isArray(jobsResponse.value?.jobs) ? jobsResponse.value.jobs : []; if (queueResponse.status === 'fulfilled') { const queuedRows = Array.isArray(queueResponse.value?.queue?.queuedJobs) ? queueResponse.value.queue.queuedJobs : []; setQueuedJobIds( queuedRows .map((item) => normalizeJobId(item?.jobId)) .filter(Boolean) ); } else { setQueuedJobIds([]); } setJobs(nextJobs); const runningStates = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK']); setJobProgress((prev) => { const next = { ...prev }; for (const job of nextJobs) { const jobId = normalizeJobId(job?.id); if (!jobId) continue; const status = String(job?.status || '').trim().toUpperCase(); if (!runningStates.has(status) && next[jobId]) { delete next[jobId]; } } return next; }); const previousStatuses = previousJobStatusesRef.current; const nextStatuses = new Map(); let hasStatusChanges = false; let hasCompletedJobTransitions = false; for (const job of nextJobs) { const jobId = normalizeJobId(job?.id); if (!jobId) continue; const nextStatus = String(job?.status || '').trim().toUpperCase(); const prevStatus = previousStatuses.get(jobId) || null; nextStatuses.set(jobId, nextStatus); if (prevStatus !== nextStatus) { hasStatusChanges = true; if (prevStatus && !TERMINAL_JOB_STATUSES.has(prevStatus) && TERMINAL_JOB_STATUSES.has(nextStatus)) { hasCompletedJobTransitions = true; } } } if (previousStatuses.size !== nextStatuses.size) { hasStatusChanges = true; } const isFirstSnapshot = previousStatuses.size === 0; previousJobStatusesRef.current = nextStatuses; if (!isFirstSnapshot && hasStatusChanges) { setExplorerRefreshToken((t) => t + 1); } if (!isFirstSnapshot && hasCompletedJobTransitions) { setSelectedEntries([]); setSelectionResetToken((t) => t + 1); } } catch (err) { console.error('Load converter jobs error:', err); } finally { setLoadingJobs(false); } }, []); useEffect(() => { loadJobs(); const interval = setInterval(loadJobs, 5000); return () => clearInterval(interval); }, [loadJobs]); useWebSocket({ onMessage: (message) => { if (!message?.type) return; if (message.type === 'PIPELINE_PROGRESS') { const payload = message.payload; const jobId = normalizeJobId(payload?.activeJobId); if (jobId) { setJobProgress((prev) => ({ ...prev, [jobId]: { progress: payload?.progress ?? null, eta: payload?.eta ?? null, statusText: payload?.statusText ?? null, state: payload?.state ?? null } })); } } if ( message.type === 'PIPELINE_UPDATE' || message.type === 'PIPELINE_STATE_CHANGED' || message.type === 'PIPELINE_QUEUE_CHANGED' || message.type === 'CONVERTER_SCAN_UPDATE' ) { loadJobs(); if (message.type === 'CONVERTER_SCAN_UPDATE' || message.type === 'PIPELINE_STATE_CHANGED') { setExplorerRefreshToken((t) => t + 1); } } if (message.type === 'SETTINGS_UPDATED') { if (String(message?.payload?.key || '').trim() === 'converter_scan_extensions') { void loadUploadExtensions(); } } if (message.type === 'SETTINGS_BULK_UPDATED') { const keys = Array.isArray(message?.payload?.keys) ? message.payload.keys : []; if (keys.includes('converter_scan_extensions')) { void loadUploadExtensions(); } } } }); const queuedJobIdSet = useMemo(() => { const next = new Set(); for (const value of queuedJobIds) { const jobId = normalizeJobId(value); if (jobId) { next.add(jobId); } } return next; }, [queuedJobIds]); const assignableJobs = useMemo(() => ( jobs .map((job) => ({ job, plan: parseConverterPlan(job) })) .filter(({ job, plan }) => { const status = String(job?.status || '').trim().toUpperCase(); const mediaType = String(plan?.converterMediaType || '').trim().toLowerCase(); return status === 'READY_TO_START' && mediaType === 'audio' && !Boolean(plan?.isFolder); }) .map(({ job, plan }) => { const rawTitle = String(job?.title || '').trim(); const title = rawTitle ? `#${job.id} | ${rawTitle}` : `#${job.id}`; const fileCount = Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0 ? plan.inputPaths.length : (plan?.inputPath ? 1 : 0); return { label: `${title}${fileCount > 0 ? ` (${fileCount} Datei${fileCount !== 1 ? 'en' : ''})` : ''}`, value: job.id }; }) ), [jobs]); const handleSelectionChange = (entries) => setSelectedEntries(entries || []); const handleOpenJobModal = () => { // Explorer expandiert Ordner bereits zu Einzel-Dateien const entries = [...selectedEntries]; jobEntriesRef.current = entries; setJobEntries(entries); const hasAudio = entries.some(isAudioEntry); const hasAssignableJobs = assignableJobs.length > 0; if (!hasAudio && !hasAssignableJobs) { // Nur Videos oder sonstige Dateien → direkt individual erstellen handleCreateJobsFromSelection('individual'); return; } setAudioMode('individual'); setJobModalAction('create'); setAssignTargetJobId(assignableJobs[0]?.value || null); setJobModeVisible(true); }; const handleCreateJobsFromSelection = async (explicitAudioMode) => { const entries = jobEntriesRef.current; if (entries.length === 0) return; const resolvedAudioMode = typeof explicitAudioMode === 'string' ? explicitAudioMode : audioMode; setCreatingJobs(true); setJobModeVisible(false); try { const relPaths = entries.map((e) => e.relPath).filter(Boolean); const result = await api.converterCreateJobsFromSelection(relPaths, resolvedAudioMode); const newJobs = result?.jobs || []; toastRef.current?.show({ severity: 'success', summary: 'Jobs erstellt', detail: `${newJobs.length} Converter-Job${newJobs.length !== 1 ? 's' : ''} erstellt.`, life: 3500 }); jobEntriesRef.current = []; setJobEntries([]); setSelectedEntries([]); setSelectionResetToken((t) => t + 1); setExplorerRefreshToken((t) => t + 1); await loadJobs(); } catch (err) { toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message || 'Jobs konnten nicht erstellt werden.', life: 4500 }); } finally { setCreatingJobs(false); } }; const handleAssignSelectionToJob = async (targetJobId) => { const entries = jobEntriesRef.current; if (entries.length === 0) return; const normalizedJobId = Number(targetJobId); if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) return; setCreatingJobs(true); setJobModeVisible(false); try { const relPaths = entries.map((e) => e.relPath).filter(Boolean); const result = await api.converterAssignFilesToJob(normalizedJobId, relPaths); const addedCount = Array.isArray(result?.addedRelPaths) ? result.addedRelPaths.length : 0; toastRef.current?.show({ severity: 'success', summary: 'Dateien zugewiesen', detail: addedCount > 0 ? `${addedCount} Datei${addedCount !== 1 ? 'en' : ''} zu Job #${normalizedJobId} hinzugefügt.` : `Keine neuen Dateien zu Job #${normalizedJobId} hinzugefügt.`, life: 3600 }); jobEntriesRef.current = []; setJobEntries([]); setSelectedEntries([]); setSelectionResetToken((t) => t + 1); setExplorerRefreshToken((t) => t + 1); await loadJobs(); } catch (err) { toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message || 'Dateien konnten dem Job nicht zugewiesen werden.', life: 4600 }); } finally { setCreatingJobs(false); } }; const handleJobModeConfirm = async ({ action, audioMode: selectedAudioMode, jobId }) => { if (action === 'assign') { await handleAssignSelectionToJob(jobId || assignTargetJobId); return; } await handleCreateJobsFromSelection(selectedAudioMode || audioMode); }; const handleUploaded = (folders) => { setExplorerRefreshToken((t) => t + 1); if (folders?.length > 0) { setExplorerNavigateTo({ path: folders[0].folderRelPath, ts: Date.now() }); toastRef.current?.show({ severity: 'success', summary: 'Upload abgeschlossen', detail: `${folders.length} Ordner hochgeladen. Dateien auswählen und Jobs anlegen.`, life: 4000 }); } }; const runJobAction = useCallback(async (jobId, action) => { const normalizedJobId = normalizeJobId(jobId); if (!normalizedJobId || typeof action !== 'function') return; setActionBusyJobIds((prev) => new Set([...prev, normalizedJobId])); try { await action(normalizedJobId); await loadJobs(); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Aktion fehlgeschlagen', detail: error?.message || 'Unbekannter Fehler', life: 4200 }); } finally { setActionBusyJobIds((prev) => { const next = new Set(prev); next.delete(normalizedJobId); return next; }); } }, [loadJobs]); const handleOpenMetadataDialog = async (jobId) => { const normalizedJobId = normalizeJobId(jobId); if (!normalizedJobId) return; const row = jobs.find((entry) => normalizeJobId(entry?.id) === normalizedJobId) || null; if (!row) return; const plan = parseConverterPlan(row); const metadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {}; setMetadataDialogContext({ jobId: normalizedJobId, detectedTitle: row?.detected_title || row?.title || metadata?.title || '', selectedMetadata: { title: row?.title || metadata?.title || row?.detected_title || '', year: row?.year || metadata?.year || null, imdbId: row?.imdb_id || metadata?.imdbId || null, poster: row?.poster_url || metadata?.poster || null }, omdbCandidates: [] }); setMetadataDialogVisible(true); }; const handleMetadataSubmit = async (payload) => { setMetadataDialogBusy(true); try { await api.selectMetadata(payload || {}); setMetadataDialogVisible(false); setMetadataDialogContext(null); await loadJobs(); toastRef.current?.show({ severity: 'success', summary: 'Metadaten übernommen', detail: `Job #${payload?.jobId} wurde aktualisiert.`, life: 3200 }); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Metadaten fehlgeschlagen', detail: error.message || 'Unbekannter Fehler', life: 4200 }); } finally { setMetadataDialogBusy(false); } }; const handleOmdbSearch = async (query) => { try { const response = await api.searchOmdb(query); return Array.isArray(response?.results) ? response.results : []; } catch (_error) { return []; } }; const handleStartPipelineJob = async (jobId, options = null) => { await runJobAction(jobId, async (resolvedJobId) => { const startOptions = options && typeof options === 'object' ? options : {}; if (startOptions.ensureConfirmed) { const confirmPayload = { selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null, selectedTrackSelection: startOptions.selectedTrackSelection ?? null, skipPipelineStateUpdate: true }; if (startOptions.selectedPostEncodeScriptIds !== undefined) { confirmPayload.selectedPostEncodeScriptIds = startOptions.selectedPostEncodeScriptIds; } if (startOptions.selectedPreEncodeScriptIds !== undefined) { confirmPayload.selectedPreEncodeScriptIds = startOptions.selectedPreEncodeScriptIds; } if (startOptions.selectedPostEncodeChainIds !== undefined) { confirmPayload.selectedPostEncodeChainIds = startOptions.selectedPostEncodeChainIds; } if (startOptions.selectedPreEncodeChainIds !== undefined) { confirmPayload.selectedPreEncodeChainIds = startOptions.selectedPreEncodeChainIds; } if (startOptions.selectedUserPresetId !== undefined) { confirmPayload.selectedUserPresetId = startOptions.selectedUserPresetId; } if (startOptions.selectedHandBrakePreset !== undefined) { confirmPayload.selectedHandBrakePreset = startOptions.selectedHandBrakePreset; } await api.confirmEncodeReview(resolvedJobId, confirmPayload); } await api.startJob(resolvedJobId); toastRef.current?.show({ severity: 'success', summary: 'Job gestartet', detail: `Job #${resolvedJobId} wurde gestartet.`, life: 2800 }); }); }; const handleConfirmReview = async (jobId, payload = {}) => { await runJobAction(jobId, async (resolvedJobId) => { await api.confirmEncodeReview(resolvedJobId, payload || {}); toastRef.current?.show({ severity: 'success', summary: 'Review bestätigt', detail: `Encode für Job #${resolvedJobId} ist freigegeben.`, life: 3000 }); }); }; const handleSelectPlaylist = async (jobId, selectedPlaylist) => { await runJobAction(jobId, async (resolvedJobId) => { await api.selectMetadata({ jobId: resolvedJobId, selectedPlaylist }); }); }; const handleSelectHandBrakeTitle = async (jobId, selectedHandBrakeTitleId) => { await runJobAction(jobId, async (resolvedJobId) => { await api.selectMetadata({ jobId: resolvedJobId, selectedHandBrakeTitleId }); }); }; const handleCancelPipelineJob = async (jobId) => { const normalizedJobId = normalizeJobId(jobId); if (!normalizedJobId) return; const confirmed = await confirmModal({ header: 'Job abbrechen', message: `Job #${normalizedJobId} abbrechen?`, acceptLabel: 'Abbrechen', rejectLabel: 'Zurück', danger: true }); if (!confirmed) return; await runJobAction(normalizedJobId, async (resolvedJobId) => { await api.cancelPipeline(resolvedJobId); handleJobCancelledImmediate(resolvedJobId); toastRef.current?.show({ severity: 'success', summary: 'Abbruch gesendet', detail: `Job #${resolvedJobId} wird abgebrochen.`, life: 2800 }); }); }; const handleRetryJob = async (jobId) => { await runJobAction(jobId, async (resolvedJobId) => { await api.retryJob(resolvedJobId); toastRef.current?.show({ severity: 'success', summary: 'Retry gestartet', detail: `Job #${resolvedJobId} wurde neu eingeplant.`, life: 3000 }); }); }; const handleDeleteConverterJob = async (jobId) => { const normalizedJobId = normalizeJobId(jobId); if (!normalizedJobId) return; const confirmed = await confirmModal({ header: 'Job löschen', message: `Job #${normalizedJobId} wirklich löschen?`, acceptLabel: 'Löschen', rejectLabel: 'Abbrechen', danger: true }); if (!confirmed) return; await runJobAction(normalizedJobId, async (resolvedJobId) => { await api.deleteConverterJob(resolvedJobId); handleJobDeleted(resolvedJobId); toastRef.current?.show({ severity: 'success', summary: 'Job gelöscht', detail: `Job #${resolvedJobId} wurde entfernt.`, life: 3000 }); }); }; const handleRemoveFromQueue = async (jobId) => { await runJobAction(jobId, async (resolvedJobId) => { await api.cancelPipeline(resolvedJobId); handleJobCancelledImmediate(resolvedJobId); toastRef.current?.show({ severity: 'success', summary: 'Aus Queue entfernt', detail: `Job #${resolvedJobId} wurde aus der Queue entfernt.`, life: 3000 }); }); }; const handleJobStarted = async (jobId) => { await loadJobs(); toastRef.current?.show({ severity: 'success', summary: 'Gestartet', detail: `Converter-Job ${jobId} läuft.`, life: 3000 }); }; const handleOpenJobDetails = async (row) => { const jobId = Number(row?.id || 0); if (!jobId) { return; } setSelectedJob({ ...row, logs: [], log: '', logMeta: { loaded: false, total: Number(row?.log_count || 0), returned: 0, truncated: false } }); setDetailVisible(true); setDetailLoading(true); try { const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true }); setSelectedJob(response.job); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Details konnten nicht geladen werden', detail: error.message || 'Unbekannter Fehler', life: 4200 }); } finally { setDetailLoading(false); } }; const handleLoadDetailLog = async (job, mode = 'tail') => { const jobId = Number(job?.id || selectedJob?.id || 0); if (!jobId) { return; } setLogLoadingMode(mode); try { const response = await api.getJob(jobId, { includeLogs: true, includeAllLogs: mode === 'all', logTailLines: mode === 'all' ? null : 800 }); setSelectedJob(response.job); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Log konnte nicht geladen werden', detail: error.message || 'Unbekannter Fehler', life: 4200 }); } finally { setLogLoadingMode(null); } }; const handleDeleteDetailEntry = async (job) => { const jobId = Number(job?.id || 0); if (!jobId) { return; } const confirmed = await confirmModal({ header: 'Historieneintrag löschen', message: `Historieneintrag Job #${jobId} löschen?`, acceptLabel: 'Löschen', rejectLabel: 'Abbrechen', danger: true }); if (!confirmed) { return; } setDeleteEntryBusy(true); try { await api.deleteConverterJob(jobId); handleJobDeleted(jobId); setDetailVisible(false); setSelectedJob(null); toastRef.current?.show({ severity: 'success', summary: 'Eintrag gelöscht', detail: `Job #${jobId} wurde entfernt.`, life: 3200 }); await loadJobs(); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message || 'Unbekannter Fehler', life: 4200 }); } finally { setDeleteEntryBusy(false); } }; const handleCancelDetailJob = async (job) => { const jobId = Number(job?.id || selectedJob?.id || 0); if (!jobId) { return; } const confirmed = await confirmModal({ header: 'Job abbrechen', message: `Job #${jobId} abbrechen?`, acceptLabel: 'Abbrechen', rejectLabel: 'Zurück', danger: true }); if (!confirmed) { return; } setCancelDetailBusy(true); try { await api.cancelConverterJob(jobId); handleJobCancelledImmediate(jobId); toastRef.current?.show({ severity: 'success', summary: 'Abbruch gesendet', detail: `Job #${jobId} wird abgebrochen.`, life: 2800 }); await loadJobs(); if (detailVisible) { const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true }); setSelectedJob(response.job); } } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Abbruch fehlgeschlagen', detail: error.message || 'Unbekannter Fehler', life: 4200 }); } finally { setCancelDetailBusy(false); } }; const handleJobDeleted = (jobId) => { setJobs((prev) => prev.filter((j) => j.id !== jobId)); setJobProgress((prev) => { const next = { ...prev }; delete next[jobId]; return next; }); setExplorerRefreshToken((t) => t + 1); if (Number(selectedJob?.id || 0) === Number(jobId || 0)) { setDetailVisible(false); setSelectedJob(null); setDetailLoading(false); setLogLoadingMode(null); } }; const handleJobCancelledImmediate = (jobId) => { const normalizedJobId = normalizeJobId(jobId); if (!normalizedJobId) { return; } setJobs((prev) => prev.map((job) => ( Number(job?.id) === normalizedJobId ? { ...job, status: 'CANCELLED' } : job ))); setJobProgress((prev) => { const next = { ...prev }; delete next[normalizedJobId]; return next; }); if (Number(selectedJob?.id || 0) === normalizedJobId) { setSelectedJob((prev) => (prev ? { ...prev, status: 'CANCELLED' } : prev)); } }; const handleJobCancelled = () => setTimeout(loadJobs, 1000); const handleJobInputsChanged = async (_jobId, _payload) => { setExplorerRefreshToken((t) => t + 1); await loadJobs(); }; const activeJobs = jobs.filter((j) => !['DONE', 'FINISHED', 'ERROR'].includes(String(j.status || '').toUpperCase())); // Auto-expand: ersten Job aufklappen, wenn keiner expanded ist (außer user hat explizit zugeklappt) useEffect(() => { const normalizedExpanded = Number(expandedJobId) || null; const hasExpanded = activeJobs.some((j) => Number(j.id) === normalizedExpanded); if (hasExpanded) return; if (expandedJobId === null) return; // explizit vom User zugeklappt if (activeJobs.length === 0) return; setExpandedJobId(Number(activeJobs[0].id)); }, [activeJobs, expandedJobId]); const jobsCardHeader = (
Converter Jobs {activeJobs.length > 0 && ( )}
); return (
{/* Import-Ordner */} {selectedEntries.length > 0 && (
ausgewählt
)}
{/* Jobs */} {activeJobs.length === 0 ? (

Keine aktiven Converter-Jobs vorhanden. Abgeschlossene Jobs findest du in der Historie.

) : (
{activeJobs.map((job) => { const plan = parseConverterPlan(job); const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase(); const isAudioJob = converterMediaType === 'audio'; if (isAudioJob) { return ( setExpandedJobId(Number(job.id))} onCollapse={() => setExpandedJobId(null)} onStarted={handleJobStarted} onDeleted={handleJobDeleted} onCancelled={handleJobCancelled} onOpenDetails={handleOpenJobDetails} onInputsChanged={handleJobInputsChanged} /> ); } return ( setExpandedJobId(Number(job.id))} onCollapse={() => setExpandedJobId(null)} onOpenMetadata={handleOpenMetadataDialog} onReassignOmdb={handleOpenMetadataDialog} onStart={handleStartPipelineJob} onConfirmReview={handleConfirmReview} onSelectPlaylist={handleSelectPlaylist} onSelectHandBrakeTitle={handleSelectHandBrakeTitle} onCancel={handleCancelPipelineJob} onRetry={handleRetryJob} onDeleteJob={handleDeleteConverterJob} onRemoveFromQueue={handleRemoveFromQueue} onInputsChanged={handleJobInputsChanged} /> ); })}
)}
{/* Upload */} setJobModeVisible(false)} /> { if (metadataDialogBusy) return; setMetadataDialogVisible(false); setMetadataDialogContext(null); }} onSubmit={handleMetadataSubmit} onSearch={handleOmdbSearch} busy={metadataDialogBusy} /> { setDetailVisible(false); setSelectedJob(null); setDetailLoading(false); setLogLoadingMode(null); setDeleteEntryBusy(false); setCancelDetailBusy(false); }} />
); } // ── Job-Modus-Dialog ────────────────────────────────────────────────────────── function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, action, onActionChange, assignableJobs, assignJobId, onAssignJobIdChange, busy, onConfirm, onHide }) { const audioEntries = entries.filter(isAudioEntry); const videoEntries = entries.filter(isVideoEntry); const otherEntries = entries.filter((e) => !isAudioEntry(e) && !isVideoEntry(e)); const hasAudio = audioEntries.length > 0; const hasVideo = videoEntries.length > 0; const hasOther = otherEntries.length > 0; const hasAssignableJobs = Array.isArray(assignableJobs) && assignableJobs.length > 0; // Videos immer individual, audio per Modus const audioJobCount = audioMode === 'shared' ? 1 : audioEntries.length; const totalJobs = videoEntries.length + otherEntries.length + audioJobCount; const isAssignMode = action === 'assign'; const confirmLabel = isAssignMode ? 'Dateien zuweisen' : `${totalJobs} Job${totalJobs !== 1 ? 's' : ''} anlegen`; const footer = (
); return ( {/* Videos / Sonstige immer individual */} {(hasVideo || hasOther) && (

Videos / Sonstige ({videoEntries.length + otherEntries.length}): Je eine Datei ein eigener Job.

)} {hasAudio && (hasVideo || hasOther) && } {/* Audio: Abfrage */} {hasAudio && (

Wie sollen die {audioEntries.length} Audiodatei{audioEntries.length !== 1 ? 'en' : ''} verarbeitet werden?

)} {hasAssignableJobs && ( <>

Optional: Dateien einem nicht gestarteten Job direkt zuweisen.

onAssignJobIdChange(e.value)} placeholder="Job auswählen …" disabled={!isAssignMode} style={{ width: '100%' }} />
)}
); } function ConverterVideoJobCard({ job, plan, jobProgress, isQueued, busy, userPresets, hbPresets, isExpanded, onExpand, onCollapse, onOpenMetadata, onReassignOmdb, onStart, onConfirmReview, onSelectPlaylist, onSelectHandBrakeTitle, onCancel, onRetry, onDeleteJob, onRemoveFromQueue, onInputsChanged }) { const jobId = Number(job?.id || 0); const status = String(job?.status || '').trim().toUpperCase() || 'UNKNOWN'; const title = String(job?.title || job?.detected_title || `Job #${jobId}`).trim() || `Job #${jobId}`; const state = String(jobProgress?.state || status).trim().toUpperCase() || status; const pipeline = buildConverterPipeline(job, plan, jobProgress); const progressValue = Number(jobProgress?.progress); const hasProgress = Number.isFinite(progressValue); if (!isExpanded) { return (