Prototype

This commit is contained in:
2026-03-12 10:15:50 +00:00
parent 3dd043689e
commit 5cf869eaca
13 changed files with 1440 additions and 240 deletions

View File

@@ -276,6 +276,9 @@ export const api = {
searchMusicBrainz(q) {
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
},
getMusicBrainzRelease(mbId) {
return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`);
},
async selectCdMetadata(payload) {
const result = await request('/pipeline/cd/select-metadata', {
method: 'POST',

View File

@@ -1,14 +1,16 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext';
import { InputNumber } from 'primereact/inputnumber';
import { Checkbox } from 'primereact/checkbox';
function CoverThumb({ url, alt }) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [url]);
if (!url || failed) {
return <div className="poster-thumb-lg poster-fallback">-</div>;
}
@@ -17,16 +19,46 @@ function CoverThumb({ url, alt }) {
src={url}
alt={alt}
className="poster-thumb-lg"
loading="eager"
decoding="sync"
onError={() => setFailed(true)}
/>
);
}
function formatDurationMs(ms) {
const totalSec = Math.round((ms || 0) / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}:${String(sec).padStart(2, '0')}`;
const COVER_PRELOAD_TIMEOUT_MS = 3000;
function preloadCoverImage(url) {
const src = String(url || '').trim();
if (!src) {
return Promise.resolve();
}
return new Promise((resolve) => {
const image = new Image();
let settled = false;
const cleanup = () => {
image.onload = null;
image.onerror = null;
};
const done = () => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve();
};
const timer = window.setTimeout(done, COVER_PRELOAD_TIMEOUT_MS);
image.onload = () => {
window.clearTimeout(timer);
done();
};
image.onerror = () => {
window.clearTimeout(timer);
done();
};
image.src = src;
});
}
export default function CdMetadataDialog({
@@ -35,20 +67,22 @@ export default function CdMetadataDialog({
onHide,
onSubmit,
onSearch,
onFetchRelease,
busy
}) {
const [selected, setSelected] = useState(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [searchBusy, setSearchBusy] = useState(false);
const searchRunRef = useRef(0);
// Manual metadata inputs
const [manualTitle, setManualTitle] = useState('');
const [manualArtist, setManualArtist] = useState('');
const [manualYear, setManualYear] = useState(null);
// Per-track title editing
// Track titles are pre-filled from MusicBrainz and edited in the next step.
const [trackTitles, setTrackTitles] = useState({});
const [selectedTrackPositions, setSelectedTrackPositions] = useState(new Set());
const tocTracks = Array.isArray(context?.tracks) ? context.tracks : [];
@@ -57,20 +91,18 @@ export default function CdMetadataDialog({
return;
}
setSelected(null);
setQuery(context?.detectedTitle || '');
setQuery('');
setManualTitle(context?.detectedTitle || '');
setManualArtist('');
setManualYear(null);
setResults([]);
setSearchBusy(false);
const titles = {};
const positions = new Set();
for (const t of tocTracks) {
titles[t.position] = t.title || `Track ${t.position}`;
positions.add(t.position);
}
setTrackTitles(titles);
setSelectedTrackPositions(positions);
}, [visible, context]);
useEffect(() => {
@@ -100,48 +132,79 @@ export default function CdMetadataDialog({
}, [selected]);
const handleSearch = async () => {
if (!query.trim()) {
const trimmedQuery = query.trim();
if (!trimmedQuery) {
return;
}
const searchResults = await onSearch(query.trim());
setResults(searchResults || []);
setSelected(null);
};
const handleToggleTrack = (position) => {
setSelectedTrackPositions((prev) => {
const next = new Set(prev);
if (next.has(position)) {
next.delete(position);
} else {
next.add(position);
setSearchBusy(true);
const searchRunId = searchRunRef.current + 1;
searchRunRef.current = searchRunId;
try {
const searchResults = await onSearch(trimmedQuery);
const normalizedResults = Array.isArray(searchResults) ? searchResults : [];
await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl)));
if (searchRunRef.current !== searchRunId) {
return;
}
setResults(normalizedResults);
setSelected(null);
} finally {
if (searchRunRef.current === searchRunId) {
setSearchBusy(false);
}
return next;
});
};
const handleToggleAll = () => {
if (selectedTrackPositions.size === tocTracks.length) {
setSelectedTrackPositions(new Set());
} else {
setSelectedTrackPositions(new Set(tocTracks.map((t) => t.position)));
}
};
const handleSubmit = async () => {
const tracks = tocTracks.map((t) => ({
position: t.position,
title: trackTitles[t.position] || `Track ${t.position}`,
selected: selectedTrackPositions.has(t.position)
}));
const normalizeTrackText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
let releaseDetails = selected;
if (selected?.mbId && (!Array.isArray(selected?.tracks) || selected.tracks.length === 0) && typeof onFetchRelease === 'function') {
const fetched = await onFetchRelease(selected.mbId);
if (fetched && typeof fetched === 'object') {
releaseDetails = fetched;
}
}
const releaseTracks = Array.isArray(releaseDetails?.tracks) ? releaseDetails.tracks : [];
const releaseTracksByPosition = new Map();
releaseTracks.forEach((track, index) => {
const parsedPosition = Number(track?.position);
const normalizedPosition = Number.isFinite(parsedPosition) && parsedPosition > 0
? Math.trunc(parsedPosition)
: index + 1;
if (!releaseTracksByPosition.has(normalizedPosition)) {
releaseTracksByPosition.set(normalizedPosition, track);
}
});
const tracks = tocTracks.map((t, index) => {
const position = Number(t.position);
const byPosition = releaseTracksByPosition.get(position);
const byIndex = releaseTracks[index];
return {
position,
title: normalizeTrackText(
byPosition?.title
|| byIndex?.title
|| trackTitles[t.position]
) || `Track ${t.position}`,
artist: normalizeTrackText(
byPosition?.artist
|| byIndex?.artist
|| manualArtist.trim()
|| releaseDetails?.artist
) || null,
selected: true
};
});
const payload = {
jobId: context.jobId,
title: manualTitle.trim() || context?.detectedTitle || 'Audio CD',
artist: manualArtist.trim() || null,
year: manualYear || null,
mbId: selected?.mbId || null,
coverUrl: selected?.coverArtUrl || null,
mbId: releaseDetails?.mbId || selected?.mbId || null,
coverUrl: releaseDetails?.coverArtUrl || selected?.coverArtUrl || null,
tracks
};
@@ -159,9 +222,6 @@ export default function CdMetadataDialog({
</div>
);
const allSelected = tocTracks.length > 0 && selectedTrackPositions.size === tocTracks.length;
const tracksBlocking = tocTracks.length > 0 && selectedTrackPositions.size === 0;
return (
<Dialog
header="CD-Metadaten auswählen"
@@ -180,7 +240,12 @@ export default function CdMetadataDialog({
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Album / Interpret suchen"
/>
<Button label="MusicBrainz Suche" icon="pi pi-search" onClick={handleSearch} loading={busy} />
<Button
label="MusicBrainz Suche"
icon="pi pi-search"
onClick={handleSearch}
loading={busy || searchBusy}
/>
</div>
{results.length > 0 ? (
@@ -226,42 +291,11 @@ export default function CdMetadataDialog({
/>
</div>
{/* Track selection */}
{/* Track selection/editing moved to CD-Rip configuration panel */}
{tocTracks.length > 0 ? (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginTop: '1rem', marginBottom: '0.25rem' }}>
<h4 style={{ margin: 0 }}>Tracks ({tocTracks.length})</h4>
<Button
label={allSelected ? 'Alle abwählen' : 'Alle auswählen'}
size="small"
severity="secondary"
outlined
onClick={handleToggleAll}
/>
</div>
<div className="cd-track-list">
{tocTracks.map((track) => (
<div key={track.position} className="cd-track-row">
<Checkbox
checked={selectedTrackPositions.has(track.position)}
onChange={() => handleToggleTrack(track.position)}
inputId={`track-${track.position}`}
/>
<span className="cd-track-num">{String(track.position).padStart(2, '0')}</span>
<InputText
value={trackTitles[track.position] ?? `Track ${track.position}`}
onChange={(e) => setTrackTitles((prev) => ({ ...prev, [track.position]: e.target.value }))}
className="cd-track-title-input"
placeholder={`Track ${track.position}`}
disabled={!selectedTrackPositions.has(track.position)}
/>
<span className="cd-track-duration">
{track.durationMs ? formatDurationMs(track.durationMs) : '-'}
</span>
</div>
))}
</div>
</>
<small style={{ display: 'block', marginTop: '0.9rem' }}>
{tocTracks.length} Tracks erkannt. Auswahl/Feinschliff (Checkboxen, Interpret, Titel, Länge) erfolgt im nächsten Schritt in der Job-Übersicht.
</small>
) : null}
<div className="dialog-actions" style={{ marginTop: '1rem' }}>
@@ -271,7 +305,7 @@ export default function CdMetadataDialog({
icon="pi pi-arrow-right"
onClick={handleSubmit}
loading={busy}
disabled={tracksBlocking || (!manualTitle.trim() && !context?.detectedTitle)}
disabled={!manualTitle.trim() && !context?.detectedTitle}
/>
</div>
</Dialog>

View File

@@ -4,7 +4,10 @@ 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';
function isFieldVisible(field, values) {
if (!field.showWhen) {
@@ -51,6 +54,147 @@ function FormatField({ field, value, onChange }) {
return null;
}
function quoteShellArg(value) {
const text = String(value || '');
if (!text) {
return "''";
}
if (/^[a-zA-Z0-9_./:-]+$/.test(text)) {
return text;
}
return `'${text.replace(/'/g, "'\\''")}'`;
}
function buildCommandLine(cmd, args = []) {
const normalizedArgs = Array.isArray(args) ? args : [];
return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
}
function buildEncodeCommandPreview({
format,
formatOptions,
wavFile,
outFile,
trackTitle,
trackArtist,
albumTitle,
year,
trackNo
}) {
const normalizedFormat = String(format || '').trim().toLowerCase();
const title = String(trackTitle || `Track ${trackNo || 1}`).trim() || `Track ${trackNo || 1}`;
const artist = String(trackArtist || '').trim();
const album = String(albumTitle || '').trim();
const releaseYear = year == null ? '' : String(year).trim();
const number = String(trackNo || 1);
if (normalizedFormat === 'wav') {
return buildCommandLine('mv', [wavFile, outFile]);
}
if (normalizedFormat === 'flac') {
const level = Math.max(0, Math.min(8, Number(formatOptions?.flacCompression ?? 5)));
return buildCommandLine('flac', [
`--compression-level-${level}`,
'--tag', `TITLE=${title}`,
'--tag', `ARTIST=${artist}`,
'--tag', `ALBUM=${album}`,
'--tag', `DATE=${releaseYear}`,
'--tag', `TRACKNUMBER=${number}`,
wavFile,
'-o', outFile
]);
}
if (normalizedFormat === 'mp3') {
const mode = String(formatOptions?.mp3Mode || 'cbr').trim().toLowerCase();
const args = ['--id3v2-only', '--noreplaygain'];
if (mode === 'vbr') {
const quality = Math.max(0, Math.min(9, Number(formatOptions?.mp3Quality ?? 4)));
args.push('-V', String(quality));
} else {
const bitrate = Number(formatOptions?.mp3Bitrate ?? 192);
args.push('-b', String(bitrate));
}
args.push(
'--tt', title,
'--ta', artist,
'--tl', album,
'--ty', releaseYear,
'--tn', number,
wavFile,
outFile
);
return buildCommandLine('lame', args);
}
if (normalizedFormat === 'opus') {
const bitrate = Math.max(32, Math.min(512, Number(formatOptions?.opusBitrate ?? 160)));
const complexity = Math.max(0, Math.min(10, Number(formatOptions?.opusComplexity ?? 10)));
return buildCommandLine('opusenc', [
'--bitrate', String(bitrate),
'--comp', String(complexity),
'--title', title,
'--artist', artist,
'--album', album,
'--date', releaseYear,
'--tracknumber', number,
wavFile,
outFile
]);
}
if (normalizedFormat === 'ogg') {
const quality = Math.max(-1, Math.min(10, Number(formatOptions?.oggQuality ?? 6)));
return buildCommandLine('oggenc', [
'-q', String(quality),
'-t', title,
'-a', artist,
'-l', album,
'-d', releaseYear,
'-N', number,
'-o', outFile,
wavFile
]);
}
return '';
}
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')}`;
}
export default function CdRipConfigPanel({
pipeline,
onStart,
@@ -67,27 +211,117 @@ export default function CdRipConfigPanel({
const [format, setFormat] = useState('flac');
const [formatOptions, setFormatOptions] = useState(() => getDefaultFormatOptions('flac'));
const [settingsCdparanoiaCmd, setSettingsCdparanoiaCmd] = useState('');
// Track selection: position → boolean
const [selectedTracks, setSelectedTracks] = useState(() => {
const map = {};
for (const t of tracks) {
map[t.position] = true;
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)
}));
useEffect(() => {
setFormatOptions(getDefaultFormatOptions(format));
}, [format]);
useEffect(() => {
const map = {};
for (const t of tracks) {
map[t.position] = selectedTracks[t.position] !== false;
}
setSelectedTracks(map);
}, [tracks.length]);
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(() => {
let cancelled = false;
const refreshSettings = async () => {
try {
const response = await api.getSettings({ forceRefresh: true });
if (cancelled) {
return;
}
const value = String(response?.settings?.cdparanoia_command || '').trim();
setSettingsCdparanoiaCmd(value);
} catch (_error) {
if (!cancelled) {
setSettingsCdparanoiaCmd('');
}
}
};
refreshSettings();
const intervalId = setInterval(refreshSettings, 5000);
return () => {
cancelled = true;
clearInterval(intervalId);
};
}, [context?.jobId]);
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]);
const handleFormatOptionChange = (key, value) => {
setFormatOptions((prev) => ({ ...prev, [key]: value }));
@@ -98,17 +332,73 @@ export default function CdRipConfigPanel({
};
const handleToggleAll = () => {
const allSelected = tracks.every((t) => selectedTracks[t.position] !== false);
const allSelected = tracks.every((t) => {
const position = normalizePosition(t?.position);
return position ? selectedTracks[position] !== false : false;
});
const map = {};
for (const t of tracks) {
map[t.position] = !allSelected;
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 handleStart = () => {
const selected = tracks
.filter((t) => selectedTracks[t.position] !== false)
const albumTitle = normalizeTrackText(metaFields?.title)
|| normalizeTrackText(selectedMeta?.title)
|| normalizeTrackText(context?.detectedTitle)
|| 'Audio CD';
const albumArtist = normalizeTrackText(metaFields?.artist)
|| normalizeTrackText(selectedMeta?.artist)
|| 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) {
@@ -118,14 +408,74 @@ export default function CdRipConfigPanel({
onStart && onStart({
format,
formatOptions,
selectedTracks: selected
selectedTracks: selected,
tracks: normalizedTracks,
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) => selectedTracks[t.position] !== false).length;
const selectedCount = tracks.filter((t) => {
const position = normalizePosition(t?.position);
return position ? selectedTracks[position] !== false : false;
}).length;
const firstSelectedTrack = tracks.find((t) => {
const position = normalizePosition(t?.position);
return position ? selectedTracks[position] !== false : false;
}) || null;
const devicePath = String(context?.devicePath || context?.device?.path || '').trim();
const cdparanoiaCmd = settingsCdparanoiaCmd
|| String(context?.cdparanoiaCmd || '').trim()
|| 'cdparanoia';
const rawWavDir = String(context?.rawWavDir || '').trim();
const commandTrackNumber = firstSelectedTrack ? Math.trunc(Number(firstSelectedTrack.position)) : null;
const commandWavTarget = commandTrackNumber
? (
rawWavDir
? `${rawWavDir}/track${String(commandTrackNumber).padStart(2, '0')}.cdda.wav`
: `<temp>/track${String(commandTrackNumber).padStart(2, '0')}.cdda.wav`
)
: '<temp>/trackNN.cdda.wav';
const cdparanoiaCommandPreview = [
quoteShellArg(cdparanoiaCmd),
'-d',
quoteShellArg(devicePath || '<device>'),
String(commandTrackNumber || '<trackNr>'),
quoteShellArg(commandWavTarget)
].join(' ');
const commandTrackNo = commandTrackNumber || 1;
const commandTrackFields = trackFields[commandTrackNo] || {};
const commandTrackTitle = normalizeTrackText(commandTrackFields.title)
|| normalizeTrackText(firstSelectedTrack?.title)
|| `Track ${commandTrackNo}`;
const commandTrackArtist = normalizeTrackText(commandTrackFields.artist)
|| normalizeTrackText(firstSelectedTrack?.artist)
|| normalizeTrackText(metaFields?.artist)
|| normalizeTrackText(selectedMeta?.artist)
|| 'Unknown Artist';
const commandAlbumTitle = normalizeTrackText(metaFields?.title)
|| normalizeTrackText(selectedMeta?.title)
|| normalizeTrackText(context?.detectedTitle)
|| 'Audio CD';
const commandYear = normalizeYear(metaFields?.year) ?? normalizeYear(selectedMeta?.year);
const commandOutputFile = `<output>/track${String(commandTrackNo).padStart(2, '0')}.${format}`;
const encodeCommandPreview = buildEncodeCommandPreview({
format,
formatOptions,
wavFile: commandWavTarget,
outFile: commandOutputFile,
trackTitle: commandTrackTitle,
trackArtist: commandTrackArtist,
albumTitle: commandAlbumTitle,
year: commandYear,
trackNo: commandTrackNo
});
const progress = Number(pipeline?.progress ?? 0);
const clampedProgress = Math.max(0, Math.min(100, progress));
const eta = String(pipeline?.eta || '').trim();
@@ -140,6 +490,8 @@ export default function CdRipConfigPanel({
severity={isFinished ? 'success' : 'info'}
/>
{statusText ? <small>{statusText}</small> : null}
<small>1) {cdparanoiaCommandPreview}</small>
{encodeCommandPreview ? <small>2) {encodeCommandPreview}</small> : null}
{!isFinished ? (
<>
<ProgressBar value={clampedProgress} />
@@ -165,12 +517,32 @@ export default function CdRipConfigPanel({
<div className="cd-rip-config-panel">
<h4 style={{ marginTop: 0, marginBottom: '0.75rem' }}>CD-Rip Konfiguration</h4>
{selectedMeta.title ? (
<div className="cd-meta-summary">
<strong>{selectedMeta.artist ? `${selectedMeta.artist} ` : ''}{selectedMeta.title}</strong>
{selectedMeta.year ? <span> ({selectedMeta.year})</span> : null}
<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>
) : null}
</div>
{/* Format selection */}
<div className="cd-format-field">
@@ -210,30 +582,74 @@ export default function CdRipConfigPanel({
/>
</div>
<div className="cd-track-list">
{tracks.map((track) => {
const isSelected = selectedTracks[track.position] !== false;
const totalSec = Math.round((track.durationMs || track.durationSec * 1000 || 0) / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
const duration = totalSec > 0 ? `${min}:${String(sec).padStart(2, '0')}` : '-';
return (
<button
key={track.position}
type="button"
className={`cd-track-row selectable${isSelected ? ' selected' : ''}`}
onClick={() => !busy && handleToggleTrack(track.position)}
disabled={busy}
>
<span className="cd-track-num">{String(track.position).padStart(2, '0')}</span>
<span className="cd-track-title">{track.title || `Track ${track.position}`}</span>
<span className="cd-track-duration">{duration}</span>
</button>
);
})}
<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="cd-format-field">
<label>Prompt-/Befehlskette (Preview)</label>
<small>1) {cdparanoiaCommandPreview}</small>
{encodeCommandPreview ? <small>2) {encodeCommandPreview}</small> : null}
</div>
{/* Actions */}
<div className="actions-row" style={{ marginTop: '1rem' }}>
<Button

View File

@@ -423,7 +423,48 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
);
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
const analyzeContext = getAnalyzeContext(job);
const cdTracks = Array.isArray(makemkvInfo?.tracks)
? makemkvInfo.tracks
.map((track) => {
const position = Number(track?.position);
if (!Number.isFinite(position) || position <= 0) {
return null;
}
return {
...track,
position: Math.trunc(position),
selected: track?.selected !== false
};
})
.filter(Boolean)
: [];
const cdSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {};
const cdparanoiaCmd = String(makemkvInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
const devicePath = String(job?.disc_device || '').trim() || null;
const firstConfiguredTrack = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0
? Number(encodePlan.selectedTracks[0])
: null;
const fallbackTrack = cdTracks[0]?.position ? Number(cdTracks[0].position) : null;
const previewTrackPos = Number.isFinite(firstConfiguredTrack) && firstConfiguredTrack > 0
? Math.trunc(firstConfiguredTrack)
: (Number.isFinite(fallbackTrack) && fallbackTrack > 0 ? Math.trunc(fallbackTrack) : null);
const previewWavPath = previewTrackPos && job?.raw_path
? `${job.raw_path}/track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`
: '<temp>/trackNN.cdda.wav';
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
const selectedMetadata = {
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
artist: cdSelectedMeta?.artist || null,
year: cdSelectedMeta?.year ?? job?.year ?? null,
mbId: cdSelectedMeta?.mbId || null,
coverUrl: cdSelectedMeta?.coverUrl || null,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || cdSelectedMeta?.coverUrl || null
};
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
const inputPath = isPreRip
@@ -468,17 +509,17 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
jobId,
rawPath: job?.raw_path || null,
detectedTitle: job?.detected_title || null,
mediaProfile: resolveMediaType(job),
devicePath,
cdparanoiaCmd,
cdparanoiaCommandPreview,
tracks: cdTracks,
inputPath,
hasEncodableTitle,
reviewConfirmed,
mode,
sourceJobId: encodePlan?.sourceJobId || null,
selectedMetadata: {
title: job?.title || job?.detected_title || null,
year: job?.year || null,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || null
},
selectedMetadata,
mediaInfoReview: encodePlan,
playlistAnalysis: analyzeContext.playlistAnalysis || null,
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
@@ -502,6 +543,9 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
...computedContext,
...existingContext,
rawPath: existingContext.rawPath || computedContext.rawPath,
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
? existingContext.tracks
: computedContext.tracks,
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
canRestartEncodeFromLastSettings:
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
@@ -1353,6 +1397,16 @@ export default function DashboardPage({
}
};
const handleMusicBrainzReleaseFetch = async (mbId) => {
try {
const response = await api.getMusicBrainzRelease(mbId);
return response?.release || null;
} catch (error) {
showError(error);
return null;
}
};
const handleCdMetadataSubmit = async (payload) => {
setBusy(true);
try {
@@ -2271,6 +2325,7 @@ export default function DashboardPage({
}}
onSubmit={handleCdMetadataSubmit}
onSearch={handleMusicBrainzSearch}
onFetchRelease={handleMusicBrainzReleaseFetch}
busy={busy}
/>

View File

@@ -1095,6 +1095,101 @@ body {
gap: 0.5rem;
}
.cd-rip-config-panel {
display: grid;
gap: 0.85rem;
}
.cd-rip-status {
display: grid;
gap: 0.55rem;
}
.cd-meta-summary {
border: 1px solid var(--rip-border);
border-radius: 0.45rem;
background: var(--rip-panel-soft);
padding: 0.55rem 0.65rem;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem;
}
.cd-format-field {
display: grid;
gap: 0.35rem;
}
.cd-format-field label {
font-weight: 600;
}
.cd-format-field small {
color: var(--rip-muted);
}
.cd-track-selection {
border: 1px solid var(--rip-border);
border-radius: 0.5rem;
background: var(--rip-panel-soft);
padding: 0.65rem;
display: grid;
gap: 0.55rem;
}
.cd-track-list {
overflow-x: auto;
}
.cd-track-table {
width: 100%;
min-width: 44rem;
border-collapse: collapse;
}
.cd-track-table th,
.cd-track-table td {
border-bottom: 1px solid var(--rip-border);
padding: 0.45rem 0.5rem;
text-align: left;
vertical-align: middle;
}
.cd-track-table thead th {
color: var(--rip-muted);
font-size: 0.8rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.cd-track-table tbody tr.selected {
background: rgba(175, 114, 7, 0.09);
}
.cd-track-table td.check,
.cd-track-table th.check {
width: 4.5rem;
text-align: center;
}
.cd-track-table td.num,
.cd-track-table th.num {
width: 4rem;
white-space: nowrap;
}
.cd-track-table td.duration,
.cd-track-table th.duration {
width: 5.5rem;
white-space: nowrap;
}
.cd-track-table td.artist .p-inputtext,
.cd-track-table td.title .p-inputtext {
width: 100%;
}
.device-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2220,6 +2315,10 @@ body {
flex-wrap: wrap;
}
.cd-track-table {
min-width: 36rem;
}
.script-list {
grid-template-columns: 1fr;
}