0.12.0 Begin neu Architecture

This commit is contained in:
2026-03-18 15:35:10 +00:00
parent d00191324b
commit 0a9cf6969f
30 changed files with 4570 additions and 183 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.11.0-5",
"version": "0.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.11.0-5",
"version": "0.12.0",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.11.0-5",
"version": "0.12.0",
"private": true,
"type": "module",
"scripts": {
+10 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
@@ -493,13 +493,16 @@ function App() {
pendingExpandedJobId={pendingDashboardJobId}
onPendingExpandedJobHandled={handleDashboardJobFocusConsumed}
downloadSummary={downloadSummary}
/>
>
<Outlet />
</DashboardPage>
}
/>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="/downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="/database" element={<DatabasePage />} />
>
<Route path="settings" element={<SettingsPage />} />
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} />
</Route>
</Routes>
</main>
</div>
+141 -14
View File
@@ -1,9 +1,11 @@
import { useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import { resolveJobPluginExecution } from '../utils/pluginExecution';
import { getStatusLabel } from '../utils/statusPresentation';
const CD_FORMAT_LABELS = {
@@ -465,6 +467,59 @@ function BoolState({ value }) {
);
}
function resolveSelectionStateMeta({ selected, outcome }) {
if (!selected) {
return {
label: 'Nicht ausgewählt',
icon: 'pi-minus-circle',
className: 'track-selection-inline-neutral',
title: 'Nicht ausgewählt'
};
}
if (outcome === 'success') {
return {
label: 'Ausgewählt',
icon: 'pi-check-circle',
className: 'job-step-inline-ok',
title: 'Ausgewählt und erfolgreich'
};
}
if (outcome === 'error') {
return {
label: 'Ausgewählt',
icon: 'pi-times-circle',
className: 'job-step-inline-no',
title: 'Ausgewählt, aber nicht erfolgreich'
};
}
if (outcome === 'cancelled') {
return {
label: 'Ausgewählt',
icon: 'pi-ban',
className: 'job-step-inline-warn',
title: 'Ausgewählt, aber abgebrochen'
};
}
return {
label: 'Ausgewählt',
icon: 'pi-clock',
className: 'track-selection-inline-neutral',
title: 'Ausgewählt'
};
}
function SelectionStateNote({ selected, outcome }) {
const meta = resolveSelectionStateMeta({ selected, outcome });
return (
<small className="track-action-note">
<span className={meta.className} title={meta.title}>
<span>{meta.label}</span>
<i className={`pi ${meta.icon}`} aria-hidden="true" />
</span>
</small>
);
}
function PathField({
label,
value,
@@ -528,17 +583,18 @@ export default function JobDetailDialog({
downloadBusyTarget = null
}) {
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(job?.status);
const showFinalLog = !running;
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && mkDone && !running);
const mediaType = resolveMediaType(job);
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && !running
&& (isCd || mkDone));
const canResumeReady = Boolean(
(String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' || String(job?.last_state || '').trim().toUpperCase() === 'READY_TO_ENCODE')
&& !running
&& typeof onResumeReady === 'function'
);
const mediaType = resolveMediaType(job);
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const hasConfirmedPlan = Boolean(
job?.encodePlan
&& Array.isArray(job?.encodePlan?.titles)
@@ -578,6 +634,7 @@ export default function JobDetailDialog({
const mediaTypeAlt = mediaTypeLabel;
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const pluginExecution = resolveJobPluginExecution(job, null);
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0
@@ -776,6 +833,12 @@ export default function JobDetailDialog({
<i className={`pi ${statusMeta.icon}`} aria-hidden="true" />
</span>
</span>
{pluginExecution ? (
<>
<span className="job-infos-sep">|</span>
<Tag value={pluginExecution.label} severity="warning" title={pluginExecution.title} />
</>
) : null}
<span className="job-infos-sep">|</span>
{isCd ? (
<>
@@ -949,22 +1012,23 @@ export default function JobDetailDialog({
: '-';
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
const encodeResult = encodeResultMap.get(pos);
const encodeLabel = !selected ? 'Nicht übernommen'
: encodeResult ? (encodeResult.success ? 'Erfolgreich' : 'Fehler')
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'Erfolgreich' : 'Fehler') : 'Ausgewählt');
const encodeClass = !selected ? ''
: encodeResult ? (encodeResult.success ? 'tone-ok' : 'tone-no')
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'tone-ok' : 'tone-no') : '');
const outcome = !selected
? null
: encodeResult
? (encodeResult.success ? 'success' : 'error')
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'success' : 'error') : null);
return (
<div key={pos} className="track-item">
<span>#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel}</span>
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
<SelectionStateNote selected={selected} outcome={outcome} />
</div>
);
})}
</div>
);
})() : (() => {
const audiobookFormat = String(job?.handbrakeInfo?.format || job?.encodePlan?.format || '').trim().toLowerCase();
const isSplitAudiobook = Boolean(audiobookFormat) && audiobookFormat !== 'm4b';
const chapters = Array.isArray(job.handbrakeInfo?.metadata?.chapters) && job.handbrakeInfo.metadata.chapters.length > 0
? job.handbrakeInfo.metadata.chapters
: (Array.isArray(job.makemkvInfo?.chapters) && job.makemkvInfo.chapters.length > 0
@@ -988,6 +1052,15 @@ export default function JobDetailDialog({
: '-';
const step = stepsByIndex.get(id);
const stepStatus = step ? String(step.status || '').toUpperCase() : null;
const outcome = stepStatus === 'SUCCESS'
? 'success'
: stepStatus === 'ERROR'
? 'error'
: stepStatus === 'CANCELLED'
? 'cancelled'
: (job?.encodeSuccess != null
? (job.encodeSuccess ? 'success' : (String(job?.status || '').trim().toUpperCase() === 'CANCELLED' ? 'cancelled' : 'error'))
: null);
const encodeLabel = stepStatus === 'SUCCESS' ? 'Erfolgreich'
: stepStatus === 'ERROR' ? 'Fehler'
: stepStatus === 'CANCELLED' ? 'Abgebrochen'
@@ -999,7 +1072,11 @@ export default function JobDetailDialog({
return (
<div key={id} className="track-item">
<span>#{id} | {label} | {durLabel}</span>
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
{isSplitAudiobook ? (
<SelectionStateNote selected outcome={outcome} />
) : (
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
)}
</div>
);
})}
@@ -1076,7 +1153,38 @@ export default function JobDetailDialog({
</div>
) : (
<div className="actions-section">
{!isCd ? (
{isCd ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audio CD</div>
<div className="action-item">
<Button
label="Encode neu starten"
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode || typeof onReencode !== 'function'}
/>
<span className="action-desc">Encodiert die vorhandenen WAV-Rohdaten erneut ohne die CD neu zu lesen. Nützlich wenn sich Format oder Metadaten geändert haben.</span>
</div>
{typeof onAssignCdMetadata === 'function' ? (
<div className="action-item">
<Button
label="MusicBrainz neu zuweisen"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onAssignCdMetadata?.(job)}
loading={cdMetadataAssignBusy}
disabled={running}
/>
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
</div>
) : null}
</div>
) : (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
{canResumeReady ? (
@@ -1141,6 +1249,25 @@ export default function JobDetailDialog({
}</span>
</div>
</div>
)}
{!isCd && !isAudiobook && typeof onAssignOmdb === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item">
<Button
label="OMDB neu zuweisen"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onAssignOmdb?.(job)}
loading={omdbAssignBusy}
disabled={running}
/>
<span className="action-desc">Öffnet die OMDB-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.</span>
</div>
</div>
) : null}
<div className="actions-group">
+27 -15
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { Toast } from 'primereact/toast';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
@@ -20,10 +20,12 @@ import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
import { resolveJobPluginExecution } from '../utils/pluginExecution';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING'];
const dashboardStatuses = new Set([
'ANALYZING',
'METADATA_SELECTION',
@@ -822,8 +824,10 @@ export default function DashboardPage({
jobsRefreshToken,
pendingExpandedJobId,
onPendingExpandedJobHandled,
downloadSummary = null
downloadSummary = null,
children = null
}) {
const location = useLocation();
const navigate = useNavigate();
const [busy, setBusy] = useState(false);
const [busyJobIds, setBusyJobIds] = useState(() => new Set());
@@ -955,6 +959,7 @@ export default function DashboardPage({
: audiobookUploadPhase === 'error'
? 'Fehler'
: 'Inaktiv';
const isSubpageRoute = location.pathname !== '/';
const loadDashboardJobs = async () => {
setJobsLoading(true);
@@ -1335,19 +1340,12 @@ export default function DashboardPage({
}
};
const refreshKnownDrives = () => {
api.getDetectedDrives()
.then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : []))
.catch(() => {});
};
const handleRescan = async () => {
setBusy(true);
try {
const response = await api.rescanDisc();
refreshKnownDrives();
const allDetected = response?.result?.allDetected || [];
const emitted = response?.result?.emitted || 'none';
const allDetected = Array.isArray(response?.result?.allDetected) ? response.result.allDetected : [];
const count = allDetected.length;
toastRef.current?.show({
severity: count > 0 ? 'success' : 'info',
@@ -1357,7 +1355,6 @@ export default function DashboardPage({
: 'Kein Medium erkannt.',
life: 2800
});
void emitted; // used implicitly via count
await refreshPipeline();
await loadDashboardJobs();
} catch (error) {
@@ -1367,6 +1364,12 @@ export default function DashboardPage({
}
};
const refreshKnownDrives = () => {
api.getDetectedDrives()
.then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : []))
.catch(() => {});
};
const handleRescanDrive = async (devicePath) => {
setDriveRescanBusy((prev) => new Set([...prev, devicePath]));
try {
@@ -2029,7 +2032,6 @@ export default function DashboardPage({
const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null;
const contextDevice = pipeline?.context?.device;
const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null);
const cdDriveEntries = Object.entries(pipeline?.cdDrives || {});
// Unified list of all known drives: merge lsblk drives + CD state + non-CD pipeline state
const allDrives = useMemo(() => {
@@ -2360,6 +2362,7 @@ export default function DashboardPage({
const driveJobId = normalizeJobId(cdDrive?.jobId);
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
const isCdDriveLocked = cdState ? activeCdDriveStates.includes(cdState) : false;
return (
<div key={drivePath} className="drive-list-item">
@@ -2380,7 +2383,7 @@ export default function DashboardPage({
size="small"
severity="secondary"
loading={isRescanBusy}
disabled={isRescanBusy || isDriveActive}
disabled={isRescanBusy || isDriveActive || isCdDriveLocked}
onClick={() => handleRescanDrive(drivePath)}
title="Laufwerk neu lesen"
aria-label="Laufwerk neu lesen"
@@ -2481,7 +2484,12 @@ export default function DashboardPage({
</div>
<div className="dashboard-col dashboard-col-center">
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
{isSubpageRoute ? (
<div className="dashboard-subpage-content">
{children}
</div>
) : (
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
{jobsLoading ? (
<p>Jobs werden geladen ...</p>
) : dashboardJobs.length === 0 ? (
@@ -2530,6 +2538,7 @@ export default function DashboardPage({
? pipelineForJob.context.selectedMetadata
: {};
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
const pluginExecution = resolveJobPluginExecution(job, pipelineForJob?.context);
if (isExpanded) {
return (
@@ -2552,6 +2561,7 @@ export default function DashboardPage({
</strong>
<div className="dashboard-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{pluginExecution ? <Tag value={pluginExecution.label} severity="warning" title={pluginExecution.title} /> : null}
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
{normalizedStatus === 'READY_TO_ENCODE'
@@ -2681,6 +2691,7 @@ export default function DashboardPage({
</div>
<div className="dashboard-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{pluginExecution ? <Tag value={pluginExecution.label} severity="warning" title={pluginExecution.title} /> : null}
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
{normalizedStatus === 'READY_TO_ENCODE'
@@ -2700,6 +2711,7 @@ export default function DashboardPage({
</div>
)}
</Card>
)}
</div>
<div className="dashboard-col dashboard-col-right">
@@ -3051,7 +3063,7 @@ export default function DashboardPage({
</div>
</div>
{monitoringState.enabled ? (
{!isSubpageRoute && monitoringState.enabled ? (
<Card title="Hardware Monitoring">
<div className="hardware-monitor-head">
{!monitoringState.enabled ? (
+7 -26
View File
@@ -48,7 +48,7 @@ export default function DatabasePage() {
const handleImportOrphanRaw = async (row) => {
const target = row?.rawPath || row?.folderName || '-';
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen und direkt scannen?`);
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`);
if (!confirmed) {
return;
}
@@ -57,31 +57,12 @@ export default function DatabasePage() {
try {
const response = await api.importOrphanRawFolder(row.rawPath);
const newJobId = response?.job?.id;
if (newJobId) {
try {
await api.reencodeJob(newJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Job angelegt & Scan gestartet',
detail: `Historieneintrag #${newJobId} erstellt, Mediainfo-Scan läuft.`,
life: 4000
});
} catch (scanError) {
toastRef.current?.show({
severity: 'info',
summary: 'Job angelegt',
detail: `Historieneintrag #${newJobId} erstellt. Scan konnte nicht automatisch gestartet werden: ${scanError.message}`,
life: 6000
});
}
} else {
toastRef.current?.show({
severity: 'success',
summary: 'Job angelegt',
detail: `Historieneintrag wurde erstellt.`,
life: 3500
});
}
toastRef.current?.show({
severity: 'success',
summary: 'Job angelegt',
detail: newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.',
life: 3500
});
await loadOrphans();
} catch (error) {
toastRef.current?.show({
+135
View File
@@ -10,6 +10,8 @@ import { Toast } from 'primereact/toast';
import { Dialog } from 'primereact/dialog';
import { api } from '../api/client';
import JobDetailDialog from '../components/JobDetailDialog';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import CdMetadataDialog from '../components/CdMetadataDialog';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
@@ -371,6 +373,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [queuedJobIds, setQueuedJobIds] = useState([]);
const toastRef = useRef(null);
@@ -735,6 +743,104 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const handleAssignOmdb = (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) return;
const context = {
jobId,
detectedTitle: row?.detected_title || row?.title || '',
selectedMetadata: {
title: row?.title || row?.detected_title || '',
year: row?.year || null,
imdbId: row?.imdb_id || null,
poster: row?.poster_url || null
},
omdbCandidates: []
};
setMetadataDialogContext(context);
setMetadataDialogVisible(true);
};
const handleOmdbSearch = async (query) => {
try {
const response = await api.searchOmdb(query);
return response.results || [];
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Suche fehlgeschlagen', detail: error.message });
return [];
}
};
const handleOmdbSubmit = async (payload) => {
setOmdbAssignBusy(true);
try {
await api.assignJobOmdb(payload.jobId, payload);
toastRef.current?.show({ severity: 'success', summary: 'Metadaten zugewiesen', detail: 'OMDB-Metadaten wurden aktualisiert.', life: 3000 });
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
await load();
await refreshDetailIfOpen(payload.jobId);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Zuweisung fehlgeschlagen', detail: error.message });
} finally {
setOmdbAssignBusy(false);
}
};
const handleAssignCdMetadata = (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) return;
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
const tracks = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0
? makemkvInfo.tracks
: (Array.isArray(encodePlan?.tracks) ? encodePlan.tracks : []);
const context = {
jobId,
detectedTitle: row?.detected_title || row?.title || '',
tracks,
selectedMetadata: makemkvInfo?.selectedMetadata || {}
};
setCdMetadataDialogContext(context);
setCdMetadataDialogVisible(true);
};
const handleMusicBrainzSearch = async (query) => {
try {
const response = await api.searchMusicBrainz(query);
return response.results || [];
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Suche fehlgeschlagen', detail: error.message });
return [];
}
};
const handleMusicBrainzReleaseFetch = async (mbId) => {
try {
const response = await api.getMusicBrainzRelease(mbId);
return response?.release || null;
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Abruf fehlgeschlagen', detail: error.message });
return null;
}
};
const handleCdMetadataSubmit = async (payload) => {
setCdMetadataAssignBusy(true);
try {
await api.assignJobCdMetadata(payload.jobId, payload);
toastRef.current?.show({ severity: 'success', summary: 'Metadaten zugewiesen', detail: 'MusicBrainz-Metadaten wurden aktualisiert.', life: 3000 });
setCdMetadataDialogVisible(false);
setCdMetadataDialogContext(null);
await load();
await refreshDetailIfOpen(payload.jobId);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Metadaten-Zuweisung fehlgeschlagen', detail: error.message });
} finally {
setCdMetadataAssignBusy(false);
}
};
const closeDeleteEntryDialog = () => {
if (deleteEntryTargetBusy) {
return;
@@ -1177,12 +1283,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
onRestartReview={handleRestartReview}
onReencode={handleReencode}
onRetry={handleRetry}
onAssignOmdb={handleAssignOmdb}
onAssignCdMetadata={handleAssignCdMetadata}
onDeleteFiles={handleDeleteFiles}
onDeleteEntry={handleDeleteEntry}
onDownloadArchive={handleDownloadArchive}
onRemoveFromQueue={handleRemoveFromQueue}
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
actionBusy={actionBusy}
omdbAssignBusy={omdbAssignBusy}
cdMetadataAssignBusy={cdMetadataAssignBusy}
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
deleteEntryBusy={deleteEntryBusy}
downloadBusyTarget={downloadBusyTarget}
@@ -1324,6 +1434,31 @@ export default function HistoryPage({ refreshToken = 0 }) {
/>
</div>
</Dialog>
<MetadataSelectionDialog
visible={metadataDialogVisible}
context={metadataDialogContext || {}}
onHide={() => {
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
}}
onSubmit={handleOmdbSubmit}
onSearch={handleOmdbSearch}
busy={omdbAssignBusy}
/>
<CdMetadataDialog
visible={cdMetadataDialogVisible}
context={cdMetadataDialogContext || {}}
onHide={() => {
setCdMetadataDialogVisible(false);
setCdMetadataDialogContext(null);
}}
onSubmit={handleCdMetadataSubmit}
onSearch={handleMusicBrainzSearch}
onFetchRelease={handleMusicBrainzReleaseFetch}
busy={cdMetadataAssignBusy}
/>
</div>
);
}
+35 -5
View File
@@ -13,6 +13,7 @@
--rip-muted: #6a4d38;
--rip-panel: #fffaf1;
--rip-panel-soft: #fdf5e7;
--dashboard-side-width: 20rem;
/* PrimeReact theme tokens */
--primary-color: var(--rip-brown-600);
@@ -238,13 +239,16 @@ body {
}
.app-main {
width: min(1280px, 96vw);
margin: 1rem auto 2rem;
width: 100%;
max-width: none;
margin: 1rem 0 2rem;
padding: 0 1rem;
}
.app-upload-banner {
width: min(1280px, 96vw);
margin: 0.9rem auto 0;
width: auto;
max-width: none;
margin: 0.9rem 1rem 0;
padding: 0.8rem 0.95rem;
border: 1px solid var(--rip-border);
border-radius: 0.7rem;
@@ -316,7 +320,8 @@ body {
.dashboard-3col-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
width: 100%;
grid-template-columns: var(--dashboard-side-width) minmax(0, 1fr) var(--dashboard-side-width);
gap: 1rem;
align-items: start;
}
@@ -328,6 +333,18 @@ body {
min-width: 0;
}
.dashboard-subpage-content {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.dashboard-subpage-content > * {
min-width: 0;
}
@media (max-width: 1100px) {
.dashboard-3col-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
@@ -3295,6 +3312,19 @@ body {
color: var(--rip-muted);
}
.track-selection-inline-neutral {
display: inline-flex;
align-items: center;
gap: 0.28rem;
color: var(--rip-muted);
font-weight: 600;
font-size: 0.8rem;
}
.track-selection-inline-neutral .pi {
font-size: 0.85rem;
}
.spurauswahl-block {
display: grid;
gap: 0.5rem;
+77
View File
@@ -0,0 +1,77 @@
function normalizeStage(value) {
const stage = String(value || '').trim().toLowerCase();
return stage || null;
}
const STAGE_LABELS = {
analyze: 'Analyse',
rip: 'Rip',
review: 'Review',
encode: 'Encode',
finalize: 'Finalize',
oncancel: 'Cancel',
onretry: 'Retry'
};
export function normalizePluginExecutionState(rawState) {
if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) {
return null;
}
const markerSource = String(rawState.markerSource || '').trim().toLowerCase();
const pluginFile = String(rawState.pluginFile || '').trim();
if (markerSource !== 'plugin-file' || !pluginFile) {
return null;
}
const pluginId = String(rawState.pluginId || '').trim().toLowerCase() || null;
const pluginName = String(rawState.pluginName || '').trim() || pluginId || 'Plugin';
const explicitStages = Array.isArray(rawState.stages)
? rawState.stages.map((stage) => normalizeStage(stage)).filter(Boolean)
: [];
const byStage = rawState.byStage && typeof rawState.byStage === 'object' && !Array.isArray(rawState.byStage)
? rawState.byStage
: {};
const stages = Array.from(new Set([
...explicitStages,
...Object.keys(byStage).map((stage) => normalizeStage(stage)).filter(Boolean),
...[normalizeStage(rawState.lastStage)].filter(Boolean)
]));
const stageLabels = stages.map((stage) => STAGE_LABELS[stage] || stage);
const titleParts = [
`Plugin-Code aktiv: ${pluginName}`,
pluginId ? `ID: ${pluginId}` : null,
`Datei: ${pluginFile}`,
stageLabels.length > 0 ? `Phasen: ${stageLabels.join(', ')}` : null
].filter(Boolean);
return {
markerSource,
pluginId,
pluginName,
pluginFile,
stages,
stageLabels,
label: `Beta: ${pluginName}`,
title: titleParts.join(' | ')
};
}
export function resolveJobPluginExecution(job = null, liveContext = null) {
const liveExecution = normalizePluginExecutionState(liveContext?.pluginExecution);
if (liveExecution) {
return liveExecution;
}
const makemkvExecution = normalizePluginExecutionState(job?.makemkvInfo?.pluginExecution);
if (makemkvExecution) {
return makemkvExecution;
}
const handbrakeExecution = normalizePluginExecutionState(job?.handbrakeInfo?.pluginExecution);
if (handbrakeExecution) {
return handbrakeExecution;
}
return normalizePluginExecutionState(job?.pluginExecution);
}