Files
ripster/frontend/src/components/ConverterJobCard.jsx
T
michael 9b437fe5e5
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
0.16.1-7 Release-Bugfixes
2026-05-08 08:10:34 +02:00

1558 lines
65 KiB
React

import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { InputNumber } from 'primereact/inputnumber';
import { InputText } from 'primereact/inputtext';
import { ProgressBar } from 'primereact/progressbar';
import { Slider } from 'primereact/slider';
import { Tag } from 'primereact/tag';
import { Message } from 'primereact/message';
import { api } from '../api/client';
import MetadataSelectionDialog from './MetadataSelectionDialog';
import CdMetadataDialog from './CdMetadataDialog';
import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas';
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal';
const VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' },
{ label: 'MP4', value: 'mp4' },
{ label: 'M4V', value: 'm4v' }
];
// ── Shared helpers (mirrored from CdRipConfigPanel) ───────────────────────────
function normalizeTrackText(value) {
return String(value || '')
.normalize('NFC')
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function isFieldVisible(field, values) {
if (!field.showWhen) return true;
return values[field.showWhen.field] === field.showWhen.value;
}
function FormatField({ field, value, onChange }) {
if (field.type === 'slider') {
return (
<div className="cd-format-field">
<label>{field.label}: <strong>{value}</strong></label>
{field.description ? <small>{field.description}</small> : null}
<Slider
value={value}
onChange={(e) => onChange(field.key, e.value)}
min={field.min}
max={field.max}
step={field.step || 1}
/>
</div>
);
}
if (field.type === 'select') {
return (
<div className="cd-format-field">
<label>{field.label}</label>
{field.description ? <small>{field.description}</small> : null}
<Dropdown
value={value}
options={field.options}
optionLabel="label"
optionValue="value"
onChange={(e) => onChange(field.key, e.value)}
/>
</div>
);
}
return null;
}
function normalizeScriptId(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : null;
}
function normalizeChainId(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : null;
}
function normalizeIdList(values, kind = 'script') {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value);
if (normalized === null) continue;
const key = String(normalized);
if (seen.has(key)) continue;
seen.add(key);
output.push(normalized);
}
return output;
}
function buildEncodeItemsFromConfig(config, phase) {
const source = config && typeof config === 'object' ? config : {};
const prefix = phase === 'post' ? 'post' : 'pre';
const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : [];
const fromExplicit = explicitItems
.map((item) => {
const type = String(item?.type || '').trim().toLowerCase();
if (type !== 'script' && type !== 'chain') return null;
const id = type === 'chain'
? normalizeChainId(item?.id ?? item?.chainId)
: normalizeScriptId(item?.id ?? item?.scriptId);
if (!id) return null;
return { type, id };
})
.filter(Boolean);
if (fromExplicit.length > 0) return fromExplicit;
const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script');
const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain');
return [
...scriptIds.map((id) => ({ type: 'script', id })),
...chainIds.map((id) => ({ type: 'chain', id }))
];
}
function normalizeTrackStageStatus(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') return 'done';
if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') return 'in_progress';
if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') return 'error';
return 'pending';
}
function trackStatusTagMeta(value) {
const normalized = normalizeTrackStageStatus(value);
if (normalized === 'done') return { label: 'Fertig', severity: 'success' };
if (normalized === 'in_progress') return { label: 'Läuft', severity: 'info' };
if (normalized === 'error') return { label: 'Fehler', severity: 'danger' };
return { label: 'Offen', severity: 'secondary' };
}
// ── Helper: derive audio tracks from plan ─────────────────────────────────────
function deriveAudioTracks(plan) {
if (Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0) {
return plan.inputPaths.map((p, i) => ({
position: i + 1,
filePath: p,
defaultTitle: p.split('/').pop().replace(/\.[^.]+$/, '')
}));
}
const p = plan?.inputPath || '';
if (p && !plan?.isFolder) {
return [{ position: 1, filePath: p, defaultTitle: p.split('/').pop().replace(/\.[^.]+$/, '') }];
}
return [];
}
function normalizePositiveInt(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function buildVideoMetadataPayload(sourceMetadata, fallbackTitle = '') {
const source = sourceMetadata && typeof sourceMetadata === 'object' ? sourceMetadata : {};
const normalized = {
title: String(source?.title || fallbackTitle || '').trim() || null,
year: Number.isFinite(Number(source?.year)) ? Math.trunc(Number(source.year)) : null,
imdbId: String(source?.imdbId || '').trim() || null,
poster: String(source?.poster || '').trim() || null
};
const metadataProvider = String(source?.metadataProvider || '').trim() || null;
const workflowKind = String(source?.workflowKind || '').trim() || null;
const providerId = String(source?.providerId || '').trim() || null;
const metadataKind = String(source?.metadataKind || '').trim() || null;
const seasonName = String(source?.seasonName || '').trim() || null;
const imdbRating = String(source?.imdbRating || '').trim() || null;
const tmdbId = normalizePositiveInt(source?.tmdbId);
const seasonNumber = normalizePositiveInt(source?.seasonNumber);
const discNumber = normalizePositiveInt(source?.discNumber);
const episodeCount = Number(source?.episodeCount || 0);
const voteAverageRaw = Number(source?.voteAverage || 0);
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
? Number(voteAverageRaw.toFixed(1))
: null;
const episodes = Array.isArray(source?.episodes) ? source.episodes : [];
const tmdbDetails = source?.tmdbDetails && typeof source.tmdbDetails === 'object' && !Array.isArray(source.tmdbDetails)
? source.tmdbDetails
: null;
if (metadataProvider) normalized.metadataProvider = metadataProvider;
if (workflowKind) normalized.workflowKind = workflowKind;
if (providerId) normalized.providerId = providerId;
if (metadataKind) normalized.metadataKind = metadataKind;
if (tmdbId) normalized.tmdbId = tmdbId;
if (seasonNumber) normalized.seasonNumber = seasonNumber;
if (seasonName) normalized.seasonName = seasonName;
if (discNumber) normalized.discNumber = discNumber;
if (Number.isFinite(episodeCount) && episodeCount > 0) normalized.episodeCount = Math.trunc(episodeCount);
if (episodes.length > 0) normalized.episodes = episodes;
if (voteAverage !== null) normalized.voteAverage = voteAverage;
if (imdbRating) normalized.imdbRating = imdbRating;
if (tmdbDetails) normalized.tmdbDetails = tmdbDetails;
return normalized;
}
// ── Status helpers ────────────────────────────────────────────────────────────
function mediaTypeBadge(type) {
const map = {
video: { label: 'Video', severity: 'info' },
audio: { label: 'Audio', severity: 'success' },
iso: { label: 'ISO', severity: 'warning' }
};
const m = map[type] || { label: type || '?', severity: 'secondary' };
return <Tag value={m.label} severity={m.severity} style={{ fontSize: 11 }} />;
}
function isActiveStatus(status) {
return ['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK'].includes(String(status || '').toUpperCase());
}
function isTerminal(status) {
return ['DONE', 'FINISHED', 'ERROR', 'CANCELLED'].includes(String(status || '').toUpperCase());
}
// ── Inline-Konfiguration ──────────────────────────────────────────────────────
function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsChanged }) {
const converterMediaType = plan?.converterMediaType || 'video';
const isVideo = converterMediaType === 'video' || converterMediaType === 'iso';
const isAudio = converterMediaType === 'audio';
const planMetadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {};
const planTracks = Array.isArray(plan?.tracks) ? plan.tracks : [];
const planMusicBrainz = plan?.musicBrainz && typeof plan.musicBrainz === 'object' ? plan.musicBrainz : {};
// ── Format state ────────────────────────────────────────────────────────────
const audioFormatValues = new Set(CD_FORMATS.map((e) => e.value));
const videoFormatValues = new Set(VIDEO_OUTPUT_FORMATS.map((e) => e.value));
const initialOutputFormat = String(plan?.outputFormat || (isAudio ? 'flac' : 'mkv')).trim().toLowerCase();
const [outputFormat, setOutputFormat] = useState(
isAudio
? (audioFormatValues.has(initialOutputFormat) ? initialOutputFormat : 'flac')
: (videoFormatValues.has(initialOutputFormat) ? initialOutputFormat : 'mkv')
);
const [audioFormatOptions, setAudioFormatOptions] = useState(() => ({
...getDefaultFormatOptions(initialOutputFormat),
...(plan?.audioFormatOptions && typeof plan.audioFormatOptions === 'object' ? plan.audioFormatOptions : {})
}));
// Sync format options defaults when format changes
useEffect(() => {
if (!isAudio) return;
setAudioFormatOptions((prev) => ({
...getDefaultFormatOptions(outputFormat),
...prev
}));
}, [outputFormat, isAudio]);
// ── Video presets ───────────────────────────────────────────────────────────
const [userPresets, setUserPresets] = useState([]);
const [hbPresets, setHbPresets] = useState([]);
const [selectedUserPreset, setSelectedUserPreset] = useState(
Number.isFinite(Number(plan?.userPreset?.id)) ? Math.trunc(Number(plan.userPreset.id)) : null
);
const [selectedHbPreset, setSelectedHbPreset] = useState(
String(plan?.userPreset?.handbrakePreset || '').trim()
);
// ── Audio tracks ────────────────────────────────────────────────────────────
const audioTrackInputPathsKey = Array.isArray(plan?.inputPaths) ? plan.inputPaths.join('|') : '';
const audioTracks = useMemo(
() => deriveAudioTracks(plan),
// eslint-disable-next-line react-hooks/exhaustive-deps
[plan?.inputPath, plan?.isFolder, audioTrackInputPathsKey]
);
// ── Audio metadata ──────────────────────────────────────────────────────────
const [albumTitle, setAlbumTitle] = useState(
String(planMetadata?.albumTitle || job.title || job.detected_title || '').trim()
);
const [albumArtist, setAlbumArtist] = useState(String(planMetadata?.albumArtist || '').trim());
const [albumYear, setAlbumYear] = useState(() => {
const y = Number(planMetadata?.albumYear);
return Number.isFinite(y) ? Math.trunc(y) : null;
});
const [coverUrl, setCoverUrl] = useState(
String(planMetadata?.coverUrl || job?.poster_url || '').trim() || null
);
const [trackFields, setTrackFields] = useState(() => {
const map = {};
for (const { position, defaultTitle } of audioTracks) {
const savedTrack = planTracks.find((t) => Number(t?.position) === Number(position)) || {};
map[position] = {
title: String(savedTrack?.title || defaultTitle || '').trim(),
artist: String(savedTrack?.artist || '').trim()
};
}
return map;
});
// ── MusicBrainz ─────────────────────────────────────────────────────────────
const [selectedMb, setSelectedMb] = useState(
planMusicBrainz?.selected && typeof planMusicBrainz.selected === 'object'
? planMusicBrainz.selected
: null
);
const [mbQuery] = useState(String(planMusicBrainz?.query || '').trim());
const [mbResults] = useState(Array.isArray(planMusicBrainz?.results) ? planMusicBrainz.results : []);
// ── Video metadata ──────────────────────────────────────────────────────────
const [videoMetadata, setVideoMetadata] = useState(() => ({
...buildVideoMetadataPayload(
planMetadata,
String(job.title || job.detected_title || '').trim()
)
}));
// ── Script / Chain catalog ──────────────────────────────────────────────────
const [scriptCatalog, setScriptCatalog] = useState([]);
const [chainCatalog, setChainCatalog] = useState([]);
const [preItems, setPreItems] = useState(() => buildEncodeItemsFromConfig(plan, 'pre'));
const [postItems, setPostItems] = useState(() => buildEncodeItemsFromConfig(plan, 'post'));
useEffect(() => {
if (!isAudio) return;
let cancelled = false;
const loadCatalog = async () => {
try {
const [scriptsRes, chainsRes] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]);
if (cancelled) return;
setScriptCatalog(
scriptsRes.status === 'fulfilled'
? (Array.isArray(scriptsRes.value?.scripts) ? scriptsRes.value.scripts : []).map((s) => ({ id: s.id, name: s.name }))
: []
);
setChainCatalog(
chainsRes.status === 'fulfilled'
? (Array.isArray(chainsRes.value?.chains) ? chainsRes.value.chains : []).map((c) => ({ id: c.id, name: c.name }))
: []
);
} catch {
if (!cancelled) { setScriptCatalog([]); setChainCatalog([]); }
}
};
void loadCatalog();
return () => { cancelled = true; };
}, [isAudio]);
// ── Misc state ──────────────────────────────────────────────────────────────
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [searchDialogBusy, setSearchDialogBusy] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [draftSaving, setDraftSaving] = useState(false);
const [draftError, setDraftError] = useState(null);
const saveTimeoutRef = useRef(null);
const initializedRef = useRef(false);
const latestSavedRef = useRef(null);
// ── Video presets loader ────────────────────────────────────────────────────
useEffect(() => {
if (!isVideo) return;
api.getUserPresets?.('video')
.then((d) => setUserPresets(Array.isArray(d?.presets) ? d.presets : []))
.catch(() => setUserPresets([]));
api.getHandBrakePresets?.()
.then((d) => setHbPresets(Array.isArray(d?.presets) ? d.presets : []))
.catch(() => setHbPresets([]));
}, [isVideo]);
// ── Sync trackFields when audioTracks change ────────────────────────────────
useEffect(() => {
if (!isAudio || audioTracks.length === 0) return;
setTrackFields((prev) => {
const next = {};
for (const { position, defaultTitle } of audioTracks) {
const savedTrack = planTracks.find((t) => Number(t?.position) === Number(position)) || {};
const existing = prev[position] || null;
next[position] = {
title: String(existing?.title || savedTrack?.title || defaultTitle || '').trim(),
artist: String(existing?.artist || savedTrack?.artist || '').trim()
};
}
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length === nextKeys.length) {
const equal = nextKeys.every((key) => (
String(prev[key]?.title || '') === String(next[key]?.title || '')
&& String(prev[key]?.artist || '') === String(next[key]?.artist || '')
));
if (equal) return prev;
}
return next;
});
}, [isAudio, audioTracks, planTracks]);
// ── Draft payload ───────────────────────────────────────────────────────────
const draftPayload = useMemo(() => {
let userPreset = null;
if (selectedUserPreset) {
const preset = userPresets.find((p) => Number(p?.id) === Number(selectedUserPreset));
userPreset = preset
? { id: preset.id, handbrakePreset: preset.handbrake_preset || null, extraArgs: preset.extra_args || '' }
: { id: selectedUserPreset };
} else if (selectedHbPreset) {
userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' };
}
const tracks = isAudio
? audioTracks.map(({ position, defaultTitle }) => {
const f = trackFields[position] || {};
return {
position,
title: String(f.title || defaultTitle || '').trim() || defaultTitle || `Track ${position}`,
artist: String(f.artist || '').trim() || null
};
})
: null;
const metadata = isAudio
? {
albumTitle: String(albumTitle || '').trim() || job.detected_title || '',
albumArtist: String(albumArtist || '').trim() || null,
albumYear: Number.isFinite(Number(albumYear)) ? Math.trunc(Number(albumYear)) : null,
mbId: selectedMb?.mbId || null,
coverUrl: coverUrl || null
}
: {
...buildVideoMetadataPayload(
videoMetadata,
String(job.title || job.detected_title || '').trim()
)
};
return {
converterMediaType,
outputFormat,
userPreset,
audioFormatOptions: isAudio ? audioFormatOptions : null,
metadata,
tracks,
musicBrainz: isAudio
? { query: mbQuery, selected: selectedMb || null, results: Array.isArray(mbResults) ? mbResults.slice(0, 50) : [] }
: null,
preEncodeItems: isAudio ? preItems : null,
postEncodeItems: isAudio ? postItems : null
};
}, [
selectedUserPreset, selectedHbPreset, userPresets, isAudio, audioTracks, trackFields,
albumTitle, albumArtist, albumYear, coverUrl, selectedMb, converterMediaType, outputFormat,
audioFormatOptions, mbQuery, mbResults, job.detected_title, job.title, videoMetadata,
preItems, postItems
]);
const draftSerialized = useMemo(() => JSON.stringify(draftPayload), [draftPayload]);
useEffect(() => {
if (!initializedRef.current) {
initializedRef.current = true;
latestSavedRef.current = draftSerialized;
return;
}
if (busy || draftSerialized === latestSavedRef.current) return;
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(async () => {
setDraftSaving(true);
setDraftError(null);
try {
await api.converterUpdateJobConfig(job.id, draftPayload);
latestSavedRef.current = draftSerialized;
} catch (err) {
setDraftError(err?.message || 'Draft konnte nicht gespeichert werden.');
} finally {
setDraftSaving(false);
}
}, 650);
return () => {
if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; }
};
}, [busy, draftPayload, draftSerialized, job.id]);
// ── Dialog contexts ─────────────────────────────────────────────────────────
const metadataDialogContext = useMemo(() => ({
jobId: job.id,
detectedTitle: videoMetadata?.title || job.title || job.detected_title || '',
selectedMetadata: {
...videoMetadata,
title: videoMetadata?.title || job.title || job.detected_title || '',
year: videoMetadata?.year || null,
imdbId: videoMetadata?.imdbId || null,
poster: videoMetadata?.poster || null
},
metadataCandidates: []
}), [job.id, job.title, job.detected_title, videoMetadata]);
const cdMetadataDialogContext = useMemo(() => ({
jobId: job.id,
tracks: audioTracks.map(({ position, defaultTitle }) => ({
position,
title: String(trackFields[position]?.title || defaultTitle || '').trim() || `Track ${position}`,
artist: String(trackFields[position]?.artist || '').trim() || null
}))
}), [job.id, audioTracks, trackFields]);
// ── Dialog handlers ─────────────────────────────────────────────────────────
const handleMetadataSearch = async (query) => {
try {
const response = await api.searchTmdbMovie(query);
return Array.isArray(response?.results) ? response.results : [];
} catch { return []; }
};
const handleMetadataSubmit = async (payload) => {
setSearchDialogBusy(true);
try {
setVideoMetadata(buildVideoMetadataPayload(payload, String(job.title || job.detected_title || '').trim()));
setMetadataDialogVisible(false);
} finally {
setSearchDialogBusy(false);
}
};
const handleMusicBrainzSearchDialog = async (query, options = {}) => {
try {
const response = await api.searchMusicBrainz(query, {
trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null
});
return Array.isArray(response?.results) ? response.results : [];
} catch { return []; }
};
const handleMusicBrainzReleaseFetch = async (mbId) => {
try {
const response = await api.getMusicBrainzRelease(mbId);
return response?.release || null;
} catch { return null; }
};
const handleCdMetadataSubmit = async (payload) => {
setSearchDialogBusy(true);
try {
const title = String(payload?.title || '').trim();
const artist = String(payload?.artist || '').trim();
const year = Number(payload?.year);
const tracks = Array.isArray(payload?.tracks) ? payload.tracks : [];
if (title) setAlbumTitle(title);
setAlbumArtist(artist);
setAlbumYear(Number.isFinite(year) ? Math.trunc(year) : null);
setSelectedMb(payload?.mbId ? {
mbId: payload.mbId,
title: title || null,
artist: artist || null,
year: Number.isFinite(year) ? Math.trunc(year) : null
} : null);
setCoverUrl(String(payload?.coverUrl || '').trim() || null);
if (tracks.length > 0) {
setTrackFields((prev) => {
const next = { ...prev };
tracks.forEach((track, index) => {
const position = Number.isFinite(Number(track?.position)) && Number(track.position) > 0
? Math.trunc(Number(track.position))
: (index + 1);
next[position] = {
title: String(track?.title || next[position]?.title || `Track ${position}`).trim(),
artist: String(track?.artist || next[position]?.artist || '').trim()
};
});
return next;
});
}
setCdMetadataDialogVisible(false);
} finally {
setSearchDialogBusy(false);
}
};
// ── Track field handlers ────────────────────────────────────────────────────
const handleTrackField = (position, key, value) => {
setTrackFields((prev) => ({ ...prev, [position]: { ...(prev[position] || {}), [key]: value } }));
};
const handleDeleteTrack = async (filePath) => {
try {
await api.converterRemoveInputFromJob(job.id, filePath);
onInputsChanged?.(job.id);
} catch (err) {
// silently ignore — UI will refresh via onInputsChanged
}
};
// ── Encode item handlers ────────────────────────────────────────────────────
const moveItem = (phase, index, direction) => {
const updater = phase === 'post' ? setPostItems : setPreItems;
updater((prev) => {
const list = Array.isArray(prev) ? [...prev] : [];
const from = Number(index);
const to = from + (direction === 'up' ? -1 : 1);
if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) return list;
const [moved] = list.splice(from, 1);
list.splice(to, 0, moved);
return list;
});
};
const addItem = (phase, type) => {
const normalizedType = type === 'chain' ? 'chain' : 'script';
const updater = phase === 'post' ? setPostItems : setPreItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const selectedIds = new Set(
current
.filter((item) => item?.type === normalizedType)
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog;
const candidate = (Array.isArray(catalog) ? catalog : [])
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
.find((id) => id !== null && !selectedIds.has(String(id)));
if (candidate === undefined || candidate === null) return current;
return [...current, { type: normalizedType, id: candidate }];
});
};
const changeItem = (phase, index, type, nextId) => {
const normalizedType = type === 'chain' ? 'chain' : 'script';
const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId);
if (normalizedId === null) return;
const updater = phase === 'post' ? setPostItems : setPreItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const rowIndex = Number(index);
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) return current;
const duplicate = current.some((item, itemIndex) => {
if (itemIndex === rowIndex) return false;
if (item?.type !== normalizedType) return false;
const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id);
return existingId !== null && String(existingId) === String(normalizedId);
});
if (duplicate) return current;
const next = [...current];
next[rowIndex] = { type: normalizedType, id: normalizedId };
return next;
});
};
const removeItem = (phase, index) => {
const updater = phase === 'post' ? setPostItems : setPreItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const rowIndex = Number(index);
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) return current;
return current.filter((_, itemIndex) => itemIndex !== rowIndex);
});
};
// ── Start handler ───────────────────────────────────────────────────────────
const handleStart = async () => {
setBusy(true);
setError(null);
try {
let userPreset = null;
if (selectedUserPreset) {
const found = userPresets.find((p) => p.id === selectedUserPreset);
if (found) userPreset = { id: found.id, handbrakePreset: found.handbrake_preset || null, extraArgs: found.extra_args || '' };
} else if (selectedHbPreset) {
userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' };
}
const tracks = isAudio
? audioTracks.map(({ position, defaultTitle }) => {
const f = trackFields[position] || {};
return {
position,
title: (f.title || '').trim() || defaultTitle,
artist: (f.artist || '').trim() || albumArtist.trim() || null
};
})
: undefined;
const metadata = isAudio
? {
albumTitle: albumTitle.trim() || job.detected_title || '',
albumArtist: albumArtist.trim() || null,
albumYear: albumYear || null,
mbId: selectedMb?.mbId || null,
coverUrl: coverUrl || null
}
: {
...buildVideoMetadataPayload(
videoMetadata,
String(job.title || job.detected_title || '').trim()
)
};
await api.startConverterJob(job.id, {
converterMediaType,
outputFormat,
userPreset,
audioFormatOptions: isAudio ? audioFormatOptions : null,
metadata,
tracks,
preEncodeItems: isAudio ? preItems : null,
postEncodeItems: isAudio ? postItems : null
});
onStarted?.(job.id);
} catch (err) {
setError(err.message || 'Fehler beim Starten.');
setBusy(false);
}
};
// ── Format fields (audio) ───────────────────────────────────────────────────
const audioSchema = CD_FORMAT_SCHEMAS[outputFormat] || { fields: [] };
const visibleAudioFields = audioSchema.fields.filter((f) => isFieldVisible(f, audioFormatOptions));
// ── Encode item section renderer ────────────────────────────────────────────
const renderEncodeSection = (phase, items) => {
const label = phase === 'post' ? 'Post-Rip Ausführungen (optional)' : 'Pre-Rip Ausführungen (optional)';
const hint = phase === 'post'
? 'Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.'
: 'Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.';
return (
<div className="post-script-box">
<h4>{label}</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{items.length === 0 ? (
<small>Keine {phase === 'post' ? 'Post' : 'Pre'}-Rip Ausführungen ausgewählt.</small>
) : null}
{items.map((item, rowIndex) => {
const isScript = item?.type === 'script';
const usedScriptIds = new Set(
items.filter((e, i) => e?.type === 'script' && i !== rowIndex)
.map((e) => normalizeScriptId(e?.id)).filter((id) => id !== null).map((id) => String(id))
);
const usedChainIds = new Set(
items.filter((e, i) => e?.type === 'chain' && i !== rowIndex)
.map((e) => normalizeChainId(e?.id)).filter((id) => id !== null).map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((e) => ({
label: e?.name || `Skript #${e?.id}`,
value: normalizeScriptId(e?.id),
disabled: usedScriptIds.has(String(normalizeScriptId(e?.id)))
})).filter((e) => e.value !== null);
const chainOptions = chainCatalog.map((e) => ({
label: e?.name || `Kette #${e?.id}`,
value: normalizeChainId(e?.id),
disabled: usedChainIds.has(String(normalizeChainId(e?.id)))
})).filter((e) => e.value !== null);
return (
<div key={`${phase}-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order">
<Button icon="pi pi-angle-up" severity="secondary" text rounded onClick={() => moveItem(phase, rowIndex, 'up')} disabled={busy || rowIndex <= 0} />
<Button icon="pi pi-angle-down" severity="secondary" text rounded onClick={() => moveItem(phase, rowIndex, 'down')} disabled={busy || rowIndex >= items.length - 1} />
</div>
{isScript ? (
<Dropdown
value={normalizeScriptId(item?.id)}
options={scriptOptions}
optionLabel="label" optionValue="value" optionDisabled="disabled"
onChange={(e) => changeItem(phase, rowIndex, 'script', e.value)}
className="full-width" disabled={busy}
/>
) : (
<Dropdown
value={normalizeChainId(item?.id)}
options={chainOptions}
optionLabel="label" optionValue="value" optionDisabled="disabled"
onChange={(e) => changeItem(phase, rowIndex, 'chain', e.value)}
className="full-width" disabled={busy}
/>
)}
<Button icon="pi pi-times" severity="danger" outlined onClick={() => removeItem(phase, rowIndex)} disabled={busy} />
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > items.filter((e) => e?.type === 'script').length ? (
<Button label="Skript hinzufügen" icon="pi pi-code" severity="secondary" outlined onClick={() => addItem(phase, 'script')} disabled={busy} />
) : null}
{chainCatalog.length > items.filter((e) => e?.type === 'chain').length ? (
<Button label="Kette hinzufügen" icon="pi pi-link" severity="secondary" outlined onClick={() => addItem(phase, 'chain')} disabled={busy} />
) : null}
</div>
<small>{hint}</small>
</div>
);
};
// ── Render ──────────────────────────────────────────────────────────────────
if (isAudio) {
return (
<div className="cd-rip-config-panel">
{error && <Message severity="error" text={error} style={{ marginBottom: 10, width: '100%' }} />}
<h4 style={{ marginTop: 0, marginBottom: '0.75rem' }}>Converter Konfiguration</h4>
{/* Album-Metadaten */}
<div className="cd-meta-summary">
<strong>Album-Metadaten</strong>
{coverUrl && (
<div className="cd-media-meta-layout" style={{ marginTop: '0.55rem' }}>
<div className="cd-cover-wrap">
<img src={coverUrl} alt={albumTitle || 'Cover'} className="cd-cover-image" />
</div>
</div>
)}
<div className="metadata-grid" style={{ marginTop: '0.55rem' }}>
<InputText
value={normalizeTrackText(albumTitle)}
onChange={(e) => setAlbumTitle(e.target.value)}
placeholder="Album"
disabled={busy}
/>
<InputText
value={normalizeTrackText(albumArtist)}
onChange={(e) => setAlbumArtist(e.target.value)}
placeholder="Interpret"
disabled={busy}
/>
<InputNumber
value={albumYear}
onValueChange={(e) => setAlbumYear(e.value || null)}
placeholder="Jahr"
useGrouping={false}
min={1900} max={2100}
disabled={busy}
/>
</div>
</div>
{/* Ausgabeformat */}
<div className="cd-format-field">
<label>Ausgabeformat</label>
<Dropdown
value={outputFormat}
options={CD_FORMATS}
optionLabel="label"
optionValue="value"
onChange={(e) => setOutputFormat(e.value)}
disabled={busy}
/>
</div>
{/* Format-spezifische Felder */}
{visibleAudioFields.map((field) => (
<FormatField
key={field.key}
field={field}
value={audioFormatOptions[field.key] ?? field.default}
onChange={(key, value) => setAudioFormatOptions((prev) => ({ ...prev, [key]: value }))}
/>
))}
{/* Track-Auswahl */}
{audioTracks.length > 0 && (
<div className="cd-track-selection">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
<strong>Tracks ({audioTracks.length})</strong>
</div>
<div className="cd-track-list">
<table className="cd-track-table">
<thead>
<tr>
<th className="check"></th>
<th className="num">Nr</th>
<th className="artist">Interpret</th>
<th className="title">Titel</th>
<th className="duration">Länge</th>
</tr>
</thead>
<tbody>
{audioTracks.map(({ position, filePath, defaultTitle }) => {
const f = trackFields[position] || {};
return (
<tr key={position} className="selected">
<td className="check">
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
size="small"
onClick={() => handleDeleteTrack(filePath)}
disabled={busy}
title="Track entfernen"
/>
</td>
<td className="num">{String(position).padStart(2, '0')}</td>
<td className="artist">
<InputText
value={normalizeTrackText(f.artist)}
onChange={(e) => handleTrackField(position, 'artist', e.target.value)}
placeholder={normalizeTrackText(albumArtist) || 'Interpret'}
disabled={busy}
/>
</td>
<td className="title">
<InputText
value={normalizeTrackText(f.title) || defaultTitle}
onChange={(e) => handleTrackField(position, 'title', e.target.value)}
placeholder={defaultTitle}
disabled={busy}
/>
</td>
<td className="duration">-</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{plan?.isFolder && audioTracks.length === 0 && (
<p style={{ margin: '8px 0 0', color: 'var(--rip-muted)', fontSize: '0.82rem' }}>
Track-Liste wird beim Start aus dem Ordner gelesen.
</p>
)}
{/* Pre / Post Ausführungen */}
<div className="encode-automation-grid">
{renderEncodeSection('pre', preItems)}
{renderEncodeSection('post', postItems)}
</div>
{/* Aktionen */}
<div className="actions-row" style={{ marginTop: '1rem' }}>
<Button
label="Metadaten ändern"
icon="pi pi-pencil"
severity="secondary"
outlined
onClick={() => setCdMetadataDialogVisible(true)}
disabled={busy}
/>
<Button
label="Job löschen"
icon="pi pi-trash"
severity="danger"
outlined
onClick={() => onDeleted?.(job.id)}
disabled={busy}
/>
<Button
label="Encode starten"
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-play'}
onClick={handleStart}
loading={busy}
disabled={audioTracks.length === 0}
/>
</div>
{draftError && (
<small style={{ color: 'var(--red-600)', display: 'block', marginTop: 6 }}>{draftError}</small>
)}
{!draftError && (
<small style={{ color: 'var(--text-color-secondary)', display: 'block', marginTop: 6 }}>
{draftSaving ? 'Speichere Entwurf …' : 'Entwurf gespeichert'}
</small>
)}
<CdMetadataDialog
visible={cdMetadataDialogVisible}
context={cdMetadataDialogContext}
onHide={() => setCdMetadataDialogVisible(false)}
onSubmit={handleCdMetadataSubmit}
onSearch={handleMusicBrainzSearchDialog}
onFetchRelease={handleMusicBrainzReleaseFetch}
busy={searchDialogBusy}
/>
</div>
);
}
// ── Video render (unchanged layout) ─────────────────────────────────────────
return (
<div className="cjc-config">
{error && <Message severity="error" text={error} style={{ marginBottom: 10, width: '100%' }} />}
{isVideo && (
<div className="converter-job-card-actions" style={{ marginTop: 0 }}>
<Button
label="Metadaten neu zuweisen"
icon="pi pi-search"
severity="info"
outlined
size="small"
onClick={() => setMetadataDialogVisible(true)}
disabled={busy}
/>
</div>
)}
<div className="cjc-config-row">
<div className="cjc-config-field">
<label className="cjc-config-label">Medientyp</label>
<div className="cjc-config-value">
{mediaTypeBadge(converterMediaType)}
{converterMediaType === 'iso' && (
<small style={{ marginLeft: 6, color: 'var(--text-color-secondary)' }}>MakeMKV HandBrake</small>
)}
</div>
</div>
<div className="cjc-config-field">
<label className="cjc-config-label">Ausgabeformat</label>
<Dropdown
value={outputFormat}
options={VIDEO_OUTPUT_FORMATS}
onChange={(e) => setOutputFormat(e.value)}
style={{ width: '100%' }}
/>
</div>
</div>
{isVideo && (
<div className="cjc-config-row">
<div className="cjc-config-field">
<label className="cjc-config-label">User-Preset</label>
<Dropdown
value={selectedUserPreset}
options={[
{ label: '— Kein User-Preset —', value: null },
...userPresets.map((p) => ({ label: p.name, value: p.id }))
]}
onChange={(e) => { setSelectedUserPreset(e.value); if (e.value) setSelectedHbPreset(''); }}
style={{ width: '100%' }}
/>
</div>
{!selectedUserPreset && (
<div className="cjc-config-field">
<label className="cjc-config-label">HandBrake-Preset</label>
<Dropdown
value={selectedHbPreset}
options={[
{ label: '— Kein Preset (Standard) —', value: '' },
...hbPresets.map((p) => ({ label: p.category ? `[${p.category}] ${p.name}` : p.name, value: p.name }))
]}
onChange={(e) => setSelectedHbPreset(e.value)}
filter
placeholder="Preset auswählen …"
style={{ width: '100%' }}
/>
</div>
)}
</div>
)}
<div className="cjc-config-actions">
{draftError ? (
<small style={{ color: 'var(--red-600)' }}>{draftError}</small>
) : (
<small style={{ color: 'var(--text-color-secondary)' }}>
{draftSaving ? 'Speichere Entwurf …' : 'Entwurf gespeichert'}
</small>
)}
<Button
label="Encoding starten"
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-play'}
disabled={busy}
onClick={handleStart}
/>
</div>
<MetadataSelectionDialog
visible={metadataDialogVisible}
context={metadataDialogContext}
onHide={() => setMetadataDialogVisible(false)}
onSubmit={handleMetadataSubmit}
onSearch={handleMetadataSearch}
busy={searchDialogBusy}
/>
</div>
);
}
// ── Job-Karte ─────────────────────────────────────────────────────────────────
export default function ConverterJobCard({
job,
jobProgress,
isExpanded,
onExpand,
onCollapse,
onStarted,
onDeleted,
onCancelled,
onOpenDetails,
onInputsChanged
}) {
const [cancelBusy, setCancelBusy] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
const [deleteConfirmVisible, setDeleteConfirmVisible] = useState(false);
const plan = (() => {
try { return JSON.parse(job.encode_plan_json || '{}'); } catch { return {}; }
})();
const jobIdLabel = Number.isFinite(Number(job?.id)) ? `Job #${Math.trunc(Number(job.id))}` : 'Job';
const ripperTitleId = Number.isFinite(Number(job?.id)) ? `#${Math.trunc(Number(job.id))}` : '#-';
const customTitle = String(job?.title || '').trim();
const title = customTitle ? `${ripperTitleId} | ${customTitle}` : ripperTitleId;
const status = job.status || 'UNKNOWN';
const statusLabel = getStatusLabel(status);
const statusSeverity = getStatusSeverity(status);
const converterMediaType = plan?.converterMediaType;
const isAudio = converterMediaType === 'audio';
const active = isActiveStatus(status);
const terminal = isTerminal(status);
const isReady = String(status).toUpperCase() === 'READY_TO_START';
const liveProgress = jobProgress?.progress ?? null;
const liveEta = jobProgress?.eta || null;
const liveStatusText = jobProgress?.statusText || null;
const progressValue = liveProgress !== null ? Math.trunc(liveProgress) : null;
// ── Audio details (for active/terminal state) ─────────────────────────────
const audioTracks = isAudio ? deriveAudioTracks(plan) : [];
const planTracks = Array.isArray(plan?.tracks) ? plan.tracks : [];
const planMetadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {};
const coverUrl = String(planMetadata?.coverUrl || plan?.musicBrainz?.selected?.coverUrl || job?.poster_url || '').trim() || null;
const albumTitle = String(planMetadata?.albumTitle || job.title || job.detected_title || '').trim() || '-';
const albumArtist = String(planMetadata?.albumArtist || '').trim() || '-';
const albumYear = planMetadata?.albumYear || '-';
const mbId = String(planMetadata?.mbId || '').trim() || '-';
const outputFormatLabel = (CD_FORMATS.find((f) => f.value === plan?.outputFormat)?.label) || (plan?.outputFormat ? String(plan.outputFormat).toUpperCase() : '-');
const outputPath = String(job.output_path || '').trim() || '-';
// Derive pre/post script names for summary
const preItems = buildEncodeItemsFromConfig(plan, 'pre');
const postItems = buildEncodeItemsFromConfig(plan, 'post');
const preNames = [
...(Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts.map((s) => String(s?.name || '').trim()).filter(Boolean) : []),
...preItems.filter((i) => i.type === 'script').map((i) => `Skript #${i.id}`)
];
const postNames = [
...(Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts.map((s) => String(s?.name || '').trim()).filter(Boolean) : []),
...postItems.filter((i) => i.type === 'script').map((i) => `Skript #${i.id}`)
];
const preChainNames = [
...(Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains.map((c) => String(c?.name || '').trim()).filter(Boolean) : []),
...preItems.filter((i) => i.type === 'chain').map((i) => `Kette #${i.id}`)
];
const postChainNames = [
...(Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains.map((c) => String(c?.name || '').trim()).filter(Boolean) : []),
...postItems.filter((i) => i.type === 'chain').map((i) => `Kette #${i.id}`)
];
const isFinishedOk = terminal && !['ERROR', 'CANCELLED'].includes(String(status).toUpperCase());
const completedEncodeCount = (() => {
if (isFinishedOk) return audioTracks.length;
if (!active || audioTracks.length === 0) return 0;
// "Audio: N/M — filename" is emitted by the backend at the START of track N.
// N-1 tracks are done, track N is in progress.
const statusMatch = liveStatusText?.match(/^Audio:\s*(\d+)\s*\//);
if (statusMatch) {
const pos = parseInt(statusMatch[1], 10);
return Math.max(0, Math.min(audioTracks.length, pos - 1));
}
// Fallback: use progress percentage (Math.round avoids floor precision loss)
if (progressValue !== null) {
return Math.min(audioTracks.length, Math.round((progressValue / 100) * audioTracks.length));
}
return 0;
})();
// ── Action handlers ───────────────────────────────────────────────────────
const handleCancel = async () => {
const statusUpper = String(status || '').toUpperCase();
const confirmText = statusUpper === 'READY_TO_START'
? `${jobIdLabel} wirklich abbrechen?\nDer Job wird auf "Abgebrochen" gesetzt.`
: `${jobIdLabel} wirklich abbrechen?`;
const confirmed = await confirmModal({
header: 'Job abbrechen',
message: confirmText,
acceptLabel: 'Abbrechen',
rejectLabel: 'Zurück',
danger: true
});
if (!confirmed) return;
setCancelBusy(true);
try {
await api.cancelConverterJob(job.id);
onCancelled?.(job.id);
} catch (err) {
console.error('Cancel error:', err);
} finally {
setCancelBusy(false);
}
};
const handleDelete = () => setDeleteConfirmVisible(true);
const handleDeleteConfirmed = async () => {
setDeleteConfirmVisible(false);
setDeleteBusy(true);
try {
await api.deleteConverterJob(job.id);
onDeleted?.(job.id);
} catch (err) {
console.error('Delete error:', err);
window.alert(`Fehler beim Löschen:\n${err?.message || 'Unbekannter Fehler'}`);
} finally {
setDeleteBusy(false);
}
};
// ── Eingeklappt ───────────────────────────────────────────────────────────
if (!isExpanded) {
const fileCount = Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0
? plan.inputPaths.length
: null;
return (
<button
type="button"
className="ripper-job-row"
onClick={onExpand}
>
<div className="poster-thumb ripper-job-poster-fallback">
{isAudio ? 'Audio' : 'Video'}
</div>
<div className="ripper-job-row-content">
<div className="ripper-job-row-main">
<strong className="ripper-job-title-line">
<i className="pi pi-circle-fill media-indicator-icon" style={{ color: 'var(--rip-muted)', fontSize: '0.65rem' }} />
<span>{title}</span>
</strong>
<small>
{fileCount != null ? `${fileCount} Datei${fileCount !== 1 ? 'en' : ''}` : null}
</small>
</div>
<div className="ripper-job-badges">
{converterMediaType && mediaTypeBadge(converterMediaType)}
<Tag value={statusLabel} severity={statusSeverity} />
</div>
{active && progressValue !== null && (
<div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} />
<small>
{liveEta ? `${progressValue}% | ETA ${liveEta}` : `${progressValue}%`}
</small>
</div>
)}
</div>
<i className="pi pi-angle-down" aria-hidden="true" />
</button>
);
}
// ── Ausgeklappt ───────────────────────────────────────────────────────────
return (
<div className={`ripper-job-expanded converter-job-card status-${status.toLowerCase()}${isReady ? ' is-ready' : ''}`}>
{/* Header */}
<div className="ripper-job-expanded-head">
{job?.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={title} className="poster-thumb" />
) : (
<div className="poster-thumb ripper-job-poster-fallback">Kein Poster</div>
)}
<div className="ripper-job-expanded-title">
<strong className="ripper-job-title-line">
<i className="pi pi-circle-fill media-indicator-icon" style={{ color: 'var(--rip-muted)', fontSize: '0.65rem' }} />
<span>{title}</span>
</strong>
<div className="ripper-job-badges">
{converterMediaType && mediaTypeBadge(converterMediaType)}
<Tag value={statusLabel} severity={statusSeverity} />
</div>
</div>
<Button
label="Einklappen"
icon="pi pi-angle-up"
severity="secondary"
outlined
size="small"
onClick={onCollapse}
/>
</div>
{/* READY: Inline-Konfiguration */}
{isReady && (
<InlineConfig
job={job}
plan={plan}
onStarted={onStarted}
onInputsChanged={onInputsChanged}
onDeleted={handleDelete}
onCancelled={handleCancel}
/>
)}
{/* READY: Aktions-Buttons für Video-Jobs */}
{isReady && !isAudio && (
<div className="converter-job-card-actions">
<Button
label="Job löschen"
icon={deleteBusy ? 'pi pi-spin pi-spinner' : 'pi pi-trash'}
size="small"
severity="danger"
outlined
disabled={deleteBusy}
onClick={handleDelete}
/>
</div>
)}
{/* ACTIVE / TERMINAL: Details-Ansicht (Audio) */}
{!isReady && isAudio && (
<div className="cd-rip-config-panel">
{/* Fortschritt */}
{active && (
<div className="progress-wrap">
{progressValue !== null ? (
<ProgressBar value={progressValue} showValue displayValueTemplate={() => `${progressValue}%`} />
) : (
<ProgressBar value={0} showValue displayValueTemplate={() => '0%'} />
)}
<small>
{`Encode fertig: ${completedEncodeCount}/${audioTracks.length}`}
{liveEta ? ` | ETA ${liveEta}` : ''}
</small>
{liveStatusText && <small>{liveStatusText}</small>}
</div>
)}
{/* Aktions-Buttons */}
{active && (
<div className="actions-row">
<Button
label="Abbrechen"
icon="pi pi-stop"
severity="danger"
onClick={handleCancel}
disabled={cancelBusy}
/>
<Button
label="Job löschen"
icon="pi pi-trash"
severity="danger"
outlined
onClick={handleDelete}
disabled={deleteBusy}
/>
</div>
)}
{terminal && (
<div className="actions-row">
<Button
label="Details"
icon="pi pi-eye"
size="small"
severity="secondary"
outlined
onClick={() => onOpenDetails?.(job)}
/>
<Button
label="Job löschen"
icon={deleteBusy ? 'pi pi-spin pi-spinner' : 'pi pi-trash'}
size="small"
severity="danger"
outlined
disabled={deleteBusy}
onClick={handleDelete}
/>
</div>
)}
{/* CD-Details Zusammenfassung */}
<div className="cd-meta-summary">
<strong>CD-Details</strong>
<div className="cd-media-meta-layout">
{coverUrl ? (
<div className="cd-cover-wrap">
<img src={coverUrl} alt={`Cover ${albumTitle}`} className="cd-cover-image" />
</div>
) : null}
<div className="device-meta" style={{ marginTop: '0.55rem' }}>
<div><strong>Album:</strong> {albumTitle}</div>
<div><strong>Interpret:</strong> {albumArtist}</div>
<div><strong>Jahr:</strong> {albumYear}</div>
<div><strong>MusicBrainz:</strong> {mbId}</div>
<div><strong>Status:</strong> {statusLabel}</div>
<div><strong>Format:</strong> {outputFormatLabel}</div>
<div><strong>Rip fertig:</strong> {audioTracks.length} / {audioTracks.length}</div>
<div><strong>Encode fertig:</strong> {completedEncodeCount} / {audioTracks.length}</div>
<div><strong>Pre-Skripte:</strong> {preNames.length > 0 ? preNames.join(' | ') : '-'}</div>
<div><strong>Pre-Ketten:</strong> {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}</div>
<div><strong>Post-Skripte:</strong> {postNames.length > 0 ? postNames.join(' | ') : '-'}</div>
<div><strong>Post-Ketten:</strong> {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}</div>
<div><strong>Auswahl:</strong> {audioTracks.map((t) => String(t.position).padStart(2, '0')).join(', ') || '-'}</div>
<div><strong>Gesamtdauer:</strong> -</div>
<div><strong>Output-Pfad:</strong> {outputPath}</div>
<div><strong>Job-ID:</strong> #{job.id}</div>
</div>
</div>
</div>
{/* Zu encodierende Tracks */}
{audioTracks.length > 0 && (
<div className="cd-track-selection">
<strong>Zu encodierende Tracks ({audioTracks.length})</strong>
<div className="cd-track-list">
<table className="cd-track-table">
<thead>
<tr>
<th className="num">Nr</th>
<th className="artist">Interpret</th>
<th className="title">Titel</th>
<th className="duration">Länge</th>
<th className="status">Rip</th>
<th className="status">Encode</th>
</tr>
</thead>
<tbody>
{audioTracks.map((track, trackIdx) => {
const saved = planTracks.find((t) => Number(t?.position) === track.position);
const trackTitle = saved?.title || track.defaultTitle || `Track ${track.position}`;
const trackArtist = saved?.artist || (albumArtist !== '-' ? albumArtist : '-');
const ripMeta = trackStatusTagMeta('done');
let encodeTrackStatus;
if (isFinishedOk) {
encodeTrackStatus = 'done';
} else if (trackIdx < completedEncodeCount) {
encodeTrackStatus = 'done';
} else if (active && trackIdx === completedEncodeCount) {
encodeTrackStatus = 'in_progress';
} else {
encodeTrackStatus = 'pending';
}
const encodeMeta = trackStatusTagMeta(encodeTrackStatus);
return (
<tr key={track.position} className="selected">
<td className="num">{String(track.position).padStart(2, '0')}</td>
<td className="artist">{trackArtist}</td>
<td className="title">{trackTitle}</td>
<td className="duration">-</td>
<td className="status"><Tag value={ripMeta.label} severity={ripMeta.severity} /></td>
<td className="status"><Tag value={encodeMeta.label} severity={encodeMeta.severity} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Fehler */}
{status === 'ERROR' && job.error_message && (
<div className="converter-job-card-error" style={{ marginTop: 8 }}>
<small>{job.error_message}</small>
</div>
)}
</div>
)}
{/* ACTIVE / TERMINAL: Video-Jobs (alte Darstellung) */}
{!isReady && !isAudio && (
<>
{active && (
<div className="converter-job-card-progress">
{progressValue !== null ? (
<ProgressBar value={progressValue} style={{ height: 6 }} displayValueTemplate={() => `${progressValue}%`} />
) : (
<ProgressBar mode="indeterminate" style={{ height: 6 }} />
)}
{(liveStatusText || liveEta) && (
<div className="converter-job-card-progress-meta">
{liveStatusText && <small>{liveStatusText}</small>}
{liveEta && <small>ETA: {liveEta}</small>}
</div>
)}
</div>
)}
{status === 'ERROR' && job.error_message && (
<div className="converter-job-card-error">
<small>{job.error_message}</small>
</div>
)}
{terminal && job.output_path && (
<div className="converter-job-card-output">
<small title={job.output_path}> {job.output_path.split('/').slice(-2).join('/')}</small>
</div>
)}
<div className="converter-job-card-actions">
<Button label="Details" icon="pi pi-eye" size="small" severity="secondary" outlined onClick={() => onOpenDetails?.(job)} />
{!terminal && !isReady && (
<Button
label="Abbrechen"
icon={cancelBusy ? 'pi pi-spin pi-spinner' : 'pi pi-times'}
size="small" severity="warning" outlined
disabled={cancelBusy}
onClick={handleCancel}
/>
)}
{!active && (
<Button
label="Löschen"
icon={deleteBusy ? 'pi pi-spin pi-spinner' : 'pi pi-trash'}
size="small" severity="danger" outlined
disabled={deleteBusy}
onClick={handleDelete}
/>
)}
</div>
</>
)}
<Dialog
header="Job löschen"
visible={deleteConfirmVisible}
onHide={() => setDeleteConfirmVisible(false)}
modal
style={{ width: '22rem' }}
footer={
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<Button
label="Abbrechen"
severity="secondary"
outlined
size="small"
onClick={() => setDeleteConfirmVisible(false)}
/>
<Button
label="Löschen"
icon="pi pi-trash"
severity="danger"
size="small"
onClick={handleDeleteConfirmed}
/>
</div>
}
>
<p style={{ margin: 0 }}>
{jobIdLabel} wirklich löschen?<br />
<small>Es wird nur der Job-Eintrag entfernt, keine Dateien.</small>
</p>
</Dialog>
</div>
);
}