0.13.1-4 release snapshot

This commit is contained in:
2026-04-13 11:52:07 +00:00
parent 6115090da1
commit ba3732af6b
176 changed files with 103457 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>
+1713
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "ripster-frontend",
"version": "0.13.1-4",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"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

+684
View File
@@ -0,0 +1,684 @@
import { useEffect, useRef, useState } from 'react';
import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { ConfirmDialog } from 'primereact/confirmdialog';
import { api } from './api/client';
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 JobsInboxPage from './pages/JobsInboxPage';
import AudiobooksPage from './pages/AudiobooksPage';
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return 0;
}
return Math.max(0, Math.min(100, parsed));
}
function normalizeStage(value) {
return String(value || '').trim().toUpperCase();
}
function isTerminalStage(value) {
const normalized = normalizeStage(value);
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
}
function formatBytes(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 'n/a';
}
if (parsed === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;
let current = parsed;
while (current >= 1024 && unitIndex < units.length - 1) {
current /= 1024;
unitIndex += 1;
}
const digits = unitIndex <= 1 ? 0 : 2;
return `${current.toFixed(digits)} ${units[unitIndex]}`;
}
function createInitialAudiobookUploadState() {
return {
phase: 'idle',
fileName: null,
loadedBytes: 0,
totalBytes: 0,
progressPercent: 0,
statusText: null,
errorMessage: null,
jobId: null,
startedAt: null,
finishedAt: null
};
}
function getAudiobookUploadTagMeta(phase) {
const normalized = String(phase || '').trim().toLowerCase();
if (normalized === 'uploading') {
return { label: 'Upload läuft', severity: 'warning' };
}
if (normalized === 'processing') {
return { label: 'Server verarbeitet', severity: 'info' };
}
if (normalized === 'completed') {
return { label: 'Bereit', severity: 'success' };
}
if (normalized === 'error') {
return { label: 'Fehler', severity: 'danger' };
}
return { label: 'Inaktiv', severity: 'secondary' };
}
function 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'
};
}
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 [pendingRipperJobId, setPendingRipperJobId] = useState(null);
const location = useLocation();
const navigate = useNavigate();
const globalToastRef = useRef(null);
const prevCdDrivesRef = useRef({});
// 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]);
const refreshPipeline = async () => {
const response = await api.getPipelineState();
setPipeline(response.pipeline);
setHardwareMonitoring(response?.hardwareMonitoring || null);
return response;
};
const clearAudiobookUpload = () => {
setAudiobookUpload(createInitialAudiobookUploadState());
};
const handleAudiobookUpload = async (file, payload = {}) => {
if (!file) {
throw new Error('Bitte zuerst eine AAX-Datei auswählen.');
}
const fallbackTotalBytes = Number.isFinite(Number(file.size)) && Number(file.size) > 0
? Number(file.size)
: 0;
setAudiobookUpload({
phase: 'uploading',
fileName: String(file.name || '').trim() || 'upload.aax',
loadedBytes: 0,
totalBytes: fallbackTotalBytes,
progressPercent: 0,
statusText: 'AAX-Datei wird hochgeladen ...',
errorMessage: null,
jobId: null,
startedAt: new Date().toISOString(),
finishedAt: null
});
try {
const response = await api.uploadAudiobook(file, payload, {
onProgress: ({ loaded, total, percent }) => {
const nextLoaded = Number.isFinite(Number(loaded)) && Number(loaded) >= 0
? Number(loaded)
: 0;
const nextTotal = Number.isFinite(Number(total)) && Number(total) > 0
? Number(total)
: fallbackTotalBytes;
const nextPercent = Number.isFinite(Number(percent))
? clampPercent(Number(percent))
: (nextTotal > 0 ? clampPercent((nextLoaded / nextTotal) * 100) : 0);
const transferComplete = nextTotal > 0 && nextLoaded >= nextTotal;
setAudiobookUpload((prev) => ({
...prev,
phase: transferComplete ? 'processing' : 'uploading',
loadedBytes: nextLoaded,
totalBytes: nextTotal,
progressPercent: nextPercent,
statusText: transferComplete
? 'Upload abgeschlossen, AAX wird serverseitig verarbeitet ...'
: 'AAX-Datei wird hochgeladen ...'
}));
}
});
const uploadedJobId = normalizeJobId(response?.result?.jobId);
await refreshPipeline().catch(() => null);
setRipperJobsRefreshToken((prev) => prev + 1);
setHistoryJobsRefreshToken((prev) => prev + 1);
if (uploadedJobId) {
setPendingRipperJobId(uploadedJobId);
}
setAudiobookUpload((prev) => ({
...prev,
phase: 'completed',
loadedBytes: prev.totalBytes || prev.loadedBytes || fallbackTotalBytes,
totalBytes: prev.totalBytes || fallbackTotalBytes,
progressPercent: 100,
statusText: uploadedJobId
? `Upload abgeschlossen. Job #${uploadedJobId} ist bereit fuer den naechsten Schritt.`
: 'Upload abgeschlossen.',
errorMessage: null,
jobId: uploadedJobId,
finishedAt: new Date().toISOString()
}));
return response;
} catch (error) {
setAudiobookUpload((prev) => ({
...prev,
phase: 'error',
errorMessage: error?.message || 'Upload fehlgeschlagen.',
statusText: error?.message || 'Upload fehlgeschlagen.',
finishedAt: new Date().toISOString()
}));
throw error;
}
};
const handleRipperJobFocusConsumed = (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setPendingRipperJobId((prev) => (
normalizeJobId(prev) === normalizedJobId ? null : prev
));
};
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);
}, []);
useWebSocket({
onMessage: (message) => {
if (message.type === 'PIPELINE_STATE_CHANGED') {
setPipeline(message.payload);
}
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 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 prevJobProgressIsTerminal = isTerminalStage(prevJobProgressState);
const matchingCdDriveEntry = normalizedProgressJobId
? Object.values(prev?.cdDrives || {}).find((driveState) => normalizeJobId(driveState?.jobId) === normalizedProgressJobId)
: null;
const matchingCdDriveIsTerminal = isTerminalStage(matchingCdDriveEntry?.state);
const hasKnownBinding = Boolean(
normalizedProgressJobId
&& (
prevActiveJobId === normalizedProgressJobId
|| prevJobProgress
|| matchingCdDriveEntry
)
);
// Ignore late/stale progress packets that arrive after a terminal state
// or for jobs that are no longer bound in pipeline state.
if (
normalizedProgressJobId
&& !incomingIsTerminal
&& (
prevJobProgressIsTerminal
|| matchingCdDriveIsTerminal
|| !hasKnownBinding
)
) {
return prev;
}
if (progressJobId != null) {
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
const previousJobStage = normalizeStage(previousJobProgress?.state);
const keepPreviousJobStage = isTerminalStage(previousJobStage) && !incomingIsTerminal;
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 previousGlobalStage = normalizeStage(prev?.state);
const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) && !incomingIsTerminal;
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
}));
}
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);
}
}
if (message.type === 'SETTINGS_BULK_UPDATED') {
const keys = message.payload?.keys || [];
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 (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: 'Jobs', path: '/jobs' },
{ 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 uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
const showAudiobookUploadBanner = uploadPhase !== 'idle';
const uploadProgress = clampPercent(audiobookUpload?.progressPercent);
const uploadTagMeta = getAudiobookUploadTagMeta(uploadPhase);
const uploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0);
const uploadTotalBytes = Number(audiobookUpload?.totalBytes || 0);
const uploadBytesLabel = uploadTotalBytes > 0
? `${formatBytes(uploadLoadedBytes)} / ${formatBytes(uploadTotalBytes)}`
: (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null);
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
const isAudiobooksRoute = location.pathname === '/audiobooks';
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
const isNavActive = (path) => {
if (path === '/ripper') {
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>
{showAudiobookUploadBanner ? (
<section className={`app-upload-banner phase-${uploadPhase}`}>
<div className="app-upload-banner-copy">
<div className="app-upload-banner-head">
<strong>Audiobook Upload</strong>
<Tag value={uploadTagMeta.label} severity={uploadTagMeta.severity} />
</div>
<small>{audiobookUpload?.statusText || 'Upload aktiv.'}</small>
{audiobookUpload?.fileName ? <small>Datei: {audiobookUpload.fileName}</small> : null}
</div>
<div
className="app-upload-banner-progress"
aria-label={`Audiobook Upload ${Math.round(uploadProgress)} Prozent`}
>
<ProgressBar value={uploadProgress} showValue={false} />
<small>
{uploadPhase === 'processing'
? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}`
: uploadBytesLabel
? `${Math.round(uploadProgress)}% | ${uploadBytesLabel}`
: `${Math.round(uploadProgress)}%`}
</small>
</div>
<div className="app-upload-banner-actions">
{hasUploadedJob && !isAudiobooksRoute ? (
<Button
label="Zu Audiobooks"
icon="pi pi-arrow-right"
severity="secondary"
outlined
onClick={() => {
navigate('/audiobooks');
}}
/>
) : null}
{canDismissUploadBanner ? (
<Button
icon="pi pi-times"
rounded
text
severity="secondary"
aria-label="Upload-Hinweis schliessen"
onClick={clearAudiobookUpload}
/>
) : null}
</div>
</section>
) : null}
<main className="app-main">
<Routes>
<Route
path="/"
element={
<RipperPage
pipeline={pipeline}
hardwareMonitoring={hardwareMonitoring}
lastDiscEvent={lastDiscEvent}
refreshPipeline={refreshPipeline}
jobsRefreshToken={ripperJobsRefreshToken}
pendingExpandedJobId={pendingRipperJobId}
onPendingExpandedJobHandled={handleRipperJobFocusConsumed}
downloadSummary={downloadSummary}
>
<Outlet />
</RipperPage>
}
>
<Route index element={<Navigate to="jobs" replace />} />
<Route path="ripper" element={null} />
<Route path="settings" element={<SettingsPage />} />
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} />
<Route path="jobs" element={<JobsInboxPage />} />
<Route path="converter" element={<ConverterPage />} />
<Route
path="audiobooks"
element={
<AudiobooksPage
audiobookUpload={audiobookUpload}
onAudiobookUpload={handleAudiobookUpload}
/>
}
/>
</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

+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,406 @@
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 { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
import { InputText } from 'primereact/inputtext';
import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
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 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,
onRetry,
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);
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 || [])]);
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3;
const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED');
const isRunning = state === 'ENCODING';
const isFinished = state === 'FINISHED';
const progress = Number.isFinite(Number(pipeline?.progress)) ? Math.max(0, Math.min(100, Number(pipeline.progress))) : 0;
const outputPath = String(context?.outputPath || '').trim() || null;
const isSplitOutput = format === 'mp3' || format === 'flac';
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]
);
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="audiobook-config-tags">
<Tag value={statusLabel} severity={statusSeverity} />
<Tag value={`Format: ${format.toUpperCase()}`} severity="info" />
{metadata?.durationMs ? <Tag value={`Dauer: ${Math.round(Number(metadata.durationMs) / 60000)} min`} severity="secondary" /> : null}
{posterUrl ? <Tag value="Cover erkannt" severity="success" /> : null}
</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>
<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>
</div>
{isRunning ? (
<div className="ripper-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.round(progress)}%`}>
<ProgressBar value={progress} showValue={false} />
<small>{Math.round(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}</small>
</div>
) : null}
{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="audiobook-chapter-status-list">
{editableChapters.map((chapter, idx) => {
const chIdx = chapter.index || idx + 1;
const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount);
const isActive = !isDone && currentChapter?.index === chIdx;
const statusIcon = isDone ? 'pi pi-check-circle' : isActive ? 'pi pi-spin pi-spinner' : 'pi pi-circle';
const statusClass = isDone ? 'chapter-status-done' : isActive ? 'chapter-status-active' : 'chapter-status-pending';
return (
<div key={chIdx} className={`audiobook-chapter-status-row ${statusClass}`}>
<i className={statusIcon} />
<span className="chapter-status-nr">#{String(chIdx).padStart(2, '0')}</span>
<span className="chapter-status-title">{chapter.title}</span>
</div>
);
})}
</div>
</div>
) : null}
{outputPath ? (
<div className="audiobook-output-path">
<strong>Ausgabe:</strong> <code>{outputPath}</code>
</div>
) : null}
<div className="actions-row">
{canStart ? (
<Button
label={state === 'READY_TO_START' ? 'Encoding 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
}))
})}
loading={busy}
disabled={!jobId}
/>
) : null}
{jobId ? (
<Button
label="Job löschen"
icon="pi pi-trash"
severity="danger"
outlined
onClick={() => onDeleteJob?.(jobId)}
loading={busy}
/>
) : null}
{isRunning ? (
<Button
label="Abbrechen"
icon="pi pi-stop"
severity="danger"
onClick={() => onCancel?.()}
loading={busy}
disabled={!jobId}
/>
) : null}
{(state === 'ERROR' || state === 'CANCELLED') ? (
<Button
label="Retry-Job anlegen"
icon="pi pi-refresh"
severity="warning"
outlined
onClick={() => onRetry?.()}
loading={busy}
disabled={!jobId}
/>
) : null}
</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,298 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from 'primereact/button';
import { ProgressSpinner } from 'primereact/progressspinner';
import { api } from '../api/client';
function formatBytes(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
return '';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let index = 0;
let current = n;
while (current >= 1024 && index < units.length - 1) {
current /= 1024;
index += 1;
}
return `${current.toFixed(index <= 1 ? 0 : 1)} ${units[index]}`;
}
function formatDateTime(value) {
if (!value) {
return '-';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return '-';
}
return parsed.toLocaleString('de-DE');
}
function getNodeByPath(root, targetPath) {
if (!root) {
return null;
}
if ((root.path || '') === (targetPath || '')) {
return root;
}
for (const child of (root.children || [])) {
if (child.type !== 'folder') {
continue;
}
const found = getNodeByPath(child, targetPath);
if (found) {
return found;
}
}
return null;
}
function listChildren(node) {
if (!node || !Array.isArray(node.children)) {
return [];
}
return node.children;
}
function buildBreadcrumb(pathValue) {
if (!pathValue) {
return [];
}
const parts = String(pathValue).split('/').filter(Boolean);
return parts.map((part, index) => ({
name: part,
path: parts.slice(0, index + 1).join('/')
}));
}
function filterFolderTree(node, query) {
if (!node || node.type !== 'folder') {
return null;
}
if (!query || !query.trim()) {
return node;
}
const normalized = query.toLowerCase();
const children = (node.children || [])
.filter((child) => child.type === 'folder')
.map((child) => filterFolderTree(child, query))
.filter(Boolean);
const nameMatches = String(node.name || '').toLowerCase().includes(normalized);
if (nameMatches || children.length > 0) {
return { ...node, children };
}
return null;
}
function defaultExpandedSet(tree) {
const next = new Set(['']);
const firstLevel = Array.isArray(tree?.children) ? tree.children : [];
for (const entry of firstLevel) {
if (entry?.type === 'folder' && entry?.path) {
next.add(entry.path);
}
}
return next;
}
export default function AudiobookOutputExplorer({ refreshToken = 0 }) {
const [tree, setTree] = useState(null);
const [outputDir, setOutputDir] = useState(null);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [currentPath, setCurrentPath] = useState('');
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
const [sidebarQuery, setSidebarQuery] = useState('');
const loadTree = useCallback(async () => {
setLoading(true);
setErrorMessage('');
try {
const response = await api.getAudiobookOutputTree();
const nextTree = response?.tree || null;
setTree(nextTree);
setOutputDir(response?.outputDir || null);
setExpandedFolders(defaultExpandedSet(nextTree));
setCurrentPath('');
} catch (error) {
setTree(null);
setErrorMessage(error?.message || 'Explorer konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadTree();
}, [loadTree, refreshToken]);
const navigateTo = (pathValue) => {
const normalized = String(pathValue || '').trim();
const node = getNodeByPath(tree, normalized);
if (node && node.type === 'folder') {
setCurrentPath(normalized);
}
};
const toggleFolder = (pathValue) => {
const normalized = String(pathValue || '').trim();
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(normalized)) {
next.delete(normalized);
} else {
next.add(normalized);
}
return next;
});
};
const renderTreeNode = (node, depth = 0) => {
if (!node || node.type !== 'folder') {
return null;
}
const key = node.path || '';
const isExpanded = expandedFolders.has(key);
const isCurrent = currentPath === key;
const children = Array.isArray(node.children) ? node.children.filter((entry) => entry.type === 'folder') : [];
return (
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
<button
type="button"
className={`tree-row folder${isCurrent ? ' active' : ''}`}
onClick={() => navigateTo(key)}
>
{children.length > 0 ? (
<span
className="tree-caret"
onClick={(event) => {
event.stopPropagation();
toggleFolder(key);
}}
>
<i className={`pi ${isExpanded ? 'pi-chevron-down' : 'pi-chevron-right'}`} />
</span>
) : (
<span className="tree-caret disabled" aria-hidden="true" />
)}
<span className="tree-icon folder">
<i className="pi pi-folder" />
</span>
<span className="tree-label">{node.name || 'audiobooks'}</span>
</button>
{isExpanded && children.map((child) => renderTreeNode(child, depth + 1))}
</div>
);
};
const filteredTree = useMemo(() => filterFolderTree(tree, sidebarQuery), [tree, sidebarQuery]);
const currentNode = getNodeByPath(tree, currentPath);
const currentChildren = listChildren(currentNode);
const breadcrumb = buildBreadcrumb(currentPath);
if (loading && !tree) {
return (
<div className="explorer-loading">
<ProgressSpinner style={{ width: '2.2rem', height: '2.2rem' }} strokeWidth="5" />
</div>
);
}
if (!tree) {
return (
<div className="explorer-empty">
{errorMessage ? (
<small className="error-text">{errorMessage}</small>
) : (
<small>Kein Audiobook-Output vorhanden. Ausgabepfad: <code>{outputDir || 'nicht konfiguriert'}</code></small>
)}
<div style={{ marginTop: '0.6rem' }}>
<Button icon="pi pi-refresh" label="Neu laden" outlined size="small" onClick={() => void loadTree()} />
</div>
</div>
);
}
return (
<div className="explorer audiobook-output-explorer">
<div className="explorer-sidebar">
<div className="explorer-toolbar sidebar-toolbar">
<input
type="text"
placeholder="Ordner filtern..."
value={sidebarQuery}
onChange={(event) => setSidebarQuery(event.target.value)}
className="sidebar-search"
/>
</div>
<div className="sidebar-tree">
{filteredTree ? renderTreeNode(filteredTree, 0) : <small>Keine Ordner gefunden.</small>}
</div>
</div>
<div className="explorer-main">
<div className="explorer-toolbar">
<Button
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
label="Aktualisieren"
outlined
size="small"
onClick={() => void loadTree()}
disabled={loading}
/>
<div className="explorer-path">
<Button text label="/" onClick={() => navigateTo('')} />
{breadcrumb.map((crumb) => (
<Button key={crumb.path} text label={crumb.name} onClick={() => navigateTo(crumb.path)} />
))}
</div>
</div>
<div className="explorer-list">
<div className="explorer-row header audiobook-output-row">
<span>Name</span>
<span>Größe</span>
<span>Geändert</span>
</div>
{currentChildren.length === 0 ? (
<div className="explorer-row audiobook-output-row">
<span>Keine Einträge in diesem Ordner.</span>
<span />
<span />
</div>
) : (
currentChildren.map((entry) => (
<button
type="button"
key={entry.path || entry.name}
className="explorer-row audiobook-output-row"
onClick={() => {
if (entry.type === 'folder') {
navigateTo(entry.path);
}
}}
style={{ cursor: entry.type === 'folder' ? 'pointer' : 'default' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
<i className={`pi ${entry.type === 'folder' ? 'pi-folder' : 'pi-file'}`} />
{entry.name}
</span>
<span>{entry.type === 'file' ? (formatBytes(entry.size) || '-') : '-'}</span>
<span>{formatDateTime(entry.mtime)}</span>
</button>
))
)}
</div>
<div className="explorer-footer">
<small>
Root: <code>{outputDir || '-'}</code>
</small>
</div>
</div>
</div>
);
}
@@ -0,0 +1,200 @@
import { useEffect, useRef, useState } from 'react';
import { FileUpload } from 'primereact/fileupload';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
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);
}
export default function AudiobookUploadPanel({
audiobookUpload,
onAudiobookUpload,
onUploaded = null
}) {
const toastRef = useRef(null);
const fileUploadRef = useRef(null);
const [uploadFile, setUploadFile] = useState(null);
const [statusVisible, setStatusVisible] = useState(false);
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 fileName = String(audiobookUpload?.fileName || '').trim()
|| String(uploadFile?.name || '').trim()
|| null;
const statusTone = phase === 'error'
? 'danger'
: phase === 'completed'
? 'success'
: phase === 'processing'
? 'info'
: phase === 'uploading'
? 'warning'
: 'secondary';
const statusLabel = phase === 'uploading'
? 'Upload laeuft'
: phase === 'processing'
? 'Server verarbeitet'
: phase === 'completed'
? 'Bereit'
: phase === 'error'
? 'Fehler'
: 'Inaktiv';
useEffect(() => {
if (phase === 'idle') {
setStatusVisible(false);
return;
}
setStatusVisible(true);
if (phase === 'completed') {
const timer = setTimeout(() => setStatusVisible(false), 5000);
return () => clearTimeout(timer);
}
return undefined;
}, [phase]);
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 = normalizeJobId(response?.result?.jobId);
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) {
toastRef.current?.show({
severity: 'error',
summary: 'Upload fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
}
};
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) => setUploadFile(event.files[0] || null)}
onClear={() => setUploadFile(null)}
onRemove={() => setUploadFile(null)}
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' }}
itemTemplate={(file, options) => (
<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}</span>
<small>{options.formatSize}</small>
</div>
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
onClick={options.onRemove}
disabled={uploadBusy}
/>
</div>
)}
emptyTemplate={() => (
<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>
)}
/>
{statusVisible ? (
<div className={`audiobook-upload-status tone-${statusTone}`}>
<div className="audiobook-upload-status-head">
<strong>{statusLabel}</strong>
<Tag value={statusLabel} severity={statusTone} />
</div>
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
{fileName ? (
<small className="audiobook-upload-file" title={fileName}>
Datei: {fileName}
</small>
) : null}
<div
className="ripper-job-row-progress audiobook-upload-progress"
aria-label={`Audiobook Upload ${Math.round(progress)} Prozent`}
>
<ProgressBar value={progress} showValue={false} />
<small>
{phase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: totalBytes > 0
? `${Math.round(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
: `${Math.round(progress)}%`}
</small>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,315 @@
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 searchResults = await onSearch(trimmedQuery);
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="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.round(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);
});
}
+626
View File
@@ -0,0 +1,626 @@
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 { 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({ onWsMessage }) {
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);
// ── 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 activeCronRunByJobId = useMemo(() => {
const map = new Map();
for (const item of activeCronRuns) {
if (!item?.cronJobId) {
continue;
}
map.set(item.cronJobId, item);
}
return map;
}, [activeCronRuns]);
// WebSocket: Cronjob-Updates empfangen
useEffect(() => {
if (!onWsMessage) return;
// onWsMessage ist eine Funktion, die wir anmelden
}, [onWsMessage]);
// ── 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,353 @@
import { useEffect, useMemo, 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';
export default function MetadataSelectionDialog({
visible,
context,
onHide,
onSubmit,
onSearch,
busy
}) {
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 [searchBusy, setSearchBusy] = useState(false);
const [searchError, setSearchError] = useState('');
const [submitError, setSubmitError] = useState('');
const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState('');
const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
const mediaProfile = String(
context?.mediaProfile
|| context?.selectedMetadata?.mediaProfile
|| ''
).trim().toLowerCase();
const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb';
const supportsExternalIdInput = metadataProvider === 'omdb';
const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile);
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
const hasValidDiscNumber = effectiveDiscNumber !== null;
useEffect(() => {
if (!visible) {
return;
}
const selectedMetadata = context?.selectedMetadata || {};
const defaultTitle = selectedMetadata.title || context?.detectedTitle || '';
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
const defaultImdb = selectedMetadata.imdbId || '';
setSelected(null);
setQuery(defaultTitle);
setManualTitle(defaultTitle);
setManualYear(defaultYear);
setManualImdb(defaultImdb);
setManualDiscNumber('');
setExtraResults([]);
setSearchBusy(false);
setSearchError('');
setSubmitError('');
setSeasonFilter('');
setDiscValidationError('');
}, [visible, context]);
useEffect(() => {
if (!discValidationError) {
return;
}
if (!requiresDiscNumber || hasValidDiscNumber) {
setDiscValidationError('');
}
}, [discValidationError, requiresDiscNumber, hasValidDiscNumber]);
useEffect(() => {
if (submitError) {
setSubmitError('');
}
}, [manualDiscNumber, seasonFilter, selected]);
const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates
: (context?.omdbCandidates || []);
const all = [...base, ...extraResults];
const map = new Map();
all.forEach((item) => {
const rowKey = String(
item?.providerId
|| item?.imdbId
|| item?.tmdbId
|| `${item?.title || '-'}::${item?.year || '-'}::${item?.seasonNumber || '-'}`
).trim();
if (!rowKey) {
return;
}
map.set(rowKey, {
...item,
_metadataKey: rowKey
});
});
return Array.from(map.values());
}, [context, extraResults]);
const filteredRows = useMemo(() => {
if (metadataProvider !== 'tmdb') {
return rows;
}
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
if (!normalizedFilter) {
return rows;
}
return rows.filter((row) => {
const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase();
const seasonName = String(row?.seasonName || '').trim().toLowerCase();
return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter);
});
}, [metadataProvider, rows, seasonFilter]);
const titleWithPosterBody = (row) => (
<div className="omdb-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;
}
setSearchBusy(true);
setSearchError('');
try {
const results = await onSearch(trimmedQuery, {
metadataProvider
});
setExtraResults(Array.isArray(results) ? results : []);
} catch (error) {
setExtraResults([]);
setSearchError(error?.message || 'Suche fehlgeschlagen.');
} finally {
setSearchBusy(false);
}
};
const handleSubmit = async () => {
setDiscValidationError('');
setSubmitError('');
if (requiresDiscNumber && !hasValidDiscNumber) {
setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
return;
}
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,
fromOmdb: metadataProvider === 'omdb',
metadataProvider,
providerId: selected.providerId || null,
tmdbId: selected.tmdbId || null,
metadataKind: selected.metadataKind || null,
seasonNumber: selected.seasonNumber || null,
seasonName: selected.seasonName || null,
episodeCount: selected.episodeCount || 0,
episodes: Array.isArray(selected.episodes) ? selected.episodes : [],
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
}
: {
jobId: context.jobId,
title: manualTitle,
year: manualYear,
imdbId: supportsExternalIdInput ? manualImdb : null,
poster: null,
fromOmdb: false,
metadataProvider,
seasonNumber: null,
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
};
try {
await onSubmit(payload);
} catch (error) {
const details = Array.isArray(error?.details) ? error.details : [];
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 handleHideRequest = () => {
if (requiresDiscNumber && !hasValidDiscNumber) {
setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
return;
}
onHide?.();
};
return (
<Dialog
header="Metadaten auswählen"
visible={visible}
onHide={handleHideRequest}
style={{ width: '52rem', maxWidth: '95vw' }}
className="metadata-selection-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
modal
>
<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${metadataProvider !== 'tmdb' ? ' metadata-filter-grid-single' : ''}`}>
{metadataProvider === 'tmdb' ? (
<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 fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht verlassen werden.'
: 'Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.'}
</small>
</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={metadataProvider === 'tmdb' ? 'Staffel' : 'IMDb'}
body={(row) => (metadataProvider === 'tmdb'
? (row.seasonNumber ? `S${row.seasonNumber}` : '-')
: (row.imdbId || '-'))}
style={{ width: '10rem' }}
/>
</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}
disabled={requiresDiscNumber && !hasValidDiscNumber}
/>
<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;
}
+62
View File
@@ -0,0 +1,62 @@
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`;
}
export function useWebSocket({ onMessage }) {
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
let socket;
let reconnectTimer;
let isUnmounted = false;
const connect = () => {
if (isUnmounted) {
return;
}
socket = new WebSocket(buildWsUrl());
socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
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;
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>
);
+508
View File
@@ -0,0 +1,508 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
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 AudiobookOutputExplorer from '../components/AudiobookOutputExplorer';
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 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 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
: {}
},
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
}) {
const toastRef = useRef(null);
const navigate = useNavigate();
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 explorerRefreshToken = 0;
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 = { ...prev };
for (const job of audiobookRows) {
const jobId = normalizeJobId(job?.id);
if (!jobId) {
continue;
}
const status = String(job?.status || '').trim().toUpperCase();
if (!['ANALYZING', 'ENCODING'].includes(status)) {
delete next[jobId];
}
}
return next;
});
} catch (error) {
console.error('AudiobooksPage load jobs error:', error);
} 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) => ({
...prev,
[jobId]: {
progress: payload?.progress ?? null,
eta: payload?.eta ?? null,
statusText: payload?.statusText ?? null,
state: payload?.state ?? null,
currentChapter: payload?.contextPatch?.currentChapter ?? null,
completedChapterCount: payload?.contextPatch?.completedChapterCount ?? null
}
}));
}
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.cancelJob(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 handleRetry = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setActionBusy(normalizedJobId, true);
try {
const response = await api.retryJob(normalizedJobId);
const retryJobId = normalizeJobId(response?.retryJob?.id);
toastRef.current?.show({
severity: 'success',
summary: 'Retry erstellt',
detail: retryJobId
? `Neuer Job #${retryJobId} wurde angelegt.`
: `Retry fuer Job #${normalizedJobId} wurde angelegt.`,
life: 3200
});
await loadJobs();
if (retryJobId) {
setExpandedJobId(retryJobId);
}
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Retry 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) => {
const normalizedJobId = normalizeJobId(uploadedJobId);
await loadJobs();
if (normalizedJobId) {
setExpandedJobId(normalizedJobId);
}
};
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}
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 showProgress = ['ANALYZING', 'ENCODING'].includes(state) && progressValue > 0;
if (!isExpanded) {
return (
<button
key={jobId}
type="button"
className="ripper-job-row"
onClick={() => setExpandedJobId(jobId)}
>
<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>#{jobId}</small>
</div>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
<Tag value="Audiobook" severity="info" />
</div>
{showProgress ? (
<div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} />
</div>
) : null}
</div>
</button>
);
}
return (
<div key={jobId} className="ripper-job-expanded">
<div className="ripper-job-expanded-head">
<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>#{jobId} | {title}</span>
</strong>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
<Tag value="Audiobook" severity="info" />
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button
label="Im Ripper"
icon="pi pi-arrow-right"
severity="secondary"
outlined
onClick={() => navigate('/ripper')}
/>
<Button
label="Einklappen"
icon="pi pi-angle-up"
severity="secondary"
outlined
onClick={() => setExpandedJobId(null)}
disabled={busy}
/>
</div>
</div>
<AudiobookConfigPanel
pipeline={pipelineForJob}
onStart={(config) => void handleAudiobookStart(jobId, config)}
onCancel={() => void handleCancel(jobId)}
onRetry={() => void handleRetry(jobId)}
onDeleteJob={() => void handleDelete(jobId)}
busy={busy}
/>
</div>
);
})}
</div>
)}
</Card>
<Card
title="Audiobook Output Explorer"
subTitle="Read-only Explorer auf dem Audiobook-Ausgabeordner"
>
<AudiobookOutputExplorer refreshToken={explorerRefreshToken} />
</Card>
</div>
);
}
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
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 { 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 [orphanRows, setOrphanRows] = useState([]);
const [orphanLoading, setOrphanLoading] = useState(false);
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
const toastRef = useRef(null);
const loadOrphans = async () => {
setOrphanLoading(true);
try {
const response = await api.getOrphanRawFolders();
setOrphanRows(response.rows || []);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'RAW-Prüfung fehlgeschlagen', detail: error.message });
} finally {
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;
toastRef.current?.show({
severity: 'success',
summary: 'Job angelegt',
detail: newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.',
life: 3500
});
await loadOrphans();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Import fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setOrphanImportBusyPath(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) => (
<Button
label="Job anlegen"
icon="pi pi-plus"
size="small"
onClick={() => handleImportOrphanRaw(row)}
loading={orphanImportBusyPath === row.rawPath}
disabled={Boolean(orphanImportBusyPath)}
/>
);
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)}
/>
<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={{ width: '10rem' }} />
</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>
);
}
File diff suppressed because it is too large Load Diff
+357
View File
@@ -0,0 +1,357 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { InputText } from 'primereact/inputtext';
import { Tag } from 'primereact/tag';
import { Paginator } from 'primereact/paginator';
import { api } from '../api/client';
import { classifyJob } from '../utils/jobTaxonomy';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
const ACTIVE_STATUSES = [
'ANALYZING',
'METADATA_SELECTION',
'WAITING_FOR_USER_DECISION',
'READY_TO_START',
'MEDIAINFO_CHECK',
'READY_TO_ENCODE',
'RIPPING',
'ENCODING',
'CD_METADATA_SELECTION',
'CD_READY_TO_RIP',
'CD_ANALYZING',
'CD_RIPPING',
'CD_ENCODING'
];
const STATUS_FILTERS = [
{ key: 'all', label: 'Alle' },
{ key: 'active', label: 'Aktiv' },
{ key: 'errors', label: 'Fehler/Abbruch' }
];
const VIEW_FILTERS = [
{ key: 'all', label: 'Alle Jobs' },
{ key: 'ripper', label: 'Ripper-View' },
{ key: 'converter', label: 'Converter-View' },
{ key: 'audiobook', label: 'Audiobooks' }
];
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function formatUpdatedAt(value) {
if (!value) {
return '-';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return '-';
}
return parsed.toLocaleString('de-DE');
}
function getKindLabel(meta) {
if (meta.family === 'converter') {
if (meta.converterMediaType === 'audio') {
return 'Converter Audio';
}
if (meta.converterMediaType === 'iso') {
return 'Converter ISO';
}
return 'Converter Video';
}
if (meta.family === 'audiobook') {
return 'Audiobook';
}
if (meta.mediaType === 'bluray') {
return 'Blu-ray';
}
if (meta.mediaType === 'dvd') {
return 'DVD';
}
if (meta.mediaType === 'cd') {
return 'CD';
}
return 'Sonstiges';
}
function getKindSeverity(meta) {
if (meta.family === 'converter') {
return 'info';
}
if (meta.family === 'audiobook') {
return 'warning';
}
if (meta.mediaType === 'bluray' || meta.mediaType === 'dvd') {
return 'success';
}
return 'secondary';
}
export default function JobsInboxPage() {
const navigate = useNavigate();
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(false);
const [viewFilter, setViewFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [search, setSearch] = useState('');
const [queuedJobIds, setQueuedJobIds] = useState(new Set());
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
const [first, setFirst] = useState(0);
const [rows, setRows] = useState(25);
const loadJobs = useCallback(async () => {
setLoading(true);
try {
const query = { limit: 500, lite: true };
if (statusFilter === 'active') {
query.statuses = ACTIVE_STATUSES;
} else if (statusFilter === 'errors') {
query.statuses = ['ERROR', 'CANCELLED'];
}
const [jobsResponse, queueResponse] = await Promise.allSettled([
api.getJobs(query),
api.getPipelineQueue()
]);
const nextJobs = jobsResponse.status === 'fulfilled' && Array.isArray(jobsResponse.value?.jobs)
? jobsResponse.value.jobs
: [];
setJobs(nextJobs);
if (queueResponse.status === 'fulfilled') {
const rows = Array.isArray(queueResponse.value?.queue?.queuedJobs)
? queueResponse.value.queue.queuedJobs
: [];
setQueuedJobIds(new Set(rows.map((item) => normalizeJobId(item?.jobId)).filter(Boolean)));
} else {
setQueuedJobIds(new Set());
}
setLastUpdatedAt(new Date().toISOString());
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
loadJobs();
const interval = setInterval(() => {
loadJobs().catch(() => null);
}, 7000);
return () => clearInterval(interval);
}, [loadJobs]);
const jobsWithMeta = useMemo(() => (
jobs.map((job) => ({
job,
meta: classifyJob(job)
}))
), [jobs]);
const viewCounts = useMemo(() => {
let all = 0;
let ripper = 0;
let converter = 0;
let audiobook = 0;
for (const item of jobsWithMeta) {
all += 1;
if (item.meta.family === 'converter') {
converter += 1;
} else {
ripper += 1;
}
if (item.meta.family === 'audiobook') {
audiobook += 1;
}
}
return { all, ripper, converter, audiobook };
}, [jobsWithMeta]);
const filteredRows = useMemo(() => {
const normalizedSearch = String(search || '').trim().toLowerCase();
return jobsWithMeta.filter(({ job, meta }) => {
if (viewFilter === 'ripper' && meta.family === 'converter') {
return false;
}
if (viewFilter === 'converter' && meta.family !== 'converter') {
return false;
}
if (viewFilter === 'audiobook' && meta.family !== 'audiobook') {
return false;
}
if (!normalizedSearch) {
return true;
}
const haystack = [
job?.title,
job?.detected_title,
job?.imdb_id,
job?.status,
job?.job_kind,
job?.media_type
]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ');
return haystack.includes(normalizedSearch);
});
}, [jobsWithMeta, search, viewFilter]);
useEffect(() => {
setFirst(0);
}, [viewFilter, statusFilter, search]);
useEffect(() => {
if (filteredRows.length === 0) {
if (first !== 0) {
setFirst(0);
}
return;
}
if (first >= filteredRows.length) {
const pageStart = Math.max(0, Math.floor((filteredRows.length - 1) / rows) * rows);
setFirst(pageStart);
}
}, [filteredRows.length, first, rows]);
const pagedRows = useMemo(
() => filteredRows.slice(first, first + rows),
[filteredRows, first, rows]
);
return (
<Card
title="Job Inbox"
subTitle="Zentrale Jobliste mit denselben Datensätzen für Ripper-, Converter- und Audiobook-Sicht."
>
<div className="job-inbox-toolbar">
<div className="job-inbox-filter-row">
{VIEW_FILTERS.map((filter) => {
const isActive = filter.key === viewFilter;
const count = viewCounts[filter.key] ?? 0;
return (
<Button
key={filter.key}
label={`${filter.label} (${count})`}
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
outlined={!isActive}
onClick={() => setViewFilter(filter.key)}
size="small"
/>
);
})}
</div>
<div className="job-inbox-filter-row">
{STATUS_FILTERS.map((filter) => {
const isActive = filter.key === statusFilter;
return (
<Button
key={filter.key}
label={filter.label}
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
outlined={!isActive}
onClick={() => setStatusFilter(filter.key)}
size="small"
/>
);
})}
<Button
label="Aktualisieren"
icon="pi pi-refresh"
outlined
size="small"
loading={loading}
onClick={() => {
loadJobs().catch(() => null);
}}
/>
</div>
</div>
<div className="job-inbox-search-row">
<InputText
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Suche nach Titel, Status oder IMDB-ID"
className="job-inbox-search-input"
/>
<small>
Letztes Update: {formatUpdatedAt(lastUpdatedAt)} | Treffer: {filteredRows.length}
</small>
</div>
<div className="job-inbox-list">
{loading && filteredRows.length === 0 ? (
<p>Inbox wird geladen ...</p>
) : filteredRows.length === 0 ? (
<p>Keine Jobs für den aktuellen Filter gefunden.</p>
) : (
pagedRows.map(({ job, meta }) => {
const jobId = normalizeJobId(job?.id);
if (!jobId) {
return null;
}
const isQueued = queuedJobIds.has(jobId);
const normalizedStatus = normalizeStatus(job?.status);
const targetPath = meta.family === 'converter' ? '/converter' : '/ripper';
const jobTitle = String(job?.title || job?.detected_title || '').trim() || `Job #${jobId}`;
return (
<article key={jobId} className="job-inbox-row">
<div className="job-inbox-row-main">
<strong>#{jobId} | {jobTitle}</strong>
<small>
Status: {getStatusLabel(normalizedStatus, { queued: isQueued })}
{' | '}
Aktualisiert: {formatUpdatedAt(job?.updated_at || job?.created_at || null)}
</small>
</div>
<div className="job-inbox-row-tags">
<Tag value={getKindLabel(meta)} severity={getKindSeverity(meta)} />
<Tag
value={getStatusLabel(normalizedStatus, { queued: isQueued })}
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
/>
</div>
<div className="job-inbox-row-actions">
<Button
label={meta.family === 'converter' ? 'Im Converter öffnen' : 'Im Ripper öffnen'}
icon={meta.family === 'converter' ? 'pi pi-external-link' : 'pi pi-arrow-right'}
outlined
size="small"
onClick={() => navigate(targetPath)}
/>
</div>
</article>
);
})
)}
</div>
{filteredRows.length > 0 ? (
<div className="job-inbox-pagination">
<Paginator
first={first}
rows={rows}
totalRecords={filteredRows.length}
rowsPerPageOptions={[25, 50, 100]}
onPageChange={(event) => {
setFirst(event.first);
setRows(event.rows);
}}
template="PrevPageLink PageLinks NextPageLink RowsPerPageDropdown CurrentPageReport"
currentPageReportTemplate="{first} - {last} von {totalRecords}"
/>
</div>
) : null}
</Card>
);
}
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
+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)
});
});
}
+285
View File
@@ -0,0 +1,285 @@
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', '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';
}
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 isSeriesDvdJob(job) {
if (resolveMediaType(job) !== 'dvd') {
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 metadataProvider = String(
analyzeContext?.metadataProvider
|| selectedMetadata?.metadataProvider
|| ''
).trim().toLowerCase();
const metadataKind = String(
selectedMetadata?.metadataKind
|| analyzeContext?.metadataKind
|| ''
).trim().toLowerCase();
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;
}
const tmdbIdRaw = selectedMetadata?.tmdbId
?? analyzeContext?.tmdbId
?? selectedMetadata?.providerId
?? analyzeContext?.providerId
?? null;
const tmdbIdText = String(tmdbIdRaw ?? '').trim();
const hasTmdbId = Boolean(tmdbIdText) && tmdbIdText !== '0';
return (metadataProvider === 'tmdb' || metadataProvider === 'themoviedb') && hasTmdbId;
}
export function isConverterJob(job) {
return resolveMediaType(job) === 'converter';
}
export function classifyJob(job) {
const jobKind = resolveJobKind(job);
const mediaType = resolveMediaType(job);
const converterMediaType = normalizeConverterMediaType(
(jobKind && jobKind.startsWith('converter_'))
? jobKind.replace('converter_', '')
: (getEncodePlan(job)?.converterMediaType || job?.converterMediaType)
);
let family = 'other';
if (mediaType === 'converter') {
family = 'converter';
} else if (mediaType === 'audiobook') {
family = 'audiobook';
} else if (mediaType === 'cd' || mediaType === 'dvd' || mediaType === 'bluray') {
family = 'rip';
}
return {
jobKind,
mediaType,
converterMediaType,
family
};
}
+158
View File
@@ -0,0 +1,158 @@
const STATUS_LABELS = {
IDLE: 'Bereit',
DISC_DETECTED: 'Medium erkannt',
ANALYZING: 'Analyse',
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: 'CD-Analyse',
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
CD_READY_TO_RIP: 'CD bereit (Rip/Encode)',
CD_RIPPING: 'CD-Rippen',
CD_ENCODING: 'CD-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',
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 === '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_SELECTION'), value: 'METADATA_SELECTION' }
];
+56
View File
@@ -0,0 +1,56 @@
import { readFileSync } from 'node:fs';
import { 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)
};
}
export default defineConfig({
plugins: [react()],
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
}
});