0.16.1-7 Release-Bugfixes
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled

This commit is contained in:
2026-05-08 08:10:34 +02:00
parent 024a6305e2
commit 9b437fe5e5
52 changed files with 5815 additions and 2600 deletions
+114 -149
View File
@@ -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}
/>
}
/>