diff --git a/backend/package-lock.json b/backend/package-lock.json
index 00f7090..a7298fa 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
- "version": "0.10.0-6",
+ "version": "0.10.0-7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
- "version": "0.10.0-6",
+ "version": "0.10.0-7",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.7",
diff --git a/backend/package.json b/backend/package.json
index bae061b..a9bddab 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
- "version": "0.10.0-6",
+ "version": "0.10.0-7",
"private": true,
"type": "commonjs",
"scripts": {
diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js
index 4c9a894..2485545 100644
--- a/backend/src/services/diskDetectionService.js
+++ b/backend/src/services/diskDetectionService.js
@@ -191,6 +191,7 @@ class DiskDetectionService extends EventEmitter {
this.lastDetected = null;
this.lastPresent = false;
this.deviceLocks = new Map();
+ this.pollingSuspended = false;
}
start() {
@@ -211,6 +212,20 @@ class DiskDetectionService extends EventEmitter {
logger.info('stop');
}
+ suspendPolling() {
+ if (!this.pollingSuspended) {
+ this.pollingSuspended = true;
+ logger.info('polling:suspended');
+ }
+ }
+
+ resumePolling() {
+ if (this.pollingSuspended) {
+ this.pollingSuspended = false;
+ logger.info('polling:resumed');
+ }
+ }
+
scheduleNext(delayMs) {
if (!this.running) {
return;
@@ -227,9 +242,12 @@ class DiskDetectionService extends EventEmitter {
driveMode: map.drive_mode,
driveDevice: map.drive_device,
nextDelay,
- autoDetectionEnabled
+ autoDetectionEnabled,
+ suspended: this.pollingSuspended
});
- if (autoDetectionEnabled) {
+ if (this.pollingSuspended) {
+ logger.debug('poll:skip:suspended', { nextDelay });
+ } else if (autoDetectionEnabled) {
const detected = await this.detectDisc(map);
this.applyDetectionResult(detected, { forceInsertEvent: false });
} else {
diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js
index 68301f4..72b01bf 100644
--- a/backend/src/services/pipelineService.js
+++ b/backend/src/services/pipelineService.js
@@ -5094,6 +5094,13 @@ class PipelineService extends EventEmitter {
statusText: this.snapshot.statusText
});
+ const DRIVE_ACTIVE_STATES = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']);
+ if (DRIVE_ACTIVE_STATES.has(state)) {
+ diskDetectionService.suspendPolling();
+ } else if (DRIVE_ACTIVE_STATES.has(previous)) {
+ diskDetectionService.resumePolling();
+ }
+
await this.persistSnapshot();
const snapshotPayload = this.getSnapshot();
wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 0508f53..679ee7b 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
- "version": "0.10.0-6",
+ "version": "0.10.0-7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
- "version": "0.10.0-6",
+ "version": "0.10.0-7",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
diff --git a/frontend/package.json b/frontend/package.json
index 8fd2150..4eaa543 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
- "version": "0.10.0-6",
+ "version": "0.10.0-7",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index c9366b1..929f7d7 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
+import { ProgressBar } from 'primereact/progressbar';
+import { Tag } from 'primereact/tag';
import { api } from './api/client';
import { useWebSocket } from './hooks/useWebSocket';
import DashboardPage from './pages/DashboardPage';
@@ -8,11 +10,81 @@ import SettingsPage from './pages/SettingsPage';
import HistoryPage from './pages/HistoryPage';
import DatabasePage from './pages/DatabasePage';
+function normalizeJobId(value) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ return null;
+ }
+ return Math.trunc(parsed);
+}
+
+function clampPercent(value) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) {
+ return 0;
+ }
+ return Math.max(0, Math.min(100, parsed));
+}
+
+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',
+ fileName: null,
+ loadedBytes: 0,
+ totalBytes: 0,
+ progressPercent: 0,
+ statusText: null,
+ errorMessage: null,
+ jobId: 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 App() {
const appVersion = __APP_VERSION__;
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
const [lastDiscEvent, setLastDiscEvent] = useState(null);
+ const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
+ const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
+ const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null);
const location = useLocation();
const navigate = useNavigate();
@@ -20,6 +92,104 @@ function App() {
const response = await api.getPipelineState();
setPipeline(response.pipeline);
setHardwareMonitoring(response?.hardwareMonitoring || null);
+ 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;
+
+ setAudiobookUpload({
+ phase: 'uploading',
+ fileName: String(file.name || '').trim() || 'upload.aax',
+ loadedBytes: 0,
+ totalBytes: fallbackTotalBytes,
+ progressPercent: 0,
+ statusText: 'AAX-Datei wird hochgeladen ...',
+ errorMessage: null,
+ jobId: null,
+ startedAt: new Date().toISOString(),
+ finishedAt: null
+ });
+
+ try {
+ const response = await api.uploadAudiobook(file, payload, {
+ 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))
+ ? clampPercent(Number(percent))
+ : (nextTotal > 0 ? clampPercent((nextLoaded / nextTotal) * 100) : 0);
+ const transferComplete = nextTotal > 0 && nextLoaded >= nextTotal;
+
+ 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 ...'
+ }));
+ }
+ });
+
+ const uploadedJobId = normalizeJobId(response?.result?.jobId);
+ await refreshPipeline().catch(() => null);
+ setDashboardJobsRefreshToken((prev) => prev + 1);
+ if (uploadedJobId) {
+ setPendingDashboardJobId(uploadedJobId);
+ }
+
+ setAudiobookUpload((prev) => ({
+ ...prev,
+ phase: 'completed',
+ loadedBytes: prev.totalBytes || prev.loadedBytes || fallbackTotalBytes,
+ totalBytes: prev.totalBytes || fallbackTotalBytes,
+ progressPercent: 100,
+ statusText: uploadedJobId
+ ? `Upload abgeschlossen. Job #${uploadedJobId} ist bereit fuer den naechsten Schritt.`
+ : 'Upload abgeschlossen.',
+ errorMessage: null,
+ jobId: uploadedJobId,
+ finishedAt: new Date().toISOString()
+ }));
+
+ return response;
+ } catch (error) {
+ setAudiobookUpload((prev) => ({
+ ...prev,
+ phase: 'error',
+ errorMessage: error?.message || 'Upload fehlgeschlagen.',
+ statusText: error?.message || 'Upload fehlgeschlagen.',
+ finishedAt: new Date().toISOString()
+ }));
+ throw error;
+ }
+ };
+
+ const handleDashboardJobFocusConsumed = (jobId) => {
+ const normalizedJobId = normalizeJobId(jobId);
+ if (!normalizedJobId) {
+ return;
+ }
+ setPendingDashboardJobId((prev) => (
+ normalizeJobId(prev) === normalizedJobId ? null : prev
+ ));
};
useEffect(() => {
@@ -40,7 +210,6 @@ function App() {
: null;
setPipeline((prev) => {
const next = { ...prev };
- // Update per-job progress map so concurrent jobs don't overwrite each other.
if (progressJobId != null) {
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
const mergedJobContext = contextPatch
@@ -65,7 +234,6 @@ function App() {
}
};
}
- // Update global snapshot fields only for the primary active job.
if (progressJobId === prev?.activeJobId || progressJobId == null) {
next.state = payload.state ?? prev?.state;
next.progress = payload.progress ?? prev?.progress;
@@ -108,6 +276,18 @@ function App() {
{ label: 'Settings', path: '/settings' },
{ label: 'Historie', path: '/history' }
];
+ 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 isDashboardRoute = location.pathname === '/';
return (
@@ -137,6 +317,61 @@ function App() {
+ {showAudiobookUploadBanner ? (
+
+
+
+ Audiobook Upload
+
+
+
{audiobookUpload?.statusText || 'Upload aktiv.'}
+ {audiobookUpload?.fileName ?
Datei: {audiobookUpload.fileName} : null}
+
+
+
+
+
+ {uploadPhase === 'processing'
+ ? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}`
+ : uploadBytesLabel
+ ? `${Math.round(uploadProgress)}% | ${uploadBytesLabel}`
+ : `${Math.round(uploadProgress)}%`}
+
+
+
+
+ {hasUploadedJob && !isDashboardRoute ? (
+
+
+ ) : null}
+
}
/>
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index a6ce843..54bacbe 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -111,6 +111,123 @@ async function request(path, options = {}) {
return response.text();
}
+async function requestWithXhr(path, options = {}) {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ const method = String(options?.method || 'GET').trim().toUpperCase() || 'GET';
+ const url = `${API_BASE}${path}`;
+ const headers = options?.headers && typeof options.headers === 'object' ? options.headers : {};
+ const signal = options?.signal;
+ const onUploadProgress = typeof options?.onUploadProgress === 'function'
+ ? options.onUploadProgress
+ : null;
+
+ let finished = false;
+ let abortListener = null;
+
+ const cleanup = () => {
+ if (signal && abortListener) {
+ signal.removeEventListener('abort', abortListener);
+ }
+ };
+
+ const settle = (callback) => {
+ if (finished) {
+ return;
+ }
+ finished = true;
+ cleanup();
+ callback();
+ };
+
+ xhr.open(method, url, true);
+ xhr.responseType = 'text';
+
+ Object.entries(headers).forEach(([key, value]) => {
+ if (value == null) {
+ return;
+ }
+ xhr.setRequestHeader(key, String(value));
+ });
+
+ if (onUploadProgress && xhr.upload) {
+ xhr.upload.onprogress = (event) => {
+ const loaded = Number(event?.loaded || 0);
+ const total = Number(event?.total || 0);
+ const hasKnownTotal = Boolean(event?.lengthComputable && total > 0);
+ onUploadProgress({
+ loaded,
+ total: hasKnownTotal ? total : null,
+ percent: hasKnownTotal ? (loaded / total) * 100 : null
+ });
+ };
+ }
+
+ xhr.onerror = () => {
+ settle(() => {
+ reject(new Error('Netzwerkfehler'));
+ });
+ };
+
+ xhr.onabort = () => {
+ settle(() => {
+ const error = new Error('Request abgebrochen.');
+ error.name = 'AbortError';
+ reject(error);
+ });
+ };
+
+ xhr.onload = () => {
+ settle(() => {
+ const contentType = xhr.getResponseHeader('content-type') || '';
+ const rawText = xhr.responseText || '';
+
+ if (xhr.status < 200 || xhr.status >= 300) {
+ let errorPayload = null;
+ let message = `HTTP ${xhr.status}`;
+ try {
+ errorPayload = rawText ? JSON.parse(rawText) : null;
+ message = errorPayload?.error?.message || message;
+ } catch (_error) {
+ // ignore parse errors
+ }
+ const error = new Error(message);
+ error.status = xhr.status;
+ error.details = errorPayload?.error?.details || null;
+ reject(error);
+ return;
+ }
+
+ if (contentType.includes('application/json')) {
+ try {
+ resolve(rawText ? JSON.parse(rawText) : {});
+ } catch (_error) {
+ reject(new Error('Ungültige JSON-Antwort vom Server.'));
+ }
+ return;
+ }
+
+ resolve(rawText);
+ });
+ };
+
+ if (signal) {
+ if (signal.aborted) {
+ xhr.abort();
+ return;
+ }
+ abortListener = () => {
+ if (!finished) {
+ xhr.abort();
+ }
+ };
+ signal.addEventListener('abort', abortListener, { once: true });
+ }
+
+ xhr.send(options?.body ?? null);
+ });
+}
+
export const api = {
getSettings(options = {}) {
return requestCachedGet('/settings', {
@@ -303,7 +420,7 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
- async uploadAudiobook(file, payload = {}) {
+ async uploadAudiobook(file, payload = {}, options = {}) {
const formData = new FormData();
if (file) {
formData.append('file', file);
@@ -314,9 +431,11 @@ export const api = {
if (payload?.startImmediately !== undefined) {
formData.append('startImmediately', String(payload.startImmediately));
}
- const result = await request('/pipeline/audiobook/upload', {
+ const result = await requestWithXhr('/pipeline/audiobook/upload', {
method: 'POST',
- body: formData
+ body: formData,
+ signal: options?.signal,
+ onUploadProgress: options?.onProgress
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index d2e2500..f775d30 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import { Toast } from 'primereact/toast';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
@@ -18,6 +19,7 @@ import otherIndicatorIcon from '../assets/media-other.svg';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
+const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
const dashboardStatuses = new Set([
'ANALYZING',
'METADATA_SELECTION',
@@ -749,8 +751,14 @@ export default function DashboardPage({
pipeline,
hardwareMonitoring,
lastDiscEvent,
- refreshPipeline
+ refreshPipeline,
+ audiobookUpload,
+ onAudiobookUpload,
+ jobsRefreshToken,
+ pendingExpandedJobId,
+ onPendingExpandedJobHandled
}) {
+ const navigate = useNavigate();
const [busy, setBusy] = useState(false);
const [busyJobIds, setBusyJobIds] = useState(() => new Set());
const setJobBusy = (jobId, isBusy) => {
@@ -767,6 +775,7 @@ export default function DashboardPage({
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [metadataDialogReassignMode, setMetadataDialogReassignMode] = useState(false);
+ const [duplicateJobDialog, setDuplicateJobDialog] = useState({ visible: false, existingJob: null, pendingPayload: null });
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdRipPanelJobId, setCdRipPanelJobId] = useState(null);
@@ -790,7 +799,6 @@ export default function DashboardPage({
const [dashboardJobs, setDashboardJobs] = useState([]);
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [audiobookUploadFile, setAudiobookUploadFile] = useState(null);
- const [audiobookUploadBusy, setAudiobookUploadBusy] = useState(false);
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
@@ -825,6 +833,34 @@ export default function DashboardPage({
}, [storageMetrics]);
const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : [];
const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : [];
+ const audiobookUploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
+ const audiobookUploadBusy = audiobookUploadPhase === 'uploading' || audiobookUploadPhase === 'processing';
+ const audiobookUploadProgress = Number.isFinite(Number(audiobookUpload?.progressPercent))
+ ? Math.max(0, Math.min(100, Number(audiobookUpload.progressPercent)))
+ : 0;
+ const audiobookUploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0);
+ const audiobookUploadTotalBytes = Number(audiobookUpload?.totalBytes || 0);
+ const audiobookUploadFileName = String(audiobookUpload?.fileName || '').trim()
+ || String(audiobookUploadFile?.name || '').trim()
+ || null;
+ const audiobookUploadStatusTone = audiobookUploadPhase === 'error'
+ ? 'danger'
+ : audiobookUploadPhase === 'completed'
+ ? 'success'
+ : audiobookUploadPhase === 'processing'
+ ? 'info'
+ : audiobookUploadPhase === 'uploading'
+ ? 'warning'
+ : 'secondary';
+ const audiobookUploadStatusLabel = audiobookUploadPhase === 'uploading'
+ ? 'Upload läuft'
+ : audiobookUploadPhase === 'processing'
+ ? 'Server verarbeitet'
+ : audiobookUploadPhase === 'completed'
+ ? 'Bereit'
+ : audiobookUploadPhase === 'error'
+ ? 'Fehler'
+ : 'Inaktiv';
const loadDashboardJobs = async () => {
setJobsLoading(true);
@@ -913,7 +949,20 @@ export default function DashboardPage({
useEffect(() => {
void loadDashboardJobs();
- }, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId]);
+ }, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, jobsRefreshToken]);
+
+ useEffect(() => {
+ const requestedJobId = normalizeJobId(pendingExpandedJobId);
+ if (!requestedJobId) {
+ return;
+ }
+ const hasRequestedJob = dashboardJobs.some((job) => normalizeJobId(job?.id) === requestedJobId);
+ if (!hasRequestedJob) {
+ return;
+ }
+ setExpandedJobId(requestedJobId);
+ onPendingExpandedJobHandled?.(requestedJobId);
+ }, [pendingExpandedJobId, dashboardJobs, onPendingExpandedJobHandled]);
useEffect(() => {
let cancelled = false;
@@ -1128,22 +1177,6 @@ export default function DashboardPage({
};
const handleReanalyze = async () => {
- const hasActiveJob = Boolean(pipeline?.context?.jobId || pipeline?.activeJobId);
- if (state === 'ENCODING') {
- const confirmed = window.confirm(
- 'Laufendes Encoding bleibt aktiv. Neue Disk jetzt als separaten Job analysieren?'
- );
- if (!confirmed) {
- return;
- }
- } else if (hasActiveJob && !['IDLE', 'DISC_DETECTED', 'FINISHED'].includes(state)) {
- const confirmed = window.confirm(
- 'Aktuellen Ablauf verwerfen und die Disk ab der ersten MakeMKV-Analyse neu starten?'
- );
- if (!confirmed) {
- return;
- }
- }
await handleAnalyze();
};
@@ -1335,31 +1368,20 @@ export default function DashboardPage({
showError(new Error('Bitte zuerst eine AAX-Datei auswählen.'));
return;
}
- setAudiobookUploadBusy(true);
try {
- const response = await api.uploadAudiobook(audiobookUploadFile, { startImmediately: false });
- const result = getQueueActionResult(response);
+ const response = await onAudiobookUpload?.(audiobookUploadFile, { startImmediately: false });
const uploadedJobId = normalizeJobId(response?.result?.jobId);
- await refreshPipeline();
- await loadDashboardJobs();
- if (result.queued) {
- showQueuedToast(toastRef, 'Audiobook', result);
- } else {
+ if (uploadedJobId) {
toastRef.current?.show({
severity: 'success',
summary: 'Audiobook importiert',
- detail: uploadedJobId ? `Job #${uploadedJobId} wurde angelegt.` : 'Audiobook wurde importiert.',
+ detail: `Job #${uploadedJobId} wurde angelegt und wird geoeffnet.`,
life: 3200
});
}
- if (uploadedJobId) {
- setExpandedJobId(uploadedJobId);
- }
setAudiobookUploadFile(null);
} catch (error) {
showError(error);
- } finally {
- setAudiobookUploadBusy(false);
}
};
@@ -1626,7 +1648,7 @@ export default function DashboardPage({
}
};
- const handleMetadataSubmit = async (payload) => {
+ const doSelectMetadata = async (payload) => {
setBusy(true);
try {
if (metadataDialogReassignMode) {
@@ -1646,6 +1668,51 @@ export default function DashboardPage({
}
};
+ const handleMetadataSubmit = async (payload) => {
+ if (metadataDialogReassignMode) {
+ await doSelectMetadata(payload);
+ return;
+ }
+
+ // Duplikatprüfung: nur bei OMDB-Auswahl mit imdbId sinnvoll
+ const searchTitle = payload.title || '';
+ const searchImdbId = payload.imdbId || null;
+ if (searchTitle) {
+ try {
+ const currentJobMediaProfile = String(effectiveMetadataDialogContext?.mediaProfile || '').trim().toLowerCase();
+ const historyResponse = await api.getJobs({ search: searchTitle, limit: 50, lite: true });
+ const historyJobs = Array.isArray(historyResponse?.jobs) ? historyResponse.jobs : [];
+ const duplicate = historyJobs.find((job) => {
+ if (normalizeJobId(job.id) === normalizeJobId(payload.jobId)) {
+ return false; // aktueller Job selbst
+ }
+ // Gleicher Titel / imdbId?
+ const titleMatch = searchImdbId
+ ? (job.imdb_id && job.imdb_id === searchImdbId)
+ : (String(job.title || '').toLowerCase() === searchTitle.toLowerCase());
+ if (!titleMatch) {
+ return false;
+ }
+ // Gleiches Medium? Verschiedene Medien (DVD vs. Bluray) → kein Duplikat
+ if (!currentJobMediaProfile || currentJobMediaProfile === 'other') {
+ return false;
+ }
+ const jobMediaType = resolveMediaType(job);
+ return jobMediaType === currentJobMediaProfile;
+ });
+
+ if (duplicate) {
+ setDuplicateJobDialog({ visible: true, existingJob: duplicate, pendingPayload: payload });
+ return;
+ }
+ } catch (_error) {
+ // Bei Fehler einfach fortfahren
+ }
+ }
+
+ await doSelectMetadata(payload);
+ };
+
const handleMusicBrainzSearch = async (query) => {
try {
const response = await api.searchMusicBrainz(query);
@@ -1711,9 +1778,9 @@ export default function DashboardPage({
};
const device = lastDiscEvent || pipeline?.context?.device;
- const canReanalyze = state === 'ENCODING'
- ? Boolean(device)
- : !processingStates.includes(state);
+ const isDriveActive = driveActiveStates.includes(state);
+ const canRescan = !isDriveActive;
+ const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state));
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
@@ -2066,9 +2133,36 @@ export default function DashboardPage({
disabled={!audiobookUploadFile}
/>
+ {audiobookUploadPhase !== 'idle' ? (
+
+
+ {audiobookUploadStatusLabel}
+
+
+ {audiobookUpload?.statusText ?
{audiobookUpload.statusText} : null}
+ {audiobookUploadFileName ? (
+
+ Datei: {audiobookUploadFileName}
+
+ ) : null}
+
+
+
+ {audiobookUploadPhase === 'processing'
+ ? '100% | Upload fertig, Job wird vorbereitet ...'
+ : audiobookUploadTotalBytes > 0
+ ? `${Math.round(audiobookUploadProgress)}% | ${formatBytes(audiobookUploadLoadedBytes)} / ${formatBytes(audiobookUploadTotalBytes)}`
+ : `${Math.round(audiobookUploadProgress)}%`}
+
+
+
+ ) : null}
- {audiobookUploadFile
- ? `Ausgewählt: ${audiobookUploadFile.name}`
+ {audiobookUploadFileName && audiobookUploadPhase === 'idle'
+ ? `Ausgewählt: ${audiobookUploadFileName}`
: 'Unterstützt im MVP: AAX-Upload. Danach erscheint ein eigener Audiobook-Startschritt mit Format- und Qualitätswahl.'}
@@ -2607,6 +2701,7 @@ export default function DashboardPage({
severity="secondary"
onClick={handleRescan}
loading={busy}
+ disabled={!canRescan}
/>
+
+