1349 lines
50 KiB
React
1349 lines
50 KiB
React
import { useState, useEffect } from 'react';
|
|
import { Dropdown } from 'primereact/dropdown';
|
|
import { Slider } from 'primereact/slider';
|
|
import { Button } from 'primereact/button';
|
|
import { ProgressBar } from 'primereact/progressbar';
|
|
import { Tag } from 'primereact/tag';
|
|
import { InputText } from 'primereact/inputtext';
|
|
import { InputNumber } from 'primereact/inputnumber';
|
|
import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas';
|
|
import { api } from '../api/client';
|
|
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
|
|
|
|
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 normalizePosition(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
function normalizeTrackText(value) {
|
|
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function normalizeYear(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
function formatTrackDuration(track) {
|
|
const durationMs = Number(track?.durationMs);
|
|
const durationSec = Number(track?.durationSec);
|
|
const totalSec = Number.isFinite(durationMs) && durationMs > 0
|
|
? Math.round(durationMs / 1000)
|
|
: (Number.isFinite(durationSec) && durationSec > 0 ? Math.round(durationSec) : 0);
|
|
if (totalSec <= 0) {
|
|
return '-';
|
|
}
|
|
const min = Math.floor(totalSec / 60);
|
|
const sec = totalSec % 60;
|
|
return `${min}:${String(sec).padStart(2, '0')}`;
|
|
}
|
|
|
|
function formatTotalDuration(totalSec) {
|
|
const parsed = Number(totalSec);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return '-';
|
|
}
|
|
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 formatProgressLabel(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return '0%';
|
|
}
|
|
const clamped = Math.max(0, Math.min(100, parsed));
|
|
const rounded = Math.round(clamped * 10) / 10;
|
|
if (Number.isInteger(rounded)) {
|
|
return `${rounded}%`;
|
|
}
|
|
return `${rounded.toFixed(1)}%`;
|
|
}
|
|
|
|
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' };
|
|
}
|
|
|
|
function normalizeScriptId(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
function normalizeChainId(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
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 describeEncodeItem(item, scriptById, chainById) {
|
|
if (!item || typeof item !== 'object') {
|
|
return '-';
|
|
}
|
|
if (item.type === 'chain') {
|
|
const chain = chainById.get(normalizeChainId(item.id));
|
|
return chain?.name || `Kette #${item.id}`;
|
|
}
|
|
const script = scriptById.get(normalizeScriptId(item.id));
|
|
return script?.name || `Skript #${item.id}`;
|
|
}
|
|
|
|
export default function CdRipConfigPanel({
|
|
pipeline,
|
|
onStart,
|
|
onCancel,
|
|
onRetry,
|
|
onRestartReview,
|
|
onOpenMetadata,
|
|
onDeleteJob,
|
|
busy
|
|
}) {
|
|
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
|
const tracks = Array.isArray(context.tracks) ? context.tracks : [];
|
|
const selectedMeta = context.selectedMetadata || {};
|
|
const state = String(pipeline?.state || '').trim().toUpperCase();
|
|
const jobId = normalizePosition(context?.jobId);
|
|
|
|
const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING';
|
|
const isFinished = state === 'FINISHED';
|
|
const isTerminalFailure = state === 'CANCELLED' || state === 'ERROR';
|
|
|
|
const [format, setFormat] = useState('flac');
|
|
const [formatOptions, setFormatOptions] = useState(() => getDefaultFormatOptions('flac'));
|
|
|
|
// Track selection: position → boolean
|
|
const [selectedTracks, setSelectedTracks] = useState(() => {
|
|
const map = {};
|
|
for (const t of tracks) {
|
|
const position = normalizePosition(t?.position);
|
|
if (!position) {
|
|
continue;
|
|
}
|
|
map[position] = t?.selected !== false;
|
|
}
|
|
return map;
|
|
});
|
|
// Editable track metadata in job overview (artist/title).
|
|
const [trackFields, setTrackFields] = useState(() => {
|
|
const map = {};
|
|
const defaultArtist = normalizeTrackText(selectedMeta?.artist);
|
|
for (const t of tracks) {
|
|
const position = normalizePosition(t?.position);
|
|
if (!position) {
|
|
continue;
|
|
}
|
|
const fallbackTitle = `Track ${position}`;
|
|
map[position] = {
|
|
title: normalizeTrackText(t?.title) || fallbackTitle,
|
|
artist: normalizeTrackText(t?.artist) || defaultArtist
|
|
};
|
|
}
|
|
return map;
|
|
});
|
|
const [metaFields, setMetaFields] = useState(() => ({
|
|
title: normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || '',
|
|
artist: normalizeTrackText(selectedMeta?.artist) || '',
|
|
year: normalizeYear(selectedMeta?.year)
|
|
}));
|
|
const [scriptCatalog, setScriptCatalog] = useState([]);
|
|
const [chainCatalog, setChainCatalog] = useState([]);
|
|
const [preRipItems, setPreRipItems] = useState([]);
|
|
const [postRipItems, setPostRipItems] = useState([]);
|
|
const cdRipConfig = context?.cdRipConfig && typeof context.cdRipConfig === 'object' ? context.cdRipConfig : {};
|
|
const scriptById = new Map(
|
|
(Array.isArray(scriptCatalog) ? scriptCatalog : [])
|
|
.map((item) => [normalizeScriptId(item?.id), item])
|
|
.filter(([id]) => id !== null)
|
|
);
|
|
const chainById = new Map(
|
|
(Array.isArray(chainCatalog) ? chainCatalog : [])
|
|
.map((item) => [normalizeChainId(item?.id), item])
|
|
.filter(([id]) => id !== null)
|
|
);
|
|
const cdRipConfigKey = JSON.stringify({
|
|
preEncodeScriptIds: normalizeIdList(cdRipConfig?.preEncodeScriptIds, 'script'),
|
|
postEncodeScriptIds: normalizeIdList(cdRipConfig?.postEncodeScriptIds, 'script'),
|
|
preEncodeChainIds: normalizeIdList(cdRipConfig?.preEncodeChainIds, 'chain'),
|
|
postEncodeChainIds: normalizeIdList(cdRipConfig?.postEncodeChainIds, 'chain')
|
|
});
|
|
|
|
useEffect(() => {
|
|
setFormatOptions(getDefaultFormatOptions(format));
|
|
}, [format]);
|
|
|
|
useEffect(() => {
|
|
const configuredFormat = String(cdRipConfig?.format || '').trim().toLowerCase();
|
|
if (!configuredFormat || !CD_FORMAT_SCHEMAS[configuredFormat]) {
|
|
return;
|
|
}
|
|
setFormat(configuredFormat);
|
|
setFormatOptions((prev) => ({
|
|
...getDefaultFormatOptions(configuredFormat),
|
|
...(prev && typeof prev === 'object' ? prev : {}),
|
|
...(cdRipConfig?.formatOptions && typeof cdRipConfig.formatOptions === 'object' ? cdRipConfig.formatOptions : {})
|
|
}));
|
|
}, [context?.jobId, cdRipConfig?.format, JSON.stringify(cdRipConfig?.formatOptions || {})]);
|
|
|
|
useEffect(() => {
|
|
setMetaFields({
|
|
title: normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || '',
|
|
artist: normalizeTrackText(selectedMeta?.artist) || '',
|
|
year: normalizeYear(selectedMeta?.year)
|
|
});
|
|
}, [context?.jobId, selectedMeta?.title, selectedMeta?.artist, selectedMeta?.year, context?.detectedTitle]);
|
|
|
|
useEffect(() => {
|
|
setSelectedTracks((prev) => {
|
|
const next = {};
|
|
for (const t of tracks) {
|
|
const normalized = normalizePosition(t?.position);
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
if (prev[normalized] !== undefined) {
|
|
next[normalized] = prev[normalized];
|
|
} else {
|
|
next[normalized] = t?.selected !== false;
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
}, [tracks]);
|
|
|
|
useEffect(() => {
|
|
const defaultArtist = normalizeTrackText(selectedMeta?.artist);
|
|
setTrackFields((prev) => {
|
|
const next = {};
|
|
for (const t of tracks) {
|
|
const position = normalizePosition(t?.position);
|
|
if (!position) {
|
|
continue;
|
|
}
|
|
const previous = prev[position] || {};
|
|
const fallbackTitle = normalizeTrackText(t?.title) || `Track ${position}`;
|
|
const fallbackArtist = normalizeTrackText(t?.artist) || defaultArtist;
|
|
next[position] = {
|
|
title: normalizeTrackText(previous.title) || fallbackTitle,
|
|
artist: normalizeTrackText(previous.artist) || fallbackArtist
|
|
};
|
|
}
|
|
return next;
|
|
});
|
|
}, [tracks, selectedMeta?.artist]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const loadCatalog = async () => {
|
|
try {
|
|
const [scriptsResponse, chainsResponse] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]);
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
const scripts = scriptsResponse.status === 'fulfilled'
|
|
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
|
|
: [];
|
|
const chains = chainsResponse.status === 'fulfilled'
|
|
? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : [])
|
|
: [];
|
|
setScriptCatalog(scripts.map((item) => ({ id: item?.id, name: item?.name })));
|
|
setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name })));
|
|
} catch (_error) {
|
|
if (!cancelled) {
|
|
setScriptCatalog([]);
|
|
setChainCatalog([]);
|
|
}
|
|
}
|
|
};
|
|
void loadCatalog();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setPreRipItems(buildEncodeItemsFromConfig(cdRipConfig, 'pre'));
|
|
setPostRipItems(buildEncodeItemsFromConfig(cdRipConfig, 'post'));
|
|
}, [context?.jobId, cdRipConfigKey]);
|
|
|
|
const handleFormatOptionChange = (key, value) => {
|
|
setFormatOptions((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const handleToggleTrack = (position) => {
|
|
setSelectedTracks((prev) => ({ ...prev, [position]: !prev[position] }));
|
|
};
|
|
|
|
const handleToggleAll = () => {
|
|
const allSelected = tracks.every((t) => {
|
|
const position = normalizePosition(t?.position);
|
|
return position ? selectedTracks[position] !== false : false;
|
|
});
|
|
const map = {};
|
|
for (const t of tracks) {
|
|
const position = normalizePosition(t?.position);
|
|
if (!position) {
|
|
continue;
|
|
}
|
|
map[position] = !allSelected;
|
|
}
|
|
setSelectedTracks(map);
|
|
};
|
|
|
|
const handleTrackFieldChange = (position, key, value) => {
|
|
if (!position) {
|
|
return;
|
|
}
|
|
setTrackFields((prev) => ({
|
|
...prev,
|
|
[position]: {
|
|
...(prev[position] || {}),
|
|
[key]: value
|
|
}
|
|
}));
|
|
};
|
|
|
|
const handleMetaFieldChange = (key, value) => {
|
|
setMetaFields((prev) => ({
|
|
...prev,
|
|
[key]: value
|
|
}));
|
|
};
|
|
|
|
const moveEncodeItem = (phase, index, direction) => {
|
|
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
|
|
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 addEncodeItem = (phase, type) => {
|
|
const normalizedType = type === 'chain' ? 'chain' : 'script';
|
|
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
|
|
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 changeEncodeItem = (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' ? setPostRipItems : setPreRipItems;
|
|
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 removeEncodeItem = (phase, index) => {
|
|
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
|
|
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);
|
|
});
|
|
};
|
|
|
|
const handleStart = (options = {}) => {
|
|
const forceSkipRip = Boolean(options?.forceSkipRip);
|
|
const albumTitle = normalizeTrackText(metaFields?.title)
|
|
|| normalizeTrackText(selectedMeta?.title)
|
|
|| normalizeTrackText(context?.detectedTitle)
|
|
|| 'Audio CD';
|
|
const fallbackArtistFromTracks = tracks
|
|
.map((track) => normalizeTrackText(track?.artist))
|
|
.find(Boolean) || null;
|
|
const albumArtist = normalizeTrackText(metaFields?.artist)
|
|
|| normalizeTrackText(selectedMeta?.artist)
|
|
|| fallbackArtistFromTracks
|
|
|| null;
|
|
const albumYear = normalizeYear(metaFields?.year);
|
|
|
|
const normalizedTracks = tracks
|
|
.map((t) => {
|
|
const position = normalizePosition(t?.position);
|
|
if (!position) {
|
|
return null;
|
|
}
|
|
const baseTitle = normalizeTrackText(t?.title) || `Track ${position}`;
|
|
const baseArtist = normalizeTrackText(t?.artist) || normalizeTrackText(selectedMeta?.artist);
|
|
const edited = trackFields[position] || {};
|
|
const title = normalizeTrackText(edited.title) || baseTitle;
|
|
const artist = normalizeTrackText(edited.artist) || baseArtist || null;
|
|
return {
|
|
position,
|
|
title,
|
|
artist,
|
|
selected: selectedTracks[position] !== false
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const selected = normalizedTracks
|
|
.filter((t) => t.selected)
|
|
.map((t) => t.position);
|
|
|
|
if (selected.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const selectedPreEncodeScriptIds = normalizeIdList(
|
|
preRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
|
|
'script'
|
|
);
|
|
const selectedPostEncodeScriptIds = normalizeIdList(
|
|
postRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
|
|
'script'
|
|
);
|
|
const selectedPreEncodeChainIds = normalizeIdList(
|
|
preRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
|
|
'chain'
|
|
);
|
|
const selectedPostEncodeChainIds = normalizeIdList(
|
|
postRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
|
|
'chain'
|
|
);
|
|
|
|
onStart && onStart({
|
|
format,
|
|
formatOptions,
|
|
selectedTracks: selected,
|
|
tracks: normalizedTracks,
|
|
selectedPreEncodeScriptIds,
|
|
selectedPostEncodeScriptIds,
|
|
selectedPreEncodeChainIds,
|
|
selectedPostEncodeChainIds,
|
|
skipRip: forceSkipRip || context.skipRip === true ? true : undefined,
|
|
metadata: {
|
|
title: albumTitle,
|
|
artist: albumArtist,
|
|
year: albumYear
|
|
}
|
|
});
|
|
};
|
|
|
|
const schema = CD_FORMAT_SCHEMAS[format] || { fields: [] };
|
|
const visibleFields = schema.fields.filter((f) => isFieldVisible(f, formatOptions));
|
|
|
|
const selectedCount = tracks.filter((t) => {
|
|
const position = normalizePosition(t?.position);
|
|
return position ? selectedTracks[position] !== false : false;
|
|
}).length;
|
|
const progress = Number(pipeline?.progress ?? 0);
|
|
const clampedProgress = Math.max(0, Math.min(100, progress));
|
|
const roundedProgress = Math.round(clampedProgress * 10) / 10;
|
|
const eta = String(pipeline?.eta || '').trim();
|
|
const statusText = String(pipeline?.statusText || '').trim();
|
|
const stateLabel = getStatusLabel(state);
|
|
const stateSeverity = getStatusSeverity(state);
|
|
const cdLive = context?.cdLive && typeof context.cdLive === 'object' ? context.cdLive : {};
|
|
const cdLiveTrackStates = Array.isArray(cdLive?.trackStates) ? cdLive.trackStates : [];
|
|
const cdLiveTrackStateByPosition = new Map(
|
|
cdLiveTrackStates
|
|
.map((item) => [normalizePosition(item?.position), item])
|
|
.filter(([position]) => position !== null)
|
|
);
|
|
const cdLiveSelectedTrackPositions = Array.isArray(cdLive?.selectedTrackPositions)
|
|
? cdLive.selectedTrackPositions
|
|
.map((value) => normalizePosition(value))
|
|
.filter((value) => value !== null)
|
|
: [];
|
|
const cdLiveSelectedTrackSet = new Set(cdLiveSelectedTrackPositions.map((value) => String(value)));
|
|
const livePhase = String(cdLive?.phase || '').trim().toLowerCase();
|
|
const livePhaseLabel = livePhase === 'encode' ? 'Encode' : 'Rip';
|
|
const albumTitle = normalizeTrackText(metaFields?.title)
|
|
|| normalizeTrackText(selectedMeta?.title)
|
|
|| normalizeTrackText(context?.detectedTitle)
|
|
|| '-';
|
|
const fallbackArtistFromTracks = tracks
|
|
.map((track) => normalizeTrackText(track?.artist))
|
|
.find(Boolean) || '-';
|
|
const albumArtist = normalizeTrackText(metaFields?.artist)
|
|
|| normalizeTrackText(selectedMeta?.artist)
|
|
|| fallbackArtistFromTracks
|
|
|| '-';
|
|
const albumYear = normalizeYear(metaFields?.year)
|
|
?? normalizeYear(selectedMeta?.year)
|
|
?? '-';
|
|
const musicBrainzId = normalizeTrackText(
|
|
selectedMeta?.mbId
|
|
|| selectedMeta?.musicBrainzId
|
|
|| selectedMeta?.musicbrainzId
|
|
|| selectedMeta?.mbid
|
|
|| ''
|
|
) || '-';
|
|
const coverUrl = normalizeTrackText(
|
|
selectedMeta?.coverUrl
|
|
|| selectedMeta?.poster
|
|
|| selectedMeta?.posterUrl
|
|
|| ''
|
|
) || null;
|
|
const devicePath = normalizeTrackText(context?.devicePath) || '-';
|
|
const outputPath = normalizeTrackText(context?.outputPath) || '-';
|
|
const formatValue = String(cdRipConfig?.format || '').trim().toLowerCase();
|
|
const formatLabel = (Array.isArray(CD_FORMATS)
|
|
? CD_FORMATS.find((entry) => String(entry?.value || '').trim().toLowerCase() === formatValue)?.label
|
|
: null) || (formatValue ? formatValue.toUpperCase() : '-');
|
|
const effectiveTrackRows = tracks
|
|
.map((track) => {
|
|
const position = normalizePosition(track?.position);
|
|
if (!position) {
|
|
return null;
|
|
}
|
|
const selected = cdLiveSelectedTrackSet.size > 0
|
|
? cdLiveSelectedTrackSet.has(String(position))
|
|
: (selectedTracks[position] !== false);
|
|
const trackDurationSec = Number.isFinite(Number(track?.durationMs)) && Number(track.durationMs) > 0
|
|
? Math.round(Number(track.durationMs) / 1000)
|
|
: (Number.isFinite(Number(track?.durationSec)) && Number(track.durationSec) > 0
|
|
? Math.round(Number(track.durationSec))
|
|
: 0);
|
|
const liveTrackState = cdLiveTrackStateByPosition.get(position) || null;
|
|
const fallbackRipStatus = isFinished && selected ? 'done' : 'pending';
|
|
const fallbackEncodeStatus = isFinished && selected ? 'done' : 'pending';
|
|
return {
|
|
position,
|
|
selected,
|
|
title: normalizeTrackText(trackFields?.[position]?.title)
|
|
|| normalizeTrackText(track?.title)
|
|
|| `Track ${position}`,
|
|
artist: normalizeTrackText(trackFields?.[position]?.artist)
|
|
|| normalizeTrackText(track?.artist)
|
|
|| normalizeTrackText(selectedMeta?.artist)
|
|
|| '-',
|
|
durationLabel: formatTrackDuration(track),
|
|
durationSec: trackDurationSec,
|
|
ripStatus: normalizeTrackStageStatus(liveTrackState?.ripStatus || fallbackRipStatus),
|
|
encodeStatus: normalizeTrackStageStatus(liveTrackState?.encodeStatus || fallbackEncodeStatus)
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
if (effectiveTrackRows.length === 0 && cdLiveTrackStates.length > 0) {
|
|
for (const trackState of cdLiveTrackStates) {
|
|
const position = normalizePosition(trackState?.position);
|
|
if (!position) {
|
|
continue;
|
|
}
|
|
const trackDurationSec = Number(trackState?.durationSec);
|
|
effectiveTrackRows.push({
|
|
position,
|
|
selected: true,
|
|
title: normalizeTrackText(trackState?.title) || `Track ${position}`,
|
|
artist: normalizeTrackText(trackState?.artist) || '-',
|
|
durationLabel: formatTotalDuration(trackDurationSec),
|
|
durationSec: Number.isFinite(trackDurationSec) && trackDurationSec > 0 ? Math.trunc(trackDurationSec) : 0,
|
|
ripStatus: normalizeTrackStageStatus(trackState?.ripStatus),
|
|
encodeStatus: normalizeTrackStageStatus(trackState?.encodeStatus)
|
|
});
|
|
}
|
|
}
|
|
const selectedTrackRows = effectiveTrackRows.filter((track) => track.selected);
|
|
const displayTrackRows = selectedTrackRows.length > 0 ? selectedTrackRows : effectiveTrackRows;
|
|
const selectedTrackNumbers = selectedTrackRows
|
|
.map((track) => String(track.position).padStart(2, '0'))
|
|
.join(', ');
|
|
const selectedTrackDurationSec = selectedTrackRows.reduce(
|
|
(sum, track) => sum + (Number.isFinite(track.durationSec) ? track.durationSec : 0),
|
|
0
|
|
);
|
|
const completedRipCount = selectedTrackRows.filter((track) => track.ripStatus === 'done').length;
|
|
const completedEncodeCount = selectedTrackRows.filter((track) => track.encodeStatus === 'done').length;
|
|
const liveCurrentTrackPosition = normalizePosition(cdLive?.trackPosition);
|
|
const lastState = String(context?.lastState || '').trim().toUpperCase();
|
|
const lastStateLabel = getStatusLabel(lastState);
|
|
const failureStage = String(context?.stage || '').trim().toUpperCase();
|
|
const normalizedLivePhase = String(cdLive?.phase || '').trim().toLowerCase();
|
|
const normalizedStatusText = String(statusText || '').trim().toUpperCase();
|
|
const isEncodingFailure = Boolean(
|
|
isTerminalFailure
|
|
&& (
|
|
lastState === 'CD_ENCODING'
|
|
|| failureStage === 'CD_ENCODING'
|
|
|| context?.skipRip === true
|
|
|| normalizedLivePhase === 'encode'
|
|
|| normalizedStatusText.includes('CD_ENCODING')
|
|
)
|
|
);
|
|
const preScriptNamesFromConfig = (Array.isArray(cdRipConfig?.preEncodeScripts) ? cdRipConfig.preEncodeScripts : [])
|
|
.map((item) => String(item?.name || '').trim())
|
|
.filter(Boolean);
|
|
const postScriptNamesFromConfig = (Array.isArray(cdRipConfig?.postEncodeScripts) ? cdRipConfig.postEncodeScripts : [])
|
|
.map((item) => String(item?.name || '').trim())
|
|
.filter(Boolean);
|
|
const preChainNamesFromConfig = (Array.isArray(cdRipConfig?.preEncodeChains) ? cdRipConfig.preEncodeChains : [])
|
|
.map((item) => String(item?.name || '').trim())
|
|
.filter(Boolean);
|
|
const postChainNamesFromConfig = (Array.isArray(cdRipConfig?.postEncodeChains) ? cdRipConfig.postEncodeChains : [])
|
|
.map((item) => String(item?.name || '').trim())
|
|
.filter(Boolean);
|
|
const preScriptNames = preScriptNamesFromConfig.length > 0
|
|
? preScriptNamesFromConfig
|
|
: preRipItems
|
|
.filter((item) => item?.type === 'script')
|
|
.map((item) => describeEncodeItem(item, scriptById, chainById));
|
|
const postScriptNames = postScriptNamesFromConfig.length > 0
|
|
? postScriptNamesFromConfig
|
|
: postRipItems
|
|
.filter((item) => item?.type === 'script')
|
|
.map((item) => describeEncodeItem(item, scriptById, chainById));
|
|
const preChainNames = preChainNamesFromConfig.length > 0
|
|
? preChainNamesFromConfig
|
|
: preRipItems
|
|
.filter((item) => item?.type === 'chain')
|
|
.map((item) => describeEncodeItem(item, scriptById, chainById));
|
|
const postChainNames = postChainNamesFromConfig.length > 0
|
|
? postChainNamesFromConfig
|
|
: postRipItems
|
|
.filter((item) => item?.type === 'chain')
|
|
.map((item) => describeEncodeItem(item, scriptById, chainById));
|
|
|
|
if (isRipping || isFinished || isTerminalFailure) {
|
|
return (
|
|
<div className="cd-rip-config-panel">
|
|
<div className="status-row">
|
|
<Tag value={stateLabel} severity={stateSeverity} />
|
|
<span>{statusText || 'Bereit'}</span>
|
|
</div>
|
|
{isRipping ? (
|
|
<div className="progress-wrap">
|
|
<ProgressBar
|
|
value={roundedProgress}
|
|
showValue
|
|
displayValueTemplate={(value) => formatProgressLabel(value)}
|
|
/>
|
|
<small>
|
|
{`Fortschritt: ${formatProgressLabel(roundedProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`}
|
|
</small>
|
|
<small>{eta ? `ETA ${eta}` : 'ETA unbekannt'}</small>
|
|
</div>
|
|
) : isFinished ? (
|
|
<div className="progress-wrap">
|
|
<ProgressBar value={100} showValue displayValueTemplate={() => '100%'} />
|
|
</div>
|
|
) : null}
|
|
{isRipping ? (
|
|
<div className="actions-row">
|
|
<Button
|
|
label="Abbrechen"
|
|
icon="pi pi-stop"
|
|
severity="danger"
|
|
onClick={() => onCancel && onCancel()}
|
|
disabled={busy}
|
|
/>
|
|
{jobId ? (
|
|
<Button
|
|
label="Job löschen"
|
|
icon="pi pi-trash"
|
|
severity="danger"
|
|
outlined
|
|
onClick={() => onDeleteJob && onDeleteJob(jobId)}
|
|
disabled={busy}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{isTerminalFailure ? (
|
|
<div className="actions-row">
|
|
{isEncodingFailure ? (
|
|
<Button
|
|
label="Encode starten"
|
|
icon="pi pi-play"
|
|
severity="success"
|
|
onClick={() => handleStart({ forceSkipRip: true })}
|
|
loading={busy}
|
|
/>
|
|
) : (
|
|
<>
|
|
{jobId ? (
|
|
<Button
|
|
label="Retry Rippen"
|
|
icon="pi pi-refresh"
|
|
severity="warning"
|
|
onClick={() => onRetry && onRetry()}
|
|
loading={busy}
|
|
/>
|
|
) : null}
|
|
<Button
|
|
label="Metadaten ändern"
|
|
icon="pi pi-pencil"
|
|
severity="secondary"
|
|
onClick={() => onOpenMetadata && onOpenMetadata()}
|
|
loading={busy}
|
|
/>
|
|
</>
|
|
)}
|
|
{isEncodingFailure ? (
|
|
<Button
|
|
label="Review starten"
|
|
icon="pi pi-search"
|
|
severity="info"
|
|
outlined
|
|
onClick={() => onRestartReview && onRestartReview()}
|
|
loading={busy}
|
|
/>
|
|
) : null}
|
|
{jobId ? (
|
|
<Button
|
|
label="Job löschen"
|
|
icon="pi pi-trash"
|
|
severity="danger"
|
|
outlined
|
|
onClick={() => onDeleteJob && onDeleteJob(jobId)}
|
|
loading={busy}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<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> {musicBrainzId}</div>
|
|
<div><strong>Status:</strong> {stateLabel}</div>
|
|
<div><strong>Format:</strong> {formatLabel}</div>
|
|
<div><strong>Rip fertig:</strong> {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
|
|
<div><strong>Encode fertig:</strong> {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
|
|
<div><strong>Aktueller Track:</strong> {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}</div>
|
|
<div><strong>Pre-Skripte:</strong> {preScriptNames.length > 0 ? preScriptNames.join(' | ') : '-'}</div>
|
|
<div><strong>Pre-Ketten:</strong> {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}</div>
|
|
<div><strong>Post-Skripte:</strong> {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}</div>
|
|
<div><strong>Post-Ketten:</strong> {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}</div>
|
|
<div><strong>Auswahl:</strong> {selectedTrackNumbers || '-'}</div>
|
|
<div><strong>Gesamtdauer:</strong> {formatTotalDuration(selectedTrackDurationSec)}</div>
|
|
<div><strong>Laufwerk:</strong> {devicePath}</div>
|
|
<div><strong>Output-Pfad:</strong> {outputPath}</div>
|
|
{lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastStateLabel}</div> : null}
|
|
{jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{displayTrackRows.length > 0 ? (
|
|
<div className="cd-track-selection">
|
|
<strong>Zu rippende Tracks ({displayTrackRows.length})</strong>
|
|
<div className="cd-track-list">
|
|
<table className="cd-track-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="check">Auswahl</th>
|
|
<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>
|
|
{displayTrackRows.map((track) => {
|
|
const ripMeta = trackStatusTagMeta(track?.ripStatus);
|
|
const encodeMeta = trackStatusTagMeta(track?.encodeStatus);
|
|
return (
|
|
<tr key={track.position} className={track.selected ? 'selected' : ''}>
|
|
<td className="check">{track.selected ? 'Ja' : 'Nein'}</td>
|
|
<td className="num">{String(track.position).padStart(2, '0')}</td>
|
|
<td className="artist">{track.artist || '-'}</td>
|
|
<td className="title">{track.title || '-'}</td>
|
|
<td className="duration">{track.durationLabel}</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>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="cd-rip-config-panel">
|
|
<h4 style={{ marginTop: 0, marginBottom: '0.75rem' }}>CD-Rip Konfiguration</h4>
|
|
|
|
<div className="cd-meta-summary">
|
|
<strong>Album-Metadaten</strong>
|
|
<div className="metadata-grid" style={{ marginTop: '0.55rem' }}>
|
|
<InputText
|
|
value={metaFields.title}
|
|
onChange={(e) => handleMetaFieldChange('title', e.target.value)}
|
|
placeholder="Album"
|
|
disabled={busy}
|
|
/>
|
|
<InputText
|
|
value={metaFields.artist}
|
|
onChange={(e) => handleMetaFieldChange('artist', e.target.value)}
|
|
placeholder="Interpret"
|
|
disabled={busy}
|
|
/>
|
|
<InputNumber
|
|
value={metaFields.year}
|
|
onValueChange={(e) => handleMetaFieldChange('year', normalizeYear(e.value))}
|
|
placeholder="Jahr"
|
|
useGrouping={false}
|
|
min={1900}
|
|
max={2100}
|
|
disabled={busy}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Format selection */}
|
|
<div className="cd-format-field">
|
|
<label>Ausgabeformat</label>
|
|
<Dropdown
|
|
value={format}
|
|
options={CD_FORMATS}
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
onChange={(e) => setFormat(e.value)}
|
|
disabled={busy}
|
|
/>
|
|
</div>
|
|
|
|
{/* Format-specific options */}
|
|
{visibleFields.map((field) => (
|
|
<FormatField
|
|
key={field.key}
|
|
field={field}
|
|
value={formatOptions[field.key] ?? field.default}
|
|
onChange={handleFormatOptionChange}
|
|
/>
|
|
))}
|
|
|
|
{/* Track selection */}
|
|
{tracks.length > 0 ? (
|
|
<div className="cd-track-selection">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
|
<strong>Tracks ({selectedCount} / {tracks.length} ausgewählt)</strong>
|
|
<Button
|
|
label={selectedCount === tracks.length ? 'Alle abwählen' : 'Alle auswählen'}
|
|
size="small"
|
|
severity="secondary"
|
|
outlined
|
|
onClick={handleToggleAll}
|
|
disabled={busy}
|
|
/>
|
|
</div>
|
|
<div className="cd-track-list">
|
|
<table className="cd-track-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="check">Auswahl</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>
|
|
{tracks.map((track) => {
|
|
const position = normalizePosition(track?.position);
|
|
if (!position) {
|
|
return null;
|
|
}
|
|
const isSelected = selectedTracks[position] !== false;
|
|
const fields = trackFields[position] || {};
|
|
const titleValue = normalizeTrackText(fields.title)
|
|
|| normalizeTrackText(track?.title)
|
|
|| `Track ${position}`;
|
|
const artistValue = normalizeTrackText(fields.artist)
|
|
|| normalizeTrackText(track?.artist)
|
|
|| normalizeTrackText(selectedMeta?.artist);
|
|
const duration = formatTrackDuration(track);
|
|
return (
|
|
<tr key={position} className={isSelected ? 'selected' : ''}>
|
|
<td className="check">
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => handleToggleTrack(position)}
|
|
disabled={busy}
|
|
/>
|
|
</td>
|
|
<td className="num">{String(position).padStart(2, '0')}</td>
|
|
<td className="artist">
|
|
<InputText
|
|
value={artistValue}
|
|
onChange={(e) => handleTrackFieldChange(position, 'artist', e.target.value)}
|
|
placeholder="Interpret"
|
|
disabled={busy || !isSelected}
|
|
/>
|
|
</td>
|
|
<td className="title">
|
|
<InputText
|
|
value={titleValue}
|
|
onChange={(e) => handleTrackFieldChange(position, 'title', e.target.value)}
|
|
placeholder={`Track ${position}`}
|
|
disabled={busy || !isSelected}
|
|
/>
|
|
</td>
|
|
<td className="duration">{duration}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="post-script-box">
|
|
<h4>Pre-Rip Ausführungen (optional)</h4>
|
|
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
|
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
|
) : null}
|
|
{preRipItems.length === 0 ? (
|
|
<small>Keine Pre-Rip Ausführungen ausgewählt.</small>
|
|
) : null}
|
|
{preRipItems.map((item, rowIndex) => {
|
|
const isScript = item?.type === 'script';
|
|
const usedScriptIds = new Set(
|
|
preRipItems
|
|
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
|
|
.map((entry) => normalizeScriptId(entry?.id))
|
|
.filter((id) => id !== null)
|
|
.map((id) => String(id))
|
|
);
|
|
const usedChainIds = new Set(
|
|
preRipItems
|
|
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
|
|
.map((entry) => normalizeChainId(entry?.id))
|
|
.filter((id) => id !== null)
|
|
.map((id) => String(id))
|
|
);
|
|
const scriptOptions = scriptCatalog.map((entry) => ({
|
|
label: entry?.name || `Skript #${entry?.id}`,
|
|
value: normalizeScriptId(entry?.id),
|
|
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
|
|
})).filter((entry) => entry.value !== null);
|
|
const chainOptions = chainCatalog.map((entry) => ({
|
|
label: entry?.name || `Kette #${entry?.id}`,
|
|
value: normalizeChainId(entry?.id),
|
|
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
|
|
})).filter((entry) => entry.value !== null);
|
|
return (
|
|
<div key={`cd-pre-${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={() => moveEncodeItem('pre', rowIndex, 'up')}
|
|
disabled={busy || rowIndex <= 0}
|
|
/>
|
|
<Button
|
|
icon="pi pi-angle-down"
|
|
severity="secondary"
|
|
text
|
|
rounded
|
|
onClick={() => moveEncodeItem('pre', rowIndex, 'down')}
|
|
disabled={busy || rowIndex >= preRipItems.length - 1}
|
|
/>
|
|
</div>
|
|
{isScript ? (
|
|
<Dropdown
|
|
value={normalizeScriptId(item?.id)}
|
|
options={scriptOptions}
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
optionDisabled="disabled"
|
|
onChange={(event) => changeEncodeItem('pre', rowIndex, 'script', event.value)}
|
|
className="full-width"
|
|
disabled={busy}
|
|
/>
|
|
) : (
|
|
<Dropdown
|
|
value={normalizeChainId(item?.id)}
|
|
options={chainOptions}
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
optionDisabled="disabled"
|
|
onChange={(event) => changeEncodeItem('pre', rowIndex, 'chain', event.value)}
|
|
className="full-width"
|
|
disabled={busy}
|
|
/>
|
|
)}
|
|
<Button
|
|
icon="pi pi-times"
|
|
severity="danger"
|
|
outlined
|
|
onClick={() => removeEncodeItem('pre', rowIndex)}
|
|
disabled={busy}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="actions-row">
|
|
{scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? (
|
|
<Button
|
|
label="Skript hinzufügen"
|
|
icon="pi pi-code"
|
|
severity="secondary"
|
|
outlined
|
|
onClick={() => addEncodeItem('pre', 'script')}
|
|
disabled={busy}
|
|
/>
|
|
) : null}
|
|
{chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? (
|
|
<Button
|
|
label="Kette hinzufügen"
|
|
icon="pi pi-link"
|
|
severity="secondary"
|
|
outlined
|
|
onClick={() => addEncodeItem('pre', 'chain')}
|
|
disabled={busy}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.</small>
|
|
</div>
|
|
|
|
<div className="post-script-box">
|
|
<h4>Post-Rip Ausführungen (optional)</h4>
|
|
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
|
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
|
) : null}
|
|
{postRipItems.length === 0 ? (
|
|
<small>Keine Post-Rip Ausführungen ausgewählt.</small>
|
|
) : null}
|
|
{postRipItems.map((item, rowIndex) => {
|
|
const isScript = item?.type === 'script';
|
|
const usedScriptIds = new Set(
|
|
postRipItems
|
|
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
|
|
.map((entry) => normalizeScriptId(entry?.id))
|
|
.filter((id) => id !== null)
|
|
.map((id) => String(id))
|
|
);
|
|
const usedChainIds = new Set(
|
|
postRipItems
|
|
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
|
|
.map((entry) => normalizeChainId(entry?.id))
|
|
.filter((id) => id !== null)
|
|
.map((id) => String(id))
|
|
);
|
|
const scriptOptions = scriptCatalog.map((entry) => ({
|
|
label: entry?.name || `Skript #${entry?.id}`,
|
|
value: normalizeScriptId(entry?.id),
|
|
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
|
|
})).filter((entry) => entry.value !== null);
|
|
const chainOptions = chainCatalog.map((entry) => ({
|
|
label: entry?.name || `Kette #${entry?.id}`,
|
|
value: normalizeChainId(entry?.id),
|
|
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
|
|
})).filter((entry) => entry.value !== null);
|
|
return (
|
|
<div key={`cd-post-${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={() => moveEncodeItem('post', rowIndex, 'up')}
|
|
disabled={busy || rowIndex <= 0}
|
|
/>
|
|
<Button
|
|
icon="pi pi-angle-down"
|
|
severity="secondary"
|
|
text
|
|
rounded
|
|
onClick={() => moveEncodeItem('post', rowIndex, 'down')}
|
|
disabled={busy || rowIndex >= postRipItems.length - 1}
|
|
/>
|
|
</div>
|
|
{isScript ? (
|
|
<Dropdown
|
|
value={normalizeScriptId(item?.id)}
|
|
options={scriptOptions}
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
optionDisabled="disabled"
|
|
onChange={(event) => changeEncodeItem('post', rowIndex, 'script', event.value)}
|
|
className="full-width"
|
|
disabled={busy}
|
|
/>
|
|
) : (
|
|
<Dropdown
|
|
value={normalizeChainId(item?.id)}
|
|
options={chainOptions}
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
optionDisabled="disabled"
|
|
onChange={(event) => changeEncodeItem('post', rowIndex, 'chain', event.value)}
|
|
className="full-width"
|
|
disabled={busy}
|
|
/>
|
|
)}
|
|
<Button
|
|
icon="pi pi-times"
|
|
severity="danger"
|
|
outlined
|
|
onClick={() => removeEncodeItem('post', rowIndex)}
|
|
disabled={busy}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="actions-row">
|
|
{scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? (
|
|
<Button
|
|
label="Skript hinzufügen"
|
|
icon="pi pi-code"
|
|
severity="secondary"
|
|
outlined
|
|
onClick={() => addEncodeItem('post', 'script')}
|
|
disabled={busy}
|
|
/>
|
|
) : null}
|
|
{chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? (
|
|
<Button
|
|
label="Kette hinzufügen"
|
|
icon="pi pi-link"
|
|
severity="secondary"
|
|
outlined
|
|
onClick={() => addEncodeItem('post', 'chain')}
|
|
disabled={busy}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="actions-row" style={{ marginTop: '1rem' }}>
|
|
{jobId ? (
|
|
<Button
|
|
label="Metadaten ändern"
|
|
icon="pi pi-pencil"
|
|
severity="secondary"
|
|
outlined
|
|
onClick={() => onOpenMetadata && onOpenMetadata()}
|
|
disabled={busy}
|
|
/>
|
|
) : null}
|
|
{jobId ? (
|
|
<Button
|
|
label="Job löschen"
|
|
icon="pi pi-trash"
|
|
severity="danger"
|
|
outlined
|
|
onClick={() => onDeleteJob && onDeleteJob(jobId)}
|
|
disabled={busy}
|
|
/>
|
|
) : null}
|
|
<Button
|
|
label="Abbrechen"
|
|
severity="secondary"
|
|
outlined
|
|
onClick={() => onCancel && onCancel()}
|
|
disabled={busy}
|
|
/>
|
|
<Button
|
|
label={context.skipRip === true ? 'Encode starten' : 'Rip starten'}
|
|
icon="pi pi-play"
|
|
onClick={handleStart}
|
|
loading={busy}
|
|
disabled={selectedCount === 0}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|