0.12.0-15 Fix DVD Review

This commit is contained in:
2026-03-24 07:05:45 +00:00
parent 3e3f3231be
commit 76b020d5c0
30 changed files with 5148 additions and 44 deletions
+3
View File
@@ -11,6 +11,7 @@ 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';
function normalizeJobId(value) {
const parsed = Number(value);
@@ -524,6 +525,7 @@ function App() {
const nav = [
{ label: 'Dashboard', path: '/' },
{ label: 'Converter', path: '/converter' },
{ label: 'Settings', path: '/settings' },
{ label: 'Historie', path: '/history' },
{ label: 'Downloads', path: '/downloads' },
@@ -653,6 +655,7 @@ function App() {
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} />
<Route path="converter" element={<ConverterPage />} />
</Route>
</Routes>
</main>
+63
View File
@@ -895,6 +895,69 @@ export const api = {
method: 'POST',
body: JSON.stringify({ cronExpression })
});
},
// ── Converter ──────────────────────────────────────────────────────────────
converterGetTree() {
return request('/converter/tree');
},
converterBrowse(parent = null) {
const q = parent ? `?parent=${encodeURIComponent(parent)}` : '';
return request(`/converter/browse${q}`);
},
converterScan() {
return request('/converter/scan', { method: 'POST' });
},
converterCreateJobs(entries) {
return request('/converter/create-jobs', {
method: 'POST',
body: JSON.stringify({ entries })
});
},
converterCreateJobsFromSelection(relPaths, audioMode = 'individual') {
return request('/converter/jobs/from-selection', {
method: 'POST',
body: JSON.stringify({ relPaths, audioMode })
});
},
converterUploadFiles(formData) {
return request('/converter/upload', { method: 'POST', body: formData });
},
getConverterJobs() {
return request('/converter/jobs');
},
startConverterJob(jobId, config = {}) {
afterMutationInvalidate(['/converter/jobs']);
return request(`/converter/jobs/${encodeURIComponent(jobId)}/start`, {
method: 'POST',
body: JSON.stringify(config)
});
},
cancelConverterJob(jobId) {
afterMutationInvalidate(['/converter/jobs']);
return request(`/converter/jobs/${encodeURIComponent(jobId)}/cancel`, {
method: 'POST'
});
},
deleteConverterJob(jobId) {
afterMutationInvalidate(['/converter/jobs']);
return request(`/converter/jobs/${encodeURIComponent(jobId)}`, {
method: 'DELETE'
});
},
// ── Converter Datei-Operationen ──────────────────────────────────────────
converterDeleteFile(relPath) {
return request('/converter/files', { method: 'DELETE', body: JSON.stringify({ relPath }) });
},
converterRenameFile(relPath, newName) {
return request('/converter/files/rename', { method: 'POST', body: JSON.stringify({ relPath, newName }) });
},
converterMoveFile(relPath, targetParentRelPath) {
return request('/converter/files/move', { method: 'POST', body: JSON.stringify({ relPath, targetParentRelPath }) });
},
converterCreateFolder(parentRelPath, name) {
return request('/converter/files/folder', { method: 'POST', body: JSON.stringify({ parentRelPath, name }) });
}
};
@@ -0,0 +1,725 @@
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 */
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;
}
}
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;
}
/** 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, navigateToPath }) {
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
const [selectedPaths, setSelectedPaths] = useState([]);
// 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);
// Stabile Ref für onSelectionChange
const onSelectionChangeRef = useRef(onSelectionChange);
useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]);
// ── 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]);
// 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: nur Wurzel-Selektionen (keine redundanten Kind-Pfade)
useEffect(() => {
if (!tree) return;
const rootPaths = getRootSelections(selectedPaths);
const report = rootPaths.map((p) => {
const node = getNodeByPath(tree, p);
if (!node) return null;
return {
relPath: node.path,
entryType: node.type === 'folder' ? 'directory' : 'file',
detectedMediaType: node.detectedMediaType || null,
detectedFormat: node.detectedFormat || null
};
}).filter(Boolean);
onSelectionChangeRef.current?.(report);
}, [selectedPaths, tree]);
// Outside-Click → Auswahl aufheben
useEffect(() => {
function handleOutside(event) {
if (activeModal) return;
if (!explorerRef.current) return;
if (!explorerRef.current.contains(event.target)) {
setSelectedPaths([]);
}
}
window.addEventListener('mousedown', handleOutside);
return () => window.removeEventListener('mousedown', handleOutside);
}, [activeModal]);
// ── 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 || '');
setSelectedPaths([]);
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 === 'folder') {
const folderNode = getNodeByPath(tree, p);
const descendantFiles = collectDescendantFilePaths(folderNode);
if (checked) {
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;
});
}
}
}
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 (!selectedPaths.length) return;
const name = renameName.trim();
if (!name) return;
setBusy(true);
try {
await api.converterRenameFile(rootSelected[0], name);
toastRef.current?.show({ severity: 'success', summary: 'Umbenannt', detail: `${name}`, life: 2500 });
setRenameName('');
setActiveModal('');
setSelectedPaths([]);
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
} finally { setBusy(false); }
}
async function handleDeleteSelected() {
if (!rootSelected.length) return;
setBusy(true);
try {
for (const p of rootSelected) {
await api.converterDeleteFile(p);
}
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${rootSelected.length} Eintrag/Einträge`, life: 2500 });
setSelectedPaths([]);
setActiveModal('');
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
} finally { setBusy(false); }
}
async function handleMoveSelected() {
if (!selectedPaths.length) return;
setBusy(true);
try {
const isRoot = moveTarget === '' || moveTarget === '__root__';
await api.converterMoveFile(rootSelected[0], isRoot ? '' : moveTarget);
toastRef.current?.show({ severity: 'success', summary: 'Verschoben', life: 2500 });
setMoveTarget('');
setSelectedPaths([]);
setActiveModal('');
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
} finally { setBusy(false); }
}
// ── 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;
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-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);
const canRename = rootSelected.length === 1;
const canDelete = selectedPaths.length > 0;
const canMove = rootSelected.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>
<button
type="button"
className="icon-button"
onClick={() => {
const node = getNodeByPath(tree, rootSelected[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>
</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 sel = isSelected(item.path);
const indet = isIndeterminate(item);
return (
<div
key={item.path}
className={`explorer-row${sel ? ' selected' : ''}`}
onClick={() => {
if (item.type === 'folder') {
handleOpen(item);
} else {
handleCheckboxChange(item, !sel);
}
}}
role="row"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }}
>
<span className="row-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={sel}
ref={(el) => { if (el) el.indeterminate = indet; }}
onChange={(e) => handleCheckboxChange(item, e.target.checked)}
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>
</div>
);
})}
{/* Footer: Auswahl-Anzahl */}
{selectedPaths.length > 0 && (
<div className="explorer-row footer">
<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 ─────────────────────────────────────────────────────────── */}
{/* 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>
);
}
@@ -0,0 +1,440 @@
import { useEffect, useState } from 'react';
import { Button } from 'primereact/button';
import { Dropdown } from 'primereact/dropdown';
import { InputNumber } from 'primereact/inputnumber';
import { SelectButton } from 'primereact/selectbutton';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
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: 'AAC', value: 'aac' },
{ label: 'Opus', value: 'opus' },
{ label: 'OGG', value: 'ogg' },
{ label: 'WAV', value: 'wav' }
];
const MP3_MODES = [
{ label: 'CBR', value: 'cbr' },
{ label: 'VBR', value: 'vbr' }
];
function getStatusMeta(status) {
const s = String(status || '').toUpperCase();
const map = {
READY_TO_START: { label: 'Bereit', severity: 'warning', icon: 'pi-clock' },
ANALYZING: { label: 'Analysiere', severity: 'info', icon: 'pi-spin pi-spinner' },
RIPPING: { label: 'Rippen', severity: 'info', icon: 'pi-spin pi-spinner' },
ENCODING: { label: 'Encoding', severity: 'info', icon: 'pi-spin pi-spinner' },
MEDIAINFO_CHECK:{ label: 'MediaInfo', severity: 'info', icon: 'pi-spin pi-spinner' },
DONE: { label: 'Fertig', severity: 'success', icon: 'pi-check' },
ERROR: { label: 'Fehler', severity: 'danger', icon: 'pi-exclamation-triangle' },
CANCELLED: { label: 'Abgebrochen', severity: 'secondary', icon: 'pi-times' }
};
return map[s] || { label: s, severity: 'secondary', icon: 'pi-question' };
}
function mediaTypeBadge(type) {
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} style={{ fontSize: 11 }} />;
}
function isActiveStatus(status) {
return ['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK'].includes(String(status || '').toUpperCase());
}
function isTerminal(status) {
return ['DONE', 'ERROR', 'CANCELLED'].includes(String(status || '').toUpperCase());
}
/**
* Inline-Konfigurationsbereich für READY_TO_START Jobs.
* Zeigt alle nötigen Optionen direkt in der Karte an.
*/
function InlineConfig({ job, plan, onStarted }) {
const converterMediaType = plan?.converterMediaType || 'video';
const isVideo = converterMediaType === 'video' || converterMediaType === 'iso';
const isAudio = converterMediaType === 'audio';
const [outputFormat, setOutputFormat] = useState(isAudio ? 'flac' : '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,
aacBitrate: 256,
opusBitrate: 160,
oggQuality: 6
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// Presets laden
useEffect(() => {
if (!isVideo) return;
api.getUserPresets?.('video')
.then((d) => setUserPresets(Array.isArray(d?.presets) ? d.presets : []))
.catch(() => setUserPresets([]));
api.getHandBrakePresets?.()
.then((d) => setHbPresets(Array.isArray(d?.presets) ? d.presets : []))
.catch(() => setHbPresets([]));
}, [isVideo]);
const handleStart = async () => {
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: isAudio ? audioFormatOptions : undefined
});
onStarted?.(job.id);
} catch (err) {
setError(err.message || 'Fehler beim Starten.');
setBusy(false);
}
};
const outputFormats = isVideo ? VIDEO_OUTPUT_FORMATS : AUDIO_OUTPUT_FORMATS;
return (
<div className="cjc-config">
{error && (
<Message severity="error" text={error} style={{ marginBottom: 10, width: '100%' }} />
)}
<div className="cjc-config-row">
{/* Medientyp (read-only) */}
<div className="cjc-config-field">
<label className="cjc-config-label">Medientyp</label>
<div className="cjc-config-value">
{mediaTypeBadge(converterMediaType)}
{converterMediaType === 'iso' && (
<small style={{ marginLeft: 6, color: 'var(--text-color-secondary)' }}>MakeMKV HandBrake</small>
)}
</div>
</div>
{/* Ausgabeformat */}
<div className="cjc-config-field">
<label className="cjc-config-label">Ausgabeformat</label>
<Dropdown
value={outputFormat}
options={outputFormats}
onChange={(e) => setOutputFormat(e.value)}
style={{ width: '100%' }}
/>
</div>
</div>
{/* Video: Presets */}
{isVideo && (
<div className="cjc-config-row">
<div className="cjc-config-field">
<label className="cjc-config-label">User-Preset</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%' }}
/>
</div>
{!selectedUserPreset && (
<div className="cjc-config-field">
<label className="cjc-config-label">HandBrake-Preset</label>
<Dropdown
value={selectedHbPreset}
options={[
{ label: '— Kein Preset (Standard) —', value: '' },
...hbPresets.map((p) => ({ label: p.category ? `[${p.category}] ${p.name}` : p.name, value: p.name }))
]}
onChange={(e) => setSelectedHbPreset(e.value)}
filter
placeholder="Preset auswählen …"
style={{ width: '100%' }}
/>
</div>
)}
</div>
)}
{/* Audio: Format-Optionen */}
{isAudio && (
<div className="cjc-config-row">
{outputFormat === 'flac' && (
<div className="cjc-config-field">
<label className="cjc-config-label">Komprimierung (012)</label>
<InputNumber
value={audioFormatOptions.flacCompression}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, flacCompression: e.value ?? 5 }))}
min={0} max={12} step={1}
style={{ width: '100%' }}
/>
</div>
)}
{outputFormat === 'mp3' && (
<>
<div className="cjc-config-field">
<label className="cjc-config-label">Modus</label>
<SelectButton
value={audioFormatOptions.mp3Mode}
options={MP3_MODES}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Mode: e.value || 'cbr' }))}
/>
</div>
<div className="cjc-config-field">
{audioFormatOptions.mp3Mode === 'cbr' ? (
<>
<label className="cjc-config-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%' }}
/>
</>
) : (
<>
<label className="cjc-config-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%' }}
/>
</>
)}
</div>
</>
)}
{outputFormat === 'aac' && (
<div className="cjc-config-field">
<label className="cjc-config-label">Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.aacBitrate}
options={[128, 160, 192, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, aacBitrate: e.value }))}
style={{ width: '100%' }}
/>
</div>
)}
{outputFormat === 'opus' && (
<div className="cjc-config-field">
<label className="cjc-config-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%' }}
/>
</div>
)}
{outputFormat === 'ogg' && (
<div className="cjc-config-field">
<label className="cjc-config-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%' }}
/>
</div>
)}
</div>
)}
<div className="cjc-config-actions">
<Button
label="Encoding starten"
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-play'}
disabled={busy}
onClick={handleStart}
/>
</div>
</div>
);
}
/**
* Status-Karte für einen einzelnen Converter-Job.
* Zeigt Konfiguration inline an wenn der Job READY_TO_START ist.
*/
export default function ConverterJobCard({
job,
jobProgress,
onStarted,
onDeleted,
onCancelled
}) {
const [cancelBusy, setCancelBusy] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
const plan = (() => {
try { return JSON.parse(job.encode_plan_json || '{}'); } catch (_e) { return {}; }
})();
const title = job.title || job.detected_title || 'Converter Job';
const status = job.status || 'UNKNOWN';
const statusMeta = getStatusMeta(status);
const converterMediaType = plan?.converterMediaType;
const active = isActiveStatus(status);
const terminal = isTerminal(status);
const isReady = String(status).toUpperCase() === 'READY_TO_START';
const liveProgress = jobProgress?.progress ?? null;
const liveEta = jobProgress?.eta || null;
const liveStatusText = jobProgress?.statusText || null;
const progressValue = liveProgress !== null ? Math.round(liveProgress) : null;
const handleCancel = async () => {
setCancelBusy(true);
try {
await api.cancelConverterJob(job.id);
onCancelled?.(job.id);
} catch (err) {
console.error('Cancel error:', err);
} finally {
setCancelBusy(false);
}
};
const handleDelete = async () => {
if (!window.confirm(`Job "${title}" wirklich löschen?`)) return;
setDeleteBusy(true);
try {
await api.deleteConverterJob(job.id);
onDeleted?.(job.id);
} catch (err) {
console.error('Delete error:', err);
} finally {
setDeleteBusy(false);
}
};
return (
<div className={`converter-job-card status-${status.toLowerCase()}${isReady ? ' is-ready' : ''}`}>
{/* Header */}
<div className="converter-job-card-header">
<div className="converter-job-card-title-row">
<span className="converter-job-card-title" title={title}>{title}</span>
{converterMediaType && mediaTypeBadge(converterMediaType)}
<Tag
value={statusMeta.label}
severity={statusMeta.severity}
icon={`pi ${statusMeta.icon}`}
/>
</div>
{!isReady && plan?.outputFormat && (
<div className="converter-job-card-meta">
<small>Format: <strong>{String(plan.outputFormat).toUpperCase()}</strong></small>
{plan?.inputPath && (
<small title={plan.inputPath} style={{ marginLeft: 8 }}>
{plan.inputPath.split('/').pop()}
</small>
)}
</div>
)}
{isReady && plan?.inputPath && (
<div className="converter-job-card-meta">
<small title={plan.inputPath}> {plan.inputPath.split('/').pop()}</small>
</div>
)}
</div>
{/* Inline-Konfiguration für READY_TO_START */}
{isReady && (
<InlineConfig job={job} plan={plan} onStarted={onStarted} />
)}
{/* Fortschrittsbalken */}
{active && (
<div className="converter-job-card-progress">
{progressValue !== null ? (
<ProgressBar value={progressValue} style={{ height: 6 }} displayValueTemplate={() => `${progressValue}%`} />
) : (
<ProgressBar mode="indeterminate" style={{ height: 6 }} />
)}
{(liveStatusText || liveEta) && (
<div className="converter-job-card-progress-meta">
{liveStatusText && <small>{liveStatusText}</small>}
{liveEta && <small>ETA: {liveEta}</small>}
</div>
)}
</div>
)}
{/* Fehler */}
{status === 'ERROR' && job.error_message && (
<div className="converter-job-card-error">
<small>{job.error_message}</small>
</div>
)}
{/* Ausgabe-Pfad */}
{terminal && job.output_path && (
<div className="converter-job-card-output">
<small title={job.output_path}> {job.output_path.split('/').slice(-2).join('/')}</small>
</div>
)}
{/* Aktions-Buttons */}
<div className="converter-job-card-actions">
{active && (
<Button
label="Abbrechen"
icon={cancelBusy ? 'pi pi-spin pi-spinner' : 'pi pi-times'}
size="small"
severity="warning"
outlined
disabled={cancelBusy}
onClick={handleCancel}
/>
)}
{terminal && (
<Button
label="Löschen"
icon={deleteBusy ? 'pi pi-spin pi-spinner' : 'pi pi-trash'}
size="small"
severity="danger"
outlined
disabled={deleteBusy}
onClick={handleDelete}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,308 @@
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: 'AAC', value: 'aac' },
{ 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,
aacBitrate: 256,
opusBitrate: 160,
oggQuality: 6
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// Beim Öffnen: Medientyp und Format aus dem Job-Plan lesen
useEffect(() => {
if (!job || !visible) return;
let plan = null;
try {
plan = job.encode_plan_json ? JSON.parse(job.encode_plan_json) : null;
} catch (_err) { /* ignore */ }
const mediaType = plan?.converterMediaType || 'video';
setConverterMediaType(mediaType);
setOutputFormat(mediaType === 'audio' ? 'flac' : 'mkv');
setSelectedUserPreset(null);
setSelectedHbPreset('');
setError(null);
}, [job, visible]);
// User-Presets laden
useEffect(() => {
if (!visible) return;
api.getUserPresets?.('video')
.then((data) => setUserPresets(Array.isArray(data?.presets) ? data.presets : []))
.catch(() => setUserPresets([]));
}, [visible]);
// HandBrake Built-in Presets laden
useEffect(() => {
if (!visible || converterMediaType !== 'video') return;
api.getHandBrakePresets?.()
.then((data) => {
const list = Array.isArray(data?.presets) ? data.presets : [];
setHbPresets(list);
})
.catch(() => setHbPresets([]));
}, [visible, converterMediaType]);
const handleStart = async () => {
if (!job) return;
setBusy(true);
setError(null);
try {
let userPreset = null;
if (selectedUserPreset) {
const found = userPresets.find((p) => p.id === selectedUserPreset);
if (found) {
userPreset = {
id: found.id,
handbrakePreset: found.handbrake_preset || null,
extraArgs: found.extra_args || ''
};
}
} else if (selectedHbPreset) {
userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' };
}
await api.startConverterJob(job.id, {
converterMediaType,
outputFormat,
userPreset,
audioFormatOptions: converterMediaType === 'audio' ? audioFormatOptions : undefined
});
onStarted?.(job.id);
onHide?.();
} catch (err) {
setError(err.message || 'Fehler beim Starten.');
} finally {
setBusy(false);
}
};
const isVideo = converterMediaType === 'video' || converterMediaType === 'iso';
const isAudio = converterMediaType === 'audio';
const outputFormats = isVideo ? VIDEO_OUTPUT_FORMATS : AUDIO_OUTPUT_FORMATS;
const footer = (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
<Button
label="Encoding starten"
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-play'}
disabled={busy}
onClick={handleStart}
/>
</div>
);
return (
<Dialog
header={`Converter konfigurieren — ${job?.title || job?.detected_title || 'Job'}`}
visible={visible}
onHide={onHide}
footer={footer}
style={{ width: '520px' }}
modal
>
{error && (
<Message severity="error" text={error} style={{ marginBottom: 16, width: '100%' }} />
)}
{/* Medientyp anzeigen (read-only) */}
<div className="field">
<label>Medientyp</label>
<div style={{ marginTop: 4 }}>
<strong>{converterMediaType === 'iso' ? 'ISO (Video)' : converterMediaType === 'video' ? 'Video' : 'Audio'}</strong>
{converterMediaType === 'iso' && (
<small style={{ marginLeft: 8, color: '#888' }}> MakeMKV Rip, dann HandBrake</small>
)}
</div>
</div>
{/* Ausgabeformat */}
<div className="field" style={{ marginTop: 12 }}>
<label>Ausgabeformat</label>
<Dropdown
value={outputFormat}
options={outputFormats}
onChange={(e) => setOutputFormat(e.value)}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
{/* Video: Preset-Auswahl */}
{isVideo && (
<>
<Divider />
<div className="field">
<label>User-Preset (optional)</label>
<Dropdown
value={selectedUserPreset}
options={[
{ label: '— Kein User-Preset —', value: null },
...userPresets.map((p) => ({ label: p.name, value: p.id }))
]}
onChange={(e) => {
setSelectedUserPreset(e.value);
if (e.value) setSelectedHbPreset('');
}}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
{!selectedUserPreset && (
<div className="field" style={{ marginTop: 12 }}>
<label>HandBrake-Preset (optional)</label>
<Dropdown
value={selectedHbPreset}
options={[
{ label: '— Kein Preset (HB-Standard) —', value: '' },
...hbPresets.map((p) => ({
label: p.category ? `[${p.category}] ${p.name}` : p.name,
value: p.name
}))
]}
onChange={(e) => setSelectedHbPreset(e.value)}
filter
style={{ width: '100%', marginTop: 4 }}
placeholder="Preset auswählen …"
/>
</div>
)}
</>
)}
{/* Audio: Format-Optionen */}
{isAudio && (
<>
<Divider />
{outputFormat === 'flac' && (
<div className="field">
<label>Komprimierung (012)</label>
<InputNumber
value={audioFormatOptions.flacCompression}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, flacCompression: e.value ?? 5 }))}
min={0} max={12} step={1}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
{outputFormat === 'mp3' && (
<>
<div className="field">
<label>Modus</label>
<SelectButton
value={audioFormatOptions.mp3Mode}
options={MP3_MODES}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Mode: e.value || 'cbr' }))}
style={{ marginTop: 4 }}
/>
</div>
{audioFormatOptions.mp3Mode === 'cbr' ? (
<div className="field" style={{ marginTop: 12 }}>
<label>Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.mp3Bitrate}
options={[128, 160, 192, 224, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Bitrate: e.value }))}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
) : (
<div className="field" style={{ marginTop: 12 }}>
<label>VBR Qualität (0=beste, 9=schlechteste)</label>
<InputNumber
value={audioFormatOptions.mp3Quality}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Quality: e.value ?? 4 }))}
min={0} max={9} step={1}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
</>
)}
{outputFormat === 'aac' && (
<div className="field">
<label>Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.aacBitrate}
options={[128, 160, 192, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, aacBitrate: e.value }))}
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,224 @@
import { useCallback, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
const ACCEPTED_EXTENSIONS = [
'.mkv', '.mp4', '.m2ts', '.avi', '.mov', '.m4v', '.wmv', '.ts',
'.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape',
'.iso'
].join(',');
/**
* Upload-Panel für den Converter.
* Unterstützt Mehrfach-Dateien und Ordner-Upload (webkitdirectory).
*/
export default function ConverterUploadPanel({ onUploaded }) {
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 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 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]);
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: MKV, MP4, ISO, AVI, FLAC, MP3, WAV, AAC, OGG, OPUS </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={ACCEPTED_EXTENSIONS}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<input
ref={dirInputRef}
type="file"
webkitdirectory="true"
multiple
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);
});
}
@@ -351,6 +351,7 @@ const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'output_chapter_template_audiobook', 'audiobook_raw_template'];
const DOWNLOAD_PATH_KEYS = ['download_dir'];
const LOG_PATH_KEYS = ['log_dir'];
const CONVERTER_PATH_KEYS = ['converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio'];
function buildSectionsForCategory(categoryName, settings) {
const list = Array.isArray(settings) ? settings : [];
@@ -539,6 +540,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const audiobookSettings = list.filter((s) => AUDIOBOOK_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && AUDIOBOOK_PATH_KEYS.includes(s.key.replace('_owner', ''))));
const downloadSettings = list.filter((s) => DOWNLOAD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DOWNLOAD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
const converterSettings = list.filter((s) => CONVERTER_PATH_KEYS.includes(s.key));
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
@@ -547,6 +549,10 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
const defaultDownloads = effectivePaths?.defaults?.downloads || 'data/downloads';
const defaultConverterRaw = effectivePaths?.defaults?.converterRaw || 'data/output/converter-raw';
const defaultConverterMovies = effectivePaths?.defaults?.converterMovies || 'data/output/converted-movies';
const defaultConverterAudio = effectivePaths?.defaults?.converterAudio || 'data/output/converted-audio';
const ep = effectivePaths || {};
const blurayRaw = ep.bluray?.raw || defaultRaw;
const blurayMovies = ep.bluray?.movies || defaultMovies;
@@ -557,6 +563,9 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
const audiobookMovies = ep.audiobook?.movies || defaultAudiobookMovies;
const downloadPath = ep.downloads?.path || defaultDownloads;
const converterRaw = ep.converter?.raw || defaultConverterRaw;
const converterMovies = ep.converter?.movies || defaultConverterMovies;
const converterAudio = ep.converter?.audio || defaultConverterAudio;
const isDefault = (path, def) => path === def;
@@ -572,8 +581,8 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
<thead>
<tr>
<th>Medium</th>
<th>RAW-Ordner</th>
<th>Encode-Ordner</th>
<th>RAW / Eingang</th>
<th>Encode / Ausgang</th>
</tr>
</thead>
<tbody>
@@ -627,8 +636,30 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
<code>{downloadPath}</code>
{isDefault(downloadPath, defaultDownloads) && <span className="path-default-badge">Standard</span>}
</td>
<td>---</td>
</tr>
<tr>
<td><strong>Converter Raw</strong></td>
<td>
---
<code>{converterRaw}</code>
{isDefault(converterRaw, defaultConverterRaw) && <span className="path-default-badge">Standard</span>}
</td>
<td>---</td>
</tr>
<tr>
<td><strong>Converter Video</strong></td>
<td>---</td>
<td>
<code>{converterMovies}</code>
{isDefault(converterMovies, defaultConverterMovies) && <span className="path-default-badge">Standard</span>}
</td>
</tr>
<tr>
<td><strong>Converter Audio</strong></td>
<td>---</td>
<td>
<code>{converterAudio}</code>
{isDefault(converterAudio, defaultConverterAudio) && <span className="path-default-badge">Standard</span>}
</td>
</tr>
</tbody>
@@ -642,6 +673,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
{ title: 'DVD', pathSettings: dvdSettings },
{ title: 'CD / Audio', pathSettings: cdSettings },
{ title: 'Audiobook', pathSettings: audiobookSettings },
{ title: 'Converter', pathSettings: converterSettings },
{ title: 'Downloads', pathSettings: downloadSettings },
...(logSettings.length > 0 ? [{ title: 'Logs', pathSettings: logSettings }] : [])
]
+377
View File
@@ -0,0 +1,377 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Card } from 'primereact/card';
import { Toast } from 'primereact/toast';
import { Divider } from 'primereact/divider';
import { Badge } from 'primereact/badge';
import { Dialog } from 'primereact/dialog';
import { RadioButton } from 'primereact/radiobutton';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import ConverterFileExplorer from '../components/ConverterFileExplorer';
import ConverterUploadPanel from '../components/ConverterUploadPanel';
import ConverterJobCard from '../components/ConverterJobCard';
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
function isAudioEntry(e) {
const p = (e.relPath || '').toLowerCase();
const dot = p.lastIndexOf('.');
if (dot === -1) return false;
return AUDIO_EXTS.has(p.slice(dot));
}
function normalizeJobId(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
}
export default function ConverterPage() {
const toastRef = useRef(null);
const [jobs, setJobs] = useState([]);
const [jobProgress, setJobProgress] = useState({});
const [loadingJobs, setLoadingJobs] = useState(false);
const [explorerRefreshToken, setExplorerRefreshToken] = useState(0);
const [explorerNavigateTo, setExplorerNavigateTo] = useState(null);
const [selectedEntries, setSelectedEntries] = useState([]);
const [jobModeVisible, setJobModeVisible] = useState(false);
const [audioMode, setAudioMode] = useState('individual');
const [creatingJobs, setCreatingJobs] = useState(false);
const loadJobs = useCallback(async () => {
setLoadingJobs(true);
try {
const data = await api.getConverterJobs();
setJobs(Array.isArray(data?.jobs) ? data.jobs : []);
} catch (err) {
console.error('Load converter jobs error:', err);
} finally {
setLoadingJobs(false);
}
}, []);
useEffect(() => {
loadJobs();
const interval = setInterval(loadJobs, 5000);
return () => clearInterval(interval);
}, [loadJobs]);
useWebSocket((message) => {
if (!message?.type || !message?.payload) return;
if (message.type === 'PIPELINE_PROGRESS') {
const payload = message.payload;
const jobId = normalizeJobId(payload?.activeJobId);
if (jobId) {
setJobProgress((prev) => ({
...prev,
[jobId]: {
progress: payload?.progress ?? null,
eta: payload?.eta ?? null,
statusText: payload?.statusText ?? null,
state: payload?.state ?? null
}
}));
}
}
if (message.type === 'PIPELINE_UPDATE' || message.type === 'CONVERTER_SCAN_UPDATE') {
loadJobs();
if (message.type === 'CONVERTER_SCAN_UPDATE') {
setExplorerRefreshToken((t) => t + 1);
}
}
});
const handleSelectionChange = (entries) => setSelectedEntries(entries || []);
const handleOpenJobModal = () => {
const hasAudio = selectedEntries.some(isAudioEntry);
if (!hasAudio) {
// Keine Audio-Dateien (nur Videos/Ordner) → kein Modal, direkt Einzeljobs
handleCreateJobsFromSelection('individual');
return;
}
setAudioMode('individual');
setJobModeVisible(true);
};
const handleCreateJobsFromSelection = async (explicitMode) => {
if (selectedEntries.length === 0) return;
setCreatingJobs(true);
setJobModeVisible(false);
try {
const relPaths = selectedEntries.map((e) => e.relPath).filter(Boolean);
const result = await api.converterCreateJobsFromSelection(relPaths, explicitMode ?? audioMode);
const newJobs = result?.jobs || [];
toastRef.current?.show({
severity: 'success',
summary: 'Jobs erstellt',
detail: `${newJobs.length} Converter-Job${newJobs.length !== 1 ? 's' : ''} erstellt.`,
life: 3500
});
setSelectedEntries([]);
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
} catch (err) {
toastRef.current?.show({
severity: 'error',
summary: 'Fehler',
detail: err.message || 'Jobs konnten nicht erstellt werden.',
life: 4500
});
} finally {
setCreatingJobs(false);
}
};
const handleUploaded = (folders) => {
setExplorerRefreshToken((t) => t + 1);
if (folders?.length > 0) {
// Explorer in den ersten hochgeladenen Ordner navigieren
setExplorerNavigateTo({ path: folders[0].folderRelPath, ts: Date.now() });
toastRef.current?.show({
severity: 'success',
summary: 'Upload abgeschlossen',
detail: `${folders.length} Ordner hochgeladen. Dateien auswählen und Jobs anlegen.`,
life: 4000
});
}
};
const handleJobStarted = async (jobId) => {
await loadJobs();
toastRef.current?.show({
severity: 'success',
summary: 'Gestartet',
detail: `Converter-Job ${jobId} läuft.`,
life: 3000
});
};
const handleJobDeleted = (jobId) => {
setJobs((prev) => prev.filter((j) => j.id !== jobId));
setJobProgress((prev) => {
const next = { ...prev };
delete next[jobId];
return next;
});
};
const handleJobCancelled = () => setTimeout(loadJobs, 1000);
const activeJobs = jobs.filter((j) => !['DONE', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
const finishedJobs = jobs.filter((j) => ['DONE', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
const jobsCardHeader = (
<div className="converter-card-header">
<div className="converter-card-title">
<span>Converter Jobs</span>
{activeJobs.length > 0 && (
<Badge value={activeJobs.length} severity="info" />
)}
</div>
<Button
icon={loadingJobs ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
text
rounded
size="small"
onClick={loadJobs}
disabled={loadingJobs}
aria-label="Jobs neu laden"
/>
</div>
);
return (
<div className="dashboard-subpage-content">
<Toast ref={toastRef} position="top-right" />
{/* Import-Ordner */}
<Card title="Import-Ordner" subTitle="Dateien aus dem Raw-Ordner auswählen und als Job anlegen">
<ConverterFileExplorer
onSelectionChange={handleSelectionChange}
refreshToken={explorerRefreshToken}
navigateToPath={explorerNavigateTo}
/>
{selectedEntries.length > 0 && (
<div className="converter-selection-bar">
<span>
<Badge value={selectedEntries.length} severity="info" /> ausgewählt
</span>
<Button
label="Job(s) anlegen"
icon={creatingJobs ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
disabled={creatingJobs}
onClick={handleOpenJobModal}
/>
</div>
)}
</Card>
{/* Jobs */}
<Card header={jobsCardHeader}>
{jobs.length === 0 ? (
<p className="converter-jobs-empty-hint">
<small>Keine Jobs vorhanden. Dateien im Import-Ordner auswählen oder per Upload hochladen.</small>
</p>
) : (
<>
{activeJobs.length > 0 && (
<>
<p className="converter-jobs-group-label">Aktiv</p>
<div className="converter-jobs-list">
{activeJobs.map((job) => (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={jobProgress[job.id] || null}
onStarted={handleJobStarted}
onDeleted={handleJobDeleted}
onCancelled={handleJobCancelled}
/>
))}
</div>
</>
)}
{activeJobs.length > 0 && finishedJobs.length > 0 && <Divider />}
{finishedJobs.length > 0 && (
<>
<p className="converter-jobs-group-label">Abgeschlossen</p>
<div className="converter-jobs-list">
{finishedJobs.slice(0, 20).map((job) => (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={null}
onDeleted={handleJobDeleted}
/>
))}
</div>
</>
)}
</>
)}
</Card>
{/* Upload */}
<Card title="Datei-Upload" subTitle="Einzelne Dateien oder ganze Ordner (Album) hochladen">
<ConverterUploadPanel onUploaded={handleUploaded} />
</Card>
<JobModeDialog
visible={jobModeVisible}
entries={selectedEntries}
audioMode={audioMode}
onAudioModeChange={setAudioMode}
busy={creatingJobs}
onConfirm={handleCreateJobsFromSelection}
onHide={() => setJobModeVisible(false)}
/>
</div>
);
}
// ── Job-Modus-Dialog ──────────────────────────────────────────────────────────
function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, onConfirm, onHide }) {
const audioEntries = entries.filter(isAudioEntry);
const nonAudioEntries = entries.filter((e) => !isAudioEntry(e));
const hasAudio = audioEntries.length > 0;
const hasNonAudio = nonAudioEntries.length > 0;
const onlyAudio = hasAudio && !hasNonAudio;
const mixed = hasAudio && hasNonAudio;
// Anzahl der Jobs die entstehen werden
const audioJobCount = audioMode === 'shared' ? 1 : audioEntries.length;
const totalJobs = nonAudioEntries.length + audioJobCount;
const footer = (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
<Button
label={`${totalJobs} Job${totalJobs !== 1 ? 's' : ''} anlegen`}
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
disabled={busy}
onClick={onConfirm}
/>
</div>
);
return (
<Dialog
header="Jobs anlegen"
visible={visible}
onHide={onHide}
footer={footer}
style={{ width: '440px' }}
modal
>
{/* Nur Videos / Ordner gewählt */}
{!hasAudio && (
<p style={{ margin: 0, lineHeight: 1.6 }}>
Für jede ausgewählte Datei / jeden Ordner wird ein eigener Job angelegt.
<br />
<strong>{nonAudioEntries.length} Job{nonAudioEntries.length !== 1 ? 's' : ''}</strong> werden erstellt.
</p>
)}
{/* Nur Audio gewählt */}
{onlyAudio && (
<div>
<p style={{ marginTop: 0, marginBottom: 12 }}>
Wie sollen die <strong>{audioEntries.length} Audiodatei{audioEntries.length !== 1 ? 'en' : ''}</strong> verarbeitet werden?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="individual"
checked={audioMode === 'individual'}
onChange={() => onAudioModeChange('individual')}
/>
<span>Für jede Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="shared"
checked={audioMode === 'shared'}
onChange={() => onAudioModeChange('shared')}
/>
<span>Ein gemeinsamer Job für alle Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
</label>
</div>
</div>
)}
{/* Gemischte Auswahl */}
{mixed && (
<div>
<p style={{ marginTop: 0, marginBottom: 8 }}>
<strong>Videos/Ordner ({nonAudioEntries.length}):</strong> Für jede Datei ein eigener Job.
</p>
<Divider style={{ margin: '8px 0' }} />
<p style={{ marginBottom: 10 }}>
<strong>Audiodateien ({audioEntries.length}):</strong> Wie verarbeiten?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="individual"
checked={audioMode === 'individual'}
onChange={() => onAudioModeChange('individual')}
/>
<span>Für jede Audio-Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="shared"
checked={audioMode === 'shared'}
onChange={() => onAudioModeChange('shared')}
/>
<span>Ein gemeinsamer Job für alle Audio-Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
</label>
</div>
</div>
)}
</Dialog>
);
}
+6
View File
@@ -865,6 +865,12 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
? {
...computedContext,
...liveContext,
// Prefer the DB plan when it has been confirmed but the live (jobProgress) plan hasn't —
// this happens when confirmEncodeReview is called with skipPipelineStateUpdate=true
// (always the case for queued jobs), leaving jobProgress with the pre-confirmation plan.
mediaInfoReview: (computedContext.mediaInfoReview?.reviewConfirmedAt && !liveContext.mediaInfoReview?.reviewConfirmedAt)
? computedContext.mediaInfoReview
: (liveContext.mediaInfoReview || computedContext.mediaInfoReview),
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
? liveContext.tracks
: computedContext.tracks,
+16 -5
View File
@@ -88,6 +88,9 @@ function resolveMediaType(row) {
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
if (raw === 'converter') {
return 'converter';
}
}
const statusCandidates = [
row?.status,
@@ -151,6 +154,14 @@ function resolveMediaTypeMeta(row) {
alt: 'Audiobook'
};
}
if (mediaType === 'converter') {
return {
mediaType,
icon: otherIndicatorIcon,
label: 'Converter',
alt: 'Converter'
};
}
return {
mediaType,
icon: otherIndicatorIcon,
@@ -1582,11 +1593,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
detailLoading={detailLoading}
onLoadLog={handleLoadLog}
logLoadingMode={logLoadingMode}
onRestartEncode={handleRestartEncode}
onRestartReview={handleRestartReview}
onRestartCdReview={handleRestartCdReview}
onReencode={handleReencode}
onRetry={handleRetry}
onRestartEncode={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartEncode}
onRestartReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartReview}
onRestartCdReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartCdReview}
onReencode={resolveMediaType(selectedJob) === 'converter' ? null : handleReencode}
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
onAssignOmdb={handleAssignOmdb}
onAssignCdMetadata={handleAssignCdMetadata}
onDeleteFiles={handleDeleteFiles}
+32 -1
View File
@@ -14,6 +14,26 @@ import CronJobsTab from '../components/CronJobsTab';
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
// Validates template/string fields: only letters, digits, space, _, -, and {placeholders} allowed
function validateSettingValue(key, value) {
if (!key.includes('template')) return null;
const str = String(value ?? '');
if (!str) return null;
// Strip valid placeholder patterns ${...} and {...}
const stripped = str.replace(/\$\{[^}]*\}/g, '').replace(/\{[^}]*\}/g, '');
// Standalone { or } after stripping = unclosed placeholder
if (/[{}]/.test(stripped)) {
return 'Geschweifte Klammern sind nur als vollständige Platzhalter erlaubt, z.B. {title}.';
}
// Find first invalid special char (anything beyond letters, digits, space, _, -, (), [], /)
// . is not allowed (extensions are set separately); / is only for folder separators
const invalid = stripped.match(/[^a-zA-Z0-9 ()[\]/_-]/);
if (invalid) {
return `Ungültiges Zeichen "${invalid[0]}". Erlaubt sind nur Buchstaben, Ziffern, Leerzeichen, _, -, und {Platzhalter}.`;
}
return null;
}
function buildValuesMap(categories) {
const next = {};
for (const category of categories || []) {
@@ -427,7 +447,8 @@ export default function SettingsPage() {
const handleFieldChange = (key, value) => {
setDraftValues((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => ({ ...prev, [key]: null }));
const validationError = validateSettingValue(key, value);
setErrors((prev) => ({ ...prev, [key]: validationError || null }));
};
const handleExpertModeToggle = async (checked) => {
@@ -468,6 +489,16 @@ export default function SettingsPage() {
return;
}
const hasValidationErrors = Object.values(errors).some(Boolean);
if (hasValidationErrors) {
toastRef.current?.show({
severity: 'error',
summary: 'Validierungsfehler',
detail: 'Bitte korrigiere die fehlerhaften Felder vor dem Speichern.'
});
return;
}
const patch = {};
for (const key of dirtyKeys) {
patch[key] = draftValues[key];
+574
View File
@@ -4864,3 +4864,577 @@ body {
justify-content: flex-end;
flex-wrap: wrap;
}
/* ===== Converter Page ===== */
.converter-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 1rem 1.25rem 0;
}
.converter-card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 700;
color: var(--text-color, #333);
}
.converter-jobs-group-label {
margin: 0 0 0.6rem;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--rip-muted, #6a4d38);
}
.converter-jobs-empty-hint {
margin: 0;
color: var(--rip-muted, #6a4d38);
}
/* ===== File Explorer (Klangkiste 1:1 mit Ripster-Farben) ===== */
/* Äußere Hülle */
.cfx-wrap {
display: flex;
flex-direction: column;
gap: 0;
}
/* Obere Infozeile: rawdir + Aktualisieren */
.cfx-top-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
font-size: 0.8rem;
}
.cfx-rawdir {
flex: 1;
font-size: 0.75rem;
color: var(--rip-muted, #6a4d38);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Klangkiste Explorer-Layout ───────────────────────────────────────────── */
.explorer {
display: grid;
grid-template-columns: 260px 1fr;
grid-template-rows: 1fr auto;
gap: 0;
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 12px;
overflow-x: auto;
overflow-y: hidden;
background: var(--rip-panel, #fffaf1);
min-height: 260px;
align-items: stretch;
font-size: 13px;
}
.explorer-sidebar {
grid-row: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--rip-panel-soft, #fdf5e7);
border-right: 1px solid var(--rip-border, #d9bc8d);
height: 100%;
}
.explorer-toolbar,
.sidebar-toolbar {
height: 40px;
min-height: 40px;
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-toolbar {
border: 1px solid var(--rip-border, #d9bc8d);
background: var(--rip-cream-50, #fffaf0);
padding: 0 10px;
border-radius: 8px;
flex-shrink: 0;
}
.sidebar-tree {
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 10px;
background: var(--rip-cream-50, #fffaf0);
padding: 8px;
overflow: auto;
flex: 1;
}
.sidebar-search {
flex: 1;
width: 100%;
border: 1px solid var(--rip-border, #d9bc8d);
background: var(--rip-cream-50, #fffaf0);
border-radius: 8px;
padding: 0 10px;
font-size: 13px;
color: var(--rip-ink, #2f180f);
min-width: 0;
height: 28px;
line-height: 28px;
outline: none;
}
.sidebar-search:focus {
border-color: var(--rip-gold-400, #d49c56);
box-shadow: 0 0 0 2px rgba(212, 156, 86, 0.2);
}
.explorer-main {
grid-row: 1;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px 14px;
background: var(--rip-panel, #fffaf1);
height: 100%;
overflow: hidden;
}
.explorer-toolbar {
flex-wrap: wrap;
padding: 0 10px;
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 10px;
background: var(--rip-cream-50, #fffaf0);
flex-shrink: 0;
}
.explorer-toolbar .icon-button {
width: 30px;
height: 30px;
}
.explorer-path {
display: flex;
flex-wrap: wrap;
gap: 0;
font-size: 13px;
color: var(--rip-muted, #6a4d38);
align-items: center;
line-height: 1;
flex: 1;
min-width: 0;
}
.breadcrumb-root {
font-weight: 600;
color: var(--rip-ink, #2f180f);
}
.path-link {
background: transparent;
border: none;
color: var(--rip-ink, #2f180f);
cursor: pointer;
padding: 0;
font-size: 13px;
font-weight: 600;
}
.path-link:hover,
.path-link:focus { background: transparent; color: var(--primary-color, #8e4d2d); outline: none; text-decoration: underline; }
.toolbar-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.explorer-list {
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 10px;
background: var(--rip-cream-50, #fffaf0);
overflow: auto;
flex: 1;
min-height: 0;
}
.explorer-row {
display: grid;
grid-template-columns: 28px 2fr repeat(2, minmax(80px, 1fr));
gap: 10px;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--rip-border, #d9bc8d);
font-size: 13px;
cursor: pointer;
user-select: none;
transition: background 0.08s, border-left 0.08s;
}
.explorer-row:hover:not(.header):not(.footer) { background: var(--rip-cream-100, #fbf2df); }
.explorer-row.selected {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
border-left: 3px solid var(--primary-color);
padding-left: 9px;
}
.explorer-row.header {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--rip-muted, #6a4d38);
background: var(--rip-panel-soft, #fdf5e7);
cursor: default;
border-left: 3px solid transparent;
position: sticky;
top: 0;
z-index: 1;
}
.explorer-row.footer {
background: var(--rip-panel-soft, #fdf5e7);
cursor: default;
border-left: 3px solid transparent;
}
.explorer-row:last-child { border-bottom: none; }
.row-name {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-icon {
display: inline-flex;
align-items: center;
color: var(--rip-brown-700, #6f3922);
flex-shrink: 0;
}
.row-checkbox {
display: flex;
align-items: center;
justify-content: center;
}
.row-checkbox input[type="checkbox"] {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--primary-color);
border-radius: 3px;
}
.explorer-row.header .row-checkbox { pointer-events: none; }
.footer-count {
font-weight: 600;
color: var(--rip-ink, #2f180f);
text-align: right;
}
.explorer-footer {
grid-column: 1 / -1;
padding: 8px 14px;
font-size: 12px;
color: var(--rip-muted, #6a4d38);
border-top: 1px solid var(--rip-border, #d9bc8d);
background: var(--rip-panel-soft, #fdf5e7);
border-radius: 0 0 11px 11px;
}
/* ── Icon-Button (wie Klangkiste) ────────────────────────────────────────── */
.icon-button {
border: 1px solid var(--rip-border, #d9bc8d);
background: var(--rip-panel, #fffaf1);
color: var(--rip-ink, #2f180f);
border-radius: 8px;
padding: 0;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
line-height: 1;
font-size: 14px;
transition: background 0.1s;
flex-shrink: 0;
}
.icon-button:hover:not(:disabled) { background: var(--rip-cream-100, #fbf2df); }
.icon-button.danger {
border-color: rgba(239, 68, 68, 0.55);
color: rgb(220, 38, 38);
background: rgba(239, 68, 68, 0.08);
}
.icon-button.danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.15); }
.icon-button:disabled { cursor: not-allowed; opacity: 0.4; }
/* ── Ordnerbaum ──────────────────────────────────────────────────────────── */
.tree-node { display: flex; flex-direction: column; gap: 2px; }
.depth-1 .tree-row { margin-left: 16px; }
.depth-2 .tree-row { margin-left: 32px; }
.depth-3 .tree-row { margin-left: 48px; }
.depth-4 .tree-row { margin-left: 64px; }
.depth-5 .tree-row { margin-left: 80px; }
.tree-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 8px;
cursor: pointer;
color: var(--rip-muted, #6a4d38);
user-select: none;
}
.tree-row:hover { background: var(--rip-cream-100, #fbf2df); color: var(--rip-ink, #2f180f); }
.tree-row.active { background: var(--primary-50, #fef6ec); color: var(--rip-ink, #2f180f); font-weight: 600; }
.tree-caret {
border: none;
background: transparent;
color: var(--rip-muted, #6a4d38);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
flex: 0 0 18px;
cursor: pointer;
}
.tree-caret.disabled { width: 18px; flex: 0 0 18px; }
.tree-icon.folder { color: var(--rip-brown-700, #6f3922); }
.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Selection bar */
.converter-selection-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--primary-50, #fef6ec);
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 6px;
font-size: 0.85rem;
}
/* ===== Upload Panel ===== */
.converter-upload-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.converter-upload-dropzone {
border: 2px dashed var(--rip-border, #d9bc8d);
border-radius: 8px;
padding: 1.25rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
background: var(--rip-panel, #fffaf1);
transition: border-color 0.15s, background 0.15s;
}
.converter-upload-dropzone.drag-over {
border-color: var(--primary-color, #8e4d2d);
background: var(--primary-50, #fef6ec);
}
.converter-upload-hint {
text-align: center;
font-size: 0.85rem;
color: var(--rip-muted, #6a4d38);
line-height: 1.5;
}
.converter-upload-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}
.converter-upload-status {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
background: var(--rip-panel-soft, #fdf5e7);
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 6px;
}
.converter-upload-status-head {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
/* ===== Job Card ===== */
.converter-jobs-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.converter-jobs-empty {
padding: 1rem 0;
color: var(--rip-muted, #6a4d38);
}
.converter-job-card {
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 8px;
padding: 0.75rem 1rem;
background: var(--rip-panel, #fffaf1);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.converter-job-card.status-done {
border-color: var(--green-300, #74c69d);
background: #f6fff9;
}
.converter-job-card.status-error {
border-color: var(--red-300, #ff8080);
background: #fff5f5;
}
.converter-job-card.status-cancelled {
opacity: 0.7;
}
.converter-job-card-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.converter-job-card-title-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.converter-job-card-title {
font-weight: 600;
font-size: 0.9rem;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.converter-job-card-meta {
font-size: 0.78rem;
color: var(--rip-muted, #6a4d38);
}
.converter-job-card-progress {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.converter-job-card-progress-meta {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--rip-muted, #6a4d38);
}
.converter-job-card-error {
color: var(--red-600, #c62828);
font-size: 0.8rem;
background: #fff0f0;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.converter-job-card-output {
font-size: 0.78rem;
color: var(--rip-muted, #6a4d38);
}
.converter-job-card-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
/* Inline-Konfiguration für READY_TO_START Jobs */
.converter-job-card.is-ready {
border-color: var(--amber-400, #f59e0b);
background: var(--surface-50, #fafaf9);
}
.cjc-config {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--surface-200, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cjc-config-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.cjc-config-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
flex: 1;
min-width: 160px;
}
.cjc-config-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.cjc-config-value {
display: flex;
align-items: center;
gap: 0.4rem;
padding-top: 0.1rem;
}
.cjc-config-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding-top: 0.25rem;
}