0.12.0 Begin neu Architecture
This commit is contained in:
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.11.0-5",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+10
-7
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user