0.13.1-4 release snapshot
This commit is contained in:
@@ -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
|
||||
@@ -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>
|
||||
Generated
+1713
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||
<strong>
|
||||
{jobUnassignTarget?.jobTitle || (jobUnassignTarget?.jobId ? `#${jobUnassignTarget.jobId}` : '-')}
|
||||
</strong>
|
||||
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 (0–12)</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);
|
||||
});
|
||||
}
|
||||
@@ -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 “Neuer Cronjob”, 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 & 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;
|
||||
}
|
||||
@@ -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 (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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
opus: {
|
||||
fields: [
|
||||
{
|
||||
key: 'opusBitrate',
|
||||
label: 'Bitrate (kbps)',
|
||||
description: 'Empfohlen: 96–192 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: 5–7.',
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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' }
|
||||
];
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user