2326 lines
104 KiB
React
2326 lines
104 KiB
React
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>
|
||
);
|
||
}
|