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