0.16.1-7 Release-Bugfixes
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.16.1-6",
|
||||
"version": "0.16.1-7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.16.1-6",
|
||||
"version": "0.16.1-7",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.16.1-6",
|
||||
"version": "0.16.1-7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+114
-149
@@ -1,8 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { ConfirmDialog } from 'primereact/confirmdialog';
|
||||
import { api } from './api/client';
|
||||
@@ -14,6 +12,7 @@ import DatabasePage from './pages/DatabasePage';
|
||||
import DownloadsPage from './pages/DownloadsPage';
|
||||
import ConverterPage from './pages/ConverterPage';
|
||||
import AudiobooksPage from './pages/AudiobooksPage';
|
||||
import TmdbMigrationPage from './pages/TmdbMigrationPage';
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
@@ -23,6 +22,20 @@ function normalizeJobId(value) {
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function extractUploadJobIdFromResponse(response) {
|
||||
const payload = response && typeof response === 'object' ? response : {};
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
|
||||
return (
|
||||
normalizeJobId(result?.jobId)
|
||||
|| normalizeJobId(payload?.jobId)
|
||||
|| normalizeJobId(result?.id)
|
||||
|| normalizeJobId(payload?.id)
|
||||
|| normalizeJobId(result?.job?.id)
|
||||
|| normalizeJobId(payload?.job?.id)
|
||||
|| null
|
||||
);
|
||||
}
|
||||
|
||||
function clampPercent(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
@@ -51,25 +64,6 @@ function isTerminalStage(value) {
|
||||
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 'n/a';
|
||||
}
|
||||
if (parsed === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let unitIndex = 0;
|
||||
let current = parsed;
|
||||
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||
current /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
const digits = unitIndex <= 1 ? 0 : 2;
|
||||
return `${current.toFixed(digits)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function createInitialAudiobookUploadState() {
|
||||
return {
|
||||
phase: 'idle',
|
||||
@@ -80,28 +74,14 @@ function createInitialAudiobookUploadState() {
|
||||
statusText: null,
|
||||
errorMessage: null,
|
||||
jobId: null,
|
||||
uploadSessionId: null,
|
||||
fileLastModified: null,
|
||||
fileFingerprint: null,
|
||||
startedAt: null,
|
||||
finishedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
function getAudiobookUploadTagMeta(phase) {
|
||||
const normalized = String(phase || '').trim().toLowerCase();
|
||||
if (normalized === 'uploading') {
|
||||
return { label: 'Upload läuft', severity: 'warning' };
|
||||
}
|
||||
if (normalized === 'processing') {
|
||||
return { label: 'Server verarbeitet', severity: 'info' };
|
||||
}
|
||||
if (normalized === 'completed') {
|
||||
return { label: 'Bereit', severity: 'success' };
|
||||
}
|
||||
if (normalized === 'error') {
|
||||
return { label: 'Fehler', severity: 'danger' };
|
||||
}
|
||||
return { label: 'Inaktiv', severity: 'secondary' };
|
||||
}
|
||||
|
||||
function getDownloadIndicatorMeta(summary) {
|
||||
const activeCount = Number(summary?.activeCount || 0);
|
||||
const failedCount = Number(summary?.failedCount || 0);
|
||||
@@ -144,12 +124,13 @@ function App() {
|
||||
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
||||
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
|
||||
const [downloadSummary, setDownloadSummary] = useState(null);
|
||||
const [pendingRipperJobId, setPendingRipperJobId] = useState(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const globalToastRef = useRef(null);
|
||||
const prevCdDrivesRef = useRef({});
|
||||
const prevJobProgressStatesRef = useRef({});
|
||||
const hardwareWarmupRef = useRef({ timer: null, attempts: 0 });
|
||||
const audiobookUploadAbortRef = useRef(null);
|
||||
|
||||
const clearHardwareWarmupRetry = () => {
|
||||
if (hardwareWarmupRef.current?.timer) {
|
||||
@@ -198,6 +179,39 @@ function App() {
|
||||
prevCdDrivesRef.current = current;
|
||||
}, [pipeline?.cdDrives]);
|
||||
|
||||
// Trigger a jobs refresh when a tracked job transitions into a terminal
|
||||
// state through PIPELINE_PROGRESS, so Ripper/History update immediately.
|
||||
useEffect(() => {
|
||||
const current = pipeline?.jobProgress && typeof pipeline.jobProgress === 'object'
|
||||
? pipeline.jobProgress
|
||||
: {};
|
||||
const previousStates = prevJobProgressStatesRef.current || {};
|
||||
const nextStates = {};
|
||||
let shouldRefresh = false;
|
||||
|
||||
for (const [jobId, entry] of Object.entries(current)) {
|
||||
const currentState = normalizeStage(entry?.state);
|
||||
const previousState = normalizeStage(previousStates[jobId]);
|
||||
if (currentState) {
|
||||
nextStates[jobId] = currentState;
|
||||
}
|
||||
if (
|
||||
currentState
|
||||
&& isTerminalStage(currentState)
|
||||
&& currentState !== previousState
|
||||
&& !isTerminalStage(previousState)
|
||||
) {
|
||||
shouldRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
prevJobProgressStatesRef.current = nextStates;
|
||||
if (shouldRefresh) {
|
||||
setRipperJobsRefreshToken((token) => token + 1);
|
||||
setHistoryJobsRefreshToken((token) => token + 1);
|
||||
}
|
||||
}, [pipeline?.jobProgress]);
|
||||
|
||||
const refreshPipeline = async () => {
|
||||
const response = await api.getPipelineState();
|
||||
setPipeline(response.pipeline);
|
||||
@@ -221,83 +235,84 @@ function App() {
|
||||
return response;
|
||||
};
|
||||
|
||||
const clearAudiobookUpload = () => {
|
||||
setAudiobookUpload(createInitialAudiobookUploadState());
|
||||
};
|
||||
|
||||
const handleAudiobookUpload = async (file, payload = {}) => {
|
||||
if (!file) {
|
||||
throw new Error('Bitte zuerst eine AAX-Datei auswählen.');
|
||||
}
|
||||
|
||||
const fallbackTotalBytes = Number.isFinite(Number(file.size)) && Number(file.size) > 0
|
||||
? Number(file.size)
|
||||
: 0;
|
||||
const fileName = String(file.name || '').trim() || 'upload.aax';
|
||||
const totalBytes = Number(file.size || 0);
|
||||
const abortController = new AbortController();
|
||||
audiobookUploadAbortRef.current = abortController;
|
||||
|
||||
setAudiobookUpload({
|
||||
phase: 'uploading',
|
||||
fileName: String(file.name || '').trim() || 'upload.aax',
|
||||
fileName,
|
||||
loadedBytes: 0,
|
||||
totalBytes: fallbackTotalBytes,
|
||||
totalBytes,
|
||||
progressPercent: 0,
|
||||
statusText: 'AAX-Datei wird hochgeladen ...',
|
||||
errorMessage: null,
|
||||
jobId: null,
|
||||
uploadSessionId: null,
|
||||
fileLastModified: Number.isFinite(Number(file.lastModified))
|
||||
? Math.trunc(Number(file.lastModified))
|
||||
: null,
|
||||
fileFingerprint: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await api.uploadAudiobook(file, payload, {
|
||||
signal: abortController.signal,
|
||||
onProgress: ({ loaded, total, percent }) => {
|
||||
const nextLoaded = Number.isFinite(Number(loaded)) && Number(loaded) >= 0
|
||||
? Number(loaded)
|
||||
: 0;
|
||||
const nextTotal = Number.isFinite(Number(total)) && Number(total) > 0
|
||||
? Number(total)
|
||||
: fallbackTotalBytes;
|
||||
const nextPercent = Number.isFinite(Number(percent))
|
||||
const loadedBytes = Number.isFinite(Number(loaded)) ? Number(loaded) : 0;
|
||||
const totalSize = Number.isFinite(Number(total)) && Number(total) > 0 ? Number(total) : totalBytes;
|
||||
const progressPercent = Number.isFinite(Number(percent))
|
||||
? clampPercent(Number(percent))
|
||||
: (nextTotal > 0 ? clampPercent((nextLoaded / nextTotal) * 100) : 0);
|
||||
const transferComplete = nextTotal > 0 && nextLoaded >= nextTotal;
|
||||
|
||||
: (totalSize > 0 ? clampPercent((loadedBytes / totalSize) * 100) : 0);
|
||||
setAudiobookUpload((prev) => ({
|
||||
...prev,
|
||||
phase: transferComplete ? 'processing' : 'uploading',
|
||||
loadedBytes: nextLoaded,
|
||||
totalBytes: nextTotal,
|
||||
progressPercent: nextPercent,
|
||||
statusText: transferComplete
|
||||
? 'Upload abgeschlossen, AAX wird serverseitig verarbeitet ...'
|
||||
: 'AAX-Datei wird hochgeladen ...'
|
||||
phase: 'uploading',
|
||||
loadedBytes,
|
||||
totalBytes: totalSize,
|
||||
progressPercent,
|
||||
statusText: 'AAX-Datei wird hochgeladen ...',
|
||||
errorMessage: null
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
||||
const uploadedJobId = extractUploadJobIdFromResponse(response);
|
||||
const started = Boolean(response?.result?.started);
|
||||
const queued = Boolean(response?.result?.queued);
|
||||
await refreshPipeline().catch(() => null);
|
||||
setRipperJobsRefreshToken((prev) => prev + 1);
|
||||
setHistoryJobsRefreshToken((prev) => prev + 1);
|
||||
if (uploadedJobId) {
|
||||
setPendingRipperJobId(uploadedJobId);
|
||||
}
|
||||
|
||||
setAudiobookUpload((prev) => ({
|
||||
...prev,
|
||||
phase: 'completed',
|
||||
loadedBytes: prev.totalBytes || prev.loadedBytes || fallbackTotalBytes,
|
||||
totalBytes: prev.totalBytes || fallbackTotalBytes,
|
||||
loadedBytes: prev.totalBytes || totalBytes,
|
||||
totalBytes: prev.totalBytes || totalBytes,
|
||||
progressPercent: 100,
|
||||
statusText: uploadedJobId
|
||||
? `Upload abgeschlossen. Job #${uploadedJobId} ist bereit fuer den naechsten Schritt.`
|
||||
? (
|
||||
queued
|
||||
? `Upload abgeschlossen. Job #${uploadedJobId} wurde in die Queue eingereiht.`
|
||||
: (started
|
||||
? `Upload abgeschlossen. Job #${uploadedJobId} wurde gestartet.`
|
||||
: `Upload abgeschlossen. Job #${uploadedJobId} ist bereit.`)
|
||||
)
|
||||
: 'Upload abgeschlossen.',
|
||||
errorMessage: null,
|
||||
jobId: uploadedJobId,
|
||||
finishedAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
setAudiobookUpload(createInitialAudiobookUploadState());
|
||||
throw error;
|
||||
}
|
||||
setAudiobookUpload((prev) => ({
|
||||
...prev,
|
||||
phase: 'error',
|
||||
@@ -306,17 +321,20 @@ function App() {
|
||||
finishedAt: new Date().toISOString()
|
||||
}));
|
||||
throw error;
|
||||
} finally {
|
||||
if (audiobookUploadAbortRef.current === abortController) {
|
||||
audiobookUploadAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRipperJobFocusConsumed = (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
const handleCancelAudiobookUpload = () => {
|
||||
const activeAbortController = audiobookUploadAbortRef.current;
|
||||
if (activeAbortController) {
|
||||
activeAbortController.abort();
|
||||
return;
|
||||
}
|
||||
setPendingRipperJobId((prev) => (
|
||||
normalizeJobId(prev) === normalizedJobId ? null : prev
|
||||
));
|
||||
setAudiobookUpload(createInitialAudiobookUploadState());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -401,6 +419,11 @@ function App() {
|
||||
)
|
||||
)
|
||||
);
|
||||
const allowRunningTransitionFromTerminal = Boolean(
|
||||
normalizedProgressJobId
|
||||
&& incomingIsRunning
|
||||
&& prevActiveJobId === normalizedProgressJobId
|
||||
);
|
||||
|
||||
// Ignore late/stale progress packets that arrive after a terminal state
|
||||
// or regress a job from an interactive/ready state back into an
|
||||
@@ -408,6 +431,7 @@ function App() {
|
||||
if (
|
||||
normalizedProgressJobId
|
||||
&& !incomingIsTerminal
|
||||
&& !allowRunningTransitionFromTerminal
|
||||
&& (
|
||||
prevJobProgressIsTerminal
|
||||
|| matchingCdDriveIsTerminal
|
||||
@@ -421,7 +445,9 @@ function App() {
|
||||
if (progressJobId != null) {
|
||||
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
|
||||
const previousJobStage = normalizeStage(previousJobProgress?.state);
|
||||
const keepPreviousJobStage = isTerminalStage(previousJobStage) && !incomingIsTerminal;
|
||||
const keepPreviousJobStage = isTerminalStage(previousJobStage)
|
||||
&& !incomingIsTerminal
|
||||
&& !allowRunningTransitionFromTerminal;
|
||||
const mergedJobContext = contextPatch
|
||||
? {
|
||||
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
|
||||
@@ -445,7 +471,9 @@ function App() {
|
||||
};
|
||||
}
|
||||
if (progressJobId === prev?.activeJobId || progressJobId == null) {
|
||||
const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) && !incomingIsTerminal;
|
||||
const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage)
|
||||
&& !incomingIsTerminal
|
||||
&& !allowRunningTransitionFromTerminal;
|
||||
next.state = keepPreviousGlobalStage ? prev?.state : (payload.state ?? prev?.state);
|
||||
next.progress = keepPreviousGlobalStage ? prev?.progress : (payload.progress ?? prev?.progress);
|
||||
next.eta = keepPreviousGlobalStage ? prev?.eta : (payload.eta ?? prev?.eta);
|
||||
@@ -613,18 +641,6 @@ function App() {
|
||||
{ label: 'Downloads', path: '/downloads' },
|
||||
...(expertMode ? [{ label: 'Database', path: '/database' }] : [])
|
||||
];
|
||||
const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
|
||||
const showAudiobookUploadBanner = uploadPhase !== 'idle';
|
||||
const uploadProgress = clampPercent(audiobookUpload?.progressPercent);
|
||||
const uploadTagMeta = getAudiobookUploadTagMeta(uploadPhase);
|
||||
const uploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0);
|
||||
const uploadTotalBytes = Number(audiobookUpload?.totalBytes || 0);
|
||||
const uploadBytesLabel = uploadTotalBytes > 0
|
||||
? `${formatBytes(uploadLoadedBytes)} / ${formatBytes(uploadTotalBytes)}`
|
||||
: (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null);
|
||||
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
|
||||
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
|
||||
const isAudiobooksRoute = location.pathname === '/audiobooks';
|
||||
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
|
||||
const isNavActive = (path) => {
|
||||
if (path === '/ripper') {
|
||||
@@ -664,57 +680,6 @@ function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showAudiobookUploadBanner ? (
|
||||
<section className={`app-upload-banner phase-${uploadPhase}`}>
|
||||
<div className="app-upload-banner-copy">
|
||||
<div className="app-upload-banner-head">
|
||||
<strong>Audiobook Upload</strong>
|
||||
<Tag value={uploadTagMeta.label} severity={uploadTagMeta.severity} />
|
||||
</div>
|
||||
<small>{audiobookUpload?.statusText || 'Upload aktiv.'}</small>
|
||||
{audiobookUpload?.fileName ? <small>Datei: {audiobookUpload.fileName}</small> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="app-upload-banner-progress"
|
||||
aria-label={`Audiobook Upload ${Math.trunc(uploadProgress)} Prozent`}
|
||||
>
|
||||
<ProgressBar value={uploadProgress} showValue={false} />
|
||||
<small>
|
||||
{uploadPhase === 'processing'
|
||||
? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}`
|
||||
: uploadBytesLabel
|
||||
? `${Math.trunc(uploadProgress)}% | ${uploadBytesLabel}`
|
||||
: `${Math.trunc(uploadProgress)}%`}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="app-upload-banner-actions">
|
||||
{hasUploadedJob && !isAudiobooksRoute ? (
|
||||
<Button
|
||||
label="Zu Audiobooks"
|
||||
icon="pi pi-arrow-right"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => {
|
||||
navigate('/audiobooks');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{canDismissUploadBanner ? (
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
aria-label="Upload-Hinweis schliessen"
|
||||
onClick={clearAudiobookUpload}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -726,8 +691,6 @@ function App() {
|
||||
lastDiscEvent={lastDiscEvent}
|
||||
refreshPipeline={refreshPipeline}
|
||||
jobsRefreshToken={ripperJobsRefreshToken}
|
||||
pendingExpandedJobId={pendingRipperJobId}
|
||||
onPendingExpandedJobHandled={handleRipperJobFocusConsumed}
|
||||
downloadSummary={downloadSummary}
|
||||
>
|
||||
<Outlet />
|
||||
@@ -738,6 +701,7 @@ function App() {
|
||||
<Route path="ripper" element={null} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
||||
<Route path="tmdb-migration" element={<TmdbMigrationPage />} />
|
||||
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
||||
<Route path="database" element={<DatabasePage />} />
|
||||
<Route path="converter" element={<ConverterPage />} />
|
||||
@@ -747,6 +711,7 @@ function App() {
|
||||
<AudiobooksPage
|
||||
audiobookUpload={audiobookUpload}
|
||||
onAudiobookUpload={handleAudiobookUpload}
|
||||
onCancelAudiobookUpload={handleCancelAudiobookUpload}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -267,6 +267,12 @@ async function requestWithXhr(path, options = {}) {
|
||||
|
||||
xhr.onerror = () => {
|
||||
settle(() => {
|
||||
if (Number(xhr.status || 0) === 0) {
|
||||
const abortLikeError = new Error('Request unterbrochen.');
|
||||
abortLikeError.name = 'AbortError';
|
||||
reject(abortLikeError);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Netzwerkfehler'));
|
||||
});
|
||||
};
|
||||
@@ -564,8 +570,8 @@ export const api = {
|
||||
});
|
||||
return result;
|
||||
},
|
||||
searchOmdb(q) {
|
||||
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
|
||||
searchTmdbMovie(q) {
|
||||
return request(`/pipeline/tmdb/movie/search?q=${encodeURIComponent(q)}`);
|
||||
},
|
||||
searchTmdbSeries(q, seasonNumber = null) {
|
||||
const params = new URLSearchParams();
|
||||
@@ -575,8 +581,14 @@ export const api = {
|
||||
}
|
||||
return request(`/pipeline/tmdb/series/search?${params.toString()}`);
|
||||
},
|
||||
searchMusicBrainz(q) {
|
||||
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
|
||||
searchMusicBrainz(q, options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', String(q || ''));
|
||||
const expectedTrackCount = Number(options?.trackCount);
|
||||
if (Number.isFinite(expectedTrackCount) && expectedTrackCount > 0) {
|
||||
params.set('trackCount', String(Math.trunc(expectedTrackCount)));
|
||||
}
|
||||
return request(`/pipeline/cd/musicbrainz/search?${params.toString()}`);
|
||||
},
|
||||
getMusicBrainzRelease(mbId) {
|
||||
return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`);
|
||||
@@ -628,9 +640,6 @@ export const api = {
|
||||
getAudiobookJobs() {
|
||||
return request('/pipeline/audiobook/jobs');
|
||||
},
|
||||
getAudiobookOutputTree() {
|
||||
return request('/pipeline/audiobook/output-tree');
|
||||
},
|
||||
async selectMetadata(payload) {
|
||||
const result = await request('/pipeline/select-metadata', {
|
||||
method: 'POST',
|
||||
@@ -816,6 +825,24 @@ export const api = {
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return request(`/history${suffix}`);
|
||||
},
|
||||
getTmdbMigrationPendingJobs(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (Number.isFinite(Number(params.limit)) && Number(params.limit) > 0) {
|
||||
query.set('limit', String(Math.trunc(Number(params.limit))));
|
||||
}
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return request(`/history/tmdb-migration/pending${suffix}`);
|
||||
},
|
||||
async setJobTmdbMigrationFlag(jobId, migrated = false) {
|
||||
const result = await request(`/history/${jobId}/tmdb-migration`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
migrated: Boolean(migrated)
|
||||
})
|
||||
});
|
||||
afterMutationInvalidate(['/history']);
|
||||
return result;
|
||||
},
|
||||
getOrphanRawFolders() {
|
||||
return request('/history/orphan-raw');
|
||||
},
|
||||
@@ -827,8 +854,16 @@ export const api = {
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
async assignJobOmdb(jobId, payload = {}) {
|
||||
const result = await request(`/history/${jobId}/omdb/assign`, {
|
||||
async deleteOrphanRawFolder(rawPath) {
|
||||
const result = await request('/history/orphan-raw/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rawPath })
|
||||
});
|
||||
afterMutationInvalidate(['/history']);
|
||||
return result;
|
||||
},
|
||||
async assignJobMetadata(jobId, payload = {}) {
|
||||
const result = await request(`/history/${jobId}/metadata/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Dialog } from 'primereact/dialog';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Slider } from 'primereact/slider';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
|
||||
import { api } from '../api/client';
|
||||
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
|
||||
|
||||
function normalizeJobId(value) {
|
||||
@@ -84,6 +84,108 @@ function normalizeEditableChapters(chapters = []) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatChapterDuration(startSecondsValue, endSecondsValue) {
|
||||
const startSeconds = Number(startSecondsValue || 0);
|
||||
const endSeconds = Number(endSecondsValue || 0);
|
||||
if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || endSeconds <= startSeconds) {
|
||||
return '-';
|
||||
}
|
||||
return formatChapterTime(endSeconds - startSeconds);
|
||||
}
|
||||
|
||||
function normalizeTrackStageStatus(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') {
|
||||
return 'done';
|
||||
}
|
||||
if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') {
|
||||
return 'in_progress';
|
||||
}
|
||||
if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') {
|
||||
return 'error';
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function trackStatusTagMeta(value) {
|
||||
const normalized = normalizeTrackStageStatus(value);
|
||||
if (normalized === 'done') {
|
||||
return { label: 'Fertig', severity: 'success' };
|
||||
}
|
||||
if (normalized === 'in_progress') {
|
||||
return { label: 'Läuft', severity: 'info' };
|
||||
}
|
||||
if (normalized === 'error') {
|
||||
return { label: 'Fehler', severity: 'danger' };
|
||||
}
|
||||
return { label: 'Offen', severity: 'secondary' };
|
||||
}
|
||||
|
||||
function normalizeScriptId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeChainId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeIdList(values, kind = 'script') {
|
||||
const list = Array.isArray(values) ? values : [];
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const value of list) {
|
||||
const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value);
|
||||
if (normalized === null) {
|
||||
continue;
|
||||
}
|
||||
const key = String(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildEncodeItemsFromConfig(config, phase) {
|
||||
const source = config && typeof config === 'object' ? config : {};
|
||||
const prefix = phase === 'post' ? 'post' : 'pre';
|
||||
const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : [];
|
||||
const fromExplicit = explicitItems
|
||||
.map((item) => {
|
||||
const type = String(item?.type || '').trim().toLowerCase();
|
||||
if (type !== 'script' && type !== 'chain') {
|
||||
return null;
|
||||
}
|
||||
const id = type === 'chain'
|
||||
? normalizeChainId(item?.id ?? item?.chainId)
|
||||
: normalizeScriptId(item?.id ?? item?.scriptId);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return { type, id };
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (fromExplicit.length > 0) {
|
||||
return fromExplicit;
|
||||
}
|
||||
const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script');
|
||||
const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain');
|
||||
return [
|
||||
...scriptIds.map((id) => ({ type: 'script', id })),
|
||||
...chainIds.map((id) => ({ type: 'chain', id }))
|
||||
];
|
||||
}
|
||||
|
||||
function FormatField({ field, value, onChange, disabled }) {
|
||||
if (field.type === 'slider') {
|
||||
return (
|
||||
@@ -128,7 +230,6 @@ export default function AudiobookConfigPanel({
|
||||
pipeline,
|
||||
onStart,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onDeleteJob,
|
||||
busy
|
||||
}) {
|
||||
@@ -149,6 +250,18 @@ export default function AudiobookConfigPanel({
|
||||
const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions));
|
||||
const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters));
|
||||
const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false);
|
||||
const [scriptCatalog, setScriptCatalog] = useState([]);
|
||||
const [chainCatalog, setChainCatalog] = useState([]);
|
||||
const [preRipItems, setPreRipItems] = useState([]);
|
||||
const [postRipItems, setPostRipItems] = useState([]);
|
||||
const audiobookConfigKey = JSON.stringify({
|
||||
preEncodeScriptIds: normalizeIdList(audiobookConfig?.preEncodeScriptIds, 'script'),
|
||||
postEncodeScriptIds: normalizeIdList(audiobookConfig?.postEncodeScriptIds, 'script'),
|
||||
preEncodeChainIds: normalizeIdList(audiobookConfig?.preEncodeChainIds, 'chain'),
|
||||
postEncodeChainIds: normalizeIdList(audiobookConfig?.postEncodeChainIds, 'chain'),
|
||||
preEncodeItems: Array.isArray(audiobookConfig?.preEncodeItems) ? audiobookConfig.preEncodeItems : [],
|
||||
postEncodeItems: Array.isArray(audiobookConfig?.postEncodeItems) ? audiobookConfig.postEncodeItems : []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const nextFormat = normalizeFormat(audiobookConfig?.format);
|
||||
@@ -160,13 +273,51 @@ export default function AudiobookConfigPanel({
|
||||
setEditableChapters(normalizeEditableChapters(chapters));
|
||||
}, [jobId, JSON.stringify(chapters || [])]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadCatalog = async () => {
|
||||
try {
|
||||
const [scriptsResponse, chainsResponse] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const scripts = scriptsResponse.status === 'fulfilled'
|
||||
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
|
||||
: [];
|
||||
const chains = chainsResponse.status === 'fulfilled'
|
||||
? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : [])
|
||||
: [];
|
||||
setScriptCatalog(scripts.map((item) => ({ id: item?.id, name: item?.name })));
|
||||
setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name })));
|
||||
} catch (_error) {
|
||||
if (!cancelled) {
|
||||
setScriptCatalog([]);
|
||||
setChainCatalog([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
void loadCatalog();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPreRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'pre'));
|
||||
setPostRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'post'));
|
||||
}, [jobId, audiobookConfigKey]);
|
||||
|
||||
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3;
|
||||
const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED');
|
||||
const canStart = Boolean(jobId) && (
|
||||
state === 'READY_TO_START'
|
||||
|| state === 'READY_TO_ENCODE'
|
||||
|| state === 'ERROR'
|
||||
|| state === 'CANCELLED'
|
||||
);
|
||||
const isRunning = state === 'ENCODING';
|
||||
const isFinished = state === 'FINISHED';
|
||||
const progress = Number.isFinite(Number(pipeline?.progress)) ? Math.max(0, Math.min(100, Number(pipeline.progress))) : 0;
|
||||
const outputPath = String(context?.outputPath || '').trim() || null;
|
||||
const isSplitOutput = format === 'mp3' || format === 'flac';
|
||||
const showEditableChapters = !(isSplitOutput && isRunning);
|
||||
const currentChapter = context?.currentChapter && typeof context.currentChapter === 'object' ? context.currentChapter : null;
|
||||
const completedChapterCount = Number(context?.completedChapterCount ?? -1);
|
||||
const chapterTotal = Number(currentChapter?.total || editableChapters.length || 0);
|
||||
@@ -182,6 +333,88 @@ export default function AudiobookConfigPanel({
|
||||
[schema, formatOptions]
|
||||
);
|
||||
|
||||
const moveEncodeItem = (phase, index, direction) => {
|
||||
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
|
||||
updater((prev) => {
|
||||
const list = Array.isArray(prev) ? [...prev] : [];
|
||||
const from = Number(index);
|
||||
const to = from + (direction === 'up' ? -1 : 1);
|
||||
if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) {
|
||||
return list;
|
||||
}
|
||||
const [moved] = list.splice(from, 1);
|
||||
list.splice(to, 0, moved);
|
||||
return list;
|
||||
});
|
||||
};
|
||||
|
||||
const addEncodeItem = (phase, type) => {
|
||||
const normalizedType = type === 'chain' ? 'chain' : 'script';
|
||||
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
|
||||
updater((prev) => {
|
||||
const current = Array.isArray(prev) ? prev : [];
|
||||
const selectedIds = new Set(
|
||||
current
|
||||
.filter((item) => item?.type === normalizedType)
|
||||
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
|
||||
.filter((id) => id !== null)
|
||||
.map((id) => String(id))
|
||||
);
|
||||
const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog;
|
||||
const candidate = (Array.isArray(catalog) ? catalog : [])
|
||||
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
|
||||
.find((id) => id !== null && !selectedIds.has(String(id)));
|
||||
if (candidate === undefined || candidate === null) {
|
||||
return current;
|
||||
}
|
||||
return [...current, { type: normalizedType, id: candidate }];
|
||||
});
|
||||
};
|
||||
|
||||
const changeEncodeItem = (phase, index, type, nextId) => {
|
||||
const normalizedType = type === 'chain' ? 'chain' : 'script';
|
||||
const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId);
|
||||
if (normalizedId === null) {
|
||||
return;
|
||||
}
|
||||
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
|
||||
updater((prev) => {
|
||||
const current = Array.isArray(prev) ? prev : [];
|
||||
const rowIndex = Number(index);
|
||||
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) {
|
||||
return current;
|
||||
}
|
||||
const duplicate = current.some((item, itemIndex) => {
|
||||
if (itemIndex === rowIndex) {
|
||||
return false;
|
||||
}
|
||||
if (item?.type !== normalizedType) {
|
||||
return false;
|
||||
}
|
||||
const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id);
|
||||
return existingId !== null && String(existingId) === String(normalizedId);
|
||||
});
|
||||
if (duplicate) {
|
||||
return current;
|
||||
}
|
||||
const next = [...current];
|
||||
next[rowIndex] = { type: normalizedType, id: normalizedId };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeEncodeItem = (phase, index) => {
|
||||
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
|
||||
updater((prev) => {
|
||||
const current = Array.isArray(prev) ? prev : [];
|
||||
const rowIndex = Number(index);
|
||||
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) {
|
||||
return current;
|
||||
}
|
||||
return current.filter((_, itemIndex) => itemIndex !== rowIndex);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="audiobook-config-panel">
|
||||
<div className="audiobook-config-head">
|
||||
@@ -217,11 +450,8 @@ export default function AudiobookConfigPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audiobook-config-tags">
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={statusLabel} severity={statusSeverity} />
|
||||
<Tag value={`Format: ${format.toUpperCase()}`} severity="info" />
|
||||
{metadata?.durationMs ? <Tag value={`Dauer: ${Math.round(Number(metadata.durationMs) / 60000)} min`} severity="secondary" /> : null}
|
||||
{posterUrl ? <Tag value="Cover erkannt" severity="success" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -263,78 +493,316 @@ export default function AudiobookConfigPanel({
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="audiobook-config-chapters">
|
||||
<h4>Kapitel</h4>
|
||||
{editableChapters.length === 0 ? (
|
||||
<small>Keine Kapitel in der Quelle erkannt.</small>
|
||||
) : (
|
||||
<div className="audiobook-chapter-list">
|
||||
{editableChapters.map((chapter, index) => (
|
||||
<div key={`${chapter.index}-${index}`} className="audiobook-chapter-row audiobook-chapter-row-editable">
|
||||
<div className="audiobook-chapter-row-head">
|
||||
<strong>#{chapter.index || index + 1}</strong>
|
||||
<small>
|
||||
{formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)}
|
||||
</small>
|
||||
{showEditableChapters ? (
|
||||
<div className="audiobook-config-chapters">
|
||||
<h4>Kapitel</h4>
|
||||
{editableChapters.length === 0 ? (
|
||||
<small>Keine Kapitel in der Quelle erkannt.</small>
|
||||
) : (
|
||||
<div className="audiobook-chapter-list">
|
||||
{editableChapters.map((chapter, index) => (
|
||||
<div key={`${chapter.index}-${index}`} className="audiobook-chapter-row audiobook-chapter-row-editable">
|
||||
<div className="audiobook-chapter-row-head">
|
||||
<strong>#{chapter.index || index + 1}</strong>
|
||||
<small>
|
||||
{formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)}
|
||||
</small>
|
||||
</div>
|
||||
<InputText
|
||||
value={chapter.title}
|
||||
onChange={(event) => {
|
||||
const nextTitle = event.target.value;
|
||||
setEditableChapters((prev) => prev.map((entry, entryIndex) => (
|
||||
entryIndex === index
|
||||
? { ...entry, title: nextTitle }
|
||||
: entry
|
||||
)));
|
||||
}}
|
||||
disabled={busy || isRunning}
|
||||
/>
|
||||
</div>
|
||||
<InputText
|
||||
value={chapter.title}
|
||||
onChange={(event) => {
|
||||
const nextTitle = event.target.value;
|
||||
setEditableChapters((prev) => prev.map((entry, entryIndex) => (
|
||||
entryIndex === index
|
||||
? { ...entry, title: nextTitle }
|
||||
: entry
|
||||
)));
|
||||
}}
|
||||
disabled={busy || isRunning}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isRunning ? (
|
||||
<div className="ripper-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.trunc(progress)}%`}>
|
||||
<ProgressBar value={progress} showValue={false} />
|
||||
<small>{Math.trunc(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}</small>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSplitOutput && (isRunning || isFinished) && editableChapters.length > 0 ? (
|
||||
<div className="audiobook-chapter-status">
|
||||
<strong>Kapitel-Status ({completedChapterCount >= 0 ? completedChapterCount : (isFinished ? editableChapters.length : 0)}/{chapterTotal || editableChapters.length} fertig)</strong>
|
||||
<div className="audiobook-chapter-status-list">
|
||||
{editableChapters.map((chapter, idx) => {
|
||||
const chIdx = chapter.index || idx + 1;
|
||||
const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount);
|
||||
const isActive = !isDone && currentChapter?.index === chIdx;
|
||||
const statusIcon = isDone ? 'pi pi-check-circle' : isActive ? 'pi pi-spin pi-spinner' : 'pi pi-circle';
|
||||
const statusClass = isDone ? 'chapter-status-done' : isActive ? 'chapter-status-active' : 'chapter-status-pending';
|
||||
return (
|
||||
<div key={chIdx} className={`audiobook-chapter-status-row ${statusClass}`}>
|
||||
<i className={statusIcon} />
|
||||
<span className="chapter-status-nr">#{String(chIdx).padStart(2, '0')}</span>
|
||||
<span className="chapter-status-title">{chapter.title}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="cd-track-selection">
|
||||
<div className="cd-track-list">
|
||||
<table className="cd-track-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="check">Auswahl</th>
|
||||
<th className="num">Nr</th>
|
||||
<th className="title">Titel</th>
|
||||
<th className="duration">Länge</th>
|
||||
<th className="status">Rip</th>
|
||||
<th className="status">Encode</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{editableChapters.map((chapter, idx) => {
|
||||
const chIdx = chapter.index || idx + 1;
|
||||
const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount);
|
||||
const isActive = !isDone && currentChapter?.index === chIdx;
|
||||
const ripMeta = trackStatusTagMeta('done');
|
||||
const encodeMeta = trackStatusTagMeta(isDone ? 'done' : (isActive ? 'in_progress' : 'pending'));
|
||||
return (
|
||||
<tr key={chIdx} className="selected">
|
||||
<td className="check">Ja</td>
|
||||
<td className="num">{String(chIdx).padStart(2, '0')}</td>
|
||||
<td className="title">{chapter.title || '-'}</td>
|
||||
<td className="duration">{formatChapterDuration(chapter.startSeconds, chapter.endSeconds)}</td>
|
||||
<td className="status"><Tag value={ripMeta.label} severity={ripMeta.severity} /></td>
|
||||
<td className="status"><Tag value={encodeMeta.label} severity={encodeMeta.severity} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{outputPath ? (
|
||||
<div className="audiobook-output-path">
|
||||
<strong>Ausgabe:</strong> <code>{outputPath}</code>
|
||||
<div className="encode-automation-grid">
|
||||
<div className="post-script-box">
|
||||
<h4>Pre-Rip Ausführungen (optional)</h4>
|
||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||
) : null}
|
||||
{preRipItems.length === 0 ? (
|
||||
<small>Keine Pre-Rip Ausführungen ausgewählt.</small>
|
||||
) : null}
|
||||
{preRipItems.map((item, rowIndex) => {
|
||||
const isScript = item?.type === 'script';
|
||||
const usedScriptIds = new Set(
|
||||
preRipItems
|
||||
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
|
||||
.map((entry) => normalizeScriptId(entry?.id))
|
||||
.filter((id) => id !== null)
|
||||
.map((id) => String(id))
|
||||
);
|
||||
const usedChainIds = new Set(
|
||||
preRipItems
|
||||
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
|
||||
.map((entry) => normalizeChainId(entry?.id))
|
||||
.filter((id) => id !== null)
|
||||
.map((id) => String(id))
|
||||
);
|
||||
const scriptOptions = scriptCatalog.map((entry) => ({
|
||||
label: entry?.name || `Skript #${entry?.id}`,
|
||||
value: normalizeScriptId(entry?.id),
|
||||
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
|
||||
})).filter((entry) => entry.value !== null);
|
||||
const chainOptions = chainCatalog.map((entry) => ({
|
||||
label: entry?.name || `Kette #${entry?.id}`,
|
||||
value: normalizeChainId(entry?.id),
|
||||
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
|
||||
})).filter((entry) => entry.value !== null);
|
||||
return (
|
||||
<div key={`ab-pre-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
|
||||
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
|
||||
<div className="cd-encode-item-order">
|
||||
<Button
|
||||
icon="pi pi-angle-up"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
onClick={() => moveEncodeItem('pre', rowIndex, 'up')}
|
||||
disabled={busy || rowIndex <= 0}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-angle-down"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
onClick={() => moveEncodeItem('pre', rowIndex, 'down')}
|
||||
disabled={busy || rowIndex >= preRipItems.length - 1}
|
||||
/>
|
||||
</div>
|
||||
{isScript ? (
|
||||
<Dropdown
|
||||
value={normalizeScriptId(item?.id)}
|
||||
options={scriptOptions}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => changeEncodeItem('pre', rowIndex, 'script', event.value)}
|
||||
className="full-width"
|
||||
disabled={busy}
|
||||
/>
|
||||
) : (
|
||||
<Dropdown
|
||||
value={normalizeChainId(item?.id)}
|
||||
options={chainOptions}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => changeEncodeItem('pre', rowIndex, 'chain', event.value)}
|
||||
className="full-width"
|
||||
disabled={busy}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => removeEncodeItem('pre', rowIndex)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="actions-row">
|
||||
{scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? (
|
||||
<Button
|
||||
label="Skript hinzufügen"
|
||||
icon="pi pi-code"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('pre', 'script')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
{chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? (
|
||||
<Button
|
||||
label="Kette hinzufügen"
|
||||
icon="pi pi-link"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('pre', 'chain')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="actions-row">
|
||||
<div className="post-script-box">
|
||||
<h4>Post-Rip Ausführungen (optional)</h4>
|
||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||
) : null}
|
||||
{postRipItems.length === 0 ? (
|
||||
<small>Keine Post-Rip Ausführungen ausgewählt.</small>
|
||||
) : null}
|
||||
{postRipItems.map((item, rowIndex) => {
|
||||
const isScript = item?.type === 'script';
|
||||
const usedScriptIds = new Set(
|
||||
postRipItems
|
||||
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
|
||||
.map((entry) => normalizeScriptId(entry?.id))
|
||||
.filter((id) => id !== null)
|
||||
.map((id) => String(id))
|
||||
);
|
||||
const usedChainIds = new Set(
|
||||
postRipItems
|
||||
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
|
||||
.map((entry) => normalizeChainId(entry?.id))
|
||||
.filter((id) => id !== null)
|
||||
.map((id) => String(id))
|
||||
);
|
||||
const scriptOptions = scriptCatalog.map((entry) => ({
|
||||
label: entry?.name || `Skript #${entry?.id}`,
|
||||
value: normalizeScriptId(entry?.id),
|
||||
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
|
||||
})).filter((entry) => entry.value !== null);
|
||||
const chainOptions = chainCatalog.map((entry) => ({
|
||||
label: entry?.name || `Kette #${entry?.id}`,
|
||||
value: normalizeChainId(entry?.id),
|
||||
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
|
||||
})).filter((entry) => entry.value !== null);
|
||||
return (
|
||||
<div key={`ab-post-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
|
||||
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
|
||||
<div className="cd-encode-item-order">
|
||||
<Button
|
||||
icon="pi pi-angle-up"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
onClick={() => moveEncodeItem('post', rowIndex, 'up')}
|
||||
disabled={busy || rowIndex <= 0}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-angle-down"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
onClick={() => moveEncodeItem('post', rowIndex, 'down')}
|
||||
disabled={busy || rowIndex >= postRipItems.length - 1}
|
||||
/>
|
||||
</div>
|
||||
{isScript ? (
|
||||
<Dropdown
|
||||
value={normalizeScriptId(item?.id)}
|
||||
options={scriptOptions}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => changeEncodeItem('post', rowIndex, 'script', event.value)}
|
||||
className="full-width"
|
||||
disabled={busy}
|
||||
/>
|
||||
) : (
|
||||
<Dropdown
|
||||
value={normalizeChainId(item?.id)}
|
||||
options={chainOptions}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => changeEncodeItem('post', rowIndex, 'chain', event.value)}
|
||||
className="full-width"
|
||||
disabled={busy}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => removeEncodeItem('post', rowIndex)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="actions-row">
|
||||
{scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? (
|
||||
<Button
|
||||
label="Skript hinzufügen"
|
||||
icon="pi pi-code"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('post', 'script')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
{chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? (
|
||||
<Button
|
||||
label="Kette hinzufügen"
|
||||
icon="pi pi-link"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('post', 'chain')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions-row" style={{ marginTop: '1rem' }}>
|
||||
{canStart ? (
|
||||
<Button
|
||||
label={state === 'READY_TO_START' ? 'Encoding starten' : 'Mit diesen Einstellungen starten'}
|
||||
label={(state === 'READY_TO_START' || state === 'READY_TO_ENCODE')
|
||||
? 'Encode starten'
|
||||
: 'Mit diesen Einstellungen starten'}
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={() => onStart?.({
|
||||
@@ -347,7 +815,23 @@ export default function AudiobookConfigPanel({
|
||||
endSeconds: chapter.endSeconds,
|
||||
startMs: chapter.startMs,
|
||||
endMs: chapter.endMs
|
||||
}))
|
||||
})),
|
||||
selectedPreEncodeScriptIds: normalizeIdList(
|
||||
preRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
|
||||
'script'
|
||||
),
|
||||
selectedPostEncodeScriptIds: normalizeIdList(
|
||||
postRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
|
||||
'script'
|
||||
),
|
||||
selectedPreEncodeChainIds: normalizeIdList(
|
||||
preRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
|
||||
'chain'
|
||||
),
|
||||
selectedPostEncodeChainIds: normalizeIdList(
|
||||
postRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
|
||||
'chain'
|
||||
)
|
||||
})}
|
||||
loading={busy}
|
||||
disabled={!jobId}
|
||||
@@ -365,28 +849,15 @@ export default function AudiobookConfigPanel({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isRunning ? (
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
icon="pi pi-stop"
|
||||
severity="danger"
|
||||
onClick={() => onCancel?.()}
|
||||
loading={busy}
|
||||
disabled={!jobId}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => onCancel?.()}
|
||||
loading={busy}
|
||||
disabled={!jobId}
|
||||
/>
|
||||
|
||||
{(state === 'ERROR' || state === 'CANCELLED') ? (
|
||||
<Button
|
||||
label="Retry-Job anlegen"
|
||||
icon="pi pi-refresh"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => onRetry?.()}
|
||||
loading={busy}
|
||||
disabled={!jobId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { api } from '../api/client';
|
||||
|
||||
function formatBytes(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
return '';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let index = 0;
|
||||
let current = n;
|
||||
while (current >= 1024 && index < units.length - 1) {
|
||||
current /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${current.toFixed(index <= 1 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
return parsed.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function getNodeByPath(root, targetPath) {
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
if ((root.path || '') === (targetPath || '')) {
|
||||
return root;
|
||||
}
|
||||
for (const child of (root.children || [])) {
|
||||
if (child.type !== 'folder') {
|
||||
continue;
|
||||
}
|
||||
const found = getNodeByPath(child, targetPath);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function listChildren(node) {
|
||||
if (!node || !Array.isArray(node.children)) {
|
||||
return [];
|
||||
}
|
||||
return node.children;
|
||||
}
|
||||
|
||||
function buildBreadcrumb(pathValue) {
|
||||
if (!pathValue) {
|
||||
return [];
|
||||
}
|
||||
const parts = String(pathValue).split('/').filter(Boolean);
|
||||
return parts.map((part, index) => ({
|
||||
name: part,
|
||||
path: parts.slice(0, index + 1).join('/')
|
||||
}));
|
||||
}
|
||||
|
||||
function filterFolderTree(node, query) {
|
||||
if (!node || node.type !== 'folder') {
|
||||
return null;
|
||||
}
|
||||
if (!query || !query.trim()) {
|
||||
return node;
|
||||
}
|
||||
const normalized = query.toLowerCase();
|
||||
const children = (node.children || [])
|
||||
.filter((child) => child.type === 'folder')
|
||||
.map((child) => filterFolderTree(child, query))
|
||||
.filter(Boolean);
|
||||
const nameMatches = String(node.name || '').toLowerCase().includes(normalized);
|
||||
if (nameMatches || children.length > 0) {
|
||||
return { ...node, children };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function defaultExpandedSet(tree) {
|
||||
const next = new Set(['']);
|
||||
const firstLevel = Array.isArray(tree?.children) ? tree.children : [];
|
||||
for (const entry of firstLevel) {
|
||||
if (entry?.type === 'folder' && entry?.path) {
|
||||
next.add(entry.path);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function AudiobookOutputExplorer({ refreshToken = 0 }) {
|
||||
const [tree, setTree] = useState(null);
|
||||
const [outputDir, setOutputDir] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
|
||||
const [sidebarQuery, setSidebarQuery] = useState('');
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await api.getAudiobookOutputTree();
|
||||
const nextTree = response?.tree || null;
|
||||
setTree(nextTree);
|
||||
setOutputDir(response?.outputDir || null);
|
||||
setExpandedFolders(defaultExpandedSet(nextTree));
|
||||
setCurrentPath('');
|
||||
} catch (error) {
|
||||
setTree(null);
|
||||
setErrorMessage(error?.message || 'Explorer konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTree();
|
||||
}, [loadTree, refreshToken]);
|
||||
|
||||
const navigateTo = (pathValue) => {
|
||||
const normalized = String(pathValue || '').trim();
|
||||
const node = getNodeByPath(tree, normalized);
|
||||
if (node && node.type === 'folder') {
|
||||
setCurrentPath(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFolder = (pathValue) => {
|
||||
const normalized = String(pathValue || '').trim();
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(normalized)) {
|
||||
next.delete(normalized);
|
||||
} else {
|
||||
next.add(normalized);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderTreeNode = (node, depth = 0) => {
|
||||
if (!node || node.type !== 'folder') {
|
||||
return null;
|
||||
}
|
||||
const key = node.path || '';
|
||||
const isExpanded = expandedFolders.has(key);
|
||||
const isCurrent = currentPath === key;
|
||||
const children = Array.isArray(node.children) ? node.children.filter((entry) => entry.type === 'folder') : [];
|
||||
|
||||
return (
|
||||
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`tree-row folder${isCurrent ? ' active' : ''}`}
|
||||
onClick={() => navigateTo(key)}
|
||||
>
|
||||
{children.length > 0 ? (
|
||||
<span
|
||||
className="tree-caret"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleFolder(key);
|
||||
}}
|
||||
>
|
||||
<i className={`pi ${isExpanded ? 'pi-chevron-down' : 'pi-chevron-right'}`} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="tree-caret disabled" aria-hidden="true" />
|
||||
)}
|
||||
<span className="tree-icon folder">
|
||||
<i className="pi pi-folder" />
|
||||
</span>
|
||||
<span className="tree-label">{node.name || 'audiobooks'}</span>
|
||||
</button>
|
||||
{isExpanded && children.map((child) => renderTreeNode(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const filteredTree = useMemo(() => filterFolderTree(tree, sidebarQuery), [tree, sidebarQuery]);
|
||||
const currentNode = getNodeByPath(tree, currentPath);
|
||||
const currentChildren = listChildren(currentNode);
|
||||
const breadcrumb = buildBreadcrumb(currentPath);
|
||||
|
||||
if (loading && !tree) {
|
||||
return (
|
||||
<div className="explorer-loading">
|
||||
<ProgressSpinner style={{ width: '2.2rem', height: '2.2rem' }} strokeWidth="5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tree) {
|
||||
return (
|
||||
<div className="explorer-empty">
|
||||
{errorMessage ? (
|
||||
<small className="error-text">{errorMessage}</small>
|
||||
) : (
|
||||
<small>Kein Audiobook-Output vorhanden. Ausgabepfad: <code>{outputDir || 'nicht konfiguriert'}</code></small>
|
||||
)}
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
<Button icon="pi pi-refresh" label="Neu laden" outlined size="small" onClick={() => void loadTree()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explorer audiobook-output-explorer">
|
||||
<div className="explorer-sidebar">
|
||||
<div className="explorer-toolbar sidebar-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ordner filtern..."
|
||||
value={sidebarQuery}
|
||||
onChange={(event) => setSidebarQuery(event.target.value)}
|
||||
className="sidebar-search"
|
||||
/>
|
||||
</div>
|
||||
<div className="sidebar-tree">
|
||||
{filteredTree ? renderTreeNode(filteredTree, 0) : <small>Keine Ordner gefunden.</small>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="explorer-main">
|
||||
<div className="explorer-toolbar">
|
||||
<Button
|
||||
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
|
||||
label="Aktualisieren"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => void loadTree()}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="explorer-path">
|
||||
<Button text label="/" onClick={() => navigateTo('')} />
|
||||
{breadcrumb.map((crumb) => (
|
||||
<Button key={crumb.path} text label={crumb.name} onClick={() => navigateTo(crumb.path)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="explorer-list">
|
||||
<div className="explorer-row header audiobook-output-row">
|
||||
<span>Name</span>
|
||||
<span>Größe</span>
|
||||
<span>Geändert</span>
|
||||
</div>
|
||||
|
||||
{currentChildren.length === 0 ? (
|
||||
<div className="explorer-row audiobook-output-row">
|
||||
<span>Keine Einträge in diesem Ordner.</span>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
) : (
|
||||
currentChildren.map((entry) => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.path || entry.name}
|
||||
className="explorer-row audiobook-output-row"
|
||||
onClick={() => {
|
||||
if (entry.type === 'folder') {
|
||||
navigateTo(entry.path);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: entry.type === 'folder' ? 'pointer' : 'default' }}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<i className={`pi ${entry.type === 'folder' ? 'pi-folder' : 'pi-file'}`} />
|
||||
{entry.name}
|
||||
</span>
|
||||
<span>{entry.type === 'file' ? (formatBytes(entry.size) || '-') : '-'}</span>
|
||||
<span>{formatDateTime(entry.mtime)}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="explorer-footer">
|
||||
<small>
|
||||
Root: <code>{outputDir || '-'}</code>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { FileUpload } from 'primereact/fileupload';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Toast } from 'primereact/toast';
|
||||
|
||||
@@ -32,15 +31,30 @@ function normalizeJobId(value) {
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function extractUploadJobIdFromResponse(response) {
|
||||
const payload = response && typeof response === 'object' ? response : {};
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
|
||||
return (
|
||||
normalizeJobId(result?.jobId)
|
||||
|| normalizeJobId(payload?.jobId)
|
||||
|| normalizeJobId(result?.id)
|
||||
|| normalizeJobId(payload?.id)
|
||||
|| normalizeJobId(result?.job?.id)
|
||||
|| normalizeJobId(payload?.job?.id)
|
||||
|| null
|
||||
);
|
||||
}
|
||||
|
||||
export default function AudiobookUploadPanel({
|
||||
audiobookUpload,
|
||||
onAudiobookUpload,
|
||||
onCancelUpload = null,
|
||||
onUploaded = null
|
||||
}) {
|
||||
const toastRef = useRef(null);
|
||||
const fileUploadRef = useRef(null);
|
||||
const fallbackFileInputRef = useRef(null);
|
||||
const [uploadFile, setUploadFile] = useState(null);
|
||||
const [statusVisible, setStatusVisible] = useState(false);
|
||||
|
||||
const phase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
|
||||
const uploadBusy = phase === 'uploading' || phase === 'processing';
|
||||
@@ -49,40 +63,15 @@ export default function AudiobookUploadPanel({
|
||||
: 0;
|
||||
const loadedBytes = Number(audiobookUpload?.loadedBytes || 0);
|
||||
const totalBytes = Number(audiobookUpload?.totalBytes || 0);
|
||||
const fileName = String(audiobookUpload?.fileName || '').trim()
|
||||
|| String(uploadFile?.name || '').trim()
|
||||
|| null;
|
||||
const statusTone = phase === 'error'
|
||||
? 'danger'
|
||||
: phase === 'completed'
|
||||
? 'success'
|
||||
: phase === 'processing'
|
||||
? 'info'
|
||||
: phase === 'uploading'
|
||||
? 'warning'
|
||||
: 'secondary';
|
||||
const statusLabel = phase === 'uploading'
|
||||
? 'Upload laeuft'
|
||||
: phase === 'processing'
|
||||
? 'Server verarbeitet'
|
||||
: phase === 'completed'
|
||||
? 'Bereit'
|
||||
: phase === 'error'
|
||||
? 'Fehler'
|
||||
: 'Inaktiv';
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'idle') {
|
||||
setStatusVisible(false);
|
||||
return;
|
||||
}
|
||||
setStatusVisible(true);
|
||||
if (phase === 'completed') {
|
||||
const timer = setTimeout(() => setStatusVisible(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [phase]);
|
||||
const progressLabel = phase === 'processing'
|
||||
? '100% | Upload fertig, Job wird vorbereitet ...'
|
||||
: totalBytes > 0
|
||||
? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
|
||||
: `${Math.trunc(progress)}%`;
|
||||
const hasHeaderStatus = phase !== 'idle' || Boolean(uploadFile);
|
||||
const canStartUpload = Boolean(uploadFile) && !uploadBusy;
|
||||
const canCancelUpload = uploadBusy && typeof onCancelUpload === 'function';
|
||||
const canClearSelection = Boolean(uploadFile) && !uploadBusy;
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadFile) {
|
||||
@@ -96,7 +85,7 @@ export default function AudiobookUploadPanel({
|
||||
}
|
||||
try {
|
||||
const response = await onAudiobookUpload?.(uploadFile, { startImmediately: false });
|
||||
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
||||
const uploadedJobId = extractUploadJobIdFromResponse(response);
|
||||
if (uploadedJobId) {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
@@ -116,6 +105,15 @@ export default function AudiobookUploadPanel({
|
||||
fileUploadRef.current?.clear?.();
|
||||
onUploaded?.(uploadedJobId, response);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
toastRef.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Upload abgebrochen',
|
||||
detail: 'Der Audiobook-Upload wurde gestoppt.',
|
||||
life: 2800
|
||||
});
|
||||
return;
|
||||
}
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Upload fehlgeschlagen',
|
||||
@@ -125,6 +123,72 @@ export default function AudiobookUploadPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelected = (file) => {
|
||||
if (!file) {
|
||||
setUploadFile(null);
|
||||
return;
|
||||
}
|
||||
setUploadFile(file);
|
||||
};
|
||||
|
||||
const handleFileCleared = () => {
|
||||
setUploadFile(null);
|
||||
};
|
||||
|
||||
const handleChooseFile = () => {
|
||||
if (uploadBusy) {
|
||||
return;
|
||||
}
|
||||
fallbackFileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileItemAction = (onRemove = null) => {
|
||||
if (canCancelUpload) {
|
||||
void onCancelUpload();
|
||||
onRemove?.();
|
||||
fileUploadRef.current?.clear?.();
|
||||
handleFileCleared();
|
||||
return;
|
||||
}
|
||||
onRemove?.();
|
||||
fileUploadRef.current?.clear?.();
|
||||
handleFileCleared();
|
||||
};
|
||||
|
||||
const renderFileRow = (file, onRemove = null) => (
|
||||
<div className="aax-file-item">
|
||||
<i className="pi pi-headphones aax-file-icon" />
|
||||
<div className="aax-file-info">
|
||||
<span className="aax-file-name" title={file?.name}>{file?.name || 'upload.aax'}</span>
|
||||
<small>{formatBytes(Number(file?.size || 0))}</small>
|
||||
{hasHeaderStatus ? (
|
||||
<div className="aax-file-status">
|
||||
<>
|
||||
<small className="audiobook-upload-inline-text">{progressLabel}</small>
|
||||
<ProgressBar value={progress} showValue={false} />
|
||||
{audiobookUpload?.statusText ? (
|
||||
<small className="audiobook-upload-inline-text">{audiobookUpload.statusText}</small>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
rounded
|
||||
severity="danger"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
handleFileItemAction(onRemove);
|
||||
}}
|
||||
disabled={!canCancelUpload && !canClearSelection}
|
||||
tooltip={canCancelUpload ? 'Upload abbrechen' : 'Auswahl entfernen'}
|
||||
tooltipOptions={{ position: 'left' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="audiobook-upload-panel">
|
||||
<Toast ref={toastRef} position="top-right" />
|
||||
@@ -135,66 +199,91 @@ export default function AudiobookUploadPanel({
|
||||
customUpload
|
||||
uploadHandler={() => void handleUpload()}
|
||||
disabled={uploadBusy}
|
||||
onSelect={(event) => setUploadFile(event.files[0] || null)}
|
||||
onClear={() => setUploadFile(null)}
|
||||
onRemove={() => setUploadFile(null)}
|
||||
onSelect={(event) => {
|
||||
handleFileSelected(event.files[0] || null);
|
||||
}}
|
||||
onClear={() => {
|
||||
handleFileCleared();
|
||||
}}
|
||||
onRemove={() => {
|
||||
handleFileCleared();
|
||||
}}
|
||||
chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }}
|
||||
uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }}
|
||||
cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }}
|
||||
itemTemplate={(file, options) => (
|
||||
<div className="aax-file-item">
|
||||
<i className="pi pi-headphones aax-file-icon" />
|
||||
<div className="aax-file-info">
|
||||
<span className="aax-file-name" title={file.name}>{file.name}</span>
|
||||
<small>{options.formatSize}</small>
|
||||
headerTemplate={(options) => (
|
||||
<div className={options.className}>
|
||||
<div className="audiobook-upload-header">
|
||||
<div className="audiobook-upload-header-actions">
|
||||
<Button
|
||||
icon="pi pi-images"
|
||||
rounded
|
||||
outlined
|
||||
aria-label="Datei auswählen"
|
||||
onClick={() => {
|
||||
void handleChooseFile();
|
||||
}}
|
||||
disabled={uploadBusy}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-cloud-upload"
|
||||
rounded
|
||||
outlined
|
||||
severity="success"
|
||||
aria-label="Upload starten"
|
||||
onClick={() => {
|
||||
void handleUpload();
|
||||
}}
|
||||
disabled={!canStartUpload}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
rounded
|
||||
outlined
|
||||
severity="secondary"
|
||||
aria-label="Auswahl entfernen"
|
||||
onClick={() => {
|
||||
fileUploadRef.current?.clear?.();
|
||||
handleFileCleared();
|
||||
}}
|
||||
disabled={!canClearSelection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
rounded
|
||||
severity="danger"
|
||||
size="small"
|
||||
onClick={options.onRemove}
|
||||
disabled={uploadBusy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
itemTemplate={(file, options) => (
|
||||
renderFileRow(file, () => {
|
||||
options.onRemove?.();
|
||||
})
|
||||
)}
|
||||
emptyTemplate={() => (
|
||||
<div className="aax-drop-zone">
|
||||
<i className="pi pi-headphones aax-drop-icon" />
|
||||
<p>AAX-Datei hier ablegen</p>
|
||||
<small>oder oben "Auswaehlen" klicken</small>
|
||||
</div>
|
||||
uploadFile
|
||||
? renderFileRow(uploadFile, () => {
|
||||
// handled by handleFileItemAction
|
||||
})
|
||||
: (
|
||||
<div className="aax-drop-zone">
|
||||
<i className="pi pi-headphones aax-drop-icon" />
|
||||
<p>AAX-Datei hier ablegen</p>
|
||||
<small>oder oben "Auswaehlen" klicken</small>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
/>
|
||||
|
||||
{statusVisible ? (
|
||||
<div className={`audiobook-upload-status tone-${statusTone}`}>
|
||||
<div className="audiobook-upload-status-head">
|
||||
<strong>{statusLabel}</strong>
|
||||
<Tag value={statusLabel} severity={statusTone} />
|
||||
</div>
|
||||
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
|
||||
{fileName ? (
|
||||
<small className="audiobook-upload-file" title={fileName}>
|
||||
Datei: {fileName}
|
||||
</small>
|
||||
) : null}
|
||||
<div
|
||||
className="ripper-job-row-progress audiobook-upload-progress"
|
||||
aria-label={`Audiobook Upload ${Math.trunc(progress)} Prozent`}
|
||||
>
|
||||
<ProgressBar value={progress} showValue={false} />
|
||||
<small>
|
||||
{phase === 'processing'
|
||||
? '100% | Upload fertig, Job wird vorbereitet ...'
|
||||
: totalBytes > 0
|
||||
? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
|
||||
: `${Math.trunc(progress)}%`}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
ref={fallbackFileInputRef}
|
||||
type="file"
|
||||
accept=".aax,audio/vnd.audible.aax"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
const file = event.target?.files?.[0] || null;
|
||||
handleFileSelected(file);
|
||||
if (event.target) {
|
||||
event.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,7 +142,10 @@ export default function CdMetadataDialog({
|
||||
const searchRunId = searchRunRef.current + 1;
|
||||
searchRunRef.current = searchRunId;
|
||||
try {
|
||||
const searchResults = await onSearch(trimmedQuery);
|
||||
const expectedTrackCount = Array.isArray(tocTracks) ? tocTracks.length : 0;
|
||||
const searchResults = await onSearch(trimmedQuery, {
|
||||
trackCount: expectedTrackCount > 0 ? expectedTrackCount : null
|
||||
});
|
||||
const normalizedResults = Array.isArray(searchResults) ? searchResults : [];
|
||||
await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl)));
|
||||
if (searchRunRef.current !== searchRunId) {
|
||||
@@ -264,6 +267,7 @@ export default function CdMetadataDialog({
|
||||
emptyMessage="Keine Treffer"
|
||||
>
|
||||
<Column header="Album" body={mbTitleBody} />
|
||||
<Column field="trackCount" header="Tracks" style={{ width: '6rem' }} />
|
||||
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
|
||||
<Column field="country" header="Land" style={{ width: '6rem' }} />
|
||||
</DataTable>
|
||||
|
||||
@@ -239,6 +239,7 @@ export default function CdRipConfigPanel({
|
||||
const selectedMeta = context.selectedMetadata || {};
|
||||
const state = String(pipeline?.state || '').trim().toUpperCase();
|
||||
const jobId = normalizePosition(context?.jobId);
|
||||
const isPostRipWorkflow = context?.skipRip === true;
|
||||
|
||||
const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING';
|
||||
const isFinished = state === 'FINISHED';
|
||||
@@ -662,6 +663,7 @@ export default function CdRipConfigPanel({
|
||||
|| ''
|
||||
) || null;
|
||||
const devicePath = normalizeTrackText(context?.devicePath) || '-';
|
||||
const rawPath = normalizeTrackText(context?.rawPath) || '-';
|
||||
const outputPath = normalizeTrackText(context?.outputPath) || '-';
|
||||
const formatValue = String(cdRipConfig?.format || '').trim().toLowerCase();
|
||||
const formatLabel = (Array.isArray(CD_FORMATS)
|
||||
@@ -795,7 +797,9 @@ export default function CdRipConfigPanel({
|
||||
displayValueTemplate={(value) => formatProgressLabel(value)}
|
||||
/>
|
||||
<small>
|
||||
{`Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`}
|
||||
{isPostRipWorkflow
|
||||
? `Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`
|
||||
: `Fortschritt: ${formatProgressLabel(wholeProgress)}`}
|
||||
</small>
|
||||
<small>{eta ? `ETA ${eta}` : 'ETA unbekannt'}</small>
|
||||
</div>
|
||||
@@ -892,25 +896,30 @@ export default function CdRipConfigPanel({
|
||||
<div><strong>Jahr:</strong> {albumYear}</div>
|
||||
<div><strong>MusicBrainz:</strong> {musicBrainzId}</div>
|
||||
<div><strong>Status:</strong> {stateLabel}</div>
|
||||
<div><strong>Format:</strong> {formatLabel}</div>
|
||||
<div><strong>Rip fertig:</strong> {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
|
||||
<div><strong>Encode fertig:</strong> {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
|
||||
<div><strong>Aktueller Track:</strong> {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}</div>
|
||||
<div><strong>Pre-Skripte:</strong> {preScriptNames.length > 0 ? preScriptNames.join(' | ') : '-'}</div>
|
||||
<div><strong>Pre-Ketten:</strong> {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}</div>
|
||||
<div><strong>Post-Skripte:</strong> {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}</div>
|
||||
<div><strong>Post-Ketten:</strong> {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}</div>
|
||||
<div><strong>Auswahl:</strong> {selectedTrackNumbers || '-'}</div>
|
||||
<div><strong>Gesamtdauer:</strong> {formatTotalDuration(selectedTrackDurationSec)}</div>
|
||||
<div><strong>Laufwerk:</strong> {devicePath}</div>
|
||||
<div><strong>Output-Pfad:</strong> {outputPath}</div>
|
||||
{!isRipping ? <div><strong>Format:</strong> {formatLabel}</div> : null}
|
||||
{isPostRipWorkflow ? (
|
||||
<>
|
||||
<div><strong>Rip fertig:</strong> {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
|
||||
<div><strong>Encode fertig:</strong> {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
|
||||
<div><strong>Aktueller Track:</strong> {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}</div>
|
||||
<div><strong>Pre-Skripte:</strong> {preScriptNames.length > 0 ? preScriptNames.join(' | ') : '-'}</div>
|
||||
<div><strong>Pre-Ketten:</strong> {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}</div>
|
||||
<div><strong>Post-Skripte:</strong> {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}</div>
|
||||
<div><strong>Post-Ketten:</strong> {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}</div>
|
||||
<div><strong>Auswahl:</strong> {selectedTrackNumbers || '-'}</div>
|
||||
<div><strong>Gesamtdauer:</strong> {formatTotalDuration(selectedTrackDurationSec)}</div>
|
||||
</>
|
||||
) : null}
|
||||
{!isRipping ? <div><strong>Laufwerk:</strong> {devicePath}</div> : null}
|
||||
<div><strong>RAW-Pfad:</strong> {rawPath}</div>
|
||||
{!isRipping ? <div><strong>Output-Pfad:</strong> {outputPath}</div> : null}
|
||||
{lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastStateLabel}</div> : null}
|
||||
{jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
|
||||
{!isRipping && jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayTrackRows.length > 0 ? (
|
||||
{isPostRipWorkflow && displayTrackRows.length > 0 ? (
|
||||
<div className="cd-track-selection">
|
||||
<strong>Zu rippende Tracks ({displayTrackRows.length})</strong>
|
||||
<div className="cd-track-list">
|
||||
@@ -951,6 +960,58 @@ export default function CdRipConfigPanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPostRipWorkflow) {
|
||||
return (
|
||||
<div className="cd-rip-config-panel">
|
||||
<div className="status-row">
|
||||
<Tag value={stateLabel} severity={stateSeverity} />
|
||||
<span>{statusText || 'Bereit'}</span>
|
||||
</div>
|
||||
<div className="cd-meta-summary">
|
||||
<strong>CD-Details</strong>
|
||||
<div className="device-meta" style={{ marginTop: '0.55rem' }}>
|
||||
<div><strong>Album:</strong> {albumTitle}</div>
|
||||
<div><strong>Interpret:</strong> {albumArtist}</div>
|
||||
<div><strong>Jahr:</strong> {albumYear}</div>
|
||||
<div><strong>MusicBrainz:</strong> {musicBrainzId}</div>
|
||||
<div><strong>Status:</strong> {stateLabel}</div>
|
||||
<div><strong>Laufwerk:</strong> {devicePath}</div>
|
||||
<div><strong>RAW-Pfad:</strong> {rawPath}</div>
|
||||
<div><strong>Output-Pfad:</strong> {outputPath}</div>
|
||||
{lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastStateLabel}</div> : null}
|
||||
{jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="Rip starten"
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={() => handleStart({ forceSkipRip: false })}
|
||||
loading={busy}
|
||||
/>
|
||||
<Button
|
||||
label="Metadaten ändern"
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
onClick={() => onOpenMetadata && onOpenMetadata()}
|
||||
loading={busy}
|
||||
/>
|
||||
{jobId ? (
|
||||
<Button
|
||||
label="Job löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => onDeleteJob && onDeleteJob(jobId)}
|
||||
loading={busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cd-rip-config-panel">
|
||||
<h4 style={{ marginTop: 0, marginBottom: '0.75rem' }}>CD-Rip Konfiguration</h4>
|
||||
@@ -1082,15 +1143,16 @@ export default function CdRipConfigPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="post-script-box">
|
||||
<h4>Pre-Rip Ausführungen (optional)</h4>
|
||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||
) : null}
|
||||
{preRipItems.length === 0 ? (
|
||||
<small>Keine Pre-Rip Ausführungen ausgewählt.</small>
|
||||
) : null}
|
||||
{preRipItems.map((item, rowIndex) => {
|
||||
<div className="encode-automation-grid">
|
||||
<div className="post-script-box">
|
||||
<h4>Pre-Rip Ausführungen (optional)</h4>
|
||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||
) : null}
|
||||
{preRipItems.length === 0 ? (
|
||||
<small>Keine Pre-Rip Ausführungen ausgewählt.</small>
|
||||
) : null}
|
||||
{preRipItems.map((item, rowIndex) => {
|
||||
const isScript = item?.type === 'script';
|
||||
const usedScriptIds = new Set(
|
||||
preRipItems
|
||||
@@ -1116,8 +1178,8 @@ export default function CdRipConfigPanel({
|
||||
value: normalizeChainId(entry?.id),
|
||||
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
|
||||
})).filter((entry) => entry.value !== null);
|
||||
return (
|
||||
<div key={`cd-pre-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
|
||||
return (
|
||||
<div key={`cd-pre-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
|
||||
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
|
||||
<div className="cd-encode-item-order">
|
||||
<Button
|
||||
@@ -1167,43 +1229,43 @@ export default function CdRipConfigPanel({
|
||||
onClick={() => removeEncodeItem('pre', rowIndex)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="actions-row">
|
||||
{scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? (
|
||||
<Button
|
||||
label="Skript hinzufügen"
|
||||
icon="pi pi-code"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('pre', 'script')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
{chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? (
|
||||
<Button
|
||||
label="Kette hinzufügen"
|
||||
icon="pi pi-link"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('pre', 'chain')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="actions-row">
|
||||
{scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? (
|
||||
<Button
|
||||
label="Skript hinzufügen"
|
||||
icon="pi pi-code"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('pre', 'script')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
{chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? (
|
||||
<Button
|
||||
label="Kette hinzufügen"
|
||||
icon="pi pi-link"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('pre', 'chain')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.</small>
|
||||
</div>
|
||||
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.</small>
|
||||
</div>
|
||||
|
||||
<div className="post-script-box">
|
||||
<h4>Post-Rip Ausführungen (optional)</h4>
|
||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||
) : null}
|
||||
{postRipItems.length === 0 ? (
|
||||
<small>Keine Post-Rip Ausführungen ausgewählt.</small>
|
||||
) : null}
|
||||
{postRipItems.map((item, rowIndex) => {
|
||||
<div className="post-script-box">
|
||||
<h4>Post-Rip Ausführungen (optional)</h4>
|
||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||
) : null}
|
||||
{postRipItems.length === 0 ? (
|
||||
<small>Keine Post-Rip Ausführungen ausgewählt.</small>
|
||||
) : null}
|
||||
{postRipItems.map((item, rowIndex) => {
|
||||
const isScript = item?.type === 'script';
|
||||
const usedScriptIds = new Set(
|
||||
postRipItems
|
||||
@@ -1229,8 +1291,8 @@ export default function CdRipConfigPanel({
|
||||
value: normalizeChainId(entry?.id),
|
||||
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
|
||||
})).filter((entry) => entry.value !== null);
|
||||
return (
|
||||
<div key={`cd-post-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
|
||||
return (
|
||||
<div key={`cd-post-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
|
||||
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
|
||||
<div className="cd-encode-item-order">
|
||||
<Button
|
||||
@@ -1280,32 +1342,33 @@ export default function CdRipConfigPanel({
|
||||
onClick={() => removeEncodeItem('post', rowIndex)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="actions-row">
|
||||
{scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? (
|
||||
<Button
|
||||
label="Skript hinzufügen"
|
||||
icon="pi pi-code"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('post', 'script')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
{chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? (
|
||||
<Button
|
||||
label="Kette hinzufügen"
|
||||
icon="pi pi-link"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('post', 'chain')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="actions-row">
|
||||
{scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? (
|
||||
<Button
|
||||
label="Skript hinzufügen"
|
||||
icon="pi pi-code"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('post', 'script')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
{chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? (
|
||||
<Button
|
||||
label="Kette hinzufügen"
|
||||
icon="pi pi-link"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => addEncodeItem('post', 'chain')}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
|
||||
</div>
|
||||
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -154,6 +154,59 @@ function deriveAudioTracks(plan) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizePositiveInt(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function buildVideoMetadataPayload(sourceMetadata, fallbackTitle = '') {
|
||||
const source = sourceMetadata && typeof sourceMetadata === 'object' ? sourceMetadata : {};
|
||||
const normalized = {
|
||||
title: String(source?.title || fallbackTitle || '').trim() || null,
|
||||
year: Number.isFinite(Number(source?.year)) ? Math.trunc(Number(source.year)) : null,
|
||||
imdbId: String(source?.imdbId || '').trim() || null,
|
||||
poster: String(source?.poster || '').trim() || null
|
||||
};
|
||||
|
||||
const metadataProvider = String(source?.metadataProvider || '').trim() || null;
|
||||
const workflowKind = String(source?.workflowKind || '').trim() || null;
|
||||
const providerId = String(source?.providerId || '').trim() || null;
|
||||
const metadataKind = String(source?.metadataKind || '').trim() || null;
|
||||
const seasonName = String(source?.seasonName || '').trim() || null;
|
||||
const imdbRating = String(source?.imdbRating || '').trim() || null;
|
||||
const tmdbId = normalizePositiveInt(source?.tmdbId);
|
||||
const seasonNumber = normalizePositiveInt(source?.seasonNumber);
|
||||
const discNumber = normalizePositiveInt(source?.discNumber);
|
||||
const episodeCount = Number(source?.episodeCount || 0);
|
||||
const voteAverageRaw = Number(source?.voteAverage || 0);
|
||||
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
|
||||
? Number(voteAverageRaw.toFixed(1))
|
||||
: null;
|
||||
const episodes = Array.isArray(source?.episodes) ? source.episodes : [];
|
||||
const tmdbDetails = source?.tmdbDetails && typeof source.tmdbDetails === 'object' && !Array.isArray(source.tmdbDetails)
|
||||
? source.tmdbDetails
|
||||
: null;
|
||||
|
||||
if (metadataProvider) normalized.metadataProvider = metadataProvider;
|
||||
if (workflowKind) normalized.workflowKind = workflowKind;
|
||||
if (providerId) normalized.providerId = providerId;
|
||||
if (metadataKind) normalized.metadataKind = metadataKind;
|
||||
if (tmdbId) normalized.tmdbId = tmdbId;
|
||||
if (seasonNumber) normalized.seasonNumber = seasonNumber;
|
||||
if (seasonName) normalized.seasonName = seasonName;
|
||||
if (discNumber) normalized.discNumber = discNumber;
|
||||
if (Number.isFinite(episodeCount) && episodeCount > 0) normalized.episodeCount = Math.trunc(episodeCount);
|
||||
if (episodes.length > 0) normalized.episodes = episodes;
|
||||
if (voteAverage !== null) normalized.voteAverage = voteAverage;
|
||||
if (imdbRating) normalized.imdbRating = imdbRating;
|
||||
if (tmdbDetails) normalized.tmdbDetails = tmdbDetails;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ── Status helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function mediaTypeBadge(type) {
|
||||
@@ -260,10 +313,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
|
||||
|
||||
// ── Video metadata ──────────────────────────────────────────────────────────
|
||||
const [videoMetadata, setVideoMetadata] = useState(() => ({
|
||||
title: String(planMetadata?.title || job.title || job.detected_title || '').trim(),
|
||||
year: Number.isFinite(Number(planMetadata?.year)) ? Math.trunc(Number(planMetadata.year)) : null,
|
||||
imdbId: String(planMetadata?.imdbId || '').trim() || null,
|
||||
poster: String(planMetadata?.poster || '').trim() || null
|
||||
...buildVideoMetadataPayload(
|
||||
planMetadata,
|
||||
String(job.title || job.detected_title || '').trim()
|
||||
)
|
||||
}));
|
||||
|
||||
// ── Script / Chain catalog ──────────────────────────────────────────────────
|
||||
@@ -378,10 +431,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
|
||||
coverUrl: coverUrl || null
|
||||
}
|
||||
: {
|
||||
title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null,
|
||||
year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null,
|
||||
imdbId: String(videoMetadata?.imdbId || '').trim() || null,
|
||||
poster: String(videoMetadata?.poster || '').trim() || null
|
||||
...buildVideoMetadataPayload(
|
||||
videoMetadata,
|
||||
String(job.title || job.detected_title || '').trim()
|
||||
)
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -436,12 +489,13 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
|
||||
jobId: job.id,
|
||||
detectedTitle: videoMetadata?.title || job.title || job.detected_title || '',
|
||||
selectedMetadata: {
|
||||
...videoMetadata,
|
||||
title: videoMetadata?.title || job.title || job.detected_title || '',
|
||||
year: videoMetadata?.year || null,
|
||||
imdbId: videoMetadata?.imdbId || null,
|
||||
poster: videoMetadata?.poster || null
|
||||
},
|
||||
omdbCandidates: []
|
||||
metadataCandidates: []
|
||||
}), [job.id, job.title, job.detected_title, videoMetadata]);
|
||||
|
||||
const cdMetadataDialogContext = useMemo(() => ({
|
||||
@@ -454,31 +508,28 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
|
||||
}), [job.id, audioTracks, trackFields]);
|
||||
|
||||
// ── Dialog handlers ─────────────────────────────────────────────────────────
|
||||
const handleOmdbSearch = async (query) => {
|
||||
const handleMetadataSearch = async (query) => {
|
||||
try {
|
||||
const response = await api.searchOmdb(query);
|
||||
const response = await api.searchTmdbMovie(query);
|
||||
return Array.isArray(response?.results) ? response.results : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const handleOmdbSubmit = async (payload) => {
|
||||
const handleMetadataSubmit = async (payload) => {
|
||||
setSearchDialogBusy(true);
|
||||
try {
|
||||
setVideoMetadata({
|
||||
title: String(payload?.title || job.title || job.detected_title || '').trim(),
|
||||
year: Number.isFinite(Number(payload?.year)) ? Math.trunc(Number(payload.year)) : null,
|
||||
imdbId: String(payload?.imdbId || '').trim() || null,
|
||||
poster: String(payload?.poster || '').trim() || null
|
||||
});
|
||||
setVideoMetadata(buildVideoMetadataPayload(payload, String(job.title || job.detected_title || '').trim()));
|
||||
setMetadataDialogVisible(false);
|
||||
} finally {
|
||||
setSearchDialogBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMusicBrainzSearchDialog = async (query) => {
|
||||
const handleMusicBrainzSearchDialog = async (query, options = {}) => {
|
||||
try {
|
||||
const response = await api.searchMusicBrainz(query);
|
||||
const response = await api.searchMusicBrainz(query, {
|
||||
trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null
|
||||
});
|
||||
return Array.isArray(response?.results) ? response.results : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
@@ -642,10 +693,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
|
||||
coverUrl: coverUrl || null
|
||||
}
|
||||
: {
|
||||
title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null,
|
||||
year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null,
|
||||
imdbId: String(videoMetadata?.imdbId || '').trim() || null,
|
||||
poster: String(videoMetadata?.poster || '').trim() || null
|
||||
...buildVideoMetadataPayload(
|
||||
videoMetadata,
|
||||
String(job.title || job.detected_title || '').trim()
|
||||
)
|
||||
};
|
||||
|
||||
await api.startConverterJob(job.id, {
|
||||
@@ -880,8 +931,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
|
||||
)}
|
||||
|
||||
{/* Pre / Post Ausführungen */}
|
||||
{renderEncodeSection('pre', preItems)}
|
||||
{renderEncodeSection('post', postItems)}
|
||||
<div className="encode-automation-grid">
|
||||
{renderEncodeSection('pre', preItems)}
|
||||
{renderEncodeSection('post', postItems)}
|
||||
</div>
|
||||
|
||||
{/* Aktionen */}
|
||||
<div className="actions-row" style={{ marginTop: '1rem' }}>
|
||||
@@ -1025,8 +1078,8 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
|
||||
visible={metadataDialogVisible}
|
||||
context={metadataDialogContext}
|
||||
onHide={() => setMetadataDialogVisible(false)}
|
||||
onSubmit={handleOmdbSubmit}
|
||||
onSearch={handleOmdbSearch}
|
||||
onSubmit={handleMetadataSubmit}
|
||||
onSearch={handleMetadataSearch}
|
||||
busy={searchDialogBusy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Dropdown } from 'primereact/dropdown';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { confirmModal } from '../utils/confirmModal';
|
||||
|
||||
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
|
||||
@@ -61,7 +62,7 @@ const EMPTY_FORM = {
|
||||
|
||||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function CronJobsTab({ onWsMessage }) {
|
||||
export default function CronJobsTab() {
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const [jobs, setJobs] = useState([]);
|
||||
@@ -89,6 +90,7 @@ export default function CronJobsTab({ onWsMessage }) {
|
||||
|
||||
// Aktionen Busy-State per Job-ID
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
const wsReloadTimerRef = useRef(null);
|
||||
|
||||
// ── Daten laden ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -135,6 +137,16 @@ export default function CronJobsTab({ onWsMessage }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleLoadAll = (delayMs = 120) => {
|
||||
if (wsReloadTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
wsReloadTimerRef.current = setTimeout(() => {
|
||||
wsReloadTimerRef.current = null;
|
||||
void loadAll();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const activeCronRunByJobId = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const item of activeCronRuns) {
|
||||
@@ -146,11 +158,28 @@ export default function CronJobsTab({ onWsMessage }) {
|
||||
return map;
|
||||
}, [activeCronRuns]);
|
||||
|
||||
// WebSocket: Cronjob-Updates empfangen
|
||||
useEffect(() => {
|
||||
if (!onWsMessage) return;
|
||||
// onWsMessage ist eine Funktion, die wir anmelden
|
||||
}, [onWsMessage]);
|
||||
useEffect(() => () => {
|
||||
if (wsReloadTimerRef.current) {
|
||||
clearTimeout(wsReloadTimerRef.current);
|
||||
wsReloadTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
onMessage: (message) => {
|
||||
const type = String(message?.type || '').trim().toUpperCase();
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
if (type === 'RUNTIME_ACTIVITY_CHANGED') {
|
||||
setActiveCronRuns(normalizeActiveCronRuns(message?.payload));
|
||||
return;
|
||||
}
|
||||
if (type === 'CRON_JOBS_UPDATED' || type === 'CRON_JOB_UPDATED') {
|
||||
scheduleLoadAll(80);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cron-Ausdruck validieren (debounced) ─────────────────────────────────────
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ const GENERAL_TOOL_KEYS = new Set([
|
||||
'mkvmerge_command',
|
||||
'ffprobe_command',
|
||||
'handbrake_restart_delete_incomplete_output',
|
||||
'handbrake_pre_metadata_scan_enabled',
|
||||
'script_test_timeout_ms'
|
||||
]);
|
||||
|
||||
@@ -58,7 +57,8 @@ const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
|
||||
'makemkv_rip_mode',
|
||||
'makemkv_rip_mode_bluray',
|
||||
'makemkv_rip_mode_dvd',
|
||||
'makemkv_backup_mode'
|
||||
'makemkv_backup_mode',
|
||||
'handbrake_pre_metadata_scan_enabled'
|
||||
]);
|
||||
const EXPERT_ONLY_SETTING_KEYS = new Set([
|
||||
'pushover_device',
|
||||
@@ -78,6 +78,9 @@ const EXPERT_ONLY_SETTING_KEYS = new Set([
|
||||
'cdparanoia_command',
|
||||
'script_test_timeout_ms'
|
||||
]);
|
||||
const EXPERT_ONLY_CATEGORY_NAMES = new Set([
|
||||
'logging'
|
||||
]);
|
||||
|
||||
function toBoolean(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
@@ -436,6 +439,14 @@ function filterSettingsByVisibility(settings, expertModeEnabled) {
|
||||
return list.filter((setting) => !shouldHideSettingByExpertMode(setting?.key, expertModeEnabled));
|
||||
}
|
||||
|
||||
function shouldHideCategoryByExpertMode(categoryName, expertModeEnabled) {
|
||||
const normalizedCategory = normalizeText(categoryName);
|
||||
if (!normalizedCategory) {
|
||||
return false;
|
||||
}
|
||||
return !expertModeEnabled && EXPERT_ONLY_CATEGORY_NAMES.has(normalizedCategory);
|
||||
}
|
||||
|
||||
function buildToolSections(settings) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
const generalBucket = {
|
||||
@@ -1118,6 +1129,7 @@ export default function DynamicSettingsForm({
|
||||
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
|
||||
const visibleCategories = safeCategories
|
||||
.filter((category) => !shouldHideCategoryByExpertMode(category?.category, expertModeEnabled))
|
||||
.map((category) => ({
|
||||
...category,
|
||||
settings: filterSettingsByVisibility(category?.settings, expertModeEnabled)
|
||||
|
||||
@@ -641,24 +641,39 @@ function statusBadgeMeta(status, queued = false, options = {}) {
|
||||
if (normalized === 'READY_TO_ENCODE' || normalized === 'READY_TO_START') {
|
||||
return { label, icon: 'pi-play-circle', tone: 'info' };
|
||||
}
|
||||
if (normalized === 'CD_READY_TO_RIP') {
|
||||
return { label, icon: 'pi-play-circle', tone: 'info' };
|
||||
}
|
||||
if (normalized === 'WAITING_FOR_USER_DECISION') {
|
||||
return { label, icon: 'pi-exclamation-circle', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'METADATA_SELECTION') {
|
||||
return { label, icon: 'pi-list', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'CD_METADATA_SELECTION') {
|
||||
return { label, icon: 'pi-list', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'ANALYZING') {
|
||||
return { label, icon: 'pi-search', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'CD_ANALYZING') {
|
||||
return { label, icon: 'pi-search', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'RIPPING') {
|
||||
return { label, icon: 'pi-download', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'CD_RIPPING') {
|
||||
return { label, icon: 'pi-download', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'MEDIAINFO_CHECK') {
|
||||
return { label, icon: 'pi-sliders-h', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'ENCODING') {
|
||||
return { label, icon: 'pi-cog', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'CD_ENCODING') {
|
||||
return { label, icon: 'pi-cog', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'MERGING') {
|
||||
return { label, icon: 'pi-spinner pi-spin', tone: 'warning' };
|
||||
}
|
||||
@@ -669,6 +684,25 @@ function normalizePathForCompare(value) {
|
||||
return String(value || '').trim().replace(/[\\/]+$/, '');
|
||||
}
|
||||
|
||||
function isIncompleteRawPath(value) {
|
||||
const normalized = String(value || '').trim().replace(/[\\]+/g, '/');
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
const baseName = segments[segments.length - 1] || '';
|
||||
return /^incomplete_/i.test(baseName);
|
||||
}
|
||||
|
||||
function isIncompleteOutputPath(value) {
|
||||
const normalized = String(value || '').trim().replace(/[\\]+/g, '/');
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return /(^|\/)incomplete_job-\d+(\/|$)/i.test(normalized)
|
||||
|| /(^|\/)incomplete_merge_[^/]+_job_\d+(\/|$)/i.test(normalized);
|
||||
}
|
||||
|
||||
function buildMergeToolLogFallback(job = null) {
|
||||
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object'
|
||||
? job.handbrakeInfo
|
||||
@@ -695,19 +729,13 @@ function buildMergeToolLogFallback(job = null) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function omdbField(value) {
|
||||
function metadataField(value) {
|
||||
const raw = String(value || '').trim();
|
||||
return raw || '-';
|
||||
}
|
||||
|
||||
function omdbRottenTomatoesScore(omdbInfo) {
|
||||
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
|
||||
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === 'rotten tomatoes');
|
||||
return omdbField(entry?.Value);
|
||||
}
|
||||
|
||||
function normalizeMetadataProvider(value) {
|
||||
return String(value || '').trim().toLowerCase() || 'omdb';
|
||||
return String(value || '').trim().toLowerCase() || 'tmdb';
|
||||
}
|
||||
|
||||
function resolveJobMetadataContext(job) {
|
||||
@@ -734,7 +762,7 @@ function resolveJobMetadataContext(job) {
|
||||
const metadataProvider = normalizeMetadataProvider(
|
||||
mergedSelectedMetadata?.metadataProvider
|
||||
|| analyzeContext?.metadataProvider
|
||||
|| 'omdb'
|
||||
|| 'tmdb'
|
||||
);
|
||||
return {
|
||||
analyzeContext,
|
||||
@@ -744,7 +772,13 @@ function resolveJobMetadataContext(job) {
|
||||
}
|
||||
|
||||
function uniqueNameList(values, maxItems = 10) {
|
||||
const source = Array.isArray(values) ? values : [];
|
||||
const source = Array.isArray(values)
|
||||
? values
|
||||
: (typeof values === 'string'
|
||||
? values.split(',')
|
||||
: (values && typeof values === 'object'
|
||||
? [values]
|
||||
: []));
|
||||
const limit = Math.max(1, Number(maxItems || 10));
|
||||
const output = [];
|
||||
const seen = new Set();
|
||||
@@ -787,53 +821,34 @@ function formatRuntimeLabel(value) {
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function resolveMetadataDetailsForDisplay(job, omdbInfo = {}) {
|
||||
function resolveMetadataDetailsForDisplay(job) {
|
||||
const metadataContext = resolveJobMetadataContext(job);
|
||||
const provider = metadataContext.metadataProvider;
|
||||
const selectedMetadata = metadataContext.selectedMetadata;
|
||||
const tmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
|
||||
? selectedMetadata.tmdbDetails
|
||||
: {};
|
||||
const isTmdbProvider = provider === 'tmdb' || provider === 'themoviedb';
|
||||
|
||||
if (isTmdbProvider) {
|
||||
const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null;
|
||||
const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null;
|
||||
const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null;
|
||||
const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime);
|
||||
const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage);
|
||||
const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0
|
||||
? ratingNumber.toFixed(1)
|
||||
: null;
|
||||
const imdbId = String(selectedMetadata?.imdbId || tmdbDetails?.imdbId || '').trim() || null;
|
||||
const hasMatch = Boolean(selectedMetadata?.tmdbId || tmdbDetails?.tmdbId);
|
||||
|
||||
return {
|
||||
provider: 'tmdb',
|
||||
title: 'TMDb Details',
|
||||
matchLabel: 'TMDb Match',
|
||||
hasMatch,
|
||||
imdbId,
|
||||
director: director || '-',
|
||||
actors: actors || '-',
|
||||
runtime: runtime || '-',
|
||||
genre: genre || '-',
|
||||
tmdbRating: tmdbRating || '-'
|
||||
};
|
||||
}
|
||||
const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null;
|
||||
const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null;
|
||||
const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null;
|
||||
const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime);
|
||||
const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage);
|
||||
const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0
|
||||
? ratingNumber.toFixed(1)
|
||||
: null;
|
||||
const imdbId = String(selectedMetadata?.imdbId || tmdbDetails?.imdbId || '').trim() || null;
|
||||
const hasMatch = Boolean(selectedMetadata?.tmdbId || tmdbDetails?.tmdbId);
|
||||
|
||||
return {
|
||||
provider: 'omdb',
|
||||
title: 'OMDb Details',
|
||||
matchLabel: 'OMDb Match',
|
||||
hasMatch: Boolean(job?.selected_from_omdb),
|
||||
imdbId: String(job?.imdb_id || '').trim() || null,
|
||||
director: omdbField(omdbInfo?.Director),
|
||||
actors: omdbField(omdbInfo?.Actors),
|
||||
runtime: omdbField(omdbInfo?.Runtime),
|
||||
genre: omdbField(omdbInfo?.Genre),
|
||||
rottenTomatoes: omdbRottenTomatoesScore(omdbInfo),
|
||||
imdbRating: omdbField(omdbInfo?.imdbRating)
|
||||
provider: 'tmdb',
|
||||
title: 'TMDb Details',
|
||||
matchLabel: 'TMDb Match',
|
||||
hasMatch,
|
||||
imdbId,
|
||||
director: director || '-',
|
||||
actors: actors || '-',
|
||||
runtime: runtime || '-',
|
||||
genre: genre || '-',
|
||||
tmdbRating: tmdbRating || '-'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1058,7 +1073,7 @@ export default function JobDetailDialog({
|
||||
detailLoading = false,
|
||||
onLoadLog,
|
||||
logLoadingMode = null,
|
||||
onAssignOmdb,
|
||||
onAssignMetadata,
|
||||
onAssignCdMetadata,
|
||||
onGenerateNfo,
|
||||
onAcknowledgeError,
|
||||
@@ -1076,7 +1091,7 @@ export default function JobDetailDialog({
|
||||
onRestoreMultipartMerge,
|
||||
onCancel,
|
||||
isQueued = false,
|
||||
omdbAssignBusy = false,
|
||||
metadataAssignBusy = false,
|
||||
cdMetadataAssignBusy = false,
|
||||
acknowledgeErrorBusy = false,
|
||||
generateNfoBusy = false,
|
||||
@@ -1234,8 +1249,7 @@ export default function JobDetailDialog({
|
||||
job?.rawStatus?.exists
|
||||
&& job?.rawStatus?.isEmpty !== true
|
||||
&& !running
|
||||
&& mediaType !== 'audiobook'
|
||||
&& !blockMetadataAndReviewUntilRetry
|
||||
&& (isAudiobook || !blockMetadataAndReviewUntilRetry)
|
||||
&& typeof onRestartReview === 'function'
|
||||
);
|
||||
const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object'
|
||||
@@ -1328,9 +1342,8 @@ export default function JobDetailDialog({
|
||||
queueLocked,
|
||||
{ label: isMultipartMergeJob && statusUpper === 'ENCODING' ? 'Merging' : '' }
|
||||
);
|
||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||
const metadataContext = resolveJobMetadataContext(job);
|
||||
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
|
||||
const metadataDetails = resolveMetadataDetailsForDisplay(job);
|
||||
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
|
||||
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
|
||||
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
|
||||
@@ -1455,17 +1468,15 @@ export default function JobDetailDialog({
|
||||
&& Number(multipartMergeSummary?.inputReady || 0) >= Number(multipartMergeSummary?.inputExpected || 0)
|
||||
);
|
||||
const displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null;
|
||||
const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info';
|
||||
const metadataJsonValue = metadataDetails.provider === 'tmdb'
|
||||
? (metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null)
|
||||
: job?.omdbInfo;
|
||||
const metadataJsonTitle = 'TMDb Info';
|
||||
const metadataJsonValue = metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null;
|
||||
const metadataAssignProvider = isDvdSeries
|
||||
? 'tmdb'
|
||||
: (metadataDetails.provider === 'tmdb' ? 'tmdb' : 'omdb');
|
||||
const metadataAssignButtonLabel = metadataAssignProvider === 'tmdb' ? 'TMDb neu zuweisen' : 'OMDb neu zuweisen';
|
||||
: 'tmdb';
|
||||
const metadataAssignButtonLabel = 'TMDb neu zuweisen';
|
||||
const metadataAssignActionDescription = metadataAssignProvider === 'tmdb'
|
||||
? 'Öffnet die TMDb-Suche, um Serien-Metadaten (Titel, Staffel, Episoden, Poster) neu zuzuweisen.'
|
||||
: 'Öffnet die OMDb-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.';
|
||||
? 'Öffnet die TMDb-Suche, um Metadaten (Titel, Staffel/Episoden bzw. Film) neu zuzuweisen.'
|
||||
: 'Öffnet die TMDb-Suche, um Metadaten neu zuzuweisen.';
|
||||
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
|
||||
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|
||||
|| configuredSelection.postScriptIds.length > 0
|
||||
@@ -1490,8 +1501,24 @@ export default function JobDetailDialog({
|
||||
|| ''
|
||||
).trim() || null;
|
||||
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
|
||||
const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const jobMediaType = resolveMediaType(job);
|
||||
const isDiscMediaJob = ['dvd', 'bluray', 'cd'].includes(jobMediaType);
|
||||
const jobStatusUpper = String(job?.status || '').trim().toUpperCase();
|
||||
const outputDownloadReady = Boolean(
|
||||
job?.output_path
|
||||
&& job?.outputStatus?.exists
|
||||
&& !isIncompleteOutputPath(job.output_path)
|
||||
&& (job?.encodeSuccess || jobStatusUpper === 'FINISHED')
|
||||
);
|
||||
const rawDownloadReady = Boolean(
|
||||
resolvedRawPath
|
||||
&& job?.rawStatus?.exists
|
||||
&& job?.rawStatus?.isEmpty !== true
|
||||
&& !isIncompleteRawPath(resolvedRawPath)
|
||||
&& (!isDiscMediaJob || Boolean(job?.ripSuccessful))
|
||||
);
|
||||
const canDownloadRaw = Boolean(rawDownloadReady && typeof onDownloadArchive === 'function');
|
||||
const canDownloadOutput = Boolean(outputDownloadReady && typeof onDownloadArchive === 'function');
|
||||
const outputFolders = (() => {
|
||||
const baseFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
|
||||
if (!isDiskContainer) {
|
||||
@@ -1507,7 +1534,9 @@ export default function JobDetailDialog({
|
||||
seen.add(outputPath);
|
||||
merged.push({
|
||||
id: folder?.id ?? fallbackId ?? outputPath,
|
||||
output_path: outputPath
|
||||
output_path: outputPath,
|
||||
job_id: normalizePositiveInteger(folder?.job_id ?? folder?.jobId ?? null) || null,
|
||||
exists: folder?.exists === undefined ? undefined : Boolean(folder?.exists)
|
||||
});
|
||||
};
|
||||
for (const folder of baseFolders) {
|
||||
@@ -1516,11 +1545,17 @@ export default function JobDetailDialog({
|
||||
for (const child of childJobs) {
|
||||
const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : [];
|
||||
for (const childFolder of childOutputFolders) {
|
||||
addFolder(childFolder, `child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`);
|
||||
addFolder(
|
||||
{
|
||||
...childFolder,
|
||||
job_id: childFolder?.job_id ?? child?.id ?? null
|
||||
},
|
||||
`child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`
|
||||
);
|
||||
}
|
||||
if (child?.output_path) {
|
||||
addFolder(
|
||||
{ output_path: child.output_path },
|
||||
{ output_path: child.output_path, job_id: child?.id ?? null },
|
||||
`child-output-${child?.id || 'x'}-${String(child.output_path || '')}`
|
||||
);
|
||||
}
|
||||
@@ -1843,37 +1878,24 @@ export default function JobDetailDialog({
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Regisseur:</strong>
|
||||
<span>{omdbField(metadataDetails.director)}</span>
|
||||
<span>{metadataField(metadataDetails.director)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Schauspieler:</strong>
|
||||
<span>{omdbField(metadataDetails.actors)}</span>
|
||||
<span>{metadataField(metadataDetails.actors)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Laufzeit:</strong>
|
||||
<span>{omdbField(metadataDetails.runtime)}</span>
|
||||
<span>{metadataField(metadataDetails.runtime)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Genre:</strong>
|
||||
<span>{omdbField(metadataDetails.genre)}</span>
|
||||
<span>{metadataField(metadataDetails.genre)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>TMDb Rating:</strong>
|
||||
<span>{metadataField(metadataDetails.tmdbRating)}</span>
|
||||
</div>
|
||||
{metadataDetails.provider === 'tmdb' ? (
|
||||
<div className="job-meta-item">
|
||||
<strong>TMDb Rating:</strong>
|
||||
<span>{omdbField(metadataDetails.tmdbRating)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="job-meta-item">
|
||||
<strong>Rotten Tomatoes:</strong>
|
||||
<span>{omdbField(metadataDetails.rottenTomatoes)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>imdbRating:</strong>
|
||||
<span>{omdbField(metadataDetails.imdbRating)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
@@ -1954,7 +1976,16 @@ export default function JobDetailDialog({
|
||||
const childDiscNumber = resolveSeriesDiscNumber(child);
|
||||
const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:';
|
||||
const childRawPath = child?.raw_path || child?.output_path || null;
|
||||
const canDownloadChildRaw = Boolean(childRawPath && child?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const childMediaType = resolveMediaType(child);
|
||||
const childIsDiscMedia = ['dvd', 'bluray', 'cd'].includes(childMediaType);
|
||||
const childRawDownloadReady = Boolean(
|
||||
childRawPath
|
||||
&& child?.rawStatus?.exists
|
||||
&& child?.rawStatus?.isEmpty !== true
|
||||
&& !isIncompleteRawPath(childRawPath)
|
||||
&& (!childIsDiscMedia || Boolean(child?.ripSuccessful))
|
||||
);
|
||||
const canDownloadChildRaw = Boolean(childRawDownloadReady && typeof onDownloadArchive === 'function');
|
||||
return (
|
||||
<PathField
|
||||
key={`child-raw-${child.id}`}
|
||||
@@ -1973,6 +2004,19 @@ export default function JobDetailDialog({
|
||||
if (!folderPath) {
|
||||
return null;
|
||||
}
|
||||
const folderOwnerJobId = normalizePositiveInteger(folder?.job_id) || normalizePositiveInteger(job?.id);
|
||||
const folderOwner = folderOwnerJobId === normalizePositiveInteger(job?.id)
|
||||
? job
|
||||
: (childJobs.find((child) => normalizePositiveInteger(child?.id) === folderOwnerJobId) || job);
|
||||
const folderOwnerStatusUpper = String(folderOwner?.status || '').trim().toUpperCase();
|
||||
const folderExists = folder?.exists !== undefined
|
||||
? Boolean(folder?.exists)
|
||||
: Boolean(folderOwner?.outputStatus?.exists);
|
||||
const folderDownloadReady = Boolean(
|
||||
folderExists
|
||||
&& !isIncompleteOutputPath(folderPath)
|
||||
&& (folderOwner?.encodeSuccess || folderOwnerStatusUpper === 'FINISHED')
|
||||
);
|
||||
const normalizedFolderPath = normalizePathForCompare(folderPath);
|
||||
const isMergeOutput = Boolean(
|
||||
(normalizedFolderPath && mergeOutputPathCandidates.has(normalizedFolderPath))
|
||||
@@ -1980,13 +2024,13 @@ export default function JobDetailDialog({
|
||||
);
|
||||
const outputLabel = outputFolders.length > 1 ? `Output ${idx + 1}` : 'Output';
|
||||
const label = isMergeOutput ? `${outputLabel} (Merge):` : `${outputLabel}:`;
|
||||
const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function');
|
||||
const canDownloadFolder = Boolean(folderDownloadReady && typeof onDownloadOutputFolder === 'function');
|
||||
return (
|
||||
<PathField
|
||||
key={folder.id || folderPath}
|
||||
label={label}
|
||||
value={folderPath}
|
||||
onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath) : null}
|
||||
onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath, folderOwnerJobId) : null}
|
||||
downloadDisabled={!canDownloadFolder}
|
||||
downloadLoading={downloadFolderBusyPath === folderPath}
|
||||
/>
|
||||
@@ -2610,7 +2654,7 @@ export default function JobDetailDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignOmdb === 'function' ? (
|
||||
{!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignMetadata === 'function' ? (
|
||||
<div className="actions-group">
|
||||
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
||||
<div className="action-item">
|
||||
@@ -2620,8 +2664,8 @@ export default function JobDetailDialog({
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onAssignOmdb?.(child)}
|
||||
loading={omdbAssignBusy}
|
||||
onClick={() => onAssignMetadata?.(child)}
|
||||
loading={metadataAssignBusy}
|
||||
disabled={!childActionState.childCanAssignMetadata}
|
||||
/>
|
||||
<span className="action-desc">{metadataAssignActionDescription}</span>
|
||||
@@ -2917,7 +2961,7 @@ export default function JobDetailDialog({
|
||||
loading={actionBusy}
|
||||
disabled={!canCdStartReview}
|
||||
/>
|
||||
<span className="action-desc">Öffnet den Vorprüfungs-Workflow: MusicBrainz-Suche, Trackauswahl, Ausgabeeinstellungen — dann Encode aus vorhandenen WAV-Daten starten.</span>
|
||||
<span className="action-desc">Öffnet den Vorprüfungs-Workflow mit den zuletzt verwendeten CD-Metadaten: Trackauswahl und Ausgabeeinstellungen prüfen, dann Encode aus vorhandenen WAV-Daten starten.</span>
|
||||
</div>
|
||||
<div className="action-item">
|
||||
<Button
|
||||
@@ -2933,6 +2977,39 @@ export default function JobDetailDialog({
|
||||
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
|
||||
</div>
|
||||
</div>
|
||||
) : isAudiobook ? (
|
||||
<div className="actions-group">
|
||||
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audiobook</div>
|
||||
{typeof onReencode === 'function' ? (
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label="Encode neu starten"
|
||||
icon="pi pi-sync"
|
||||
severity="info"
|
||||
size="small"
|
||||
onClick={() => onReencode?.(job)}
|
||||
loading={reencodeBusy}
|
||||
disabled={!canReencode}
|
||||
/>
|
||||
<span className="action-desc">Startet FFmpeg direkt mit den zuletzt bestätigten Einstellungen neu. Es gibt dabei keinen Anpassungsschritt.</span>
|
||||
</div>
|
||||
) : null}
|
||||
{typeof onRestartReview === 'function' ? (
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label="Vorprüfung starten"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onRestartReview?.(job)}
|
||||
loading={actionBusy}
|
||||
disabled={!canRestartReview}
|
||||
/>
|
||||
<span className="action-desc">Legt den Job neu aus der Quelldatei an und öffnet ihn im Ripper, damit Einstellungen vor dem Encode angepasst werden können.</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
canShowGeneralEncodeActions ? (
|
||||
<div className="actions-group">
|
||||
@@ -3025,7 +3102,7 @@ export default function JobDetailDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignOmdb === 'function' ? (
|
||||
{!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignMetadata === 'function' ? (
|
||||
<div className="actions-group">
|
||||
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
||||
<div className="action-item">
|
||||
@@ -3035,8 +3112,8 @@ export default function JobDetailDialog({
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onAssignOmdb?.(job)}
|
||||
loading={omdbAssignBusy}
|
||||
onClick={() => onAssignMetadata?.(job)}
|
||||
loading={metadataAssignBusy}
|
||||
disabled={running}
|
||||
/>
|
||||
<span className="action-desc">{metadataAssignActionDescription}</span>
|
||||
|
||||
@@ -301,6 +301,17 @@ function resolveSubtitleTrackVariantFlags(track) {
|
||||
return { isForcedOnly, fullHasForced };
|
||||
}
|
||||
|
||||
function isClosedCaptionSubtitleTrack(track) {
|
||||
if (Boolean(track?.sdhLikely)) {
|
||||
return true;
|
||||
}
|
||||
const title = String(track?.title || track?.description || '').trim();
|
||||
if (!title) {
|
||||
return false;
|
||||
}
|
||||
return /\b(closed\s*caption|cc|sdh|hoh|hearing\s*impaired)\b/i.test(title);
|
||||
}
|
||||
|
||||
function compareForcedSubtitleCandidate(a, b) {
|
||||
const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
|
||||
if (defaultDiff !== 0) {
|
||||
@@ -1153,6 +1164,7 @@ function TrackList({
|
||||
|
||||
if (type === 'subtitle') {
|
||||
const selectedVariants = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
|
||||
const selectedTrackIdSet = new Set(normalizeTrackIdList(selectedTrackIds).map((id) => String(id)));
|
||||
const sortedSubtitleTracks = orderedTracks
|
||||
.filter((track) => !isDuplicateSubtitleTrack(track));
|
||||
|
||||
@@ -1206,10 +1218,13 @@ function TrackList({
|
||||
<div className="media-track-list">
|
||||
{sortedSubtitleTracks.map((track) => {
|
||||
const burned = isBurnedSubtitleTrack(track);
|
||||
const trackId = normalizeTrackId(track?.id);
|
||||
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
|
||||
const displayLanguage = toLang2(track?.language || track?.languageLabel || 'und');
|
||||
const displayCodec = simplifyCodec('subtitle', track?.format, track?.description || track?.title);
|
||||
const flags = resolveSubtitleTrackVariantFlags(track);
|
||||
const isClosedCaption = isClosedCaptionSubtitleTrack(track);
|
||||
const ccSuffix = isClosedCaption ? ' | Closed Caption (CC/SDH)' : '';
|
||||
const confidenceRaw = String(track?.sourceConfidence || '').trim().toLowerCase();
|
||||
const confidence = confidenceRaw === 'high' || confidenceRaw === 'medium' || confidenceRaw === 'low'
|
||||
? confidenceRaw
|
||||
@@ -1232,7 +1247,7 @@ function TrackList({
|
||||
}
|
||||
onToggleSubtitleVariant(language, 'forced', event.target.checked);
|
||||
},
|
||||
text: `#${track.id} | ${displayLanguage} | Automatische Übersetzungen (${displayCodec})`,
|
||||
text: `#${track.id} | ${displayLanguage} | Automatische Übersetzungen (${displayCodec})${ccSuffix}`,
|
||||
confidence
|
||||
})}
|
||||
<small className="track-action-note">Untertitel: {actionInfo}</small>
|
||||
@@ -1248,7 +1263,7 @@ function TrackList({
|
||||
return (
|
||||
<div key={`${title}-${track.id}-combo`} className="media-track-item">
|
||||
<div className="media-track-item">
|
||||
<small className="track-action-note">{`#${track.id} | ${displayLanguage}`}</small>
|
||||
<small className="track-action-note">{`#${track.id} | ${displayLanguage}${ccSuffix}`}</small>
|
||||
</div>
|
||||
{renderVariantRow({
|
||||
key: `${title}-${track.id}-combo-full`,
|
||||
@@ -1282,7 +1297,9 @@ function TrackList({
|
||||
);
|
||||
}
|
||||
|
||||
const checked = allowSelection ? Boolean(languageSelection.full) : Boolean(track?.selectedForEncode);
|
||||
const checked = allowSelection
|
||||
? (trackId !== null && selectedTrackIdSet.has(String(trackId)))
|
||||
: Boolean(track?.selectedForEncode);
|
||||
const disabled = !allowSelection || burned;
|
||||
const actionInfo = resolveSubtitleActionInfo(track, checked);
|
||||
return (
|
||||
@@ -1292,12 +1309,12 @@ function TrackList({
|
||||
checked,
|
||||
disabled,
|
||||
onChange: (event) => {
|
||||
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
|
||||
if (disabled || typeof onToggleTrack !== 'function' || trackId === null) {
|
||||
return;
|
||||
}
|
||||
onToggleSubtitleVariant(language, 'full', event.target.checked);
|
||||
onToggleTrack(trackId, event.target.checked);
|
||||
},
|
||||
text: `#${track.id} | ${displayLanguage} | Komplette Untertitel (${displayCodec})`
|
||||
text: `#${track.id} | ${displayLanguage} | Komplette Untertitel (${displayCodec})${ccSuffix}`
|
||||
})}
|
||||
<small className="track-action-note">Untertitel: {actionInfo}</small>
|
||||
</div>
|
||||
@@ -2177,9 +2194,11 @@ export default function MediaInfoReviewPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Pre-Encode Items (scripts + chains unified) */}
|
||||
{!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? (
|
||||
<div className="post-script-box">
|
||||
{!compactTitleSelection ? (
|
||||
<div className="encode-automation-grid">
|
||||
{/* Pre-Encode Items (scripts + chains unified) */}
|
||||
{(allowEncodeItemSelection || preEncodeItems.length > 0) ? (
|
||||
<div className="post-script-box">
|
||||
<h4>Pre-Encode Ausführungen (optional)</h4>
|
||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||
@@ -2294,13 +2313,12 @@ export default function MediaInfoReviewPanel({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<small>Ausführung vor dem Encoding, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small>
|
||||
</div>
|
||||
) : null}
|
||||
<small>Ausführung vor dem Encoding, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Post-Encode Items (scripts + chains unified) */}
|
||||
{!compactTitleSelection ? (
|
||||
<div className="post-script-box">
|
||||
{/* Post-Encode Items (scripts + chains unified) */}
|
||||
<div className="post-script-box">
|
||||
<h4>Post-Encode Ausführungen (optional)</h4>
|
||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||
@@ -2415,7 +2433,8 @@ export default function MediaInfoReviewPanel({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
|
||||
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -2716,6 +2735,12 @@ export default function MediaInfoReviewPanel({
|
||||
selectedTrackIds={selectedSubtitleTrackIds}
|
||||
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
|
||||
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
|
||||
onToggleTrack={(trackId, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
|
||||
}}
|
||||
onToggleSubtitleVariant={(language, variant, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
|
||||
return;
|
||||
@@ -2751,6 +2776,12 @@ export default function MediaInfoReviewPanel({
|
||||
selectedTrackIds={selectedSubtitleTrackIds}
|
||||
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
|
||||
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
|
||||
onToggleTrack={(trackId, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
|
||||
}}
|
||||
onToggleSubtitleVariant={(language, variant, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
|
||||
return;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Button } from 'primereact/button';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
|
||||
export default function MetadataSelectionDialog({
|
||||
visible,
|
||||
@@ -105,6 +104,13 @@ export default function MetadataSelectionDialog({
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const normalizeSeriesClassification = (value) => {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (raw === 'series' || raw === 'film' || raw === 'ambiguous') {
|
||||
return raw;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const parseDiscNumber = (rawValue) => {
|
||||
const text = String(rawValue ?? '').trim();
|
||||
if (!/^\d+$/.test(text)) {
|
||||
@@ -133,45 +139,78 @@ export default function MetadataSelectionDialog({
|
||||
const [conflictError, setConflictError] = useState('');
|
||||
const [seasonFilter, setSeasonFilter] = useState('');
|
||||
const [discValidationError, setDiscValidationError] = useState('');
|
||||
const [manualWorkflowKind, setManualWorkflowKind] = useState(null);
|
||||
const activeSearchRequestRef = useRef(0);
|
||||
const autoSearchDoneRef = useRef(false);
|
||||
const autoSearchWorkflowRef = useRef(null);
|
||||
const wasVisibleRef = useRef(false);
|
||||
const lastVisibleContextIdentityRef = useRef(null);
|
||||
const contextWorkflowKind = normalizeWorkflowKind(
|
||||
context?.selectedMetadata?.workflowKind
|
||||
|| context?.workflowKind
|
||||
|| null
|
||||
);
|
||||
const contextSelectedMetadataProvider = String(
|
||||
context?.selectedMetadata?.metadataProvider
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const contextMetadataProvider = (
|
||||
contextWorkflowKind === 'series'
|
||||
? 'tmdb'
|
||||
: (contextWorkflowKind === 'film'
|
||||
? 'omdb'
|
||||
: (contextSelectedMetadataProvider || String(context?.metadataProvider || 'omdb').trim().toLowerCase()))
|
||||
) || 'omdb';
|
||||
const [selectedMetadataProvider, setSelectedMetadataProvider] = useState(contextMetadataProvider);
|
||||
const mediaProfile = String(
|
||||
context?.mediaProfile
|
||||
|| context?.selectedMetadata?.mediaProfile
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const metadataProvider = selectedMetadataProvider === 'tmdb' ? 'tmdb' : 'omdb';
|
||||
const providerOptions = mediaProfile === 'dvd'
|
||||
? [
|
||||
{ label: 'Film (OMDb)', value: 'omdb' },
|
||||
{ label: 'Serie (TMDb)', value: 'tmdb' }
|
||||
]
|
||||
: [{ label: contextMetadataProvider === 'tmdb' ? 'TMDb' : 'OMDb', value: contextMetadataProvider }];
|
||||
const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb';
|
||||
const supportsExternalIdInput = metadataProvider === 'omdb';
|
||||
const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile);
|
||||
const metadataProvider = 'tmdb';
|
||||
const providerLabel = 'TMDb';
|
||||
const seriesDecisionClassification = normalizeSeriesClassification(context?.seriesDecision?.classification);
|
||||
const hasSeriesSignals = useMemo(() => {
|
||||
if (contextWorkflowKind === 'series') {
|
||||
return true;
|
||||
}
|
||||
const selectedMetadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object'
|
||||
? context.selectedMetadata
|
||||
: {};
|
||||
const metadataKind = String(selectedMetadata?.metadataKind || '').trim().toLowerCase();
|
||||
if (metadataKind === 'series' || metadataKind === 'season') {
|
||||
return true;
|
||||
}
|
||||
if (Number(selectedMetadata?.seasonNumber || 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
if (Number(context?.seriesLookupHint?.seasonNumber || 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
if (Boolean(context?.seriesAnalysis?.summary?.seriesLike)) {
|
||||
return true;
|
||||
}
|
||||
const contextCandidates = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : [];
|
||||
return contextCandidates.some((row) => Number(row?.seasonNumber || 0) > 0);
|
||||
}, [context, contextWorkflowKind]);
|
||||
const recommendedWorkflowKind = normalizeWorkflowKind(
|
||||
context?.seriesDecision?.recommendedWorkflowKind
|
||||
|| contextWorkflowKind
|
||||
|| null
|
||||
) || (hasSeriesSignals ? 'series' : 'film');
|
||||
const effectiveWorkflowKind = manualWorkflowKind || recommendedWorkflowKind;
|
||||
const allowsWorkflowSwitch = (
|
||||
(mediaProfile === 'dvd' || mediaProfile === 'bluray')
|
||||
&& seriesDecisionClassification === 'ambiguous'
|
||||
);
|
||||
const supportsExternalIdInput = false;
|
||||
const requiresDiscNumber = metadataProvider === 'tmdb'
|
||||
&& effectiveWorkflowKind === 'series'
|
||||
&& (mediaProfile === 'dvd' || mediaProfile === 'bluray');
|
||||
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
|
||||
const hasValidDiscNumber = effectiveDiscNumber !== null;
|
||||
const contextJobId = Number(context?.jobId || 0) || null;
|
||||
const contextIdentity = contextJobId
|
||||
? `job:${contextJobId}`
|
||||
: `detected:${String(context?.detectedTitle || '').trim().toLowerCase()}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
wasVisibleRef.current = false;
|
||||
lastVisibleContextIdentityRef.current = null;
|
||||
return;
|
||||
}
|
||||
const openingNow = !wasVisibleRef.current;
|
||||
const contextChanged = lastVisibleContextIdentityRef.current !== contextIdentity;
|
||||
if (!openingNow && !contextChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -198,10 +237,13 @@ export default function MetadataSelectionDialog({
|
||||
setConflictError('');
|
||||
setSeasonFilter('');
|
||||
setDiscValidationError('');
|
||||
setSelectedMetadataProvider(contextMetadataProvider);
|
||||
setManualWorkflowKind(allowsWorkflowSwitch ? recommendedWorkflowKind : null);
|
||||
autoSearchDoneRef.current = false;
|
||||
autoSearchWorkflowRef.current = null;
|
||||
activeSearchRequestRef.current += 1;
|
||||
}, [visible, context]);
|
||||
wasVisibleRef.current = true;
|
||||
lastVisibleContextIdentityRef.current = contextIdentity;
|
||||
}, [visible, contextIdentity, context, allowsWorkflowSwitch, recommendedWorkflowKind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!discValidationError) {
|
||||
@@ -221,7 +263,7 @@ export default function MetadataSelectionDialog({
|
||||
const rows = useMemo(() => {
|
||||
const base = Array.isArray(context?.metadataCandidates)
|
||||
? context.metadataCandidates
|
||||
: (context?.omdbCandidates || []);
|
||||
: [];
|
||||
const sourceRows = hasSearchOverride ? extraResults : base;
|
||||
const all = Array.isArray(sourceRows) ? sourceRows : [];
|
||||
const map = new Map();
|
||||
@@ -246,7 +288,7 @@ export default function MetadataSelectionDialog({
|
||||
}, [context, extraResults, hasSearchOverride]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (metadataProvider !== 'tmdb') {
|
||||
if (effectiveWorkflowKind !== 'series') {
|
||||
return rows;
|
||||
}
|
||||
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
|
||||
@@ -261,7 +303,7 @@ export default function MetadataSelectionDialog({
|
||||
}, [metadataProvider, rows, seasonFilter]);
|
||||
|
||||
const titleWithPosterBody = (row) => (
|
||||
<div className="omdb-row">
|
||||
<div className="metadata-row">
|
||||
{row.poster && row.poster !== 'N/A' ? (
|
||||
<img src={row.poster} alt={row.title} className="poster-thumb-lg" />
|
||||
) : (
|
||||
@@ -299,7 +341,8 @@ export default function MetadataSelectionDialog({
|
||||
let effectiveResults = [];
|
||||
for (const candidateQuery of queryVariants) {
|
||||
const results = await onSearch(candidateQuery, {
|
||||
metadataProvider
|
||||
metadataProvider,
|
||||
workflowKind: effectiveWorkflowKind
|
||||
});
|
||||
if (activeSearchRequestRef.current !== requestId) {
|
||||
return;
|
||||
@@ -323,20 +366,26 @@ export default function MetadataSelectionDialog({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || autoSearchDoneRef.current) {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
if (typeof onSearch !== 'function') {
|
||||
autoSearchDoneRef.current = true;
|
||||
autoSearchWorkflowRef.current = effectiveWorkflowKind;
|
||||
return;
|
||||
}
|
||||
const trimmedQuery = String(query || '').trim();
|
||||
if (!trimmedQuery) {
|
||||
return;
|
||||
}
|
||||
const workflowChanged = autoSearchWorkflowRef.current !== effectiveWorkflowKind;
|
||||
if (autoSearchDoneRef.current && !workflowChanged) {
|
||||
return;
|
||||
}
|
||||
autoSearchDoneRef.current = true;
|
||||
autoSearchWorkflowRef.current = effectiveWorkflowKind;
|
||||
void handleSearch();
|
||||
}, [visible, query, metadataProvider, onSearch]);
|
||||
}, [visible, query, effectiveWorkflowKind, onSearch]);
|
||||
|
||||
const handleSubmitError = (error, pendingPayload = null) => {
|
||||
const details = Array.isArray(error?.details) ? error.details : [];
|
||||
@@ -393,12 +442,22 @@ export default function MetadataSelectionDialog({
|
||||
year: selected.year,
|
||||
imdbId: selected.imdbId,
|
||||
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
|
||||
fromOmdb: metadataProvider === 'omdb',
|
||||
metadataProvider,
|
||||
workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film',
|
||||
workflowKind: effectiveWorkflowKind,
|
||||
providerId: selected.providerId || null,
|
||||
tmdbId: selected.tmdbId || null,
|
||||
metadataKind: selected.metadataKind || null,
|
||||
voteAverage: Number.isFinite(Number(selected?.voteAverage))
|
||||
? Number(Number(selected.voteAverage).toFixed(1))
|
||||
: null,
|
||||
imdbRating: String(
|
||||
selected?.imdbRating
|
||||
|| selected?.tmdbDetails?.imdbRating
|
||||
|| ''
|
||||
).trim() || null,
|
||||
tmdbDetails: selected?.tmdbDetails && typeof selected.tmdbDetails === 'object'
|
||||
? selected.tmdbDetails
|
||||
: null,
|
||||
seasonNumber: selected.seasonNumber || null,
|
||||
seasonName: selected.seasonName || null,
|
||||
episodeCount: selected.episodeCount || 0,
|
||||
@@ -409,11 +468,10 @@ export default function MetadataSelectionDialog({
|
||||
jobId: context.jobId,
|
||||
title: manualTitle,
|
||||
year: manualYear,
|
||||
imdbId: supportsExternalIdInput ? manualImdb : null,
|
||||
imdbId: null,
|
||||
poster: null,
|
||||
fromOmdb: false,
|
||||
metadataProvider,
|
||||
workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film',
|
||||
workflowKind: effectiveWorkflowKind,
|
||||
seasonNumber: null,
|
||||
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
|
||||
};
|
||||
@@ -572,26 +630,6 @@ export default function MetadataSelectionDialog({
|
||||
modal
|
||||
>
|
||||
<div className="search-row">
|
||||
{providerOptions.length > 1 ? (
|
||||
<Dropdown
|
||||
value={metadataProvider}
|
||||
options={providerOptions}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => {
|
||||
const nextProvider = String(event?.value || 'omdb').trim().toLowerCase() || 'omdb';
|
||||
setSelectedMetadataProvider(nextProvider);
|
||||
setSelected(null);
|
||||
setExtraResults([]);
|
||||
setHasSearchOverride(false);
|
||||
setSearchError('');
|
||||
setSubmitError('');
|
||||
setDiscValidationError('');
|
||||
}}
|
||||
disabled={searchBusy || busy}
|
||||
style={{ minWidth: '12.5rem' }}
|
||||
/>
|
||||
) : null}
|
||||
<InputText
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
@@ -605,8 +643,26 @@ export default function MetadataSelectionDialog({
|
||||
/>
|
||||
<Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
|
||||
</div>
|
||||
<div className={`metadata-filter-grid${metadataProvider !== 'tmdb' ? ' metadata-filter-grid-single' : ''}`}>
|
||||
{metadataProvider === 'tmdb' ? (
|
||||
{allowsWorkflowSwitch ? (
|
||||
<div className="dialog-actions" style={{ justifyContent: 'flex-start', marginTop: '-0.25rem' }}>
|
||||
<Button
|
||||
label="Als Film suchen"
|
||||
severity={effectiveWorkflowKind === 'film' ? 'primary' : 'secondary'}
|
||||
outlined={effectiveWorkflowKind !== 'film'}
|
||||
onClick={() => setManualWorkflowKind('film')}
|
||||
disabled={searchBusy || busy}
|
||||
/>
|
||||
<Button
|
||||
label="Als Serie suchen"
|
||||
severity={effectiveWorkflowKind === 'series' ? 'primary' : 'secondary'}
|
||||
outlined={effectiveWorkflowKind !== 'series'}
|
||||
onClick={() => setManualWorkflowKind('series')}
|
||||
disabled={searchBusy || busy}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`metadata-filter-grid${effectiveWorkflowKind !== 'series' ? ' metadata-filter-grid-single' : ''}`}>
|
||||
{effectiveWorkflowKind === 'series' ? (
|
||||
<InputText
|
||||
value={seasonFilter}
|
||||
onChange={(event) => setSeasonFilter(event.target.value)}
|
||||
@@ -650,8 +706,8 @@ export default function MetadataSelectionDialog({
|
||||
<Column header="Titel" body={titleWithPosterBody} />
|
||||
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
|
||||
<Column
|
||||
header={metadataProvider === 'tmdb' ? 'Staffel' : 'IMDb'}
|
||||
body={(row) => (metadataProvider === 'tmdb'
|
||||
header="Staffel"
|
||||
body={(row) => (effectiveWorkflowKind === 'series'
|
||||
? (row.seasonNumber ? `S${row.seasonNumber}` : '-')
|
||||
: (row.imdbId || '-'))}
|
||||
style={{ width: '10rem' }}
|
||||
|
||||
@@ -316,6 +316,14 @@ function normalizeSubtitleLanguageOrder(rawOrder = []) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeSubtitleSelectionMode(value, fallback = 'variants') {
|
||||
const mode = String(value || '').trim().toLowerCase();
|
||||
if (mode === 'track_ids' || mode === 'variants') {
|
||||
return mode;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveSubtitleTrackVariantFlags(track) {
|
||||
const subtitleType = String(track?.subtitleType || '').trim().toLowerCase();
|
||||
const isForcedOnly = Boolean(
|
||||
@@ -430,6 +438,15 @@ function buildDefaultTrackSelection(review) {
|
||||
const manualSubtitleLanguageOrder = manualSelectionMatchesTitle
|
||||
? normalizeSubtitleLanguageOrder(manualSelection?.subtitleLanguageOrder || [])
|
||||
: [];
|
||||
const manualHasVariants = Object.keys(manualSubtitleVariantSelection).length > 0;
|
||||
const subtitleSelectionMode = manualSelectionMatchesTitle
|
||||
? normalizeSubtitleSelectionMode(
|
||||
manualSelection?.subtitleSelectionMode,
|
||||
manualHasVariants
|
||||
? 'variants'
|
||||
: (manualSubtitleTrackIds.length > 0 ? 'track_ids' : 'variants')
|
||||
)
|
||||
: 'variants';
|
||||
|
||||
selection[titleId] = {
|
||||
audioTrackIds: manualAudioTrackIds.length > 0
|
||||
@@ -447,7 +464,8 @@ function buildDefaultTrackSelection(review) {
|
||||
: defaultSubtitleVariantSelection.subtitleVariantSelection,
|
||||
subtitleLanguageOrder: manualSubtitleLanguageOrder.length > 0
|
||||
? manualSubtitleLanguageOrder
|
||||
: defaultSubtitleVariantSelection.subtitleLanguageOrder
|
||||
: defaultSubtitleVariantSelection.subtitleLanguageOrder,
|
||||
subtitleSelectionMode
|
||||
};
|
||||
}
|
||||
|
||||
@@ -460,7 +478,8 @@ function defaultTrackSelectionForTitle(review, titleId) {
|
||||
audioTrackIds: [],
|
||||
subtitleTrackIds: [],
|
||||
subtitleVariantSelection: {},
|
||||
subtitleLanguageOrder: []
|
||||
subtitleLanguageOrder: [],
|
||||
subtitleSelectionMode: 'variants'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -569,15 +588,22 @@ function hasSeriesMetadataShape(source = null) {
|
||||
const metadata = source && typeof source === 'object' ? source : {};
|
||||
const seasonNumber = normalizePositiveInt(metadata?.seasonNumber ?? metadata?.season ?? null);
|
||||
const episodes = Array.isArray(metadata?.episodes) ? metadata.episodes : [];
|
||||
const metadataProvider = String(metadata?.metadataProvider || '').trim().toLowerCase();
|
||||
const workflowKind = String(metadata?.workflowKind || metadata?.kind || '').trim().toLowerCase();
|
||||
const metadataKind = String(metadata?.metadataKind || '').trim().toLowerCase();
|
||||
const episodeCount = Number(metadata?.episodeCount || 0) || 0;
|
||||
if (['film', 'movie', 'feature'].includes(workflowKind) || ['film', 'movie', 'feature'].includes(metadataKind)) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
seasonNumber
|
||||
|| episodeCount > 0
|
||||
|| episodes.length > 0
|
||||
|| metadataProvider === 'tmdb'
|
||||
|| metadataProvider === 'themoviedb'
|
||||
|| workflowKind === 'series'
|
||||
|| workflowKind === 'season'
|
||||
|| workflowKind === 'tv'
|
||||
|| metadataKind === 'series'
|
||||
|| metadataKind === 'season'
|
||||
|| metadataKind === 'tv'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -631,24 +657,14 @@ function resolveDiscWorkflowKind({
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
const providerCandidates = [
|
||||
selected?.metadataProvider,
|
||||
analyze?.metadataProvider,
|
||||
pipelineCtx?.metadataProvider
|
||||
];
|
||||
for (const candidate of providerCandidates) {
|
||||
const provider = String(candidate || '').trim().toLowerCase();
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
if (provider === 'tmdb' || provider === 'themoviedb') {
|
||||
return 'series';
|
||||
}
|
||||
if (provider === 'omdb') {
|
||||
return 'film';
|
||||
}
|
||||
if (
|
||||
hasSeriesMetadataShape(selected)
|
||||
|| hasSeriesMetadataShape(analyze)
|
||||
|| hasSeriesMetadataShape(pipelineCtx)
|
||||
) {
|
||||
return 'series';
|
||||
}
|
||||
return fallbackIsSeries ? 'series' : 'film';
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) {
|
||||
@@ -656,7 +672,10 @@ function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) {
|
||||
if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') {
|
||||
return null;
|
||||
}
|
||||
const kind = normalizeDiscWorkflowKind(workflowKind) || 'film';
|
||||
const kind = normalizeDiscWorkflowKind(workflowKind);
|
||||
if (!kind) {
|
||||
return null;
|
||||
}
|
||||
if (normalizedMediaProfile === 'bluray') {
|
||||
return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film';
|
||||
}
|
||||
@@ -1555,7 +1574,7 @@ export default function PipelineStatusCard({
|
||||
onAnalyze,
|
||||
onReanalyze,
|
||||
onOpenMetadata,
|
||||
onReassignOmdb,
|
||||
onReassignMetadata,
|
||||
onStart,
|
||||
onRemoveFromQueue,
|
||||
onRestartEncode,
|
||||
@@ -3496,11 +3515,24 @@ export default function PipelineStatusCard({
|
||||
const hasExplicitVariantSelection = Object.keys(filteredVariantSelection).length > 0;
|
||||
const filteredSubtitleTrackIds = normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
|
||||
.filter((id) => !blockedSubtitleTrackIds.has(String(id)));
|
||||
const subtitleSelectionMode = normalizeSubtitleSelectionMode(
|
||||
effectiveSelection?.subtitleSelectionMode,
|
||||
hasExplicitVariantSelection
|
||||
? 'variants'
|
||||
: (filteredSubtitleTrackIds.length > 0 ? 'track_ids' : 'variants')
|
||||
);
|
||||
selectedTrackSelection[titleId] = {
|
||||
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
|
||||
subtitleTrackIds: hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds,
|
||||
subtitleVariantSelection: filteredVariantSelection,
|
||||
subtitleLanguageOrder: normalizedLanguageOrder
|
||||
subtitleSelectionMode,
|
||||
subtitleTrackIds: subtitleSelectionMode === 'track_ids'
|
||||
? filteredSubtitleTrackIds
|
||||
: (hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds),
|
||||
...(subtitleSelectionMode === 'variants'
|
||||
? {
|
||||
subtitleVariantSelection: filteredVariantSelection,
|
||||
subtitleLanguageOrder: normalizedLanguageOrder
|
||||
}
|
||||
: {})
|
||||
};
|
||||
}
|
||||
const episodeAssignments = {};
|
||||
@@ -3721,7 +3753,7 @@ export default function PipelineStatusCard({
|
||||
&& state !== 'IDLE'
|
||||
&& state !== 'DISC_DETECTED'
|
||||
&& retryJobId
|
||||
&& typeof onReassignOmdb === 'function'
|
||||
&& typeof onReassignMetadata === 'function'
|
||||
&& !isOrphanCancelled
|
||||
&& !blockMetadataAndReviewForRipRetry
|
||||
&& !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? (
|
||||
@@ -3730,7 +3762,7 @@ export default function PipelineStatusCard({
|
||||
icon="pi pi-search"
|
||||
severity="info"
|
||||
size="small"
|
||||
onClick={() => onReassignOmdb?.(retryJobId)}
|
||||
onClick={() => onReassignMetadata?.(retryJobId)}
|
||||
loading={busy}
|
||||
/>
|
||||
) : null}
|
||||
@@ -4357,7 +4389,8 @@ export default function PipelineStatusCard({
|
||||
audioTrackIds: [],
|
||||
subtitleTrackIds: [],
|
||||
subtitleVariantSelection: {},
|
||||
subtitleLanguageOrder: []
|
||||
subtitleLanguageOrder: [],
|
||||
subtitleSelectionMode: 'variants'
|
||||
};
|
||||
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
|
||||
const existing = normalizeTrackIdList(current?.[key] || []);
|
||||
@@ -4369,7 +4402,14 @@ export default function PipelineStatusCard({
|
||||
...prev,
|
||||
[normalizedTitleId]: {
|
||||
...current,
|
||||
[key]: next
|
||||
[key]: next,
|
||||
...(trackType === 'subtitle'
|
||||
? {
|
||||
subtitleSelectionMode: 'track_ids',
|
||||
subtitleVariantSelection: {},
|
||||
subtitleLanguageOrder: []
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -4387,7 +4427,8 @@ export default function PipelineStatusCard({
|
||||
audioTrackIds: [],
|
||||
subtitleTrackIds: [],
|
||||
subtitleVariantSelection: {},
|
||||
subtitleLanguageOrder: []
|
||||
subtitleLanguageOrder: [],
|
||||
subtitleSelectionMode: 'variants'
|
||||
};
|
||||
const currentMap = normalizeSubtitleVariantSelectionMap(current?.subtitleVariantSelection || {});
|
||||
const nextMap = { ...currentMap };
|
||||
@@ -4415,6 +4456,7 @@ export default function PipelineStatusCard({
|
||||
...prev,
|
||||
[normalizedTitleId]: {
|
||||
...current,
|
||||
subtitleSelectionMode: 'variants',
|
||||
subtitleVariantSelection: nextMap,
|
||||
subtitleLanguageOrder: nextOrder
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function useWebSocket({ onMessage }) {
|
||||
let reconnectTimer;
|
||||
let progressFlushTimer = null;
|
||||
let progressFlushRaf = null;
|
||||
let latestProgressMessage = null;
|
||||
const latestProgressMessagesByJob = new Map();
|
||||
let isUnmounted = false;
|
||||
|
||||
const clearProgressFlush = () => {
|
||||
@@ -35,12 +35,14 @@ export function useWebSocket({ onMessage }) {
|
||||
const flushProgressMessage = () => {
|
||||
progressFlushRaf = null;
|
||||
progressFlushTimer = null;
|
||||
const message = latestProgressMessage;
|
||||
latestProgressMessage = null;
|
||||
if (!message) {
|
||||
if (latestProgressMessagesByJob.size === 0) {
|
||||
return;
|
||||
}
|
||||
onMessageRef.current?.(message);
|
||||
const messages = Array.from(latestProgressMessagesByJob.values());
|
||||
latestProgressMessagesByJob.clear();
|
||||
for (const message of messages) {
|
||||
onMessageRef.current?.(message);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleProgressFlush = () => {
|
||||
@@ -65,7 +67,9 @@ export function useWebSocket({ onMessage }) {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message?.type === 'PIPELINE_PROGRESS') {
|
||||
latestProgressMessage = message;
|
||||
const progressJobId = message?.payload?.activeJobId;
|
||||
const jobKey = progressJobId == null ? '__global__' : String(progressJobId);
|
||||
latestProgressMessagesByJob.set(jobKey, message);
|
||||
scheduleProgressFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Toast } from 'primereact/toast';
|
||||
@@ -10,7 +9,6 @@ import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import AudiobookUploadPanel from '../components/AudiobookUploadPanel';
|
||||
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
|
||||
import AudiobookOutputExplorer from '../components/AudiobookOutputExplorer';
|
||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||
import { resolveMediaType } from '../utils/jobTaxonomy';
|
||||
import { confirmModal } from '../utils/confirmModal';
|
||||
@@ -25,6 +23,44 @@ function normalizeJobId(value) {
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function hasOwn(source, key) {
|
||||
return source != null && Object.prototype.hasOwnProperty.call(source, key);
|
||||
}
|
||||
|
||||
function normalizeLiveProgressForJob(job) {
|
||||
const live = job?.liveProgress && typeof job.liveProgress === 'object' ? job.liveProgress : null;
|
||||
if (!live) {
|
||||
return null;
|
||||
}
|
||||
const liveContext = live?.context && typeof live.context === 'object' ? live.context : {};
|
||||
return {
|
||||
progress: live?.progress ?? null,
|
||||
eta: live?.eta ?? null,
|
||||
statusText: live?.statusText ?? null,
|
||||
state: live?.state ?? null,
|
||||
currentChapter: liveContext?.currentChapter && typeof liveContext.currentChapter === 'object'
|
||||
? liveContext.currentChapter
|
||||
: null,
|
||||
completedChapterCount: Number.isFinite(Number(liveContext?.completedChapterCount))
|
||||
? Number(liveContext.completedChapterCount)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
function extractUploadJobIdFromResponse(response) {
|
||||
const payload = response && typeof response === 'object' ? response : {};
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
|
||||
return (
|
||||
normalizeJobId(result?.jobId)
|
||||
|| normalizeJobId(payload?.jobId)
|
||||
|| normalizeJobId(result?.id)
|
||||
|| normalizeJobId(payload?.id)
|
||||
|| normalizeJobId(result?.job?.id)
|
||||
|| normalizeJobId(payload?.job?.id)
|
||||
|| null
|
||||
);
|
||||
}
|
||||
|
||||
function parseEncodePlan(job) {
|
||||
if (job?.encodePlan && typeof job.encodePlan === 'object') {
|
||||
return job.encodePlan;
|
||||
@@ -71,6 +107,22 @@ function resolveAudiobookMetadata(job, encodePlan) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildAudiobookJobMetaLine(jobId, metadata = {}, fallbackYear = null) {
|
||||
const author = String(metadata?.author || metadata?.artist || '').trim() || null;
|
||||
const narrator = String(metadata?.narrator || '').trim() || null;
|
||||
const chapterCount = Array.isArray(metadata?.chapters) ? metadata.chapters.length : 0;
|
||||
const year = Number.isFinite(Number(metadata?.year))
|
||||
? Math.trunc(Number(metadata.year))
|
||||
: (Number.isFinite(Number(fallbackYear)) ? Math.trunc(Number(fallbackYear)) : null);
|
||||
return (
|
||||
`#${jobId}`
|
||||
+ (author ? ` | ${author}` : '')
|
||||
+ (narrator ? ` | ${narrator}` : '')
|
||||
+ (chapterCount > 0 ? ` | ${chapterCount} Kapitel` : '')
|
||||
+ (year ? ` | ${year}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
function buildAudiobookPipeline(job, progress = null) {
|
||||
const encodePlan = parseEncodePlan(job);
|
||||
const selectedMetadata = resolveAudiobookMetadata(job, encodePlan);
|
||||
@@ -93,7 +145,13 @@ function buildAudiobookPipeline(job, progress = null) {
|
||||
format: String(encodePlan?.format || job?.handbrakeInfo?.format || 'mp3').trim().toLowerCase() || 'mp3',
|
||||
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
|
||||
? encodePlan.formatOptions
|
||||
: {}
|
||||
: {},
|
||||
preEncodeScriptIds: Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : [],
|
||||
postEncodeScriptIds: Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : [],
|
||||
preEncodeChainIds: Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [],
|
||||
postEncodeChainIds: Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [],
|
||||
preEncodeItems: Array.isArray(encodePlan?.preEncodeItems) ? encodePlan.preEncodeItems : [],
|
||||
postEncodeItems: Array.isArray(encodePlan?.postEncodeItems) ? encodePlan.postEncodeItems : []
|
||||
},
|
||||
mediaInfoReview: reviewData,
|
||||
outputPath: String(job?.output_path || encodePlan?.outputPath || '').trim() || null,
|
||||
@@ -109,16 +167,15 @@ function buildAudiobookPipeline(job, progress = null) {
|
||||
|
||||
export default function AudiobooksPage({
|
||||
audiobookUpload,
|
||||
onAudiobookUpload
|
||||
onAudiobookUpload,
|
||||
onCancelAudiobookUpload = null
|
||||
}) {
|
||||
const toastRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loadingJobs, setLoadingJobs] = useState(false);
|
||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||
const [jobProgress, setJobProgress] = useState({});
|
||||
const [actionBusyJobIds, setActionBusyJobIds] = useState(() => new Set());
|
||||
const explorerRefreshToken = 0;
|
||||
|
||||
const setActionBusy = (jobId, busy) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
@@ -146,21 +203,32 @@ export default function AudiobooksPage({
|
||||
.sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0));
|
||||
setJobs(audiobookRows);
|
||||
setJobProgress((prev) => {
|
||||
const next = { ...prev };
|
||||
const next = {};
|
||||
for (const job of audiobookRows) {
|
||||
const jobId = normalizeJobId(job?.id);
|
||||
if (!jobId) {
|
||||
continue;
|
||||
}
|
||||
const status = String(job?.status || '').trim().toUpperCase();
|
||||
if (!['ANALYZING', 'ENCODING'].includes(status)) {
|
||||
delete next[jobId];
|
||||
const isLiveStatus = ['ANALYZING', 'ENCODING'].includes(status);
|
||||
if (!isLiveStatus) {
|
||||
continue;
|
||||
}
|
||||
const serverProgress = normalizeLiveProgressForJob(job);
|
||||
if (serverProgress) {
|
||||
next[jobId] = serverProgress;
|
||||
continue;
|
||||
}
|
||||
if (prev?.[jobId] && typeof prev[jobId] === 'object') {
|
||||
next[jobId] = prev[jobId];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return audiobookRows;
|
||||
} catch (error) {
|
||||
console.error('AudiobooksPage load jobs error:', error);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadingJobs(false);
|
||||
}
|
||||
@@ -183,17 +251,31 @@ export default function AudiobooksPage({
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
setJobProgress((prev) => ({
|
||||
...prev,
|
||||
[jobId]: {
|
||||
progress: payload?.progress ?? null,
|
||||
eta: payload?.eta ?? null,
|
||||
statusText: payload?.statusText ?? null,
|
||||
state: payload?.state ?? null,
|
||||
currentChapter: payload?.contextPatch?.currentChapter ?? null,
|
||||
completedChapterCount: payload?.contextPatch?.completedChapterCount ?? null
|
||||
}
|
||||
}));
|
||||
setJobProgress((prev) => {
|
||||
const previousEntry = prev?.[jobId] && typeof prev[jobId] === 'object' ? prev[jobId] : {};
|
||||
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
|
||||
? payload.contextPatch
|
||||
: null;
|
||||
const nextCurrentChapter = hasOwn(contextPatch, 'currentChapter')
|
||||
? (contextPatch?.currentChapter ?? null)
|
||||
: (previousEntry?.currentChapter ?? null);
|
||||
const nextCompletedChapterCount = hasOwn(contextPatch, 'completedChapterCount')
|
||||
? (Number.isFinite(Number(contextPatch?.completedChapterCount))
|
||||
? Number(contextPatch.completedChapterCount)
|
||||
: null)
|
||||
: (previousEntry?.completedChapterCount ?? null);
|
||||
return {
|
||||
...prev,
|
||||
[jobId]: {
|
||||
progress: payload?.progress ?? previousEntry?.progress ?? null,
|
||||
eta: payload?.eta ?? previousEntry?.eta ?? null,
|
||||
statusText: payload?.statusText ?? previousEntry?.statusText ?? null,
|
||||
state: payload?.state ?? previousEntry?.state ?? null,
|
||||
currentChapter: nextCurrentChapter,
|
||||
completedChapterCount: nextCompletedChapterCount
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
if (
|
||||
message.type === 'PIPELINE_UPDATE'
|
||||
@@ -262,7 +344,7 @@ export default function AudiobooksPage({
|
||||
}
|
||||
setActionBusy(normalizedJobId, true);
|
||||
try {
|
||||
await api.cancelJob(normalizedJobId);
|
||||
await api.cancelPipeline(normalizedJobId);
|
||||
toastRef.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Abbruch angefordert',
|
||||
@@ -282,39 +364,6 @@ export default function AudiobooksPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
setActionBusy(normalizedJobId, true);
|
||||
try {
|
||||
const response = await api.retryJob(normalizedJobId);
|
||||
const retryJobId = normalizeJobId(response?.retryJob?.id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Retry erstellt',
|
||||
detail: retryJobId
|
||||
? `Neuer Job #${retryJobId} wurde angelegt.`
|
||||
: `Retry fuer Job #${normalizedJobId} wurde angelegt.`,
|
||||
life: 3200
|
||||
});
|
||||
await loadJobs();
|
||||
if (retryJobId) {
|
||||
setExpandedJobId(retryJobId);
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Retry fehlgeschlagen',
|
||||
detail: error?.message || 'Bitte Logs pruefen.',
|
||||
life: 4200
|
||||
});
|
||||
} finally {
|
||||
setActionBusy(normalizedJobId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
@@ -357,11 +406,14 @@ export default function AudiobooksPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploaded = async (uploadedJobId) => {
|
||||
const normalizedJobId = normalizeJobId(uploadedJobId);
|
||||
await loadJobs();
|
||||
if (normalizedJobId) {
|
||||
setExpandedJobId(normalizedJobId);
|
||||
const handleUploaded = async (uploadedJobId, response = null) => {
|
||||
const normalizedJobId = normalizeJobId(uploadedJobId)
|
||||
|| extractUploadJobIdFromResponse(response);
|
||||
const rows = await loadJobs();
|
||||
const fallbackJobId = normalizeJobId(rows?.[0]?.id);
|
||||
const targetJobId = normalizedJobId || fallbackJobId;
|
||||
if (targetJobId) {
|
||||
setExpandedJobId(targetJobId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -393,6 +445,7 @@ export default function AudiobooksPage({
|
||||
<AudiobookUploadPanel
|
||||
audiobookUpload={audiobookUpload}
|
||||
onAudiobookUpload={onAudiobookUpload}
|
||||
onCancelUpload={onCancelAudiobookUpload}
|
||||
onUploaded={handleUploaded}
|
||||
/>
|
||||
</Card>
|
||||
@@ -417,7 +470,17 @@ export default function AudiobooksPage({
|
||||
const progressValue = Number.isFinite(Number(pipelineForJob?.progress))
|
||||
? Math.max(0, Math.min(100, Number(pipelineForJob.progress)))
|
||||
: 0;
|
||||
const showProgress = ['ANALYZING', 'ENCODING'].includes(state) && progressValue > 0;
|
||||
const isRunning = ['ANALYZING', 'ENCODING'].includes(state);
|
||||
const showProgress = isRunning || progressValue > 0;
|
||||
const progressLabel = `${Math.trunc(progressValue)}%`;
|
||||
const etaLabel = String(pipelineForJob?.eta || '').trim();
|
||||
const currentChapter = pipelineForJob?.context?.currentChapter && typeof pipelineForJob.context.currentChapter === 'object'
|
||||
? pipelineForJob.context.currentChapter
|
||||
: null;
|
||||
const chapterLabel = currentChapter?.index && currentChapter?.total
|
||||
? ` | Kapitel ${currentChapter.index}/${currentChapter.total}${currentChapter.title ? `: ${currentChapter.title}` : ''}`
|
||||
: '';
|
||||
const metaLine = buildAudiobookJobMetaLine(jobId, pipelineForJob?.context?.selectedMetadata, job?.year);
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
@@ -427,25 +490,30 @@ export default function AudiobooksPage({
|
||||
className="ripper-job-row"
|
||||
onClick={() => setExpandedJobId(jobId)}
|
||||
>
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
|
||||
{(job?.poster_url && job.poster_url !== 'N/A') ? (
|
||||
<img src={job.poster_url} alt={title} className="poster-thumb" />
|
||||
) : (
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
|
||||
)}
|
||||
<div className="ripper-job-row-content">
|
||||
<div className="ripper-job-row-main">
|
||||
<strong className="ripper-job-title-line">
|
||||
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
|
||||
<span>{title}</span>
|
||||
</strong>
|
||||
<small>#{jobId}</small>
|
||||
<small>{metaLine}</small>
|
||||
</div>
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
|
||||
<Tag value="Audiobook" severity="info" />
|
||||
</div>
|
||||
{showProgress ? (
|
||||
<div className="ripper-job-row-progress">
|
||||
<ProgressBar value={progressValue} showValue={false} />
|
||||
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<i className="pi pi-angle-down" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -453,25 +521,22 @@ export default function AudiobooksPage({
|
||||
return (
|
||||
<div key={jobId} className="ripper-job-expanded">
|
||||
<div className="ripper-job-expanded-head">
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
|
||||
{(job?.poster_url && job.poster_url !== 'N/A') ? (
|
||||
<img src={job.poster_url} alt={title} className="poster-thumb" />
|
||||
) : (
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
|
||||
)}
|
||||
<div className="ripper-job-expanded-title">
|
||||
<strong className="ripper-job-title-line">
|
||||
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
|
||||
<span>#{jobId} | {title}</span>
|
||||
<span>{title}</span>
|
||||
</strong>
|
||||
<small className="ripper-job-subtitle">{metaLine}</small>
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
|
||||
<Tag value="Audiobook" severity="info" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<Button
|
||||
label="Im Ripper"
|
||||
icon="pi pi-arrow-right"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => navigate('/ripper')}
|
||||
/>
|
||||
<Button
|
||||
label="Einklappen"
|
||||
icon="pi pi-angle-up"
|
||||
@@ -482,11 +547,16 @@ export default function AudiobooksPage({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showProgress ? (
|
||||
<div className="ripper-job-row-progress">
|
||||
<ProgressBar value={progressValue} showValue={false} />
|
||||
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
|
||||
</div>
|
||||
) : null}
|
||||
<AudiobookConfigPanel
|
||||
pipeline={pipelineForJob}
|
||||
onStart={(config) => void handleAudiobookStart(jobId, config)}
|
||||
onCancel={() => void handleCancel(jobId)}
|
||||
onRetry={() => void handleRetry(jobId)}
|
||||
onDeleteJob={() => void handleDelete(jobId)}
|
||||
busy={busy}
|
||||
/>
|
||||
@@ -496,13 +566,6 @@ export default function AudiobooksPage({
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Audiobook Output Explorer"
|
||||
subTitle="Read-only Explorer auf dem Audiobook-Ausgabeordner"
|
||||
>
|
||||
<AudiobookOutputExplorer refreshToken={explorerRefreshToken} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -507,7 +507,7 @@ export default function ConverterPage() {
|
||||
imdbId: row?.imdb_id || metadata?.imdbId || null,
|
||||
poster: row?.poster_url || metadata?.poster || null
|
||||
},
|
||||
omdbCandidates: []
|
||||
metadataCandidates: []
|
||||
});
|
||||
setMetadataDialogVisible(true);
|
||||
};
|
||||
@@ -537,9 +537,9 @@ export default function ConverterPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOmdbSearch = async (query) => {
|
||||
const handleMetadataSearch = async (query) => {
|
||||
try {
|
||||
const response = await api.searchOmdb(query);
|
||||
const response = await api.searchTmdbMovie(query);
|
||||
return Array.isArray(response?.results) ? response.results : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
@@ -978,7 +978,7 @@ export default function ConverterPage() {
|
||||
onExpand={() => setExpandedJobId(Number(job.id))}
|
||||
onCollapse={() => setExpandedJobId(null)}
|
||||
onOpenMetadata={handleOpenMetadataDialog}
|
||||
onReassignOmdb={handleOpenMetadataDialog}
|
||||
onReassignMetadata={handleOpenMetadataDialog}
|
||||
onStart={handleStartPipelineJob}
|
||||
onConfirmReview={handleConfirmReview}
|
||||
onSelectPlaylist={handleSelectPlaylist}
|
||||
@@ -1027,7 +1027,7 @@ export default function ConverterPage() {
|
||||
setMetadataDialogContext(null);
|
||||
}}
|
||||
onSubmit={handleMetadataSubmit}
|
||||
onSearch={handleOmdbSearch}
|
||||
onSearch={handleMetadataSearch}
|
||||
busy={metadataDialogBusy}
|
||||
/>
|
||||
|
||||
@@ -1196,7 +1196,7 @@ function ConverterVideoJobCard({
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onOpenMetadata,
|
||||
onReassignOmdb,
|
||||
onReassignMetadata,
|
||||
onStart,
|
||||
onConfirmReview,
|
||||
onSelectPlaylist,
|
||||
@@ -1282,7 +1282,7 @@ function ConverterVideoJobCard({
|
||||
jobRow={job}
|
||||
pipeline={pipeline}
|
||||
onOpenMetadata={onOpenMetadata}
|
||||
onReassignOmdb={onReassignOmdb}
|
||||
onReassignMetadata={onReassignMetadata}
|
||||
onStart={onStart}
|
||||
onConfirmReview={onConfirmReview}
|
||||
onSelectPlaylist={onSelectPlaylist}
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function DatabasePage() {
|
||||
const [orphanRows, setOrphanRows] = useState([]);
|
||||
const [orphanLoading, setOrphanLoading] = useState(false);
|
||||
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
|
||||
const [orphanDeleteBusyPath, setOrphanDeleteBusyPath] = useState(null);
|
||||
const toastRef = useRef(null);
|
||||
const orphanLoadRequestRef = useRef(0);
|
||||
|
||||
@@ -130,6 +131,50 @@ export default function DatabasePage() {
|
||||
setOrphanImportBusyPath(null);
|
||||
}
|
||||
};
|
||||
const handleDeleteOrphanRaw = async (row) => {
|
||||
const target = String(row?.rawPath || row?.folderName || '-').trim() || '-';
|
||||
const confirmed = await confirmModal({
|
||||
header: 'RAW löschen',
|
||||
message:
|
||||
`RAW-Ordner "${target}" wirklich löschen?\n` +
|
||||
'Der Ordner wird dauerhaft aus dem Dateisystem entfernt und verschwindet aus der /database-Liste.',
|
||||
acceptLabel: 'RAW löschen',
|
||||
rejectLabel: 'Abbrechen',
|
||||
danger: true
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOrphanDeleteBusyPath(row.rawPath);
|
||||
try {
|
||||
const response = await api.deleteOrphanRawFolder(row.rawPath);
|
||||
const deletedRawPath = String(response?.rawPath || row?.rawPath || '').trim();
|
||||
const deletedFiles = Number(response?.filesDeleted || 0);
|
||||
const deletedDirs = Number(response?.dirsRemoved || 0);
|
||||
if (deletedRawPath) {
|
||||
setOrphanRows((previousRows) => (
|
||||
(Array.isArray(previousRows) ? previousRows : []).filter((entry) => String(entry?.rawPath || '').trim() !== deletedRawPath)
|
||||
));
|
||||
}
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'RAW gelöscht',
|
||||
detail: `Ordner entfernt | Dateien: ${deletedFiles}, Ordner: ${deletedDirs}`,
|
||||
life: 3500
|
||||
});
|
||||
await loadOrphans({ silent: true });
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Löschen fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setOrphanDeleteBusyPath(null);
|
||||
}
|
||||
};
|
||||
const orphanTitleBody = (row) => (
|
||||
<div>
|
||||
<div><strong>{row.title || '-'}</strong></div>
|
||||
@@ -145,14 +190,26 @@ export default function DatabasePage() {
|
||||
</div>
|
||||
);
|
||||
const orphanActionBody = (row) => (
|
||||
<Button
|
||||
label="Job anlegen"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
onClick={() => handleImportOrphanRaw(row)}
|
||||
loading={orphanImportBusyPath === row.rawPath}
|
||||
disabled={Boolean(orphanImportBusyPath)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
label="Job anlegen"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
onClick={() => handleImportOrphanRaw(row)}
|
||||
loading={orphanImportBusyPath === row.rawPath}
|
||||
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
|
||||
/>
|
||||
<Button
|
||||
label="RAW löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => handleDeleteOrphanRaw(row)}
|
||||
loading={orphanDeleteBusyPath === row.rawPath}
|
||||
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -169,7 +226,7 @@ export default function DatabasePage() {
|
||||
icon="pi pi-search"
|
||||
onClick={loadOrphans}
|
||||
loading={orphanLoading}
|
||||
disabled={Boolean(orphanImportBusyPath)}
|
||||
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
|
||||
/>
|
||||
<Tag value={`${orphanRows.length} gefunden`} severity={orphanRows.length > 0 ? 'warning' : 'success'} />
|
||||
</div>
|
||||
@@ -190,7 +247,7 @@ export default function DatabasePage() {
|
||||
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
|
||||
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
|
||||
<Column field="lastModifiedAt" header="Geändert" style={{ width: '16rem' }} />
|
||||
<Column header="Aktion" body={orphanActionBody} style={{ width: '10rem' }} />
|
||||
<Column header="Aktion" body={orphanActionBody} style={{ minWidth: '18rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
+208
-184
@@ -9,6 +9,7 @@ import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import JobDetailDialog from '../components/JobDetailDialog';
|
||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||
import CdMetadataDialog from '../components/CdMetadataDialog';
|
||||
@@ -459,33 +460,63 @@ function sanitizeRating(value) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
function findOmdbRatingBySource(omdbInfo, sourceName) {
|
||||
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
|
||||
const source = String(sourceName || '').trim().toLowerCase();
|
||||
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === source);
|
||||
return sanitizeRating(entry?.Value);
|
||||
function normalizeRatingValue(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
const numericValue = Number(value);
|
||||
if (Number.isFinite(numericValue) && numericValue > 0) {
|
||||
return numericValue.toFixed(1);
|
||||
}
|
||||
return sanitizeRating(value);
|
||||
}
|
||||
|
||||
function resolveRatings(row) {
|
||||
const omdbInfo = row?.omdbInfo && typeof row.omdbInfo === 'object' ? row.omdbInfo : null;
|
||||
if (!omdbInfo) {
|
||||
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
|
||||
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
|
||||
? makemkvInfo.analyzeContext
|
||||
: {};
|
||||
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||
? analyzeContext.selectedMetadata
|
||||
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {});
|
||||
const planMetadata = encodePlan?.metadata && typeof encodePlan.metadata === 'object'
|
||||
? encodePlan.metadata
|
||||
: {};
|
||||
const selectedTmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
|
||||
? selectedMetadata.tmdbDetails
|
||||
: null;
|
||||
const planTmdbDetails = planMetadata?.tmdbDetails && typeof planMetadata.tmdbDetails === 'object'
|
||||
? planMetadata.tmdbDetails
|
||||
: null;
|
||||
const rowTmdbDetails = row?.tmdbDetails && typeof row.tmdbDetails === 'object'
|
||||
? row.tmdbDetails
|
||||
: null;
|
||||
const tmdbDetails = selectedTmdbDetails || planTmdbDetails || rowTmdbDetails;
|
||||
|
||||
const tmdbRating = normalizeRatingValue(
|
||||
tmdbDetails?.seasonVoteAverage
|
||||
?? tmdbDetails?.voteAverage
|
||||
?? tmdbDetails?.imdbRating
|
||||
?? selectedMetadata?.seasonVoteAverage
|
||||
?? selectedMetadata?.voteAverage
|
||||
?? selectedMetadata?.imdbRating
|
||||
?? planMetadata?.seasonVoteAverage
|
||||
?? planMetadata?.voteAverage
|
||||
?? planMetadata?.imdbRating
|
||||
?? row?.voteAverage
|
||||
?? row?.imdbRating
|
||||
);
|
||||
|
||||
if (!tmdbRating) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const imdb = sanitizeRating(omdbInfo?.imdbRating)
|
||||
|| findOmdbRatingBySource(omdbInfo, 'Internet Movie Database');
|
||||
const rotten = findOmdbRatingBySource(omdbInfo, 'Rotten Tomatoes');
|
||||
const metascore = sanitizeRating(omdbInfo?.Metascore);
|
||||
|
||||
const ratings = [];
|
||||
if (imdb) {
|
||||
ratings.push({ key: 'imdb', label: 'IMDb', value: imdb });
|
||||
}
|
||||
if (rotten) {
|
||||
ratings.push({ key: 'rt', label: 'RT', value: rotten });
|
||||
}
|
||||
if (metascore) {
|
||||
ratings.push({ key: 'meta', label: 'Meta', value: metascore });
|
||||
if (tmdbRating) {
|
||||
ratings.push({ key: 'tmdb', label: 'TMDb', value: tmdbRating });
|
||||
}
|
||||
return ratings;
|
||||
}
|
||||
@@ -553,7 +584,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [downloadFolderBusyPath, setDownloadFolderBusyPath] = useState(null);
|
||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
|
||||
const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
|
||||
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
||||
@@ -568,6 +599,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [conflictModalMode, setConflictModalMode] = useState('reencode'); // 'reencode' | 'delete'
|
||||
const [conflictModalBusy, setConflictModalBusy] = useState(false);
|
||||
const toastRef = useRef(null);
|
||||
const wsReloadTimerRef = useRef(null);
|
||||
const progressStateByJobRef = useRef(new Map());
|
||||
|
||||
const queuedJobIdSet = useMemo(() => {
|
||||
const next = new Set();
|
||||
@@ -638,6 +671,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleLiveReload = (delayMs = 120) => {
|
||||
if (wsReloadTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
wsReloadTimerRef.current = setTimeout(() => {
|
||||
wsReloadTimerRef.current = null;
|
||||
void load();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.getPref('history_layout').then((res) => {
|
||||
if (res?.value === 'list' || res?.value === 'grid') {
|
||||
@@ -654,6 +697,55 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, status, refreshToken]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (wsReloadTimerRef.current) {
|
||||
clearTimeout(wsReloadTimerRef.current);
|
||||
wsReloadTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
onMessage: (message) => {
|
||||
const type = String(message?.type || '').trim().toUpperCase();
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
type === 'PIPELINE_STATE_CHANGED'
|
||||
|| type === 'PIPELINE_QUEUE_CHANGED'
|
||||
|| type === 'DISC_DETECTED'
|
||||
|| type === 'DISC_REMOVED'
|
||||
) {
|
||||
scheduleLiveReload(80);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type !== 'PIPELINE_PROGRESS') {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = message?.payload && typeof message.payload === 'object'
|
||||
? message.payload
|
||||
: {};
|
||||
const normalizedJobId = normalizeJobId(payload?.activeJobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
const nextState = String(payload?.state || '').trim().toUpperCase();
|
||||
if (!nextState) {
|
||||
return;
|
||||
}
|
||||
const key = String(normalizedJobId);
|
||||
const prevState = progressStateByJobRef.current.get(key) || '';
|
||||
if (nextState === prevState) {
|
||||
return;
|
||||
}
|
||||
progressStateByJobRef.current.set(key, nextState);
|
||||
scheduleLiveReload(120);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const openJobId = Number(params.get('open') || 0);
|
||||
@@ -869,11 +961,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
} else if (action === 'restart_review') {
|
||||
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
|
||||
const isAudiobookJob = resolveMediaType(job) === 'audiobook';
|
||||
if (!skipConfirm) {
|
||||
const confirmed = await confirmModal({
|
||||
header: 'Review neu starten',
|
||||
message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`,
|
||||
acceptLabel: 'Review starten',
|
||||
header: isAudiobookJob ? 'Vorprüfung starten' : 'Review neu starten',
|
||||
message: isAudiobookJob
|
||||
? `Vorprüfung für "${title}" starten?\nDer Job wird neu angelegt und kann danach im Ripper vor dem Encode angepasst werden.`
|
||||
: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`,
|
||||
acceptLabel: isAudiobookJob ? 'Vorprüfung starten' : 'Review starten',
|
||||
rejectLabel: 'Abbrechen'
|
||||
});
|
||||
if (!confirmed) {
|
||||
@@ -887,14 +982,21 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const replacementJobId = normalizeJobId(result?.jobId);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Review-Neustart',
|
||||
detail: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.',
|
||||
summary: isAudiobookJob ? 'Vorprüfung gestartet' : 'Review-Neustart',
|
||||
detail: isAudiobookJob
|
||||
? 'Job neu angelegt. Im Ripper können die Encode-Einstellungen angepasst werden.'
|
||||
: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.',
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
await refreshDetailAfterReplacement(job.id, replacementJobId);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: isAudiobookJob ? 'Vorprüfung fehlgeschlagen' : 'Review-Neustart fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
@@ -903,7 +1005,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!skipConfirm) {
|
||||
const confirmed = await confirmModal({
|
||||
header: 'CD-Vorprüfung starten',
|
||||
message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`,
|
||||
message: `CD-Vorprüfung für "${title}" starten?\nTrackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`,
|
||||
acceptLabel: 'Vorprüfung starten',
|
||||
rejectLabel: 'Abbrechen'
|
||||
});
|
||||
@@ -917,7 +1019,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'CD-Vorprüfung gestartet',
|
||||
detail: 'Job ist jetzt im Ripper verfügbar — bitte Metadaten und Einstellungen wählen.',
|
||||
detail: 'Job ist jetzt im Ripper verfügbar — bitte Tracks und Einstellungen prüfen.',
|
||||
life: 4000
|
||||
});
|
||||
await load();
|
||||
@@ -1129,8 +1231,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadOutputFolder = async (row, folderPath) => {
|
||||
const jobId = Number(row?.id || selectedJob?.id || 0);
|
||||
const handleDownloadOutputFolder = async (row, folderPath, ownerJobId = null) => {
|
||||
const jobId = Number(ownerJobId || row?.id || selectedJob?.id || 0);
|
||||
if (!jobId || !folderPath) return;
|
||||
setDownloadFolderBusyPath(folderPath);
|
||||
try {
|
||||
@@ -1269,7 +1371,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignOmdb = (row) => {
|
||||
const handleAssignMetadata = (row) => {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) return;
|
||||
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||
@@ -1291,11 +1393,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
|
||||
const rowMediaType = resolveMediaType(row);
|
||||
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
|
||||
const metadataProvider = workflowKind === 'series'
|
||||
? 'tmdb'
|
||||
: (workflowKind === 'film'
|
||||
? 'omdb'
|
||||
: (String(selectedMetadata?.metadataProvider || analyzeContext?.metadataProvider || (isSeriesDvd ? 'tmdb' : 'omdb')).trim().toLowerCase() || 'omdb'));
|
||||
const metadataProvider = 'tmdb';
|
||||
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
|
||||
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
|
||||
const context = {
|
||||
@@ -1326,47 +1424,63 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
...(discNumber ? { discNumber } : {})
|
||||
},
|
||||
metadataProvider,
|
||||
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
|
||||
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
|
||||
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
|
||||
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
|
||||
omdbCandidates: []
|
||||
seriesDecision: analyzeContext?.seriesDecision || null
|
||||
};
|
||||
setMetadataDialogContext(context);
|
||||
setMetadataDialogVisible(true);
|
||||
};
|
||||
|
||||
const handleOmdbSearch = async (query, options = {}) => {
|
||||
const provider = String(
|
||||
options?.metadataProvider
|
||||
|| metadataDialogContext?.metadataProvider
|
||||
|| 'omdb'
|
||||
).trim().toLowerCase() || 'omdb';
|
||||
const handleMetadataSearch = async (query, options = {}) => {
|
||||
const workflow = String(
|
||||
options?.workflowKind
|
||||
|| metadataDialogContext?.selectedMetadata?.workflowKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
try {
|
||||
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
|
||||
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
|
||||
?? null;
|
||||
const response = provider === 'tmdb'
|
||||
const response = workflow === 'series'
|
||||
? await api.searchTmdbSeries(query, tmdbSeasonHint)
|
||||
: await api.searchOmdb(query);
|
||||
: await api.searchTmdbMovie(query);
|
||||
return response.results || [];
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: provider === 'tmdb' ? 'TMDb-Suche fehlgeschlagen' : 'OMDb-Suche fehlgeschlagen',
|
||||
summary: 'TMDb-Suche fehlgeschlagen',
|
||||
detail: error.message
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleOmdbSubmit = async (payload) => {
|
||||
const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
|
||||
const handleMetadataSubmit = async (payload) => {
|
||||
const submitMode = String(metadataDialogContext?.submitMode || 'assign').trim().toLowerCase() || 'assign';
|
||||
const isPipelineFlow = submitMode === 'pipeline';
|
||||
setOmdbAssignBusy(true);
|
||||
setMetadataAssignBusy(true);
|
||||
try {
|
||||
if (isPipelineFlow) {
|
||||
await api.selectMetadata(payload);
|
||||
const selectResponse = await api.selectMetadata(payload);
|
||||
const selectedJobUpdate = selectResponse?.job && typeof selectResponse.job === 'object'
|
||||
? selectResponse.job
|
||||
: null;
|
||||
if (selectedJobUpdate?.id) {
|
||||
const normalizedSelectedId = Number(selectedJobUpdate.id);
|
||||
setJobs((prev) => (
|
||||
Array.isArray(prev)
|
||||
? prev.map((row) => (Number(row?.id || 0) === normalizedSelectedId ? { ...row, ...selectedJobUpdate } : row))
|
||||
: prev
|
||||
));
|
||||
setSelectedJob((prev) => (
|
||||
Number(prev?.id || 0) === normalizedSelectedId
|
||||
? { ...(prev || {}), ...selectedJobUpdate }
|
||||
: prev
|
||||
));
|
||||
}
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
|
||||
@@ -1408,11 +1522,27 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api.assignJobOmdb(payload.jobId, payload);
|
||||
const assignResponse = await api.assignJobMetadata(payload.jobId, payload);
|
||||
const assignedJobUpdate = assignResponse?.job && typeof assignResponse.job === 'object'
|
||||
? assignResponse.job
|
||||
: null;
|
||||
if (assignedJobUpdate?.id) {
|
||||
const normalizedAssignedId = Number(assignedJobUpdate.id);
|
||||
setJobs((prev) => (
|
||||
Array.isArray(prev)
|
||||
? prev.map((row) => (Number(row?.id || 0) === normalizedAssignedId ? { ...row, ...assignedJobUpdate } : row))
|
||||
: prev
|
||||
));
|
||||
setSelectedJob((prev) => (
|
||||
Number(prev?.id || 0) === normalizedAssignedId
|
||||
? { ...(prev || {}), ...assignedJobUpdate }
|
||||
: prev
|
||||
));
|
||||
}
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Metadaten zugewiesen',
|
||||
detail: provider === 'tmdb' ? 'TMDb-Metadaten wurden aktualisiert.' : 'OMDb-Metadaten wurden aktualisiert.',
|
||||
detail: 'TMDb-Metadaten wurden aktualisiert.',
|
||||
life: 3000
|
||||
});
|
||||
setMetadataDialogVisible(false);
|
||||
@@ -1425,11 +1555,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen',
|
||||
summary: 'TMDb-Zuweisung fehlgeschlagen',
|
||||
detail: error.message
|
||||
});
|
||||
} finally {
|
||||
setOmdbAssignBusy(false);
|
||||
setMetadataAssignBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1451,9 +1581,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
setCdMetadataDialogVisible(true);
|
||||
};
|
||||
|
||||
const handleMusicBrainzSearch = async (query) => {
|
||||
const handleMusicBrainzSearch = async (query, options = {}) => {
|
||||
try {
|
||||
const response = await api.searchMusicBrainz(query);
|
||||
const response = await api.searchMusicBrainz(query, {
|
||||
trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null
|
||||
});
|
||||
return response.results || [];
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Suche fehlgeschlagen', detail: error.message });
|
||||
@@ -1594,71 +1726,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshDeleteEntryPreview = async (jobId, selectedJobIds) => {
|
||||
setDeleteEntryPreviewLoading(true);
|
||||
try {
|
||||
const response = await api.getJobDeletePreview(jobId, {
|
||||
includeRelated: deleteEntryIncludeRelated,
|
||||
selectedJobIds
|
||||
});
|
||||
const preview = response?.preview || null;
|
||||
const relatedJobs = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [];
|
||||
const nextSelectedJobIds = Array.isArray(preview?.selectedJobIds)
|
||||
? preview.selectedJobIds.map((id) => normalizeJobId(id)).filter(Boolean)
|
||||
: relatedJobs
|
||||
.map((item) => normalizeJobId(item?.id))
|
||||
.filter(Boolean);
|
||||
setDeleteEntryPreview(preview);
|
||||
setDeleteEntrySelectedJobIds(nextSelectedJobIds);
|
||||
const rawCandidates = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
|
||||
const defaultSelectedRawPaths = rawCandidates
|
||||
.filter((item) => Boolean(item?.exists))
|
||||
.map((item) => String(item?.path || '').trim())
|
||||
.filter(Boolean);
|
||||
setDeleteEntrySelectedRawPaths(defaultSelectedRawPaths);
|
||||
const movieCandidates = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
|
||||
const defaultSelectedMoviePaths = movieCandidates
|
||||
.filter((item) => Boolean(item?.exists))
|
||||
.map((item) => String(item?.path || '').trim())
|
||||
.filter(Boolean);
|
||||
setDeleteEntrySelectedMoviePaths(defaultSelectedMoviePaths);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Löschvorschau fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDeleteEntryPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeleteRelatedJobSelection = async (relatedJobId, checked) => {
|
||||
const normalizedRelatedJobId = normalizeJobId(relatedJobId);
|
||||
const dialogJobId = normalizeJobId(deleteEntryDialogRow?.id);
|
||||
if (!normalizedRelatedJobId || !dialogJobId) {
|
||||
return;
|
||||
}
|
||||
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
const knownJobIds = relatedJobs
|
||||
.map((item) => normalizeJobId(item?.id))
|
||||
.filter(Boolean);
|
||||
const currentSelected = new Set(
|
||||
deleteEntrySelectedJobIds
|
||||
.map((item) => normalizeJobId(item))
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (checked) {
|
||||
currentSelected.add(normalizedRelatedJobId);
|
||||
} else {
|
||||
currentSelected.delete(normalizedRelatedJobId);
|
||||
}
|
||||
const orderedSelected = knownJobIds.filter((jobId) => currentSelected.has(jobId));
|
||||
setDeleteEntrySelectedJobIds(orderedSelected);
|
||||
await refreshDeleteEntryPreview(dialogJobId, orderedSelected);
|
||||
};
|
||||
|
||||
const confirmDeleteEntry = async (target) => {
|
||||
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
|
||||
@@ -1672,16 +1739,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Historie-Auswahl erforderlich',
|
||||
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(normalizedTarget === 'raw' || normalizedTarget === 'both')
|
||||
&& rawDeleteSelectionEnabled
|
||||
@@ -1761,16 +1818,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Historie-Auswahl erforderlich',
|
||||
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(normalizedTarget === 'raw' || normalizedTarget === 'both')
|
||||
&& rawDeleteSelectionEnabled
|
||||
@@ -2455,12 +2502,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
};
|
||||
|
||||
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
const previewSelectedJobIds = Array.isArray(deleteEntryPreview?.selectedJobIds)
|
||||
? deleteEntryPreview.selectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)
|
||||
: previewRelatedJobs
|
||||
.filter((item) => Boolean(item?.selected))
|
||||
.map((item) => normalizeJobId(item?.id))
|
||||
.filter(Boolean);
|
||||
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
|
||||
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
||||
const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
|
||||
@@ -2474,17 +2515,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
() => new Set(deleteEntrySelectedRawPaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||
[deleteEntrySelectedRawPaths]
|
||||
);
|
||||
const selectedDeleteRelatedJobSet = useMemo(
|
||||
() => new Set(deleteEntrySelectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)),
|
||||
[deleteEntrySelectedJobIds]
|
||||
);
|
||||
const selectedDeleteMoviePathSet = useMemo(
|
||||
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||
[deleteEntrySelectedMoviePaths]
|
||||
);
|
||||
const relatedDeleteSelectionRequired = deleteEntryIncludeRelated
|
||||
&& previewRelatedJobs.length > 0
|
||||
&& deleteEntrySelectedJobIds.length === 0;
|
||||
const rawDeleteSelectionRequired = !deleteEntryIsMergeJob
|
||||
&& rawDeleteSelectionEnabled
|
||||
&& deleteEntrySelectedRawPaths.length === 0;
|
||||
@@ -2571,7 +2605,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
onRestartCdReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartCdReview}
|
||||
onReencode={resolveMediaType(selectedJob) === 'converter' ? null : handleReencode}
|
||||
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
|
||||
onAssignOmdb={handleAssignOmdb}
|
||||
onAssignMetadata={handleAssignMetadata}
|
||||
onAssignCdMetadata={handleAssignCdMetadata}
|
||||
onGenerateNfo={handleGenerateNfo}
|
||||
onAcknowledgeError={handleAcknowledgeError}
|
||||
@@ -2586,7 +2620,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
actionBusy={actionBusy}
|
||||
cancelBusy={actionBusy}
|
||||
restoreMergeBusy={actionBusy}
|
||||
omdbAssignBusy={omdbAssignBusy}
|
||||
metadataAssignBusy={metadataAssignBusy}
|
||||
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||
generateNfoBusy={generateNfoBusy}
|
||||
acknowledgeErrorBusy={acknowledgeErrorBusy}
|
||||
@@ -2612,7 +2646,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
modal
|
||||
>
|
||||
<p>
|
||||
{`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge verknüpft. Ausgewählt: ${deleteEntrySelectedJobIds.length || previewSelectedJobIds.length || 0}.`}
|
||||
{`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge im Lösch-Scope enthalten.`}
|
||||
</p>
|
||||
<small className="history-dv-subtle">
|
||||
{deleteEntryIsMergeJob
|
||||
@@ -2636,29 +2670,19 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
<ul className="history-delete-preview-list">
|
||||
{previewRelatedJobs.map((item) => (
|
||||
<li key={`delete-related-${item.id}`}>
|
||||
<label className="history-delete-preview-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDeleteRelatedJobSet.has(normalizeJobId(item?.id))}
|
||||
onChange={(event) => toggleDeleteRelatedJobSelection(item.id, Boolean(event.target.checked))}
|
||||
/>
|
||||
<span>
|
||||
<strong>#{item.id}</strong>
|
||||
{' '}| {item.title || '-'}
|
||||
{' '}| {item.roleLabel || 'Job'}
|
||||
{' '}| {getStatusLabel(item.status)}
|
||||
{' '}{item.isPrimary ? '(aktuell)' : '(verknüpft)'}
|
||||
</span>
|
||||
</label>
|
||||
<span>
|
||||
<strong>#{item.id}</strong>
|
||||
{' '}| {item.title || '-'}
|
||||
{' '}| {item.roleLabel || 'Job'}
|
||||
{' '}| {getStatusLabel(item.status)}
|
||||
{' '}{item.isPrimary ? '(aktuell)' : '(verknüpft)'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
|
||||
)}
|
||||
{relatedDeleteSelectionRequired ? (
|
||||
<small className="history-dv-subtle">Bitte mindestens einen Historien-Eintrag auswählen.</small>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!deleteEntryIsMergeJob ? (
|
||||
@@ -2751,7 +2775,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('movie')}
|
||||
loading={deleteEntryTargetBusy === 'movie'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -2762,7 +2786,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('raw')}
|
||||
loading={deleteEntryTargetBusy === 'raw'}
|
||||
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
|
||||
/>
|
||||
<Button
|
||||
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
|
||||
@@ -2771,7 +2795,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('movie')}
|
||||
loading={deleteEntryTargetBusy === 'movie'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||
/>
|
||||
<Button
|
||||
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
|
||||
@@ -2779,7 +2803,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
severity="danger"
|
||||
onClick={() => confirmDeleteEntry('both')}
|
||||
loading={deleteEntryTargetBusy === 'both'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -2791,7 +2815,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteFilesOnly('both')}
|
||||
loading={deleteEntryTargetBusy === 'files-both'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -2801,7 +2825,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('none')}
|
||||
loading={deleteEntryTargetBusy === 'none'}
|
||||
disabled={deleteTargetActionsDisabled || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
/>
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
@@ -2820,9 +2844,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
}}
|
||||
onSubmit={handleOmdbSubmit}
|
||||
onSearch={handleOmdbSearch}
|
||||
busy={omdbAssignBusy}
|
||||
onSubmit={handleMetadataSubmit}
|
||||
onSearch={handleMetadataSearch}
|
||||
busy={metadataAssignBusy}
|
||||
/>
|
||||
|
||||
<CdMetadataDialog
|
||||
|
||||
+601
-175
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,53 @@ function normalizeUserPresetDefaultsMap(raw = {}) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRunningChainIds(rawPayload) {
|
||||
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
|
||||
const active = Array.isArray(payload.active) ? payload.active : [];
|
||||
const ids = [];
|
||||
const seen = new Set();
|
||||
for (const item of active) {
|
||||
const type = String(item?.type || '').trim().toLowerCase();
|
||||
if (type !== 'chain') {
|
||||
continue;
|
||||
}
|
||||
const chainIdRaw = Number(item?.chainId || 0);
|
||||
if (!Number.isFinite(chainIdRaw) || chainIdRaw <= 0) {
|
||||
continue;
|
||||
}
|
||||
const chainId = Math.trunc(chainIdRaw);
|
||||
if (seen.has(chainId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(chainId);
|
||||
ids.push(chainId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function isLikelyNetworkRequestError(error) {
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
const status = Number(error?.status);
|
||||
if (Number.isFinite(status) && status > 0) {
|
||||
return false;
|
||||
}
|
||||
const message = String(error?.message || '').trim().toLowerCase();
|
||||
if (!message) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
message.includes('failed to fetch')
|
||||
|| message.includes('networkerror')
|
||||
|| message.includes('network error')
|
||||
|| message.includes('load failed')
|
||||
|| message.includes('timeout')
|
||||
|| message.includes('aborted')
|
||||
|| message.includes('socket hang up')
|
||||
);
|
||||
}
|
||||
|
||||
function isPresetCompatibleWithSlot(preset, slotMediaType) {
|
||||
const mediaType = String(preset?.mediaType || '').trim().toLowerCase();
|
||||
if (mediaType === 'all') {
|
||||
@@ -272,7 +319,8 @@ export default function SettingsPage() {
|
||||
const [chainSaving, setChainSaving] = useState(false);
|
||||
const [chainReordering, setChainReordering] = useState(false);
|
||||
const [chainListDragSourceId, setChainListDragSourceId] = useState(null);
|
||||
const [chainActionBusyId, setChainActionBusyId] = useState(null);
|
||||
const [chainActionBusyIds, setChainActionBusyIds] = useState(() => new Set());
|
||||
const [runningChainIds, setRunningChainIds] = useState([]);
|
||||
const [lastChainTestResult, setLastChainTestResult] = useState(null);
|
||||
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
|
||||
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
||||
@@ -328,6 +376,27 @@ export default function SettingsPage() {
|
||||
}
|
||||
return next;
|
||||
}, [userPresets, userPresetDefaultsDraft]);
|
||||
const runningChainIdSet = useMemo(
|
||||
() => new Set(Array.isArray(runningChainIds) ? runningChainIds : []),
|
||||
[runningChainIds]
|
||||
);
|
||||
|
||||
const setChainActionBusy = (chainId, busyFlag) => {
|
||||
const normalizedId = Number(chainId);
|
||||
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
|
||||
return;
|
||||
}
|
||||
const targetId = Math.trunc(normalizedId);
|
||||
setChainActionBusyIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (busyFlag) {
|
||||
next.add(targetId);
|
||||
} else {
|
||||
next.delete(targetId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const loadScripts = async ({ silent = false } = {}) => {
|
||||
if (!silent) {
|
||||
@@ -404,6 +473,15 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshRunningChains = async () => {
|
||||
try {
|
||||
const response = await api.getRuntimeActivities();
|
||||
setRunningChainIds(normalizeRunningChainIds(response));
|
||||
} catch (_error) {
|
||||
// ignore polling errors
|
||||
}
|
||||
};
|
||||
|
||||
const openNewUserPreset = () => {
|
||||
setUserPresetEditor({ open: true, id: null, name: '', mediaType: 'all', handbrakePreset: '', extraArgs: '', description: '' });
|
||||
setUserPresetErrors({});
|
||||
@@ -546,7 +624,8 @@ export default function SettingsPage() {
|
||||
const presetsPromise = api.getHandBrakePresets();
|
||||
const scriptsPromise = api.getScripts();
|
||||
const chainsPromise = api.getScriptChains();
|
||||
const [scriptsResponse, chainsResponse] = await Promise.allSettled([scriptsPromise, chainsPromise]);
|
||||
const runtimePromise = api.getRuntimeActivities();
|
||||
const [scriptsResponse, chainsResponse, runtimeResponse] = await Promise.allSettled([scriptsPromise, chainsPromise, runtimePromise]);
|
||||
if (scriptsResponse.status === 'fulfilled') {
|
||||
setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []);
|
||||
} else {
|
||||
@@ -559,6 +638,9 @@ export default function SettingsPage() {
|
||||
if (chainsResponse.status === 'fulfilled') {
|
||||
setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []);
|
||||
}
|
||||
if (runtimeResponse.status === 'fulfilled') {
|
||||
setRunningChainIds(normalizeRunningChainIds(runtimeResponse.value));
|
||||
}
|
||||
|
||||
presetsPromise
|
||||
.then((presetPayload) => {
|
||||
@@ -593,6 +675,25 @@ export default function SettingsPage() {
|
||||
loadUserPresetDefaults();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const pollRuntime = async () => {
|
||||
try {
|
||||
const response = await api.getRuntimeActivities();
|
||||
if (!cancelled) {
|
||||
setRunningChainIds(normalizeRunningChainIds(response));
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore polling errors
|
||||
}
|
||||
};
|
||||
const timer = setInterval(pollRuntime, 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dirtyKeys = useMemo(() => {
|
||||
const keys = new Set();
|
||||
const allKeys = new Set([...Object.keys(initialValues), ...Object.keys(draftValues)]);
|
||||
@@ -974,7 +1075,8 @@ export default function SettingsPage() {
|
||||
if (!Number.isFinite(chainId) || chainId <= 0) {
|
||||
return;
|
||||
}
|
||||
setChainActionBusyId(chainId);
|
||||
const normalizedChainId = Math.trunc(chainId);
|
||||
setChainActionBusy(normalizedChainId, true);
|
||||
setLastChainTestResult(null);
|
||||
try {
|
||||
const response = await api.testScriptChain(chainId);
|
||||
@@ -994,9 +1096,27 @@ export default function SettingsPage() {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isLikelyNetworkRequestError(error)) {
|
||||
try {
|
||||
const runtimeResponse = await api.getRuntimeActivities();
|
||||
const activeIds = normalizeRunningChainIds(runtimeResponse);
|
||||
setRunningChainIds(activeIds);
|
||||
if (activeIds.includes(normalizedChainId)) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Ketten-Test läuft weiter',
|
||||
detail: `"${chain?.name || chainId}" läuft im Hintergrund weiter. Der Test-Request wurde vorzeitig beendet (z.B. Timeout/Verbindungsabbruch).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (_runtimeError) {
|
||||
// ignore follow-up runtime fetch errors and show default error toast below
|
||||
}
|
||||
}
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Ketten-Test fehlgeschlagen', detail: error.message });
|
||||
} finally {
|
||||
setChainActionBusyId(null);
|
||||
setChainActionBusy(normalizedChainId, false);
|
||||
refreshRunningChains();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1121,7 +1241,7 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
const handleChainListDragStart = (event, chainId) => {
|
||||
if (chainSaving || chainsLoading || chainReordering || Boolean(chainActionBusyId)) {
|
||||
if (chainSaving || chainsLoading || chainReordering) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -1221,8 +1341,7 @@ export default function SettingsPage() {
|
||||
|| Boolean(scriptActionBusyId);
|
||||
const chainListDnDDisabled = chainSaving
|
||||
|| chainsLoading
|
||||
|| chainReordering
|
||||
|| Boolean(chainActionBusyId);
|
||||
|| chainReordering;
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
@@ -1555,6 +1674,10 @@ export default function SettingsPage() {
|
||||
<div className="script-order-list">
|
||||
{chains.map((chain, index) => {
|
||||
const isDragging = Number(chainListDragSourceId) === Number(chain.id);
|
||||
const chainId = Number(chain?.id);
|
||||
const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null;
|
||||
const isChainRuntimeRunning = normalizedChainId !== null && runningChainIdSet.has(normalizedChainId);
|
||||
const isChainActionBusy = normalizedChainId !== null && chainActionBusyIds.has(normalizedChainId);
|
||||
return (
|
||||
<div key={chain.id} className="script-order-wrapper">
|
||||
<div
|
||||
@@ -1596,15 +1719,15 @@ export default function SettingsPage() {
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => openChainEditor(chain)}
|
||||
disabled={chainReordering || Boolean(chainActionBusyId)}
|
||||
disabled={chainReordering}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-play"
|
||||
label="Test"
|
||||
severity="info"
|
||||
onClick={() => handleTestChain(chain)}
|
||||
loading={chainActionBusyId === chain.id}
|
||||
disabled={chainReordering || (Boolean(chainActionBusyId) && chainActionBusyId !== chain.id)}
|
||||
loading={isChainActionBusy}
|
||||
disabled={chainReordering || isChainActionBusy || isChainRuntimeRunning}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
@@ -1612,7 +1735,7 @@ export default function SettingsPage() {
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => handleDeleteChain(chain)}
|
||||
disabled={chainReordering || Boolean(chainActionBusyId)}
|
||||
disabled={chainReordering}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { api } from '../api/client';
|
||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||
import { isSeriesVideoJob } from '../utils/jobTaxonomy';
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function resolveMediaType(row) {
|
||||
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
|
||||
const candidates = [
|
||||
row?.mediaType,
|
||||
row?.media_type,
|
||||
row?.mediaProfile,
|
||||
row?.media_profile,
|
||||
encodePlan?.mediaProfile,
|
||||
row?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
row?.makemkvInfo?.mediaProfile,
|
||||
row?.mediainfoInfo?.mediaProfile
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const raw = String(candidate || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (raw === 'converter') {
|
||||
return 'converter';
|
||||
}
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function buildMetadataContextFromJob(row) {
|
||||
const jobId = normalizeJobId(row?.id);
|
||||
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
|
||||
? makemkvInfo.analyzeContext
|
||||
: {};
|
||||
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||
? analyzeContext.selectedMetadata
|
||||
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {});
|
||||
const workflowKindRaw = String(
|
||||
selectedMetadata?.workflowKind
|
||||
|| analyzeContext?.workflowKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw)
|
||||
? 'film'
|
||||
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
|
||||
const rowMediaType = resolveMediaType(row);
|
||||
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
|
||||
const metadataProvider = 'tmdb';
|
||||
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
|
||||
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
|
||||
|
||||
return {
|
||||
jobId,
|
||||
status: row?.status || null,
|
||||
lastState: row?.last_state || null,
|
||||
submitMode: 'assign',
|
||||
mediaProfile: rowMediaType,
|
||||
detectedTitle: row?.detected_title || row?.title || '',
|
||||
selectedMetadata: {
|
||||
...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}),
|
||||
title: row?.title || selectedMetadata?.title || row?.detected_title || '',
|
||||
year: row?.year || selectedMetadata?.year || null,
|
||||
imdbId: row?.imdb_id || selectedMetadata?.imdbId || null,
|
||||
poster: row?.poster_url || selectedMetadata?.poster || null,
|
||||
metadataProvider,
|
||||
workflowKind,
|
||||
tmdbId: selectedMetadata?.tmdbId || null,
|
||||
providerId: selectedMetadata?.providerId || null,
|
||||
metadataKind: selectedMetadata?.metadataKind || (isSeriesDvd ? 'season' : null),
|
||||
seasonNumber,
|
||||
seasonName: selectedMetadata?.seasonName || null,
|
||||
episodeCount: selectedMetadata?.episodeCount || 0,
|
||||
episodes: Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : [],
|
||||
...(discNumber ? { discNumber } : {})
|
||||
},
|
||||
metadataProvider,
|
||||
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
|
||||
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
|
||||
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
|
||||
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
|
||||
seriesDecision: analyzeContext?.seriesDecision || null
|
||||
};
|
||||
}
|
||||
|
||||
export default function TmdbMigrationPage() {
|
||||
const toastRef = useRef(null);
|
||||
const countdownIntervalRef = useRef(null);
|
||||
const nextJobTimeoutRef = useRef(null);
|
||||
const jobsRef = useRef([]);
|
||||
const runningRef = useRef(false);
|
||||
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||
const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
|
||||
const [cooldownRemaining, setCooldownRemaining] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
jobsRef.current = Array.isArray(jobs) ? jobs : [];
|
||||
}, [jobs]);
|
||||
|
||||
useEffect(() => {
|
||||
runningRef.current = running;
|
||||
}, [running]);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
countdownIntervalRef.current = null;
|
||||
}
|
||||
if (nextJobTimeoutRef.current) {
|
||||
clearTimeout(nextJobTimeoutRef.current);
|
||||
nextJobTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
clearTimers();
|
||||
}, [clearTimers]);
|
||||
|
||||
const loadPendingJobs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getTmdbMigrationPendingJobs({ limit: 1000 });
|
||||
const rows = Array.isArray(response?.jobs) ? response.jobs : [];
|
||||
setJobs(rows);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Laden fehlgeschlagen',
|
||||
detail: error?.message || 'TMDb-Migrationsliste konnte nicht geladen werden.'
|
||||
});
|
||||
setJobs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPendingJobs();
|
||||
}, [loadPendingJobs]);
|
||||
|
||||
const openJobInDialog = useCallback((job) => {
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
const context = buildMetadataContextFromJob(job);
|
||||
if (!context?.jobId) {
|
||||
return;
|
||||
}
|
||||
setMetadataDialogContext(context);
|
||||
setMetadataDialogVisible(true);
|
||||
}, []);
|
||||
|
||||
const scheduleNextJob = useCallback((nextJob, seconds = 10) => {
|
||||
clearTimers();
|
||||
const normalizedSeconds = Math.max(0, Math.trunc(Number(seconds || 0)));
|
||||
if (!nextJob) {
|
||||
setRunning(false);
|
||||
setCooldownRemaining(0);
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCooldownRemaining(normalizedSeconds);
|
||||
if (normalizedSeconds > 0) {
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
setCooldownRemaining((prev) => {
|
||||
const next = Number(prev || 0) - 1;
|
||||
return next > 0 ? next : 0;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
nextJobTimeoutRef.current = setTimeout(() => {
|
||||
clearTimers();
|
||||
setCooldownRemaining(0);
|
||||
if (!runningRef.current) {
|
||||
return;
|
||||
}
|
||||
openJobInDialog(nextJob);
|
||||
}, normalizedSeconds * 1000);
|
||||
}, [clearTimers, openJobInDialog]);
|
||||
|
||||
const startSequence = () => {
|
||||
const pending = jobsRef.current;
|
||||
if (!Array.isArray(pending) || pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
clearTimers();
|
||||
setRunning(true);
|
||||
setMetadataDialogVisible(true);
|
||||
setCooldownRemaining(0);
|
||||
openJobInDialog(pending[0]);
|
||||
};
|
||||
|
||||
const stopSequence = useCallback(() => {
|
||||
clearTimers();
|
||||
setRunning(false);
|
||||
setCooldownRemaining(0);
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
}, [clearTimers]);
|
||||
|
||||
const handleMetadataSearch = async (query, options = {}) => {
|
||||
const workflow = String(
|
||||
options?.workflowKind
|
||||
|| metadataDialogContext?.selectedMetadata?.workflowKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
try {
|
||||
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
|
||||
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
|
||||
?? null;
|
||||
const response = workflow === 'series'
|
||||
? await api.searchTmdbSeries(query, tmdbSeasonHint)
|
||||
: await api.searchTmdbMovie(query);
|
||||
return response?.results || [];
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'TMDb-Suche fehlgeschlagen',
|
||||
detail: error?.message || 'Suche fehlgeschlagen.'
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetadataSubmit = async (payload) => {
|
||||
const normalizedJobId = normalizeJobId(payload?.jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMetadataAssignBusy(true);
|
||||
clearTimers();
|
||||
setCooldownRemaining(0);
|
||||
|
||||
try {
|
||||
await api.assignJobMetadata(normalizedJobId, payload || {});
|
||||
|
||||
const remaining = jobsRef.current.filter((row) => normalizeJobId(row?.id) !== normalizedJobId);
|
||||
jobsRef.current = remaining;
|
||||
setJobs(remaining);
|
||||
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Job migriert',
|
||||
detail: `Job #${normalizedJobId} wurde auf TMDb aktualisiert.`,
|
||||
life: 2600
|
||||
});
|
||||
|
||||
if (remaining.length === 0) {
|
||||
setRunning(false);
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Migration abgeschlossen',
|
||||
detail: 'Alle ausstehenden Jobs wurden abgearbeitet.',
|
||||
life: 3200
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runningRef.current) {
|
||||
return;
|
||||
}
|
||||
scheduleNextJob(remaining[0], 10);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Migration fehlgeschlagen',
|
||||
detail: error?.message || 'Metadaten konnten nicht übernommen werden.'
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
setMetadataAssignBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const mediumTagBody = (row) => {
|
||||
const mediaType = resolveMediaType(row);
|
||||
const label = mediaType === 'bluray' ? 'Blu-ray' : mediaType === 'dvd' ? 'DVD' : mediaType === 'converter' ? 'Converter' : 'Other';
|
||||
return <Tag value={label} severity={mediaType === 'converter' ? 'info' : 'warning'} />;
|
||||
};
|
||||
|
||||
const titleBody = (row) => String(row?.title || row?.detected_title || `Job #${row?.id || '-'}`).trim() || '-';
|
||||
|
||||
const countdownProgressValue = useMemo(() => {
|
||||
if (cooldownRemaining <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const elapsed = 10 - Math.min(10, cooldownRemaining);
|
||||
return Math.max(0, Math.min(100, Math.round((elapsed / 10) * 100)));
|
||||
}, [cooldownRemaining]);
|
||||
|
||||
return (
|
||||
<div className="page-grid tmdb-migration-page">
|
||||
<Toast ref={toastRef} position="top-right" />
|
||||
|
||||
<Card title="TMDb Migration (Direktlink)" subTitle="Diese Seite ist nicht in der Navigation verlinkt.">
|
||||
<div className="tmdb-migration-toolbar">
|
||||
<div className="tmdb-migration-toolbar-left">
|
||||
<Tag value={`${jobs.length} offen`} severity={jobs.length > 0 ? 'warning' : 'success'} />
|
||||
{running ? <Tag value="Sequenz aktiv" severity="info" /> : null}
|
||||
</div>
|
||||
<div className="tmdb-migration-toolbar-actions">
|
||||
<Button
|
||||
label="Liste neu laden"
|
||||
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
|
||||
outlined
|
||||
onClick={() => {
|
||||
if (!metadataAssignBusy) {
|
||||
void loadPendingJobs();
|
||||
}
|
||||
}}
|
||||
disabled={metadataAssignBusy}
|
||||
/>
|
||||
{!running ? (
|
||||
<Button
|
||||
label="Start"
|
||||
icon="pi pi-play"
|
||||
onClick={startSequence}
|
||||
disabled={jobs.length === 0 || loading || metadataAssignBusy}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
label="Stop"
|
||||
icon="pi pi-stop"
|
||||
severity="secondary"
|
||||
onClick={stopSequence}
|
||||
disabled={metadataAssignBusy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{running && cooldownRemaining > 0 ? (
|
||||
<div className="tmdb-migration-countdown">
|
||||
<ProgressSpinner style={{ width: '2rem', height: '2rem' }} strokeWidth="5" />
|
||||
<div className="tmdb-migration-countdown-copy">
|
||||
<strong>Nächste Suche startet in {cooldownRemaining}s</strong>
|
||||
<small>API-Wartezeit aktiv (mind. 10 Sekunden zwischen Jobs).</small>
|
||||
</div>
|
||||
<div className="tmdb-migration-countdown-progress" aria-label="Cooldown Fortschritt">
|
||||
<div className="tmdb-migration-countdown-bar" style={{ width: `${countdownProgressValue}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DataTable
|
||||
value={jobs}
|
||||
loading={loading}
|
||||
dataKey="id"
|
||||
emptyMessage="Keine offenen Jobs für TMDb-Migration."
|
||||
className="tmdb-migration-table"
|
||||
rows={50}
|
||||
paginator={jobs.length > 50}
|
||||
>
|
||||
<Column field="id" header="ID" style={{ width: '6rem' }} body={(row) => `#${row?.id || '-'}`} />
|
||||
<Column header="Titel" body={titleBody} />
|
||||
<Column field="year" header="Jahr" style={{ width: '8rem' }} body={(row) => row?.year || '-'} />
|
||||
<Column header="Medium" body={mediumTagBody} style={{ width: '10rem' }} />
|
||||
<Column
|
||||
header="Status"
|
||||
style={{ width: '14rem' }}
|
||||
body={(row) => String(row?.status || row?.last_state || '-').trim() || '-'}
|
||||
/>
|
||||
</DataTable>
|
||||
</Card>
|
||||
|
||||
<MetadataSelectionDialog
|
||||
visible={metadataDialogVisible}
|
||||
context={metadataDialogContext}
|
||||
onHide={stopSequence}
|
||||
onSubmit={handleMetadataSubmit}
|
||||
onSearch={handleMetadataSearch}
|
||||
busy={metadataAssignBusy || (running && cooldownRemaining > 0)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+199
-58
@@ -983,6 +983,62 @@ body {
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.tmdb-migration-page .p-card .p-card-content {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.tmdb-migration-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tmdb-migration-toolbar-left,
|
||||
.tmdb-migration-toolbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tmdb-migration-countdown {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.65rem 0.75rem;
|
||||
align-items: center;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--surface-section);
|
||||
padding: 0.65rem 0.8rem;
|
||||
}
|
||||
|
||||
.tmdb-migration-countdown-copy {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.tmdb-migration-countdown-copy small {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.tmdb-migration-countdown-progress {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
height: 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tmdb-migration-countdown-bar {
|
||||
height: 100%;
|
||||
background: var(--primary-color);
|
||||
transition: width 180ms linear;
|
||||
}
|
||||
|
||||
.queue-job-expand-btn {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
@@ -1300,7 +1356,7 @@ body {
|
||||
|
||||
.aax-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-top: 1px solid var(--rip-border);
|
||||
@@ -1328,6 +1384,60 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.aax-file-status {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.aax-file-status .p-progressbar {
|
||||
height: 0.35rem;
|
||||
}
|
||||
|
||||
.audiobook-upload-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.audiobook-upload-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.audiobook-upload-header-actions .p-button.p-button-icon-only {
|
||||
width: 2.35rem !important;
|
||||
height: 2.35rem !important;
|
||||
}
|
||||
|
||||
.audiobook-upload-header-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.audiobook-upload-header-status-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.audiobook-upload-header-status-top small {
|
||||
color: var(--rip-muted);
|
||||
}
|
||||
|
||||
.audiobook-upload-inline-text {
|
||||
color: var(--rip-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.audiobook-upload-status {
|
||||
margin-top: 0.8rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
@@ -1379,6 +1489,17 @@ body {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.audiobook-upload-header-status {
|
||||
min-width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.audiobook-upload-header-status-top {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.ripper-job-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3552,14 +3673,14 @@ body {
|
||||
background: #f6ebd6;
|
||||
}
|
||||
|
||||
.omdb-row {
|
||||
.metadata-row {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.omdb-row > div {
|
||||
.metadata-row > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -3793,7 +3914,8 @@ body {
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
@@ -3801,6 +3923,44 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.post-script-box > small:last-child {
|
||||
display: block;
|
||||
min-height: 2.4em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.post-script-box .actions-row,
|
||||
.post-script-box .encode-item-add-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.post-script-box .actions-row .p-button,
|
||||
.post-script-box .encode-item-add-row .p-button {
|
||||
width: 100%;
|
||||
min-height: 2.35rem;
|
||||
}
|
||||
|
||||
.post-script-box .actions-row > .p-button:only-child,
|
||||
.post-script-box .encode-item-add-row > .p-button:only-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 1675px) {
|
||||
.post-script-box .actions-row,
|
||||
.post-script-box .encode-item-add-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.post-script-box .actions-row > .p-button:only-child,
|
||||
.post-script-box .encode-item-add-row > .p-button:only-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.post-script-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -5119,6 +5279,17 @@ body {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.encode-automation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.encode-automation-grid > :only-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.audiobook-config-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -5152,13 +5323,6 @@ body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.audiobook-config-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.audiobook-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -5189,9 +5353,7 @@ body {
|
||||
.audiobook-chapter-list {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
padding-right: 0.25rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.audiobook-chapter-row {
|
||||
@@ -5224,47 +5386,13 @@ body {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.audiobook-chapter-status-list {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
padding-right: 0.25rem;
|
||||
.audiobook-chapter-status .cd-track-table {
|
||||
min-width: 38rem;
|
||||
}
|
||||
|
||||
.audiobook-chapter-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.chapter-status-done {
|
||||
background: var(--green-50, #f0fdf4);
|
||||
color: var(--green-700, #15803d);
|
||||
}
|
||||
|
||||
.chapter-status-active {
|
||||
background: var(--blue-50, #eff6ff);
|
||||
color: var(--blue-700, #1d4ed8);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chapter-status-pending {
|
||||
color: var(--rip-muted, #666);
|
||||
}
|
||||
|
||||
.chapter-status-nr {
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chapter-status-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.audiobook-chapter-status .cd-track-table td.title,
|
||||
.audiobook-chapter-status .cd-track-table th.title {
|
||||
min-width: 16rem;
|
||||
}
|
||||
|
||||
.audiobook-description-dialog p {
|
||||
@@ -5281,7 +5409,13 @@ body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
@media (max-width: 1600px) {
|
||||
.audiobook-chapter-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1350px) {
|
||||
.audiobook-config-summary {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -5294,6 +5428,17 @@ body {
|
||||
.audiobook-config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.audiobook-chapter-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.encode-automation-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ReencodeConflictModal ──────────────────────────────────────────────── */
|
||||
@@ -5723,10 +5868,6 @@ body {
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.audiobook-output-explorer .audiobook-output-row {
|
||||
grid-template-columns: minmax(180px, 2fr) minmax(96px, 0.8fr) minmax(160px, 1.2fr);
|
||||
}
|
||||
|
||||
/* ── Icon-Button (wie Klangkiste) ────────────────────────────────────────── */
|
||||
|
||||
.icon-button {
|
||||
|
||||
@@ -254,16 +254,14 @@ export function isSeriesVideoJob(job) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const metadataProvider = String(
|
||||
analyzeContext?.metadataProvider
|
||||
|| selectedMetadata?.metadataProvider
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const metadataKind = String(
|
||||
selectedMetadata?.metadataKind
|
||||
|| analyzeContext?.metadataKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
if (['film', 'movie', 'feature'].includes(metadataKind)) {
|
||||
return false;
|
||||
}
|
||||
const seasonNumberRaw = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
|
||||
const seasonNumberText = String(seasonNumberRaw ?? '').trim();
|
||||
const parsedSeasonNumber = Number(seasonNumberText.replace(',', '.'));
|
||||
@@ -279,15 +277,7 @@ export function isSeriesVideoJob(job) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tmdbIdRaw = selectedMetadata?.tmdbId
|
||||
?? analyzeContext?.tmdbId
|
||||
?? selectedMetadata?.providerId
|
||||
?? analyzeContext?.providerId
|
||||
?? null;
|
||||
const tmdbIdText = String(tmdbIdRaw ?? '').trim();
|
||||
const hasTmdbId = Boolean(tmdbIdText) && tmdbIdText !== '0';
|
||||
|
||||
return (metadataProvider === 'tmdb' || metadataProvider === 'themoviedb') && hasTmdbId;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isSeriesDvdJob(job) {
|
||||
|
||||
@@ -13,11 +13,11 @@ const STATUS_LABELS = {
|
||||
FINISHED: 'Fertig',
|
||||
CANCELLED: 'Abgebrochen',
|
||||
ERROR: 'Fehler',
|
||||
CD_ANALYZING: 'CD-Analyse',
|
||||
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
|
||||
CD_READY_TO_RIP: 'CD bereit (Rip/Encode)',
|
||||
CD_RIPPING: 'CD-Rippen',
|
||||
CD_ENCODING: 'CD-Encodieren'
|
||||
CD_ANALYZING: 'Analyse',
|
||||
CD_METADATA_SELECTION: 'Metadatenauswahl',
|
||||
CD_READY_TO_RIP: 'Bereit zum Encodieren',
|
||||
CD_RIPPING: 'Rippen',
|
||||
CD_ENCODING: 'Encodieren'
|
||||
};
|
||||
|
||||
const PROCESS_STATUS_LABELS = {
|
||||
|
||||
Reference in New Issue
Block a user