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 (
{field.description ? {field.description} : null}
onChange(field.key, e.value)}
min={field.min}
max={field.max}
step={field.step || 1}
/>
);
}
if (field.type === 'select') {
return (
{field.description ? {field.description} : null}
onChange(field.key, e.value)}
/>
);
}
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 ;
}
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 (
{label}
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.
) : null}
{items.length === 0 ? (
Keine {phase === 'post' ? 'Post' : 'Pre'}-Rip Ausführungen ausgewählt.
) : 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 (
{isScript ? (
changeItem(phase, rowIndex, 'script', e.value)}
className="full-width" disabled={busy}
/>
) : (
changeItem(phase, rowIndex, 'chain', e.value)}
className="full-width" disabled={busy}
/>
)}
removeItem(phase, rowIndex)} disabled={busy} />
);
})}
{scriptCatalog.length > items.filter((e) => e?.type === 'script').length ? (
addItem(phase, 'script')} disabled={busy} />
) : null}
{chainCatalog.length > items.filter((e) => e?.type === 'chain').length ? (
addItem(phase, 'chain')} disabled={busy} />
) : null}
{hint}
);
};
// ── Render ──────────────────────────────────────────────────────────────────
if (isAudio) {
return (
{error &&
}
Converter Konfiguration
{/* Album-Metadaten */}
Album-Metadaten
{coverUrl && (
)}
setAlbumTitle(e.target.value)}
placeholder="Album"
disabled={busy}
/>
setAlbumArtist(e.target.value)}
placeholder="Interpret"
disabled={busy}
/>
setAlbumYear(e.value || null)}
placeholder="Jahr"
useGrouping={false}
min={1900} max={2100}
disabled={busy}
/>
{/* Ausgabeformat */}
setOutputFormat(e.value)}
disabled={busy}
/>
{/* Format-spezifische Felder */}
{visibleAudioFields.map((field) => (
setAudioFormatOptions((prev) => ({ ...prev, [key]: value }))}
/>
))}
{/* Track-Auswahl */}
{audioTracks.length > 0 && (
Tracks ({audioTracks.length})
|
Nr |
Interpret |
Titel |
Länge |
{audioTracks.map(({ position, filePath, defaultTitle }) => {
const f = trackFields[position] || {};
return (
|
handleDeleteTrack(filePath)}
disabled={busy}
title="Track entfernen"
/>
|
{String(position).padStart(2, '0')} |
handleTrackField(position, 'artist', e.target.value)}
placeholder={normalizeTrackText(albumArtist) || 'Interpret'}
disabled={busy}
/>
|
handleTrackField(position, 'title', e.target.value)}
placeholder={defaultTitle}
disabled={busy}
/>
|
- |
);
})}
)}
{plan?.isFolder && audioTracks.length === 0 && (
Track-Liste wird beim Start aus dem Ordner gelesen.
)}
{/* Pre / Post Ausführungen */}
{renderEncodeSection('pre', preItems)}
{renderEncodeSection('post', postItems)}
{/* Aktionen */}
setCdMetadataDialogVisible(true)}
disabled={busy}
/>
onDeleted?.(job.id)}
disabled={busy}
/>
{draftError && (
{draftError}
)}
{!draftError && (
{draftSaving ? 'Speichere Entwurf …' : 'Entwurf gespeichert'}
)}
setCdMetadataDialogVisible(false)}
onSubmit={handleCdMetadataSubmit}
onSearch={handleMusicBrainzSearchDialog}
onFetchRelease={handleMusicBrainzReleaseFetch}
busy={searchDialogBusy}
/>
);
}
// ── Video render (unchanged layout) ─────────────────────────────────────────
return (
{error &&
}
{isVideo && (
setMetadataDialogVisible(true)}
disabled={busy}
/>
)}
{mediaTypeBadge(converterMediaType)}
{converterMediaType === 'iso' && (
MakeMKV → HandBrake
)}
setOutputFormat(e.value)}
style={{ width: '100%' }}
/>
{isVideo && (
({ label: p.name, value: p.id }))
]}
onChange={(e) => { setSelectedUserPreset(e.value); if (e.value) setSelectedHbPreset(''); }}
style={{ width: '100%' }}
/>
{!selectedUserPreset && (
({ 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%' }}
/>
)}
)}
{draftError ? (
{draftError}
) : (
{draftSaving ? 'Speichere Entwurf …' : 'Entwurf gespeichert'}
)}
setMetadataDialogVisible(false)}
onSubmit={handleMetadataSubmit}
onSearch={handleMetadataSearch}
busy={searchDialogBusy}
/>
);
}
// ── 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 (
{isAudio ? 'Audio' : 'Video'}
{title}
{fileCount != null ? `${fileCount} Datei${fileCount !== 1 ? 'en' : ''}` : null}
{converterMediaType && mediaTypeBadge(converterMediaType)}
{active && progressValue !== null && (
{liveEta ? `${progressValue}% | ETA ${liveEta}` : `${progressValue}%`}
)}
);
}
// ── Ausgeklappt ───────────────────────────────────────────────────────────
return (
{/* Header */}
{job?.poster_url && job.poster_url !== 'N/A' ? (

) : (
Kein Poster
)}
{title}
{converterMediaType && mediaTypeBadge(converterMediaType)}
{/* READY: Inline-Konfiguration */}
{isReady && (
)}
{/* READY: Aktions-Buttons für Video-Jobs */}
{isReady && !isAudio && (
)}
{/* ACTIVE / TERMINAL: Details-Ansicht (Audio) */}
{!isReady && isAudio && (
{/* Fortschritt */}
{active && (
{progressValue !== null ? (
`${progressValue}%`} />
) : (
'0%'} />
)}
{`Encode fertig: ${completedEncodeCount}/${audioTracks.length}`}
{liveEta ? ` | ETA ${liveEta}` : ''}
{liveStatusText && {liveStatusText}}
)}
{/* Aktions-Buttons */}
{active && (
)}
{terminal && (
onOpenDetails?.(job)}
/>
)}
{/* CD-Details Zusammenfassung */}
CD-Details
{coverUrl ? (
) : null}
Album: {albumTitle}
Interpret: {albumArtist}
Jahr: {albumYear}
MusicBrainz: {mbId}
Status: {statusLabel}
Format: {outputFormatLabel}
Rip fertig: {audioTracks.length} / {audioTracks.length}
Encode fertig: {completedEncodeCount} / {audioTracks.length}
Pre-Skripte: {preNames.length > 0 ? preNames.join(' | ') : '-'}
Pre-Ketten: {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}
Post-Skripte: {postNames.length > 0 ? postNames.join(' | ') : '-'}
Post-Ketten: {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}
Auswahl: {audioTracks.map((t) => String(t.position).padStart(2, '0')).join(', ') || '-'}
Gesamtdauer: -
Output-Pfad: {outputPath}
Job-ID: #{job.id}
{/* Zu encodierende Tracks */}
{audioTracks.length > 0 && (
Zu encodierende Tracks ({audioTracks.length})
| Nr |
Interpret |
Titel |
Länge |
Rip |
Encode |
{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 (
| {String(track.position).padStart(2, '0')} |
{trackArtist} |
{trackTitle} |
- |
|
|
);
})}
)}
{/* Fehler */}
{status === 'ERROR' && job.error_message && (
{job.error_message}
)}
)}
{/* ACTIVE / TERMINAL: Video-Jobs (alte Darstellung) */}
{!isReady && !isAudio && (
<>
{active && (
{progressValue !== null ? (
`${progressValue}%`} />
) : (
)}
{(liveStatusText || liveEta) && (
{liveStatusText && {liveStatusText}}
{liveEta && ETA: {liveEta}}
)}
)}
{status === 'ERROR' && job.error_message && (
{job.error_message}
)}
{terminal && job.output_path && (
→ {job.output_path.split('/').slice(-2).join('/')}
)}
onOpenDetails?.(job)} />
{!terminal && !isReady && (
)}
{!active && (
)}
>
)}
);
}