Files
ripster/frontend/src/components/JobDetailDialog.jsx
T
2026-03-22 14:43:10 +00:00

1453 lines
63 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation';
const CD_FORMAT_LABELS = {
flac: 'FLAC',
wav: 'WAV',
mp3: 'MP3',
opus: 'Opus',
ogg: 'Ogg Vorbis'
};
function JsonView({ title, value }) {
return (
<div>
<h4>{title}</h4>
<pre className="json-box">{value ? JSON.stringify(value, null, 2) : '-'}</pre>
</div>
);
}
function ScriptResultRow({ result }) {
const statusCode = String(result?.status || '').toUpperCase();
const status = getProcessStatusLabel(statusCode);
const isSuccess = statusCode === 'SUCCESS';
const isError = statusCode === 'ERROR';
const icon = isSuccess ? 'pi-check-circle' : isError ? 'pi-times-circle' : 'pi-minus-circle';
const tone = isSuccess ? 'success' : isError ? 'danger' : 'warning';
return (
<div className="script-result-row">
<span className={`job-step-inline-${isSuccess ? 'ok' : isError ? 'no' : 'warn'}`}>
<i className={`pi ${icon}`} aria-hidden="true" />
</span>
<span className="script-result-name">{result?.scriptName || result?.chainName || `#${result?.scriptId ?? result?.chainId ?? '?'}`}</span>
<span className={`script-result-status tone-${tone}`}>{status}</span>
{result?.error ? <span className="script-result-error">{result.error}</span> : null}
</div>
);
}
function ScriptSummarySection({ title, summary }) {
if (!summary || summary.configured === 0) return null;
const results = Array.isArray(summary.results) ? summary.results : [];
return (
<div className="script-summary-block">
<strong>{title}:</strong>
<span className="script-summary-counts">
{summary.succeeded > 0 ? <span className="tone-success">{summary.succeeded} OK</span> : null}
{summary.failed > 0 ? <span className="tone-danger">{summary.failed} Fehler</span> : null}
{summary.skipped > 0 ? <span className="tone-warning">{summary.skipped} übersprungen</span> : null}
</span>
{results.length > 0 ? (
<div className="script-result-list">
{results.map((r, i) => <ScriptResultRow key={i} result={r} />)}
</div>
) : null}
</div>
);
}
function normalizeIdList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
continue;
}
const id = Math.trunc(parsed);
const key = String(id);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(id);
}
return output;
}
function normalizePositiveInteger(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeCdText(value) {
return String(value || '')
.normalize('NFC')
.replace(/\s+/g, ' ')
.trim();
}
function isPlaceholderCdTrackTitle(value) {
const normalized = normalizeCdText(value).toLowerCase();
if (!normalized) {
return true;
}
return /^track\s*\d+$/i.test(normalized);
}
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 shellQuote(value) {
const raw = String(value ?? '');
if (raw.length === 0) {
return "''";
}
if (/^[A-Za-z0-9_./:=,+-]+$/.test(raw)) {
return raw;
}
return `'${raw.replace(/'/g, `'"'"'`)}'`;
}
function trackLang(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) return 'und';
const map = { en: 'en', eng: 'en', de: 'de', deu: 'de', ger: 'de', tr: 'tr', tur: 'tr', fr: 'fr', fra: 'fr', fre: 'fr', es: 'es', spa: 'es', it: 'it', ita: 'it' };
if (map[raw]) return map[raw];
return raw.length >= 2 ? raw.slice(0, 2) : raw;
}
function trackCodec(type, value, hint = null) {
const raw = String(value || '').trim();
const merged = `${raw} ${String(hint || '')}`.toLowerCase();
if (!raw) return '-';
if (type === 'subtitle') return merged.includes('pgs') ? 'PGS' : raw.toUpperCase();
if (merged.includes('dts-hd ma') || merged.includes('dts hd ma')) return 'DTS-HD MA';
if (merged.includes('dts-hd')) return 'DTS-HD';
if (merged.includes('dts') || merged.includes('dca')) return 'DTS';
if (merged.includes('truehd')) return 'TRUEHD';
if (merged.includes('e-ac-3') || merged.includes('eac3') || merged.includes('dd+')) return 'E-AC-3';
if (merged.includes('ac-3') || merged.includes('ac3') || merged.includes('dolby digital')) return 'AC-3';
return raw.toUpperCase();
}
function trackChLayout(rawValue) {
const raw = String(rawValue || '').trim().toLowerCase();
if (!raw) return null;
if (raw.includes('7.1')) return '8ch | Surround 7.1';
if (raw.includes('5.1')) return '6ch | Surround 5.1';
if (raw.includes('stereo') || raw.includes('2.0') || raw.includes('downmix')) return '2ch | Stereo';
if (raw.includes('mono') || raw.includes('1.0')) return '1ch | Mono';
const nMatch = raw.match(/^([\d.]+)$/);
if (nMatch) {
const n = Number(nMatch[1]);
if (Number.isFinite(n) && n > 0) return `${Math.trunc(n)}ch`;
}
const chMatch = raw.match(/(\d+)\s*ch/);
if (chMatch) return `${chMatch[1]}ch`;
return null;
}
function trackSizeFormat(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = value;
let i = 0;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(2)} ${units[i]}`;
}
function buildExecutedHandBrakeCommand(handbrakeInfo) {
const cmd = String(handbrakeInfo?.cmd || '').trim();
const args = Array.isArray(handbrakeInfo?.args) ? handbrakeInfo.args : [];
if (!cmd) {
return null;
}
return `${cmd} ${args.map((arg) => shellQuote(arg)).join(' ')}`.trim();
}
function buildConfiguredScriptAndChainSelection(job) {
const plan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' ? job.handbrakeInfo : {};
const scriptNameById = new Map();
const chainNameById = new Map();
const addScriptHint = (idValue, nameValue) => {
const id = normalizeIdList([idValue])[0] || null;
const name = String(nameValue || '').trim();
if (!id || !name || scriptNameById.has(id)) {
return;
}
scriptNameById.set(id, name);
};
const addChainHint = (idValue, nameValue) => {
const id = normalizeIdList([idValue])[0] || null;
const name = String(nameValue || '').trim();
if (!id || !name || chainNameById.has(id)) {
return;
}
chainNameById.set(id, name);
};
const addScriptHintsFromList = (list) => {
for (const item of (Array.isArray(list) ? list : [])) {
addScriptHint(item?.id ?? item?.scriptId, item?.name ?? item?.scriptName);
}
};
const addChainHintsFromList = (list) => {
for (const item of (Array.isArray(list) ? list : [])) {
addChainHint(item?.id ?? item?.chainId, item?.name ?? item?.chainName);
}
};
addScriptHintsFromList(plan?.preEncodeScripts);
addScriptHintsFromList(plan?.postEncodeScripts);
addChainHintsFromList(plan?.preEncodeChains);
addChainHintsFromList(plan?.postEncodeChains);
const scriptSummaries = [handbrakeInfo?.preEncodeScripts, handbrakeInfo?.postEncodeScripts];
for (const summary of scriptSummaries) {
const results = Array.isArray(summary?.results) ? summary.results : [];
for (const result of results) {
addScriptHint(result?.scriptId, result?.scriptName);
addChainHint(result?.chainId, result?.chainName);
}
}
const preScriptIds = normalizeIdList([
...(Array.isArray(plan?.preEncodeScriptIds) ? plan.preEncodeScriptIds : []),
...(Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts.map((item) => item?.id ?? item?.scriptId) : [])
]);
const postScriptIds = normalizeIdList([
...(Array.isArray(plan?.postEncodeScriptIds) ? plan.postEncodeScriptIds : []),
...(Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts.map((item) => item?.id ?? item?.scriptId) : [])
]);
const preChainIds = normalizeIdList([
...(Array.isArray(plan?.preEncodeChainIds) ? plan.preEncodeChainIds : []),
...(Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains.map((item) => item?.id ?? item?.chainId) : [])
]);
const postChainIds = normalizeIdList([
...(Array.isArray(plan?.postEncodeChainIds) ? plan.postEncodeChainIds : []),
...(Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains.map((item) => item?.id ?? item?.chainId) : [])
]);
return {
preScriptIds,
postScriptIds,
preChainIds,
postChainIds,
preScripts: preScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`),
postScripts: postScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`),
preChains: preChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`),
postChains: postChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`),
scriptCatalog: Array.from(scriptNameById.entries()).map(([id, name]) => ({ id, name })),
chainCatalog: Array.from(chainNameById.entries()).map(([id, name]) => ({ id, name }))
};
}
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';
}
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
}
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
if (statusCandidates.some((v) => String(v || '').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 'other';
}
function resolveCdDetails(job) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.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((v) => normalizePositiveInteger(v)).filter(Boolean)
: [];
const selectedTrackPositions = selectedTracksFromPlan.length > 0
? selectedTracksFromPlan
: tracks.filter((t) => t.selected !== false).map((t) => t.position);
const fallbackArtist = tracks.map((t) => String(t?.artist || '').trim()).find(Boolean) || null;
const fallbackAlbum = tracks.map((t) => String(t?.album || '').trim()).find(Boolean) || null;
const totalDurationSec = tracks.reduce((sum, t) => {
const ms = Number(t?.durationMs);
const sec = Number(t?.durationSec);
if (Number.isFinite(ms) && ms > 0) {
return sum + ms / 1000;
}
if (Number.isFinite(sec) && sec > 0) {
return sum + sec;
}
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
|| job?.imdb_id
|| ''
).trim() || null;
return {
artist: String(selectedMetadata?.artist || '').trim() || fallbackArtist || null,
album: String(selectedMetadata?.album || '').trim() || fallbackAlbum || null,
trackCount: tracks.length,
selectedTrackCount: selectedTrackPositions.length,
format,
formatLabel: format ? (CD_FORMAT_LABELS[format] || format.toUpperCase()) : null,
totalDurationLabel: formatDurationSeconds(totalDurationSec),
mbId
};
}
function resolveAudiobookDetails(job) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
const selectedMetadata = {
...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {}),
...(encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {})
};
const chapters = Array.isArray(selectedMetadata?.chapters)
? selectedMetadata.chapters
: (Array.isArray(makemkvInfo?.chapters) ? makemkvInfo.chapters : []);
const format = String(job?.handbrakeInfo?.format || encodePlan?.format || '').trim().toLowerCase() || null;
const formatOptions = job?.handbrakeInfo?.formatOptions && typeof job.handbrakeInfo.formatOptions === 'object'
? job.handbrakeInfo.formatOptions
: (encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object' ? encodePlan.formatOptions : {});
const qualityLabel = format === 'mp3'
? (
String(formatOptions?.mp3Mode || '').trim().toLowerCase() === 'vbr'
? `VBR V${Number(formatOptions?.mp3Quality ?? 4)}`
: `CBR ${Number(formatOptions?.mp3Bitrate ?? 192)} kbps`
)
: (format === 'flac'
? `Kompression ${Number(formatOptions?.flacCompression ?? 5)}`
: (format === 'm4b' ? 'Original-Audio' : null));
return {
author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null,
narrator: String(selectedMetadata?.narrator || '').trim() || null,
series: String(selectedMetadata?.series || '').trim() || null,
part: String(selectedMetadata?.part || '').trim() || null,
chapterCount: chapters.length,
formatLabel: format ? format.toUpperCase() : null,
qualityLabel
};
}
function statusBadgeMeta(status, queued = false) {
const normalized = String(status || '').trim().toUpperCase();
const label = getStatusLabel(normalized, { queued });
if (queued) {
return { label, icon: 'pi-list', tone: 'info' };
}
if (normalized === 'FINISHED') {
return { label, icon: 'pi-check-circle', tone: 'success' };
}
if (normalized === 'ERROR') {
return { label, icon: 'pi-times-circle', tone: 'danger' };
}
if (normalized === 'CANCELLED') {
return { label, icon: 'pi-ban', tone: 'warning' };
}
if (normalized === 'READY_TO_ENCODE' || normalized === 'READY_TO_START') {
return { label, icon: 'pi-play-circle', tone: 'info' };
}
if (normalized === 'WAITING_FOR_USER_DECISION') {
return { label, icon: 'pi-exclamation-circle', tone: 'warning' };
}
if (normalized === 'METADATA_SELECTION') {
return { label, icon: 'pi-list', tone: 'warning' };
}
if (normalized === 'ANALYZING') {
return { label, icon: 'pi-search', tone: 'warning' };
}
if (normalized === 'RIPPING') {
return { label, icon: 'pi-download', tone: 'warning' };
}
if (normalized === 'MEDIAINFO_CHECK') {
return { label, icon: 'pi-sliders-h', tone: 'warning' };
}
if (normalized === 'ENCODING') {
return { label, icon: 'pi-cog', tone: 'warning' };
}
return { label: label || '-', icon: 'pi-info-circle', tone: 'secondary' };
}
function omdbField(value) {
const raw = String(value || '').trim();
return raw || '-';
}
function omdbRottenTomatoesScore(omdbInfo) {
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === 'rotten tomatoes');
return omdbField(entry?.Value);
}
function BoolState({ value }) {
const isTrue = Boolean(value);
return isTrue ? (
<span className="job-step-inline-ok" title="Ja">
<i className="pi pi-check-circle" aria-hidden="true" />
</span>
) : (
<span className="job-step-inline-no" title="Nein">
<i className="pi pi-times-circle" aria-hidden="true" />
</span>
);
}
function resolveSelectionStateMeta({ selected, outcome }) {
if (!selected) {
return {
label: 'Nicht ausgewählt',
icon: 'pi-minus-circle',
className: 'track-selection-inline-neutral',
title: 'Nicht ausgewählt'
};
}
if (outcome === 'success') {
return {
label: 'Ausgewählt',
icon: 'pi-check-circle',
className: 'job-step-inline-ok',
title: 'Ausgewählt und erfolgreich'
};
}
if (outcome === 'error') {
return {
label: 'Ausgewählt',
icon: 'pi-times-circle',
className: 'job-step-inline-no',
title: 'Ausgewählt, aber nicht erfolgreich'
};
}
if (outcome === 'cancelled') {
return {
label: 'Ausgewählt',
icon: 'pi-ban',
className: 'job-step-inline-warn',
title: 'Ausgewählt, aber abgebrochen'
};
}
return {
label: 'Ausgewählt',
icon: 'pi-clock',
className: 'track-selection-inline-neutral',
title: 'Ausgewählt'
};
}
function SelectionStateNote({ selected, outcome }) {
const meta = resolveSelectionStateMeta({ selected, outcome });
return (
<small className="track-action-note">
<span className={meta.className} title={meta.title}>
<span>{meta.label}</span>
<i className={`pi ${meta.icon}`} aria-hidden="true" />
</span>
</small>
);
}
function PathField({
label,
value,
onDownload = null,
downloadDisabled = false,
downloadLoading = false
}) {
const hasValue = Boolean(String(value || '').trim());
const canDownload = hasValue && typeof onDownload === 'function' && !downloadDisabled;
return (
<div className="job-path-field">
<strong>{label}</strong>
<div className="job-path-field-value">
<span>{hasValue ? value : '-'}</span>
{canDownload ? (
<Button
type="button"
icon="pi pi-download"
text
rounded
size="small"
className="job-path-download-button"
aria-label={`${label} als ZIP vorbereiten`}
tooltip={`${label} als ZIP vorbereiten`}
tooltipOptions={{ position: 'top' }}
onClick={onDownload}
disabled={downloadDisabled || downloadLoading}
loading={downloadLoading}
/>
) : null}
</div>
</div>
);
}
export default function JobDetailDialog({
visible,
job,
onHide,
detailLoading = false,
onLoadLog,
logLoadingMode = null,
onAssignOmdb,
onAssignCdMetadata,
onResumeReady,
onRestartEncode,
onRestartReview,
onRestartCdReview,
onReencode,
onRetry,
onDeleteFiles,
onDeleteEntry,
onDownloadArchive,
onDownloadOutputFolder,
onRemoveFromQueue,
isQueued = false,
omdbAssignBusy = false,
cdMetadataAssignBusy = false,
actionBusy = false,
reencodeBusy = false,
deleteEntryBusy = false,
downloadBusyTarget = null,
downloadFolderBusyPath = null
}) {
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(job?.status);
const showFinalLog = !running;
const mediaType = resolveMediaType(job);
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const cdRawPathCandidates = [
job?.raw_path,
job?.makemkvInfo?.rawPath,
job?.makemkvInfo?.importContext?.requestedRawPath,
job?.makemkvInfo?.importContext?.originalRawPath
]
.map((value) => String(value || '').trim())
.filter(Boolean);
const hasCdRawInputCandidate = Boolean(job?.rawStatus?.exists) || cdRawPathCandidates.length > 0;
const hasReencodeRawInput = isCd
? hasCdRawInputCandidate
: Boolean(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true);
const canReencode = !!(hasReencodeRawInput && !running && (isCd || mkDone));
// For CDs: direct re-encode requires track data from the last run
const cdEncodePlan = isCd && job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const cdSelectedMeta = isCd && job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
? job.makemkvInfo.selectedMetadata
: {};
const cdPlanTracks = isCd && Array.isArray(cdEncodePlan?.tracks)
? cdEncodePlan.tracks
: [];
const cdSelectedTrackPositions = isCd
? (
Array.isArray(cdEncodePlan?.selectedTracks) && cdEncodePlan.selectedTracks.length > 0
? cdEncodePlan.selectedTracks
: cdPlanTracks.filter((track) => track?.selected !== false).map((track) => track?.position)
)
.map((value) => normalizePositiveInteger(value))
.filter(Boolean)
: [];
const cdTrackByPosition = new Map(
cdPlanTracks
.map((track) => {
const position = normalizePositiveInteger(track?.position);
if (!position) {
return null;
}
return [position, track];
})
.filter(Boolean)
);
const cdHasCoreMetadata = Boolean(
normalizeCdText(cdSelectedMeta?.title || cdSelectedMeta?.album || job?.title || job?.detected_title)
&& normalizeCdText(cdSelectedMeta?.artist)
);
const cdHasMeaningfulTrackTitles = cdSelectedTrackPositions.some((position) => {
const track = cdTrackByPosition.get(position);
const title = normalizeCdText(track?.title);
return Boolean(title) && !isPlaceholderCdTrackTitle(title);
});
const cdHasPriorRunEvidence = Boolean(
cdEncodePlan?.directReencodeReady
|| (Array.isArray(job?.handbrakeInfo?.tracks) && job.handbrakeInfo.tracks.length > 0)
|| String(job?.handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
);
const cdHasLastRunData = Boolean(
cdEncodePlan
&& Array.isArray(cdEncodePlan.tracks)
&& cdEncodePlan.tracks.length > 0
&& cdEncodePlan.format
);
const canCdDirectEncode = Boolean(
canReencode
&& cdHasLastRunData
&& cdHasCoreMetadata
&& cdHasMeaningfulTrackTitles
&& cdHasPriorRunEvidence
);
const canCdStartReview = !!(hasCdRawInputCandidate && !running
&& isCd && typeof onRestartCdReview === 'function');
const canResumeReady = Boolean(
(String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' || String(job?.last_state || '').trim().toUpperCase() === 'READY_TO_ENCODE')
&& !running
&& typeof onResumeReady === 'function'
);
const hasConfirmedPlan = Boolean(
job?.encodePlan
&& Array.isArray(job?.encodePlan?.titles)
&& job?.encodePlan?.titles.length > 0
&& Number(job?.encode_review_confirmed || 0) === 1
);
const hasRestartInput = Boolean(job?.encode_input_path || job?.raw_path || job?.encodePlan?.encodeInputPath);
const canRestartEncode = Boolean(hasConfirmedPlan && hasRestartInput && !running && job?.rawStatus?.exists);
const canRestartReview = Boolean(
job?.rawStatus?.exists
&& job?.rawStatus?.isEmpty !== true
&& !running
&& mediaType !== 'audiobook'
&& typeof onRestartReview === 'function'
);
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
const queueLocked = Boolean(isQueued && job?.id);
const logCount = Number(job?.log_count || 0);
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
const logTruncated = Boolean(logMeta?.truncated);
const cdDetails = isCd ? resolveCdDetails(job) : null;
const audiobookDetails = isAudiobook ? resolveAudiobookDetails(job) : null;
const canRetry = isCd && !running && typeof onRetry === 'function';
const mediaTypeLabel = mediaType === 'bluray'
? 'Blu-ray'
: mediaType === 'dvd'
? 'DVD'
: isCd
? 'Audio CD'
: (isAudiobook ? 'Audiobook' : 'Sonstiges Medium');
const mediaTypeIcon = mediaType === 'bluray'
? blurayIndicatorIcon
: mediaType === 'dvd'
? discIndicatorIcon
: otherIndicatorIcon;
const mediaTypeAlt = mediaTypeLabel;
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0
|| configuredSelection.preChainIds.length > 0
|| configuredSelection.postChainIds.length > 0;
const encodePlanUserPreset = job?.encodePlan?.userPreset && typeof job.encodePlan.userPreset === 'object'
? job.encodePlan.userPreset
: null;
const reviewSelectedEncodeTitleId = job?.encodePlan?.encodeInputTitleId ?? null;
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
const canDownloadRaw = Boolean(job?.raw_path && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
return (
<Dialog
header={`Job #${job?.id || ''}`}
visible={visible}
onHide={onHide}
style={{ width: '70rem', maxWidth: '96vw' }}
className="job-detail-dialog"
breakpoints={{ '1440px': '94vw', '1024px': '96vw', '640px': '98vw' }}
modal
>
{!job ? null : (
<div className="job-detail-body">
{detailLoading ? <p>Details werden geladen ...</p> : null}
<div className="job-head-row">
{job.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={job.title || 'Poster'} className={isCd || isAudiobook ? 'poster-large-audio' : 'poster-large'} />
) : (
<div className={`${isCd || isAudiobook ? 'poster-large-audio' : 'poster-large'} poster-fallback`}>{isCd || isAudiobook ? 'Kein Cover' : 'Kein Poster'}</div>
)}
<div className="job-film-info-grid">
{isCd ? (
<section className="job-meta-block job-meta-block-film">
<h4>Musik-Infos</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Album:</strong>
<span>{job.title || job.detected_title || cdDetails?.album || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Interpret:</strong>
<span>{cdDetails?.artist || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Jahr:</strong>
<span>{job.year || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Tracks:</strong>
<span>
{cdDetails?.trackCount > 0
? (cdDetails.selectedTrackCount > 0 && cdDetails.selectedTrackCount !== cdDetails.trackCount
? `${cdDetails.selectedTrackCount}/${cdDetails.trackCount}`
: String(cdDetails.trackCount))
: '-'}
</span>
</div>
<div className="job-meta-item">
<strong>Format:</strong>
<span>{cdDetails?.formatLabel || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Gesamtdauer:</strong>
<span>{cdDetails?.totalDurationLabel || '-'}</span>
</div>
<div className="job-meta-item">
<strong>MusicBrainz ID:</strong>
<span>{cdDetails?.mbId || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Medium:</strong>
<span className="job-step-cell">
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
<span>{mediaTypeLabel}</span>
</span>
</div>
</div>
</section>
) : (
<>
<section className="job-meta-block job-meta-block-film">
<h4>{isAudiobook ? 'Audiobook-Infos' : 'Film-Infos'}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Titel:</strong>
<span>{job.title || job.detected_title || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Jahr:</strong>
<span>{job.year || '-'}</span>
</div>
{isAudiobook ? (
<>
<div className="job-meta-item">
<strong>Autor:</strong>
<span>{audiobookDetails?.author || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Sprecher:</strong>
<span>{audiobookDetails?.narrator || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Serie:</strong>
<span>{audiobookDetails?.series || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Teil:</strong>
<span>{audiobookDetails?.part || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Kapitel:</strong>
<span>{audiobookDetails?.chapterCount || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Format:</strong>
<span>{audiobookDetails?.formatLabel || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Qualität:</strong>
<span>{audiobookDetails?.qualityLabel || '-'}</span>
</div>
</>
) : (
<>
<div className="job-meta-item">
<strong>IMDb:</strong>
<span>{job.imdb_id || '-'}</span>
</div>
<div className="job-meta-item">
<strong>OMDb Match:</strong>
<BoolState value={job.selected_from_omdb} />
</div>
</>
)}
<div className="job-meta-item">
<strong>Medium:</strong>
<span className="job-step-cell">
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
<span>{mediaTypeLabel}</span>
</span>
</div>
</div>
</section>
{!isAudiobook ? (
<section className="job-meta-block job-meta-block-film">
<h4>OMDb Details</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Regisseur:</strong>
<span>{omdbField(omdbInfo?.Director)}</span>
</div>
<div className="job-meta-item">
<strong>Schauspieler:</strong>
<span>{omdbField(omdbInfo?.Actors)}</span>
</div>
<div className="job-meta-item">
<strong>Laufzeit:</strong>
<span>{omdbField(omdbInfo?.Runtime)}</span>
</div>
<div className="job-meta-item">
<strong>Genre:</strong>
<span>{omdbField(omdbInfo?.Genre)}</span>
</div>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(omdbInfo?.imdbRating)}</span>
</div>
</div>
</section>
) : null}
</>
)}
</div>
</div>
<section className="job-meta-block job-meta-block-full">
<div className="job-infos-header">
<h4>Job-Infos</h4>
<div className="job-infos-badges">
<span>
<strong>Status:</strong>{' '}
<span
className={`job-status-icon tone-${statusMeta.tone}`}
title={statusMeta.label}
aria-label={statusMeta.label}
>
<i className={`pi ${statusMeta.icon}`} aria-hidden="true" />
</span>
</span>
<span className="job-infos-sep">|</span>
{isCd ? (
<>
<span><strong>Rip:</strong> <BoolState value={job?.ripSuccessful} /></span>
<span className="job-infos-sep">|</span>
<span><strong>Backup:</strong> <BoolState value={job?.backupSuccess} /></span>
</>
) : (
<>
<span><strong>{isAudiobook ? 'Import:' : 'Backup:'}</strong> <BoolState value={job?.backupSuccess} /></span>
<span className="job-infos-sep">|</span>
<span><strong>Encode:</strong> <BoolState value={job?.encodeSuccess} /></span>
</>
)}
</div>
</div>
<div className="job-infos-layout">
{/* Zeile 1: Start | Ende */}
<div><strong>Start:</strong> {job.start_time || '-'}</div>
<div><strong>Ende:</strong> {job.end_time || '-'}</div>
{/* Zeile 3+4: Pfade */}
<PathField
label={isCd ? 'WAV:' : 'RAW:'}
value={isCd ? (job.raw_path || job.output_path) : job.raw_path}
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
downloadDisabled={!canDownloadRaw}
downloadLoading={downloadBusyTarget === 'raw'}
/>
{outputFolders.length > 0 ? (
outputFolders.map((folder, idx) => {
const folderPath = String(folder?.output_path || '').trim();
if (!folderPath) {
return null;
}
const label = outputFolders.length > 1 ? `Output ${idx + 1}:` : 'Output:';
const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function');
return (
<PathField
key={folder.id || folderPath}
label={label}
value={folderPath}
onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath) : null}
downloadDisabled={!canDownloadFolder}
downloadLoading={downloadFolderBusyPath === folderPath}
/>
);
})
) : (
<PathField
label="Output:"
value={job.output_path}
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
downloadDisabled={!canDownloadOutput}
downloadLoading={downloadBusyTarget === 'output'}
/>
)}
{job.error_message ? (
<div><strong>Fehler:</strong> {job.error_message}</div>
) : null}
</div>
</section>
{!isCd && !isAudiobook && (hasConfiguredSelection || encodePlanUserPreset || job.encodePlan?.minLengthMinutes != null || Array.isArray(job.encodePlan?.titles)) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Encode-Konfiguration</h4>
{/* Zeile 1: Preset + Mindestlaufzeit */}
<div className="job-meta-grid job-meta-grid-compact">
{encodePlanUserPreset ? (
<div>
<strong>Preset:</strong> {encodePlanUserPreset.name || '-'}
</div>
) : null}
{job.encodePlan?.minLengthMinutes != null ? (
<div>
<strong>Mindestlaufzeit:</strong> {job.encodePlan.minLengthMinutes} Min.
</div>
) : null}
</div>
{/* Zeile 2: Skripte */}
{hasConfiguredSelection ? (
<div className="job-meta-grid job-meta-grid-compact">
<div>
<strong>Pre-Skripte:</strong>{' '}
{configuredSelection.preScripts.length > 0
? configuredSelection.preScripts.join(', ')
: configuredSelection.preChains.length > 0
? configuredSelection.preChains.join(', ')
: '-'}
</div>
<div>
<strong>Post-Skripte:</strong>{' '}
{configuredSelection.postScripts.length > 0
? configuredSelection.postScripts.join(', ')
: configuredSelection.postChains.length > 0
? configuredSelection.postChains.join(', ')
: '-'}
</div>
</div>
) : null}
{/* Spurauswahl read-only */}
{job.encodePlan && Array.isArray(job.encodePlan.titles) && job.encodePlan.titles.length > 0 ? (
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{job.encodePlan.titles
.filter((t) => t.selectedForEncode || t.encodeInput || String(t.id) === String(reviewSelectedEncodeTitleId))
.map((title) => {
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
return (
<div key={title.id} className="track-title-block">
<div className="track-title-row">
<span>#{title.id} | {title.fileName} | {title.durationMinutes != null ? `${Number(title.durationMinutes).toFixed(2)} min` : '-'}</span>
</div>
<div className="track-groups-row">
{audioTracks.length > 0 ? (
<div className="track-group">
<div className="track-group-label">Tonspuren (Titel #{title.id})</div>
{audioTracks.map((track) => {
const lang = trackLang(track.language || track.languageLabel);
const codec = trackCodec('audio', track.format, track.description || track.title);
const chLayout = trackChLayout(track.channels);
let displayText = `#${track.id} | ${lang} | ${codec}`;
if (chLayout) displayText += ` | ${chLayout}`;
const actionInfo = track.selectedForEncode
? (String(track.encodePreviewSummary || track.encodeActionSummary || '').trim() || 'Copy')
: 'Nicht übernommen';
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
<small className="track-action-note">Encode: {actionInfo}</small>
</div>
);
})}
</div>
) : null}
{subtitleTracks.length > 0 ? (
<div className="track-group">
<div className="track-group-label">Subtitles (Titel #{title.id})</div>
{subtitleTracks.map((track) => {
const lang = trackLang(track.language || track.languageLabel);
const codec = trackCodec('subtitle', track.format);
const displayText = `#${track.id} | ${lang} | ${codec}`;
const rawAction = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
const actionInfo = track.selectedForEncode
? (/^nicht übernommen$/i.test(rawAction) ? 'Übernehmen' : (rawAction || 'Übernehmen'))
: 'Nicht übernommen';
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
<small className="track-action-note">Encode: {actionInfo}</small>
</div>
);
})}
</div>
) : null}
</div>
</div>
);
})
}
</div>
) : null}
</section>
) : null}
{(isCd || isAudiobook) && job.encodePlan ? (
<section className="job-meta-block job-meta-block-full">
<h4>{isCd ? 'Titelauswahl' : 'Kapitelauswahl'}</h4>
{isCd ? (() => {
const tracksRaw = Array.isArray(job.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0
? job.makemkvInfo.tracks
: (Array.isArray(job.encodePlan?.tracks) ? job.encodePlan.tracks : []);
const selectedSet = Array.isArray(job.encodePlan?.selectedTracks) && job.encodePlan.selectedTracks.length > 0
? new Set(job.encodePlan.selectedTracks.map(Number))
: null;
const encodeResultMap = new Map(
(Array.isArray(job.handbrakeInfo?.tracks) ? job.handbrakeInfo.tracks : [])
.map((r) => [Number(r.position), r])
);
if (tracksRaw.length === 0) return <p className="job-meta-subtle">Keine Tracks vorhanden.</p>;
return (
<div className="track-group">
{tracksRaw.map((track, i) => {
const pos = Number(track.position ?? (i + 1));
const label = String(track.title || track.name || `Track ${pos}`).trim();
const artist = String(track.artist || '').trim() || String(job?.makemkvInfo?.selectedMetadata?.artist || '').trim();
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
const encodeResult = encodeResultMap.get(pos);
const outcome = !selected
? null
: encodeResult
? (encodeResult.success ? 'success' : 'error')
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'success' : 'error') : null);
return (
<div key={pos} className="track-item track-item-inline-status">
<span className="track-item-main">#{pos} {artist ? `${artist} - ` : ''}{label}</span>
<SelectionStateNote selected={selected} outcome={outcome} />
</div>
);
})}
</div>
);
})() : (() => {
const audiobookFormat = String(job?.handbrakeInfo?.format || job?.encodePlan?.format || '').trim().toLowerCase();
const isSplitAudiobook = Boolean(audiobookFormat) && audiobookFormat !== 'm4b';
const chapters = Array.isArray(job.handbrakeInfo?.metadata?.chapters) && job.handbrakeInfo.metadata.chapters.length > 0
? job.handbrakeInfo.metadata.chapters
: (Array.isArray(job.makemkvInfo?.chapters) && job.makemkvInfo.chapters.length > 0
? job.makemkvInfo.chapters
: (Array.isArray(job.encodePlan?.metadata?.chapters) ? job.encodePlan.metadata.chapters : []));
const stepsByIndex = new Map(
(Array.isArray(job.handbrakeInfo?.steps) ? job.handbrakeInfo.steps : [])
.map((s) => [Number(s.chapterIndex), s])
);
if (chapters.length === 0) return <p className="job-meta-subtle">Keine Kapitelinformationen vorhanden.</p>;
return (
<div className="track-group">
{chapters.map((chapter, i) => {
const id = Number(chapter.id ?? chapter.index ?? chapter.position ?? (i + 1));
const label = String(chapter.title || chapter.name || `Kapitel ${id}`).trim();
const durationSec = Number(chapter.durationMs || 0) > 0
? Number(chapter.durationMs) / 1000
: Number(chapter.durationSec || 0);
const durLabel = durationSec > 0
? `${Math.floor(durationSec / 60)}:${String(Math.floor(durationSec % 60)).padStart(2, '0')} min`
: '-';
const step = stepsByIndex.get(id);
const stepStatus = step ? String(step.status || '').toUpperCase() : null;
const outcome = stepStatus === 'SUCCESS'
? 'success'
: stepStatus === 'ERROR'
? 'error'
: stepStatus === 'CANCELLED'
? 'cancelled'
: (job?.encodeSuccess != null
? (job.encodeSuccess ? 'success' : (String(job?.status || '').trim().toUpperCase() === 'CANCELLED' ? 'cancelled' : 'error'))
: null);
const encodeLabel = stepStatus === 'SUCCESS' ? 'Erfolgreich'
: stepStatus === 'ERROR' ? 'Fehler'
: stepStatus === 'CANCELLED' ? 'Abgebrochen'
: stepStatus ? stepStatus
: 'Übernommen';
const encodeClass = stepStatus === 'SUCCESS' ? 'tone-ok'
: stepStatus === 'ERROR' ? 'tone-no'
: '';
return (
<div key={id} className={`track-item${isSplitAudiobook ? ' track-item-inline-status' : ''}`}>
<span className={isSplitAudiobook ? 'track-item-main' : undefined}>#{id} | {label} | {durLabel}</span>
{isSplitAudiobook ? (
<SelectionStateNote selected outcome={outcome} />
) : (
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
)}
</div>
);
})}
</div>
);
})()}
</section>
) : null}
<section className="job-meta-block job-meta-block-full">
<h4>Logs</h4>
<div className="job-json-grid">
{!isCd && !isAudiobook ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : 'MakeMKV Info')} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : 'Encode Plan'} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : 'HandBrake Info')} value={job.handbrakeInfo} />
</div>
</section>
<h4>Log</h4>
{showFinalLog ? (
<>
<div className="actions-row">
<Button
label={logLoaded ? 'Tail neu laden (800)' : 'Tail laden (800)'}
icon="pi pi-download"
severity="secondary"
outlined
size="small"
onClick={() => onLoadLog?.(job, 'tail')}
loading={logLoadingMode === 'tail'}
/>
<Button
label="Vollständiges Log laden"
icon="pi pi-list"
severity="secondary"
outlined
size="small"
onClick={() => onLoadLog?.(job, 'all')}
loading={logLoadingMode === 'all'}
disabled={logCount <= 0}
/>
<small>{`Log-Zeilen: ${logCount}`}</small>
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
</div>
{logLoaded ? (
<pre className="log-box">{job.log || ''}</pre>
) : (
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
)}
</>
) : (
<p>Live-Log wird nur im Dashboard während laufender Analyse/Rip/Encode angezeigt.</p>
)}
<h4>Aktionen</h4>
{queueLocked ? (
<div className="actions-row">
<Button
label="Aus Queue löschen"
icon="pi pi-times"
severity="danger"
outlined
size="small"
onClick={() => onRemoveFromQueue?.(job)}
loading={actionBusy}
disabled={typeof onRemoveFromQueue !== 'function'}
/>
</div>
) : (
<div className="actions-section">
{isCd ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audio CD</div>
<div className="action-item">
<Button
label="Encode neu starten"
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canCdDirectEncode || typeof onReencode !== 'function'}
/>
<span className="action-desc">
{canCdDirectEncode
? 'Encodiert die vorhandenen WAV-Rohdaten mit den zuletzt bestätigten Einstellungen erneut — ohne die CD neu zu lesen.'
: 'Direktes Encoding ist gesperrt, solange kein bestätigter Vorlauf mit vollständigen Metadaten vorhanden ist. Bitte zuerst "Vorprüfung starten".'}
</span>
</div>
<div className="action-item">
<Button
label="Vorprüfung starten"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onRestartCdReview?.(job)}
loading={actionBusy}
disabled={!canCdStartReview}
/>
<span className="action-desc">Öffnet den Vorprüfungs-Workflow: MusicBrainz-Suche, Trackauswahl, Ausgabeeinstellungen dann Encode aus vorhandenen WAV-Daten starten.</span>
</div>
<div className="action-item">
<Button
label="MusicBrainz neu zuweisen"
icon="pi pi-tag"
severity="secondary"
outlined
size="small"
onClick={() => onAssignCdMetadata?.(job)}
loading={cdMetadataAssignBusy}
disabled={running || typeof onAssignCdMetadata !== 'function'}
/>
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
</div>
</div>
) : (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
{canResumeReady ? (
<div className="action-item">
<Button
label="Im Dashboard öffnen"
icon="pi pi-window-maximize"
severity="info"
outlined
size="small"
onClick={() => onResumeReady?.(job)}
loading={actionBusy}
/>
<span className="action-desc">Öffnet den wartenden Job im Dashboard zur Weiterverarbeitung.</span>
</div>
) : null}
{typeof onRestartEncode === 'function' ? (
<div className="action-item">
<Button
label="Encode neu starten"
icon="pi pi-play"
severity="success"
size="small"
onClick={() => onRestartEncode?.(job)}
loading={actionBusy}
disabled={!canRestartEncode}
/>
<span className="action-desc">Startet {isAudiobook ? 'FFmpeg' : 'HandBrake'} mit den zuletzt gespeicherten Einstellungen direkt neu ohne Review.</span>
</div>
) : null}
{typeof onRestartReview === 'function' ? (
<div className="action-item">
<Button
label={isAudiobook ? 'Kapitel-Auswahl öffnen' : 'Spur-Auswahl öffnen'}
icon="pi pi-list"
severity="info"
outlined
size="small"
onClick={() => onRestartReview?.(job)}
loading={actionBusy}
disabled={!canRestartReview}
/>
<span className="action-desc">{isAudiobook
? 'Öffnet die Kapitel-Auswahl erneut, um Kapitel anzupassen bevor der Encode startet.'
: 'Öffnet die Spur- und Preset-Auswahl erneut, um Einstellungen vor dem Encode zu ändern.'
}</span>
</div>
) : null}
<div className="action-item">
<Button
label="Neustart"
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode || typeof onReencode !== 'function'}
/>
<span className="action-desc">{isAudiobook
? 'Liest die AAX-Datei neu ein und öffnet danach die Kapitel-Auswahl. Sinnvoll wenn sich die Quelldatei geändert hat.'
: 'Analysiert die Quelldatei neu (MediaInfo, Playlist-Check) und öffnet dann die Spur-Auswahl. Unterschied zu „Spur-Auswahl öffnen": der Analyse-Schritt wird komplett wiederholt.'
}</span>
</div>
</div>
)}
{!isCd && !isAudiobook && typeof onAssignOmdb === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item">
<Button
label="OMDB neu zuweisen"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onAssignOmdb?.(job)}
loading={omdbAssignBusy}
disabled={running}
/>
<span className="action-desc">Öffnet die OMDB-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.</span>
</div>
</div>
) : null}
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-trash" /> Dateien löschen</div>
<div className="action-item">
<Button
label="RAW löschen"
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(job, 'raw')}
loading={actionBusy}
disabled={!job.rawStatus?.exists || typeof onDeleteFiles !== 'function'}
/>
<span className="action-desc">Löscht die Quelldateien nach dem Rip ({isCd ? 'Audio-Rohdaten' : isAudiobook ? 'AAX/MP3-Quelldatei' : 'MKV-Datei von MakeMKV'}).</span>
</div>
<div className="action-item">
<Button
label={isCd ? 'Audio löschen' : (isAudiobook ? 'Ausgabe löschen' : 'Movie löschen')}
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(job, 'movie')}
loading={actionBusy}
disabled={!hasAnyOutputFolder || typeof onDeleteFiles !== 'function'}
/>
<span className="action-desc">Löscht {isCd ? 'die fertig gerippten Audiodateien' : isAudiobook ? 'die fertig konvertierte Audiobook-Ausgabe' : 'die fertig encodierte Filmdatei'}.</span>
</div>
<div className="action-item">
<Button
label="Beides löschen"
icon="pi pi-times"
severity="danger"
size="small"
onClick={() => onDeleteFiles?.(job, 'both')}
loading={actionBusy}
disabled={(!job.rawStatus?.exists && !job.outputStatus?.exists) || typeof onDeleteFiles !== 'function'}
/>
<span className="action-desc">Löscht RAW-Quelldateien und Ausgabe gemeinsam.</span>
</div>
</div>
</div>
)}
{!queueLocked ? (
<div className="action-delete-entry">
<span className="action-desc">Entfernt diesen Eintrag aus der Verlaufsliste Dateien auf der Festplatte bleiben erhalten.</span>
<Button
label="Historieneintrag löschen"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
onClick={() => onDeleteEntry?.(job)}
loading={deleteEntryBusy}
disabled={!canDeleteEntry}
/>
</div>
) : null}
</div>
)}
</Dialog>
);
}