1.0.0 release snapshot

This commit is contained in:
2026-06-12 11:17:44 +02:00
parent 6115090da1
commit c9ad6b2f06
274 changed files with 138472 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
# Optional: komplett explizite API-Basis (sonst /api via Vite-Proxy)
# VITE_API_BASE=http://10.10.10.24:3001/api
# Optional: expliziter WS-Endpunkt (sonst ws(s)://<host>/ws)
# VITE_WS_URL=ws://10.10.10.24:3001/ws
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ripster</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1730
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "ripster-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"chart.js": "^4.5.1",
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.12"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

+782
View File
@@ -0,0 +1,782 @@
import { useEffect, useRef, useState } from 'react';
import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { ConfirmDialog } from 'primereact/confirmdialog';
import { api } from './api/client';
import { useWebSocket } from './hooks/useWebSocket';
import RipperPage from './pages/RipperPage';
import SettingsPage from './pages/SettingsPage';
import HistoryPage from './pages/HistoryPage';
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';
import HardwarePage from './pages/HardwarePage';
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
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)) {
return 0;
}
return Math.max(0, Math.min(100, parsed));
}
function normalizeStage(value) {
return String(value || '').trim().toUpperCase();
}
function isRunningStage(value) {
const normalized = normalizeStage(value);
return normalized === 'ANALYZING'
|| normalized === 'RIPPING'
|| normalized === 'MEDIAINFO_CHECK'
|| normalized === 'ENCODING'
|| normalized === 'CD_ANALYZING'
|| normalized === 'CD_RIPPING'
|| normalized === 'CD_ENCODING';
}
function isTerminalStage(value) {
const normalized = normalizeStage(value);
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
}
function isInteractiveReviewStage(value) {
const normalized = normalizeStage(value);
return normalized === 'METADATA_LOOKUP'
|| normalized === 'METADATA_SELECTION'
|| normalized === 'WAITING_FOR_USER_DECISION'
|| normalized === 'READY_TO_ENCODE';
}
function parseTimestamp(value) {
if (!value) {
return 0;
}
const ts = Date.parse(String(value));
return Number.isFinite(ts) ? ts : 0;
}
function mergePipelineStatePreservingNewestQueue(previousPipeline, incomingPipeline) {
const prev = previousPipeline && typeof previousPipeline === 'object' ? previousPipeline : {};
const incoming = incomingPipeline && typeof incomingPipeline === 'object' ? incomingPipeline : {};
const prevQueue = prev?.queue && typeof prev.queue === 'object' ? prev.queue : null;
const incomingQueue = incoming?.queue && typeof incoming.queue === 'object' ? incoming.queue : null;
if (!prevQueue || !incomingQueue) {
return {
...prev,
...incoming
};
}
const prevQueueTs = parseTimestamp(prevQueue.updatedAt);
const incomingQueueTs = parseTimestamp(incomingQueue.updatedAt);
const queue = incomingQueueTs >= prevQueueTs ? incomingQueue : prevQueue;
return {
...prev,
...incoming,
queue
};
}
function createInitialAudiobookUploadState() {
return {
phase: 'idle',
fileName: null,
loadedBytes: 0,
totalBytes: 0,
progressPercent: 0,
statusText: null,
errorMessage: null,
jobId: null,
uploadSessionId: null,
fileLastModified: null,
fileFingerprint: null,
startedAt: null,
finishedAt: null
};
}
function getDownloadIndicatorMeta(summary) {
const activeCount = Number(summary?.activeCount || 0);
const failedCount = Number(summary?.failedCount || 0);
const totalCount = Number(summary?.totalCount || 0);
if (activeCount > 0) {
return {
icon: 'pi pi-spinner pi-spin',
label: activeCount === 1 ? '1 ZIP aktiv' : `${activeCount} ZIPs aktiv`,
className: 'zip-status-indicator-active'
};
}
if (totalCount > 0) {
return {
icon: 'pi pi-check',
label: failedCount > 0 ? 'ZIP-Jobs beendet' : 'ZIPs fertig',
className: 'zip-status-indicator-ready'
};
}
return {
icon: 'pi pi-download',
label: 'ZIPs',
className: 'zip-status-indicator-idle'
};
}
const HARDWARE_MONITOR_SETTING_KEYS = new Set([
'hardware_monitoring_enabled',
'hardware_monitoring_interval_ms'
]);
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 [expertMode, setExpertMode] = useState(false);
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
const [ripperJobsRefreshToken, setRipperJobsRefreshToken] = useState(0);
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
const [downloadSummary, setDownloadSummary] = 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) {
clearTimeout(hardwareWarmupRef.current.timer);
hardwareWarmupRef.current.timer = null;
}
};
// When a virtual CD drive is removed (CD encode/rip finished or failed),
// or when a CD drive transitions away from an active job, force both
// Ripper and History to re-fetch so jobs leave the live list reliably.
useEffect(() => {
const current = pipeline?.cdDrives || {};
const prev = prevCdDrivesRef.current;
const normalizeState = (value) => String(value || '').trim().toUpperCase();
const ACTIVE_CD_STATES = new Set(['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING']);
const TERMINAL_CD_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']);
let shouldRefresh = false;
for (const [devicePath, previousDrive] of Object.entries(prev)) {
const previousJobId = normalizeJobId(previousDrive?.jobId);
if (!previousJobId) {
continue;
}
const previousState = normalizeState(previousDrive?.state);
const currentDrive = current?.[devicePath] || null;
const currentJobId = normalizeJobId(currentDrive?.jobId);
const currentState = normalizeState(currentDrive?.state);
const driveRemoved = !currentDrive;
const jobChanged = currentJobId !== previousJobId;
const movedToTerminal = currentJobId === previousJobId
&& TERMINAL_CD_STATES.has(currentState)
&& currentState !== previousState;
const leftActiveLifecycle = ACTIVE_CD_STATES.has(previousState)
&& (driveRemoved || jobChanged || !ACTIVE_CD_STATES.has(currentState));
if (movedToTerminal || leftActiveLifecycle) {
shouldRefresh = true;
break;
}
}
if (shouldRefresh) {
setRipperJobsRefreshToken((t) => t + 1);
setHistoryJobsRefreshToken((t) => t + 1);
}
prevCdDrivesRef.current = current;
}, [pipeline?.cdDrives]);
// Trigger a jobs refresh when a tracked job transitions into a terminal or
// interactive review state through PIPELINE_PROGRESS, so Ripper/History
// update immediately without a manual page reload.
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;
}
if (
currentState
&& isInteractiveReviewStage(currentState)
&& currentState !== 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);
const monitoringPayload = response?.hardwareMonitoring || null;
setHardwareMonitoring(monitoringPayload);
const monitoringEnabled = Boolean(monitoringPayload?.enabled);
const hasSample = Boolean(monitoringPayload?.sample && typeof monitoringPayload.sample === 'object');
if (monitoringEnabled && !hasSample) {
const nextAttempts = Number(hardwareWarmupRef.current?.attempts || 0) + 1;
hardwareWarmupRef.current.attempts = nextAttempts;
if (nextAttempts <= 10 && !hardwareWarmupRef.current.timer) {
hardwareWarmupRef.current.timer = setTimeout(() => {
hardwareWarmupRef.current.timer = null;
refreshPipeline().catch(() => null);
}, 1000);
}
} else {
hardwareWarmupRef.current.attempts = 0;
clearHardwareWarmupRetry();
}
return response;
};
const handleAudiobookUpload = async (file, payload = {}) => {
if (!file) {
throw new Error('Bitte zuerst eine AAX-Datei auswählen.');
}
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,
loadedBytes: 0,
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 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))
: (totalSize > 0 ? clampPercent((loadedBytes / totalSize) * 100) : 0);
setAudiobookUpload((prev) => ({
...prev,
phase: 'uploading',
loadedBytes,
totalBytes: totalSize,
progressPercent,
statusText: 'AAX-Datei wird hochgeladen ...',
errorMessage: null
}));
}
});
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);
setAudiobookUpload((prev) => ({
...prev,
phase: 'completed',
loadedBytes: prev.totalBytes || totalBytes,
totalBytes: prev.totalBytes || totalBytes,
progressPercent: 100,
statusText: uploadedJobId
? (
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',
errorMessage: error?.message || 'Upload fehlgeschlagen.',
statusText: error?.message || 'Upload fehlgeschlagen.',
finishedAt: new Date().toISOString()
}));
throw error;
} finally {
if (audiobookUploadAbortRef.current === abortController) {
audiobookUploadAbortRef.current = null;
}
}
};
const handleCancelAudiobookUpload = () => {
const activeAbortController = audiobookUploadAbortRef.current;
if (activeAbortController) {
activeAbortController.abort();
return;
}
setAudiobookUpload(createInitialAudiobookUploadState());
};
useEffect(() => {
refreshPipeline().catch(() => null);
api.getDownloadsSummary()
.then((response) => {
setDownloadSummary(response?.summary || null);
})
.catch(() => null);
api.getSettings()
.then((response) => {
const allSettings = (response?.categories || []).flatMap((c) => c.settings || []);
const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value;
setExpertMode(val === 'true' || val === true);
})
.catch(() => null);
return () => {
clearHardwareWarmupRetry();
};
}, []);
useWebSocket({
onMessage: (message) => {
if (message.type === 'WS_CONNECTED') {
refreshPipeline().catch(() => null);
}
if (message.type === 'PIPELINE_STATE_CHANGED') {
setPipeline((prev) => mergePipelineStatePreservingNewestQueue(prev, message.payload));
const nextState = normalizeStage(message?.payload?.state);
if (isTerminalStage(nextState) || isInteractiveReviewStage(nextState)) {
setRipperJobsRefreshToken((token) => token + 1);
setHistoryJobsRefreshToken((token) => token + 1);
}
}
if (message.type === 'PIPELINE_PROGRESS') {
const payload = message.payload;
const progressJobId = payload?.activeJobId;
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
? payload.contextPatch
: null;
setPipeline((prev) => {
const next = { ...prev };
const normalizedProgressJobId = normalizeJobId(progressJobId);
const progressStage = normalizeStage(payload?.state);
const isCdProgressStage = progressStage === 'CD_ANALYZING'
|| progressStage === 'CD_RIPPING'
|| progressStage === 'CD_ENCODING';
const incomingIsRunning = isRunningStage(progressStage);
const incomingIsTerminal = isTerminalStage(progressStage);
const prevActiveJobId = normalizeJobId(prev?.activeJobId || prev?.context?.jobId);
const prevJobProgress = normalizedProgressJobId
? (prev?.jobProgress?.[normalizedProgressJobId] || null)
: null;
const prevJobProgressState = normalizeStage(prevJobProgress?.state);
const prevJobProgressIsRunning = isRunningStage(prevJobProgressState);
const prevJobProgressIsTerminal = isTerminalStage(prevJobProgressState);
const matchingCdDriveEntry = normalizedProgressJobId
? Object.values(prev?.cdDrives || {}).find((driveState) => normalizeJobId(driveState?.jobId) === normalizedProgressJobId)
: null;
const matchingCdDriveState = normalizeStage(matchingCdDriveEntry?.state);
const matchingCdDriveIsRunning = isRunningStage(matchingCdDriveState);
const matchingCdDriveIsTerminal = isTerminalStage(matchingCdDriveEntry?.state);
const previousGlobalStage = normalizeStage(prev?.state);
const previousGlobalIsRunning = isRunningStage(previousGlobalStage);
const previousGlobalIsTerminal = isTerminalStage(previousGlobalStage);
const hasKnownBinding = Boolean(
normalizedProgressJobId
&& (
prevActiveJobId === normalizedProgressJobId
|| prevJobProgress
|| matchingCdDriveEntry
)
);
const regressesStableJobState = Boolean(
normalizedProgressJobId
&& incomingIsRunning
&& (
(prevJobProgressState && !prevJobProgressIsRunning && !prevJobProgressIsTerminal)
|| (matchingCdDriveState && !matchingCdDriveIsRunning && !matchingCdDriveIsTerminal)
|| (
prevActiveJobId === normalizedProgressJobId
&& previousGlobalStage
&& !previousGlobalIsRunning
&& !previousGlobalIsTerminal
)
)
);
const isUnknownRunningProgress = Boolean(
normalizedProgressJobId
&& incomingIsRunning
&& !hasKnownBinding
);
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
// earlier running stage because an older progress packet arrived late.
if (
normalizedProgressJobId
&& !incomingIsTerminal
&& !allowRunningTransitionFromTerminal
&& (
prevJobProgressIsTerminal
|| matchingCdDriveIsTerminal
|| isUnknownRunningProgress
|| regressesStableJobState
)
) {
return prev;
}
if (progressJobId != null) {
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
const previousJobStage = normalizeStage(previousJobProgress?.state);
const keepPreviousJobStage = isTerminalStage(previousJobStage)
&& !incomingIsTerminal
&& !allowRunningTransitionFromTerminal;
const mergedJobContext = contextPatch
? {
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
? previousJobProgress.context
: {}),
...contextPatch
}
: (previousJobProgress?.context && typeof previousJobProgress.context === 'object'
? previousJobProgress.context
: undefined);
next.jobProgress = {
...(prev?.jobProgress || {}),
[progressJobId]: {
...previousJobProgress,
state: keepPreviousJobStage ? previousJobProgress.state : payload.state,
progress: keepPreviousJobStage ? previousJobProgress.progress : payload.progress,
eta: keepPreviousJobStage ? previousJobProgress.eta : payload.eta,
statusText: keepPreviousJobStage ? previousJobProgress.statusText : payload.statusText,
...(mergedJobContext !== undefined ? { context: mergedJobContext } : {})
}
};
}
if (progressJobId === prev?.activeJobId || progressJobId == null) {
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);
next.statusText = keepPreviousGlobalStage ? prev?.statusText : (payload.statusText ?? prev?.statusText);
if (contextPatch) {
next.context = {
...(prev?.context && typeof prev.context === 'object' ? prev.context : {}),
...contextPatch
};
}
}
// Keep per-drive CD progress in sync with live progress events.
// Backend sends frequent PIPELINE_PROGRESS updates, while cdDrives
// snapshots are only broadcast on state transitions.
if (isCdProgressStage && prev?.cdDrives && typeof prev.cdDrives === 'object') {
const cdDrivesEntries = Object.entries(prev.cdDrives);
const nextCdDrives = { ...prev.cdDrives };
const patchDrive = (driveState) => {
const currentDriveStage = normalizeStage(driveState?.state);
if (isTerminalStage(currentDriveStage) && !incomingIsTerminal) {
return driveState;
}
const mergedContext = contextPatch
? {
...(driveState?.context && typeof driveState.context === 'object' ? driveState.context : {}),
...contextPatch
}
: driveState?.context;
return {
...driveState,
state: payload?.state ?? driveState?.state,
progress: payload?.progress ?? driveState?.progress,
eta: payload?.eta ?? driveState?.eta,
statusText: payload?.statusText ?? driveState?.statusText,
...(mergedContext !== undefined ? { context: mergedContext } : {})
};
};
let updated = false;
if (normalizedProgressJobId) {
for (const [drivePath, driveState] of cdDrivesEntries) {
if (normalizeJobId(driveState?.jobId) === normalizedProgressJobId) {
nextCdDrives[drivePath] = patchDrive(driveState);
updated = true;
}
}
}
if (!updated) {
const activeCdEntries = cdDrivesEntries.filter(([, driveState]) => {
const driveStage = String(driveState?.state || '').trim().toUpperCase();
return driveStage === 'CD_ANALYZING'
|| driveStage === 'CD_RIPPING'
|| driveStage === 'CD_ENCODING';
});
if (activeCdEntries.length === 1) {
const [drivePath, driveState] = activeCdEntries[0];
nextCdDrives[drivePath] = patchDrive(driveState);
updated = true;
}
}
if (updated) {
next.cdDrives = nextCdDrives;
}
}
return next;
});
}
if (message.type === 'PIPELINE_QUEUE_CHANGED') {
setPipeline((prev) => ({
...(prev || {}),
queue: message.payload || null
}));
setRipperJobsRefreshToken((prev) => prev + 1);
setHistoryJobsRefreshToken((prev) => prev + 1);
}
if (message.type === 'DISC_DETECTED') {
setLastDiscEvent(message.payload?.device || null);
refreshPipeline().catch(() => null);
}
if (message.type === 'DISC_REMOVED') {
setLastDiscEvent(null);
}
if (message.type === 'HARDWARE_MONITOR_UPDATE') {
setHardwareMonitoring(message.payload || null);
}
if (message.type === 'SETTINGS_UPDATED') {
const setting = message.payload;
if (setting?.key === 'ui_expert_mode') {
const val = setting?.value;
setExpertMode(val === 'true' || val === true);
}
const normalizedKey = String(setting?.key || '').trim().toLowerCase();
if (HARDWARE_MONITOR_SETTING_KEYS.has(normalizedKey)) {
refreshPipeline().catch(() => null);
}
}
if (message.type === 'SETTINGS_BULK_UPDATED') {
const keys = message.payload?.keys || [];
const normalizedKeys = keys.map((key) => String(key || '').trim().toLowerCase());
if (keys.includes('ui_expert_mode')) {
api.getSettings({ forceRefresh: true })
.then((response) => {
const allSettings = (response?.categories || []).flatMap((c) => c.settings || []);
const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value;
setExpertMode(val === 'true' || val === true);
})
.catch(() => null);
}
if (normalizedKeys.some((key) => HARDWARE_MONITOR_SETTING_KEYS.has(key))) {
refreshPipeline().catch(() => null);
}
}
if (message.type === 'DOWNLOADS_UPDATED') {
const summary = message.payload?.summary && typeof message.payload.summary === 'object'
? message.payload.summary
: null;
const reason = String(message.payload?.reason || '').trim().toLowerCase();
const item = message.payload?.item && typeof message.payload.item === 'object'
? message.payload.item
: null;
if (summary) {
setDownloadSummary(summary);
}
setDownloadsRefreshToken((prev) => prev + 1);
if (reason === 'ready' && item) {
globalToastRef.current?.show({
severity: 'success',
summary: 'ZIP fertig',
detail: `${item.archiveName || 'ZIP-Datei'} steht jetzt auf der Downloads-Seite bereit.`,
life: 4500
});
}
if (reason === 'failed' && item) {
globalToastRef.current?.show({
severity: 'error',
summary: 'ZIP fehlgeschlagen',
detail: item.errorMessage || `${item.archiveName || 'ZIP-Datei'} konnte nicht erstellt werden.`,
life: 5000
});
}
}
}
});
const nav = [
{ label: 'Ripper', path: '/ripper' },
{ label: 'Converter', path: '/converter' },
{ label: 'Audiobooks', path: '/audiobooks' },
{ label: 'Settings', path: '/settings' },
{ label: 'Historie', path: '/history' },
{ label: 'Downloads', path: '/downloads' },
...(expertMode ? [{ label: 'Database', path: '/database' }] : [])
];
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
const isNavActive = (path) => {
if (path === '/ripper') {
return location.pathname === '/' || location.pathname === '/ripper';
}
return location.pathname === path;
};
return (
<div className="app-shell">
<Toast ref={globalToastRef} position="top-right" />
<ConfirmDialog />
<header className="app-header">
<div className="brand-block">
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
<div className="brand-copy">
<h1>Ripster</h1>
<div className="brand-meta">
<p>Disc Ripping Control Center</p>
<span className="app-version" aria-label={`Version ${appVersion}`}>
v{appVersion}
</span>
</div>
</div>
</div>
<div className="nav-buttons">
{nav.map((item) => (
<Button
key={item.path}
label={item.label}
onClick={() => navigate(item.path)}
className={isNavActive(item.path) ? 'nav-btn nav-btn-active' : 'nav-btn'}
outlined={!isNavActive(item.path)}
/>
))}
</div>
</header>
<main className="app-main">
<Routes>
<Route
path="/"
element={
<RipperPage
pipeline={pipeline}
hardwareMonitoring={hardwareMonitoring}
lastDiscEvent={lastDiscEvent}
refreshPipeline={refreshPipeline}
jobsRefreshToken={ripperJobsRefreshToken}
downloadSummary={downloadSummary}
>
<Outlet />
</RipperPage>
}
>
<Route index element={<Navigate to="ripper" replace />} />
<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 />} />
<Route path="hardware" element={<HardwarePage hardwareMonitoring={hardwareMonitoring} />} />
<Route
path="audiobooks"
element={
<AudiobooksPage
audiobookUpload={audiobookUpload}
onAudiobookUpload={handleAudiobookUpload}
onCancelAudiobookUpload={handleCancelAudiobookUpload}
/>
}
/>
</Route>
</Routes>
</main>
</div>
);
}
export default App;
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Audiobook">
<circle cx="32" cy="32" r="30" fill="#f4f1da"/>
<path d="M32 14c-9.9 0-18 7.4-18 16.5 0 2.7.7 5.2 2 7.5v5c0 1.1.9 2 2 2h3c1.7 0 3-1.3 3-3v-6c0-1.7-1.3-3-3-3h-1.6C21.6 29.9 26.4 26 32 26s10.4 3.9 12.6 7h-1.6c-1.7 0-3 1.3-3 3v6c0 1.7 1.3 3 3 3h3c1.1 0 2-.9 2-2v-5c1.3-2.3 2-4.8 2-7.5C50 21.4 41.9 14 32 14z" fill="#2f3440"/>
</svg>

After

Width:  |  Height:  |  Size: 429 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Blu-ray">
<defs>
<linearGradient id="brg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2f9bff"/>
<stop offset="100%" stop-color="#0a3f86"/>
</linearGradient>
</defs>
<circle cx="32" cy="32" r="30" fill="url(#brg)"/>
<circle cx="32" cy="32" r="24" fill="none" stroke="#9cd4ff" stroke-width="2.5" opacity="0.7"/>
<text x="32" y="38" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="18" font-weight="700" fill="#ffffff">BR</text>
</svg>

After

Width:  |  Height:  |  Size: 588 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Disc">
<defs>
<radialGradient id="cdg" cx="40%" cy="35%" r="70%">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="70%" stop-color="#d8dde5"/>
<stop offset="100%" stop-color="#8f98a6"/>
</radialGradient>
</defs>
<circle cx="32" cy="32" r="30" fill="url(#cdg)"/>
<circle cx="32" cy="32" r="9" fill="#f7f9fc" stroke="#9ca6b5" stroke-width="2"/>
<path d="M15 25 A20 20 0 0 1 48 18" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" opacity="0.85"/>
<text x="32" y="54" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="11" font-weight="700" fill="#3f4a5d">DVD</text>
</svg>

After

Width:  |  Height:  |  Size: 742 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Merge job">
<defs>
<linearGradient id="mergebg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#eaf6ff"/>
<stop offset="100%" stop-color="#86b6d6"/>
</linearGradient>
</defs>
<circle cx="32" cy="32" r="30" fill="url(#mergebg)"/>
<path d="M14 18h15l7 8h10" fill="none" stroke="#1f2f3f" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 46h15l7-8h10" fill="none" stroke="#1f2f3f" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M45 24l7 2-7 2" fill="none" stroke="#1f2f3f" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M45 38l7-2-7-2" fill="none" stroke="#1f2f3f" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="34" cy="32" r="3" fill="#1f2f3f"/>
</svg>

After

Width:  |  Height:  |  Size: 898 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Other medium">
<defs>
<linearGradient id="othbg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f4f1da"/>
<stop offset="100%" stop-color="#bcae73"/>
</linearGradient>
</defs>
<circle cx="32" cy="32" r="30" fill="url(#othbg)"/>
<path d="M40 15v26.5c0 5.5-4 9.5-9.5 9.5C25.8 51 22 47.5 22 43c0-4.7 4-8.5 8.8-8.5 1.4 0 2.8.3 4.2 1V22.4l12-3.2V39c0 5.6-4 9.6-9.4 9.6-4.8 0-8.6-3.4-8.6-7.8 0-4.9 4.2-8.8 9.2-8.8 1 0 2 .2 2.8.5V15z" fill="#2f3440"/>
</svg>

After

Width:  |  Height:  |  Size: 575 B

