0.10.0-7 Fix und stuff
This commit is contained in:
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.0-6",
|
"version": "0.10.0-7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.0-6",
|
"version": "0.10.0-7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.0-6",
|
"version": "0.10.0-7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
this.lastDetected = null;
|
this.lastDetected = null;
|
||||||
this.lastPresent = false;
|
this.lastPresent = false;
|
||||||
this.deviceLocks = new Map();
|
this.deviceLocks = new Map();
|
||||||
|
this.pollingSuspended = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
@@ -211,6 +212,20 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
logger.info('stop');
|
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) {
|
scheduleNext(delayMs) {
|
||||||
if (!this.running) {
|
if (!this.running) {
|
||||||
return;
|
return;
|
||||||
@@ -227,9 +242,12 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
driveMode: map.drive_mode,
|
driveMode: map.drive_mode,
|
||||||
driveDevice: map.drive_device,
|
driveDevice: map.drive_device,
|
||||||
nextDelay,
|
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);
|
const detected = await this.detectDisc(map);
|
||||||
this.applyDetectionResult(detected, { forceInsertEvent: false });
|
this.applyDetectionResult(detected, { forceInsertEvent: false });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5094,6 +5094,13 @@ class PipelineService extends EventEmitter {
|
|||||||
statusText: this.snapshot.statusText
|
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();
|
await this.persistSnapshot();
|
||||||
const snapshotPayload = this.getSnapshot();
|
const snapshotPayload = this.getSnapshot();
|
||||||
wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
|
wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.0-6",
|
"version": "0.10.0-7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.0-6",
|
"version": "0.10.0-7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.0-6",
|
"version": "0.10.0-7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
import { api } from './api/client';
|
import { api } from './api/client';
|
||||||
import { useWebSocket } from './hooks/useWebSocket';
|
import { useWebSocket } from './hooks/useWebSocket';
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
@@ -8,11 +10,81 @@ import SettingsPage from './pages/SettingsPage';
|
|||||||
import HistoryPage from './pages/HistoryPage';
|
import HistoryPage from './pages/HistoryPage';
|
||||||
import DatabasePage from './pages/DatabasePage';
|
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() {
|
function App() {
|
||||||
const appVersion = __APP_VERSION__;
|
const appVersion = __APP_VERSION__;
|
||||||
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||||
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
|
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
|
||||||
const [lastDiscEvent, setLastDiscEvent] = 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 location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -20,6 +92,104 @@ function App() {
|
|||||||
const response = await api.getPipelineState();
|
const response = await api.getPipelineState();
|
||||||
setPipeline(response.pipeline);
|
setPipeline(response.pipeline);
|
||||||
setHardwareMonitoring(response?.hardwareMonitoring || null);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -40,7 +210,6 @@ function App() {
|
|||||||
: null;
|
: null;
|
||||||
setPipeline((prev) => {
|
setPipeline((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
// Update per-job progress map so concurrent jobs don't overwrite each other.
|
|
||||||
if (progressJobId != null) {
|
if (progressJobId != null) {
|
||||||
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
|
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
|
||||||
const mergedJobContext = contextPatch
|
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) {
|
if (progressJobId === prev?.activeJobId || progressJobId == null) {
|
||||||
next.state = payload.state ?? prev?.state;
|
next.state = payload.state ?? prev?.state;
|
||||||
next.progress = payload.progress ?? prev?.progress;
|
next.progress = payload.progress ?? prev?.progress;
|
||||||
@@ -108,6 +276,18 @@ function App() {
|
|||||||
{ label: 'Settings', path: '/settings' },
|
{ label: 'Settings', path: '/settings' },
|
||||||
{ label: 'Historie', path: '/history' }
|
{ 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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
@@ -137,6 +317,61 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<main className="app-main">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
@@ -147,6 +382,11 @@ function App() {
|
|||||||
hardwareMonitoring={hardwareMonitoring}
|
hardwareMonitoring={hardwareMonitoring}
|
||||||
lastDiscEvent={lastDiscEvent}
|
lastDiscEvent={lastDiscEvent}
|
||||||
refreshPipeline={refreshPipeline}
|
refreshPipeline={refreshPipeline}
|
||||||
|
audiobookUpload={audiobookUpload}
|
||||||
|
onAudiobookUpload={handleAudiobookUpload}
|
||||||
|
jobsRefreshToken={dashboardJobsRefreshToken}
|
||||||
|
pendingExpandedJobId={pendingDashboardJobId}
|
||||||
|
onPendingExpandedJobHandled={handleDashboardJobFocusConsumed}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -111,6 +111,123 @@ async function request(path, options = {}) {
|
|||||||
return response.text();
|
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 = {
|
export const api = {
|
||||||
getSettings(options = {}) {
|
getSettings(options = {}) {
|
||||||
return requestCachedGet('/settings', {
|
return requestCachedGet('/settings', {
|
||||||
@@ -303,7 +420,7 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
async uploadAudiobook(file, payload = {}) {
|
async uploadAudiobook(file, payload = {}, options = {}) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
if (file) {
|
if (file) {
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
@@ -314,9 +431,11 @@ export const api = {
|
|||||||
if (payload?.startImmediately !== undefined) {
|
if (payload?.startImmediately !== undefined) {
|
||||||
formData.append('startImmediately', String(payload.startImmediately));
|
formData.append('startImmediately', String(payload.startImmediately));
|
||||||
}
|
}
|
||||||
const result = await request('/pipeline/audiobook/upload', {
|
const result = await requestWithXhr('/pipeline/audiobook/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData,
|
||||||
|
signal: options?.signal,
|
||||||
|
onUploadProgress: options?.onProgress
|
||||||
});
|
});
|
||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
@@ -18,6 +19,7 @@ import otherIndicatorIcon from '../assets/media-other.svg';
|
|||||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||||
|
|
||||||
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
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([
|
const dashboardStatuses = new Set([
|
||||||
'ANALYZING',
|
'ANALYZING',
|
||||||
'METADATA_SELECTION',
|
'METADATA_SELECTION',
|
||||||
@@ -749,8 +751,14 @@ export default function DashboardPage({
|
|||||||
pipeline,
|
pipeline,
|
||||||
hardwareMonitoring,
|
hardwareMonitoring,
|
||||||
lastDiscEvent,
|
lastDiscEvent,
|
||||||
refreshPipeline
|
refreshPipeline,
|
||||||
|
audiobookUpload,
|
||||||
|
onAudiobookUpload,
|
||||||
|
jobsRefreshToken,
|
||||||
|
pendingExpandedJobId,
|
||||||
|
onPendingExpandedJobHandled
|
||||||
}) {
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [busyJobIds, setBusyJobIds] = useState(() => new Set());
|
const [busyJobIds, setBusyJobIds] = useState(() => new Set());
|
||||||
const setJobBusy = (jobId, isBusy) => {
|
const setJobBusy = (jobId, isBusy) => {
|
||||||
@@ -767,6 +775,7 @@ export default function DashboardPage({
|
|||||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||||
const [metadataDialogReassignMode, setMetadataDialogReassignMode] = useState(false);
|
const [metadataDialogReassignMode, setMetadataDialogReassignMode] = useState(false);
|
||||||
|
const [duplicateJobDialog, setDuplicateJobDialog] = useState({ visible: false, existingJob: null, pendingPayload: null });
|
||||||
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||||
const [cdRipPanelJobId, setCdRipPanelJobId] = useState(null);
|
const [cdRipPanelJobId, setCdRipPanelJobId] = useState(null);
|
||||||
@@ -790,7 +799,6 @@ export default function DashboardPage({
|
|||||||
const [dashboardJobs, setDashboardJobs] = useState([]);
|
const [dashboardJobs, setDashboardJobs] = useState([]);
|
||||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||||
const [audiobookUploadFile, setAudiobookUploadFile] = useState(null);
|
const [audiobookUploadFile, setAudiobookUploadFile] = useState(null);
|
||||||
const [audiobookUploadBusy, setAudiobookUploadBusy] = useState(false);
|
|
||||||
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
||||||
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
|
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
|
||||||
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
||||||
@@ -825,6 +833,34 @@ export default function DashboardPage({
|
|||||||
}, [storageMetrics]);
|
}, [storageMetrics]);
|
||||||
const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : [];
|
const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : [];
|
||||||
const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : [];
|
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 () => {
|
const loadDashboardJobs = async () => {
|
||||||
setJobsLoading(true);
|
setJobsLoading(true);
|
||||||
@@ -913,7 +949,20 @@ export default function DashboardPage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadDashboardJobs();
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1128,22 +1177,6 @@ export default function DashboardPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReanalyze = async () => {
|
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();
|
await handleAnalyze();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1335,31 +1368,20 @@ export default function DashboardPage({
|
|||||||
showError(new Error('Bitte zuerst eine AAX-Datei auswählen.'));
|
showError(new Error('Bitte zuerst eine AAX-Datei auswählen.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAudiobookUploadBusy(true);
|
|
||||||
try {
|
try {
|
||||||
const response = await api.uploadAudiobook(audiobookUploadFile, { startImmediately: false });
|
const response = await onAudiobookUpload?.(audiobookUploadFile, { startImmediately: false });
|
||||||
const result = getQueueActionResult(response);
|
|
||||||
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
||||||
await refreshPipeline();
|
if (uploadedJobId) {
|
||||||
await loadDashboardJobs();
|
|
||||||
if (result.queued) {
|
|
||||||
showQueuedToast(toastRef, 'Audiobook', result);
|
|
||||||
} else {
|
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Audiobook importiert',
|
summary: 'Audiobook importiert',
|
||||||
detail: uploadedJobId ? `Job #${uploadedJobId} wurde angelegt.` : 'Audiobook wurde importiert.',
|
detail: `Job #${uploadedJobId} wurde angelegt und wird geoeffnet.`,
|
||||||
life: 3200
|
life: 3200
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (uploadedJobId) {
|
|
||||||
setExpandedJobId(uploadedJobId);
|
|
||||||
}
|
|
||||||
setAudiobookUploadFile(null);
|
setAudiobookUploadFile(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error);
|
showError(error);
|
||||||
} finally {
|
|
||||||
setAudiobookUploadBusy(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1626,7 +1648,7 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMetadataSubmit = async (payload) => {
|
const doSelectMetadata = async (payload) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
if (metadataDialogReassignMode) {
|
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) => {
|
const handleMusicBrainzSearch = async (query) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.searchMusicBrainz(query);
|
const response = await api.searchMusicBrainz(query);
|
||||||
@@ -1711,9 +1778,9 @@ export default function DashboardPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const device = lastDiscEvent || pipeline?.context?.device;
|
const device = lastDiscEvent || pipeline?.context?.device;
|
||||||
const canReanalyze = state === 'ENCODING'
|
const isDriveActive = driveActiveStates.includes(state);
|
||||||
? Boolean(device)
|
const canRescan = !isDriveActive;
|
||||||
: !processingStates.includes(state);
|
const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state));
|
||||||
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
|
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
|
||||||
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
||||||
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
||||||
@@ -2066,9 +2133,36 @@ export default function DashboardPage({
|
|||||||
disabled={!audiobookUploadFile}
|
disabled={!audiobookUploadFile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<small>
|
||||||
{audiobookUploadFile
|
{audiobookUploadFileName && audiobookUploadPhase === 'idle'
|
||||||
? `Ausgewählt: ${audiobookUploadFile.name}`
|
? `Ausgewählt: ${audiobookUploadFileName}`
|
||||||
: 'Unterstützt im MVP: AAX-Upload. Danach erscheint ein eigener Audiobook-Startschritt mit Format- und Qualitätswahl.'}
|
: 'Unterstützt im MVP: AAX-Upload. Danach erscheint ein eigener Audiobook-Startschritt mit Format- und Qualitätswahl.'}
|
||||||
</small>
|
</small>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -2607,6 +2701,7 @@ export default function DashboardPage({
|
|||||||
severity="secondary"
|
severity="secondary"
|
||||||
onClick={handleRescan}
|
onClick={handleRescan}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
|
disabled={!canRescan}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Disk neu analysieren"
|
label="Disk neu analysieren"
|
||||||
@@ -2672,6 +2767,40 @@ export default function DashboardPage({
|
|||||||
busy={busy}
|
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
|
<Dialog
|
||||||
header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'}
|
header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'}
|
||||||
visible={Boolean(cancelCleanupDialog.visible)}
|
visible={Boolean(cancelCleanupDialog.visible)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import { DataView, DataViewLayoutOptions } from 'primereact/dataview';
|
import { DataView, DataViewLayoutOptions } from 'primereact/dataview';
|
||||||
import { InputText } from 'primereact/inputtext';
|
import { InputText } from 'primereact/inputtext';
|
||||||
@@ -347,6 +348,8 @@ function formatDateTime(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HistoryPage() {
|
export default function HistoryPage() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [jobs, setJobs] = useState([]);
|
const [jobs, setJobs] = useState([]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('');
|
||||||
@@ -436,6 +439,17 @@ export default function HistoryPage() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [search, status]);
|
}, [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 onSortChange = (event) => {
|
||||||
const value = String(event.value || '').trim();
|
const value = String(event.value || '').trim();
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|||||||
@@ -200,6 +200,63 @@ body {
|
|||||||
margin: 1rem auto 2rem;
|
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 {
|
.page-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -971,6 +1028,53 @@ body {
|
|||||||
white-space: normal;
|
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 {
|
.dashboard-job-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2539,6 +2643,16 @@ body {
|
|||||||
padding: 0.8rem 1rem;
|
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 {
|
.brand-logo {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
@@ -2676,6 +2790,11 @@ body {
|
|||||||
width: min(1280px, 98vw);
|
width: min(1280px, 98vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-upload-banner {
|
||||||
|
width: min(1280px, 98vw);
|
||||||
|
padding: 0.75rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.hardware-storage-head {
|
.hardware-storage-head {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.0-6",
|
"version": "0.10.0-7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.0-6",
|
"version": "0.10.0-7",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.0-6",
|
"version": "0.10.0-7",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user