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} />