0.10.0-7 Fix und stuff

This commit is contained in:
2026-03-15 08:30:26 +00:00
parent 3e191a654e
commit 25d5339ada
13 changed files with 701 additions and 55 deletions

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.10.0-6",
"version": "0.10.0-7",
"private": true,
"type": "commonjs",
"scripts": {

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.10.0-6",
"version": "0.10.0-7",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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 (
<div className="app-shell">
@@ -137,6 +317,61 @@ 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.round(uploadProgress)} Prozent`}
>
<ProgressBar value={uploadProgress} showValue={false} />
<small>
{uploadPhase === 'processing'
? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}`
: uploadBytesLabel
? `${Math.round(uploadProgress)}% | ${uploadBytesLabel}`
: `${Math.round(uploadProgress)}%`}
</small>
</div>
<div className="app-upload-banner-actions">
{hasUploadedJob && !isDashboardRoute ? (
<Button
label="Zum Dashboard"
icon="pi pi-arrow-right"
severity="secondary"
outlined
onClick={() => {
const targetJobId = normalizeJobId(audiobookUpload?.jobId);
if (targetJobId) {
setPendingDashboardJobId(targetJobId);
}
navigate('/');
}}
/>
) : 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
@@ -147,6 +382,11 @@ function App() {
hardwareMonitoring={hardwareMonitoring}
lastDiscEvent={lastDiscEvent}
refreshPipeline={refreshPipeline}
audiobookUpload={audiobookUpload}
onAudiobookUpload={handleAudiobookUpload}
jobsRefreshToken={dashboardJobsRefreshToken}
pendingExpandedJobId={pendingDashboardJobId}
onPendingExpandedJobHandled={handleDashboardJobFocusConsumed}
/>
}
/>

View File

@@ -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;

View File

@@ -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}
/>
</div>
{audiobookUploadPhase !== 'idle' ? (
<div className={`audiobook-upload-status tone-${audiobookUploadStatusTone}`}>
<div className="audiobook-upload-status-head">
<strong>{audiobookUploadStatusLabel}</strong>
<Tag value={audiobookUploadStatusLabel} severity={audiobookUploadStatusTone} />
</div>
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
{audiobookUploadFileName ? (
<small className="audiobook-upload-file" title={audiobookUploadFileName}>
Datei: {audiobookUploadFileName}
</small>
) : null}
<div
className="dashboard-job-row-progress audiobook-upload-progress"
aria-label={`Audiobook Upload ${Math.round(audiobookUploadProgress)} Prozent`}
>
<ProgressBar value={audiobookUploadProgress} showValue={false} />
<small>
{audiobookUploadPhase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: audiobookUploadTotalBytes > 0
? `${Math.round(audiobookUploadProgress)}% | ${formatBytes(audiobookUploadLoadedBytes)} / ${formatBytes(audiobookUploadTotalBytes)}`
: `${Math.round(audiobookUploadProgress)}%`}
</small>
</div>
</div>
) : null}
<small>
{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.'}
</small>
</Card>
@@ -2607,6 +2701,7 @@ export default function DashboardPage({
severity="secondary"
onClick={handleRescan}
loading={busy}
disabled={!canRescan}
/>
<Button
label="Disk neu analysieren"
@@ -2672,6 +2767,40 @@ export default function DashboardPage({
busy={busy}
/>
<Dialog
header="Titel bereits in der Historie"
visible={duplicateJobDialog.visible}
onHide={() => setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null })}
style={{ width: '30rem', maxWidth: '96vw' }}
modal
>
<p>
<strong>{duplicateJobDialog.existingJob?.title || duplicateJobDialog.pendingPayload?.title}</strong> ist bereits als Job #{duplicateJobDialog.existingJob?.id} in der Historie vorhanden.
</p>
<p>Neuen Job anlegen oder mit dem vorhandenen Eintrag weiterarbeiten?</p>
<div className="dialog-actions">
<Button
label="Vorhandenen Job öffnen"
icon="pi pi-history"
onClick={() => {
const jobId = duplicateJobDialog.existingJob?.id;
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
navigate(`/history?open=${jobId}`);
}}
/>
<Button
label="Neuen Job anlegen"
severity="secondary"
outlined
onClick={async () => {
const payload = duplicateJobDialog.pendingPayload;
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
await doSelectMetadata(payload);
}}
/>
</div>
</Dialog>
<Dialog
header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'}
visible={Boolean(cancelCleanupDialog.visible)}

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Card } from 'primereact/card';
import { DataView, DataViewLayoutOptions } from 'primereact/dataview';
import { InputText } from 'primereact/inputtext';
@@ -347,6 +348,8 @@ function formatDateTime(value) {
}
export default function HistoryPage() {
const location = useLocation();
const navigate = useNavigate();
const [jobs, setJobs] = useState([]);
const [search, setSearch] = useState('');
const [status, setStatus] = useState('');
@@ -436,6 +439,17 @@ export default function HistoryPage() {
return () => clearTimeout(timer);
}, [search, status]);
useEffect(() => {
const params = new URLSearchParams(location.search);
const openJobId = Number(params.get('open') || 0);
if (!openJobId) {
return;
}
// URL-Parameter entfernen, dann Job-Modal öffnen
navigate('/history', { replace: true });
openDetail({ id: openJobId });
}, [location.search]);
const onSortChange = (event) => {
const value = String(event.value || '').trim();
if (!value) {

View File

@@ -200,6 +200,63 @@ body {
margin: 1rem auto 2rem;
}
.app-upload-banner {
width: min(1280px, 96vw);
margin: 0.9rem auto 0;
padding: 0.8rem 0.95rem;
border: 1px solid var(--rip-border);
border-radius: 0.7rem;
background: linear-gradient(135deg, rgba(255, 250, 241, 0.96), rgba(250, 237, 210, 0.92));
box-shadow: 0 10px 24px rgba(58, 29, 18, 0.08);
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(280px, 0.9fr) auto;
gap: 0.85rem;
align-items: center;
}
.app-upload-banner.phase-error {
border-color: #d8a19a;
background: linear-gradient(135deg, #fff8f5, #f9e1dc);
}
.app-upload-banner.phase-completed {
border-color: #a7cda5;
background: linear-gradient(135deg, #f8fff7, #e8f3de);
}
.app-upload-banner-copy,
.app-upload-banner-progress {
min-width: 0;
display: grid;
gap: 0.22rem;
}
.app-upload-banner-head {
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
}
.app-upload-banner-copy small,
.app-upload-banner-progress small {
color: var(--rip-muted);
overflow-wrap: anywhere;
word-break: break-word;
}
.app-upload-banner-progress .p-progressbar {
height: 0.52rem;
background: rgba(111, 57, 34, 0.12);
}
.app-upload-banner-actions {
display: inline-flex;
align-items: center;
gap: 0.4rem;
justify-self: end;
}
.page-grid {
display: grid;
gap: 1rem;
@@ -971,6 +1028,53 @@ body {
white-space: normal;
}
.audiobook-upload-status {
margin-top: 0.8rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--rip-border);
border-radius: 0.55rem;
background: var(--rip-panel-soft);
display: grid;
gap: 0.35rem;
}
.audiobook-upload-status.tone-warning {
border-color: #d9b26d;
background: #fff7e8;
}
.audiobook-upload-status.tone-info {
border-color: #c7b086;
background: #fbf5ea;
}
.audiobook-upload-status.tone-success {
border-color: #9cc7a1;
background: #f5fbf3;
}
.audiobook-upload-status.tone-danger {
border-color: #d8a19a;
background: #fff6f3;
}
.audiobook-upload-status-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.audiobook-upload-file {
overflow-wrap: anywhere;
word-break: break-word;
}
.audiobook-upload-progress {
margin-top: 0.15rem;
}
.dashboard-job-badges {
display: flex;
align-items: center;
@@ -2539,6 +2643,16 @@ body {
padding: 0.8rem 1rem;
}
.app-upload-banner {
width: calc(100% - 1.5rem);
grid-template-columns: 1fr;
justify-items: stretch;
}
.app-upload-banner-actions {
justify-self: start;
}
.brand-logo {
width: 60px;
height: 60px;
@@ -2676,6 +2790,11 @@ body {
width: min(1280px, 98vw);
}
.app-upload-banner {
width: min(1280px, 98vw);
padding: 0.75rem 0.8rem;
}
.hardware-storage-head {
grid-template-columns: 1fr;
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.10.0-6",
"version": "0.10.0-7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.10.0-6",
"version": "0.10.0-7",
"devDependencies": {
"concurrently": "^9.1.2"
}

View File

@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.10.0-6",
"version": "0.10.0-7",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",