import { useEffect, useMemo, useRef, useState } from 'react'; import { Toast } from 'primereact/toast'; import { Card } from 'primereact/card'; import { Button } from 'primereact/button'; import { Tag } from 'primereact/tag'; import { ProgressBar } from 'primereact/progressbar'; import { Dialog } from 'primereact/dialog'; import { InputNumber } from 'primereact/inputnumber'; import { api } from '../api/client'; import PipelineStatusCard from '../components/PipelineStatusCard'; import MetadataSelectionDialog from '../components/MetadataSelectionDialog'; import CdMetadataDialog from '../components/CdMetadataDialog'; import CdRipConfigPanel from '../components/CdRipConfigPanel'; 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 } from '../utils/statusPresentation'; const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']; const dashboardStatuses = new Set([ 'ANALYZING', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'READY_TO_START', 'MEDIAINFO_CHECK', 'READY_TO_ENCODE', 'RIPPING', 'ENCODING', 'CANCELLED', 'ERROR', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING' ]); function normalizeJobId(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function formatPercent(value, digits = 1) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return 'n/a'; } return `${parsed.toFixed(digits)}%`; } function formatTemperature(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return 'n/a'; } return `${parsed.toFixed(1)}°C`; } function formatBytes(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 0) { return 'n/a'; } if (parsed === 0) { return '0 B'; } const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let unitIndex = 0; let current = parsed; while (current >= 1024 && unitIndex < units.length - 1) { current /= 1024; unitIndex += 1; } const digits = unitIndex <= 1 ? 0 : 2; return `${current.toFixed(digits)} ${units[unitIndex]}`; } function formatUpdatedAt(value) { if (!value) { return '-'; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return '-'; } return date.toLocaleString('de-DE'); } function formatDurationMs(value) { const ms = Number(value); if (!Number.isFinite(ms) || ms < 0) { return '-'; } if (ms < 1000) { return `${Math.round(ms)} ms`; } const seconds = Math.round(ms / 1000); if (seconds < 60) { return `${seconds}s`; } const minutes = Math.floor(seconds / 60); const restSeconds = seconds % 60; return `${minutes}m ${restSeconds}s`; } function normalizeRuntimeActivitiesPayload(rawPayload) { const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {}; const normalizeItem = (item) => { const source = item && typeof item === 'object' ? item : {}; const parsedId = Number(source.id); const id = Number.isFinite(parsedId) && parsedId > 0 ? Math.trunc(parsedId) : null; return { id, type: String(source.type || '').trim().toLowerCase() || 'task', name: String(source.name || '').trim() || '-', status: String(source.status || '').trim().toLowerCase() || 'running', outcome: String(source.outcome || '').trim().toLowerCase() || null, source: String(source.source || '').trim() || null, message: String(source.message || '').trim() || null, errorMessage: String(source.errorMessage || '').trim() || null, currentStep: String(source.currentStep || '').trim() || null, currentScriptName: String(source.currentScriptName || '').trim() || null, output: source.output != null ? String(source.output) : null, stdout: source.stdout != null ? String(source.stdout) : null, stderr: source.stderr != null ? String(source.stderr) : null, stdoutTruncated: Boolean(source.stdoutTruncated), stderrTruncated: Boolean(source.stderrTruncated), exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null, startedAt: source.startedAt || null, finishedAt: source.finishedAt || null, durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null, jobId: Number.isFinite(Number(source.jobId)) && Number(source.jobId) > 0 ? Math.trunc(Number(source.jobId)) : null, cronJobId: Number.isFinite(Number(source.cronJobId)) && Number(source.cronJobId) > 0 ? Math.trunc(Number(source.cronJobId)) : null, canCancel: Boolean(source.canCancel), canNextStep: Boolean(source.canNextStep) }; }; const active = (Array.isArray(payload.active) ? payload.active : []).map(normalizeItem); const recent = (Array.isArray(payload.recent) ? payload.recent : []).map(normalizeItem); return { active, recent, updatedAt: payload.updatedAt || null }; } function runtimeTypeLabel(type) { const normalized = String(type || '').trim().toLowerCase(); if (normalized === 'script') return 'Skript'; if (normalized === 'chain') return 'Kette'; if (normalized === 'cron') return 'Cronjob'; return normalized || 'Task'; } function runtimeStatusMeta(status) { const normalized = String(status || '').trim().toLowerCase(); if (normalized === 'running') return { label: 'Läuft', severity: 'warning' }; if (normalized === 'success') return { label: 'Abgeschlossen', severity: 'success' }; if (normalized === 'error') return { label: 'Fehler', severity: 'danger' }; return { label: normalized || '-', severity: 'secondary' }; } function runtimeOutcomeMeta(outcome, status) { const normalized = String(outcome || '').trim().toLowerCase(); if (normalized === 'success') return { label: 'Erfolg', severity: 'success' }; if (normalized === 'error') return { label: 'Fehler', severity: 'danger' }; if (normalized === 'cancelled') return { label: 'Abgebrochen', severity: 'warning' }; if (normalized === 'skipped') return { label: 'Übersprungen', severity: 'info' }; return runtimeStatusMeta(status); } function hasRuntimeOutputDetails(item) { if (!item || typeof item !== 'object') { return false; } const hasRelevantExitCode = Number.isFinite(Number(item.exitCode)) && Number(item.exitCode) !== 0; return Boolean( String(item.errorMessage || '').trim() || String(item.output || '').trim() || String(item.stdout || '').trim() || String(item.stderr || '').trim() || hasRelevantExitCode ); } function normalizeHardwareMonitoringPayload(rawPayload) { const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {}; return { enabled: Boolean(payload.enabled), intervalMs: Number(payload.intervalMs || 0), updatedAt: payload.updatedAt || null, sample: payload.sample && typeof payload.sample === 'object' ? payload.sample : null, error: payload.error ? String(payload.error) : null }; } function getStorageUsageTone(usagePercent) { const value = Number(usagePercent); if (!Number.isFinite(value)) { return 'unknown'; } if (value >= 95) { return 'critical'; } if (value >= 85) { return 'high'; } if (value >= 70) { return 'warn'; } return 'ok'; } function normalizeQueue(queue) { const payload = queue && typeof queue === 'object' ? queue : {}; const runningJobs = Array.isArray(payload.runningJobs) ? payload.runningJobs : []; const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : []; return { maxParallelJobs: Number(payload.maxParallelJobs || 1), maxParallelCdEncodes: Number(payload.maxParallelCdEncodes || 2), maxTotalEncodes: Number(payload.maxTotalEncodes || 3), cdBypassesQueue: Boolean(payload.cdBypassesQueue), runningCount: Number(payload.runningCount || runningJobs.length || 0), runningCdCount: Number(payload.runningCdCount || 0), runningJobs, queuedJobs, queuedCount: Number(payload.queuedCount || queuedJobs.length || 0), updatedAt: payload.updatedAt || null }; } function getQueueActionResult(response) { return response?.result && typeof response.result === 'object' ? response.result : {}; } function showQueuedToast(toastRef, actionLabel, result) { if (!toastRef?.current) { return; } const queuePosition = Number(result?.queuePosition || 0); const positionText = queuePosition > 0 ? `Position ${queuePosition}` : 'in der Warteschlange'; toastRef.current.show({ severity: 'info', summary: `${actionLabel} in Queue`, detail: `${actionLabel} wurde ${positionText} eingeplant.`, life: 3200 }); } function reorderQueuedItems(items, draggedEntryId, targetEntryId) { const list = Array.isArray(items) ? items : []; const from = list.findIndex((item) => Number(item?.entryId) === Number(draggedEntryId)); const to = list.findIndex((item) => Number(item?.entryId) === Number(targetEntryId)); if (from < 0 || to < 0 || from === to) { return list; } const next = [...list]; const [moved] = next.splice(from, 1); next.splice(to, 0, moved); return next.map((item, index) => ({ ...item, position: index + 1 })); } function queueEntryIcon(type) { if (type === 'script') return 'pi pi-code'; if (type === 'chain') return 'pi pi-link'; if (type === 'wait') return 'pi pi-clock'; return 'pi pi-box'; } function queueEntryLabel(item) { if (item.type === 'script') return `Skript: ${item.title}`; if (item.type === 'chain') return `Kette: ${item.title}`; if (item.type === 'wait') return `Warten: ${item.waitSeconds}s`; return item.title || `Job #${item.jobId}`; } function normalizeQueueNameList(values) { const list = Array.isArray(values) ? values : []; const seen = new Set(); const output = []; for (const item of list) { const name = String(item || '').trim(); if (!name) { continue; } const key = name.toLowerCase(); if (seen.has(key)) { continue; } seen.add(key); output.push(name); } return output; } function normalizeQueueScriptSummary(item) { const source = item?.scriptSummary && typeof item.scriptSummary === 'object' ? item.scriptSummary : {}; return { preScripts: normalizeQueueNameList(source.preScripts), postScripts: normalizeQueueNameList(source.postScripts), preChains: normalizeQueueNameList(source.preChains), postChains: normalizeQueueNameList(source.postChains) }; } function hasQueueScriptSummary(item) { const summary = normalizeQueueScriptSummary(item); return summary.preScripts.length > 0 || summary.postScripts.length > 0 || summary.preChains.length > 0 || summary.postChains.length > 0; } function QueueJobScriptSummary({ item }) { const summary = normalizeQueueScriptSummary(item); const groups = [ { key: 'pre-scripts', icon: 'pi pi-code', label: 'Pre-Encode Skripte', values: summary.preScripts }, { key: 'post-scripts', icon: 'pi pi-code', label: 'Post-Encode Skripte', values: summary.postScripts }, { key: 'pre-chains', icon: 'pi pi-link', label: 'Pre-Encode Ketten', values: summary.preChains }, { key: 'post-chains', icon: 'pi pi-link', label: 'Post-Encode Ketten', values: summary.postChains } ].filter((group) => group.values.length > 0); if (groups.length === 0) { return null; } return (
{groups.map((group) => { const text = group.values.join(' | '); return (
{group.label} {text}
); })}
); } function getAnalyzeContext(job) { return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object' ? job.makemkvInfo.analyzeContext : {}; } function resolveMediaType(job) { const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null; const candidates = [ job?.mediaType, job?.media_type, job?.mediaProfile, job?.media_profile, encodePlan?.mediaProfile, job?.makemkvInfo?.analyzeContext?.mediaProfile, job?.makemkvInfo?.mediaProfile, job?.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'; } } const statusCandidates = [ job?.status, job?.last_state, job?.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(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') { return 'cd'; } if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) { return 'cd'; } return 'other'; } function mediaIndicatorMeta(job) { const mediaType = resolveMediaType(job); if (mediaType === 'bluray') { return { mediaType, src: blurayIndicatorIcon, alt: 'Blu-ray', title: 'Blu-ray' }; } if (mediaType === 'dvd') { return { mediaType, src: discIndicatorIcon, alt: 'DVD', title: 'DVD' }; } if (mediaType === 'cd') { return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' }; } return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' }; } function JobStepChecks({ backupSuccess, encodeSuccess }) { const hasAny = Boolean(backupSuccess || encodeSuccess); if (!hasAny) { return null; } return (
{backupSuccess ? (