import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; 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 { InputText } from 'primereact/inputtext'; import { api } from '../api/client'; import { useWebSocket } from '../hooks/useWebSocket'; import PipelineStatusCard from '../components/PipelineStatusCard'; import MetadataSelectionDialog from '../components/MetadataSelectionDialog'; import CdMetadataDialog from '../components/CdMetadataDialog'; import CdRipConfigPanel from '../components/CdRipConfigPanel'; import AudiobookConfigPanel from '../components/AudiobookConfigPanel'; import blurayIndicatorIcon from '../assets/media-bluray.svg'; import discIndicatorIcon from '../assets/media-disc.svg'; import otherIndicatorIcon from '../assets/media-other.svg'; import audiobookIndicatorIcon from '../assets/media-audiobook.svg'; import mergeIndicatorIcon from '../assets/media-merge.svg'; import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation'; import { confirmModal } from '../utils/confirmModal'; import { isConverterJob, isSeriesVideoJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy'; const processingStates = ['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']; const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING']; const ripperStatuses = new Set([ 'ANALYZING', 'METADATA_LOOKUP', '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' ]); const hiddenCancelledReviewOrigins = new Set([ 'READY_TO_START', 'READY_TO_ENCODE', 'METADATA_LOOKUP', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP' ]); function normalizeJobId(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function hasSeriesWorkflowHints(source) { const value = source && typeof source === 'object' ? source : {}; const seasonNumber = Number( value?.seasonNumber ?? value?.seriesLookupHint?.seasonNumber ?? value?.selectedMetadata?.seasonNumber ?? null ); const episodeCount = Number( value?.episodeCount ?? value?.selectedMetadata?.episodeCount ?? 0 ); const directEpisodes = Array.isArray(value?.episodes) ? value.episodes : []; const nestedEpisodes = Array.isArray(value?.selectedMetadata?.episodes) ? value.selectedMetadata.episodes : []; return Boolean( (Number.isFinite(seasonNumber) && seasonNumber > 0) || (Number.isFinite(episodeCount) && episodeCount > 0) || directEpisodes.length > 0 || nestedEpisodes.length > 0 || (value?.seriesAnalysis && typeof value.seriesAnalysis === 'object') ); } function isCdMetadataContext(context) { const rawContext = context && typeof context === 'object' ? context : {}; const state = String(rawContext?.state || rawContext?.lastState || '').trim().toUpperCase(); if (state.startsWith('CD_')) { return true; } const stage = String(rawContext?.stage || '').trim().toUpperCase(); if (stage.startsWith('CD_')) { return true; } const mediaProfile = String(rawContext?.mediaProfile || rawContext?.selectedMetadata?.mediaProfile || '').trim().toLowerCase(); if (mediaProfile === 'cd' || mediaProfile === 'audio_cd' || mediaProfile === 'audio cd') { return true; } // Strong negative signals: never route known non-CD jobs into MusicBrainz. if (['bluray', 'dvd', 'audiobook', 'converter', 'other'].includes(mediaProfile)) { return false; } if (String(rawContext?.cdparanoiaCmd || rawContext?.cdparanoiaCommandPreview || '').trim()) { return true; } const hasMusicBrainzSignals = Boolean( String( rawContext?.selectedMetadata?.mbId || rawContext?.selectedMetadata?.musicBrainzId || rawContext?.selectedMetadata?.musicbrainzId || rawContext?.selectedMetadata?.mbid || '' ).trim() ); if (hasMusicBrainzSignals) { return true; } return false; } function isCdAnalyzeResponsePayload(payload) { const result = payload && typeof payload === 'object' ? payload : {}; if (isCdMetadataContext(result)) { return true; } const mediaProfile = String(result?.mediaProfile || result?.selectedMetadata?.mediaProfile || '').trim().toLowerCase(); const hasVideoSignals = ['bluray', 'dvd'].includes(mediaProfile) || hasSeriesWorkflowHints(result) || Boolean( String( result?.selectedMetadata?.imdbId || result?.selectedMetadata?.tmdbId || result?.selectedMetadata?.providerId || result?.imdbId || result?.tmdbId || '' ).trim() ); if (hasVideoSignals) { return false; } if (Array.isArray(result?.tracks)) { const trackList = result.tracks.filter((track) => track && typeof track === 'object'); const looksLikeCdTrackList = trackList.length > 0 && trackList.every((track) => { const position = Number(track?.position); return Number.isFinite(position) && position > 0; }); if (looksLikeCdTrackList) { return true; } } const status = String(result?.status || result?.state || result?.lastState || '').trim().toUpperCase(); if (status.startsWith('CD_')) { return true; } return false; } function normalizeDrivePath(value) { return String(value || '').trim(); } function getPathBaseName(value) { const normalized = String(value || '').trim().replace(/[\\]+/g, '/'); if (!normalized) { return ''; } const segments = normalized.split('/').filter(Boolean); return segments[segments.length - 1] || ''; } function isIncompleteRawPath(rawPath) { const baseName = getPathBaseName(rawPath); return /^incomplete_/i.test(baseName); } function normalizePathForMatch(value) { return String(value || '').trim().replace(/[\\]+/g, '/'); } function isIncompleteOutputPath(outputPath) { const normalized = normalizePathForMatch(outputPath); if (!normalized) { return false; } return /(^|\/)incomplete_job-\d+(\/|$)/i.test(normalized) || /(^|\/)incomplete_merge_[^/]+_job_\d+(\/|$)/i.test(normalized); } function isIncompleteMergeOutputPath(outputPath) { const normalized = normalizePathForMatch(outputPath); if (!normalized) { return false; } return /(^|\/)incomplete_merge_[^/]+_job_\d+(\/|$)/i.test(normalized); } function getIncompleteOutputSelectionPaths(outputPath) { const normalized = normalizePathForMatch(outputPath); if (!normalized) { return []; } const segments = normalized.split('/').filter(Boolean); const incompleteJobIndex = segments.findIndex((segment) => /^incomplete_job-\d+$/i.test(segment)); if (incompleteJobIndex >= 0) { const folderPath = `/${segments.slice(0, incompleteJobIndex + 1).join('/')}`; return Array.from(new Set([normalized, folderPath])); } const incompleteMergeIndex = segments.findIndex((segment) => /^incomplete_merge_[^/]+_job_\d+$/i.test(segment)); if (incompleteMergeIndex >= 0) { const mergeFolderPath = `/${segments.slice(0, incompleteMergeIndex + 1).join('/')}`; const normalizedLooksLikeFolder = incompleteMergeIndex === (segments.length - 1); return normalizedLooksLikeFolder ? [mergeFolderPath] : [normalized]; } return []; } function formatPercent(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return 'n/a'; } return `${Math.trunc(parsed)}%`; } function isPlaylistTrackPreparationStatus(value) { const normalized = String(value || '').trim().toUpperCase(); if (!normalized) { return false; } return normalized.includes('PLAYLIST') && normalized.includes('TRACKDATEN') && normalized.includes('VORBEREIT'); } function isMediainfoCheckStatus(value) { return String(value || '').trim().toUpperCase() === 'MEDIAINFO_CHECK'; } 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 formatBytesCompact(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 0) { return 'n/a'; } if (parsed === 0) { return '0B'; } 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; } return `${Math.round(current)}${units[unitIndex]}`; } function formatUsageCompact(usedValue, totalValue) { return `${formatBytesCompact(usedValue)}/${formatBytesCompact(totalValue)}`; } 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, outputTruncated: Boolean(source.outputTruncated), 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, chainId: Number.isFinite(Number(source.chainId)) && Number(source.chainId) > 0 ? Math.trunc(Number(source.chainId)) : null, scriptId: Number.isFinite(Number(source.scriptId)) && Number(source.scriptId) > 0 ? Math.trunc(Number(source.scriptId)) : null, canCancel: Boolean(source.canCancel), canNextStep: Boolean(source.canNextStep), parentActivityId: Number.isFinite(Number(source.parentActivityId)) && Number(source.parentActivityId) > 0 ? Math.trunc(Number(source.parentActivityId)) : null, stepIndex: Number.isFinite(Number(source.stepIndex)) ? Math.trunc(Number(source.stepIndex)) : null, stepTotal: Number.isFinite(Number(source.stepTotal)) && Number(source.stepTotal) > 0 ? Math.trunc(Number(source.stepTotal)) : null }; }; 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 driveStateIconMeta(stateLabel) { const normalized = String(stateLabel || '').trim().toUpperCase(); if (!normalized || normalized === 'LEER' || normalized === 'IDLE') { return { icon: 'pi-stop', spin: false }; } if (normalized === 'DISC_DETECTED') { return { icon: 'pi-check', spin: false }; } if (normalized === 'FINISHED') { return { icon: 'pi-check-circle', spin: false }; } if (normalized === 'ERROR' || normalized === 'CANCELLED') { return { icon: 'pi-times-circle', spin: false }; } if (processingStates.includes(normalized)) { return { icon: 'pi-spinner', spin: true }; } if ( normalized === 'METADATA_SELECTION' || normalized === 'CD_METADATA_SELECTION' || normalized === 'WAITING_FOR_USER_DECISION' ) { return { icon: 'pi-list', spin: false }; } if ( normalized === 'READY_TO_START' || normalized === 'READY_TO_ENCODE' || normalized === 'CD_READY_TO_RIP' ) { return { icon: 'pi-play', spin: false }; } return { icon: 'pi-question-circle', spin: false }; } 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 hasRuntimeLogContent(item) { if (!item || typeof item !== 'object') { return false; } return Boolean( String(item.output || '').trim() || String(item.stdout || '').trim() || String(item.stderr || '').trim() ); } function RuntimeActivityDetails({ item, summary, emptyLabel = 'Noch keine Log-Ausgabe vorhanden.' }) { const hasLogs = hasRuntimeLogContent(item); const hasOutput = Boolean(String(item?.output || '').trim()); const hasStdout = Boolean(String(item?.stdout || '').trim()); const hasStderr = Boolean(String(item?.stderr || '').trim()); return (
{summary} {!hasLogs ? {emptyLabel} : null} {hasOutput ? (
Ausgabe:{item?.outputTruncated ? ' (gekürzt)' : ''}
{item.output}
) : null} {hasStdout ? (
stdout:{item?.stdoutTruncated ? ' (gekürzt)' : ''}
{item.stdout}
) : null} {hasStderr ? (
stderr:{item?.stderrTruncated ? ' (gekürzt)' : ''}
{item.stderr}
) : null}
); } 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 idleJobs = Array.isArray(payload.idleJobs) ? payload.idleJobs : []; 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, idleCount: Number(payload.idleCount || idleJobs.length || 0), idleJobs, queuedJobs, queuedCount: Number(payload.queuedCount || queuedJobs.length || 0), updatedAt: payload.updatedAt || null }; } function buildCdDriveLifecycleKey(cdDrives) { if (!cdDrives || typeof cdDrives !== 'object') { return ''; } const entries = Object.entries(cdDrives) .map(([devicePath, driveState]) => { const state = String(driveState?.state || '').trim().toUpperCase(); const jobId = normalizeJobId(driveState?.jobId || driveState?.context?.jobId) || 0; return `${devicePath}|${jobId}|${state}`; }) .sort(); return entries.join('::'); } 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 isGenericChainQueueLabel(value, chainId = null) { const text = String(value || '').trim(); if (!text) { return true; } const compact = text.toLowerCase().replace(/\s+/g, ' '); if (/^kette\s*#?\s*\d+$/.test(compact)) { return true; } const normalizedChainId = Number(chainId); if (Number.isFinite(normalizedChainId) && normalizedChainId > 0) { const idToken = String(Math.trunc(normalizedChainId)); if (compact === `kette ${idToken}` || compact === `kette #${idToken}`) { return true; } } return false; } function resolveChainQueueSubtitle(item, chainNameById = new Map()) { const chainIdRaw = Number(item?.chainId); const chainId = Number.isFinite(chainIdRaw) && chainIdRaw > 0 ? Math.trunc(chainIdRaw) : null; const explicitChainName = String(item?.chainName || '').trim(); if (explicitChainName) { return explicitChainName; } const title = String(item?.title || '').trim(); if (title && !isGenericChainQueueLabel(title, chainId)) { return title; } if (chainId && chainNameById instanceof Map) { const mappedName = String(chainNameById.get(chainId) || '').trim(); if (mappedName) { return mappedName; } } return title || null; } function parseSeriesQueueEpisodeText(value) { const text = String(value || '').trim(); if (!text) { return null; } const compactMatch = text.match(/\bS\d{1,3}E\d{1,3}(?:-\d{1,3})?\b/i); const spacedMatch = text.match(/\bS\s*(\d{1,3})\s*E\s*(\d{1,3})(?:\s*-\s*(\d{1,3}))?\b/i); const xStyleMatch = text.match(/\b(\d{1,3})\s*[xX]\s*(\d{1,3})(?:\s*-\s*(\d{1,3}))?\b/); const codeMatch = compactMatch || spacedMatch || xStyleMatch; if (!codeMatch) { return null; } let code = ''; if (compactMatch) { code = String(compactMatch[0] || '').trim().toUpperCase().replace(/\s+/g, ''); } else { const season = Number(codeMatch[1]); const episodeStart = Number(codeMatch[2]); const episodeEnd = Number(codeMatch[3]); const seasonToken = Number.isFinite(season) ? String(Math.trunc(season)).padStart(2, '0') : '??'; const episodeStartToken = Number.isFinite(episodeStart) ? String(Math.trunc(episodeStart)).padStart(2, '0') : '??'; const episodeEndToken = Number.isFinite(episodeEnd) ? String(Math.trunc(episodeEnd)).padStart(2, '0') : null; code = `S${seasonToken}E${episodeStartToken}${episodeEndToken ? `-${episodeEndToken}` : ''}`; } const index = Number(codeMatch.index || 0); const prefix = text.slice(0, index).trim().replace(/[-|:–—]+\s*$/u, '').trim(); const matchedRaw = String(codeMatch[0] || ''); const trailing = text.slice(index + matchedRaw.length).trim(); const episodeTitle = trailing.replace(/^[-|:–—]+\s*/u, '').trim() || null; return { code, prefix: prefix || null, episodeTitle }; } function resolveSeriesQueueDisplay(item) { const titleRaw = String(item?.title || '').trim(); const subtitleRaw = String(item?.subtitle || '').trim(); const titleParsed = parseSeriesQueueEpisodeText(titleRaw); const subtitleParsed = parseSeriesQueueEpisodeText(subtitleRaw); const parsed = subtitleParsed || titleParsed; if (!parsed) { return { isSeriesEpisode: false, title: titleRaw || `Job #${item?.jobId || '-'}`, subtitle: subtitleRaw || null }; } let baseTitle = titleRaw; if (titleParsed?.prefix) { baseTitle = titleParsed.prefix; } else if ( subtitleParsed?.prefix && (!baseTitle || baseTitle === subtitleRaw || /\bS\d{1,3}E\d{1,3}/i.test(baseTitle)) ) { baseTitle = subtitleParsed.prefix; } const resolvedTitle = String(baseTitle || '').trim() || `Job #${item?.jobId || '-'}`; const episodeSubtitle = parsed.episodeTitle ? `${parsed.code} - ${parsed.episodeTitle}` : parsed.code; return { isSeriesEpisode: true, title: resolvedTitle, subtitle: episodeSubtitle }; } 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 getEncodePlan(job) { if (job?.encodePlan && typeof job.encodePlan === 'object') { return job.encodePlan; } if (job?.encode_plan_json && typeof job.encode_plan_json === 'object') { return job.encode_plan_json; } const rawPlan = String(job?.encode_plan_json || '').trim(); if (!rawPlan) { return null; } try { const parsed = JSON.parse(rawPlan); return parsed && typeof parsed === 'object' ? parsed : null; } catch (_error) { return null; } } function resolveJobKindRaw(job) { return String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); } function isMultipartMergeJob(job) { return resolveJobKindRaw(job) === 'multipart_movie_merge'; } function getMergeAwareStatusLabel(job, status, options = {}) { const queued = Boolean(options?.queued); const normalizedStatus = normalizeStatus(status); if (!queued && isMultipartMergeJob(job) && normalizedStatus === 'ENCODING') { return 'Merging'; } return getStatusLabel(status, options); } function extractMultipartMergeSources(job) { const encodePlan = getEncodePlan(job) || {}; const sourceItems = Array.isArray(encodePlan?.sourceItems) ? encodePlan.sourceItems : []; const normalized = sourceItems .map((item) => { const sourceJobId = normalizeJobId(item?.jobId || item?.sourceJobId); const discNumberRaw = Number(item?.discNumber); const discNumber = Number.isFinite(discNumberRaw) && discNumberRaw > 0 ? Math.trunc(discNumberRaw) : null; const outputPath = String(item?.outputPath || item?.path || '').trim(); if (!sourceJobId || !outputPath) { return null; } return { sourceJobId, discNumber, outputPath, fileName: getPathBaseName(outputPath) }; }) .filter(Boolean); if (normalized.length === 0) { return []; } const orderedIds = Array.isArray(encodePlan?.orderedSourceJobIds) ? encodePlan.orderedSourceJobIds.map((value) => normalizeJobId(value)).filter(Boolean) : []; const byId = new Map(normalized.map((item) => [item.sourceJobId, item])); const ordered = []; const seen = new Set(); for (const sourceJobId of orderedIds) { const item = byId.get(sourceJobId); if (!item || seen.has(sourceJobId)) { continue; } seen.add(sourceJobId); ordered.push(item); } for (const item of normalized) { if (seen.has(item.sourceJobId)) { continue; } seen.add(item.sourceJobId); ordered.push(item); } return ordered; } function shouldDeleteMultipartMergeInputsAfterSuccess(job) { const encodePlan = getEncodePlan(job) || {}; return Boolean(encodePlan?.deleteInputsAfterMerge); } function formatMergeTrackLanguage(language) { const normalized = String(language || '').trim().toLowerCase(); if (!normalized || normalized === 'und') { return 'und'; } return normalized; } function formatMergeTrackSummary(track = {}, fallbackLabel = 'Track') { const source = track && typeof track === 'object' ? track : {}; const orderRaw = Number(source.order); const order = Number.isFinite(orderRaw) && orderRaw > 0 ? Math.trunc(orderRaw) : null; const language = formatMergeTrackLanguage(source.language); const codec = String(source.codec || '?').trim() || '?'; const title = String(source.title || '').trim(); const channelsRaw = Number(source.channels); const channelInfo = Number.isFinite(channelsRaw) && channelsRaw > 0 ? `${Math.trunc(channelsRaw)}ch` : null; const flags = []; if (source.default === true) { flags.push('default'); } if (source.forced === true) { flags.push('forced'); } const parts = [ order ? `#${order}` : fallbackLabel, language, codec ]; if (channelInfo) { parts.push(channelInfo); } if (title) { parts.push(title); } if (flags.length > 0) { parts.push(`[${flags.join(', ')}]`); } return parts.join(' | '); } function resolveMediaType(job) { const centralMediaType = resolveCentralMediaType(job); if (centralMediaType && centralMediaType !== 'other') { return centralMediaType; } 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'; } if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) { return 'audiobook'; } } 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'; } if (['audiobook_encode', 'audiobook_encode_split'].includes(String(job?.handbrakeInfo?.mode || '').trim().toLowerCase())) { return 'audiobook'; } if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') { return 'audiobook'; } return centralMediaType || 'other'; } function mediaIndicatorMeta(job) { if (isMultipartMergeJob(job)) { return { mediaType: 'multipart_merge', src: mergeIndicatorIcon, alt: 'Merge Job', title: 'Multipart Merge Job' }; } const mediaType = resolveMediaType(job); const isSeriesVideo = (mediaType === 'dvd' || mediaType === 'bluray') && isSeriesVideoJob(job); if (mediaType === 'bluray') { return { mediaType, src: blurayIndicatorIcon, alt: isSeriesVideo ? 'Blu-ray Serie' : 'Blu-ray', title: isSeriesVideo ? 'Blu-ray Serie' : 'Blu-ray', isSeriesDvd: isSeriesVideo, isSeriesVideo }; } if (mediaType === 'dvd') { return { mediaType, src: discIndicatorIcon, alt: isSeriesVideo ? 'DVD Serie' : 'DVD', title: isSeriesVideo ? 'DVD Serie' : 'DVD', isSeriesDvd: isSeriesVideo, isSeriesVideo }; } if (mediaType === 'cd') { return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' }; } if (mediaType === 'audiobook') { return { mediaType, src: audiobookIndicatorIcon, alt: 'Audiobook', title: 'Audiobook' }; } return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' }; } function normalizeDiscWorkflowKind(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw) { return null; } if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) { return 'series'; } if (['film', 'movie', 'feature'].includes(raw)) { return 'film'; } return null; } function resolveDiscWorkflowKind({ mediaType, selectedMetadata = null, analyzeContext = null, pipelineContext = null, fallbackIsSeries = false } = {}) { if (mediaType !== 'dvd' && mediaType !== 'bluray') { return null; } const selected = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}; const analyze = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {}; const pipelineCtx = pipelineContext && typeof pipelineContext === 'object' ? pipelineContext : {}; const workflowCandidates = [ selected?.workflowKind, selected?.kind, analyze?.workflowKind, pipelineCtx?.workflowKind ]; for (const candidate of workflowCandidates) { const normalized = normalizeDiscWorkflowKind(candidate); if (normalized) { return normalized; } } const metadataKindCandidates = [ selected?.metadataKind, analyze?.metadataKind ]; for (const candidate of metadataKindCandidates) { const normalized = normalizeDiscWorkflowKind(candidate); if (normalized) { return normalized; } } if ( hasSeriesWorkflowHints(selected) || hasSeriesWorkflowHints(analyze) || hasSeriesWorkflowHints(pipelineCtx) ) { return 'series'; } return null; } function getDiscWorkflowBadgeLabel(mediaType, workflowKind) { if (mediaType !== 'dvd' && mediaType !== 'bluray') { return null; } const kind = normalizeDiscWorkflowKind(workflowKind); if (!kind) { return null; } if (mediaType === 'bluray') { return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film'; } return kind === 'series' ? 'DVD-Serie' : 'DVD-Film'; } function resolveSeriesEpisodeJobIds(job, seriesBatchChildren = []) { const ids = []; const seen = new Set(); const pushId = (value) => { const normalized = normalizeJobId(value); if (!normalized) { return; } const key = String(normalized); if (seen.has(key)) { return; } seen.add(key); ids.push(normalized); }; const plan = getEncodePlan(job) || {}; const planChildJobIds = Array.isArray(plan?.seriesBatchChildJobIds) ? plan.seriesBatchChildJobIds : []; for (const childId of planChildJobIds) { pushId(childId); } const batchChildren = Array.isArray(seriesBatchChildren) ? seriesBatchChildren : []; for (const child of batchChildren) { pushId(child?.jobId); } const containerChildren = Array.isArray(job?.children) ? job.children : []; for (const child of containerChildren) { const childId = normalizeJobId(child?.id); if (!childId) { continue; } const childPlan = getEncodePlan(child) || {}; const looksLikeSeriesEpisodeJob = Boolean( childPlan?.seriesBatchChild || childPlan?.seriesBatchVirtualEpisode || normalizeJobId(childPlan?.seriesBatchParentJobId) || normalizeJobId(childPlan?.seriesBatchTitleId) || normalizeJobId(childPlan?.seriesBatchChildIndex) ); if (looksLikeSeriesEpisodeJob) { pushId(childId); } } return ids.sort((left, right) => left - right); } function JobStepChecks({ backupSuccess, encodeSuccess }) { const hasAny = Boolean(backupSuccess || encodeSuccess); if (!hasAny) { return null; } return (
{backupSuccess ? (