import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { Card } from 'primereact/card'; import { DataView, DataViewLayoutOptions } from 'primereact/dataview'; import { InputText } from 'primereact/inputtext'; import { Dropdown } from 'primereact/dropdown'; import { Button } from 'primereact/button'; import { Tag } from 'primereact/tag'; import { Toast } from 'primereact/toast'; import { Dialog } from 'primereact/dialog'; import { api } from '../api/client'; import JobDetailDialog from '../components/JobDetailDialog'; import MetadataSelectionDialog from '../components/MetadataSelectionDialog'; import CdMetadataDialog from '../components/CdMetadataDialog'; import ReencodeConflictModal from '../components/ReencodeConflictModal'; import blurayIndicatorIcon from '../assets/media-bluray.svg'; import discIndicatorIcon from '../assets/media-disc.svg'; import otherIndicatorIcon from '../assets/media-other.svg'; import { getStatusLabel, getStatusSeverity, normalizeStatus, STATUS_FILTER_OPTIONS } from '../utils/statusPresentation'; import { confirmModal } from '../utils/confirmModal'; import { isSeriesDvdJob } from '../utils/jobTaxonomy'; const MEDIA_FILTER_OPTIONS = [ { label: 'Alle Medien', value: '' }, { label: 'Blu-ray', value: 'bluray' }, { label: 'DVD', value: 'dvd' }, { label: 'DVD Serie', value: 'dvd_series' }, { label: 'Audio CD', value: 'cd' }, { label: 'Audiobook', value: 'audiobook' }, { label: 'Sonstiges', value: 'other' } ]; const SORT_OPTIONS = [ { label: 'Startzeit: Neu -> Alt', value: '!sortStartTime' }, { label: 'Startzeit: Alt -> Neu', value: 'sortStartTime' }, { label: 'Endzeit: Neu -> Alt', value: '!sortEndTime' }, { label: 'Endzeit: Alt -> Neu', value: 'sortEndTime' }, { label: 'Titel: A -> Z', value: 'sortTitle' }, { label: 'Titel: Z -> A', value: '!sortTitle' }, { label: 'Medium: A -> Z', value: 'sortMediaType' }, { label: 'Medium: Z -> A', value: '!sortMediaType' } ]; const CD_FORMAT_LABELS = { flac: 'FLAC', wav: 'WAV', mp3: 'MP3', opus: 'Opus', ogg: 'Ogg Vorbis' }; function normalizePositiveInteger(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function resolveMediaType(row) { const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null; const candidates = [ row?.mediaType, row?.media_type, row?.mediaProfile, row?.media_profile, encodePlan?.mediaProfile, row?.makemkvInfo?.analyzeContext?.mediaProfile, row?.makemkvInfo?.mediaProfile, row?.mediainfoInfo?.mediaProfile ]; for (const candidate of candidates) { const raw = String(candidate || '').trim().toLowerCase(); if (!raw) { continue; } 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 (['cd', 'audio_cd', 'audio cd'].includes(raw)) { return 'cd'; } if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) { return 'audiobook'; } if (raw === 'converter') { return 'converter'; } } const statusCandidates = [ row?.status, row?.last_state, row?.makemkvInfo?.lastState ]; if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) { return 'cd'; } const planFormat = String(encodePlan?.format || '').trim().toLowerCase(); const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0; if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) { return 'cd'; } if (String(row?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') { return 'cd'; } if (Array.isArray(row?.makemkvInfo?.tracks) && row.makemkvInfo.tracks.length > 0) { return 'cd'; } if (['audiobook_encode', 'audiobook_encode_split'].includes(String(row?.handbrakeInfo?.mode || '').trim().toLowerCase())) { return 'audiobook'; } if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') { return 'audiobook'; } return 'other'; } function resolveMediaTypeMeta(row) { const mediaType = resolveMediaType(row); const isSeriesDvd = mediaType === 'dvd' && isSeriesDvdJob(row); if (mediaType === 'bluray') { return { mediaType, icon: blurayIndicatorIcon, label: 'Blu-ray', alt: 'Blu-ray' }; } if (mediaType === 'dvd') { return { mediaType, icon: discIndicatorIcon, label: isSeriesDvd ? 'DVD Serie' : 'DVD', alt: isSeriesDvd ? 'DVD Serie' : 'DVD' }; } if (mediaType === 'cd') { return { mediaType, icon: otherIndicatorIcon, label: 'Audio CD', alt: 'Audio CD' }; } if (mediaType === 'audiobook') { return { mediaType, icon: otherIndicatorIcon, label: 'Audiobook', alt: 'Audiobook' }; } if (mediaType === 'converter') { return { mediaType, icon: otherIndicatorIcon, label: 'Converter', alt: 'Converter' }; } return { mediaType, icon: otherIndicatorIcon, label: 'Sonstiges', alt: 'Sonstiges Medium' }; } function isSeriesBatchChildHistoryRow(row) { const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null; if (!encodePlan) { return false; } return Boolean(encodePlan?.seriesBatchChild || encodePlan?.seriesBatchVirtualEpisode); } function formatDurationSeconds(totalSeconds) { const parsed = Number(totalSeconds); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } const rounded = Math.max(0, Math.trunc(parsed)); const hours = Math.floor(rounded / 3600); const minutes = Math.floor((rounded % 3600) / 60); const seconds = rounded % 60; if (hours > 0) { return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } return `${minutes}:${String(seconds).padStart(2, '0')}`; } function resolveCdDetails(row) { const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {}; const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {}; const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' ? makemkvInfo.selectedMetadata : {}; const tracksSource = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0 ? makemkvInfo.tracks : (Array.isArray(encodePlan?.tracks) ? encodePlan.tracks : []); const tracks = tracksSource .map((track) => { const position = normalizePositiveInteger(track?.position); if (!position) { return null; } return { ...track, position, selected: track?.selected !== false }; }) .filter(Boolean); const selectedTracksFromPlan = Array.isArray(encodePlan?.selectedTracks) ? encodePlan.selectedTracks .map((value) => normalizePositiveInteger(value)) .filter(Boolean) : []; const selectedTrackPositions = selectedTracksFromPlan.length > 0 ? selectedTracksFromPlan : tracks.filter((track) => track.selected !== false).map((track) => track.position); const fallbackArtist = tracks .map((track) => String(track?.artist || '').trim()) .find(Boolean) || null; const totalDurationSec = tracks.reduce((sum, track) => { const durationMs = Number(track?.durationMs); const durationSec = Number(track?.durationSec); if (Number.isFinite(durationMs) && durationMs > 0) { return sum + (durationMs / 1000); } if (Number.isFinite(durationSec) && durationSec > 0) { return sum + durationSec; } return sum; }, 0); const format = String(encodePlan?.format || '').trim().toLowerCase(); const mbId = String( selectedMetadata?.mbId || selectedMetadata?.musicBrainzId || selectedMetadata?.musicbrainzId || selectedMetadata?.musicbrainz_id || selectedMetadata?.music_brainz_id || selectedMetadata?.musicbrainz || selectedMetadata?.mbid || row?.imdb_id || '' ).trim() || null; return { artist: String(selectedMetadata?.artist || '').trim() || fallbackArtist || null, trackCount: tracks.length, selectedTrackCount: selectedTrackPositions.length, format, formatLabel: format ? (CD_FORMAT_LABELS[format] || format.toUpperCase()) : null, totalDurationLabel: formatDurationSeconds(totalDurationSec), mbId }; } function resolveAudiobookDetails(row) { const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {}; const selectedMetadata = row?.makemkvInfo?.selectedMetadata && typeof row.makemkvInfo.selectedMetadata === 'object' ? row.makemkvInfo.selectedMetadata : (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {}); const chapters = Array.isArray(selectedMetadata?.chapters) ? selectedMetadata.chapters : (Array.isArray(row?.makemkvInfo?.chapters) ? row.makemkvInfo.chapters : []); const format = String( row?.handbrakeInfo?.format || encodePlan?.format || '' ).trim().toLowerCase() || null; return { author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null, narrator: String(selectedMetadata?.narrator || '').trim() || null, chapterCount: chapters.length, formatLabel: format ? format.toUpperCase() : null }; } function resolveConverterMediaType(row) { const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {}; const value = String( encodePlan?.converterMediaType || row?.converterMediaType || '' ).trim().toLowerCase(); if (value === 'audio') { return 'audio'; } if (value === 'video' || value === 'iso') { return 'video'; } return null; } function hasAudioOutput(row) { const mediaType = resolveMediaType(row); if (mediaType === 'cd' || mediaType === 'audiobook') { return true; } if (mediaType === 'converter') { return resolveConverterMediaType(row) === 'audio'; } return false; } function getOutputLabelForRow(row) { if (resolveMediaType(row) === 'audiobook') { return 'Audiobook-Datei(en)'; } if (hasAudioOutput(row)) { return 'Audio-Dateien'; } return 'Movie-Datei(en)'; } function getOutputShortLabelForRow(row) { if (resolveMediaType(row) === 'audiobook') { return 'Audiobook'; } if (hasAudioOutput(row)) { return 'Audio'; } return 'Movie'; } function normalizeJobId(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function getQueueActionResult(response) { return response?.result && typeof response.result === 'object' ? response.result : {}; } function isPipelineMetadataFlowStatus(value) { const normalized = String(value || '').trim().toUpperCase(); return ['METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized); } function normalizeSortText(value) { return String(value || '').trim().toLocaleLowerCase('de-DE'); } function sanitizeRating(value) { const raw = String(value || '').trim(); if (!raw || raw.toUpperCase() === 'N/A') { return null; } return raw; } function findOmdbRatingBySource(omdbInfo, sourceName) { const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : []; const source = String(sourceName || '').trim().toLowerCase(); const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === source); return sanitizeRating(entry?.Value); } function resolveRatings(row) { const omdbInfo = row?.omdbInfo && typeof row.omdbInfo === 'object' ? row.omdbInfo : null; if (!omdbInfo) { return []; } const imdb = sanitizeRating(omdbInfo?.imdbRating) || findOmdbRatingBySource(omdbInfo, 'Internet Movie Database'); const rotten = findOmdbRatingBySource(omdbInfo, 'Rotten Tomatoes'); const metascore = sanitizeRating(omdbInfo?.Metascore); const ratings = []; if (imdb) { ratings.push({ key: 'imdb', label: 'IMDb', value: imdb }); } if (rotten) { ratings.push({ key: 'rt', label: 'RT', value: rotten }); } if (metascore) { ratings.push({ key: 'meta', label: 'Meta', value: metascore }); } return ratings; } function formatDateTime(value) { if (!value) { return '-'; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return String(value); } return date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }); } function parseSortableTimestamp(value) { if (!value) { return null; } const ts = Date.parse(value); return Number.isFinite(ts) ? ts : null; } function resolveSortTimestamp(...candidates) { for (const candidate of candidates) { const ts = parseSortableTimestamp(candidate); if (ts != null) { return ts; } } return 0; } export default function HistoryPage({ refreshToken = 0 }) { const location = useLocation(); const navigate = useNavigate(); const [jobs, setJobs] = useState([]); const [search, setSearch] = useState(''); const [status, setStatus] = useState(''); const [mediumFilter, setMediumFilter] = useState(''); const [layout, setLayout] = useState('grid'); const [sortKey, setSortKey] = useState('!sortStartTime'); const [sortField, setSortField] = useState('sortStartTime'); const [sortOrder, setSortOrder] = useState(-1); const [selectedJob, setSelectedJob] = useState(null); const [detailVisible, setDetailVisible] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [logLoadingMode, setLogLoadingMode] = useState(null); const [actionBusy, setActionBusy] = useState(false); const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null); const [deleteEntryBusy, setDeleteEntryBusy] = useState(false); const [deleteEntryDialogVisible, setDeleteEntryDialogVisible] = useState(false); const [deleteEntryDialogRow, setDeleteEntryDialogRow] = useState(null); const [deleteEntryPreview, setDeleteEntryPreview] = useState(null); const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false); const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null); const [deleteEntrySelectedMoviePaths, setDeleteEntrySelectedMoviePaths] = useState([]); const [downloadBusyTarget, setDownloadBusyTarget] = useState(null); const [downloadFolderBusyPath, setDownloadFolderBusyPath] = useState(null); const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); const [metadataDialogContext, setMetadataDialogContext] = useState(null); const [omdbAssignBusy, setOmdbAssignBusy] = useState(false); const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false); const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null); const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false); const [acknowledgeErrorBusy, setAcknowledgeErrorBusy] = useState(false); const [loading, setLoading] = useState(false); const [queuedJobIds, setQueuedJobIds] = useState([]); const [conflictModalVisible, setConflictModalVisible] = useState(false); const [conflictModalJob, setConflictModalJob] = useState(null); const [conflictModalFolders, setConflictModalFolders] = useState([]); const [conflictModalAction, setConflictModalAction] = useState(null); // 'reencode' | 'restart_encode' | 'restart_review' | 'restart_cd_review' | 'delete_output' const [conflictModalMode, setConflictModalMode] = useState('reencode'); // 'reencode' | 'delete' const [conflictModalBusy, setConflictModalBusy] = useState(false); const toastRef = useRef(null); const queuedJobIdSet = useMemo(() => { const next = new Set(); for (const value of Array.isArray(queuedJobIds) ? queuedJobIds : []) { const id = normalizeJobId(value); if (id) { next.add(id); } } return next; }, [queuedJobIds]); const preparedJobs = useMemo( () => jobs .filter((job) => !isSeriesBatchChildHistoryRow(job)) .map((job) => ({ ...job, sortTitle: normalizeSortText(job?.title || job?.detected_title || ''), sortMediaType: resolveMediaType(job), sortStartTime: resolveSortTimestamp(job?.start_time, job?.updated_at, job?.created_at, job?.end_time), sortEndTime: resolveSortTimestamp(job?.end_time, job?.updated_at, job?.created_at, job?.start_time) })), [jobs] ); const visibleJobs = useMemo( () => (mediumFilter ? preparedJobs.filter((job) => { if (mediumFilter === 'dvd_series') { return job.sortMediaType === 'dvd' && isSeriesDvdJob(job); } return job.sortMediaType === mediumFilter; }) : preparedJobs), [preparedJobs, mediumFilter] ); const load = async () => { setLoading(true); try { const [jobsResponse, queueResponse] = await Promise.allSettled([ api.getJobs({ search, status }), api.getPipelineQueue() ]); if (jobsResponse.status === 'fulfilled') { setJobs(jobsResponse.value.jobs || []); } else { setJobs([]); } if (queueResponse.status === 'fulfilled') { const queuedRows = Array.isArray(queueResponse.value?.queue?.queuedJobs) ? queueResponse.value.queue.queuedJobs : []; const queuedIds = queuedRows .map((item) => normalizeJobId(item?.jobId)) .filter(Boolean); setQueuedJobIds(queuedIds); } else { setQueuedJobIds([]); } } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message }); } finally { setLoading(false); } }; useEffect(() => { api.getPref('history_layout').then((res) => { if (res?.value === 'list' || res?.value === 'grid') { setLayout(res.value); } }).catch(() => {}); }, []); useEffect(() => { const timer = setTimeout(() => { load(); }, 300); return () => clearTimeout(timer); }, [search, status, refreshToken]); useEffect(() => { const params = new URLSearchParams(location.search); const openJobId = Number(params.get('open') || 0); if (!openJobId) { return; } // URL-Parameter entfernen, dann Job-Modal öffnen navigate('/history', { replace: true }); openDetail({ id: openJobId }); }, [location.search]); const onSortChange = (event) => { const value = String(event.value || '').trim(); if (!value) { setSortKey('!sortStartTime'); setSortField('sortStartTime'); setSortOrder(-1); return; } if (value.startsWith('!')) { setSortOrder(-1); setSortField(value.substring(1)); } else { setSortOrder(1); setSortField(value); } setSortKey(value); }; const openDetail = 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: 'Fehler', detail: error.message }); } finally { setDetailLoading(false); } }; const handleLoadLog = 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 }); } finally { setLogLoadingMode(null); } }; const refreshDetailIfOpen = async (jobId) => { if (!detailVisible || Number(selectedJob?.id || 0) !== Number(jobId || 0)) { return; } const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true }); setSelectedJob(response.job); }; const refreshDetailAfterReplacement = async (sourceJobId, replacementJobId = null) => { if (!detailVisible) { return; } const sourceId = Number(sourceJobId || 0); const replacementId = Number(replacementJobId || 0); const selectedId = Number(selectedJob?.id || 0); const hasReplacement = replacementId > 0 && replacementId !== sourceId; if (hasReplacement && selectedId === sourceId) { const response = await api.getJob(replacementId, { includeLogs: false, forceRefresh: true }); setSelectedJob(response.job); return; } await refreshDetailIfOpen(hasReplacement ? replacementId : sourceId); }; // ── Conflict Modal Helpers ───────────────────────────────────────────────── const mergeOutputFoldersForJob = (job, folders = []) => { const normalizedJobId = Number(job?.id || 0); const merged = []; const seen = new Set(); const addFolder = (folder) => { const outputPath = String(folder?.output_path || '').trim(); if (!outputPath || seen.has(outputPath)) { return; } seen.add(outputPath); merged.push({ id: folder?.id ?? 0, job_id: folder?.job_id ?? (normalizedJobId || null), output_path: outputPath, label: folder?.label || null, created_at: folder?.created_at || null }); }; for (const folder of (Array.isArray(folders) ? folders : [])) { addFolder(folder); } const currentPath = String(job?.output_path || '').trim(); if (currentPath && !seen.has(currentPath) && Boolean(job?.outputStatus?.exists)) { addFolder({ id: 0, job_id: normalizedJobId, output_path: currentPath, label: 'Aktuelle Ausgabe', created_at: null }); } return merged; }; const loadOutputFoldersForJob = async (job) => { if (!job?.id) { return []; } try { const response = await api.getOutputFolders(job.id); return mergeOutputFoldersForJob(job, response?.folders); } catch (_error) { return mergeOutputFoldersForJob(job, []); } }; const openConflictModal = async (job, action, mode = 'reencode', preloadedFolders = null) => { const folders = Array.isArray(preloadedFolders) ? mergeOutputFoldersForJob(job, preloadedFolders) : await loadOutputFoldersForJob(job); if (folders.length === 0) { return false; } setConflictModalJob(job); setConflictModalAction(action); setConflictModalMode(mode); setConflictModalFolders(folders); setConflictModalVisible(true); return true; }; const closeConflictModal = () => { if (conflictModalBusy) return; setConflictModalVisible(false); setConflictModalJob(null); setConflictModalAction(null); setConflictModalMode('reencode'); setConflictModalFolders([]); }; const executeHistoryAction = async (job, action, options = {}, executionOptions = {}) => { const skipConfirm = Boolean(executionOptions?.skipConfirm); if (action === 'reencode') { setReencodeBusyJobId(job.id); try { await api.reencodeJob(job.id, options); toastRef.current?.show({ severity: 'success', summary: 'Re-Encode gestartet', detail: 'Job wurde in die Mediainfo-Prüfung gesetzt.', life: 3500 }); await load(); await refreshDetailIfOpen(job.id); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Re-Encode fehlgeschlagen', detail: error.message, life: 4500 }); } finally { setReencodeBusyJobId(null); } } else if (action === 'restart_encode') { setActionBusy(true); try { const response = await api.restartEncodeWithLastSettings(job.id, options); const result = getQueueActionResult(response); const replacementJobId = normalizeJobId(result?.jobId); if (result.queued) { toastRef.current?.show({ severity: 'info', summary: 'Encode-Neustart in Queue', detail: result.queuePosition > 0 ? `Position ${result.queuePosition}` : 'In der Warteschlange eingeplant.', life: 3500 }); } else { toastRef.current?.show({ severity: 'success', summary: 'Encode-Neustart gestartet', detail: 'Letzte bestätigte Einstellungen werden verwendet.', life: 3500 }); } await load(); await refreshDetailAfterReplacement(job.id, replacementJobId); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 }); } finally { setActionBusy(false); } } else if (action === 'restart_review') { const title = job?.title || job?.detected_title || `Job #${job?.id}`; if (!skipConfirm) { const confirmed = await confirmModal({ header: 'Review neu starten', message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`, acceptLabel: 'Review starten', rejectLabel: 'Abbrechen' }); if (!confirmed) { return; } } setActionBusy(true); try { const response = await api.restartReviewFromRaw(job.id, options); const result = getQueueActionResult(response); const replacementJobId = normalizeJobId(result?.jobId); toastRef.current?.show({ severity: 'success', summary: 'Review-Neustart', detail: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.', life: 3500 }); await load(); await refreshDetailAfterReplacement(job.id, replacementJobId); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 }); } finally { setActionBusy(false); } } else if (action === 'restart_cd_review') { const title = job?.title || job?.detected_title || `Job #${job?.id}`; if (!skipConfirm) { const confirmed = await confirmModal({ header: 'CD-Vorprüfung starten', message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`, acceptLabel: 'Vorprüfung starten', rejectLabel: 'Abbrechen' }); if (!confirmed) { return; } } setActionBusy(true); try { await api.restartCdReviewFromRaw(job.id, options); toastRef.current?.show({ severity: 'success', summary: 'CD-Vorprüfung gestartet', detail: 'Job ist jetzt im Ripper verfügbar — bitte Metadaten und Einstellungen wählen.', life: 4000 }); await load(); await refreshDetailIfOpen(job.id); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'CD-Vorprüfung fehlgeschlagen', detail: error.message, life: 4500 }); } finally { setActionBusy(false); } } else if (action === 'delete_output') { const deleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) : []; if (deleteFolders.length === 0) { toastRef.current?.show({ severity: 'warn', summary: 'Keine Ordner gewählt', detail: 'Es wurden keine Output-Ordner zum Löschen erkannt.', life: 3500 }); return; } setActionBusy(true); try { const response = await api.deleteOutputFolders(job.id, deleteFolders); const result = response?.result && typeof response.result === 'object' ? response.result : {}; const deletedCount = Array.isArray(result.deleted) ? result.deleted.length : 0; const failedCount = Array.isArray(result.failed) ? result.failed.length : 0; toastRef.current?.show({ severity: failedCount > 0 ? 'warn' : 'success', summary: failedCount > 0 ? 'Output teilweise gelöscht' : 'Output gelöscht', detail: failedCount > 0 ? `${deletedCount} Ordner gelöscht, ${failedCount} mit Fehler.` : `${deletedCount} Ordner gelöscht.`, life: 4000 }); await load(); await refreshDetailIfOpen(job.id); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Output löschen fehlgeschlagen', detail: error.message, life: 4500 }); } finally { setActionBusy(false); } } }; const handleConflictKeepBoth = async () => { const job = conflictModalJob; const action = conflictModalAction; setConflictModalBusy(true); try { await executeHistoryAction(job, action, { keepBoth: true }, { skipConfirm: true }); closeConflictModal(); } finally { setConflictModalBusy(false); } }; const handleConflictDeleteSelected = async (selectedPaths) => { const job = conflictModalJob; const action = conflictModalAction; const selected = Array.isArray(selectedPaths) ? selectedPaths.filter((value) => typeof value === 'string' && value.trim()) : []; const allKnownPaths = conflictModalFolders .map((folder) => String(folder?.output_path || '').trim()) .filter(Boolean); const deleteFolders = selected.length > 0 ? selected : allKnownPaths; setConflictModalBusy(true); try { const options = deleteFolders.length > 0 ? { deleteFolders } : {}; await executeHistoryAction(job, action, options, { skipConfirm: true }); closeConflictModal(); } finally { setConflictModalBusy(false); } }; // ── File deletion ────────────────────────────────────────────────────────── const handleDeleteFiles = async (row, target) => { if (target === 'movie') { const outputFolders = await loadOutputFoldersForJob(row); if (outputFolders.length > 1) { await openConflictModal(row, 'delete_output', 'delete', outputFolders); return; } } const outputLabel = getOutputLabelForRow(row); const outputShortLabel = getOutputShortLabelForRow(row); const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`; const title = row.title || row.detected_title || `Job #${row.id}`; const confirmed = await confirmModal({ header: 'Dateien löschen', message: `${label} für "${title}" wirklich löschen?`, acceptLabel: 'Löschen', rejectLabel: 'Abbrechen', danger: true }); if (!confirmed) { return; } setActionBusy(true); try { const response = await api.deleteJobFiles(row.id, target); const summary = response.summary || {}; const rawSummary = summary.raw || {}; const movieSummary = summary.movie || {}; const deletedSomething = target === 'raw' ? Boolean(rawSummary.deleted) : target === 'movie' ? Boolean(movieSummary.deleted) : Boolean(rawSummary.deleted || movieSummary.deleted); if (!deletedSomething) { const reason = target === 'raw' ? (rawSummary.reason || 'Keine passenden RAW-Dateien/Ordner gefunden.') : target === 'movie' ? (movieSummary.reason || `Keine passenden ${outputShortLabel}-Dateien/Ordner gefunden.`) : (movieSummary.reason || rawSummary.reason || 'Keine passenden Dateien/Ordner gefunden.'); toastRef.current?.show({ severity: 'warn', summary: 'Nichts gelöscht', detail: reason, life: 4200 }); } else { toastRef.current?.show({ severity: 'success', summary: 'Dateien gelöscht', detail: `RAW: ${rawSummary.filesDeleted ?? 0}, ${outputShortLabel}: ${movieSummary.filesDeleted ?? 0}`, life: 3500 }); } await load(); await refreshDetailIfOpen(row.id); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 }); } finally { setActionBusy(false); } }; const handleDownloadArchive = async (row, target) => { const jobId = Number(row?.id || selectedJob?.id || 0); const normalizedTarget = String(target || '').trim().toLowerCase(); if (!jobId || !['raw', 'output'].includes(normalizedTarget)) { return; } setDownloadBusyTarget(normalizedTarget); try { const response = await api.requestJobArchive(jobId, normalizedTarget); const item = response?.item && typeof response.item === 'object' ? response.item : null; const label = normalizedTarget === 'raw' ? 'RAW' : 'Encode'; const isReady = String(item?.status || '').trim().toLowerCase() === 'ready'; const detail = isReady ? `${label}-ZIP ist bereits auf der Downloads-Seite verfuegbar.` : `${label}-ZIP wird im Hintergrund erstellt und erscheint danach auf der Downloads-Seite.`; toastRef.current?.show({ severity: isReady ? 'success' : 'info', summary: isReady ? 'ZIP bereit' : 'ZIP wird erstellt', detail, life: 4000 }); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Download fehlgeschlagen', detail: error.message, life: 4500 }); } finally { setDownloadBusyTarget(null); } }; const handleDownloadOutputFolder = async (row, folderPath) => { const jobId = Number(row?.id || selectedJob?.id || 0); if (!jobId || !folderPath) return; setDownloadFolderBusyPath(folderPath); try { const response = await api.requestJobArchive(jobId, 'output', { outputPath: folderPath }); const item = response?.item && typeof response.item === 'object' ? response.item : null; const isReady = String(item?.status || '').trim().toLowerCase() === 'ready'; toastRef.current?.show({ severity: isReady ? 'success' : 'info', summary: isReady ? 'ZIP bereit' : 'ZIP wird erstellt', detail: isReady ? 'Output-ZIP ist bereits auf der Downloads-Seite verfügbar.' : 'Output-ZIP wird im Hintergrund erstellt und erscheint danach auf der Downloads-Seite.', life: 4000 }); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Download fehlgeschlagen', detail: error.message, life: 4500 }); } finally { setDownloadFolderBusyPath(null); } }; const maybeOpenOutputConflictModal = async (row, action) => { const outputFolders = await loadOutputFoldersForJob(row); if (outputFolders.length === 0) { return false; } await openConflictModal(row, action, 'reencode', outputFolders); return true; }; const handleReencode = async (row) => { if (await maybeOpenOutputConflictModal(row, 'reencode')) { return; } await executeHistoryAction(row, 'reencode'); }; const handleRestartEncode = async (row) => { if (await maybeOpenOutputConflictModal(row, 'restart_encode')) { return; } await executeHistoryAction(row, 'restart_encode'); }; const handleRestartReview = async (row) => { if (await maybeOpenOutputConflictModal(row, 'restart_review')) { return; } await executeHistoryAction(row, 'restart_review'); }; const handleRestartCdReview = async (row) => { if (await maybeOpenOutputConflictModal(row, 'restart_cd_review')) { return; } await executeHistoryAction(row, 'restart_cd_review'); }; const handleRetry = async (row) => { const title = row?.title || row?.detected_title || `Job #${row?.id}`; const mediaType = resolveMediaType(row); const actionLabel = mediaType === 'cd' ? 'CD-Rip' : 'Retry'; const confirmed = await confirmModal({ header: `${actionLabel} neu starten`, message: `${actionLabel} für "${title}" neu starten?`, acceptLabel: 'Neu starten', rejectLabel: 'Abbrechen' }); if (!confirmed) { return; } setActionBusy(true); try { const response = await api.retryJob(row.id); const result = getQueueActionResult(response); const replacementJobId = normalizeJobId(result?.jobId); toastRef.current?.show({ severity: result.queued ? 'info' : 'success', summary: mediaType === 'cd' ? 'CD-Rip neu gestartet' : 'Retry gestartet', detail: result.queued ? 'Job wurde in die Warteschlange eingeplant.' : (replacementJobId ? `Neuer Job #${replacementJobId} wurde erstellt.` : 'Job wurde neu gestartet.'), life: 4000 }); await load(); if (replacementJobId) { const detailResponse = await api.getJob(replacementJobId, { includeLogs: false, forceRefresh: true }); setSelectedJob(detailResponse.job); setDetailVisible(true); } else { await refreshDetailIfOpen(row.id); } } catch (error) { toastRef.current?.show({ severity: 'error', summary: mediaType === 'cd' ? 'CD-Rip Neustart fehlgeschlagen' : 'Retry fehlgeschlagen', detail: error.message, life: 4500 }); } finally { setActionBusy(false); } }; const handleAssignOmdb = (row) => { const jobId = Number(row?.id || 0); if (!jobId) return; const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {}; const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' ? makemkvInfo.analyzeContext : {}; const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' ? analyzeContext.selectedMetadata : (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' ? makemkvInfo.selectedMetadata : {}); const workflowKindRaw = String( selectedMetadata?.workflowKind || analyzeContext?.workflowKind || '' ).trim().toLowerCase(); const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw) ? 'film' : (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null); const isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row); const metadataProvider = workflowKind === 'series' ? 'tmdb' : (workflowKind === 'film' ? 'omdb' : (String(selectedMetadata?.metadataProvider || analyzeContext?.metadataProvider || (isSeriesDvd ? 'tmdb' : 'omdb')).trim().toLowerCase() || 'omdb')); const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null; const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null; const context = { jobId, status: row?.status || null, lastState: row?.last_state || null, submitMode: ( isPipelineMetadataFlowStatus(row?.status) || isPipelineMetadataFlowStatus(row?.last_state) ) ? 'pipeline' : 'assign', detectedTitle: row?.detected_title || row?.title || '', selectedMetadata: { ...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}), title: row?.title || selectedMetadata?.title || row?.detected_title || '', year: row?.year || selectedMetadata?.year || null, imdbId: row?.imdb_id || selectedMetadata?.imdbId || null, poster: row?.poster_url || selectedMetadata?.poster || null, metadataProvider, tmdbId: selectedMetadata?.tmdbId || null, providerId: selectedMetadata?.providerId || null, metadataKind: selectedMetadata?.metadataKind || (isSeriesDvd ? 'season' : null), seasonNumber, seasonName: selectedMetadata?.seasonName || null, episodeCount: selectedMetadata?.episodeCount || 0, episodes: Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : [], ...(discNumber ? { discNumber } : {}) }, metadataProvider, metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [], seriesAnalysis: analyzeContext?.seriesAnalysis || null, seriesLookupHint: analyzeContext?.seriesLookupHint || null, omdbCandidates: [] }; setMetadataDialogContext(context); setMetadataDialogVisible(true); }; const handleOmdbSearch = async (query, options = {}) => { const provider = String( options?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb' ).trim().toLowerCase() || 'omdb'; try { const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber ?? metadataDialogContext?.seriesLookupHint?.seasonNumber ?? null; const response = provider === 'tmdb' ? await api.searchTmdbSeries(query, tmdbSeasonHint) : await api.searchOmdb(query); return response.results || []; } catch (error) { toastRef.current?.show({ severity: 'error', summary: provider === 'tmdb' ? 'TMDb-Suche fehlgeschlagen' : 'OMDb-Suche fehlgeschlagen', detail: error.message }); return []; } }; const handleOmdbSubmit = async (payload) => { const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb'; const submitMode = String(metadataDialogContext?.submitMode || 'assign').trim().toLowerCase() || 'assign'; const isPipelineFlow = submitMode === 'pipeline'; setOmdbAssignBusy(true); try { if (isPipelineFlow) { await api.selectMetadata(payload); setMetadataDialogVisible(false); setMetadataDialogContext(null); try { const startResponse = await api.startJob(payload.jobId); const startResult = getQueueActionResult(startResponse); if (startResult.queued) { toastRef.current?.show({ severity: 'info', summary: 'Job in Queue', detail: startResult.queuePosition > 0 ? `Metadaten übernommen. Start wurde in Queue-Position ${startResult.queuePosition} eingeplant.` : 'Metadaten übernommen. Start wurde in der Queue eingeplant.', life: 3500 }); } else { toastRef.current?.show({ severity: 'success', summary: 'Job gestartet', detail: 'Metadaten übernommen. Die Verarbeitung wurde gestartet.', life: 3200 }); } } catch (startError) { const startMessage = String(startError?.message || '').trim(); const waitingForPlaylist = startMessage.includes('waiting_for_manual_playlist_selection'); toastRef.current?.show({ severity: waitingForPlaylist ? 'warn' : 'error', summary: waitingForPlaylist ? 'Metadaten übernommen' : 'Start fehlgeschlagen', detail: waitingForPlaylist ? 'Metadaten wurden übernommen, aber es fehlt noch eine Titel-/Playlist-Auswahl. Bitte im Ripper fortsetzen.' : (startMessage || 'Job konnte nach der Metadaten-Übernahme nicht gestartet werden.'), life: waitingForPlaylist ? 4200 : 4500 }); } await load(); await refreshDetailIfOpen(payload.jobId); return; } await api.assignJobOmdb(payload.jobId, payload); toastRef.current?.show({ severity: 'success', summary: 'Metadaten zugewiesen', detail: provider === 'tmdb' ? 'TMDb-Metadaten wurden aktualisiert.' : 'OMDb-Metadaten wurden aktualisiert.', life: 3000 }); setMetadataDialogVisible(false); setMetadataDialogContext(null); await load(); await refreshDetailIfOpen(payload.jobId); } catch (error) { if (isPipelineFlow) { throw error; } toastRef.current?.show({ severity: 'error', summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen', detail: error.message }); } finally { setOmdbAssignBusy(false); } }; const handleAssignCdMetadata = (row) => { const jobId = Number(row?.id || 0); if (!jobId) return; const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {}; const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {}; const tracks = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0 ? makemkvInfo.tracks : (Array.isArray(encodePlan?.tracks) ? encodePlan.tracks : []); const context = { jobId, detectedTitle: row?.detected_title || row?.title || '', tracks, selectedMetadata: makemkvInfo?.selectedMetadata || {} }; setCdMetadataDialogContext(context); setCdMetadataDialogVisible(true); }; const handleMusicBrainzSearch = async (query) => { try { const response = await api.searchMusicBrainz(query); return response.results || []; } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Suche fehlgeschlagen', detail: error.message }); return []; } }; const handleMusicBrainzReleaseFetch = async (mbId) => { try { const response = await api.getMusicBrainzRelease(mbId); return response?.release || null; } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Abruf fehlgeschlagen', detail: error.message }); return null; } }; const handleCdMetadataSubmit = async (payload) => { setCdMetadataAssignBusy(true); try { await api.assignJobCdMetadata(payload.jobId, payload); toastRef.current?.show({ severity: 'success', summary: 'Metadaten zugewiesen', detail: 'MusicBrainz-Metadaten wurden aktualisiert.', life: 3000 }); setCdMetadataDialogVisible(false); setCdMetadataDialogContext(null); await load(); await refreshDetailIfOpen(payload.jobId); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Metadaten-Zuweisung fehlgeschlagen', detail: error.message }); } finally { setCdMetadataAssignBusy(false); } }; const handleAcknowledgeError = async (job) => { const jobId = normalizeJobId(job?.id || selectedJob?.id); if (!jobId) { return; } setAcknowledgeErrorBusy(true); try { const response = await api.acknowledgeJobError(jobId); const updated = response?.job || null; if (updated) { setSelectedJob(updated); } await load(); toastRef.current?.show({ severity: 'success', summary: 'Fehler quittiert', detail: `Job #${jobId} wurde quittiert.`, life: 2500 }); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Quittierung fehlgeschlagen', detail: error.message, life: 4500 }); } finally { setAcknowledgeErrorBusy(false); } }; const closeDeleteEntryDialog = () => { if (deleteEntryTargetBusy) { return; } setDeleteEntryDialogVisible(false); setDeleteEntryDialogRow(null); setDeleteEntryPreview(null); setDeleteEntryPreviewLoading(false); setDeleteEntryTargetBusy(null); setDeleteEntrySelectedMoviePaths([]); }; const handleDeleteEntry = async (row) => { const jobId = Number(row?.id || 0); if (!jobId) { return; } setDeleteEntryDialogRow(row); setDeleteEntryPreview(null); setDeleteEntrySelectedMoviePaths([]); setDeleteEntryDialogVisible(true); setDeleteEntryPreviewLoading(true); setDeleteEntryBusy(true); try { const response = await api.getJobDeletePreview(jobId, { includeRelated: true }); const preview = response?.preview || null; setDeleteEntryPreview(preview); const movieCandidates = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : []; const defaultSelectedMoviePaths = movieCandidates .filter((item) => Boolean(item?.exists)) .map((item) => String(item?.path || '').trim()) .filter(Boolean); setDeleteEntrySelectedMoviePaths(defaultSelectedMoviePaths); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Löschvorschau fehlgeschlagen', detail: error.message, life: 4500 }); setDeleteEntryDialogVisible(false); setDeleteEntryDialogRow(null); setDeleteEntryPreview(null); } finally { setDeleteEntryPreviewLoading(false); setDeleteEntryBusy(false); } }; const confirmDeleteEntry = async (target) => { const normalizedTarget = String(target || '').trim().toLowerCase(); if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) { return; } const jobId = Number(deleteEntryDialogRow?.id || 0); if (!jobId) { return; } if ( (normalizedTarget === 'movie' || normalizedTarget === 'both') && previewMovieExisting.length > 0 && deleteEntrySelectedMoviePaths.length === 0 ) { toastRef.current?.show({ severity: 'warn', summary: `${deleteEntryOutputShortLabel}-Ordner auswählen`, detail: `Bitte mindestens einen ${deleteEntryOutputShortLabel}-Ordner zum Löschen auswählen.`, life: 3500 }); return; } setDeleteEntryBusy(true); setDeleteEntryTargetBusy(normalizedTarget); try { const response = await api.deleteJobEntry(jobId, normalizedTarget, { includeRelated: true, selectedMoviePaths: deleteEntrySelectedMoviePaths }); const deletedJobIds = Array.isArray(response?.deletedJobIds) ? response.deletedJobIds : []; const fileSummary = response?.fileSummary || {}; const rawFiles = Number(fileSummary?.raw?.filesDeleted || 0); const movieFiles = Number(fileSummary?.movie?.filesDeleted || 0); const rawDirs = Number(fileSummary?.raw?.dirsRemoved || 0); const movieDirs = Number(fileSummary?.movie?.dirsRemoved || 0); const detail = normalizedTarget === 'none' ? `${deletedJobIds.length || 1} Eintrag/Einträge entfernt (Dateien bleiben erhalten)` : `${deletedJobIds.length || 1} Eintrag/Einträge entfernt | RAW: ${rawFiles} Dateien, ${rawDirs} Ordner | ${deleteEntryOutputShortLabel}: ${movieFiles} Dateien, ${movieDirs} Ordner`; toastRef.current?.show({ severity: 'success', summary: 'Historie gelöscht', detail, life: 5000 }); closeDeleteEntryDialog(); setDetailVisible(false); setSelectedJob(null); await load(); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 5000 }); } finally { setDeleteEntryTargetBusy(null); setDeleteEntryBusy(false); } }; const toggleDeleteMoviePathSelection = (moviePath, checked) => { const normalizedPath = String(moviePath || '').trim(); if (!normalizedPath) { return; } setDeleteEntrySelectedMoviePaths((previous) => { const nextSet = new Set((Array.isArray(previous) ? previous : []).map((item) => String(item || '').trim()).filter(Boolean)); if (checked) { nextSet.add(normalizedPath); } else { nextSet.delete(normalizedPath); } return previewMoviePaths .map((item) => String(item?.path || '').trim()) .filter((itemPath) => itemPath && nextSet.has(itemPath)); }); }; const handleRemoveFromQueue = async (row) => { const jobId = normalizeJobId(row?.id || row); if (!jobId) { return; } setActionBusy(true); try { await api.cancelPipeline(jobId); toastRef.current?.show({ severity: 'success', summary: 'Aus Queue entfernt', detail: `Job #${jobId} wurde aus der Warteschlange entfernt.`, life: 3200 }); await load(); await refreshDetailIfOpen(jobId); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Queue-Entfernung fehlgeschlagen', detail: error.message, life: 4500 }); } finally { setActionBusy(false); } }; const renderStatusTag = (row) => { const normalizedStatus = normalizeStatus(row?.status); const rowId = normalizeJobId(row?.id); const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId)); return ( ); }; const renderPoster = (row, className = 'history-dv-poster') => { const mediaMeta = resolveMediaTypeMeta(row); const isAudioSquare = ['cd', 'audiobook'].includes(mediaMeta.mediaType); const audioClass = className === 'history-dv-poster-grid' ? 'history-dv-poster-grid-audio' : 'history-dv-poster-audio'; const imgClass = isAudioSquare ? audioClass : className; const title = row?.title || row?.detected_title || 'Poster'; if (row?.poster_url && row.poster_url !== 'N/A') { return {title}; } return
{isAudioSquare ? 'Kein Cover' : 'Kein Poster'}
; }; const renderPresenceChip = (label, available, tone = null, suffix = '') => { const resolvedTone = tone || (available ? 'tone-ok' : 'tone-no'); const iconClass = available ? 'pi-check-circle' : 'pi-times-circle'; return (