Files
ripster/frontend/src/components/JobDetailDialog.jsx
T
2026-04-10 18:44:41 +00:00

2326 lines
104 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';
import { isSeriesDvdJob } from '../utils/jobTaxonomy';
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 normalizeSeriesEpisodeStatus(status) {
const normalized = String(status || '').trim().toUpperCase();
if (['QUEUED', 'RUNNING', 'FINISHED', 'ERROR', 'CANCELLED'].includes(normalized)) {
return normalized;
}
return 'QUEUED';
}
function seriesEpisodeStatusMeta(status) {
const normalized = normalizeSeriesEpisodeStatus(status);
if (normalized === 'FINISHED') {
return { label: 'Fertig', severity: 'success' };
}
if (normalized === 'RUNNING') {
return { label: 'Läuft', severity: 'info' };
}
if (normalized === 'ERROR') {
return { label: 'Fehler', severity: 'danger' };
}
if (normalized === 'CANCELLED') {
return { label: 'Abgebrochen', severity: 'warning' };
}
return { label: 'Wartend', severity: 'secondary' };
}
function formatDateTimeOrDash(value) {
const text = String(value || '').trim();
if (!text) {
return '-';
}
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) {
return text;
}
return parsed.toLocaleString();
}
function mergeSeriesBatchEpisodes(job) {
const planEpisodes = Array.isArray(job?.encodePlan?.seriesBatchEpisodes)
? job.encodePlan.seriesBatchEpisodes
: [];
const hbEpisodes = Array.isArray(job?.handbrakeInfo?.seriesBatch?.episodes)
? job.handbrakeInfo.seriesBatch.episodes
: [];
const map = new Map();
const append = (list, sourcePriority) => {
for (const item of list) {
if (!item || typeof item !== 'object') {
continue;
}
const episodeIndex = normalizePositiveInteger(item?.episodeIndex);
const titleId = normalizePositiveInteger(item?.titleId);
const key = `${episodeIndex || 0}:${titleId || 0}`;
const existing = map.get(key);
if (!existing) {
map.set(key, {
...item,
episodeIndex,
titleId,
sourcePriority
});
continue;
}
if (sourcePriority >= Number(existing.sourcePriority || 0)) {
map.set(key, {
...existing,
...item,
episodeIndex: episodeIndex || existing.episodeIndex || null,
titleId: titleId || existing.titleId || null,
sourcePriority
});
}
}
};
append(planEpisodes, 1);
append(hbEpisodes, 2);
return Array.from(map.values())
.map((entry, index) => ({
...entry,
episodeIndex: normalizePositiveInteger(entry?.episodeIndex) || (index + 1),
titleId: normalizePositiveInteger(entry?.titleId) || null,
progress: Number.isFinite(Number(entry?.progress))
? Math.max(0, Math.min(100, Number(entry.progress)))
: 0,
status: normalizeSeriesEpisodeStatus(entry?.status)
}))
.sort((left, right) => left.episodeIndex - right.episodeIndex);
}
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';
}
if (raw === 'converter') {
return 'converter';
}
}
if (String(encodePlan?.mediaProfile || '').trim().toLowerCase() === 'converter') {
return 'converter';
}
if (String(job?.media_type || '').trim().toLowerCase() === 'converter') {
return 'converter';
}
if (resolveConverterMediaType(job)) {
return 'converter';
}
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 resolveConverterMediaType(job) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const candidates = [
encodePlan?.converterMediaType,
job?.converterMediaType,
job?.makemkvInfo?.converterMediaType,
job?.mediainfoInfo?.converterMediaType
];
for (const candidate of candidates) {
const raw = String(candidate || '').trim().toLowerCase();
if (!raw) {
continue;
}
if (raw === 'audio' || raw === 'video' || raw === 'iso') {
return raw;
}
}
return null;
}
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 normalizeMetadataProvider(value) {
return String(value || '').trim().toLowerCase() || 'omdb';
}
function resolveJobMetadataContext(job) {
const mkInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object'
? job.makemkvInfo
: {};
const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object'
? mkInfo.analyzeContext
: {};
const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: {};
const selectedMetadata = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object'
? mkInfo.selectedMetadata
: {};
const encodeMetadata = job?.encodePlan?.metadata && typeof job.encodePlan.metadata === 'object'
? job.encodePlan.metadata
: {};
const mergedSelectedMetadata = {
...analyzeSelectedMetadata,
...selectedMetadata,
...encodeMetadata
};
const metadataProvider = normalizeMetadataProvider(
mergedSelectedMetadata?.metadataProvider
|| analyzeContext?.metadataProvider
|| 'omdb'
);
return {
analyzeContext,
selectedMetadata: mergedSelectedMetadata,
metadataProvider
};
}
function uniqueNameList(values, maxItems = 10) {
const source = Array.isArray(values) ? values : [];
const limit = Math.max(1, Number(maxItems || 10));
const output = [];
const seen = new Set();
for (const item of source) {
const name = String(item?.name || item || '').trim();
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(name);
if (output.length >= limit) {
break;
}
}
return output;
}
function formatRuntimeLabel(value) {
if (Array.isArray(value)) {
const values = value
.map((entry) => Number(entry))
.filter((entry) => Number.isFinite(entry) && entry > 0)
.map((entry) => Math.trunc(entry));
if (values.length === 0) {
return null;
}
const min = Math.min(...values);
const max = Math.max(...values);
return min === max ? `${min} min` : `${min}-${max} min`;
}
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return `${Math.trunc(numeric)} min`;
}
const text = String(value || '').trim();
return text || null;
}
function resolveMetadataDetailsForDisplay(job, omdbInfo = {}) {
const metadataContext = resolveJobMetadataContext(job);
const provider = metadataContext.metadataProvider;
const selectedMetadata = metadataContext.selectedMetadata;
const tmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
? selectedMetadata.tmdbDetails
: {};
const isTmdbProvider = provider === 'tmdb' || provider === 'themoviedb';
if (isTmdbProvider) {
const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null;
const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null;
const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null;
const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime);
const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage);
const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0
? ratingNumber.toFixed(1)
: null;
const imdbId = String(selectedMetadata?.imdbId || tmdbDetails?.imdbId || '').trim() || null;
const hasMatch = Boolean(selectedMetadata?.tmdbId || tmdbDetails?.tmdbId);
return {
provider: 'tmdb',
title: 'TMDb Details',
matchLabel: 'TMDb Match',
hasMatch,
imdbId,
director: director || '-',
actors: actors || '-',
runtime: runtime || '-',
genre: genre || '-',
tmdbRating: tmdbRating || '-'
};
}
return {
provider: 'omdb',
title: 'OMDb Details',
matchLabel: 'OMDb Match',
hasMatch: Boolean(job?.selected_from_omdb),
imdbId: String(job?.imdb_id || '').trim() || null,
director: omdbField(omdbInfo?.Director),
actors: omdbField(omdbInfo?.Actors),
runtime: omdbField(omdbInfo?.Runtime),
genre: omdbField(omdbInfo?.Genre),
rottenTomatoes: omdbRottenTomatoesScore(omdbInfo),
imdbRating: omdbField(omdbInfo?.imdbRating)
};
}
function resolveSeriesDiscNumber(job) {
const metadataContext = resolveJobMetadataContext(job);
return normalizePositiveInteger(
metadataContext?.selectedMetadata?.discNumber
?? metadataContext?.analyzeContext?.seriesLookupHint?.discNumber
?? job?.encodePlan?.discNumber
?? null
);
}
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 TriState({ existing = 0, expected = 0, labels = {} }) {
const safeExpected = Number.isFinite(Number(expected)) ? Number(expected) : 0;
const safeExisting = Number.isFinite(Number(existing)) ? Number(existing) : 0;
if (safeExpected <= 0) {
return <BoolState value={safeExisting > 0} />;
}
if (safeExisting <= 0) {
return (
<span className="job-step-inline-no" title={labels.none || `Nein (${safeExisting}/${safeExpected})`}>
<i className="pi pi-times-circle" aria-hidden="true" />
</span>
);
}
if (safeExisting < safeExpected) {
return (
<span className="job-step-inline-warn" title={labels.partial || `Teilweise (${safeExisting}/${safeExpected})`}>
<i className="pi pi-exclamation-circle" aria-hidden="true" />
</span>
);
}
return (
<span className="job-step-inline-ok" title={labels.full || `Ja (${safeExisting}/${safeExpected})`}>
<i className="pi pi-check-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,
onAcknowledgeError,
onResumeReady,
onRestartEncode,
onRestartReview,
onRestartCdReview,
onReencode,
onRetry,
onDeleteFiles,
onDeleteEntry,
onDownloadArchive,
onDownloadOutputFolder,
onRemoveFromQueue,
onCancel,
isQueued = false,
omdbAssignBusy = false,
cdMetadataAssignBusy = false,
acknowledgeErrorBusy = false,
actionBusy = false,
cancelBusy = false,
reencodeBusy = false,
deleteEntryBusy = false,
downloadBusyTarget = null,
downloadFolderBusyPath = null
}) {
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
const statusUpper = String(job?.status || '').trim().toUpperCase();
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(statusUpper);
const softCancelable = ['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP'].includes(statusUpper);
const showCancelAction = (running || softCancelable) && typeof onCancel === 'function';
const showFinalLog = !running;
const mediaType = resolveMediaType(job);
const isDvdSeries = mediaType === 'dvd' && isSeriesDvdJob(job);
const seriesBatchEpisodes = isDvdSeries ? mergeSeriesBatchEpisodes(job) : [];
const seriesEpisodeAssignments = isDvdSeries && job?.encodePlan?.episodeAssignments && typeof job.encodePlan.episodeAssignments === 'object'
? job.encodePlan.episodeAssignments
: {};
const seriesBatchSummary = seriesBatchEpisodes.reduce((acc, episode) => {
const status = normalizeSeriesEpisodeStatus(episode?.status);
if (status === 'FINISHED') {
acc.finished += 1;
} else if (status === 'ERROR') {
acc.error += 1;
} else if (status === 'CANCELLED') {
acc.cancelled += 1;
} else if (status === 'RUNNING') {
acc.running += 1;
} else {
acc.queued += 1;
}
return acc;
}, {
total: seriesBatchEpisodes.length,
finished: 0,
error: 0,
cancelled: 0,
running: 0,
queued: 0
});
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const isConverter = mediaType === 'converter';
const converterMediaType = isConverter ? (resolveConverterMediaType(job) || 'video') : null;
const converterMediaTypeLabel = converterMediaType === 'audio'
? 'Audio'
: (converterMediaType === 'iso' ? 'ISO (Video)' : 'Video');
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 converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object'
? job.encodePlan
: {};
const converterMetadata = isConverter && converterPlan?.metadata && typeof converterPlan.metadata === 'object'
? converterPlan.metadata
: {};
const converterInputPaths = isConverter
? (
Array.isArray(converterPlan?.inputPaths) && converterPlan.inputPaths.length > 0
? converterPlan.inputPaths
: [converterPlan?.inputPath || job?.raw_path]
)
.map((value) => String(value || '').trim())
.filter(Boolean)
: [];
const converterTrackList = isConverter && Array.isArray(converterPlan?.tracks)
? converterPlan.tracks
: [];
const converterOutputFormat = String(
converterPlan?.outputFormat
|| job?.handbrakeInfo?.format
|| converterPlan?.format
|| ''
).trim().toLowerCase();
const converterPresetLabel = String(
converterPlan?.userPreset?.name
|| converterPlan?.userPreset?.handbrakePreset
|| ''
).trim() || null;
const converterAudioQualityLabel = isConverter && converterMediaType === 'audio'
? (() => {
const opts = converterPlan?.audioFormatOptions && typeof converterPlan.audioFormatOptions === 'object'
? converterPlan.audioFormatOptions
: {};
if (converterOutputFormat === 'flac') {
return `Kompression ${Number(opts?.flacCompression ?? 5)}`;
}
if (converterOutputFormat === 'mp3') {
const mode = String(opts?.mp3Mode || 'cbr').trim().toLowerCase();
if (mode === 'vbr') {
return `VBR V${Number(opts?.mp3Quality ?? 4)}`;
}
return `CBR ${Number(opts?.mp3Bitrate ?? 192)} kbps`;
}
if (converterOutputFormat === 'opus') {
return `${Number(opts?.opusBitrate ?? 160)} kbps`;
}
if (converterOutputFormat === 'ogg') {
return `Qualität ${Number(opts?.oggQuality ?? 6)}`;
}
return converterOutputFormat ? converterOutputFormat.toUpperCase() : '-';
})()
: null;
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'
? (isDvdSeries ? 'DVD Serie' : 'DVD')
: isCd
? 'Audio CD'
: (isAudiobook ? 'Audiobook' : (isConverter ? `Converter ${converterMediaTypeLabel}` : '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 metadataContext = resolveJobMetadataContext(job);
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
const childJobs = Array.isArray(job?.children) ? job.children : [];
const isSeriesContainer = isDvdSeries && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
const seriesChildSummary = job?.seriesChildSummary || null;
const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null;
const seriesEncodeSummary = isSeriesContainer ? seriesChildSummary?.encode : null;
const displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null;
const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info';
const metadataJsonValue = metadataDetails.provider === 'tmdb'
? (metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null)
: job?.omdbInfo;
const metadataAssignProvider = isDvdSeries
? 'tmdb'
: (metadataDetails.provider === 'tmdb' ? 'tmdb' : 'omdb');
const metadataAssignButtonLabel = metadataAssignProvider === 'tmdb' ? 'TMDb neu zuweisen' : 'OMDb neu zuweisen';
const metadataAssignActionDescription = metadataAssignProvider === 'tmdb'
? 'Öffnet die TMDb-Suche, um Serien-Metadaten (Titel, Staffel, Episoden, Poster) neu zuzuweisen.'
: 'Öffnet die OMDb-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.';
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 encodePlanTitles = Array.isArray(job?.encodePlan?.titles) ? job.encodePlan.titles : [];
const selectedEncodeTitles = encodePlanTitles.filter(
(title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId)
);
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
const canDownloadRaw = Boolean(resolvedRawPath && 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);
const canShowGeneralEncodeActions = canResumeReady
|| typeof onRestartEncode === 'function'
|| typeof onRestartReview === 'function'
|| typeof onReencode === 'function';
const useAudioPosterLayout = isCd || isAudiobook || (isConverter && converterMediaType === 'audio');
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={useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} />
) : (
<div className={`${useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} poster-fallback`}>{useAudioPosterLayout ? '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>
) : isConverter ? (
<section className="job-meta-block job-meta-block-film">
<h4>{converterMediaType === 'audio' ? 'Converter Audio' : 'Converter Video'}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Titel:</strong>
<span>{job.title || converterMetadata?.albumTitle || (job?.id ? `Job #${job.id}` : '-')}</span>
</div>
<div className="job-meta-item">
<strong>Typ:</strong>
<span>{converterMediaTypeLabel}</span>
</div>
<div className="job-meta-item">
<strong>Eingaben:</strong>
<span>{converterInputPaths.length > 0 ? `${converterInputPaths.length} Datei${converterInputPaths.length !== 1 ? 'en' : ''}` : '-'}</span>
</div>
<div className="job-meta-item">
<strong>Format:</strong>
<span>{converterOutputFormat ? converterOutputFormat.toUpperCase() : '-'}</span>
</div>
{converterMediaType === 'audio' ? (
<>
<div className="job-meta-item">
<strong>Album:</strong>
<span>{converterMetadata?.albumTitle || job?.title || (job?.id ? `Job #${job.id}` : '-')}</span>
</div>
<div className="job-meta-item">
<strong>Interpret:</strong>
<span>{converterMetadata?.albumArtist || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Jahr:</strong>
<span>{converterMetadata?.albumYear || job?.year || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Tracks:</strong>
<span>{converterTrackList.length > 0 ? converterTrackList.length : converterInputPaths.length || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Qualität:</strong>
<span>{converterAudioQualityLabel || '-'}</span>
</div>
</>
) : (
<>
<div className="job-meta-item">
<strong>Preset:</strong>
<span>{converterPresetLabel || '-'}</span>
</div>
<div className="job-meta-item">
<strong>HandBrake Titel:</strong>
<span>{converterPlan?.handBrakeTitleId || '-'}</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' : (isDvdSeries ? 'Serieninfos' : 'Film-Infos')}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Titel:</strong>
<span>{job.title || job.detected_title || '-'}</span>
</div>
{isDvdSeries ? (
<div className="job-meta-item">
<strong>Staffel:</strong>
<span>{metadataContext?.selectedMetadata?.seasonNumber ?? '-'}</span>
</div>
) : null}
{isDvdSeries ? (
<div className="job-meta-item">
<strong>{isSeriesContainer ? 'Container' : 'Disk'}:</strong>
<span>{isSeriesContainer ? 'Ja' : (seriesDiscNumber ? `Disk ${seriesDiscNumber}` : '-')}</span>
</div>
) : null}
<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>{displayedImdbId || '-'}</span>
</div>
{isDvdSeries ? (
<div className="job-meta-item">
<strong>TMDb:</strong>
<span>{metadataContext?.selectedMetadata?.tmdbId || '-'}</span>
</div>
) : null}
<div className="job-meta-item">
<strong>{metadataDetails.matchLabel}:</strong>
<BoolState value={metadataDetails.hasMatch} />
</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>{metadataDetails.title}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Regisseur:</strong>
<span>{omdbField(metadataDetails.director)}</span>
</div>
<div className="job-meta-item">
<strong>Schauspieler:</strong>
<span>{omdbField(metadataDetails.actors)}</span>
</div>
<div className="job-meta-item">
<strong>Laufzeit:</strong>
<span>{omdbField(metadataDetails.runtime)}</span>
</div>
<div className="job-meta-item">
<strong>Genre:</strong>
<span>{omdbField(metadataDetails.genre)}</span>
</div>
{metadataDetails.provider === 'tmdb' ? (
<div className="job-meta-item">
<strong>TMDb Rating:</strong>
<span>{omdbField(metadataDetails.tmdbRating)}</span>
</div>
) : (
<>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbField(metadataDetails.rottenTomatoes)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(metadataDetails.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 || isConverter ? 'Import:' : 'Backup:'}</strong> {isSeriesContainer && seriesBackupSummary ? (
<TriState existing={seriesBackupSummary.existing} expected={seriesBackupSummary.expected} />
) : (
<BoolState value={job?.backupSuccess} />
)}</span>
<span className="job-infos-sep">|</span>
<span><strong>Encode:</strong> {isSeriesContainer && seriesEncodeSummary ? (
<TriState existing={seriesEncodeSummary.existing} expected={seriesEncodeSummary.expected} />
) : (
<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:' : (isConverter ? 'Input:' : 'RAW:')}
value={isSeriesContainer ? null : (isCd ? (job.raw_path || job.output_path) : resolvedRawPath)}
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
downloadDisabled={!canDownloadRaw}
downloadLoading={downloadBusyTarget === 'raw'}
/>
{isSeriesContainer && childJobs.length > 0 ? (
childJobs.map((child) => {
const childDiscNumber = resolveSeriesDiscNumber(child);
const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:';
const childRawPath = child?.raw_path || child?.output_path || null;
const canDownloadChildRaw = Boolean(childRawPath && child?.rawStatus?.exists && typeof onDownloadArchive === 'function');
return (
<PathField
key={`child-raw-${child.id}`}
label={childLabel}
value={childRawPath}
onDownload={canDownloadChildRaw ? () => onDownloadArchive?.(child, 'raw') : null}
downloadDisabled={!canDownloadChildRaw}
downloadLoading={downloadBusyTarget === 'raw'}
/>
);
})
) : null}
{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 style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<span><strong>Fehler:</strong> {job.error_message}</span>
{typeof onAcknowledgeError === 'function' ? (
<Button
icon="pi pi-check-circle"
rounded
text
size="small"
severity="success"
title="Fehler quittieren"
aria-label="Fehler quittieren"
onClick={() => onAcknowledgeError(job)}
loading={acknowledgeErrorBusy}
disabled={acknowledgeErrorBusy}
/>
) : null}
</div>
) : null}
</div>
</section>
{isConverter ? (
<section className="job-meta-block job-meta-block-full">
<h4>Encode-Konfiguration</h4>
<div className="job-meta-grid job-meta-grid-compact">
<div>
<strong>Typ:</strong> {converterMediaTypeLabel}
</div>
<div>
<strong>Format:</strong> {converterOutputFormat ? converterOutputFormat.toUpperCase() : '-'}
</div>
<div>
<strong>Eingabe-Modus:</strong> {converterPlan?.isFolder ? 'Ordner' : (converterPlan?.isSharedAudio ? 'Gemeinsam' : 'Einzeln')}
</div>
<div>
<strong>Eingabe-Dateien:</strong> {converterInputPaths.length > 0 ? converterInputPaths.length : '-'}
</div>
<div>
<strong>Preset:</strong> {converterPresetLabel || '-'}
</div>
<div>
<strong>HandBrake Titel:</strong> {converterPlan?.handBrakeTitleId || '-'}
</div>
</div>
{converterMediaType === 'audio' ? (
<div className="job-meta-grid job-meta-grid-compact">
<div>
<strong>Album:</strong> {converterMetadata?.albumTitle || job?.title || (job?.id ? `Job #${job.id}` : '-')}
</div>
<div>
<strong>Interpret:</strong> {converterMetadata?.albumArtist || '-'}
</div>
<div>
<strong>Jahr:</strong> {converterMetadata?.albumYear || job?.year || '-'}
</div>
<div>
<strong>Qualität:</strong> {converterAudioQualityLabel || '-'}
</div>
</div>
) : null}
{converterInputPaths.length > 0 ? (
<div className="track-group">
{converterInputPaths.map((inputPath, index) => (
<div key={`${inputPath}-${index}`} className="track-item">
<span>#{index + 1} | {inputPath}</span>
</div>
))}
</div>
) : null}
{converterMediaType === 'audio' && converterTrackList.length > 0 ? (
<div className="track-group">
{converterTrackList.map((track, index) => {
const position = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : (index + 1);
const title = String(track?.title || '').trim() || `Track ${position}`;
const artist = String(track?.artist || '').trim();
return (
<div key={`${position}-${title}`} className="track-item">
<span>#{position} | {artist ? `${artist} - ` : ''}{title}</span>
</div>
);
})}
</div>
) : null}
</section>
) : null}
{isDvdSeries && seriesBatchEpisodes.length > 0 ? (
<section className="job-meta-block job-meta-block-full">
<h4>Serien-Episoden</h4>
<div className="series-batch-progress-wrap">
<div className="series-batch-head">
<div>
<strong>Gesamt:</strong>{' '}
{`${seriesBatchSummary.finished}/${seriesBatchSummary.total} fertig`}
{seriesBatchSummary.running > 0 ? ` | laufend: ${seriesBatchSummary.running}` : ''}
{seriesBatchSummary.queued > 0 ? ` | wartend: ${seriesBatchSummary.queued}` : ''}
{seriesBatchSummary.error > 0 ? ` | fehler: ${seriesBatchSummary.error}` : ''}
{seriesBatchSummary.cancelled > 0 ? ` | abgebrochen: ${seriesBatchSummary.cancelled}` : ''}
</div>
</div>
<div className="series-batch-episodes">
{seriesBatchEpisodes.map((episode) => {
const assignment = seriesEpisodeAssignments[String(episode?.titleId)]
|| seriesEpisodeAssignments[Number(episode?.titleId)]
|| null;
const statusMeta = seriesEpisodeStatusMeta(episode?.status);
const episodeLabel = String(
episode?.label
|| assignment?.episodeTitle
|| `Episode ${episode?.episodeIndex || '?'}`
).trim();
const episodeCode = (() => {
const seasonNo = normalizePositiveInteger(assignment?.seasonNumber ?? episode?.seasonNumber);
const episodeNoRaw = Number(assignment?.episodeNumber ?? episode?.episodeNumber);
const episodeNo = Number.isFinite(episodeNoRaw) && episodeNoRaw > 0
? episodeNoRaw
: null;
if (!seasonNo && !episodeNo) {
return null;
}
const seasonToken = seasonNo ? String(seasonNo).padStart(2, '0') : '--';
const episodeToken = episodeNo
? (
Number.isInteger(episodeNo)
? String(Math.trunc(episodeNo)).padStart(2, '0')
: String(episodeNo)
)
: '--';
return `S${seasonToken}E${episodeToken}`;
})();
const trackSelection = episode?.trackSelection && typeof episode.trackSelection === 'object'
? episode.trackSelection
: null;
const audioTrackIds = Array.isArray(trackSelection?.audioTrackIds) ? trackSelection.audioTrackIds : [];
const subtitleTrackIds = Array.isArray(trackSelection?.subtitleTrackIds) ? trackSelection.subtitleTrackIds : [];
const subtitleForcedIndexes = Array.isArray(trackSelection?.subtitleForcedTrackIndexes)
? trackSelection.subtitleForcedTrackIndexes
: [];
const episodeCommand = buildExecutedHandBrakeCommand(episode?.handbrakeInfo);
const boundedProgress = Number.isFinite(Number(episode?.progress))
? Math.max(0, Math.min(100, Number(episode.progress)))
: 0;
return (
<details key={`series-episode-${episode.episodeIndex}-${episode.titleId || 'na'}`} className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">
{`#${episode?.episodeIndex || '?'} | ${episodeLabel}${episodeCode ? ` | ${episodeCode}` : ''}${seriesDiscNumber ? ` | Disk ${seriesDiscNumber}` : ''}`}
</span>
<Tag value={statusMeta.label} severity={statusMeta.severity} />
</summary>
<div className="track-group">
<div className="track-item">
<span><strong>Titel-ID:</strong> {episode?.titleId || '-'}</span>
</div>
<div className="track-item">
<span><strong>Fortschritt:</strong> {`${boundedProgress.toFixed(2)}%`}</span>
</div>
<div className="track-item">
<span><strong>Gestartet:</strong> {formatDateTimeOrDash(episode?.startedAt)}</span>
</div>
<div className="track-item">
<span><strong>Beendet:</strong> {formatDateTimeOrDash(episode?.finishedAt)}</span>
</div>
<div className="track-item">
<span><strong>Audio-Spuren:</strong> {audioTrackIds.length > 0 ? audioTrackIds.join(', ') : '-'}</span>
</div>
<div className="track-item">
<span><strong>Subtitle-Spuren:</strong> {subtitleTrackIds.length > 0 ? subtitleTrackIds.join(', ') : '-'}</span>
</div>
<div className="track-item">
<span><strong>Subtitle Forced-Index:</strong> {subtitleForcedIndexes.length > 0 ? subtitleForcedIndexes.join(', ') : '-'}</span>
</div>
{episode?.outputPath ? (
<div className="track-item">
<span><strong>Output:</strong> {episode.outputPath}</span>
</div>
) : null}
{episode?.error ? (
<div className="track-item">
<span><strong>Fehler:</strong> {episode.error}</span>
</div>
) : null}
{episodeCommand ? (
<div className="track-item">
<span><strong>Command:</strong> {episodeCommand}</span>
</div>
) : null}
<div className="track-item">
<small className="track-action-note">Hinweis: Alle Episoden-Logs liegen im Hauptjob-Log.</small>
</div>
</div>
</details>
);
})}
</div>
</div>
</section>
) : null}
{!isCd && !isAudiobook && !isConverter && (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 */}
{selectedEncodeTitles.length > 0 ? (
isDvdSeries ? (
<details className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{seriesDiscLabel}</span>
</summary>
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{selectedEncodeTitles.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>
</details>
) : (
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{selectedEncodeTitles.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>
{isSeriesContainer && childJobs.length > 0 ? (
<>
<div className="job-json-grid">
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
</div>
{childJobs.map((child) => {
const childDiscNumber = resolveSeriesDiscNumber(child);
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
return (
<details key={`child-log-${child.id}`} className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{childDiscLabel}</span>
</summary>
<div className="job-json-grid">
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={child.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={child.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={child.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={child.handbrakeInfo} />
</div>
</details>
);
})}
</>
) : isDvdSeries ? (
<details className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{seriesDiscLabel}</span>
</summary>
<div className="job-json-grid">
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : 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' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
</div>
</details>
) : (
<div className="job-json-grid">
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : 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' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode 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 ? (
isSeriesContainer && childJobs.length > 0 ? (
<div className="log-box">
{childJobs.map((child) => {
const childDiscNumber = resolveSeriesDiscNumber(child);
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
return (
<details key={`child-log-text-${child.id}`} className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{childDiscLabel}</span>
</summary>
<pre>{child.log || ''}</pre>
</details>
);
})}
</div>
) : (
<pre className="log-box">{job.log || ''}</pre>
)
) : (
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
)}
</>
) : (
<p>Live-Log wird nur im Ripper 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">
{showCancelAction ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-ban" /> {running ? 'Laufender Job' : 'Wartender Job'}</div>
<div className="action-item">
<Button
label="Job abbrechen"
icon="pi pi-times"
severity="warning"
size="small"
onClick={() => onCancel?.(job)}
loading={cancelBusy}
/>
<span className="action-desc">
{running
? 'Bricht den aktuell laufenden Job sofort ab.'
: `Bricht den wartenden Job im Status ${statusUpper || '-'} ab.`}
</span>
</div>
</div>
) : null}
{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>
) : (
canShowGeneralEncodeActions ? (
<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 Ripper ö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 Ripper 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}
{typeof onReencode === 'function' ? (
<div className="action-item">
<Button
label="Neustart"
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode}
/>
<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>
) : null}
</div>
) : null
)}
{!isCd && !isAudiobook && !isConverter && 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={metadataAssignButtonLabel}
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onAssignOmdb?.(job)}
loading={omdbAssignBusy}
disabled={running}
/>
<span className="action-desc">{metadataAssignActionDescription}</span>
</div>
</div>
) : null}
{typeof onDeleteFiles === 'function' ? (
<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}
/>
<span className="action-desc">Löscht die Quelldateien nach dem Rip ({isCd ? 'Audio-Rohdaten' : isAudiobook ? 'AAX/MP3-Quelldatei' : isConverter ? 'Quelldatei aus dem Converter-Import' : 'MKV-Datei von MakeMKV'}).</span>
</div>
<div className="action-item">
<Button
label={isCd ? 'Audio löschen' : (isAudiobook ? 'Ausgabe löschen' : (isConverter ? 'Output löschen' : 'Movie löschen'))}
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(job, 'movie')}
loading={actionBusy}
disabled={!hasAnyOutputFolder}
/>
<span className="action-desc">Löscht {isCd ? 'die fertig gerippten Audiodateien' : isAudiobook ? 'die fertig konvertierte Audiobook-Ausgabe' : isConverter ? 'die fertig konvertierte 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}
/>
<span className="action-desc">Löscht RAW-Quelldateien und Ausgabe gemeinsam.</span>
</div>
</div>
) : null}
</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>
);
}