@@ -0,0 +1,877 @@
import { useEffect, useMemo, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { Slider } from 'primereact/slider';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { InputText } from 'primereact/inputtext';
import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
import { api } from '../api/client';
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeFormat(value) {
const raw = String(value || '').trim().toLowerCase();
return AUDIOBOOK_FORMATS.some((entry) => entry.value === raw) ? raw : 'mp3';
}
function isFieldVisible(field, values) {
if (!field?.showWhen) {
return true;
}
return values?.[field.showWhen.field] === field.showWhen.value;
}
function buildFormatOptions(format, existingOptions = {}) {
return {
...getDefaultAudiobookFormatOptions(format),
...(existingOptions && typeof existingOptions === 'object' ? existingOptions : {})
};
}
function formatChapterTime(secondsValue) {
const totalSeconds = Number(secondsValue || 0);
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) {
return '-';
}
const rounded = Math.max(0, Math.round(totalSeconds));
const hours = Math.floor(rounded / 3600);
const minutes = Math.floor((rounded % 3600) / 60);
const seconds = rounded % 60;
if (hours > 0) {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function stripHtml(value) {
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
function truncateDescription(value, maxLength = 220) {
const normalized = stripHtml(value);
if (!normalized || normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength).trim()}...`;
}
function normalizeChapterTitle(value, index) {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
return normalized || `Kapitel ${index}`;
}
function normalizeEditableChapters(chapters = []) {
const source = Array.isArray(chapters) ? chapters : [];
return source.map((chapter, index) => {
const safeIndex = Number(chapter?.index);
const resolvedIndex = Number.isFinite(safeIndex) && safeIndex > 0 ? Math.trunc(safeIndex) : index + 1;
return {
index: resolvedIndex,
title: normalizeChapterTitle(chapter?.title, resolvedIndex),
startSeconds: Number(chapter?.startSeconds || 0),
endSeconds: Number(chapter?.endSeconds || 0),
startMs: Number(chapter?.startMs || 0),
endMs: Number(chapter?.endMs || 0)
};
});
}
function formatChapterDuration(startSecondsValue, endSecondsValue) {
const startSeconds = Number(startSecondsValue || 0);
const endSeconds = Number(endSecondsValue || 0);
if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || endSeconds <= startSeconds) {
return '-';
}
return formatChapterTime(endSeconds - startSeconds);
}
function normalizeTrackStageStatus(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') {
return 'done';
}
if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') {
return 'in_progress';
}
if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') {
return 'error';
}
return 'pending';
}
function trackStatusTagMeta(value) {
const normalized = normalizeTrackStageStatus(value);
if (normalized === 'done') {
return { label: 'Fertig', severity: 'success' };
}
if (normalized === 'in_progress') {
return { label: 'Läuft', severity: 'info' };
}
if (normalized === 'error') {
return { label: 'Fehler', severity: 'danger' };
}
return { label: 'Offen', severity: 'secondary' };
}
function normalizeScriptId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeChainId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeIdList(values, kind = 'script') {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function buildEncodeItemsFromConfig(config, phase) {
const source = config && typeof config === 'object' ? config : {};
const prefix = phase === 'post' ? 'post' : 'pre';
const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : [];
const fromExplicit = explicitItems
.map((item) => {
const type = String(item?.type || '').trim().toLowerCase();
if (type !== 'script' && type !== 'chain') {
return null;
}
const id = type === 'chain'
? normalizeChainId(item?.id ?? item?.chainId)
: normalizeScriptId(item?.id ?? item?.scriptId);
if (!id) {
return null;
}
return { type, id };
})
.filter(Boolean);
if (fromExplicit.length > 0) {
return fromExplicit;
}
const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script');
const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain');
return [
...scriptIds.map((id) => ({ type: 'script', id })),
...chainIds.map((id) => ({ type: 'chain', id }))
];
}
function FormatField({ field, value, onChange, disabled }) {
if (field.type === 'slider') {
return (
<div className="cd-format-field">
<label>
{field.label}: <strong>{value}</strong>
</label>
{field.description ? <small>{field.description}</small> : null}
<Slider
value={value}
onChange={(event) => onChange(field.key, event.value)}
min={field.min}
max={field.max}
step={field.step || 1}
disabled={disabled}
/>
</div>
);
}
if (field.type === 'select') {
return (
<div className="cd-format-field">
<label>{field.label}</label>
{field.description ? <small>{field.description}</small> : null}
<Dropdown
value={value}
options={field.options}
optionLabel="label"
optionValue="value"
onChange={(event) => onChange(field.key, event.value)}
disabled={disabled}
/>
</div>
);
}
return null;
}
export default function AudiobookConfigPanel({
pipeline,
onStart,
onCancel,
onDeleteJob,
busy
}) {
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase() || 'IDLE';
const jobId = normalizeJobId(context?.jobId);
const metadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object'
? context.selectedMetadata
: {};
const audiobookConfig = context?.audiobookConfig && typeof context.audiobookConfig === 'object'
? context.audiobookConfig
: (context?.mediaInfoReview && typeof context.mediaInfoReview === 'object' ? context.mediaInfoReview : {});
const initialFormat = normalizeFormat(audiobookConfig?.format);
const chapters = Array.isArray(metadata?.chapters)
? metadata.chapters
: (Array.isArray(context?.chapters) ? context.chapters : []);
const [format, setFormat] = useState(initialFormat);
const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions));
const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters));
const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false);
const [scriptCatalog, setScriptCatalog] = useState([]);
const [chainCatalog, setChainCatalog] = useState([]);
const [preRipItems, setPreRipItems] = useState([]);
const [postRipItems, setPostRipItems] = useState([]);
const audiobookConfigKey = JSON.stringify({
preEncodeScriptIds: normalizeIdList(audiobookConfig?.preEncodeScriptIds, 'script'),
postEncodeScriptIds: normalizeIdList(audiobookConfig?.postEncodeScriptIds, 'script'),
preEncodeChainIds: normalizeIdList(audiobookConfig?.preEncodeChainIds, 'chain'),
postEncodeChainIds: normalizeIdList(audiobookConfig?.postEncodeChainIds, 'chain'),
preEncodeItems: Array.isArray(audiobookConfig?.preEncodeItems) ? audiobookConfig.preEncodeItems : [],
postEncodeItems: Array.isArray(audiobookConfig?.postEncodeItems) ? audiobookConfig.postEncodeItems : []
});
useEffect(() => {
const nextFormat = normalizeFormat(audiobookConfig?.format);
setFormat(nextFormat);
setFormatOptions(buildFormatOptions(nextFormat, audiobookConfig?.formatOptions));
}, [jobId, audiobookConfig?.format, JSON.stringify(audiobookConfig?.formatOptions || {})]);
useEffect(() => {
setEditableChapters(normalizeEditableChapters(chapters));
}, [jobId, JSON.stringify(chapters || [])]);
useEffect(() => {
let cancelled = false;
const loadCatalog = async () => {
try {
const [scriptsResponse, chainsResponse] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]);
if (cancelled) {
return;
}
const scripts = scriptsResponse.status === 'fulfilled'
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
: [];
const chains = chainsResponse.status === 'fulfilled'
? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : [])
: [];
setScriptCatalog(scripts.map((item) => ({ id: item?.id, name: item?.name })));
setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name })));
} catch (_error) {
if (!cancelled) {
setScriptCatalog([]);
setChainCatalog([]);
}
}
};
void loadCatalog();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
setPreRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'pre'));
setPostRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'post'));
}, [jobId, audiobookConfigKey]);
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3;
const canStart = Boolean(jobId) && (
state === 'READY_TO_START'
|| state === 'READY_TO_ENCODE'
|| state === 'ERROR'
|| state === 'CANCELLED'
);
const isRunning = state === 'ENCODING';
const isFinished = state === 'FINISHED';
const isSplitOutput = format === 'mp3' || format === 'flac';
const showEditableChapters = !(isSplitOutput && isRunning);
const currentChapter = context?.currentChapter && typeof context.currentChapter === 'object' ? context.currentChapter : null;
const completedChapterCount = Number(context?.completedChapterCount ?? -1);
const chapterTotal = Number(currentChapter?.total || editableChapters.length || 0);
const statusLabel = getStatusLabel(state);
const statusSeverity = getStatusSeverity(state);
const description = String(metadata?.description || '').trim();
const descriptionStripped = stripHtml(description);
const descriptionPreview = truncateDescription(description);
const posterUrl = String(metadata?.poster || '').trim() || null;
const visibleFields = useMemo(
() => (Array.isArray(schema?.fields) ? schema.fields.filter((field) => isFieldVisible(field, formatOptions)) : []),
[schema, formatOptions]
);
const moveEncodeItem = (phase, index, direction) => {
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const list = Array.isArray(prev) ? [...prev] : [];
const from = Number(index);
const to = from + (direction === 'up' ? -1 : 1);
if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) {
return list;
}
const [moved] = list.splice(from, 1);
list.splice(to, 0, moved);
return list;
});
};
const addEncodeItem = (phase, type) => {
const normalizedType = type === 'chain' ? 'chain' : 'script';
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const selectedIds = new Set(
current
.filter((item) => item?.type === normalizedType)
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog;
const candidate = (Array.isArray(catalog) ? catalog : [])
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
.find((id) => id !== null && !selectedIds.has(String(id)));
if (candidate === undefined || candidate === null) {
return current;
}
return [...current, { type: normalizedType, id: candidate }];
});
};
const changeEncodeItem = (phase, index, type, nextId) => {
const normalizedType = type === 'chain' ? 'chain' : 'script';
const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId);
if (normalizedId === null) {
return;
}
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const rowIndex = Number(index);
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) {
return current;
}
const duplicate = current.some((item, itemIndex) => {
if (itemIndex === rowIndex) {
return false;
}
if (item?.type !== normalizedType) {
return false;
}
const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id);
return existingId !== null && String(existingId) === String(normalizedId);
});
if (duplicate) {
return current;
}
const next = [...current];
next[rowIndex] = { type: normalizedType, id: normalizedId };
return next;
});
};
const removeEncodeItem = (phase, index) => {
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const rowIndex = Number(index);
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) {
return current;
}
return current.filter((_, itemIndex) => itemIndex !== rowIndex);
});
};
return (
<div className="audiobook-config-panel">
<div className="audiobook-config-head">
<div className="audiobook-config-summary">
{posterUrl ? (
<div className="audiobook-config-cover">
<img src={posterUrl} alt={metadata?.title || 'Audiobook Cover'} />
</div>
) : null}
<div className="device-meta">
<div><strong>Titel:</strong> {metadata?.title || '-'}</div>
<div><strong>Autor:</strong> {metadata?.author || '-'}</div>
<div><strong>Sprecher:</strong> {metadata?.narrator || '-'}</div>
<div><strong>Jahr:</strong> {metadata?.year || '-'}</div>
<div><strong>Kapitel:</strong> {editableChapters.length || '-'}</div>
{descriptionPreview ? (
<div className="audiobook-description-preview">
<strong>Beschreibung:</strong>
<span>{descriptionPreview}</span>
{descriptionStripped.length > descriptionPreview.length ? (
<Button
type="button"
label="Vollständig anzeigen"
icon="pi pi-external-link"
text
size="small"
onClick={() => setDescriptionDialogVisible(true)}
/>
) : null}
</div>
) : null}
</div>
</div>
<div className="ripper-job-badges">
<Tag value={statusLabel} severity={statusSeverity} />
</div>
</div>
<div className="audiobook-config-grid">
<div className="audiobook-config-settings">
<div className="cd-format-field">
<label>Ausgabeformat</label>
<Dropdown
value={format}
options={AUDIOBOOK_FORMATS}
optionLabel="label"
optionValue="value"
onChange={(event) => {
const nextFormat = normalizeFormat(event.value);
setFormat(nextFormat);
setFormatOptions(buildFormatOptions(nextFormat, {}));
}}
disabled={busy || isRunning}
/>
</div>
{visibleFields.map((field) => (
<FormatField
key={`${format}-${field.key}`}
field={field}
value={formatOptions?.[field.key] ?? field.default ?? null}
onChange={(key, nextValue) => {
setFormatOptions((prev) => ({
...prev,
[key]: nextValue
}));
}}
disabled={busy || isRunning}
/>
))}
<small>
<code>m4b</code> erzeugt eine Datei mit bearbeitbaren Kapiteln. <code>mp3</code> und <code>flac</code> werden kapitelweise als einzelne Dateien erzeugt.
</small>
</div>
{showEditableChapters ? (
<div className="audiobook-config-chapters">
<h4>Kapitel</h4>
{editableChapters.length === 0 ? (
<small>Keine Kapitel in der Quelle erkannt.</small>
) : (
<div className="audiobook-chapter-list">
{editableChapters.map((chapter, index) => (
<div key={`${chapter.index}-${index}`} className="audiobook-chapter-row audiobook-chapter-row-editable">
<div className="audiobook-chapter-row-head">
<strong>#{chapter.index || index + 1}</strong>
<small>
{formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)}
</small>
</div>
<InputText
value={chapter.title}
onChange={(event) => {
const nextTitle = event.target.value;
setEditableChapters((prev) => prev.map((entry, entryIndex) => (
entryIndex === index
? { ...entry, title: nextTitle }
: entry
)));
}}
disabled={busy || isRunning}
/>
</div>
))}
</div>
)}
</div>
) : null}
</div>
{isSplitOutput && (isRunning || isFinished) && editableChapters.length > 0 ? (
<div className="audiobook-chapter-status">
<strong>Kapitel-Status ({completedChapterCount >= 0 ? completedChapterCount : (isFinished ? editableChapters.length : 0)}/{chapterTotal || editableChapters.length} fertig)</strong>
<div className="cd-track-selection">
<div className="cd-track-list">
<table className="cd-track-table">
<thead>
<tr>
<th className="check">Auswahl</th>
<th className="num">Nr</th>
<th className="title">Titel</th>
<th className="duration">Länge</th>
<th className="status">Rip</th>
<th className="status">Encode</th>
</tr>
</thead>
<tbody>
{editableChapters.map((chapter, idx) => {
const chIdx = chapter.index || idx + 1;
const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount);
const isActive = !isDone && currentChapter?.index === chIdx;
const ripMeta = trackStatusTagMeta('done');
const encodeMeta = trackStatusTagMeta(isDone ? 'done' : (isActive ? 'in_progress' : 'pending'));
return (
<tr key={chIdx} className="selected">
<td className="check">Ja</td>
<td className="num">{String(chIdx).padStart(2, '0')}</td>
<td className="title">{chapter.title || '-'}</td>
<td className="duration">{formatChapterDuration(chapter.startSeconds, chapter.endSeconds)}</td>
<td className="status"><Tag value={ripMeta.label} severity={ripMeta.severity} /></td>
<td className="status"><Tag value={encodeMeta.label} severity={encodeMeta.severity} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
) : null}
<div className="encode-automation-grid">
<div className="post-script-box">
<h4>Pre-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{preRipItems.length === 0 ? (
<small>Keine Pre-Rip Ausführungen ausgewählt.</small>
) : null}
{preRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script';
const usedScriptIds = new Set(
preRipItems
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
.map((entry) => normalizeScriptId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
preRipItems
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
.map((entry) => normalizeChainId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((entry) => ({
label: entry?.name || `Skript #${entry?.id}`,
value: normalizeScriptId(entry?.id),
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
})).filter((entry) => entry.value !== null);
const chainOptions = chainCatalog.map((entry) => ({
label: entry?.name || `Kette #${entry?.id}`,
value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null);
return (
<div key={`ab-pre-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order">
<Button
icon="pi pi-angle-up"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('pre', rowIndex, 'up')}
disabled={busy || rowIndex <= 0}
/>
<Button
icon="pi pi-angle-down"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('pre', rowIndex, 'down')}
disabled={busy || rowIndex >= preRipItems.length - 1}
/>
</div>
{isScript ? (
<Dropdown
value={normalizeScriptId(item?.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('pre', rowIndex, 'script', event.value)}
className="full-width"
disabled={busy}
/>
) : (
<Dropdown
value={normalizeChainId(item?.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('pre', rowIndex, 'chain', event.value)}
className="full-width"
disabled={busy}
/>
)}
<Button
icon="pi pi-times"
severity="danger"
outlined
onClick={() => removeEncodeItem('pre', rowIndex)}
disabled={busy}
/>
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'chain')}
disabled={busy}
/>
) : null}
</div>
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small>
</div>
<div className="post-script-box">
<h4>Post-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{postRipItems.length === 0 ? (
<small>Keine Post-Rip Ausführungen ausgewählt.</small>
) : null}
{postRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script';
const usedScriptIds = new Set(
postRipItems
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
.map((entry) => normalizeScriptId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
postRipItems
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
.map((entry) => normalizeChainId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((entry) => ({
label: entry?.name || `Skript #${entry?.id}`,
value: normalizeScriptId(entry?.id),
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
})).filter((entry) => entry.value !== null);
const chainOptions = chainCatalog.map((entry) => ({
label: entry?.name || `Kette #${entry?.id}`,
value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null);
return (
<div key={`ab-post-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order">
<Button
icon="pi pi-angle-up"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('post', rowIndex, 'up')}
disabled={busy || rowIndex <= 0}
/>
<Button
icon="pi pi-angle-down"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('post', rowIndex, 'down')}
disabled={busy || rowIndex >= postRipItems.length - 1}
/>
</div>
{isScript ? (
<Dropdown
value={normalizeScriptId(item?.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('post', rowIndex, 'script', event.value)}
className="full-width"
disabled={busy}
/>
) : (
<Dropdown
value={normalizeChainId(item?.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('post', rowIndex, 'chain', event.value)}
className="full-width"
disabled={busy}
/>
)}
<Button
icon="pi pi-times"
severity="danger"
outlined
onClick={() => removeEncodeItem('post', rowIndex)}
disabled={busy}
/>
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'chain')}
disabled={busy}
/>
) : null}
</div>
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
</div>
</div>
<div className="actions-row" style={{ marginTop: '1rem' }}>
{canStart ? (
<Button
label={(state === 'READY_TO_START' || state === 'READY_TO_ENCODE')
? 'Encode starten'
: 'Mit diesen Einstellungen starten'}
icon="pi pi-play"
severity="success"
onClick={() => onStart?.({
format,
formatOptions,
chapters: editableChapters.map((chapter, index) => ({
index: chapter.index || index + 1,
title: normalizeChapterTitle(chapter.title, chapter.index || index + 1),
startSeconds: chapter.startSeconds,
endSeconds: chapter.endSeconds,
startMs: chapter.startMs,
endMs: chapter.endMs
})),
selectedPreEncodeScriptIds: normalizeIdList(
preRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
'script'
),
selectedPostEncodeScriptIds: normalizeIdList(
postRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
'script'
),
selectedPreEncodeChainIds: normalizeIdList(
preRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
'chain'
),
selectedPostEncodeChainIds: normalizeIdList(
postRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
'chain'
)
})}
loading={busy}
disabled={!jobId}
/>
) : null}
{jobId ? (
<Button
label="Job löschen"
icon="pi pi-trash"
severity="danger"
outlined
onClick={() => onDeleteJob?.(jobId)}
loading={busy}
/>
) : null}
<Button
label="Abbrechen"
severity="secondary"
outlined
onClick={() => onCancel?.()}
loading={busy}
disabled={!jobId}
/>
</div>
<Dialog
header="Beschreibung"
visible={descriptionDialogVisible}
style={{ width: 'min(48rem, 92vw)' }}
onHide={() => setDescriptionDialogVisible(false)}
>
<div
className="audiobook-description-dialog"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: description || '<p>Keine Beschreibung vorhanden.</p>' }}
/>
</Dialog>
</div>
);
}
@@ -0,0 +1,289 @@
import { useRef, useState } from 'react';
import { FileUpload } from 'primereact/fileupload';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Toast } from 'primereact/toast';
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 normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
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
);
}
export default function AudiobookUploadPanel({
audiobookUpload,
onAudiobookUpload,
onCancelUpload = null,
onUploaded = null
}) {
const toastRef = useRef(null);
const fileUploadRef = useRef(null);
const fallbackFileInputRef = useRef(null);
const [uploadFile, setUploadFile] = useState(null);
const phase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
const uploadBusy = phase === 'uploading' || phase === 'processing';
const progress = Number.isFinite(Number(audiobookUpload?.progressPercent))
? Math.max(0, Math.min(100, Number(audiobookUpload.progressPercent)))
: 0;
const loadedBytes = Number(audiobookUpload?.loadedBytes || 0);
const totalBytes = Number(audiobookUpload?.totalBytes || 0);
const progressLabel = phase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: totalBytes > 0
? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
: `${Math.trunc(progress)}%`;
const hasHeaderStatus = phase !== 'idle' || Boolean(uploadFile);
const canStartUpload = Boolean(uploadFile) && !uploadBusy;
const canCancelUpload = uploadBusy && typeof onCancelUpload === 'function';
const canClearSelection = Boolean(uploadFile) && !uploadBusy;
const handleUpload = async () => {
if (!uploadFile) {
toastRef.current?.show({
severity: 'warn',
summary: 'Keine Datei',
detail: 'Bitte zuerst eine AAX-Datei auswaehlen.',
life: 2600
});
return;
}
try {
const response = await onAudiobookUpload?.(uploadFile, { startImmediately: false });
const uploadedJobId = extractUploadJobIdFromResponse(response);
if (uploadedJobId) {
toastRef.current?.show({
severity: 'success',
summary: 'Audiobook importiert',
detail: `Job #${uploadedJobId} ist bereit.`,
life: 3200
});
} else {
toastRef.current?.show({
severity: 'success',
summary: 'Audiobook importiert',
detail: 'Upload abgeschlossen.',
life: 2600
});
}
setUploadFile(null);
fileUploadRef.current?.clear?.();
onUploaded?.(uploadedJobId, response);
} catch (error) {
if (error?.name === 'AbortError') {
toastRef.current?.show({
severity: 'info',
summary: 'Upload abgebrochen',
detail: 'Der Audiobook-Upload wurde gestoppt.',
life: 2800
});
return;
}
toastRef.current?.show({
severity: 'error',
summary: 'Upload fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
}
};
const handleFileSelected = (file) => {
if (!file) {
setUploadFile(null);
return;
}
setUploadFile(file);
};
const handleFileCleared = () => {
setUploadFile(null);
};
const handleChooseFile = () => {
if (uploadBusy) {
return;
}
fallbackFileInputRef.current?.click();
};
const handleFileItemAction = (onRemove = null) => {
if (canCancelUpload) {
void onCancelUpload();
onRemove?.();
fileUploadRef.current?.clear?.();
handleFileCleared();
return;
}
onRemove?.();
fileUploadRef.current?.clear?.();
handleFileCleared();
};
const renderFileRow = (file, onRemove = null) => (
<div className="aax-file-item">
<i className="pi pi-headphones aax-file-icon" />
<div className="aax-file-info">
<span className="aax-file-name" title={file?.name}>{file?.name || 'upload.aax'}</span>
<small>{formatBytes(Number(file?.size || 0))}</small>
{hasHeaderStatus ? (
<div className="aax-file-status">
<>
<small className="audiobook-upload-inline-text">{progressLabel}</small>
<ProgressBar value={progress} showValue={false} />
{audiobookUpload?.statusText ? (
<small className="audiobook-upload-inline-text">{audiobookUpload.statusText}</small>
) : null}
</>
</div>
) : null}
</div>
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
onClick={() => {
handleFileItemAction(onRemove);
}}
disabled={!canCancelUpload && !canClearSelection}
tooltip={canCancelUpload ? 'Upload abbrechen' : 'Auswahl entfernen'}
tooltipOptions={{ position: 'left' }}
/>
</div>
);
return (
<div className="audiobook-upload-panel">
<Toast ref={toastRef} position="top-right" />
<FileUpload
ref={fileUploadRef}
accept=".aax"
maxFileSize={10737418240}
customUpload
uploadHandler={() => void handleUpload()}
disabled={uploadBusy}
onSelect={(event) => {
handleFileSelected(event.files[0] || null);
}}
onClear={() => {
handleFileCleared();
}}
onRemove={() => {
handleFileCleared();
}}
chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }}
uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }}
cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }}
headerTemplate={(options) => (
<div className={options.className}>
<div className="audiobook-upload-header">
<div className="audiobook-upload-header-actions">
<Button
icon="pi pi-images"
rounded
outlined
aria-label="Datei auswählen"
onClick={() => {
void handleChooseFile();
}}
disabled={uploadBusy}
/>
<Button
icon="pi pi-cloud-upload"
rounded
outlined
severity="success"
aria-label="Upload starten"
onClick={() => {
void handleUpload();
}}
disabled={!canStartUpload}
/>
<Button
icon="pi pi-times"
rounded
outlined
severity="secondary"
aria-label="Auswahl entfernen"
onClick={() => {
fileUploadRef.current?.clear?.();
handleFileCleared();
}}
disabled={!canClearSelection}
/>
</div>
</div>
</div>
)}
itemTemplate={(file, options) => (
renderFileRow(file, () => {
options.onRemove?.();
})
)}
emptyTemplate={() => (
uploadFile
? renderFileRow(uploadFile, () => {
// handled by handleFileItemAction
})
: (
<div className="aax-drop-zone">
<i className="pi pi-headphones aax-drop-icon" />
<p>AAX-Datei hier ablegen</p>
<small>oder oben "Auswaehlen" klicken</small>
</div>
)
)}
/>
<input
ref={fallbackFileInputRef}
type="file"
accept=".aax,audio/vnd.audible.aax"
style={{ display: 'none' }}
onChange={(event) => {
const file = event.target?.files?.[0] || null;
handleFileSelected(file);
if (event.target) {
event.target.value = '';
}
}}
/>
</div>
);
}
@@ -0,0 +1,319 @@
import { useEffect, useRef, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext';
import { InputNumber } from 'primereact/inputnumber';
function CoverThumb({ url, alt }) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [url]);
if (!url || failed) {
return <div className="poster-thumb-lg poster-fallback">-</div>;
}
return (
<img
src={url}
alt={alt}
className="poster-thumb-lg"
loading="eager"
decoding="sync"
onError={() => setFailed(true)}
/>
);
}
const COVER_PRELOAD_TIMEOUT_MS = 3000;
function preloadCoverImage(url) {
const src = String(url || '').trim();
if (!src) {
return Promise.resolve();
}
return new Promise((resolve) => {
const image = new Image();
let settled = false;
const cleanup = () => {
image.onload = null;
image.onerror = null;
};
const done = () => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve();
};
const timer = window.setTimeout(done, COVER_PRELOAD_TIMEOUT_MS);
image.onload = () => {
window.clearTimeout(timer);
done();
};
image.onerror = () => {
window.clearTimeout(timer);
done();
};
image.src = src;
});
}
export default function CdMetadataDialog({
visible,
context,
onHide,
onSubmit,
onSearch,
onFetchRelease,
busy
}) {
const [selected, setSelected] = useState(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [searchBusy, setSearchBusy] = useState(false);
const searchRunRef = useRef(0);
// Manual metadata inputs
const [manualTitle, setManualTitle] = useState('');
const [manualArtist, setManualArtist] = useState('');
const [manualYear, setManualYear] = useState(null);
// Track titles are pre-filled from MusicBrainz and edited in the next step.
const [trackTitles, setTrackTitles] = useState({});
const tocTracks = Array.isArray(context?.tracks) ? context.tracks : [];
const contextJobId = Number(context?.jobId || 0);
useEffect(() => {
if (!visible) {
return;
}
setSelected(null);
setQuery('');
setManualTitle('');
setManualArtist('');
setManualYear(null);
setResults([]);
setSearchBusy(false);
const titles = {};
for (const t of tocTracks) {
titles[t.position] = t.title || `Track ${t.position}`;
}
setTrackTitles(titles);
}, [visible, contextJobId]);
useEffect(() => {
if (!selected) {
return;
}
setManualTitle(selected.title || '');
setManualArtist(selected.artist || '');
setManualYear(selected.year || null);
// Pre-fill track titles from the MusicBrainz result
if (Array.isArray(selected.tracks) && selected.tracks.length > 0) {
const titles = {};
for (const t of selected.tracks) {
if (t.position <= tocTracks.length) {
titles[t.position] = t.title || `Track ${t.position}`;
}
}
// Fill any remaining tracks not in MB result
for (const t of tocTracks) {
if (!titles[t.position]) {
titles[t.position] = t.title || `Track ${t.position}`;
}
}
setTrackTitles(titles);
}
}, [selected]);
const handleSearch = async () => {
const trimmedQuery = query.trim();
if (!trimmedQuery) {
return;
}
setSearchBusy(true);
const searchRunId = searchRunRef.current + 1;
searchRunRef.current = searchRunId;
try {
const expectedTrackCount = Array.isArray(tocTracks) ? tocTracks.length : 0;
const searchResults = await onSearch(trimmedQuery, {
trackCount: expectedTrackCount > 0 ? expectedTrackCount : null
});
const normalizedResults = Array.isArray(searchResults) ? searchResults : [];
await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl)));
if (searchRunRef.current !== searchRunId) {
return;
}
setResults(normalizedResults);
setSelected(null);
} finally {
if (searchRunRef.current === searchRunId) {
setSearchBusy(false);
}
}
};
const handleSubmit = async () => {
const normalizeTrackText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
let releaseDetails = selected;
if (selected?.mbId && (!Array.isArray(selected?.tracks) || selected.tracks.length === 0) && typeof onFetchRelease === 'function') {
const fetched = await onFetchRelease(selected.mbId);
if (fetched && typeof fetched === 'object') {
releaseDetails = fetched;
}
}
const releaseTracks = Array.isArray(releaseDetails?.tracks) ? releaseDetails.tracks : [];
const releaseTracksByPosition = new Map();
releaseTracks.forEach((track, index) => {
const parsedPosition = Number(track?.position);
const normalizedPosition = Number.isFinite(parsedPosition) && parsedPosition > 0
? Math.trunc(parsedPosition)
: index + 1;
if (!releaseTracksByPosition.has(normalizedPosition)) {
releaseTracksByPosition.set(normalizedPosition, track);
}
});
const tracks = tocTracks.map((t, index) => {
const position = Number(t.position);
const byPosition = releaseTracksByPosition.get(position);
const byIndex = releaseTracks[index];
return {
position,
title: normalizeTrackText(
byPosition?.title
|| byIndex?.title
|| trackTitles[t.position]
) || `Track ${t.position}`,
artist: normalizeTrackText(
byPosition?.artist
|| byIndex?.artist
|| manualArtist.trim()
|| releaseDetails?.artist
) || null,
selected: true
};
});
const payload = {
jobId: context.jobId,
title: manualTitle.trim() || context?.detectedTitle || 'Audio CD',
artist: manualArtist.trim() || null,
year: manualYear || null,
mbId: releaseDetails?.mbId || selected?.mbId || null,
coverUrl: releaseDetails?.coverArtUrl || selected?.coverArtUrl || null,
tracks
};
await onSubmit(payload);
};
const mbTitleBody = (row) => (
<div className="mb-result-row">
<CoverThumb url={row.coverArtUrl} alt={row.title} />
<div>
<div><strong>{row.title}</strong></div>
<small>{row.artist}{row.year ? ` | ${row.year}` : ''}</small>
{row.label ? <small> | {row.label}</small> : null}
</div>
</div>
);
return (
<Dialog
header="CD-Metadaten auswählen"
visible={visible}
onHide={onHide}
style={{ width: '58rem', maxWidth: '97vw' }}
className="cd-metadata-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
modal
>
{/* MusicBrainz search */}
<div className="search-row">
<InputText
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Album / Interpret suchen"
/>
<Button
label="MusicBrainz Suche"
icon="pi pi-search"
onClick={handleSearch}
loading={busy || searchBusy}
/>
</div>
{results.length > 0 ? (
<div className="table-scroll-wrap table-scroll-medium">
<DataTable
value={results}
selectionMode="single"
selection={selected}
onSelectionChange={(e) => setSelected(e.value)}
dataKey="mbId"
size="small"
scrollable
scrollHeight="16rem"
emptyMessage="Keine Treffer"
>
<Column header="Album" body={mbTitleBody} />
<Column field="trackCount" header="Tracks" style={{ width: '6rem' }} />
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
<Column field="country" header="Land" style={{ width: '6rem' }} />
</DataTable>
</div>
) : null}
{/* Manual metadata */}
<h4 style={{ marginTop: '1rem', marginBottom: '0.5rem' }}>Metadaten</h4>
<div className="metadata-grid">
<InputText
value={manualTitle}
onChange={(e) => setManualTitle(e.target.value)}
placeholder="Album-Titel"
/>
<InputText
value={manualArtist}
onChange={(e) => setManualArtist(e.target.value)}
placeholder="Interpret / Band"
/>
<InputNumber
value={manualYear}
onValueChange={(e) => setManualYear(e.value)}
placeholder="Jahr"
useGrouping={false}
min={1900}
max={2100}
/>
</div>
{/* Track selection/editing moved to CD-Rip configuration panel */}
{tocTracks.length > 0 ? (
<small style={{ display: 'block', marginTop: '0.9rem' }}>
{tocTracks.length} Tracks erkannt. Auswahl/Feinschliff (Checkboxen, Interpret, Titel, Länge) erfolgt im nächsten Schritt in der Job-Übersicht.
</small>
) : null}
<div className="dialog-actions" style={{ marginTop: '1rem' }}>
<Button label="Abbrechen" severity="secondary" text onClick={onHide} />
<Button
label="Weiter"
icon="pi pi-arrow-right"
onClick={handleSubmit}
loading={busy}
disabled={!manualTitle.trim() && !context?.detectedTitle}
/>
</div>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,992 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { ProgressSpinner } from 'primereact/progressspinner';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
import { Toast } from 'primereact/toast';
import { api } from '../api/client';
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
function formatBytes(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0; let v = n;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(i <= 1 ? 0 : 1)} ${units[i]}`;
}
function mediaTypeBadge(type) {
if (!type) return null;
const map = { video: { label: 'Video', severity: 'info' }, audio: { label: 'Audio', severity: 'success' }, iso: { label: 'ISO', severity: 'warning' } };
const m = map[type] || { label: type, severity: 'secondary' };
return <Tag value={m.label} severity={m.severity} />;
}
/** Navigiert den Baum per Pfad-String (Ordner UND Dateien) */
function getNodeByPath(root, targetPath) {
if (!root) return null;
if ((root.path || '') === (targetPath || '')) return root;
for (const child of (root.children || [])) {
if (child.type === 'folder') {
const found = getNodeByPath(child, targetPath);
if (found) return found;
} else if ((child.path || '') === (targetPath || '')) {
return child;
}
}
return null;
}
/** Kinder des aktuellen Knotens (Ordner zuerst, alphabetisch) */
function listChildren(node) {
if (!node || !Array.isArray(node.children)) return [];
return node.children; // buildRawTree liefert bereits sortiert
}
/** Breadcrumb aus Pfad-String */
function buildBreadcrumb(pathStr) {
if (!pathStr) return [];
const parts = String(pathStr).split('/').filter(Boolean);
return parts.map((part, i) => ({ name: part, path: parts.slice(0, i + 1).join('/') }));
}
/** Baum nach Ordnername filtern (nur Ordner in der Seitenleiste) */
function filterTree(node, query) {
if (!node || node.type !== 'folder') return null;
if (!query || !query.trim()) return node;
const q = query.toLowerCase();
const filteredChildren = (node.children || [])
.filter((c) => c.type === 'folder')
.map((c) => filterTree(c, query))
.filter(Boolean);
const nameMatches = node.name.toLowerCase().includes(q);
if (nameMatches || filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
return null;
}
/** Alle Datei-Pfade innerhalb eines Knotens rekursiv sammeln */
function collectDescendantFilePaths(node) {
const result = [];
for (const child of (node?.children || [])) {
if (child.type === 'file') result.push(child.path || '');
else if (child.type === 'folder') result.push(...collectDescendantFilePaths(child));
}
return result;
}
/** Zustände, in denen ein Job aktiv läuft → Dateien sind gesperrt */
const LOCKED_JOB_STATES = new Set([
'ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK',
'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'
]);
/** Datei ist gesperrt wenn ihr Job gerade aktiv läuft */
function isNodeLocked(node) {
if (!node || node.type !== 'file') return false;
const jobId = Number(node.jobId);
if (!Number.isFinite(jobId) || jobId <= 0) return false;
return LOCKED_JOB_STATES.has(String(node.jobStatus || '').trim().toUpperCase());
}
/** Alle nicht bereits einem Job zugewiesenen und nicht gesperrten Dateipfade sammeln */
function collectDescendantSelectableFilePaths(node) {
const result = [];
for (const child of (node?.children || [])) {
if (child.type === 'file') {
const assignedJobId = Number(child.jobId);
if (!Number.isFinite(assignedJobId) || assignedJobId <= 0) {
result.push(child.path || '');
}
} else if (child.type === 'folder') {
result.push(...collectDescendantSelectableFilePaths(child));
}
}
return result;
}
/** Alle nicht bereits einem Job zugewiesenen und nicht gesperrten Datei-Knoten sammeln */
function collectDescendantSelectableFileNodes(node) {
const result = [];
for (const child of (node?.children || [])) {
if (child.type === 'file') {
const assignedJobId = Number(child.jobId);
if (!Number.isFinite(assignedJobId) || assignedJobId <= 0) {
result.push(child);
}
} else if (child.type === 'folder') {
result.push(...collectDescendantSelectableFileNodes(child));
}
}
return result;
}
/** Nur "Wurzel"-Selektionen: Pfade, die keinen ausgewählten Elternpfad haben */
function getRootSelections(paths) {
const set = new Set(paths);
return paths.filter((p) =>
!paths.some((other) => other !== p && p.startsWith(other + '/') && set.has(other))
);
}
/** Alle Ordner-Pfade für das Verschieben-Dropdown sammeln */
function collectFolderPaths(node, result = []) {
if (!node || node.type !== 'folder') return result;
result.push({ label: node.path ? node.path : '/ (Root)', value: node.path || '' });
for (const child of (node.children || [])) {
if (child.type === 'folder') collectFolderPaths(child, result);
}
return result;
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function ConverterFileExplorer({
onSelectionChange,
refreshToken,
selectionResetToken,
navigateToPath,
onAssignmentChanged
}) {
const toastRef = useRef(null);
const explorerRef = useRef(null);
// Daten
const [tree, setTree] = useState(null);
const [rawDir, setRawDir] = useState(null);
const [loading, setLoading] = useState(false);
// Navigation
const [currentPath, setCurrentPath] = useState('');
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
// Auswahl (Checkbox → Job-Zuweisung)
const [selectedPaths, setSelectedPaths] = useState([]);
// Aktive Zeilen-Auswahl (Klick → Rename/Delete/Move)
const [activePaths, setActivePaths] = useState([]);
const [activeAnchor, setActiveAnchor] = useState(null);
// Seitenleiste
const [sidebarQuery, setSidebarQuery] = useState('');
// Modals
const [activeModal, setActiveModal] = useState('');
const [newFolderName, setNewFolderName] = useState('');
const [renameName, setRenameName] = useState('');
const [moveTarget, setMoveTarget] = useState('');
const [busy, setBusy] = useState(false);
const [jobUnassignTarget, setJobUnassignTarget] = useState(null);
// Stabile Ref für onSelectionChange
const onSelectionChangeRef = useRef(onSelectionChange);
useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]);
const onAssignmentChangedRef = useRef(onAssignmentChanged);
useEffect(() => { onAssignmentChangedRef.current = onAssignmentChanged; }, [onAssignmentChanged]);
// ── Baum laden ─────────────────────────────────────────────────────────────
const loadTree = useCallback(async () => {
setLoading(true);
try {
const data = await api.converterGetTree();
setTree(data.tree || null);
setRawDir(data.rawDir || null);
} catch (err) {
console.error('ConverterFileExplorer: tree load error', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadTree(); }, [loadTree, refreshToken]);
useEffect(() => {
setSelectedPaths([]);
setActivePaths([]);
setActiveAnchor(null);
}, [selectionResetToken]);
// Auto-Navigation nach Upload: Baum neu laden, dann in Zielordner navigieren
// navigateToPath ist { path: string, ts: number } damit jeder Upload einen neuen Trigger auslöst
useEffect(() => {
if (!navigateToPath?.path) return;
loadTree().then(() => {
navigateTo(navigateToPath.path);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigateToPath]);
// Auswahl an Eltern melden: Ordner werden zu ihren Datei-Kindern expandiert
useEffect(() => {
if (!tree) return;
setSelectedPaths((prev) => {
const next = prev.filter((entryPath) => {
const node = getNodeByPath(tree, entryPath);
if (!node) return false;
if (node.type !== 'file') return true;
const assignedJobId = Number(node.jobId);
return !Number.isFinite(assignedJobId) || assignedJobId <= 0;
});
return next.length === prev.length ? prev : next;
});
const rootPaths = getRootSelections(selectedPaths);
const report = [];
for (const p of rootPaths) {
const node = getNodeByPath(tree, p);
if (!node) continue;
if (node.type === 'folder') {
const fileNodes = collectDescendantSelectableFileNodes(node);
if (fileNodes.length > 0) {
for (const fn of fileNodes) {
report.push({
relPath: fn.path,
entryType: 'file',
detectedMediaType: fn.detectedMediaType || null,
detectedFormat: fn.detectedFormat || null
});
}
} else {
// Leerer Ordner: als Ordner-Eintrag weitergeben
report.push({ relPath: node.path, entryType: 'directory', detectedMediaType: null, detectedFormat: null });
}
} else {
report.push({
relPath: node.path,
entryType: 'file',
detectedMediaType: node.detectedMediaType || null,
detectedFormat: node.detectedFormat || null
});
}
}
onSelectionChangeRef.current?.(report);
}, [selectedPaths, tree]);
// Auswahl bleibt erhalten — kein Outside-Click-Handler
// ── Navigation ─────────────────────────────────────────────────────────────
const currentNode = getNodeByPath(tree, currentPath);
const currentItems = listChildren(currentNode);
const filteredTree = filterTree(tree, sidebarQuery);
const topLevelFolders = tree ? (tree.children || []).filter((c) => c.type === 'folder') : [];
function navigateTo(pathStr) {
setCurrentPath(pathStr || '');
setActivePaths([]);
setActiveAnchor(null);
setExpandedFolders((prev) => new Set(prev).add(pathStr || ''));
}
function handleGoUp() {
if (!currentPath) return;
const parts = currentPath.split('/').filter(Boolean);
parts.pop();
navigateTo(parts.join('/'));
}
function toggleFolder(pathValue) {
const key = pathValue || '';
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
// ── Auswahl ────────────────────────────────────────────────────────────────
/** Gibt true wenn item.path direkt oder als Kind eines ausgewählten Ordners selektiert ist */
function isSelected(pathValue) {
const p = pathValue || '';
if (selectedPaths.includes(p)) return true;
return selectedPaths.some((sel) => p.startsWith(sel + '/'));
}
/** Unbestimmter Zustand: Ordner, von dem nur Teile selektiert sind */
function isIndeterminate(item) {
if (item.type !== 'folder') return false;
const p = item.path || '';
if (selectedPaths.includes(p)) return false;
const folderNode = getNodeByPath(tree, p);
const allFiles = collectDescendantFilePaths(folderNode);
return allFiles.length > 0 && allFiles.some((fp) => selectedPaths.includes(fp));
}
function handleCheckboxChange(item, checked) {
const p = item.path || '';
if (item.type === 'file') {
// Gesperrte Dateien (Job läuft) → keine Aktion möglich
if (isNodeLocked(item)) return;
const assignedJobId = Number(item.jobId);
const isAssigned = Number.isFinite(assignedJobId) && assignedJobId > 0;
if (isAssigned && !checked) {
setJobUnassignTarget({
relPath: p,
jobId: Math.trunc(assignedJobId),
jobTitle: String(item.jobTitle || '').trim() || null
});
setActiveModal('job-unassign');
return;
}
if (isAssigned) {
return;
}
}
if (item.type === 'folder') {
const folderNode = getNodeByPath(tree, p);
const descendantFiles = collectDescendantSelectableFilePaths(folderNode);
if (checked) {
if (descendantFiles.length === 0) return;
setSelectedPaths((prev) => Array.from(new Set([...prev, p, ...descendantFiles])));
} else {
const toRemove = new Set([p, ...descendantFiles]);
setSelectedPaths((prev) => prev.filter((x) => !toRemove.has(x)));
}
} else {
const allFilesInCurrentView = currentItems.filter((i) => i.type === 'file').map((i) => i.path || '');
if (checked) {
setSelectedPaths((prev) => {
const next = Array.from(new Set([...prev, p]));
// Wenn alle Dateien im aktuellen Ordner gewählt → Ordner auch auswählen
if (currentPath && allFilesInCurrentView.length > 0 &&
allFilesInCurrentView.every((fp) => next.includes(fp)) &&
!next.includes(currentPath)) {
return [...next, currentPath];
}
return next;
});
} else {
setSelectedPaths((prev) => {
const next = prev.filter((x) => x !== p);
// Ordner abwählen wenn eine seiner Dateien abgewählt wird
if (currentPath && next.includes(currentPath)) {
return next.filter((x) => x !== currentPath);
}
return next;
});
}
}
}
async function handleRemoveFromJob() {
if (!jobUnassignTarget?.jobId || !jobUnassignTarget?.relPath) return;
setBusy(true);
try {
const result = await api.converterRemoveFileFromJob(jobUnassignTarget.jobId, jobUnassignTarget.relPath);
const removedRelPath = String(result?.removedRelPath || jobUnassignTarget.relPath || '').trim();
toastRef.current?.show({
severity: 'success',
summary: 'Aus Job entfernt',
detail: removedRelPath || 'Datei wurde aus dem Job entfernt.',
life: 2800
});
setSelectedPaths((prev) => prev.filter((entryPath) => entryPath !== removedRelPath));
setJobUnassignTarget(null);
setActiveModal('');
await loadTree();
onAssignmentChangedRef.current?.();
} catch (err) {
toastRef.current?.show({
severity: 'error',
summary: 'Fehler',
detail: err.message || 'Datei konnte nicht aus dem Job entfernt werden.',
life: 4200
});
} finally {
setBusy(false);
}
}
function handleOpen(item) {
if (item.type !== 'folder') return;
navigateTo(item.path || '');
}
// ── Datei-Operationen ──────────────────────────────────────────────────────
async function handleCreateFolder() {
const name = newFolderName.trim();
if (!name) return;
setBusy(true);
try {
await api.converterCreateFolder(currentPath, name);
toastRef.current?.show({ severity: 'success', summary: 'Ordner erstellt', detail: name, life: 2500 });
setNewFolderName('');
setActiveModal('');
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
} finally { setBusy(false); }
}
async function handleRename() {
if (!activePaths.length) return;
const name = renameName.trim();
if (!name) return;
setBusy(true);
try {
await api.converterRenameFile(activePaths[0], name);
toastRef.current?.show({ severity: 'success', summary: 'Umbenannt', detail: `${name}`, life: 2500 });
setRenameName('');
setActiveModal('');
setActivePaths([]);
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
} finally { setBusy(false); }
}
async function handleDeleteSelected() {
if (!activePaths.length) return;
setBusy(true);
try {
for (const p of activePaths) {
await api.converterDeleteFile(p);
}
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${activePaths.length} Eintrag/Einträge`, life: 2500 });
setActivePaths([]);
setSelectedPaths((prev) => prev.filter((x) => !activePaths.includes(x)));
setActiveModal('');
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
} finally { setBusy(false); }
}
async function handleMoveSelected() {
if (!activePaths.length) return;
setBusy(true);
try {
const isRoot = moveTarget === '' || moveTarget === '__root__';
await api.converterMoveFile(activePaths[0], isRoot ? '' : moveTarget);
toastRef.current?.show({ severity: 'success', summary: 'Verschoben', life: 2500 });
setMoveTarget('');
setActivePaths([]);
setActiveModal('');
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
} finally { setBusy(false); }
}
function handleSelectActive() {
const newPaths = [];
for (const p of activePaths) {
const node = getNodeByPath(tree, p);
if (!node || isNodeLocked(node)) continue;
if (node.type === 'folder') {
newPaths.push(p, ...collectDescendantSelectableFilePaths(node));
} else {
newPaths.push(p);
}
}
setSelectedPaths((prev) => Array.from(new Set([...prev, ...newPaths])));
}
function handleRowClick(e, item) {
if (isNodeLocked(item)) return;
const p = item.path || '';
const items = currentItems;
if (e.shiftKey && activeAnchor) {
const anchorIdx = items.findIndex((i) => (i.path || '') === activeAnchor);
const clickIdx = items.findIndex((i) => (i.path || '') === p);
if (anchorIdx !== -1 && clickIdx !== -1) {
const from = Math.min(anchorIdx, clickIdx);
const to = Math.max(anchorIdx, clickIdx);
const rangePaths = items.slice(from, to + 1).map((i) => i.path || '');
if (e.ctrlKey || e.metaKey) {
setActivePaths((prev) => Array.from(new Set([...prev, ...rangePaths])));
} else {
setActivePaths(rangePaths);
}
}
} else if (e.ctrlKey || e.metaKey) {
setActivePaths((prev) =>
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]
);
setActiveAnchor(p);
} else {
setActivePaths([p]);
setActiveAnchor(p);
}
}
// ── Ordnerbaum (Seitenleiste) ───────────────────────────────────────────────
function renderFolderTree(node, depth) {
if (!node || node.type !== 'folder') return null;
const isActive = (node.path || '') === currentPath;
const key = node.path || '';
const isExpanded = expandedFolders.has(key) || depth === 0;
const childFolders = (node.children || []).filter((c) => c.type === 'folder');
const hasChildren = childFolders.length > 0;
const nodeSel = isSelected(key);
const nodeIndet = isIndeterminate(node);
return (
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
<div
className={`tree-row folder${isActive ? ' active' : ''}`}
onClick={() => handleOpen(node)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') handleOpen(node); }}
>
{hasChildren ? (
<div
className="tree-caret"
role="button"
tabIndex={0}
onClick={(e) => { e.stopPropagation(); toggleFolder(node.path || ''); }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); toggleFolder(node.path || ''); } }}
>
<i className={`pi ${isExpanded ? 'pi-chevron-down' : 'pi-chevron-right'}`} style={{ fontSize: '0.65rem' }} />
</div>
) : (
<span className="tree-caret disabled" aria-hidden="true" />
)}
<span className="tree-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={nodeSel}
ref={(el) => { if (el) el.indeterminate = nodeIndet; }}
onChange={(e) => handleCheckboxChange(node, e.target.checked)}
aria-label={`${node.name || 'raw'} auswählen`}
/>
</span>
<span className="tree-icon folder">
<i className="pi pi-folder" />
</span>
<span className="tree-label">{node.name || 'raw'}</span>
</div>
{isExpanded && hasChildren && childFolders.map((child) => renderFolderTree(child, depth + 1))}
</div>
);
}
// ── Abgeleitete Werte ──────────────────────────────────────────────────────
const allFolders = tree ? collectFolderPaths(tree) : [{ label: '/ (Root)', value: '' }];
const breadcrumb = buildBreadcrumb(currentPath);
const rootSelected = getRootSelections(selectedPaths);
// Aktionen basieren auf activePaths (Zeilen-Klick-Auswahl)
const hasLockedActive = activePaths.some((p) => {
const node = tree ? getNodeByPath(tree, p) : null;
return node ? isNodeLocked(node) : false;
});
const canRename = activePaths.length === 1 && !hasLockedActive;
const canDelete = activePaths.length > 0 && !hasLockedActive;
const canMove = activePaths.length === 1 && !hasLockedActive;
const canSelectActive = activePaths.length > 1;
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="cfx-wrap">
<Toast ref={toastRef} position="top-right" />
{/* Obere Leiste: rawdir-Pfad + Aktualisieren */}
<div className="cfx-top-bar">
<span className="cfx-rawdir" title={rawDir || ''}>
{rawDir
? <><i className="pi pi-server" style={{ marginRight: 4 }} />{rawDir}</>
: <em>Kein Ordner konfiguriert</em>}
</span>
<Button
label={loading ? 'Laden …' : 'Aktualisieren'}
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
size="small"
outlined
disabled={loading}
onClick={loadTree}
/>
</div>
{loading && !tree ? (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<ProgressSpinner style={{ width: 32, height: 32 }} />
</div>
) : !rawDir ? (
<div style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--rip-muted)', fontSize: '0.85rem' }}>
Bitte Converter Raw-Ordner in den Settings konfigurieren.
</div>
) : (
<div className="explorer" ref={explorerRef}>
{/* Linke Seitenleiste */}
<div className="explorer-sidebar">
<div className="explorer-toolbar sidebar-toolbar">
<InputText
className="sidebar-search p-inputtext-sm"
type="search"
placeholder="Suchen..."
value={sidebarQuery}
onChange={(e) => setSidebarQuery(e.target.value)}
aria-label="Ordner suchen"
/>
</div>
<div className="sidebar-tree">
{topLevelFolders.length === 0 && !sidebarQuery && (
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: 'var(--rip-muted)', fontStyle: 'italic' }}>
Keine Ordner gefunden.
</div>
)}
{filteredTree && renderFolderTree(filteredTree, 0)}
</div>
</div>
{/* Rechter Hauptbereich */}
<div className="explorer-main">
<div className="explorer-toolbar">
{/* ArrowUp */}
<button
type="button"
className="icon-button"
onClick={handleGoUp}
disabled={!currentPath}
title="Eine Ebene hoch"
aria-label="Hoch"
>
<i className="pi pi-arrow-up" />
</button>
{/* Breadcrumb */}
<div className="explorer-path">
<span
className="breadcrumb-root path-link"
onClick={() => navigateTo('')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') navigateTo(''); }}
>
raw
</span>
{breadcrumb.map((crumb) => (
<span
key={crumb.path}
className="path-link"
onClick={() => navigateTo(crumb.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') navigateTo(crumb.path); }}
>
/ {crumb.name}
</span>
))}
</div>
{/* Toolbar-Aktionen rechts */}
<div className="toolbar-actions">
<button
type="button"
className="icon-button"
onClick={() => { setNewFolderName(''); setActiveModal('new-folder'); }}
title="Neuer Ordner"
aria-label="Neuer Ordner"
>
<i className="pi pi-folder-plus" />
</button>
{canSelectActive && (
<button
type="button"
className="icon-button"
onClick={handleSelectActive}
title="Auswahl als Checkbox setzen"
aria-label="Auswahl als Checkbox setzen"
>
<i className="pi pi-check-square" />
</button>
)}
<button
type="button"
className="icon-button"
onClick={() => {
const node = getNodeByPath(tree, activePaths[0]);
setRenameName(node?.name || '');
setActiveModal('rename');
}}
disabled={!canRename}
title="Umbenennen"
aria-label="Umbenennen"
>
<i className="pi pi-pencil" />
</button>
<button
type="button"
className="icon-button danger"
onClick={() => setActiveModal('delete')}
disabled={!canDelete}
title="Löschen"
aria-label="Löschen"
>
<i className="pi pi-trash" />
</button>
<button
type="button"
className="icon-button"
onClick={() => { setMoveTarget(''); setActiveModal('move'); }}
disabled={!canMove}
title="Verschieben"
aria-label="Verschieben"
>
<i className="pi pi-arrow-right" />
</button>
</div>
</div>
{/* Dateiliste */}
<div className="explorer-list">
{/* Header */}
<div className="explorer-row header">
<span />
<span>Name</span>
<span>Typ</span>
<span>Größe</span>
<span>Job</span>
</div>
{/* Zeilen */}
{currentItems.length === 0 ? (
<div style={{ padding: '1.5rem', textAlign: 'center', fontSize: '0.82rem', color: 'var(--rip-muted)', fontStyle: 'italic' }}>
Leer.
</div>
) : currentItems.map((item) => {
const assignedJobId = Number(item.jobId);
const assigned = item.type === 'file' && Number.isFinite(assignedJobId) && assignedJobId > 0;
const locked = isNodeLocked(item);
const sel = (assigned || isSelected(item.path)) && !locked;
const indet = isIndeterminate(item);
const active = activePaths.includes(item.path || '');
const jobTitle = String(item.jobTitle || '').trim();
return (
<div
key={item.path}
className={`explorer-row${sel ? ' selected' : ''}${active ? ' row-active' : ''}${locked ? ' row-locked' : ''}`}
onClick={(e) => handleRowClick(e, item)}
onDoubleClick={() => item.type === 'folder' && handleOpen(item)}
role="row"
tabIndex={locked ? -1 : 0}
onKeyDown={(e) => { if (locked) return; if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }}
>
<span className="row-checkbox">
{locked ? (
<i className="pi pi-lock" style={{ fontSize: '0.8rem', color: 'var(--rip-muted)' }} title="Datei wird gerade verarbeitet" />
) : (
<input
type="checkbox"
checked={sel}
ref={(el) => { if (el) el.indeterminate = indet; }}
onChange={(e) => handleCheckboxChange(item, e.target.checked)}
onClick={(e) => e.stopPropagation()}
aria-label={`${item.name} auswählen`}
/>
)}
</span>
<span className="row-name">
<span className="row-icon">
<i className={`pi ${item.type === 'folder' ? 'pi-folder' : 'pi-file'}`} />
</span>
{item.name}
</span>
<span>
{item.type === 'folder'
? <Tag value="Ordner" severity="secondary" />
: (item.detectedMediaType ? mediaTypeBadge(item.detectedMediaType) : <span style={{ color: 'var(--rip-muted)', fontSize: '0.78rem' }}>Datei</span>)}
</span>
<span style={{ textAlign: 'right', color: 'var(--rip-muted)', fontSize: '0.78rem' }}>
{formatBytes(item.size)}
</span>
<span className="row-job">
{locked ? (
<small className="row-job-assignment" style={{ color: 'var(--orange-600, #e65100)' }}
title={`Job #${item.jobId} läuft (${item.jobStatus})`}>
<i className="pi pi-spin pi-spinner" style={{ fontSize: '0.65rem', marginRight: 3 }} />
#{item.jobId} läuft
</small>
) : assigned ? (
<small
className="row-job-assignment"
title={jobTitle ? `#${item.jobId} | ${jobTitle}` : `#${item.jobId}`}
>
#{item.jobId}{jobTitle ? ` | ${jobTitle}` : ''}
</small>
) : (
<small className="row-job-empty">-</small>
)}
</span>
</div>
);
})}
{/* Footer: Auswahl-Anzahl */}
{selectedPaths.length > 0 && (
<div className="explorer-row footer">
<span />
<span />
<span />
<span />
<span className="footer-count">{getRootSelections(selectedPaths).length} ausgewählt</span>
</div>
)}
</div>
</div>
{/* Untere Leiste (ganzeBreite) */}
<div className="explorer-footer">
<span>{currentItems.length} Einträge{rawDir ? ` · ${rawDir}` : ''}</span>
</div>
</div>
)}
{/* ── Dialoge ─────────────────────────────────────────────────────────── */}
{/* Aus Job entfernen */}
<Dialog
header="Aus Job entfernen?"
visible={activeModal === 'job-unassign'}
onHide={() => { setActiveModal(''); setJobUnassignTarget(null); }}
style={{ width: '420px' }}
footer={(
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button
label="Abbrechen"
outlined
onClick={() => { setActiveModal(''); setJobUnassignTarget(null); }}
disabled={busy}
/>
<Button
label="Entfernen"
severity="danger"
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-times'}
disabled={busy}
onClick={handleRemoveFromJob}
/>
</div>
)}
modal
>
<p style={{ margin: 0, lineHeight: 1.5 }}>
Soll die Datei aus dem Job&nbsp;
<strong>
{jobUnassignTarget?.jobTitle || (jobUnassignTarget?.jobId ? `#${jobUnassignTarget.jobId}` : '-')}
</strong>
&nbsp;entfernt werden?
</p>
</Dialog>
{/* Neuer Ordner */}
<Dialog
header="Neuen Ordner erstellen"
visible={activeModal === 'new-folder'}
onHide={() => { setActiveModal(''); setNewFolderName(''); }}
style={{ width: '380px' }}
footer={
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={() => { setActiveModal(''); setNewFolderName(''); }} disabled={busy} />
<Button label="Erstellen" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-check'} disabled={busy || !newFolderName.trim()} onClick={handleCreateFolder} />
</div>
}
modal
>
<div className="field">
<label>Ordnername</label>
<InputText
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateFolder(); }}
autoFocus
style={{ width: '100%', marginTop: 6 }}
placeholder={`In: ${currentPath || '/'}`}
/>
</div>
</Dialog>
{/* Umbenennen */}
<Dialog
header="Umbenennen"
visible={activeModal === 'rename'}
onHide={() => { setActiveModal(''); setRenameName(''); }}
style={{ width: '380px' }}
footer={
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={() => { setActiveModal(''); setRenameName(''); }} disabled={busy} />
<Button label="Umbenennen" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-check'} disabled={busy || !renameName.trim()} onClick={handleRename} />
</div>
}
modal
>
<div className="field">
<label>Neuer Name</label>
<InputText
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); }}
autoFocus
style={{ width: '100%', marginTop: 6 }}
/>
</div>
</Dialog>
{/* Löschen */}
<Dialog
header="Löschen bestätigen"
visible={activeModal === 'delete'}
onHide={() => setActiveModal('')}
style={{ width: '380px' }}
footer={
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={() => setActiveModal('')} disabled={busy} />
<Button label="Löschen" severity="danger" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-trash'} disabled={busy} onClick={handleDeleteSelected} />
</div>
}
modal
>
<p style={{ margin: 0 }}>
{rootSelected.length === 1
? <><strong>{getNodeByPath(tree, rootSelected[0])?.name}</strong> wirklich löschen?</>
: <>{rootSelected.length} Einträge wirklich löschen?</>}
</p>
</Dialog>
{/* Verschieben */}
<Dialog
header="Verschieben"
visible={activeModal === 'move'}
onHide={() => setActiveModal('')}
style={{ width: '420px' }}
footer={
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={() => setActiveModal('')} disabled={busy} />
<Button label="Verschieben" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-check'} disabled={busy} onClick={handleMoveSelected} />
</div>
}
modal
>
<div className="field">
<label>Zielordner</label>
<Dropdown
value={moveTarget}
options={allFolders.filter((f) => {
const src = rootSelected[0] || '';
return f.value !== src && !f.value.startsWith(src + '/');
})}
onChange={(e) => setMoveTarget(e.value)}
style={{ width: '100%', marginTop: 6 }}
placeholder="Zielordner auswählen …"
/>
</div>
</Dialog>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,295 @@
import { useEffect, useState } from 'react';
import { Button } from 'primereact/button';
import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { InputNumber } from 'primereact/inputnumber';
import { SelectButton } from 'primereact/selectbutton';
import { Divider } from 'primereact/divider';
import { Message } from 'primereact/message';
import { api } from '../api/client';
const VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' },
{ label: 'MP4', value: 'mp4' },
{ label: 'M4V', value: 'm4v' }
];
const AUDIO_OUTPUT_FORMATS = [
{ label: 'FLAC', value: 'flac' },
{ label: 'MP3', value: 'mp3' },
{ label: 'Opus', value: 'opus' },
{ label: 'OGG', value: 'ogg' },
{ label: 'WAV', value: 'wav' }
];
const MP3_MODES = [
{ label: 'CBR', value: 'cbr' },
{ label: 'VBR', value: 'vbr' }
];
/**
* Konfigurationsmodal für einen Converter-Job.
* Erscheint nachdem ein Job aus dem File-Explorer oder Upload erstellt wurde.
*/
export default function ConverterJobConfigDialog({
visible,
job,
onHide,
onStarted
}) {
const [converterMediaType, setConverterMediaType] = useState('video');
const [outputFormat, setOutputFormat] = useState('mkv');
const [userPresets, setUserPresets] = useState([]);
const [hbPresets, setHbPresets] = useState([]);
const [selectedUserPreset, setSelectedUserPreset] = useState(null);
const [selectedHbPreset, setSelectedHbPreset] = useState('');
const [audioFormatOptions, setAudioFormatOptions] = useState({
flacCompression: 5,
mp3Mode: 'cbr',
mp3Bitrate: 192,
mp3Quality: 4,
opusBitrate: 160,
oggQuality: 6
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// Beim Öffnen: Medientyp und Format aus dem Job-Plan lesen
useEffect(() => {
if (!job || !visible) return;
let plan = null;
try {
plan = job.encode_plan_json ? JSON.parse(job.encode_plan_json) : null;
} catch (_err) { /* ignore */ }
const mediaType = plan?.converterMediaType || 'video';
setConverterMediaType(mediaType);
setOutputFormat(mediaType === 'audio' ? 'flac' : 'mkv');
setSelectedUserPreset(null);
setSelectedHbPreset('');
setError(null);
}, [job, visible]);
// User-Presets laden
useEffect(() => {
if (!visible) return;
api.getUserPresets?.('video')
.then((data) => setUserPresets(Array.isArray(data?.presets) ? data.presets : []))
.catch(() => setUserPresets([]));
}, [visible]);
// HandBrake Built-in Presets laden
useEffect(() => {
if (!visible || converterMediaType !== 'video') return;
api.getHandBrakePresets?.()
.then((data) => {
const list = Array.isArray(data?.presets) ? data.presets : [];
setHbPresets(list);
})
.catch(() => setHbPresets([]));
}, [visible, converterMediaType]);
const handleStart = async () => {
if (!job) return;
setBusy(true);
setError(null);
try {
let userPreset = null;
if (selectedUserPreset) {
const found = userPresets.find((p) => p.id === selectedUserPreset);
if (found) {
userPreset = {
id: found.id,
handbrakePreset: found.handbrake_preset || null,
extraArgs: found.extra_args || ''
};
}
} else if (selectedHbPreset) {
userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' };
}
await api.startConverterJob(job.id, {
converterMediaType,
outputFormat,
userPreset,
audioFormatOptions: converterMediaType === 'audio' ? audioFormatOptions : undefined
});
onStarted?.(job.id);
onHide?.();
} catch (err) {
setError(err.message || 'Fehler beim Starten.');
} finally {
setBusy(false);
}
};
const isVideo = converterMediaType === 'video' || converterMediaType === 'iso';
const isAudio = converterMediaType === 'audio';
const outputFormats = isVideo ? VIDEO_OUTPUT_FORMATS : AUDIO_OUTPUT_FORMATS;
const footer = (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
<Button
label="Encoding starten"
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-play'}
disabled={busy}
onClick={handleStart}
/>
</div>
);
return (
<Dialog
header={`Converter konfigurieren — ${job?.title || job?.detected_title || 'Job'}`}
visible={visible}
onHide={onHide}
footer={footer}
style={{ width: '520px' }}
modal
>
{error && (
<Message severity="error" text={error} style={{ marginBottom: 16, width: '100%' }} />
)}
{/* Medientyp anzeigen (read-only) */}
<div className="field">
<label>Medientyp</label>
<div style={{ marginTop: 4 }}>
<strong>{converterMediaType === 'iso' ? 'ISO (Video)' : converterMediaType === 'video' ? 'Video' : 'Audio'}</strong>
{converterMediaType === 'iso' && (
<small style={{ marginLeft: 8, color: '#888' }}> MakeMKV Rip, dann HandBrake</small>
)}
</div>
</div>
{/* Ausgabeformat */}
<div className="field" style={{ marginTop: 12 }}>
<label>Ausgabeformat</label>
<Dropdown
value={outputFormat}
options={outputFormats}
onChange={(e) => setOutputFormat(e.value)}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
{/* Video: Preset-Auswahl */}
{isVideo && (
<>
<Divider />
<div className="field">
<label>User-Preset (optional)</label>
<Dropdown
value={selectedUserPreset}
options={[
{ label: '— Kein User-Preset —', value: null },
...userPresets.map((p) => ({ label: p.name, value: p.id }))
]}
onChange={(e) => {
setSelectedUserPreset(e.value);
if (e.value) setSelectedHbPreset('');
}}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
{!selectedUserPreset && (
<div className="field" style={{ marginTop: 12 }}>
<label>HandBrake-Preset (optional)</label>
<Dropdown
value={selectedHbPreset}
options={[
{ label: '— Kein Preset (HB-Standard) —', value: '' },
...hbPresets.map((p) => ({
label: p.category ? `[${p.category}] ${p.name}` : p.name,
value: p.name
}))
]}
onChange={(e) => setSelectedHbPreset(e.value)}
filter
style={{ width: '100%', marginTop: 4 }}
placeholder="Preset auswählen …"
/>
</div>
)}
</>
)}
{/* Audio: Format-Optionen */}
{isAudio && (
<>
<Divider />
{outputFormat === 'flac' && (
<div className="field">
<label>Komprimierung (012)</label>
<InputNumber
value={audioFormatOptions.flacCompression}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, flacCompression: e.value ?? 5 }))}
min={0} max={12} step={1}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
{outputFormat === 'mp3' && (
<>
<div className="field">
<label>Modus</label>
<SelectButton
value={audioFormatOptions.mp3Mode}
options={MP3_MODES}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Mode: e.value || 'cbr' }))}
style={{ marginTop: 4 }}
/>
</div>
{audioFormatOptions.mp3Mode === 'cbr' ? (
<div className="field" style={{ marginTop: 12 }}>
<label>Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.mp3Bitrate}
options={[128, 160, 192, 224, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Bitrate: e.value }))}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
) : (
<div className="field" style={{ marginTop: 12 }}>
<label>VBR Qualität (0=beste, 9=schlechteste)</label>
<InputNumber
value={audioFormatOptions.mp3Quality}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Quality: e.value ?? 4 }))}
min={0} max={9} step={1}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
</>
)}
{outputFormat === 'opus' && (
<div className="field">
<label>Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.opusBitrate}
options={[64, 96, 128, 160, 192, 256].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, opusBitrate: e.value }))}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
{outputFormat === 'ogg' && (
<div className="field">
<label>Qualität (-1 bis 10)</label>
<InputNumber
value={audioFormatOptions.oggQuality}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, oggQuality: e.value ?? 6 }))}
min={-1} max={10} step={1}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
</>
)}
</Dialog>
);
}
@@ -0,0 +1,285 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
const DEFAULT_ALLOWED_EXTENSIONS = [
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
];
function normalizeAllowedExtensions(values) {
const source = Array.isArray(values) ? values : [];
const seen = new Set();
const parsed = source
.map((item) => String(item || '').trim().toLowerCase())
.filter((item) => {
if (!item || !DEFAULT_ALLOWED_EXTENSIONS.includes(item) || seen.has(item)) {
return false;
}
seen.add(item);
return true;
});
if (parsed.length === 0) {
return [...DEFAULT_ALLOWED_EXTENSIONS];
}
return parsed;
}
function getFileExtensionWithoutDot(fileName) {
const raw = String(fileName || '').trim();
const dotIndex = raw.lastIndexOf('.');
if (dotIndex === -1 || dotIndex === raw.length - 1) {
return '';
}
return raw.slice(dotIndex + 1).toLowerCase();
}
/**
* Upload-Panel für den Converter.
* Unterstützt Mehrfach-Dateien und Ordner-Upload (webkitdirectory).
*/
export default function ConverterUploadPanel({ onUploaded, allowedExtensions = null }) {
const [phase, setPhase] = useState('idle'); // idle | uploading | done | error
const [progress, setProgress] = useState(0);
const [statusText, setStatusText] = useState('');
const [errorMsg, setErrorMsg] = useState(null);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef(null);
const dirInputRef = useRef(null);
const normalizedAllowedExtensions = useMemo(
() => normalizeAllowedExtensions(allowedExtensions),
[allowedExtensions]
);
const allowedExtensionSet = useMemo(
() => new Set(normalizedAllowedExtensions),
[normalizedAllowedExtensions]
);
const acceptValue = useMemo(
() => normalizedAllowedExtensions.map((ext) => `.${ext}`).join(','),
[normalizedAllowedExtensions]
);
const allowedListLabel = useMemo(
() => normalizedAllowedExtensions.map((ext) => ext.toUpperCase()).join(', '),
[normalizedAllowedExtensions]
);
const reset = () => {
setPhase('idle');
setProgress(0);
setStatusText('');
setErrorMsg(null);
};
const uploadFiles = useCallback(async (fileList) => {
const files = Array.from(fileList).filter((f) => {
if (f.size === 0) return false;
// Dot-Dateien (.DS_Store, .gitkeep, __MACOSX etc.) ignorieren
const relPath = f.webkitRelativePath || f.name;
return !relPath.split('/').some((seg) => seg.startsWith('.') || seg === '__MACOSX');
});
if (files.length === 0) return;
const invalidFiles = files
.map((file) => ({
name: String(file?.name || '').trim(),
ext: getFileExtensionWithoutDot(file?.name)
}))
.filter((item) => !item.ext || !allowedExtensionSet.has(item.ext));
if (invalidFiles.length > 0) {
const preview = invalidFiles.slice(0, 6).map((item) => item.name || '<ohne Dateiname>').join(', ');
const suffix = invalidFiles.length > 6 ? ` (+${invalidFiles.length - 6} weitere)` : '';
setPhase('error');
setProgress(0);
setStatusText('Upload abgelehnt');
setErrorMsg(
`Nicht erlaubte Datei-Endung in ${invalidFiles.length} Datei(en): ${preview}${suffix}. Erlaubt: ${allowedListLabel}`
);
return;
}
const isFolderUpload = files.some((f) => f.webkitRelativePath && f.webkitRelativePath.includes('/'));
setPhase('uploading');
setProgress(0);
setStatusText(isFolderUpload
? `Lade Ordner hoch (${files.length} Datei${files.length !== 1 ? 'en' : ''}) …`
: `Lade ${files.length} Datei${files.length !== 1 ? 'en' : ''} hoch …`);
setErrorMsg(null);
// Wie Klangkiste: folderName als eigenes Feld, Dateien mit file.name (kein Pfad im Dateinamen)
const formData = new FormData();
if (isFolderUpload) {
const folderName = files.find((f) => f.webkitRelativePath)?.webkitRelativePath?.split('/')[0] || 'upload';
formData.append('folderName', folderName);
for (const file of files) {
formData.append('files', file, file.name);
}
} else {
for (const file of files) {
formData.append('files', file, file.name);
}
}
try {
// XHR für Upload-Fortschritt
const result = await uploadWithProgress(formData, (pct) => {
setProgress(pct);
setStatusText(`Upload: ${Math.trunc(pct)}%`);
});
setPhase('done');
setProgress(100);
const folders = result.folders || [];
setStatusText(`${folders.length} Ordner hochgeladen.`);
onUploaded?.(folders);
} catch (err) {
setPhase('error');
setErrorMsg(err.message || 'Upload fehlgeschlagen.');
}
}, [onUploaded, allowedExtensionSet, allowedListLabel]);
const handleFileChange = (e) => {
if (e.target.files?.length > 0) {
uploadFiles(e.target.files);
}
e.target.value = '';
};
const handleDrop = (e) => {
e.preventDefault();
setIsDragOver(false);
const dt = e.dataTransfer;
if (dt?.files?.length > 0) {
uploadFiles(dt.files);
}
};
const handleDragOver = (e) => { e.preventDefault(); setIsDragOver(true); };
const handleDragLeave = () => setIsDragOver(false);
const phaseLabel = {
idle: null,
uploading: { label: 'Wird hochgeladen', severity: 'warning' },
done: { label: 'Fertig', severity: 'success' },
error: { label: 'Fehler', severity: 'danger' }
}[phase];
return (
<div className="converter-upload-panel">
<div
className={`converter-upload-dropzone${isDragOver ? ' drag-over' : ''}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<div className="converter-upload-icon">
<i className="pi pi-upload" style={{ fontSize: '2rem', color: '#888' }} />
</div>
<div className="converter-upload-hint">
Dateien hierher ziehen oder auswählen
<br />
<small>Unterstützt: {allowedListLabel}</small>
</div>
<div className="converter-upload-buttons">
<Button
label="Dateien auswählen"
icon="pi pi-file"
size="small"
outlined
disabled={phase === 'uploading'}
onClick={() => fileInputRef.current?.click()}
/>
<Button
label="Ordner auswählen"
icon="pi pi-folder-open"
size="small"
outlined
disabled={phase === 'uploading'}
onClick={() => dirInputRef.current?.click()}
/>
</div>
{/* Versteckte File-Inputs */}
<input
ref={fileInputRef}
type="file"
multiple
accept={acceptValue}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<input
ref={dirInputRef}
type="file"
webkitdirectory="true"
multiple
accept={acceptValue}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
{phase !== 'idle' && (
<div className="converter-upload-status">
<div className="converter-upload-status-head">
{phaseLabel && <Tag value={phaseLabel.label} severity={phaseLabel.severity} />}
<span>{statusText}</span>
{(phase === 'done' || phase === 'error') && (
<Button
icon="pi pi-times"
text
rounded
size="small"
onClick={reset}
aria-label="Schließen"
/>
)}
</div>
{phase === 'uploading' && (
<ProgressBar value={progress} style={{ height: 6 }} />
)}
{phase === 'error' && errorMsg && (
<small style={{ color: 'var(--red-500)' }}>{errorMsg}</small>
)}
</div>
)}
</div>
);
}
function uploadWithProgress(formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const apiBase = import.meta.env.VITE_API_BASE || '/api';
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch (_err) {
resolve({});
}
} else {
let msg = `HTTP ${xhr.status}`;
try {
const parsed = JSON.parse(xhr.responseText);
msg = parsed?.error?.message || msg;
} catch (_err) { /* ignore */ }
reject(new Error(msg));
}
});
xhr.addEventListener('error', () => reject(new Error('Netzwerkfehler beim Upload.')));
xhr.addEventListener('abort', () => reject(new Error('Upload abgebrochen.')));
xhr.open('POST', `${apiBase}/converter/upload`);
xhr.send(formData);
});
}
+655
View File
@@ -0,0 +1,655 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
import { InputSwitch } from 'primereact/inputswitch';
import { Toast } from 'primereact/toast';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import { confirmModal } from '../utils/confirmModal';
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
function formatDateTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('de-DE', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
} catch (_) {
return iso;
}
}
function StatusBadge({ status }) {
if (!status) return <span className="cron-status cron-status--none"></span>;
const map = {
success: { label: 'Erfolg', cls: 'success' },
error: { label: 'Fehler', cls: 'error' },
running: { label: 'Läuft…', cls: 'running' }
};
const info = map[status] || { label: status, cls: 'none' };
return <span className={`cron-status cron-status--${info.cls}`}>{info.label}</span>;
}
function normalizeActiveCronRuns(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
const active = Array.isArray(payload.active) ? payload.active : [];
return active
.map((item) => (item && typeof item === 'object' ? item : null))
.filter(Boolean)
.filter((item) => String(item.type || '').trim().toLowerCase() === 'cron')
.map((item) => ({
id: Number(item.id),
cronJobId: Number(item.cronJobId || 0),
currentStep: String(item.currentStep || '').trim() || null,
currentScriptName: String(item.currentScriptName || '').trim() || null,
startedAt: item.startedAt || null
}))
.filter((item) => Number.isFinite(item.cronJobId) && item.cronJobId > 0);
}
const EMPTY_FORM = {
name: '',
cronExpression: '',
sourceType: 'script',
sourceId: null,
enabled: true,
pushoverEnabled: true
};
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function CronJobsTab() {
const toastRef = useRef(null);
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(false);
const [scripts, setScripts] = useState([]);
const [chains, setChains] = useState([]);
const [activeCronRuns, setActiveCronRuns] = useState([]);
// Editor-Dialog
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState('create'); // 'create' | 'edit'
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
// Cron-Validierung
const [exprValidation, setExprValidation] = useState(null); // { valid, error, nextRunAt }
const [exprValidating, setExprValidating] = useState(false);
const exprValidateTimer = useRef(null);
// Logs-Dialog
const [logsJob, setLogsJob] = useState(null);
const [logs, setLogs] = useState([]);
const [logsLoading, setLogsLoading] = useState(false);
// Aktionen Busy-State per Job-ID
const [busyId, setBusyId] = useState(null);
const wsReloadTimerRef = useRef(null);
// ── Daten laden ──────────────────────────────────────────────────────────────
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [cronResp, scriptsResp, chainsResp, runtimeResp] = await Promise.allSettled([
api.getCronJobs(),
api.getScripts(),
api.getScriptChains(),
api.getRuntimeActivities()
]);
if (cronResp.status === 'fulfilled') setJobs(cronResp.value?.jobs || []);
if (scriptsResp.status === 'fulfilled') setScripts(scriptsResp.value?.scripts || []);
if (chainsResp.status === 'fulfilled') setChains(chainsResp.value?.chains || []);
if (runtimeResp.status === 'fulfilled') {
setActiveCronRuns(normalizeActiveCronRuns(runtimeResp.value));
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAll();
}, [loadAll]);
useEffect(() => {
let cancelled = false;
const refreshRuntime = async () => {
try {
const response = await api.getRuntimeActivities();
if (!cancelled) {
setActiveCronRuns(normalizeActiveCronRuns(response));
}
} catch (_error) {
// ignore polling errors
}
};
const interval = setInterval(refreshRuntime, 2500);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
const scheduleLoadAll = (delayMs = 120) => {
if (wsReloadTimerRef.current) {
return;
}
wsReloadTimerRef.current = setTimeout(() => {
wsReloadTimerRef.current = null;
void loadAll();
}, delayMs);
};
const activeCronRunByJobId = useMemo(() => {
const map = new Map();
for (const item of activeCronRuns) {
if (!item?.cronJobId) {
continue;
}
map.set(item.cronJobId, item);
}
return map;
}, [activeCronRuns]);
useEffect(() => () => {
if (wsReloadTimerRef.current) {
clearTimeout(wsReloadTimerRef.current);
wsReloadTimerRef.current = null;
}
}, []);
useWebSocket({
onMessage: (message) => {
const type = String(message?.type || '').trim().toUpperCase();
if (!type) {
return;
}
if (type === 'RUNTIME_ACTIVITY_CHANGED') {
setActiveCronRuns(normalizeActiveCronRuns(message?.payload));
return;
}
if (type === 'CRON_JOBS_UPDATED' || type === 'CRON_JOB_UPDATED') {
scheduleLoadAll(80);
}
}
});
// ── Cron-Ausdruck validieren (debounced) ─────────────────────────────────────
useEffect(() => {
const expr = form.cronExpression.trim();
if (!expr) {
setExprValidation(null);
return;
}
if (exprValidateTimer.current) clearTimeout(exprValidateTimer.current);
setExprValidating(true);
exprValidateTimer.current = setTimeout(async () => {
try {
const result = await api.validateCronExpression(expr);
setExprValidation(result);
} catch (_) {
setExprValidation({ valid: false, error: 'Validierung fehlgeschlagen.' });
} finally {
setExprValidating(false);
}
}, 500);
return () => clearTimeout(exprValidateTimer.current);
}, [form.cronExpression]);
// ── Editor öffnen/schließen ──────────────────────────────────────────────────
function openCreate() {
setForm(EMPTY_FORM);
setExprValidation(null);
setEditorMode('create');
setEditingId(null);
setEditorOpen(true);
}
function openEdit(job) {
setForm({
name: job.name || '',
cronExpression: job.cronExpression || '',
sourceType: job.sourceType || 'script',
sourceId: job.sourceId || null,
enabled: job.enabled !== false,
pushoverEnabled: job.pushoverEnabled !== false
});
setExprValidation(null);
setEditorMode('edit');
setEditingId(job.id);
setEditorOpen(true);
}
function closeEditor() {
setEditorOpen(false);
setSaving(false);
}
// ── Speichern ────────────────────────────────────────────────────────────────
async function handleSave() {
const name = form.name.trim();
const cronExpression = form.cronExpression.trim();
if (!name) { toastRef.current?.show({ severity: 'warn', summary: 'Name fehlt', life: 3000 }); return; }
if (!cronExpression) { toastRef.current?.show({ severity: 'warn', summary: 'Cron-Ausdruck fehlt', life: 3000 }); return; }
if (exprValidation && !exprValidation.valid) { toastRef.current?.show({ severity: 'warn', summary: 'Ungültiger Cron-Ausdruck', life: 3000 }); return; }
if (!form.sourceId) { toastRef.current?.show({ severity: 'warn', summary: 'Quelle fehlt', life: 3000 }); return; }
setSaving(true);
try {
const payload = { ...form, name, cronExpression };
if (editorMode === 'create') {
await api.createCronJob(payload);
toastRef.current?.show({ severity: 'success', summary: 'Cronjob erstellt', life: 3000 });
} else {
await api.updateCronJob(editingId, payload);
toastRef.current?.show({ severity: 'success', summary: 'Cronjob gespeichert', life: 3000 });
}
closeEditor();
await loadAll();
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 5000 });
} finally {
setSaving(false);
}
}
// ── Toggle enabled/pushover ──────────────────────────────────────────────────
async function handleToggle(job, field) {
setBusyId(job.id);
try {
const updated = await api.updateCronJob(job.id, { [field]: !job[field] });
setJobs((prev) => prev.map((j) => j.id === job.id ? updated.job : j));
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 });
} finally {
setBusyId(null);
}
}
// ── Löschen ──────────────────────────────────────────────────────────────────
async function handleDelete(job) {
const confirmed = await confirmModal({
header: 'Cronjob löschen',
message: `Cronjob "${job.name}" wirklich löschen?`,
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) return;
setBusyId(job.id);
try {
await api.deleteCronJob(job.id);
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', life: 3000 });
setJobs((prev) => prev.filter((j) => j.id !== job.id));
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 });
} finally {
setBusyId(null);
}
}
// ── Manuell ausführen ────────────────────────────────────────────────────────
async function handleRunNow(job) {
setBusyId(job.id);
try {
await api.runCronJobNow(job.id);
toastRef.current?.show({ severity: 'info', summary: `"${job.name}" gestartet`, life: 3000 });
// Kurz warten und dann neu laden
setTimeout(() => loadAll(), 1500);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 });
} finally {
setBusyId(null);
}
}
// ── Logs ─────────────────────────────────────────────────────────────────────
async function openLogs(job) {
setLogsJob(job);
setLogs([]);
setLogsLoading(true);
try {
const resp = await api.getCronJobLogs(job.id, 30);
setLogs(resp.logs || []);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Logs konnten nicht geladen werden', detail: error.message, life: 4000 });
} finally {
setLogsLoading(false);
}
}
// ── Source-Optionen ──────────────────────────────────────────────────────────
const sourceOptions = form.sourceType === 'script'
? scripts.map((s) => ({ label: s.name, value: s.id }))
: chains.map((c) => ({ label: c.name, value: c.id }));
// ── Render ────────────────────────────────────────────────────────────────────
return (
<div className="cron-tab">
<Toast ref={toastRef} />
<div className="actions-row">
<Button
label="Neuer Cronjob"
icon="pi pi-plus"
onClick={openCreate}
/>
<Button
label="Aktualisieren"
icon="pi pi-refresh"
severity="secondary"
onClick={loadAll}
loading={loading}
/>
</div>
{jobs.length === 0 && !loading && (
<p className="cron-empty-hint">Keine Cronjobs vorhanden. Klicke auf &ldquo;Neuer Cronjob&rdquo;, um einen anzulegen.</p>
)}
{jobs.length > 0 && (
<div className="cron-list">
{jobs.map((job) => {
const isBusy = busyId === job.id;
const activeCronRun = activeCronRunByJobId.get(Number(job.id)) || null;
return (
<div key={job.id} className={`cron-item${job.enabled ? '' : ' cron-item--disabled'}`}>
<div className="cron-item-header">
<span className="cron-item-name">{job.name}</span>
<code className="cron-item-expr">{job.cronExpression}</code>
</div>
<div className="cron-item-meta">
<span className="cron-meta-entry">
<span className="cron-meta-label">Quelle:</span>
<span className="cron-meta-value">
{job.sourceType === 'chain' ? '⛓ ' : '📜 '}
{job.sourceName || `#${job.sourceId}`}
</span>
</span>
<span className="cron-meta-entry">
<span className="cron-meta-label">Letzter Lauf:</span>
<span className="cron-meta-value">
{formatDateTime(job.lastRunAt)}
{job.lastRunStatus && <StatusBadge status={job.lastRunStatus} />}
</span>
</span>
<span className="cron-meta-entry">
<span className="cron-meta-label">Nächster Lauf:</span>
<span className="cron-meta-value">{formatDateTime(job.nextRunAt)}</span>
</span>
{activeCronRun ? (
<span className="cron-meta-entry">
<span className="cron-meta-label">Aktuell:</span>
<span className="cron-meta-value">
<StatusBadge status="running" />
{activeCronRun.currentScriptName
? `Skript: ${activeCronRun.currentScriptName}`
: (activeCronRun.currentStep || 'Ausführung läuft')}
</span>
</span>
) : null}
</div>
<div className="cron-item-toggles">
<label className="cron-toggle-label">
<InputSwitch
checked={job.enabled}
disabled={isBusy}
onChange={() => handleToggle(job, 'enabled')}
/>
<span>Aktiviert</span>
</label>
<label className="cron-toggle-label">
<InputSwitch
checked={job.pushoverEnabled}
disabled={isBusy}
onChange={() => handleToggle(job, 'pushoverEnabled')}
/>
<span>Pushover</span>
</label>
</div>
<div className="cron-item-actions">
<Button
icon="pi pi-play"
tooltip="Jetzt ausführen"
tooltipOptions={{ position: 'top' }}
size="small"
severity="success"
outlined
loading={isBusy && busyId === job.id}
disabled={isBusy}
onClick={() => handleRunNow(job)}
/>
<Button
icon="pi pi-list"
tooltip="Logs anzeigen"
tooltipOptions={{ position: 'top' }}
size="small"
severity="info"
outlined
disabled={isBusy}
onClick={() => openLogs(job)}
/>
<Button
icon="pi pi-pencil"
tooltip="Bearbeiten"
tooltipOptions={{ position: 'top' }}
size="small"
outlined
disabled={isBusy}
onClick={() => openEdit(job)}
/>
<Button
icon="pi pi-trash"
tooltip="Löschen"
tooltipOptions={{ position: 'top' }}
size="small"
severity="danger"
outlined
disabled={isBusy}
onClick={() => handleDelete(job)}
/>
</div>
</div>
);
})}
</div>
)}
{/* ── Editor-Dialog ──────────────────────────────────────────────────── */}
<Dialog
header={editorMode === 'create' ? 'Neuer Cronjob' : 'Cronjob bearbeiten'}
visible={editorOpen}
onHide={closeEditor}
style={{ width: '520px' }}
footer={
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<Button label="Abbrechen" severity="secondary" outlined onClick={closeEditor} disabled={saving} />
<Button label="Speichern" icon="pi pi-save" onClick={handleSave} loading={saving} />
</div>
}
>
<div className="cron-editor-fields">
{/* Name */}
<div className="cron-editor-field">
<label className="cron-editor-label">Name</label>
<InputText
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="z.B. Tägliche Bereinigung"
className="w-full"
/>
</div>
{/* Cron-Ausdruck */}
<div className="cron-editor-field">
<label className="cron-editor-label">
Cron-Ausdruck
<a
href="https://crontab.guru/"
target="_blank"
rel="noopener noreferrer"
className="cron-help-link"
title="crontab.guru öffnen"
>
<i className="pi pi-question-circle" />
</a>
</label>
<InputText
value={form.cronExpression}
onChange={(e) => setForm((f) => ({ ...f, cronExpression: e.target.value }))}
placeholder="Minute Stunde Tag Monat Wochentag z.B. 0 2 * * *"
className={`w-full${exprValidation && !exprValidation.valid ? ' p-invalid' : ''}`}
/>
{exprValidating && (
<small className="cron-expr-hint cron-expr-hint--checking">Wird geprüft</small>
)}
{!exprValidating && exprValidation && exprValidation.valid && (
<small className="cron-expr-hint cron-expr-hint--ok">
Gültig nächste Ausführung: {formatDateTime(exprValidation.nextRunAt)}
</small>
)}
{!exprValidating && exprValidation && !exprValidation.valid && (
<small className="cron-expr-hint cron-expr-hint--err"> {exprValidation.error}</small>
)}
<div className="cron-expr-examples">
{[
{ label: 'Stündlich', expr: '0 * * * *' },
{ label: 'Täglich 2 Uhr', expr: '0 2 * * *' },
{ label: 'Wöchentlich Mo', expr: '0 3 * * 1' },
{ label: 'Monatlich 1.', expr: '0 4 1 * *' }
].map(({ label, expr }) => (
<button
key={expr}
type="button"
className="cron-expr-chip"
onClick={() => setForm((f) => ({ ...f, cronExpression: expr }))}
>
{label}
</button>
))}
</div>
</div>
{/* Quell-Typ */}
<div className="cron-editor-field">
<label className="cron-editor-label">Quell-Typ</label>
<div className="cron-source-type-row">
{[
{ value: 'script', label: '📜 Skript' },
{ value: 'chain', label: '⛓ Skriptkette' }
].map(({ value, label }) => (
<button
key={value}
type="button"
className={`cron-source-type-btn${form.sourceType === value ? ' active' : ''}`}
onClick={() => setForm((f) => ({ ...f, sourceType: value, sourceId: null }))}
>
{label}
</button>
))}
</div>
</div>
{/* Quelle auswählen */}
<div className="cron-editor-field">
<label className="cron-editor-label">
{form.sourceType === 'script' ? 'Skript' : 'Skriptkette'}
</label>
<Dropdown
value={form.sourceId}
options={sourceOptions}
onChange={(e) => setForm((f) => ({ ...f, sourceId: e.value }))}
placeholder={`${form.sourceType === 'script' ? 'Skript' : 'Skriptkette'} wählen…`}
className="w-full"
emptyMessage={form.sourceType === 'script' ? 'Keine Skripte vorhanden' : 'Keine Ketten vorhanden'}
/>
</div>
{/* Toggles */}
<div className="cron-editor-toggles">
<label className="cron-toggle-label">
<InputSwitch
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.value }))}
/>
<span>Aktiviert</span>
</label>
<label className="cron-toggle-label">
<InputSwitch
checked={form.pushoverEnabled}
onChange={(e) => setForm((f) => ({ ...f, pushoverEnabled: e.value }))}
/>
<span>Pushover-Benachrichtigung</span>
</label>
</div>
</div>
</Dialog>
{/* ── Logs-Dialog ──────────────────────────────────────────────────────── */}
<Dialog
header={logsJob ? `Logs: ${logsJob.name}` : 'Logs'}
visible={Boolean(logsJob)}
onHide={() => setLogsJob(null)}
style={{ width: '720px' }}
footer={
<Button
label="Schließen"
severity="secondary"
outlined
onClick={() => setLogsJob(null)}
/>
}
>
{logsLoading && <p>Lade Logs</p>}
{!logsLoading && logs.length === 0 && (
<p className="cron-empty-hint">Noch keine Ausführungen protokolliert.</p>
)}
{!logsLoading && logs.length > 0 && (
<div className="cron-log-list">
{logs.map((log) => (
<details key={log.id} className="cron-log-entry">
<summary className="cron-log-summary">
<StatusBadge status={log.status} />
<span className="cron-log-time">{formatDateTime(log.startedAt)}</span>
{log.finishedAt && (
<span className="cron-log-duration">
{Math.round((new Date(log.finishedAt) - new Date(log.startedAt)) / 1000)}s
</span>
)}
{log.errorMessage && (
<span className="cron-log-errmsg">{log.errorMessage}</span>
)}
</summary>
{log.output && (
<pre className="cron-log-output">{log.output}</pre>
)}
</details>
))}
</div>
)}
</Dialog>
</div>
);
}
@@ -0,0 +1,39 @@
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
export default function DiscDetectedDialog({ visible, device, onHide, onAnalyze, busy }) {
return (
<Dialog
header="Neue Disk erkannt"
visible={visible}
onHide={onHide}
style={{ width: '32rem', maxWidth: '96vw' }}
className="disc-detected-dialog"
breakpoints={{ '768px': '96vw', '560px': '98vw' }}
modal
>
<p>
Laufwerk: <strong>{device?.path || 'unbekannt'}</strong>
</p>
<p>
Disk-Label: <strong>{device?.discLabel || 'n/a'}</strong>
</p>
<p>
Laufwerks-Label: <strong>{device?.label || 'n/a'}</strong>
</p>
<p>
Modell: <strong>{device?.model || 'n/a'}</strong>
</p>
<div className="dialog-actions">
<Button label="Schließen" severity="secondary" onClick={onHide} text />
<Button
label="Analyse starten"
icon="pi pi-search"
onClick={onAnalyze}
loading={busy}
/>
</div>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,780 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext';
function normalizeWorkflowKind(value) {
const raw = String(value || '').trim().toLowerCase();
if (['film', 'movie', 'feature'].includes(raw)) {
return 'film';
}
if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) {
return 'series';
}
return null;
}
function normalizeResultType(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'series') {
return 'series';
}
if (raw === 'movie' || raw === 'film' || raw === 'movies') {
return 'movie';
}
return null;
}
function normalizeMetadataRow(row = {}) {
const workflowKind = normalizeWorkflowKind(
row?.workflowKind
|| row?.kind
|| row?.metadataKind
|| null
);
const metadataKindRaw = String(row?.metadataKind || '').trim().toLowerCase();
const metadataKind = metadataKindRaw || (workflowKind === 'series' ? 'series' : 'movie');
const resultType = normalizeResultType(row?.resultType)
|| (workflowKind === 'series' ? 'series' : (workflowKind === 'film' ? 'movie' : null))
|| ((metadataKind === 'series' || metadataKind === 'season') ? 'series' : 'movie');
const normalizedWorkflowKind = workflowKind || (resultType === 'series' ? 'series' : 'film');
return {
...row,
metadataKind,
resultType,
workflowKind: normalizedWorkflowKind
};
}
export default function MetadataSelectionDialog({
visible,
context,
onHide,
onSubmit,
onSearch,
busy
}) {
const toSearchTitleCase = (value) => String(value || '')
.toLowerCase()
.replace(/\b([a-z\u00c0-\u024f])([a-z\u00c0-\u024f0-9']*)/g, (_match, first, rest) => (
`${first.toUpperCase()}${rest}`
));
const stripTrailingDiscHint = (value) => {
let working = String(value || '').trim();
if (!working) {
return '';
}
const patterns = [
/\b(?:disc|disk|dvd|bd|br|cd|part|pt|vol|volume|season|staffel|episode|ep)\s*(?:\d{1,3}|[ivxlcdm]{1,8})\b$/i,
/\b(?:[a-z]{1,3}\d{1,4}|\d{1,4}[a-z]{1,3})\b$/i
];
for (let i = 0; i < 4; i += 1) {
const before = working;
for (const pattern of patterns) {
working = working.replace(pattern, '').trim();
}
working = working.replace(/[-_.,;:]+$/g, '').trim();
if (working === before || !working) {
break;
}
}
return working;
};
const buildSearchVariants = (rawValue) => {
const raw = String(rawValue || '').trim();
if (!raw) {
return [];
}
if (/^tt\d{6,12}$/i.test(raw)) {
return [raw.toLowerCase()];
}
const variants = [];
const pushUnique = (candidate) => {
const normalized = String(candidate || '').replace(/\s+/g, ' ').trim();
if (!normalized) {
return;
}
if (!variants.some((entry) => entry.toLowerCase() === normalized.toLowerCase())) {
variants.push(normalized);
}
};
const separatorNormalized = raw
.replace(/[_]+/g, ' ')
.replace(/[.]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const punctuationReduced = separatorNormalized
.replace(/[|/\\]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const stripped = stripTrailingDiscHint(punctuationReduced);
pushUnique(raw);
pushUnique(separatorNormalized);
pushUnique(punctuationReduced);
pushUnique(toSearchTitleCase(separatorNormalized));
pushUnique(stripped);
pushUnique(toSearchTitleCase(stripped));
return variants.slice(0, 6);
};
const normalizeDetectedTitleForMetadataInput = (rawValue) => {
const raw = String(rawValue || '').trim();
if (!raw) {
return '';
}
const separatorNormalized = raw
.replace(/[_]+/g, ' ')
.replace(/[.]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const stripped = stripTrailingDiscHint(separatorNormalized);
const preferred = stripped || separatorNormalized || raw;
return toSearchTitleCase(preferred);
};
const parseDiscNumber = (rawValue) => {
const text = String(rawValue ?? '').trim();
if (!/^\d+$/.test(text)) {
return null;
}
const parsed = Number(text);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
};
const [selected, setSelected] = useState(null);
const [query, setQuery] = useState('');
const [manualTitle, setManualTitle] = useState('');
const [manualYear, setManualYear] = useState('');
const [manualImdb, setManualImdb] = useState('');
const [manualDiscNumber, setManualDiscNumber] = useState('');
const [extraResults, setExtraResults] = useState([]);
const [hasSearchOverride, setHasSearchOverride] = useState(false);
const [searchBusy, setSearchBusy] = useState(false);
const [searchError, setSearchError] = useState('');
const [submitError, setSubmitError] = useState('');
const [conflictState, setConflictState] = useState(null);
const [conflictExistingDiscNumber, setConflictExistingDiscNumber] = useState('');
const [conflictNewDiscNumber, setConflictNewDiscNumber] = useState('');
const [conflictError, setConflictError] = useState('');
const [seasonFilter, setSeasonFilter] = useState('');
const [resultFilter, setResultFilter] = useState('all');
const [discValidationError, setDiscValidationError] = useState('');
const activeSearchRequestRef = useRef(0);
const autoSearchDoneRef = useRef(false);
const wasVisibleRef = useRef(false);
const lastVisibleContextIdentityRef = useRef(null);
const mediaProfile = String(
context?.mediaProfile
|| context?.selectedMetadata?.mediaProfile
|| ''
).trim().toLowerCase();
const metadataProvider = 'tmdb';
const providerLabel = 'TMDb';
const supportsExternalIdInput = false;
const contextJobId = Number(context?.jobId || 0) || null;
const contextIdentity = contextJobId
? `job:${contextJobId}`
: `detected:${String(context?.detectedTitle || '').trim().toLowerCase()}`;
useEffect(() => {
if (!visible) {
wasVisibleRef.current = false;
lastVisibleContextIdentityRef.current = null;
return;
}
const openingNow = !wasVisibleRef.current;
const contextChanged = lastVisibleContextIdentityRef.current !== contextIdentity;
if (!openingNow && !contextChanged) {
return;
}
const selectedMetadata = context?.selectedMetadata || {};
const rawDefaultTitle = selectedMetadata.title || context?.detectedTitle || '';
const defaultTitle = normalizeDetectedTitleForMetadataInput(rawDefaultTitle) || rawDefaultTitle;
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
const defaultImdb = selectedMetadata.imdbId || '';
setSelected(null);
setQuery(defaultTitle);
setManualTitle(defaultTitle);
setManualYear(defaultYear);
setManualImdb(defaultImdb);
setManualDiscNumber('');
setExtraResults([]);
setHasSearchOverride(false);
setSearchBusy(false);
setSearchError('');
setSubmitError('');
setConflictState(null);
setConflictExistingDiscNumber('');
setConflictNewDiscNumber('');
setConflictError('');
setSeasonFilter('');
setResultFilter('all');
setDiscValidationError('');
autoSearchDoneRef.current = false;
activeSearchRequestRef.current += 1;
wasVisibleRef.current = true;
lastVisibleContextIdentityRef.current = contextIdentity;
}, [visible, contextIdentity, context]);
useEffect(() => {
if (submitError) {
setSubmitError('');
}
}, [manualDiscNumber, seasonFilter, selected, resultFilter, conflictExistingDiscNumber, conflictNewDiscNumber]);
const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates
: [];
const sourceRows = hasSearchOverride ? extraResults : base;
const all = Array.isArray(sourceRows) ? sourceRows : [];
const map = new Map();
all.forEach((item) => {
const normalized = normalizeMetadataRow(item);
const rowType = normalizeResultType(normalized?.resultType) || 'movie';
const rowKey = String(
normalized?.providerId
|| `${rowType}:${normalized?.imdbId || normalized?.tmdbId || '-'}:${normalized?.seasonNumber || '-'}:${normalized?.title || '-'}:${normalized?.year || '-'}`
).trim();
if (!rowKey) {
return;
}
map.set(rowKey, {
...normalized,
_metadataKey: rowKey
});
});
return Array.from(map.values());
}, [context, extraResults, hasSearchOverride]);
const seriesRowsAvailable = useMemo(
() => rows.some((row) => normalizeResultType(row?.resultType) === 'series'),
[rows]
);
const selectedResultType = normalizeResultType(selected?.resultType)
|| (normalizeWorkflowKind(selected?.workflowKind) === 'series' ? 'series' : 'movie');
const selectedIsSeries = Boolean(selected) && selectedResultType === 'series';
const showSeasonFilter = resultFilter !== 'movies' && seriesRowsAvailable;
const filteredRows = useMemo(() => {
let output = [...rows];
if (resultFilter === 'movies') {
output = output.filter((row) => normalizeResultType(row?.resultType) !== 'series');
} else if (resultFilter === 'series') {
output = output.filter((row) => normalizeResultType(row?.resultType) === 'series');
}
if (showSeasonFilter) {
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
if (normalizedFilter) {
output = output.filter((row) => {
if (normalizeResultType(row?.resultType) !== 'series') {
return false;
}
const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase();
const seasonName = String(row?.seasonName || '').trim().toLowerCase();
return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter);
});
}
}
return output;
}, [rows, resultFilter, seasonFilter, showSeasonFilter]);
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
const hasValidDiscNumber = effectiveDiscNumber !== null;
const requiresDiscNumber = metadataProvider === 'tmdb'
&& selectedIsSeries
&& (mediaProfile === 'dvd' || mediaProfile === 'bluray');
useEffect(() => {
if (!discValidationError) {
return;
}
if (!requiresDiscNumber || hasValidDiscNumber) {
setDiscValidationError('');
}
}, [discValidationError, requiresDiscNumber, hasValidDiscNumber]);
const titleWithPosterBody = (row) => (
<div className="metadata-row">
{row.poster && row.poster !== 'N/A' ? (
<img src={row.poster} alt={row.title} className="poster-thumb-lg" />
) : (
<div className="poster-thumb-lg poster-fallback">-</div>
)}
<div>
<div><strong>{row.title}</strong></div>
<small>
{row.year || '-'}
{row.seasonNumber ? ` | Staffel ${row.seasonNumber}` : ''}
{row.seasonName ? ` | ${row.seasonName}` : ''}
{row.episodeCount ? ` | ${row.episodeCount} Episoden` : ''}
{row.imdbId ? ` | ${row.imdbId}` : ''}
{!row.imdbId && row.tmdbId ? ` | TMDb ${row.tmdbId}` : ''}
</small>
</div>
</div>
);
const handleSearch = async () => {
const trimmedQuery = String(query || '').trim();
if (!trimmedQuery || typeof onSearch !== 'function') {
return;
}
const queryVariants = buildSearchVariants(trimmedQuery);
if (queryVariants.length === 0) {
return;
}
const requestId = activeSearchRequestRef.current + 1;
activeSearchRequestRef.current = requestId;
setHasSearchOverride(true);
setSearchBusy(true);
setSearchError('');
try {
let effectiveResults = [];
for (const candidateQuery of queryVariants) {
const results = await onSearch(candidateQuery, {
metadataProvider
});
if (activeSearchRequestRef.current !== requestId) {
return;
}
if (Array.isArray(results) && results.length > 0) {
effectiveResults = results;
break;
}
}
setExtraResults(Array.isArray(effectiveResults) ? effectiveResults : []);
} catch (error) {
if (activeSearchRequestRef.current !== requestId) {
return;
}
setSearchError(error?.message || 'Suche fehlgeschlagen.');
} finally {
if (activeSearchRequestRef.current === requestId) {
setSearchBusy(false);
}
}
};
useEffect(() => {
if (!visible) {
return;
}
if (typeof onSearch !== 'function') {
autoSearchDoneRef.current = true;
return;
}
const trimmedQuery = String(query || '').trim();
if (!trimmedQuery) {
return;
}
if (autoSearchDoneRef.current) {
return;
}
autoSearchDoneRef.current = true;
void handleSearch();
}, [visible, query, onSearch]);
const handleSubmitError = (error, pendingPayload = null) => {
const details = Array.isArray(error?.details) ? error.details : [];
const duplicateDetail = details.find(
(detail) => String(detail?.code || '').trim().toUpperCase() === 'METADATA_DUPLICATE_FOUND'
);
if (duplicateDetail) {
const existingJob = duplicateDetail?.existingJob && typeof duplicateDetail.existingJob === 'object'
? duplicateDetail.existingJob
: null;
const nextPendingPayload = pendingPayload && typeof pendingPayload === 'object'
? pendingPayload
: (conflictState?.pendingPayload && typeof conflictState.pendingPayload === 'object'
? conflictState.pendingPayload
: null);
const pendingDiscNumber = parseDiscNumber(nextPendingPayload?.discNumber);
const existingDiscNumber = parseDiscNumber(existingJob?.discNumber);
setConflictState({
existingJob,
pendingPayload: nextPendingPayload
});
setConflictExistingDiscNumber(existingDiscNumber ? String(existingDiscNumber) : '');
setConflictNewDiscNumber(pendingDiscNumber ? String(pendingDiscNumber) : '');
setConflictError('');
setSubmitError('');
return;
}
const hasDuplicateDiscError = details.some(
(detail) => String(detail?.code || '').trim().toUpperCase() === 'SERIES_DISC_ALREADY_EXISTS'
);
const message = String(error?.message || 'Metadaten konnten nicht uebernommen werden.').trim()
|| 'Metadaten konnten nicht uebernommen werden.';
if (hasDuplicateDiscError) {
setSubmitError(message);
return;
}
setSubmitError(message);
};
const handleSubmit = async () => {
setDiscValidationError('');
setSubmitError('');
setConflictError('');
if (requiresDiscNumber && !hasValidDiscNumber) {
setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
return;
}
const selectionWorkflowKind = selectedIsSeries ? 'series' : 'film';
const manualWorkflowKind = resultFilter === 'series' ? 'series' : 'film';
const payload = selected
? {
jobId: context.jobId,
title: selected.title,
year: selected.year,
imdbId: selected.imdbId,
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
metadataProvider,
workflowKind: selectionWorkflowKind,
providerId: selected.providerId || null,
tmdbId: selected.tmdbId || null,
metadataKind: selected.metadataKind || (selectedIsSeries ? 'series' : 'movie'),
voteAverage: Number.isFinite(Number(selected?.voteAverage))
? Number(Number(selected.voteAverage).toFixed(1))
: null,
imdbRating: String(
selected?.imdbRating
|| selected?.tmdbDetails?.imdbRating
|| ''
).trim() || null,
tmdbDetails: selected?.tmdbDetails && typeof selected.tmdbDetails === 'object'
? selected.tmdbDetails
: null,
seasonNumber: selectedIsSeries ? (selected.seasonNumber || null) : null,
seasonName: selectedIsSeries ? (selected.seasonName || null) : null,
episodeCount: selectedIsSeries ? (selected.episodeCount || 0) : 0,
episodes: selectedIsSeries && Array.isArray(selected.episodes) ? selected.episodes : [],
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
}
: {
jobId: context.jobId,
title: manualTitle,
year: manualYear,
imdbId: null,
poster: null,
metadataProvider,
workflowKind: manualWorkflowKind,
seasonNumber: null,
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
};
try {
await onSubmit(payload);
} catch (error) {
handleSubmitError(error, payload);
}
};
const handleForceDuplicateSubmit = async () => {
const pendingPayload = conflictState?.pendingPayload && typeof conflictState.pendingPayload === 'object'
? conflictState.pendingPayload
: null;
if (!pendingPayload) {
setConflictError('Konflikt-Zustand ist unvollständig. Bitte erneut suchen.');
return;
}
setConflictError('');
setSubmitError('');
try {
await onSubmit({
...pendingPayload,
duplicateAction: 'allow_new'
});
} catch (error) {
handleSubmitError(error, pendingPayload);
}
};
const handleSubmitMultipartMovie = async () => {
const pendingPayload = conflictState?.pendingPayload && typeof conflictState.pendingPayload === 'object'
? conflictState.pendingPayload
: null;
const existingJob = conflictState?.existingJob && typeof conflictState.existingJob === 'object'
? conflictState.existingJob
: null;
if (!pendingPayload || !existingJob?.id) {
setConflictError('Konflikt-Zustand ist unvollständig. Bitte erneut suchen.');
return;
}
const existingDisc = parseDiscNumber(conflictExistingDiscNumber);
const newDisc = parseDiscNumber(conflictNewDiscNumber);
if (!existingDisc || !newDisc) {
setConflictError('Für Multipart movie müssen beide Disc-Nummern gesetzt werden (>= 1).');
return;
}
if (existingDisc === newDisc) {
setConflictError('Disc-Nummern müssen unterschiedlich sein.');
return;
}
setConflictError('');
setSubmitError('');
try {
await onSubmit({
...pendingPayload,
duplicateAction: 'multipart_movie',
existingJobId: existingJob.id,
discNumber: newDisc,
existingDiscNumber: existingDisc
});
} catch (error) {
handleSubmitError(error, pendingPayload);
}
};
const handleHideRequest = () => {
onHide?.();
};
if (conflictState) {
const existingJob = conflictState?.existingJob && typeof conflictState.existingJob === 'object'
? conflictState.existingJob
: null;
return (
<Dialog
header="Metadaten bereits vorhanden"
visible={visible}
onHide={handleHideRequest}
style={{ width: '38rem', maxWidth: '96vw' }}
className="metadata-selection-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
modal
>
<p>
Für diese Film-Metadaten existiert bereits ein Job in der Historie.
</p>
<p>
Bestehender Job: <strong>#{existingJob?.id || '-'}</strong>
{' | '}
<strong>{existingJob?.title || '-'}</strong>
{existingJob?.year ? ` (${existingJob.year})` : ''}
</p>
<p>
Status: {existingJob?.status || '-'}
{existingJob?.discNumber ? ` | Disc ${existingJob.discNumber}` : ''}
</p>
<div className="metadata-grid">
<InputText
value={conflictExistingDiscNumber}
onChange={(event) => setConflictExistingDiscNumber(event.target.value)}
placeholder="Disc-Nr bestehender Job"
inputMode="numeric"
disabled={busy}
/>
<InputText
value={conflictNewDiscNumber}
onChange={(event) => setConflictNewDiscNumber(event.target.value)}
placeholder="Disc-Nr neuer Job"
inputMode="numeric"
disabled={busy}
/>
</div>
{conflictError ? <small className="error-text">{conflictError}</small> : null}
{submitError ? <small className="error-text">{submitError}</small> : null}
<div className="dialog-actions">
<Button
label="Zurück zur Auswahl"
severity="secondary"
text
onClick={() => {
setConflictState(null);
setConflictError('');
}}
disabled={busy}
/>
<Button
label="Übernahme erzwingen"
severity="secondary"
outlined
onClick={handleForceDuplicateSubmit}
loading={busy}
/>
<Button
label="Multipart movie"
icon="pi pi-sitemap"
onClick={handleSubmitMultipartMovie}
loading={busy}
/>
</div>
</Dialog>
);
}
return (
<Dialog
header="Metadaten auswählen"
visible={visible}
onHide={handleHideRequest}
style={{ width: '58.25rem', maxWidth: '95vw', maxHeight: '950px' }}
className="metadata-selection-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
modal
>
<div className="metadata-dialog-controls">
<div className="search-row">
<InputText
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void handleSearch();
}
}}
placeholder="Titel suchen"
/>
<Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
</div>
<div className={`metadata-filter-grid${showSeasonFilter ? '' : ' metadata-filter-grid-single'}`}>
{showSeasonFilter ? (
<InputText
value={seasonFilter}
onChange={(event) => setSeasonFilter(event.target.value)}
placeholder="Staffel-Filter (optional, z.B. 1 oder 5.2)"
disabled={searchBusy || busy}
/>
) : null}
<div className="metadata-filter-disc">
<InputText
value={manualDiscNumber}
onChange={(event) => setManualDiscNumber(event.target.value)}
placeholder={requiresDiscNumber ? 'Disc-Nr (Pflicht)' : 'Disc-Nr (optional)'}
inputMode="numeric"
disabled={searchBusy || busy}
/>
<small className="metadata-filter-subtitle">
{requiresDiscNumber
? 'Pflichtfeld für Serien-DVD/Blu-ray. Ohne Disc-Nummer können die Metadaten nicht übernommen werden.'
: 'Optional für Mapping und Output-Templates. Gespeichert werden nur Werte > 0.'}
</small>
</div>
</div>
<div className="metadata-result-filter-row">
<Button
label="Beides"
severity={resultFilter === 'all' ? 'primary' : 'secondary'}
outlined={resultFilter !== 'all'}
onClick={() => setResultFilter('all')}
disabled={searchBusy || busy}
/>
<Button
label="Filme"
severity={resultFilter === 'movies' ? 'primary' : 'secondary'}
outlined={resultFilter !== 'movies'}
onClick={() => setResultFilter('movies')}
disabled={searchBusy || busy}
/>
<Button
label="Serien"
severity={resultFilter === 'series' ? 'primary' : 'secondary'}
outlined={resultFilter !== 'series'}
onClick={() => setResultFilter('series')}
disabled={searchBusy || busy}
/>
</div>
</div>
{searchError ? <small className="error-text">{searchError}</small> : null}
{discValidationError ? <small className="error-text">{discValidationError}</small> : null}
{submitError ? <small className="error-text">{submitError}</small> : null}
<div className="table-scroll-wrap table-scroll-medium">
<DataTable
value={filteredRows}
selectionMode="single"
selection={selected}
onSelectionChange={(event) => setSelected(event.value)}
dataKey="_metadataKey"
size="small"
scrollable
scrollHeight="22rem"
emptyMessage="Keine Treffer"
responsiveLayout="stack"
breakpoint="960px"
>
<Column header="Titel" body={titleWithPosterBody} />
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
<Column
header="Typ"
body={(row) => (normalizeResultType(row?.resultType) === 'series' ? 'Serie' : 'Film')}
style={{ width: '7rem' }}
/>
<Column
header="Staffel"
body={(row) => (normalizeResultType(row?.resultType) === 'series'
? (row.seasonNumber ? `S${row.seasonNumber}` : '-')
: '-')}
style={{ width: '8rem' }}
/>
</DataTable>
</div>
<h4>Manuelle Eingabe</h4>
<div className="metadata-grid">
<InputText
value={manualTitle}
onChange={(event) => setManualTitle(event.target.value)}
placeholder="Titel"
disabled={!!selected}
/>
<InputText
value={manualYear}
onChange={(event) => setManualYear(event.target.value)}
placeholder="Jahr"
disabled={!!selected}
/>
<InputText
value={manualImdb}
onChange={(event) => setManualImdb(event.target.value)}
placeholder={supportsExternalIdInput ? 'IMDb-ID' : 'Externe ID'}
disabled={!!selected || !supportsExternalIdInput}
/>
</div>
<div className="dialog-actions">
<Button
label="Abbrechen"
severity="secondary"
text
onClick={handleHideRequest}
/>
<Button
label="Auswahl übernehmen"
icon="pi pi-play"
onClick={handleSubmit}
loading={busy}
disabled={
(!selected && !manualTitle.trim() && !manualImdb.trim())
|| (requiresDiscNumber && !hasValidDiscNumber)
}
/>
</div>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,192 @@
import { useState, useEffect } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
/**
* Modal zur Konfliktlösung wenn bereits encodierte Ausgabe-Ordner existieren.
*
* Props:
* - visible: boolean
* - onHide: () => void
* - job: object (das Job-Objekt)
* - existingFolders: Array<{ id, output_path, label, created_at }>
* - onKeepBoth: () => void "Beide behalten" geklickt
* - onDeleteSelected: (selectedPaths: string[]) => void "Auswahl löschen & neu encodieren" geklickt
* - busy: boolean
*/
export default function ReencodeConflictModal({
visible = false,
onHide,
job,
existingFolders = [],
mode = 'reencode',
onKeepBoth,
onDeleteSelected,
busy = false
}) {
const [checkedPaths, setCheckedPaths] = useState(new Set());
const isDeleteMode = String(mode || '').trim().toLowerCase() === 'delete';
// Reset on open
useEffect(() => {
if (visible) {
setCheckedPaths(new Set());
}
}, [visible]);
const togglePath = (p) => {
setCheckedPaths((prev) => {
const next = new Set(prev);
if (next.has(p)) {
next.delete(p);
} else {
next.add(p);
}
return next;
});
};
const toggleAll = () => {
if (checkedPaths.size === existingFolders.length) {
setCheckedPaths(new Set());
} else {
setCheckedPaths(new Set(existingFolders.map((f) => f.output_path)));
}
};
const allChecked = existingFolders.length > 0 && checkedPaths.size === existingFolders.length;
const someChecked = checkedPaths.size > 0 && !allChecked;
const noneChecked = checkedPaths.size === 0;
const title = job?.title || job?.detected_title || `Job #${job?.id || ''}`;
const handleDeleteSelected = () => {
const selected = [...checkedPaths];
onDeleteSelected?.(selected);
};
const footer = (
<div className="reencode-conflict-footer">
<Button
label="Abbrechen"
icon="pi pi-times"
severity="secondary"
outlined
size="small"
onClick={onHide}
disabled={busy}
/>
{!isDeleteMode ? (
<Button
label="Beide behalten"
icon="pi pi-copy"
severity="info"
size="small"
onClick={onKeepBoth}
loading={busy}
title="Bestehende Ausgabe belassen neuer Encode erhält eine Nummerierung (_2, _3 …)"
/>
) : null}
<Button
label={isDeleteMode
? (noneChecked ? 'Alle Ordner löschen' : `${checkedPaths.size} Ordner löschen`)
: (noneChecked
? 'Alle löschen & neu encodieren'
: `${checkedPaths.size} Ordner löschen & neu encodieren`)}
icon="pi pi-trash"
severity="danger"
size="small"
onClick={handleDeleteSelected}
loading={busy}
disabled={false}
title={isDeleteMode
? (noneChecked
? 'Alle bestehenden Ausgabe-Ordner löschen'
: 'Ausgewählte Ausgabe-Ordner löschen')
: (noneChecked
? 'Alle bestehenden Ausgabe-Ordner löschen, dann neu encodieren'
: 'Ausgewählte Ausgabe-Ordner löschen, dann neu encodieren')}
/>
</div>
);
return (
<Dialog
header={isDeleteMode ? 'Ausgabe-Ordner löschen' : 'Ausgabe bereits vorhanden'}
visible={visible}
onHide={onHide}
style={{ width: '44rem', maxWidth: '96vw' }}
modal
footer={footer}
className="reencode-conflict-modal"
>
<p className="reencode-conflict-intro">
{isDeleteMode ? (
<>
Für <strong>{title}</strong> wurden mehrere Ausgabe-Ordner erkannt.
Welche Ordner sollen gelöscht werden?
</>
) : (
<>
Für <strong>{title}</strong> existieren bereits encodierte Dateien.
Wie soll mit den vorhandenen Ausgabe-Ordnern umgegangen werden?
</>
)}
</p>
{existingFolders.length > 0 ? (
<div className="reencode-conflict-folders">
<div className="reencode-conflict-folders-header">
<label className="reencode-conflict-check-all">
<input
type="checkbox"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked; }}
onChange={toggleAll}
/>
<span>Alle auswählen (zum Löschen)</span>
</label>
</div>
{existingFolders.map((folder) => {
const p = folder.output_path;
const checked = checkedPaths.has(p);
const folderName = p.split(/[/\\]/).filter(Boolean).pop() || p;
return (
<label key={p} className={`reencode-conflict-folder-row${checked ? ' is-checked' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => togglePath(p)}
/>
<span className="reencode-conflict-folder-name" title={p}>{folderName}</span>
{folder.created_at ? (
<small className="reencode-conflict-folder-date">
{new Date(folder.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
})}
</small>
) : null}
</label>
);
})}
</div>
) : (
<p className="reencode-conflict-no-folders">
{isDeleteMode
? 'Es wurden keine gespeicherten Ausgabe-Ordner gefunden.'
: 'Es wurden keine gespeicherten Ausgabe-Ordner gefunden. Der neue Encode wird ggf. automatisch nummeriert.'}
</p>
)}
{!isDeleteMode ? (
<div className="reencode-conflict-hint">
<strong>Beide behalten:</strong> Bestehende Ordner bleiben. Neuer Encode erhält eine Nummerierung (_2, _3 ).<br />
<strong>Löschen &amp; neu encodieren:</strong> Ausgewählte (oder alle) Ordner werden gelöscht.
Wenn alle gelöscht wurden, erhält der neue Encode keine Nummerierung.
Wenn nur eine Auswahl gelöscht wurde, setzt die Nummerierung fort.
</div>
) : null}
</Dialog>
);
}
@@ -0,0 +1,80 @@
export const AUDIOBOOK_FORMATS = [
{ label: 'M4B (Original-Audio)', value: 'm4b' },
{ label: 'MP3', value: 'mp3' },
{ label: 'FLAC (verlustlos)', value: 'flac' }
];
export const AUDIOBOOK_FORMAT_SCHEMAS = {
m4b: {
fields: []
},
flac: {
fields: [
{
key: 'flacCompression',
label: 'Kompressionsstufe',
description: '0 = schnell / wenig Kompression, 8 = maximale Kompression',
type: 'slider',
min: 0,
max: 8,
step: 1,
default: 5
}
]
},
mp3: {
fields: [
{
key: 'mp3Mode',
label: 'Modus',
type: 'select',
options: [
{ label: 'CBR (Konstante Bitrate)', value: 'cbr' },
{ label: 'VBR (Variable Bitrate)', value: 'vbr' }
],
default: 'cbr'
},
{
key: 'mp3Bitrate',
label: 'Bitrate (kbps)',
type: 'select',
showWhen: { field: 'mp3Mode', value: 'cbr' },
options: [
{ label: '128 kbps', value: 128 },
{ label: '160 kbps', value: 160 },
{ label: '192 kbps', value: 192 },
{ label: '256 kbps', value: 256 },
{ label: '320 kbps', value: 320 }
],
default: 192
},
{
key: 'mp3Quality',
label: 'VBR Qualität (V0-V9)',
description: '0 = beste Qualität, 9 = kleinste Datei',
type: 'slider',
min: 0,
max: 9,
step: 1,
showWhen: { field: 'mp3Mode', value: 'vbr' },
default: 4
}
]
}
};
export function getDefaultAudiobookFormatOptions(format) {
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format];
if (!schema) {
return {};
}
const defaults = {};
for (const field of schema.fields) {
if (field.default !== undefined) {
defaults[field.key] = field.default;
}
}
return defaults;
}
+126
View File
@@ -0,0 +1,126 @@
/**
* CD output format schemas.
* Each format defines the fields shown in CdRipConfigPanel.
*/
export const CD_FORMATS = [
{ label: 'FLAC (verlustlos)', value: 'flac' },
{ label: 'MP3', value: 'mp3' },
{ label: 'Opus', value: 'opus' },
{ label: 'OGG Vorbis', value: 'ogg' },
{ label: 'WAV (unkomprimiert)', value: 'wav' }
];
export const CD_FORMAT_SCHEMAS = {
wav: {
fields: []
},
flac: {
fields: [
{
key: 'flacCompression',
label: 'Kompressionsstufe',
description: '0 = schnell / wenig Kompression, 8 = maximale Kompression',
type: 'slider',
min: 0,
max: 8,
step: 1,
default: 5
}
]
},
mp3: {
fields: [
{
key: 'mp3Mode',
label: 'Modus',
type: 'select',
options: [
{ label: 'CBR (Konstante Bitrate)', value: 'cbr' },
{ label: 'VBR (Variable Bitrate)', value: 'vbr' }
],
default: 'cbr'
},
{
key: 'mp3Bitrate',
label: 'Bitrate (kbps)',
type: 'select',
showWhen: { field: 'mp3Mode', value: 'cbr' },
options: [
{ label: '128 kbps', value: 128 },
{ label: '160 kbps', value: 160 },
{ label: '192 kbps', value: 192 },
{ label: '256 kbps', value: 256 },
{ label: '320 kbps', value: 320 }
],
default: 192
},
{
key: 'mp3Quality',
label: 'VBR Qualität (V0V9)',
description: '0 = beste Qualität, 9 = kleinste Datei',
type: 'slider',
min: 0,
max: 9,
step: 1,
showWhen: { field: 'mp3Mode', value: 'vbr' },
default: 4
}
]
},
opus: {
fields: [
{
key: 'opusBitrate',
label: 'Bitrate (kbps)',
description: 'Empfohlen: 96192 kbps für Musik',
type: 'slider',
min: 32,
max: 512,
step: 8,
default: 160
},
{
key: 'opusComplexity',
label: 'Encoder-Komplexität',
description: '0 = schnell, 10 = beste Qualität',
type: 'slider',
min: 0,
max: 10,
step: 1,
default: 10
}
]
},
ogg: {
fields: [
{
key: 'oggQuality',
label: 'Qualität',
description: '-1 = kleinste Datei, 10 = beste Qualität. Empfohlen: 57.',
type: 'slider',
min: -1,
max: 10,
step: 1,
default: 6
}
]
}
};
export function getDefaultFormatOptions(format) {
const schema = CD_FORMAT_SCHEMAS[format];
if (!schema) {
return {};
}
const defaults = {};
for (const field of schema.fields) {
if (field.default !== undefined) {
defaults[field.key] = field.default;
}
}
return defaults;
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useRef } from 'react';
function buildWsUrl() {
if (import.meta.env.VITE_WS_URL) {
return import.meta.env.VITE_WS_URL;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws`;
}
function normalizeStage(value) {
return String(value || '').trim().toUpperCase();
}
function isRunningStage(value) {
const normalized = normalizeStage(value);
return normalized === 'ANALYZING'
|| normalized === 'RIPPING'
|| normalized === 'MEDIAINFO_CHECK'
|| normalized === 'ENCODING'
|| normalized === 'CD_ANALYZING'
|| normalized === 'CD_RIPPING'
|| normalized === 'CD_ENCODING';
}
export function useWebSocket({ onMessage }) {
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
let socket;
let reconnectTimer;
let progressFlushTimer = null;
let progressFlushRaf = null;
const latestProgressMessagesByJob = new Map();
let isUnmounted = false;
const clearProgressFlush = () => {
if (progressFlushRaf !== null && typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(progressFlushRaf);
}
if (progressFlushTimer) {
clearTimeout(progressFlushTimer);
}
progressFlushRaf = null;
progressFlushTimer = null;
};
const flushProgressMessage = () => {
progressFlushRaf = null;
progressFlushTimer = null;
if (latestProgressMessagesByJob.size === 0) {
return;
}
const messages = Array.from(latestProgressMessagesByJob.values());
latestProgressMessagesByJob.clear();
for (const message of messages) {
onMessageRef.current?.(message);
}
};
const scheduleProgressFlush = () => {
if (progressFlushRaf !== null || progressFlushTimer) {
return;
}
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
progressFlushRaf = window.requestAnimationFrame(flushProgressMessage);
return;
}
progressFlushTimer = setTimeout(flushProgressMessage, 16);
};
const connect = () => {
if (isUnmounted) {
return;
}
socket = new WebSocket(buildWsUrl());
socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message?.type === 'PIPELINE_PROGRESS') {
const progressJobId = message?.payload?.activeJobId;
const jobKey = progressJobId == null ? '__global__' : String(progressJobId);
const progressStage = normalizeStage(message?.payload?.state);
if (isRunningStage(progressStage)) {
latestProgressMessagesByJob.set(jobKey, message);
scheduleProgressFlush();
} else {
latestProgressMessagesByJob.delete(jobKey);
clearProgressFlush();
flushProgressMessage();
onMessageRef.current?.(message);
}
return;
}
flushProgressMessage();
onMessageRef.current?.(message);
} catch (error) {
// ignore invalid json
}
};
socket.onclose = () => {
if (!isUnmounted) {
reconnectTimer = setTimeout(connect, 1500);
}
};
socket.onerror = () => {
if (socket && socket.readyState !== WebSocket.CLOSED) {
socket.close();
}
};
};
connect();
return () => {
isUnmounted = true;
clearProgressFlush();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
if (socket && socket.readyState !== WebSocket.CLOSED) {
socket.close();
}
};
}, []);
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import 'primereact/resources/themes/lara-light-amber/theme.css';
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
import './styles/app.css';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true
}}
>
<App />
</BrowserRouter>
</React.StrictMode>
);
+571
View File
@@ -0,0 +1,571 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Card } from 'primereact/card';
import { Toast } from 'primereact/toast';
import { Badge } from 'primereact/badge';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import AudiobookUploadPanel from '../components/AudiobookUploadPanel';
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
import { resolveMediaType } from '../utils/jobTaxonomy';
import { confirmModal } from '../utils/confirmModal';
const TERMINAL_STATES = new Set(['DONE', 'FINISHED']);
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function hasOwn(source, key) {
return source != null && Object.prototype.hasOwnProperty.call(source, key);
}
function normalizeLiveProgressForJob(job) {
const live = job?.liveProgress && typeof job.liveProgress === 'object' ? job.liveProgress : null;
if (!live) {
return null;
}
const liveContext = live?.context && typeof live.context === 'object' ? live.context : {};
return {
progress: live?.progress ?? null,
eta: live?.eta ?? null,
statusText: live?.statusText ?? null,
state: live?.state ?? null,
currentChapter: liveContext?.currentChapter && typeof liveContext.currentChapter === 'object'
? liveContext.currentChapter
: null,
completedChapterCount: Number.isFinite(Number(liveContext?.completedChapterCount))
? Number(liveContext.completedChapterCount)
: null
};
}
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 parseEncodePlan(job) {
if (job?.encodePlan && typeof job.encodePlan === 'object') {
return job.encodePlan;
}
try {
return JSON.parse(job?.encode_plan_json || '{}');
} catch (_error) {
return {};
}
}
function resolveAudiobookMetadata(job, encodePlan) {
const handbrakeMeta = job?.handbrakeInfo?.metadata && typeof job.handbrakeInfo.metadata === 'object'
? job.handbrakeInfo.metadata
: {};
const selectedMeta = job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
? job.makemkvInfo.selectedMetadata
: {};
const fallbackMeta = encodePlan?.metadata && typeof encodePlan.metadata === 'object'
? encodePlan.metadata
: {};
const merged = {
...fallbackMeta,
...selectedMeta,
...handbrakeMeta
};
const chapters = Array.isArray(merged?.chapters)
? merged.chapters
: (Array.isArray(encodePlan?.chapters) ? encodePlan.chapters : []);
return {
title: String(job?.title || job?.detected_title || merged?.title || '').trim() || null,
author: String(merged?.author || merged?.artist || '').trim() || null,
narrator: String(merged?.narrator || '').trim() || null,
description: String(merged?.description || '').trim() || null,
series: String(merged?.series || '').trim() || null,
part: Number.isFinite(Number(merged?.part)) ? Math.trunc(Number(merged.part)) : null,
year: Number.isFinite(Number(job?.year))
? Math.trunc(Number(job.year))
: (Number.isFinite(Number(merged?.year)) ? Math.trunc(Number(merged.year)) : null),
chapters,
durationMs: Number.isFinite(Number(merged?.durationMs)) ? Number(merged.durationMs) : 0,
poster: String(job?.poster_url || merged?.poster || '').trim() || null
};
}
function buildAudiobookJobMetaLine(jobId, metadata = {}, fallbackYear = null) {
const author = String(metadata?.author || metadata?.artist || '').trim() || null;
const narrator = String(metadata?.narrator || '').trim() || null;
const chapterCount = Array.isArray(metadata?.chapters) ? metadata.chapters.length : 0;
const year = Number.isFinite(Number(metadata?.year))
? Math.trunc(Number(metadata.year))
: (Number.isFinite(Number(fallbackYear)) ? Math.trunc(Number(fallbackYear)) : null);
return (
`#${jobId}`
+ (author ? ` | ${author}` : '')
+ (narrator ? ` | ${narrator}` : '')
+ (chapterCount > 0 ? ` | ${chapterCount} Kapitel` : '')
+ (year ? ` | ${year}` : '')
);
}
function buildAudiobookPipeline(job, progress = null) {
const encodePlan = parseEncodePlan(job);
const selectedMetadata = resolveAudiobookMetadata(job, encodePlan);
const reviewData = job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
? job.makemkvInfo.selectedMetadata
: selectedMetadata;
const state = String(progress?.state || job?.status || '').trim().toUpperCase() || 'UNKNOWN';
return {
state,
activeJobId: Number(job?.id) || null,
progress: Number.isFinite(Number(progress?.progress)) ? Number(progress.progress) : 0,
eta: progress?.eta || null,
statusText: progress?.statusText || null,
context: {
jobId: Number(job?.id) || null,
mode: 'audiobook',
mediaProfile: 'audiobook',
selectedMetadata,
audiobookConfig: {
format: String(encodePlan?.format || job?.handbrakeInfo?.format || 'mp3').trim().toLowerCase() || 'mp3',
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
? encodePlan.formatOptions
: {},
preEncodeScriptIds: Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : [],
postEncodeScriptIds: Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : [],
preEncodeChainIds: Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [],
postEncodeChainIds: Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [],
preEncodeItems: Array.isArray(encodePlan?.preEncodeItems) ? encodePlan.preEncodeItems : [],
postEncodeItems: Array.isArray(encodePlan?.postEncodeItems) ? encodePlan.postEncodeItems : []
},
mediaInfoReview: reviewData,
outputPath: String(job?.output_path || encodePlan?.outputPath || '').trim() || null,
currentChapter: progress?.currentChapter && typeof progress.currentChapter === 'object'
? progress.currentChapter
: null,
completedChapterCount: Number.isFinite(Number(progress?.completedChapterCount))
? Number(progress.completedChapterCount)
: null
}
};
}
export default function AudiobooksPage({
audiobookUpload,
onAudiobookUpload,
onCancelAudiobookUpload = null
}) {
const toastRef = useRef(null);
const [jobs, setJobs] = useState([]);
const [loadingJobs, setLoadingJobs] = useState(false);
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [jobProgress, setJobProgress] = useState({});
const [actionBusyJobIds, setActionBusyJobIds] = useState(() => new Set());
const setActionBusy = (jobId, busy) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setActionBusyJobIds((prev) => {
const next = new Set(prev);
if (busy) {
next.add(normalizedJobId);
} else {
next.delete(normalizedJobId);
}
return next;
});
};
const loadJobs = useCallback(async () => {
setLoadingJobs(true);
try {
const response = await api.getAudiobookJobs();
const rows = Array.isArray(response?.jobs) ? response.jobs : [];
const audiobookRows = rows
.filter((job) => resolveMediaType(job) === 'audiobook')
.sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0));
setJobs(audiobookRows);
setJobProgress((prev) => {
const next = {};
for (const job of audiobookRows) {
const jobId = normalizeJobId(job?.id);
if (!jobId) {
continue;
}
const status = String(job?.status || '').trim().toUpperCase();
const isLiveStatus = ['ANALYZING', 'ENCODING'].includes(status);
if (!isLiveStatus) {
continue;
}
const serverProgress = normalizeLiveProgressForJob(job);
if (serverProgress) {
next[jobId] = serverProgress;
continue;
}
if (prev?.[jobId] && typeof prev[jobId] === 'object') {
next[jobId] = prev[jobId];
}
}
return next;
});
return audiobookRows;
} catch (error) {
console.error('AudiobooksPage load jobs error:', error);
return [];
} finally {
setLoadingJobs(false);
}
}, []);
useEffect(() => {
void loadJobs();
const intervalId = setInterval(() => void loadJobs(), 5000);
return () => clearInterval(intervalId);
}, [loadJobs]);
useWebSocket({
onMessage: (message) => {
if (!message?.type || !message?.payload) {
return;
}
if (message.type === 'PIPELINE_PROGRESS') {
const payload = message.payload;
const jobId = normalizeJobId(payload?.activeJobId);
if (!jobId) {
return;
}
setJobProgress((prev) => {
const previousEntry = prev?.[jobId] && typeof prev[jobId] === 'object' ? prev[jobId] : {};
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
? payload.contextPatch
: null;
const nextCurrentChapter = hasOwn(contextPatch, 'currentChapter')
? (contextPatch?.currentChapter ?? null)
: (previousEntry?.currentChapter ?? null);
const nextCompletedChapterCount = hasOwn(contextPatch, 'completedChapterCount')
? (Number.isFinite(Number(contextPatch?.completedChapterCount))
? Number(contextPatch.completedChapterCount)
: null)
: (previousEntry?.completedChapterCount ?? null);
return {
...prev,
[jobId]: {
progress: payload?.progress ?? previousEntry?.progress ?? null,
eta: payload?.eta ?? previousEntry?.eta ?? null,
statusText: payload?.statusText ?? previousEntry?.statusText ?? null,
state: payload?.state ?? previousEntry?.state ?? null,
currentChapter: nextCurrentChapter,
completedChapterCount: nextCompletedChapterCount
}
};
});
}
if (
message.type === 'PIPELINE_UPDATE'
|| message.type === 'PIPELINE_STATE_CHANGED'
|| message.type === 'PIPELINE_QUEUE_CHANGED'
) {
void loadJobs();
}
}
});
const activeJobs = useMemo(
() => jobs.filter((job) => !TERMINAL_STATES.has(String(job?.status || '').trim().toUpperCase())),
[jobs]
);
useEffect(() => {
const normalizedExpanded = normalizeJobId(expandedJobId);
const hasExpanded = activeJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
if (hasExpanded) {
return;
}
if (expandedJobId === null) {
return;
}
if (activeJobs.length === 0) {
return;
}
setExpandedJobId(normalizeJobId(activeJobs[0]?.id));
}, [activeJobs, expandedJobId]);
const handleAudiobookStart = async (jobId, audiobookConfig) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setActionBusy(normalizedJobId, true);
try {
const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {});
const queued = Boolean(response?.result?.queued);
toastRef.current?.show({
severity: queued ? 'info' : 'success',
summary: queued ? 'Job eingereiht' : 'Audiobook gestartet',
detail: queued
? `Job #${normalizedJobId} wurde in die Warteschlange eingereiht.`
: `Job #${normalizedJobId} wurde gestartet.`,
life: 3200
});
await loadJobs();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Start fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
} finally {
setActionBusy(normalizedJobId, false);
}
};
const handleCancel = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setActionBusy(normalizedJobId, true);
try {
await api.cancelPipeline(normalizedJobId);
toastRef.current?.show({
severity: 'info',
summary: 'Abbruch angefordert',
detail: `Job #${normalizedJobId} wird abgebrochen.`,
life: 2600
});
await loadJobs();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Abbruch fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
} finally {
setActionBusy(normalizedJobId, false);
}
};
const handleDelete = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
const confirmed = await confirmModal({
message: `Job #${normalizedJobId} wirklich aus der Historie entfernen?`,
header: 'Job loeschen',
icon: 'pi pi-exclamation-triangle',
acceptClassName: 'p-button-danger',
acceptLabel: 'Loeschen',
rejectLabel: 'Abbrechen'
});
if (!confirmed) {
return;
}
setActionBusy(normalizedJobId, true);
try {
await api.deleteJobEntry(normalizedJobId, 'none', { includeRelated: false });
toastRef.current?.show({
severity: 'success',
summary: 'Job geloescht',
detail: `Job #${normalizedJobId} wurde entfernt.`,
life: 2800
});
if (normalizeJobId(expandedJobId) === normalizedJobId) {
setExpandedJobId(null);
}
await loadJobs();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Loeschen fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
} finally {
setActionBusy(normalizedJobId, false);
}
};
const handleUploaded = async (uploadedJobId, response = null) => {
const normalizedJobId = normalizeJobId(uploadedJobId)
|| extractUploadJobIdFromResponse(response);
const rows = await loadJobs();
const fallbackJobId = normalizeJobId(rows?.[0]?.id);
const targetJobId = normalizedJobId || fallbackJobId;
if (targetJobId) {
setExpandedJobId(targetJobId);
}
};
const jobsCardHeader = (
<div className="converter-card-header">
<div className="converter-card-title">
<span>Audiobook Jobs</span>
{activeJobs.length > 0 ? (
<Badge value={activeJobs.length} severity="info" />
) : null}
</div>
<Button
icon={loadingJobs ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
text
rounded
size="small"
onClick={() => void loadJobs()}
disabled={loadingJobs}
aria-label="Jobs neu laden"
/>
</div>
);
return (
<div className="ripper-subpage-content">
<Toast ref={toastRef} position="top-right" />
<Card title="Audiobooks Upload" subTitle="AAX-Dateien importieren und als Audiobook-Job vorbereiten">
<AudiobookUploadPanel
audiobookUpload={audiobookUpload}
onAudiobookUpload={onAudiobookUpload}
onCancelUpload={onCancelAudiobookUpload}
onUploaded={handleUploaded}
/>
</Card>
<Card header={jobsCardHeader}>
{activeJobs.length === 0 ? (
<p className="converter-jobs-empty-hint">
<small>Keine aktiven Audiobook-Jobs vorhanden. Fertige Jobs findest du in der Historie.</small>
</p>
) : (
<div className="ripper-job-list">
{activeJobs.map((job) => {
const jobId = normalizeJobId(job?.id);
if (!jobId) {
return null;
}
const pipelineForJob = buildAudiobookPipeline(job, jobProgress[jobId] || null);
const state = String(pipelineForJob?.state || job?.status || '').trim().toUpperCase();
const isExpanded = normalizeJobId(expandedJobId) === jobId;
const busy = actionBusyJobIds.has(jobId);
const title = String(job?.title || job?.detected_title || `Job #${jobId}`).trim();
const progressValue = Number.isFinite(Number(pipelineForJob?.progress))
? Math.max(0, Math.min(100, Number(pipelineForJob.progress)))
: 0;
const isRunning = ['ANALYZING', 'ENCODING'].includes(state);
const showProgress = isRunning || progressValue > 0;
const progressLabel = `${Math.trunc(progressValue)}%`;
const etaLabel = String(pipelineForJob?.eta || '').trim();
const currentChapter = pipelineForJob?.context?.currentChapter && typeof pipelineForJob.context.currentChapter === 'object'
? pipelineForJob.context.currentChapter
: null;
const chapterLabel = currentChapter?.index && currentChapter?.total
? ` | Kapitel ${currentChapter.index}/${currentChapter.total}${currentChapter.title ? `: ${currentChapter.title}` : ''}`
: '';
const metaLine = buildAudiobookJobMetaLine(jobId, pipelineForJob?.context?.selectedMetadata, job?.year);
if (!isExpanded) {
return (
<button
key={jobId}
type="button"
className="ripper-job-row"
onClick={() => setExpandedJobId(jobId)}
>
{(job?.poster_url && job.poster_url !== 'N/A') ? (
<img src={job.poster_url} alt={title} className="poster-thumb" />
) : (
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
)}
<div className="ripper-job-row-content">
<div className="ripper-job-row-main">
<strong className="ripper-job-title-line">
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>{title}</span>
</strong>
<small>{metaLine}</small>
</div>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
</div>
{showProgress ? (
<div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} />
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
</div>
) : null}
</div>
<i className="pi pi-angle-down" aria-hidden="true" />
</button>
);
}
return (
<div key={jobId} className="ripper-job-expanded">
<div className="ripper-job-expanded-head">
{(job?.poster_url && job.poster_url !== 'N/A') ? (
<img src={job.poster_url} alt={title} className="poster-thumb" />
) : (
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
)}
<div className="ripper-job-expanded-title">
<strong className="ripper-job-title-line">
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>{title}</span>
</strong>
<small className="ripper-job-subtitle">{metaLine}</small>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button
label="Einklappen"
icon="pi pi-angle-up"
severity="secondary"
outlined
onClick={() => setExpandedJobId(null)}
disabled={busy}
/>
</div>
</div>
{showProgress ? (
<div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} />
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
</div>
) : null}
<AudiobookConfigPanel
pipeline={pipelineForJob}
onStart={(config) => void handleAudiobookStart(jobId, config)}
onCancel={() => void handleCancel(jobId)}
onDeleteJob={() => void handleDelete(jobId)}
busy={busy}
/>
</div>
);
})}
</div>
)}
</Card>
</div>
);
}
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
import { useEffect, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { confirmModal } from '../utils/confirmModal';
function formatDetectedMediaType(value) {
const mediaType = String(value || '').trim().toLowerCase();
if (mediaType === 'bluray') {
return 'Blu-ray';
}
if (mediaType === 'dvd') {
return 'DVD';
}
if (mediaType === 'cd') {
return 'CD';
}
if (mediaType === 'audiobook') {
return 'Audiobook';
}
return 'Sonstiges';
}
export default function DatabasePage() {
const navigate = useNavigate();
const [orphanRows, setOrphanRows] = useState([]);
const [orphanLoading, setOrphanLoading] = useState(false);
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
const [orphanDeleteBusyPath, setOrphanDeleteBusyPath] = useState(null);
const toastRef = useRef(null);
const orphanLoadRequestRef = useRef(0);
const loadOrphans = async (options = {}) => {
const requestId = orphanLoadRequestRef.current + 1;
orphanLoadRequestRef.current = requestId;
const silent = Boolean(options?.silent);
if (!silent) {
setOrphanLoading(true);
}
try {
const response = await api.getOrphanRawFolders();
if (orphanLoadRequestRef.current !== requestId) {
return;
}
setOrphanRows(Array.isArray(response?.rows) ? response.rows : []);
} catch (error) {
if (orphanLoadRequestRef.current !== requestId) {
return;
}
toastRef.current?.show({ severity: 'error', summary: 'RAW-Prüfung fehlgeschlagen', detail: error.message });
} finally {
if (orphanLoadRequestRef.current === requestId && !silent) {
setOrphanLoading(false);
}
}
};
useEffect(() => {
void loadOrphans();
}, []);
const handleImportOrphanRaw = async (row) => {
const target = row?.rawPath || row?.folderName || '-';
const confirmed = await confirmModal({
header: 'Historienjob anlegen',
message: `Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`,
acceptLabel: 'Job anlegen',
rejectLabel: 'Abbrechen'
});
if (!confirmed) {
return;
}
setOrphanImportBusyPath(row.rawPath);
try {
const response = await api.importOrphanRawFolder(row.rawPath);
const newJobId = response?.job?.id;
const activationStarted = Boolean(response?.activation?.started);
const activationError = String(response?.activationError || '').trim();
const importedRawPath = String(row?.rawPath || '').trim();
if (importedRawPath) {
// Immediate UX feedback: imported orphan should disappear without a full page reload.
setOrphanRows((previousRows) => (
(Array.isArray(previousRows) ? previousRows : []).filter((entry) => String(entry?.rawPath || '').trim() !== importedRawPath)
));
}
toastRef.current?.show({
severity: 'success',
summary: activationStarted ? 'Analyse gestartet' : 'Job angelegt',
detail: activationStarted
? (newJobId
? `Job #${newJobId} läuft jetzt im Ripper-View.`
: 'Job läuft jetzt im Ripper-View.')
: (newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.'),
life: 3500
});
if (activationError) {
toastRef.current?.show({
severity: 'warn',
summary: 'Analyse-Start fehlgeschlagen',
detail: activationError,
life: 5200
});
}
// Some workflows update job linkage milliseconds later (e.g. follow-up analyze start).
// Recheck a few times in background to converge UI without manual refresh.
const refreshDelaysMs = [0, 600, 1800];
for (const delayMs of refreshDelaysMs) {
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
await loadOrphans({ silent: true });
}
// Jump to the Ripper view immediately after creating a job from /database.
navigate('/ripper');
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Import fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setOrphanImportBusyPath(null);
}
};
const handleDeleteOrphanRaw = async (row) => {
const target = String(row?.rawPath || row?.folderName || '-').trim() || '-';
const confirmed = await confirmModal({
header: 'RAW löschen',
message:
`RAW-Ordner "${target}" wirklich löschen?\n` +
'Der Ordner wird dauerhaft aus dem Dateisystem entfernt und verschwindet aus der /database-Liste.',
acceptLabel: 'RAW löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
setOrphanDeleteBusyPath(row.rawPath);
try {
const response = await api.deleteOrphanRawFolder(row.rawPath);
const deletedRawPath = String(response?.rawPath || row?.rawPath || '').trim();
const deletedFiles = Number(response?.filesDeleted || 0);
const deletedDirs = Number(response?.dirsRemoved || 0);
if (deletedRawPath) {
setOrphanRows((previousRows) => (
(Array.isArray(previousRows) ? previousRows : []).filter((entry) => String(entry?.rawPath || '').trim() !== deletedRawPath)
));
}
toastRef.current?.show({
severity: 'success',
summary: 'RAW gelöscht',
detail: `Ordner entfernt | Dateien: ${deletedFiles}, Ordner: ${deletedDirs}`,
life: 3500
});
await loadOrphans({ silent: true });
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschen fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setOrphanDeleteBusyPath(null);
}
};
const orphanTitleBody = (row) => (
<div>
<div><strong>{row.title || '-'}</strong></div>
<small>{row.year || '-'} | {row.imdbId || '-'}</small>
</div>
);
const orphanMediaBody = (row) => (
<Tag value={formatDetectedMediaType(row?.detectedMediaType)} />
);
const orphanPathBody = (row) => (
<div className="orphan-path-cell">
{row.rawPath}
</div>
);
const orphanActionBody = (row) => (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<Button
label="Job anlegen"
icon="pi pi-plus"
size="small"
onClick={() => handleImportOrphanRaw(row)}
loading={orphanImportBusyPath === row.rawPath}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
<Button
label="RAW löschen"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
onClick={() => handleDeleteOrphanRaw(row)}
loading={orphanDeleteBusyPath === row.rawPath}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
</div>
);
return (
<div className="page-grid">
<Toast ref={toastRef} />
<Card
title="Gefundene RAW-Einträge"
subTitle="Es werden RAW-Ordner ohne zugehörigen Historienjob aus Settings- und Default-RAW-Pfaden angezeigt."
>
<div className="table-filters">
<Button
label="RAW prüfen"
icon="pi pi-search"
onClick={loadOrphans}
loading={orphanLoading}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
<Tag value={`${orphanRows.length} gefunden`} severity={orphanRows.length > 0 ? 'warning' : 'success'} />
</div>
<div className="table-scroll-wrap table-scroll-wide">
<DataTable
value={orphanRows}
dataKey="rawPath"
paginator
rows={10}
loading={orphanLoading}
emptyMessage="Keine verwaisten RAW-Ordner gefunden"
responsiveLayout="scroll"
>
<Column field="folderName" header="RAW-Ordner" style={{ minWidth: '18rem' }} />
<Column header="Medium" body={orphanMediaBody} style={{ width: '10rem' }} />
<Column header="Titel" body={orphanTitleBody} style={{ minWidth: '14rem' }} />
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
<Column field="lastModifiedAt" header="Geändert" style={{ width: '16rem' }} />
<Column header="Aktion" body={orphanActionBody} style={{ minWidth: '18rem' }} />
</DataTable>
</div>
</Card>
</div>
);
}
+312
View File
@@ -0,0 +1,312 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { api } from '../api/client';
import { confirmModal } from '../utils/confirmModal';
const STATUS_OPTIONS = [
{ label: 'Alle Stati', value: '' },
{ label: 'Wartend', value: 'queued' },
{ label: 'Laufend', value: 'processing' },
{ label: 'Bereit', value: 'ready' },
{ label: 'Fehlgeschlagen', value: 'failed' }
];
function formatDateTime(value) {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value);
}
return date.toLocaleString('de-DE', {
dateStyle: 'short',
timeStyle: 'short'
});
}
function formatBytes(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return '-';
}
if (parsed === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let unitIndex = 0;
let current = parsed;
while (current >= 1024 && unitIndex < units.length - 1) {
current /= 1024;
unitIndex += 1;
}
const digits = unitIndex === 0 ? 0 : 2;
return `${current.toFixed(digits)} ${units[unitIndex]}`;
}
function normalizeSearchText(value) {
return String(value || '').trim().toLocaleLowerCase('de-DE');
}
function getStatusMeta(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'queued') {
return { label: 'Wartend', severity: 'warning' };
}
if (normalized === 'processing') {
return { label: 'Laeuft', severity: 'info' };
}
if (normalized === 'ready') {
return { label: 'Bereit', severity: 'success' };
}
return { label: 'Fehlgeschlagen', severity: 'danger' };
}
export default function DownloadsPage({ refreshToken = 0 }) {
const [items, setItems] = useState([]);
const [summary, setSummary] = useState(null);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [loading, setLoading] = useState(false);
const [downloadBusyId, setDownloadBusyId] = useState(null);
const [deleteBusyId, setDeleteBusyId] = useState(null);
const toastRef = useRef(null);
const hasActiveItems = useMemo(
() => items.some((item) => ['queued', 'processing'].includes(String(item?.status || '').trim().toLowerCase())),
[items]
);
const visibleItems = useMemo(() => {
const searchText = normalizeSearchText(search);
return items.filter((item) => {
const matchesStatus = !statusFilter || String(item?.status || '').trim().toLowerCase() === statusFilter;
if (!matchesStatus) {
return false;
}
if (!searchText) {
return true;
}
const haystack = [
item?.displayTitle,
item?.archiveName,
item?.label,
item?.sourcePath,
item?.jobId ? `job ${item.jobId}` : ''
]
.map((value) => normalizeSearchText(value))
.join(' ');
return haystack.includes(searchText);
});
}, [items, search, statusFilter]);
const load = async () => {
setLoading(true);
try {
const response = await api.getDownloads();
setItems(Array.isArray(response?.items) ? response.items : []);
setSummary(response?.summary && typeof response.summary === 'object' ? response.summary : null);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Downloads konnten nicht geladen werden',
detail: error.message,
life: 4500
});
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, [refreshToken]);
useEffect(() => {
if (!hasActiveItems) {
return undefined;
}
const timer = setInterval(() => {
void load();
}, 3000);
return () => clearInterval(timer);
}, [hasActiveItems]);
const handleDownload = async (row) => {
const id = String(row?.id || '').trim();
if (!id) {
return;
}
setDownloadBusyId(id);
try {
await api.downloadPreparedArchive(id);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'ZIP-Download fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setDownloadBusyId(null);
}
};
const handleDelete = async (row) => {
const id = String(row?.id || '').trim();
if (!id) {
return;
}
const label = row?.archiveName || `ZIP ${id}`;
const confirmed = await confirmModal({
header: 'ZIP löschen',
message: `"${label}" wirklich loeschen?`,
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
setDeleteBusyId(id);
try {
await api.deleteDownload(id);
toastRef.current?.show({
severity: 'success',
summary: 'ZIP geloescht',
detail: `"${label}" wurde entfernt.`,
life: 3500
});
await load();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Loeschen fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setDeleteBusyId(null);
}
};
const statusBody = (row) => {
const meta = getStatusMeta(row?.status);
return <Tag value={meta.label} severity={meta.severity} />;
};
const titleBody = (row) => (
<div className="downloads-title-cell">
<strong>{row?.displayTitle || '-'}</strong>
<small>
{row?.jobId ? `Job #${row.jobId}` : 'Ohne Job'} | {row?.label || '-'}
</small>
{row?.errorMessage ? <small className="downloads-error-text">{row.errorMessage}</small> : null}
</div>
);
const archiveBody = (row) => (
<div className="downloads-path-cell">
<code>{row?.archiveName || '-'}</code>
<small>{row?.downloadDir || '-'}</small>
</div>
);
const sourceBody = (row) => (
<div className="downloads-path-cell">
<code>{row?.sourcePath || '-'}</code>
<small>{row?.sourceType === 'file' ? 'Datei' : 'Ordner'}</small>
</div>
);
const actionBody = (row) => {
const normalizedStatus = String(row?.status || '').trim().toLowerCase();
const canDownload = normalizedStatus === 'ready';
const canDelete = !['queued', 'processing'].includes(normalizedStatus);
const id = String(row?.id || '').trim();
return (
<div className="downloads-actions">
<Button
label="Download"
icon="pi pi-download"
size="small"
onClick={() => handleDownload(row)}
disabled={!canDownload || Boolean(deleteBusyId)}
loading={downloadBusyId === id}
/>
<Button
label="Loeschen"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
onClick={() => handleDelete(row)}
disabled={!canDelete || Boolean(downloadBusyId)}
loading={deleteBusyId === id}
/>
</div>
);
};
return (
<div className="page-grid">
<Toast ref={toastRef} />
<Card title="Downloadbare Dateien" subTitle="Vorbereitete ZIP-Dateien aus RAW- und Encode-Inhalten">
<div className="table-filters">
<InputText
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Suche nach Titel, ZIP-Datei oder Pfad"
/>
<Dropdown
value={statusFilter}
options={STATUS_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setStatusFilter(event.value || '')}
placeholder="Status"
/>
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
</div>
<div className="downloads-summary-tags">
<Tag value={`${summary?.activeCount || 0} aktiv`} severity={(summary?.activeCount || 0) > 0 ? 'info' : 'secondary'} />
<Tag value={`${summary?.readyCount || 0} bereit`} severity={(summary?.readyCount || 0) > 0 ? 'success' : 'secondary'} />
<Tag value={`${summary?.failedCount || 0} Fehler`} severity={(summary?.failedCount || 0) > 0 ? 'danger' : 'secondary'} />
</div>
<div className="table-scroll-wrap table-scroll-wide">
<DataTable
value={visibleItems}
dataKey="id"
paginator
rows={10}
rowsPerPageOptions={[10, 20, 50]}
loading={loading}
responsiveLayout="scroll"
emptyMessage="Keine ZIP-Dateien vorhanden"
>
<Column header="Status" body={statusBody} style={{ width: '10rem' }} />
<Column header="Inhalt" body={titleBody} style={{ minWidth: '18rem' }} />
<Column header="ZIP-Datei" body={archiveBody} style={{ minWidth: '18rem' }} />
<Column header="Quelle" body={sourceBody} style={{ minWidth: '22rem' }} />
<Column header="Erstellt" body={(row) => formatDateTime(row?.createdAt)} style={{ width: '11rem' }} />
<Column header="Fertig" body={(row) => formatDateTime(row?.finishedAt)} style={{ width: '11rem' }} />
<Column header="Groesse" body={(row) => formatBytes(row?.sizeBytes)} style={{ width: '9rem' }} />
<Column header="Aktion" body={actionBody} style={{ width: '14rem' }} />
</DataTable>
</div>
</Card>
</div>
);
}
+839
View File
@@ -0,0 +1,839 @@
import { useEffect, useMemo, useState } from 'react';
import 'chart.js/auto';
import { Card } from 'primereact/card';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Chart } from 'primereact/chart';
import { Button } from 'primereact/button';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return 0;
}
return Math.max(0, Math.min(100, parsed));
}
function formatPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return '-';
}
return `${Math.round(parsed)}%`;
}
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) {
return '-';
}
if (bytes === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const normalized = bytes / (1024 ** exponent);
return `${normalized.toFixed(normalized >= 100 ? 0 : (normalized >= 10 ? 1 : 2))} ${units[exponent]}`;
}
function formatUpdatedAt(value) {
const raw = String(value || '').trim();
if (!raw) {
return '-';
}
const parsed = Date.parse(raw);
if (!Number.isFinite(parsed)) {
return raw;
}
return new Date(parsed).toLocaleString('de-DE');
}
function normalizeHardwareMonitoringPayload(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
return {
enabled: Boolean(payload.enabled),
intervalMs: Number(payload.intervalMs || 0),
updatedAt: payload.updatedAt || null,
sample: payload.sample && typeof payload.sample === 'object' ? payload.sample : null,
error: payload.error ? String(payload.error) : null
};
}
function buildGaugeDataset(value, color) {
const safe = clampPercent(value);
return {
datasets: [
{
data: [safe, Math.max(0, 100 - safe)],
backgroundColor: [color, 'rgba(255,255,255,0.08)'],
borderWidth: 0,
hoverOffset: 0
}
]
};
}
const gaugeOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: '82%',
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
},
animation: false
};
const HISTORY_WINDOWS = [
{ label: '1h', hours: 1 },
{ label: '6h', hours: 6 },
{ label: '24h', hours: 24 },
{ label: '4d', hours: 96 }
];
const HISTORY_STACK_BREAKPOINT = 1700;
const HISTORY_RESAMPLE_TARGET_POINTS = 720;
const HISTORY_INTERPOLATION_MIN_GAP_MS = 5 * 60 * 1000;
const HISTORY_INTERPOLATION_MAX_GAP_MS = 45 * 60 * 1000;
const HISTORY_INTERPOLATION_GAP_FACTOR = 6;
const CPU_SERIES_COLOR = '#c43d2f';
const GPU_SERIES_COLOR = '#c9961a';
const RAM_SERIES_COLOR = '#2e7d4f';
const historyChartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: (items) => {
const first = Array.isArray(items) ? items[0] : null;
const rawLabel = first?.label ?? first?.raw?.x ?? first?.parsed?.x ?? '';
return formatTickLabel(rawLabel);
}
}
}
},
scales: {
x: {
ticks: {
autoSkip: true,
maxTicksLimit: 8,
color: '#6a4d38',
callback: function (value, index, ticks) {
const resolvedLabel = typeof this?.getLabelForValue === 'function'
? this.getLabelForValue(value)
: (
Array.isArray(ticks) && ticks[index]
? (ticks[index].label ?? ticks[index].value ?? value)
: value
);
return formatTickLabel(resolvedLabel);
}
},
grid: {
color: 'rgba(111,57,34,0.08)'
}
},
y: {
beginAtZero: true,
ticks: {
color: '#6a4d38'
},
grid: {
color: 'rgba(111,57,34,0.08)'
}
}
}
};
function toNumberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function formatTickLabel(timestampIso) {
const parsed = Date.parse(String(timestampIso || ''));
if (!Number.isFinite(parsed)) {
return '-';
}
const date = new Date(parsed);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function formatDayMarkerLabel(timestampIso) {
const parsed = Date.parse(String(timestampIso || ''));
if (!Number.isFinite(parsed)) {
return '';
}
const date = new Date(parsed);
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.`;
}
function toDayKeyFromTimestamp(timestampIso) {
const parsed = Date.parse(String(timestampIso || ''));
if (!Number.isFinite(parsed)) {
return null;
}
const date = new Date(parsed);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function parseTimestampMs(value) {
const parsed = Date.parse(String(value || ''));
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeHistoryPoints(points = []) {
return (Array.isArray(points) ? points : [])
.map((point) => {
const timestampMs = parseTimestampMs(point?.capturedAt);
if (!Number.isFinite(timestampMs)) {
return null;
}
return {
capturedAt: new Date(timestampMs).toISOString(),
timestampMs,
cpuUsagePercent: toNumberOrNull(point?.cpuUsagePercent),
ramUsagePercent: toNumberOrNull(point?.ramUsagePercent),
gpuUsagePercent: toNumberOrNull(point?.gpuUsagePercent),
cpuTemperatureC: toNumberOrNull(point?.cpuTemperatureC),
gpuTemperatureC: toNumberOrNull(point?.gpuTemperatureC)
};
})
.filter(Boolean)
.sort((left, right) => left.timestampMs - right.timestampMs);
}
function interpolateMetric(prevPoint, nextPoint, metricKey, targetMs, maxGapMs) {
if (!prevPoint || !nextPoint) {
return null;
}
if (prevPoint.timestampMs === nextPoint.timestampMs) {
return toNumberOrNull(prevPoint[metricKey]);
}
const gapMs = nextPoint.timestampMs - prevPoint.timestampMs;
if (!Number.isFinite(gapMs) || gapMs <= 0 || gapMs > maxGapMs) {
return null;
}
const prevValue = toNumberOrNull(prevPoint[metricKey]);
const nextValue = toNumberOrNull(nextPoint[metricKey]);
if (!Number.isFinite(prevValue) || !Number.isFinite(nextValue)) {
return null;
}
const ratio = (targetMs - prevPoint.timestampMs) / gapMs;
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
return null;
}
return Number((prevValue + ((nextValue - prevValue) * ratio)).toFixed(2));
}
function buildResampledHistoryPoints(points = [], historyHours = 24) {
const normalized = normalizeHistoryPoints(points);
if (normalized.length === 0) {
return [];
}
const historyWindowMs = Math.max(1, Number(historyHours || 24)) * 60 * 60 * 1000;
const stepMsRaw = Math.round(historyWindowMs / HISTORY_RESAMPLE_TARGET_POINTS);
const stepMs = Math.max(60 * 1000, stepMsRaw);
const maxInterpolationGapMs = Math.max(
HISTORY_INTERPOLATION_MIN_GAP_MS,
Math.min(HISTORY_INTERPOLATION_MAX_GAP_MS, stepMs * HISTORY_INTERPOLATION_GAP_FACTOR)
);
const latestSourceMs = normalized[normalized.length - 1].timestampMs;
const windowEndMs = Math.max(Date.now(), latestSourceMs);
const windowStartMs = windowEndMs - historyWindowMs;
const gridStartMs = Math.floor(windowStartMs / stepMs) * stepMs;
const gridEndMs = Math.ceil(windowEndMs / stepMs) * stepMs;
const source = normalized.filter((point) => point.timestampMs >= (gridStartMs - maxInterpolationGapMs));
const keys = [
'cpuUsagePercent',
'ramUsagePercent',
'gpuUsagePercent',
'cpuTemperatureC',
'gpuTemperatureC'
];
let sourceIndex = 0;
const output = [];
for (let ts = gridStartMs; ts <= gridEndMs; ts += stepMs) {
while (sourceIndex < source.length && source[sourceIndex].timestampMs < ts) {
sourceIndex += 1;
}
const nextPoint = sourceIndex < source.length ? source[sourceIndex] : null;
const prevPoint = sourceIndex > 0 ? source[sourceIndex - 1] : null;
const row = {
capturedAt: new Date(ts).toISOString(),
timestampMs: ts
};
for (const key of keys) {
const nextValue = nextPoint && nextPoint.timestampMs === ts
? toNumberOrNull(nextPoint[key])
: null;
const prevValue = prevPoint && prevPoint.timestampMs === ts
? toNumberOrNull(prevPoint[key])
: null;
row[key] = Number.isFinite(nextValue)
? nextValue
: (Number.isFinite(prevValue)
? prevValue
: interpolateMetric(prevPoint, nextPoint, key, ts, maxInterpolationGapMs));
}
output.push(row);
}
return output;
}
const historyDayBoundaryPlugin = {
id: 'historyDayBoundary',
afterDatasetsDraw(chart, _args, options) {
if (options === false) {
return;
}
const xScale = chart?.scales?.x;
const area = chart?.chartArea;
const labels = Array.isArray(chart?.data?.labels) ? chart.data.labels : [];
if (!xScale || !area || labels.length < 2) {
return;
}
const ctx = chart.ctx;
ctx.save();
ctx.strokeStyle = 'rgba(111,57,34,0.30)';
ctx.fillStyle = 'rgba(111,57,34,0.75)';
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1;
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
for (let index = 1; index < labels.length; index += 1) {
const prevDayKey = toDayKeyFromTimestamp(labels[index - 1]);
const nextDayKey = toDayKeyFromTimestamp(labels[index]);
if (!prevDayKey || !nextDayKey || prevDayKey === nextDayKey) {
continue;
}
const xPrev = xScale.getPixelForValue(index - 1);
const xNext = xScale.getPixelForValue(index);
const x = (xPrev + xNext) / 2;
ctx.beginPath();
ctx.moveTo(x, area.top);
ctx.lineTo(x, area.bottom);
ctx.stroke();
const markerLabel = formatDayMarkerLabel(labels[index]);
if (markerLabel) {
ctx.fillText(markerLabel, x + 4, area.top + 4);
}
}
ctx.restore();
}
};
function buildSeriesToggleButtonStyle(color, active) {
if (active) {
return {
background: color,
borderColor: color,
color: '#fffaf1'
};
}
return {
background: 'transparent',
borderColor: color,
color
};
}
function getHistoryViewportBucket() {
if (typeof window === 'undefined') {
return 'wide';
}
return window.innerWidth < HISTORY_STACK_BREAKPOINT ? 'narrow' : 'wide';
}
function ChartLegendRow({ items = [] }) {
return (
<div className="hardware-history-legend-row" aria-hidden="true">
{items.map((item) => (
<span key={item.label} className="hardware-history-legend-item">
<span className="hardware-history-legend-dot" style={{ background: item.color }} />
<span>{item.label}</span>
</span>
))}
</div>
);
}
function GaugeBlock({ title, subtitle, icon, valuePercent, gaugeColor, bars = [], footer = null }) {
return (
<Card className="hardware-detail-card hardware-detail-card-top">
<div className="hardware-detail-card-head">
<div className="hardware-detail-card-icon"><i className={`pi ${icon}`} /></div>
<div>
<h3>{title}</h3>
{subtitle ? <small>{subtitle}</small> : null}
</div>
</div>
<div className="hardware-detail-gauge-layout">
<div className="hardware-detail-gauge-wrap">
<Chart type="doughnut" data={buildGaugeDataset(valuePercent, gaugeColor)} options={gaugeOptions} className="hardware-detail-gauge-chart" />
<div className="hardware-detail-gauge-center">{formatPercent(valuePercent)}</div>
</div>
<div className="hardware-detail-bar-list">
{bars.map((bar) => (
<div key={bar.label} className="hardware-detail-bar-item">
<div className="hardware-detail-bar-head">
<span>{bar.label}</span>
<span>{bar.valueLabel}</span>
</div>
<ProgressBar value={clampPercent(bar.valuePercent)} showValue={false} />
</div>
))}
</div>
</div>
{footer}
</Card>
);
}
export default function HardwarePage({ hardwareMonitoring }) {
const navigate = useNavigate();
const [historyHours, setHistoryHours] = useState(24);
const [historyViewportBucket, setHistoryViewportBucket] = useState(() => getHistoryViewportBucket());
const [usageSeriesVisible, setUsageSeriesVisible] = useState({
cpu: true,
ram: true,
gpu: true
});
const [tempSeriesVisible, setTempSeriesVisible] = useState({
cpu: true,
gpu: true
});
const [historyState, setHistoryState] = useState({
loading: false,
points: [],
totalPoints: 0,
error: null
});
const monitoringState = useMemo(
() => normalizeHardwareMonitoringPayload(hardwareMonitoring),
[hardwareMonitoring]
);
const sample = monitoringState.sample;
const cpu = sample?.cpu && typeof sample.cpu === 'object' ? sample.cpu : {};
const memory = sample?.memory && typeof sample.memory === 'object' ? sample.memory : {};
const gpu = sample?.gpu && typeof sample.gpu === 'object' ? sample.gpu : {};
const cpuPerCore = Array.isArray(cpu?.perCore) ? cpu.perCore : [];
const gpuDevices = Array.isArray(gpu?.devices) ? gpu.devices : [];
const primaryGpu = gpuDevices[0] || null;
const cpuTitle = String(cpu?.name || cpu?.model || cpu?.modelName || cpu?.brand || '').trim() || 'CPU';
const ramTitle = String(memory?.name || memory?.vendor || memory?.model || '').trim() || 'Arbeitsspeicher';
const gpuTitle = String(primaryGpu?.name || gpu?.name || gpu?.vendor || '').trim() || 'GPU';
const historyPoints = Array.isArray(historyState.points) ? historyState.points : [];
const resampledHistoryPoints = useMemo(
() => buildResampledHistoryPoints(historyPoints, historyHours),
[historyPoints, historyHours]
);
useEffect(() => {
if (!monitoringState.enabled) {
setHistoryState({
loading: false,
points: [],
totalPoints: 0,
error: null
});
return undefined;
}
let cancelled = false;
const loadHistory = async (forceRefresh = false) => {
setHistoryState((prev) => ({
...prev,
loading: prev.points.length === 0
}));
try {
const response = await api.getHardwareHistory({
hours: historyHours,
maxPoints: 900,
forceRefresh
});
if (cancelled) {
return;
}
const payload = response?.history && typeof response.history === 'object' ? response.history : {};
setHistoryState({
loading: false,
points: Array.isArray(payload.points) ? payload.points : [],
totalPoints: Number(payload.totalPoints || 0),
error: null
});
} catch (error) {
if (cancelled) {
return;
}
setHistoryState((prev) => ({
...prev,
loading: false,
error: error?.message || 'Historische Hardware-Daten konnten nicht geladen werden.'
}));
}
};
loadHistory(true);
const refreshMs = Math.max(5000, Number(monitoringState.intervalMs || 5000) * 2);
const timer = setInterval(() => {
loadHistory(true);
}, refreshMs);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [historyHours, monitoringState.enabled, monitoringState.intervalMs]);
useEffect(() => {
let frame = null;
const handleResize = () => {
if (frame) {
cancelAnimationFrame(frame);
}
frame = requestAnimationFrame(() => {
frame = null;
setHistoryViewportBucket((prev) => {
const next = getHistoryViewportBucket();
return prev === next ? prev : next;
});
});
};
window.addEventListener('resize', handleResize);
return () => {
if (frame) {
cancelAnimationFrame(frame);
}
window.removeEventListener('resize', handleResize);
};
}, []);
const historyUsageChartData = useMemo(() => ({
labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
datasets: [
{
label: 'CPU',
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)),
borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.cpu,
pointRadius: 0,
tension: 0.22
},
{
label: 'RAM',
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)),
borderColor: RAM_SERIES_COLOR,
backgroundColor: RAM_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.ram,
pointRadius: 0,
tension: 0.22
},
{
label: 'GPU',
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)),
borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.gpu,
pointRadius: 0,
tension: 0.22
}
]
}), [resampledHistoryPoints, usageSeriesVisible]);
const historyTempChartData = useMemo(() => ({
labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
datasets: [
{
label: 'CPU',
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)),
borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2,
hidden: !tempSeriesVisible.cpu,
pointRadius: 0,
tension: 0.22
},
{
label: 'GPU',
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)),
borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2,
hidden: !tempSeriesVisible.gpu,
pointRadius: 0,
tension: 0.22
}
]
}), [resampledHistoryPoints, tempSeriesVisible]);
return (
<div className="hardware-detail-page">
<Card className="hardware-detail-hero">
<div className="hardware-detail-hero-head">
<div>
<h2>Hardware Monitoring</h2>
<small>Ausführliche Live-Ansicht für CPU, RAM und GPU</small>
</div>
<div className="hardware-detail-hero-tags">
<Tag value={monitoringState.enabled ? 'Aktiv' : 'Inaktiv'} severity={monitoringState.enabled ? 'success' : 'warning'} />
<Tag value={`Intervall: ${monitoringState.intervalMs > 0 ? `${monitoringState.intervalMs} ms` : '-'}`} severity="secondary" />
<Tag value={`Letztes Update: ${formatUpdatedAt(monitoringState.updatedAt)}`} severity="info" />
</div>
</div>
</Card>
{!monitoringState.enabled ? (
<Card className="hardware-detail-empty">
<h3>Monitoring ist deaktiviert</h3>
<p>
Aktiviere <code>hardware_monitoring_enabled</code> in den Einstellungen,
um die Live-Ansicht auf dieser Seite zu nutzen.
</p>
<Button
label="Zu den Einstellungen"
icon="pi pi-cog"
onClick={() => navigate('/settings')}
/>
</Card>
) : null}
{monitoringState.enabled && !sample ? (
<Card className="hardware-detail-empty">
<h3>Warte auf erste Messung ...</h3>
<p>Die Hardware-Metriken werden gerade aufgebaut.</p>
</Card>
) : null}
{monitoringState.enabled ? (
<Card className="hardware-detail-card hardware-history-card">
<div className="hardware-detail-card-head hardware-history-card-head">
<div>
<h3>Historie</h3>
<small>
Verlauf aus Backend-Log ({historyState.totalPoints} Messpunkte, Ansicht: letzte {historyHours}h)
</small>
</div>
<div className="hardware-history-window-buttons">
{HISTORY_WINDOWS.map((window) => (
<Button
key={`history-window-${window.hours}`}
label={window.label}
size="small"
severity={historyHours === window.hours ? 'primary' : 'secondary'}
outlined={historyHours !== window.hours}
onClick={() => setHistoryHours(window.hours)}
/>
))}
</div>
</div>
{historyState.error ? <small className="error-text">{historyState.error}</small> : null}
{historyState.loading && historyPoints.length === 0 ? (
<p>Lade Verlauf ...</p>
) : resampledHistoryPoints.length === 0 ? (
<p>Noch keine Verlaufsdaten vorhanden.</p>
) : (
<div className="hardware-history-grid">
<div className="hardware-history-chart-wrap">
<h4>Auslastung (%)</h4>
<div className="hardware-history-series-toggles">
<Button
type="button"
size="small"
label="CPU"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, usageSeriesVisible.cpu)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
/>
<Button
type="button"
size="small"
label="RAM"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(RAM_SERIES_COLOR, usageSeriesVisible.ram)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, ram: !prev.ram }))}
/>
<Button
type="button"
size="small"
label="GPU"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, usageSeriesVisible.gpu)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
/>
</div>
<div className="hardware-history-chart">
<Chart
key={`usage-${historyViewportBucket}`}
className="hardware-history-chart-canvas"
type="line"
data={historyUsageChartData}
options={historyChartOptions}
plugins={[historyDayBoundaryPlugin]}
/>
</div>
<ChartLegendRow
items={[
{ label: 'CPU', color: CPU_SERIES_COLOR },
{ label: 'RAM', color: RAM_SERIES_COLOR },
{ label: 'GPU', color: GPU_SERIES_COLOR }
]}
/>
</div>
<div className="hardware-history-chart-wrap">
<h4>Temperatur (°C)</h4>
<div className="hardware-history-series-toggles">
<Button
type="button"
size="small"
label="CPU °C"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, tempSeriesVisible.cpu)}
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
/>
<Button
type="button"
size="small"
label="GPU °C"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, tempSeriesVisible.gpu)}
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
/>
</div>
<div className="hardware-history-chart">
<Chart
key={`temp-${historyViewportBucket}`}
className="hardware-history-chart-canvas"
type="line"
data={historyTempChartData}
options={historyChartOptions}
plugins={[historyDayBoundaryPlugin]}
/>
</div>
<ChartLegendRow
items={[
{ label: 'CPU', color: CPU_SERIES_COLOR },
{ label: 'GPU', color: GPU_SERIES_COLOR }
]}
/>
</div>
</div>
)}
</Card>
) : null}
{monitoringState.enabled && sample ? (
<div className="hardware-detail-grid">
<GaugeBlock
title="CPU"
subtitle={cpuTitle}
icon="pi-microchip"
valuePercent={cpu?.overallUsagePercent}
gaugeColor="#b07a24"
bars={[
{ label: 'Gesamtauslastung', valuePercent: cpu?.overallUsagePercent, valueLabel: formatPercent(cpu?.overallUsagePercent) },
{ label: 'Load Avg 1m', valuePercent: clampPercent(Number(cpu?.loadAverage?.[0]) * 100 / 8), valueLabel: Array.isArray(cpu?.loadAverage) ? String(cpu.loadAverage[0] ?? '-') : '-' }
]}
footer={(
<div className="hardware-detail-core-grid">
{cpuPerCore.length === 0 ? <small>Keine Core-Metriken verfügbar.</small> : cpuPerCore.map((core, index) => (
<div key={`cpu-core-${core?.index ?? index}`} className="hardware-detail-core-item">
<div className="hardware-detail-bar-head">
<span>Core {core?.index ?? index}</span>
<span>{formatPercent(core?.usagePercent)}</span>
</div>
<ProgressBar value={clampPercent(core?.usagePercent)} showValue={false} />
</div>
))}
</div>
)}
/>
<GaugeBlock
title="RAM"
subtitle={ramTitle}
icon="pi-server"
valuePercent={memory?.usagePercent}
gaugeColor="#9f6b1d"
bars={[
{ label: 'Verwendet', valuePercent: memory?.usagePercent, valueLabel: formatPercent(memory?.usagePercent) },
{ label: 'Belegt', valuePercent: memory?.usagePercent, valueLabel: formatBytes(memory?.usedBytes) },
{ label: 'Frei', valuePercent: Math.max(0, 100 - clampPercent(memory?.usagePercent)), valueLabel: formatBytes(memory?.freeBytes) }
]}
footer={(
<div className="hardware-detail-meter-wrap">
<div className="hardware-detail-meter-head">
<span>RAM Nutzung</span>
<span>{formatBytes(memory?.usedBytes)} / {formatBytes(memory?.totalBytes)}</span>
</div>
<ProgressBar value={clampPercent(memory?.usagePercent)} showValue={false} />
</div>
)}
/>
<GaugeBlock
title="GPU"
subtitle={gpuTitle}
icon="pi-desktop"
valuePercent={primaryGpu?.utilizationPercent}
gaugeColor="#996521"
bars={[
{ label: '3D / Compute', valuePercent: primaryGpu?.utilizationPercent, valueLabel: formatPercent(primaryGpu?.utilizationPercent) },
{ label: 'Speicher', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: formatPercent(primaryGpu?.memoryUtilizationPercent) },
{ label: 'VRAM', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: `${formatBytes(primaryGpu?.memoryUsedBytes)} / ${formatBytes(primaryGpu?.memoryTotalBytes)}` }
]}
footer={(
<div className="hardware-detail-meter-wrap">
<div className="hardware-detail-meter-head">
<span>GPU Nutzung</span>
<span>
GPU {primaryGpu?.index ?? 0}
{primaryGpu?.name ? ` | ${primaryGpu.name}` : ''}
</span>
</div>
<ProgressBar value={clampPercent(primaryGpu?.utilizationPercent)} showValue={false} />
</div>
)}
/>
</div>
) : null}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { ProgressSpinner } from 'primereact/progressspinner';
import { api } from '../api/client';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import { isSeriesVideoJob } from '../utils/jobTaxonomy';
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function resolveMediaType(row) {
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
const candidates = [
row?.mediaType,
row?.media_type,
row?.mediaProfile,
row?.media_profile,
encodePlan?.mediaProfile,
row?.makemkvInfo?.analyzeContext?.mediaProfile,
row?.makemkvInfo?.mediaProfile,
row?.mediainfoInfo?.mediaProfile
];
for (const candidate of candidates) {
const raw = String(candidate || '').trim().toLowerCase();
if (!raw) {
continue;
}
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
return 'bluray';
}
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
return 'dvd';
}
if (raw === 'converter') {
return 'converter';
}
}
return 'other';
}
function buildMetadataContextFromJob(row) {
const jobId = normalizeJobId(row?.id);
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
? makemkvInfo.analyzeContext
: {};
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {});
const workflowKindRaw = String(
selectedMetadata?.workflowKind
|| analyzeContext?.workflowKind
|| ''
).trim().toLowerCase();
const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw)
? 'film'
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
const rowMediaType = resolveMediaType(row);
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
const metadataProvider = 'tmdb';
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
return {
jobId,
status: row?.status || null,
lastState: row?.last_state || null,
submitMode: 'assign',
mediaProfile: rowMediaType,
detectedTitle: row?.detected_title || row?.title || '',
selectedMetadata: {
...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}),
title: row?.title || selectedMetadata?.title || row?.detected_title || '',
year: row?.year || selectedMetadata?.year || null,
imdbId: row?.imdb_id || selectedMetadata?.imdbId || null,
poster: row?.poster_url || selectedMetadata?.poster || null,
metadataProvider,
workflowKind,
tmdbId: selectedMetadata?.tmdbId || null,
providerId: selectedMetadata?.providerId || null,
metadataKind: selectedMetadata?.metadataKind || (isSeriesDvd ? 'season' : null),
seasonNumber,
seasonName: selectedMetadata?.seasonName || null,
episodeCount: selectedMetadata?.episodeCount || 0,
episodes: Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : [],
...(discNumber ? { discNumber } : {})
},
metadataProvider,
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
seriesDecision: analyzeContext?.seriesDecision || null
};
}
export default function TmdbMigrationPage() {
const toastRef = useRef(null);
const countdownIntervalRef = useRef(null);
const nextJobTimeoutRef = useRef(null);
const jobsRef = useRef([]);
const runningRef = useRef(false);
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
const [cooldownRemaining, setCooldownRemaining] = useState(0);
useEffect(() => {
jobsRef.current = Array.isArray(jobs) ? jobs : [];
}, [jobs]);
useEffect(() => {
runningRef.current = running;
}, [running]);
const clearTimers = useCallback(() => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
if (nextJobTimeoutRef.current) {
clearTimeout(nextJobTimeoutRef.current);
nextJobTimeoutRef.current = null;
}
}, []);
useEffect(() => () => {
clearTimers();
}, [clearTimers]);
const loadPendingJobs = useCallback(async () => {
setLoading(true);
try {
const response = await api.getTmdbMigrationPendingJobs({ limit: 1000 });
const rows = Array.isArray(response?.jobs) ? response.jobs : [];
setJobs(rows);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Laden fehlgeschlagen',
detail: error?.message || 'TMDb-Migrationsliste konnte nicht geladen werden.'
});
setJobs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadPendingJobs();
}, [loadPendingJobs]);
const openJobInDialog = useCallback((job) => {
if (!job) {
return;
}
const context = buildMetadataContextFromJob(job);
if (!context?.jobId) {
return;
}
setMetadataDialogContext(context);
setMetadataDialogVisible(true);
}, []);
const scheduleNextJob = useCallback((nextJob, seconds = 10) => {
clearTimers();
const normalizedSeconds = Math.max(0, Math.trunc(Number(seconds || 0)));
if (!nextJob) {
setRunning(false);
setCooldownRemaining(0);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
return;
}
setCooldownRemaining(normalizedSeconds);
if (normalizedSeconds > 0) {
countdownIntervalRef.current = setInterval(() => {
setCooldownRemaining((prev) => {
const next = Number(prev || 0) - 1;
return next > 0 ? next : 0;
});
}, 1000);
}
nextJobTimeoutRef.current = setTimeout(() => {
clearTimers();
setCooldownRemaining(0);
if (!runningRef.current) {
return;
}
openJobInDialog(nextJob);
}, normalizedSeconds * 1000);
}, [clearTimers, openJobInDialog]);
const startSequence = () => {
const pending = jobsRef.current;
if (!Array.isArray(pending) || pending.length === 0) {
return;
}
clearTimers();
setRunning(true);
setMetadataDialogVisible(true);
setCooldownRemaining(0);
openJobInDialog(pending[0]);
};
const stopSequence = useCallback(() => {
clearTimers();
setRunning(false);
setCooldownRemaining(0);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
}, [clearTimers]);
const handleMetadataSearch = async (query, options = {}) => {
const workflow = String(
options?.workflowKind
|| metadataDialogContext?.selectedMetadata?.workflowKind
|| ''
).trim().toLowerCase();
try {
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
?? null;
const response = workflow === 'series'
? await api.searchTmdbSeries(query, tmdbSeasonHint)
: await api.searchTmdbMovie(query);
return response?.results || [];
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'TMDb-Suche fehlgeschlagen',
detail: error?.message || 'Suche fehlgeschlagen.'
});
return [];
}
};
const handleMetadataSubmit = async (payload) => {
const normalizedJobId = normalizeJobId(payload?.jobId);
if (!normalizedJobId) {
return;
}
setMetadataAssignBusy(true);
clearTimers();
setCooldownRemaining(0);
try {
await api.assignJobMetadata(normalizedJobId, payload || {});
const remaining = jobsRef.current.filter((row) => normalizeJobId(row?.id) !== normalizedJobId);
jobsRef.current = remaining;
setJobs(remaining);
toastRef.current?.show({
severity: 'success',
summary: 'Job migriert',
detail: `Job #${normalizedJobId} wurde auf TMDb aktualisiert.`,
life: 2600
});
if (remaining.length === 0) {
setRunning(false);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
toastRef.current?.show({
severity: 'success',
summary: 'Migration abgeschlossen',
detail: 'Alle ausstehenden Jobs wurden abgearbeitet.',
life: 3200
});
return;
}
if (!runningRef.current) {
return;
}
scheduleNextJob(remaining[0], 10);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Migration fehlgeschlagen',
detail: error?.message || 'Metadaten konnten nicht übernommen werden.'
});
throw error;
} finally {
setMetadataAssignBusy(false);
}
};
const mediumTagBody = (row) => {
const mediaType = resolveMediaType(row);
const label = mediaType === 'bluray' ? 'Blu-ray' : mediaType === 'dvd' ? 'DVD' : mediaType === 'converter' ? 'Converter' : 'Other';
return <Tag value={label} severity={mediaType === 'converter' ? 'info' : 'warning'} />;
};
const titleBody = (row) => String(row?.title || row?.detected_title || `Job #${row?.id || '-'}`).trim() || '-';
const countdownProgressValue = useMemo(() => {
if (cooldownRemaining <= 0) {
return 0;
}
const elapsed = 10 - Math.min(10, cooldownRemaining);
return Math.max(0, Math.min(100, Math.round((elapsed / 10) * 100)));
}, [cooldownRemaining]);
return (
<div className="page-grid tmdb-migration-page">
<Toast ref={toastRef} position="top-right" />
<Card title="TMDb Migration (Direktlink)" subTitle="Diese Seite ist nicht in der Navigation verlinkt.">
<div className="tmdb-migration-toolbar">
<div className="tmdb-migration-toolbar-left">
<Tag value={`${jobs.length} offen`} severity={jobs.length > 0 ? 'warning' : 'success'} />
{running ? <Tag value="Sequenz aktiv" severity="info" /> : null}
</div>
<div className="tmdb-migration-toolbar-actions">
<Button
label="Liste neu laden"
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
outlined
onClick={() => {
if (!metadataAssignBusy) {
void loadPendingJobs();
}
}}
disabled={metadataAssignBusy}
/>
{!running ? (
<Button
label="Start"
icon="pi pi-play"
onClick={startSequence}
disabled={jobs.length === 0 || loading || metadataAssignBusy}
/>
) : (
<Button
label="Stop"
icon="pi pi-stop"
severity="secondary"
onClick={stopSequence}
disabled={metadataAssignBusy}
/>
)}
</div>
</div>
{running && cooldownRemaining > 0 ? (
<div className="tmdb-migration-countdown">
<ProgressSpinner style={{ width: '2rem', height: '2rem' }} strokeWidth="5" />
<div className="tmdb-migration-countdown-copy">
<strong>Nächste Suche startet in {cooldownRemaining}s</strong>
<small>API-Wartezeit aktiv (mind. 10 Sekunden zwischen Jobs).</small>
</div>
<div className="tmdb-migration-countdown-progress" aria-label="Cooldown Fortschritt">
<div className="tmdb-migration-countdown-bar" style={{ width: `${countdownProgressValue}%` }} />
</div>
</div>
) : null}
<DataTable
value={jobs}
loading={loading}
dataKey="id"
emptyMessage="Keine offenen Jobs für TMDb-Migration."
className="tmdb-migration-table"
rows={50}
paginator={jobs.length > 50}
>
<Column field="id" header="ID" style={{ width: '6rem' }} body={(row) => `#${row?.id || '-'}`} />
<Column header="Titel" body={titleBody} />
<Column field="year" header="Jahr" style={{ width: '8rem' }} body={(row) => row?.year || '-'} />
<Column header="Medium" body={mediumTagBody} style={{ width: '10rem' }} />
<Column
header="Status"
style={{ width: '14rem' }}
body={(row) => String(row?.status || row?.last_state || '-').trim() || '-'}
/>
</DataTable>
</Card>
<MetadataSelectionDialog
visible={metadataDialogVisible}
context={metadataDialogContext}
onHide={stopSequence}
onSubmit={handleMetadataSubmit}
onSearch={handleMetadataSearch}
busy={metadataAssignBusy || (running && cooldownRemaining > 0)}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
import { confirmDialog } from 'primereact/confirmdialog';
export function confirmModal(options = {}) {
const message = String(options?.message || '').trim();
if (!message) {
return Promise.resolve(false);
}
const header = String(options?.header || 'Bitte bestätigen').trim() || 'Bitte bestätigen';
const acceptLabel = String(options?.acceptLabel || 'Ja').trim() || 'Ja';
const rejectLabel = String(options?.rejectLabel || 'Abbrechen').trim() || 'Abbrechen';
const icon = String(options?.icon || 'pi pi-exclamation-triangle').trim() || 'pi pi-exclamation-triangle';
const danger = Boolean(options?.danger);
const style = options?.style && typeof options.style === 'object'
? options.style
: null;
const breakpoints = options?.breakpoints && typeof options.breakpoints === 'object'
? options.breakpoints
: null;
return new Promise((resolve) => {
let finished = false;
const settle = (value) => {
if (finished) {
return;
}
finished = true;
resolve(Boolean(value));
};
confirmDialog({
header,
message,
icon,
closable: true,
closeOnEscape: true,
draggable: false,
dismissableMask: true,
acceptLabel,
rejectLabel,
...(style ? { style } : {}),
...(breakpoints ? { breakpoints } : {}),
...(danger ? { acceptClassName: 'p-button-danger' } : {}),
accept: () => settle(true),
reject: () => settle(false),
onHide: () => settle(false)
});
});
}
+289
View File
@@ -0,0 +1,289 @@
function safeParseJson(value, fallback = null) {
if (!value) {
return fallback;
}
if (typeof value === 'object') {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return fallback;
}
}
function getEncodePlan(job) {
if (!job || typeof job !== 'object') {
return null;
}
return safeParseJson(job.encodePlan || job.encode_plan_json, null);
}
function normalizeConverterMediaType(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'audio' || raw === 'video' || raw === 'iso') {
return raw;
}
return null;
}
function normalizeMediaType(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
return 'bluray';
}
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
return 'dvd';
}
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
return 'cd';
}
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
if (raw === 'converter') {
return 'converter';
}
return null;
}
function normalizeJobKind(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (
[
'audiobook',
'cd',
'dvd',
'bluray',
'dvd_series_container',
'dvd_series_child',
'multipart_movie_container',
'multipart_movie_child',
'multipart_movie_merge',
'converter_audio',
'converter_video',
'converter_iso'
].includes(raw)
) {
return raw;
}
if (raw === 'converter') {
return 'converter_video';
}
if (raw === 'converter-audio' || raw === 'converter audio') {
return 'converter_audio';
}
if (raw === 'converter-video' || raw === 'converter video') {
return 'converter_video';
}
if (raw === 'converter-iso' || raw === 'converter iso') {
return 'converter_iso';
}
return null;
}
function converterJobKindFromMediaType(converterMediaType) {
const normalized = normalizeConverterMediaType(converterMediaType);
if (normalized === 'audio') {
return 'converter_audio';
}
if (normalized === 'iso') {
return 'converter_iso';
}
return 'converter_video';
}
function mediaTypeFromJobKind(jobKind) {
const normalized = normalizeJobKind(jobKind);
if (!normalized) {
return null;
}
if (normalized.startsWith('converter_')) {
return 'converter';
}
if (
normalized === 'dvd_series_container'
|| normalized === 'dvd_series_child'
|| normalized === 'multipart_movie_container'
|| normalized === 'multipart_movie_child'
|| normalized === 'multipart_movie_merge'
) {
return null;
}
return normalized;
}
export function resolveJobKind(job) {
const encodePlan = getEncodePlan(job);
const converterMediaType = normalizeConverterMediaType(
encodePlan?.converterMediaType || job?.converterMediaType
);
const directCandidates = [
job?.job_kind,
job?.jobKind,
encodePlan?.jobKind,
job?.makemkvInfo?.jobKind,
job?.makemkvInfo?.analyzeContext?.jobKind,
job?.mediainfoInfo?.jobKind,
job?.handbrakeInfo?.jobKind
];
for (const candidate of directCandidates) {
const normalized = normalizeJobKind(candidate);
if (!normalized) {
continue;
}
if (normalized.startsWith('converter_')) {
return converterMediaType ? converterJobKindFromMediaType(converterMediaType) : normalized;
}
return normalized;
}
const mediaCandidates = [
job?.mediaType,
job?.media_type,
job?.mediaProfile,
job?.media_profile,
encodePlan?.mediaProfile,
job?.makemkvInfo?.analyzeContext?.mediaProfile,
job?.makemkvInfo?.mediaProfile,
job?.mediainfoInfo?.mediaProfile
];
for (const candidate of mediaCandidates) {
const normalized = normalizeMediaType(candidate);
if (!normalized) {
continue;
}
if (normalized === 'converter') {
return converterJobKindFromMediaType(converterMediaType);
}
return normalized;
}
if (converterMediaType) {
return converterJobKindFromMediaType(converterMediaType);
}
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
return 'cd';
}
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
return 'cd';
}
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
return 'cd';
}
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
return 'cd';
}
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(job?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
return 'audiobook';
}
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
return 'audiobook';
}
return null;
}
export function resolveMediaType(job) {
const jobKind = resolveJobKind(job);
const mediaTypeFromKind = mediaTypeFromJobKind(jobKind);
if (mediaTypeFromKind) {
return mediaTypeFromKind;
}
const encodePlan = getEncodePlan(job);
const directCandidates = [
job?.mediaType,
job?.media_type,
job?.mediaProfile,
job?.media_profile,
encodePlan?.mediaProfile,
job?.makemkvInfo?.analyzeContext?.mediaProfile,
job?.makemkvInfo?.mediaProfile,
job?.mediainfoInfo?.mediaProfile
];
for (const candidate of directCandidates) {
const normalized = normalizeMediaType(candidate);
if (normalized) {
return normalized;
}
}
const converterMediaType = normalizeConverterMediaType(encodePlan?.converterMediaType || job?.converterMediaType);
if (converterMediaType) {
return 'converter';
}
return 'other';
}
export function isSeriesVideoJob(job) {
const mediaType = resolveMediaType(job);
if (mediaType !== 'dvd' && mediaType !== 'bluray') {
return false;
}
const analyzeContext = job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
? job.makemkvInfo.analyzeContext
: {};
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: (job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
? job.makemkvInfo.selectedMetadata
: {});
const workflowKind = String(
selectedMetadata?.workflowKind
|| analyzeContext?.workflowKind
|| ''
).trim().toLowerCase();
if (['film', 'movie', 'feature'].includes(workflowKind)) {
return false;
}
if (['series', 'tv', 'season', 'episode'].includes(workflowKind)) {
return true;
}
const metadataKind = String(
selectedMetadata?.metadataKind
|| analyzeContext?.metadataKind
|| ''
).trim().toLowerCase();
if (['film', 'movie', 'feature'].includes(metadataKind)) {
return false;
}
const seasonNumberRaw = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const seasonNumberText = String(seasonNumberRaw ?? '').trim();
const parsedSeasonNumber = Number(seasonNumberText.replace(',', '.'));
const hasSeasonNumber = seasonNumberText
? (Number.isFinite(parsedSeasonNumber) ? parsedSeasonNumber > 0 : true)
: false;
const seasonName = String(selectedMetadata?.seasonName || '').trim();
const episodeCount = Number(selectedMetadata?.episodeCount ?? 0);
const hasEpisodeList = Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0;
const isSeriesKind = ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind);
if (isSeriesKind || hasSeasonNumber || Boolean(seasonName) || hasEpisodeList || (Number.isFinite(episodeCount) && episodeCount > 0)) {
return true;
}
return false;
}
export function isSeriesDvdJob(job) {
return isSeriesVideoJob(job);
}
export function isConverterJob(job) {
return resolveMediaType(job) === 'converter';
}
+162
View File
@@ -0,0 +1,162 @@
const STATUS_LABELS = {
IDLE: 'Bereit',
DISC_DETECTED: 'Medium erkannt',
ANALYZING: 'Analyse',
METADATA_LOOKUP: 'TMDb-Suche',
METADATA_SELECTION: 'Metadatenauswahl',
WAITING_FOR_USER_DECISION: 'Warte auf Auswahl',
READY_TO_START: 'Startbereit',
MEDIAINFO_CHECK: 'Mediainfo-Prüfung',
READY_TO_ENCODE: 'Bereit zum Encodieren',
RIPPING: 'Rippen',
ENCODING: 'Encodieren',
POST_ENCODE_SCRIPTS: 'Nachbearbeitung',
FINISHED: 'Fertig',
CANCELLED: 'Abgebrochen',
ERROR: 'Fehler',
CD_ANALYZING: 'Analyse',
CD_METADATA_SELECTION: 'Metadatenauswahl',
CD_READY_TO_RIP: 'Bereit zum Encodieren',
CD_RIPPING: 'Rippen',
CD_ENCODING: 'Encodieren'
};
const PROCESS_STATUS_LABELS = {
SUCCESS: 'Erfolgreich',
ERROR: 'Fehler',
CANCELLED: 'Abgebrochen',
RUNNING: 'Läuft',
STARTED: 'Gestartet',
PENDING: 'Ausstehend',
DONE: 'Erledigt',
FAILED: 'Fehlgeschlagen',
COMPLETE: 'Abgeschlossen',
COMPLETED: 'Abgeschlossen',
SKIPPED: 'Übersprungen',
OK: 'OK'
};
const FALLBACK_TOKEN_LABELS = {
IDLE: 'Bereit',
DISC: 'Medium',
DETECTED: 'erkannt',
READY: 'bereit',
TO: 'zu',
RIP: 'rippen',
RIPPING: 'rippen',
ENCODE: 'encodieren',
ENCODING: 'encodieren',
ANALYZING: 'analyse',
LOOKUP: 'suche',
METADATA: 'metadaten',
SELECTION: 'auswahl',
WAITING: 'warte',
FOR: 'auf',
USER: 'Benutzer',
DECISION: 'entscheidung',
CHECK: 'prüfung',
POST: 'post',
SCRIPTS: 'skripte',
FINISHED: 'fertig',
CANCELLED: 'abgebrochen',
ERROR: 'fehler',
SUCCESS: 'erfolgreich',
RUNNING: 'läuft',
STARTED: 'gestartet',
PENDING: 'ausstehend',
FAILED: 'fehlgeschlagen',
SKIPPED: 'übersprungen',
CD: 'CD',
DVD: 'DVD',
RAW: 'RAW'
};
function prettifyUnknownStatus(status) {
const raw = String(status || '').trim();
if (!raw) {
return '-';
}
if (!raw.includes('_')) {
return raw;
}
const parts = raw
.split('_')
.map((part) => String(part || '').trim().toUpperCase())
.filter(Boolean);
if (parts.length === 0) {
return raw;
}
const mapped = parts.map((token) => FALLBACK_TOKEN_LABELS[token] || token.toLowerCase());
const joined = mapped.join(' ').trim();
if (!joined) {
return raw;
}
return joined.charAt(0).toUpperCase() + joined.slice(1);
}
export function normalizeStatus(status) {
return String(status || '').trim().toUpperCase();
}
export function getStatusLabel(status, options = {}) {
if (options?.queued) {
return 'In der Queue';
}
const normalized = normalizeStatus(status);
return STATUS_LABELS[normalized] || prettifyUnknownStatus(status);
}
export function getStatusSeverity(status, options = {}) {
if (options?.queued) {
return 'info';
}
const normalized = normalizeStatus(status);
if (normalized === 'FINISHED') return 'success';
if (normalized === 'CANCELLED') return 'warning';
if (normalized === 'ERROR') return 'danger';
if (normalized === 'READY_TO_START' || normalized === 'READY_TO_ENCODE') return 'info';
if (normalized === 'WAITING_FOR_USER_DECISION') return 'warning';
if (normalized === 'CD_READY_TO_RIP') return 'info';
if (normalized === 'CD_METADATA_SELECTION') return 'warning';
if (
normalized === 'RIPPING'
|| normalized === 'ENCODING'
|| normalized === 'ANALYZING'
|| normalized === 'METADATA_LOOKUP'
|| normalized === 'MEDIAINFO_CHECK'
|| normalized === 'METADATA_SELECTION'
|| normalized === 'POST_ENCODE_SCRIPTS'
|| normalized === 'CD_ANALYZING'
|| normalized === 'CD_RIPPING'
|| normalized === 'CD_ENCODING'
) {
return 'warning';
}
return 'secondary';
}
export function getProcessStatusLabel(status) {
const normalized = normalizeStatus(status);
return PROCESS_STATUS_LABELS[normalized] || prettifyUnknownStatus(status);
}
export const STATUS_FILTER_OPTIONS = [
{ label: 'Alle', value: '' },
{ label: getStatusLabel('FINISHED'), value: 'FINISHED' },
{ label: getStatusLabel('CANCELLED'), value: 'CANCELLED' },
{ label: getStatusLabel('ERROR'), value: 'ERROR' },
{ label: getStatusLabel('CD_METADATA_SELECTION'), value: 'CD_METADATA_SELECTION' },
{ label: getStatusLabel('CD_READY_TO_RIP'), value: 'CD_READY_TO_RIP' },
{ label: getStatusLabel('CD_ANALYZING'), value: 'CD_ANALYZING' },
{ label: getStatusLabel('CD_RIPPING'), value: 'CD_RIPPING' },
{ label: getStatusLabel('CD_ENCODING'), value: 'CD_ENCODING' },
{ label: getStatusLabel('WAITING_FOR_USER_DECISION'), value: 'WAITING_FOR_USER_DECISION' },
{ label: getStatusLabel('READY_TO_START'), value: 'READY_TO_START' },
{ label: getStatusLabel('READY_TO_ENCODE'), value: 'READY_TO_ENCODE' },
{ label: getStatusLabel('MEDIAINFO_CHECK'), value: 'MEDIAINFO_CHECK' },
{ label: getStatusLabel('RIPPING'), value: 'RIPPING' },
{ label: getStatusLabel('ENCODING'), value: 'ENCODING' },
{ label: getStatusLabel('ANALYZING'), value: 'ANALYZING' },
{ label: getStatusLabel('METADATA_LOOKUP'), value: 'METADATA_LOOKUP' },
{ label: getStatusLabel('METADATA_SELECTION'), value: 'METADATA_SELECTION' }
];
+74
View File
@@ -0,0 +1,74 @@
import { readFileSync } from 'node:fs';
import { createLogger, defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const appPackage = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const publicOrigin = (process.env.VITE_PUBLIC_ORIGIN || '').trim();
const parsedAllowedHosts = (process.env.VITE_ALLOWED_HOSTS || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
const allowedHosts = parsedAllowedHosts.length > 0 ? parsedAllowedHosts : true;
let hmr = undefined;
if (publicOrigin) {
const url = new URL(publicOrigin);
const defaultClientPort = url.port
? Number(url.port)
: (url.protocol === 'https:' ? 443 : 80);
hmr = {
protocol: process.env.VITE_HMR_PROTOCOL || (url.protocol === 'https:' ? 'wss' : 'ws'),
host: process.env.VITE_HMR_HOST || url.hostname,
clientPort: Number(process.env.VITE_HMR_CLIENT_PORT || defaultClientPort)
};
}
const viteLogger = createLogger();
const customLogger = {
...viteLogger,
error(message, options) {
const text = String(message || '');
const stack = String(options?.error?.stack || '');
const isWsProxyMessage = text.includes('ws proxy socket error');
const isEpipe = text.includes('EPIPE') || stack.includes('EPIPE');
if (isWsProxyMessage && isEpipe) {
return;
}
viteLogger.error(message, options);
}
};
export default defineConfig({
plugins: [react()],
customLogger,
define: {
__APP_VERSION__: JSON.stringify(appPackage.version)
},
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
origin: publicOrigin || undefined,
allowedHosts,
hmr,
proxy: {
'/api': {
target: 'http://127.0.0.1:3001',
changeOrigin: true
},
'/ws': {
target: 'ws://127.0.0.1:3001',
ws: true,
changeOrigin: true
}
}
},
preview: {
host: '0.0.0.0',
port: 5173,
strictPort: true
}
});