0.12.0-19 Final Build
This commit is contained in:
+44
-28
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||
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';
|
||||
@@ -7,12 +7,14 @@ import { Toast } from 'primereact/toast';
|
||||
import { ConfirmDialog } from 'primereact/confirmdialog';
|
||||
import { api } from './api/client';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
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);
|
||||
@@ -123,11 +125,11 @@ function App() {
|
||||
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
||||
const [expertMode, setExpertMode] = useState(false);
|
||||
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
||||
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
|
||||
const [ripperJobsRefreshToken, setRipperJobsRefreshToken] = useState(0);
|
||||
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
||||
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
|
||||
const [downloadSummary, setDownloadSummary] = useState(null);
|
||||
const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null);
|
||||
const [pendingRipperJobId, setPendingRipperJobId] = useState(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const globalToastRef = useRef(null);
|
||||
@@ -135,7 +137,7 @@ function App() {
|
||||
|
||||
// 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
|
||||
// Dashboard and History to re-fetch so jobs leave the live list reliably.
|
||||
// Ripper and History to re-fetch so jobs leave the live list reliably.
|
||||
useEffect(() => {
|
||||
const current = pipeline?.cdDrives || {};
|
||||
const prev = prevCdDrivesRef.current;
|
||||
@@ -167,7 +169,7 @@ function App() {
|
||||
}
|
||||
|
||||
if (shouldRefresh) {
|
||||
setDashboardJobsRefreshToken((t) => t + 1);
|
||||
setRipperJobsRefreshToken((t) => t + 1);
|
||||
setHistoryJobsRefreshToken((t) => t + 1);
|
||||
}
|
||||
prevCdDrivesRef.current = current;
|
||||
@@ -235,10 +237,10 @@ function App() {
|
||||
|
||||
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
||||
await refreshPipeline().catch(() => null);
|
||||
setDashboardJobsRefreshToken((prev) => prev + 1);
|
||||
setRipperJobsRefreshToken((prev) => prev + 1);
|
||||
setHistoryJobsRefreshToken((prev) => prev + 1);
|
||||
if (uploadedJobId) {
|
||||
setPendingDashboardJobId(uploadedJobId);
|
||||
setPendingRipperJobId(uploadedJobId);
|
||||
}
|
||||
|
||||
setAudiobookUpload((prev) => ({
|
||||
@@ -268,12 +270,12 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDashboardJobFocusConsumed = (jobId) => {
|
||||
const handleRipperJobFocusConsumed = (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
setPendingDashboardJobId((prev) => (
|
||||
setPendingRipperJobId((prev) => (
|
||||
normalizeJobId(prev) === normalizedJobId ? null : prev
|
||||
));
|
||||
};
|
||||
@@ -525,8 +527,10 @@ function App() {
|
||||
});
|
||||
|
||||
const nav = [
|
||||
{ label: 'Dashboard', path: '/' },
|
||||
{ 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' },
|
||||
@@ -543,8 +547,14 @@ function App() {
|
||||
: (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null);
|
||||
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
|
||||
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
|
||||
const isDashboardRoute = location.pathname === '/';
|
||||
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">
|
||||
@@ -570,8 +580,8 @@ function App() {
|
||||
key={item.path}
|
||||
label={item.label}
|
||||
onClick={() => navigate(item.path)}
|
||||
className={location.pathname === item.path ? 'nav-btn nav-btn-active' : 'nav-btn'}
|
||||
outlined={location.pathname !== item.path}
|
||||
className={isNavActive(item.path) ? 'nav-btn nav-btn-active' : 'nav-btn'}
|
||||
outlined={!isNavActive(item.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -603,18 +613,14 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="app-upload-banner-actions">
|
||||
{hasUploadedJob && !isDashboardRoute ? (
|
||||
{hasUploadedJob && !isAudiobooksRoute ? (
|
||||
<Button
|
||||
label="Zum Dashboard"
|
||||
label="Zu Audiobooks"
|
||||
icon="pi pi-arrow-right"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => {
|
||||
const targetJobId = normalizeJobId(audiobookUpload?.jobId);
|
||||
if (targetJobId) {
|
||||
setPendingDashboardJobId(targetJobId);
|
||||
}
|
||||
navigate('/');
|
||||
navigate('/audiobooks');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
@@ -637,27 +643,37 @@ function App() {
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<DashboardPage
|
||||
<RipperPage
|
||||
pipeline={pipeline}
|
||||
hardwareMonitoring={hardwareMonitoring}
|
||||
lastDiscEvent={lastDiscEvent}
|
||||
refreshPipeline={refreshPipeline}
|
||||
audiobookUpload={audiobookUpload}
|
||||
onAudiobookUpload={handleAudiobookUpload}
|
||||
jobsRefreshToken={dashboardJobsRefreshToken}
|
||||
pendingExpandedJobId={pendingDashboardJobId}
|
||||
onPendingExpandedJobHandled={handleDashboardJobFocusConsumed}
|
||||
jobsRefreshToken={ripperJobsRefreshToken}
|
||||
pendingExpandedJobId={pendingRipperJobId}
|
||||
onPendingExpandedJobHandled={handleRipperJobFocusConsumed}
|
||||
downloadSummary={downloadSummary}
|
||||
>
|
||||
<Outlet />
|
||||
</DashboardPage>
|
||||
</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>
|
||||
|
||||
@@ -597,6 +597,12 @@ export const api = {
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
getAudiobookJobs() {
|
||||
return request('/pipeline/audiobook/jobs');
|
||||
},
|
||||
getAudiobookOutputTree() {
|
||||
return request('/pipeline/audiobook/output-tree');
|
||||
},
|
||||
async selectMetadata(payload) {
|
||||
const result = await request('/pipeline/select-metadata', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -297,7 +297,7 @@ export default function AudiobookConfigPanel({
|
||||
</div>
|
||||
|
||||
{isRunning ? (
|
||||
<div className="dashboard-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.round(progress)}%`}>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1056,9 +1056,9 @@ export default function ConverterJobCard({
|
||||
})();
|
||||
|
||||
const jobIdLabel = Number.isFinite(Number(job?.id)) ? `Job #${Math.trunc(Number(job.id))}` : 'Job';
|
||||
const dashboardTitleId = Number.isFinite(Number(job?.id)) ? `#${Math.trunc(Number(job.id))}` : '#-';
|
||||
const ripperTitleId = Number.isFinite(Number(job?.id)) ? `#${Math.trunc(Number(job.id))}` : '#-';
|
||||
const customTitle = String(job?.title || '').trim();
|
||||
const title = customTitle ? `${dashboardTitleId} | ${customTitle}` : dashboardTitleId;
|
||||
const title = customTitle ? `${ripperTitleId} | ${customTitle}` : ripperTitleId;
|
||||
const status = job.status || 'UNKNOWN';
|
||||
const statusLabel = getStatusLabel(status);
|
||||
const statusSeverity = getStatusSeverity(status);
|
||||
@@ -1173,15 +1173,15 @@ export default function ConverterJobCard({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-job-row"
|
||||
className="ripper-job-row"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<div className="poster-thumb dashboard-job-poster-fallback">
|
||||
<div className="poster-thumb ripper-job-poster-fallback">
|
||||
{isAudio ? 'Audio' : 'Video'}
|
||||
</div>
|
||||
<div className="dashboard-job-row-content">
|
||||
<div className="dashboard-job-row-main">
|
||||
<strong className="dashboard-job-title-line">
|
||||
<div className="ripper-job-row-content">
|
||||
<div className="ripper-job-row-main">
|
||||
<strong className="ripper-job-title-line">
|
||||
<i className="pi pi-circle-fill media-indicator-icon" style={{ color: 'var(--rip-muted)', fontSize: '0.65rem' }} />
|
||||
<span>{title}</span>
|
||||
</strong>
|
||||
@@ -1189,12 +1189,12 @@ export default function ConverterJobCard({
|
||||
{fileCount != null ? `${fileCount} Datei${fileCount !== 1 ? 'en' : ''}` : null}
|
||||
</small>
|
||||
</div>
|
||||
<div className="dashboard-job-badges">
|
||||
<div className="ripper-job-badges">
|
||||
{converterMediaType && mediaTypeBadge(converterMediaType)}
|
||||
<Tag value={statusLabel} severity={statusSeverity} />
|
||||
</div>
|
||||
{active && progressValue !== null && (
|
||||
<div className="dashboard-job-row-progress">
|
||||
<div className="ripper-job-row-progress">
|
||||
<ProgressBar value={progressValue} showValue={false} />
|
||||
<small>
|
||||
{liveEta ? `${progressValue}% | ETA ${liveEta}` : `${progressValue}%`}
|
||||
@@ -1209,20 +1209,20 @@ export default function ConverterJobCard({
|
||||
|
||||
// ── Ausgeklappt ───────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className={`dashboard-job-expanded converter-job-card status-${status.toLowerCase()}${isReady ? ' is-ready' : ''}`}>
|
||||
<div className={`ripper-job-expanded converter-job-card status-${status.toLowerCase()}${isReady ? ' is-ready' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="dashboard-job-expanded-head">
|
||||
<div className="ripper-job-expanded-head">
|
||||
{job?.poster_url && job.poster_url !== 'N/A' ? (
|
||||
<img src={job.poster_url} alt={title} className="poster-thumb" />
|
||||
) : (
|
||||
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div>
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Poster</div>
|
||||
)}
|
||||
<div className="dashboard-job-expanded-title">
|
||||
<strong className="dashboard-job-title-line">
|
||||
<div className="ripper-job-expanded-title">
|
||||
<strong className="ripper-job-title-line">
|
||||
<i className="pi pi-circle-fill media-indicator-icon" style={{ color: 'var(--rip-muted)', fontSize: '0.65rem' }} />
|
||||
<span>{title}</span>
|
||||
</strong>
|
||||
<div className="dashboard-job-badges">
|
||||
<div className="ripper-job-badges">
|
||||
{converterMediaType && mediaTypeBadge(converterMediaType)}
|
||||
<Tag value={statusLabel} severity={statusSeverity} />
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,6 @@ const VIDEO_OUTPUT_FORMATS = [
|
||||
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' }
|
||||
@@ -49,7 +48,6 @@ export default function ConverterJobConfigDialog({
|
||||
mp3Mode: 'cbr',
|
||||
mp3Bitrate: 192,
|
||||
mp3Quality: 4,
|
||||
aacBitrate: 256,
|
||||
opusBitrate: 160,
|
||||
oggQuality: 6
|
||||
});
|
||||
@@ -268,17 +266,6 @@ export default function ConverterJobConfigDialog({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
|
||||
@@ -1,19 +1,45 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, 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(',');
|
||||
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 }) {
|
||||
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('');
|
||||
@@ -21,6 +47,22 @@ export default function ConverterUploadPanel({ onUploaded }) {
|
||||
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');
|
||||
@@ -38,6 +80,24 @@ export default function ConverterUploadPanel({ onUploaded }) {
|
||||
});
|
||||
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');
|
||||
@@ -77,7 +137,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
|
||||
setPhase('error');
|
||||
setErrorMsg(err.message || 'Upload fehlgeschlagen.');
|
||||
}
|
||||
}, [onUploaded]);
|
||||
}, [onUploaded, allowedExtensionSet, allowedListLabel]);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
if (e.target.files?.length > 0) {
|
||||
@@ -119,7 +179,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
|
||||
<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>
|
||||
<small>Unterstützt: {allowedListLabel}</small>
|
||||
</div>
|
||||
<div className="converter-upload-buttons">
|
||||
<Button
|
||||
@@ -145,7 +205,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_EXTENSIONS}
|
||||
accept={acceptValue}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
@@ -154,6 +214,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
|
||||
type="file"
|
||||
webkitdirectory="true"
|
||||
multiple
|
||||
accept={acceptValue}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { InputText } from 'primereact/inputtext';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Checkbox } from 'primereact/checkbox';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Button } from 'primereact/button';
|
||||
import { api } from '../api/client';
|
||||
@@ -444,6 +445,81 @@ const DOWNLOAD_PATH_KEYS = ['download_dir'];
|
||||
const LOG_PATH_KEYS = ['log_dir'];
|
||||
const EXTERNAL_STORAGE_PATH_KEYS = [EXTERNAL_STORAGE_PATHS_KEY];
|
||||
const CONVERTER_PATH_KEYS = ['converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio'];
|
||||
const CONVERTER_SCAN_EXTENSION_KEY = 'converter_scan_extensions';
|
||||
const CONVERTER_SCAN_EXTENSION_OPTIONS = [
|
||||
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
|
||||
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
|
||||
];
|
||||
|
||||
function parseConverterScanExtensions(value) {
|
||||
const tokens = String(value || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const known = new Set(CONVERTER_SCAN_EXTENSION_OPTIONS);
|
||||
const seen = new Set();
|
||||
const selected = [];
|
||||
for (const token of tokens) {
|
||||
if (!known.has(token) || seen.has(token)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(token);
|
||||
selected.push(token);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function serializeConverterScanExtensions(values) {
|
||||
const list = Array.isArray(values) ? values : [];
|
||||
const known = new Set(CONVERTER_SCAN_EXTENSION_OPTIONS);
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
for (const item of list) {
|
||||
const ext = String(item || '').trim().toLowerCase();
|
||||
if (!known.has(ext) || seen.has(ext)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(ext);
|
||||
normalized.push(ext);
|
||||
}
|
||||
return normalized.join(',');
|
||||
}
|
||||
|
||||
function ConverterScanExtensionsEditor({ value, onChange, settingKey }) {
|
||||
const selected = parseConverterScanExtensions(value);
|
||||
const selectedSet = new Set(selected);
|
||||
|
||||
const handleToggle = (extension) => {
|
||||
const ext = String(extension || '').trim().toLowerCase();
|
||||
if (!ext) {
|
||||
return;
|
||||
}
|
||||
let next = selected.filter((item) => item !== ext);
|
||||
if (!selectedSet.has(ext)) {
|
||||
next = [...selected, ext];
|
||||
}
|
||||
// Setting ist required/minLength: mindestens eine Endung aktiv lassen.
|
||||
if (next.length === 0) {
|
||||
return;
|
||||
}
|
||||
onChange?.(settingKey, serializeConverterScanExtensions(next));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="converter-scan-extensions-grid">
|
||||
{CONVERTER_SCAN_EXTENSION_OPTIONS.map((extension) => (
|
||||
<label key={extension} htmlFor={`${settingKey}-${extension}`} className="converter-scan-extension-option">
|
||||
<Checkbox
|
||||
inputId={`${settingKey}-${extension}`}
|
||||
checked={selectedSet.has(extension)}
|
||||
onChange={() => handleToggle(extension)}
|
||||
/>
|
||||
<span>.{extension}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSectionsForCategory(categoryName, settings) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
@@ -526,7 +602,8 @@ function SettingField({
|
||||
|
||||
{(setting.type === 'string' || setting.type === 'path')
|
||||
&& setting.key !== 'drive_devices'
|
||||
&& setting.key !== EXTERNAL_STORAGE_PATHS_KEY ? (
|
||||
&& setting.key !== EXTERNAL_STORAGE_PATHS_KEY
|
||||
&& setting.key !== CONVERTER_SCAN_EXTENSION_KEY ? (
|
||||
<InputText
|
||||
id={setting.key}
|
||||
value={value ?? ''}
|
||||
@@ -550,6 +627,14 @@ function SettingField({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.key === CONVERTER_SCAN_EXTENSION_KEY ? (
|
||||
<ConverterScanExtensionsEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
settingKey={setting.key}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'number' ? (
|
||||
<InputNumber
|
||||
id={setting.key}
|
||||
|
||||
@@ -790,9 +790,6 @@ export default function JobDetailDialog({
|
||||
}
|
||||
return `CBR ${Number(opts?.mp3Bitrate ?? 192)} kbps`;
|
||||
}
|
||||
if (converterOutputFormat === 'aac') {
|
||||
return `${Number(opts?.aacBitrate ?? 256)} kbps`;
|
||||
}
|
||||
if (converterOutputFormat === 'opus') {
|
||||
return `${Number(opts?.opusBitrate ?? 160)} kbps`;
|
||||
}
|
||||
@@ -1470,7 +1467,7 @@ export default function JobDetailDialog({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>Live-Log wird nur im Dashboard während laufender Analyse/Rip/Encode angezeigt.</p>
|
||||
<p>Live-Log wird nur im Ripper während laufender Analyse/Rip/Encode angezeigt.</p>
|
||||
)}
|
||||
|
||||
<h4>Aktionen</h4>
|
||||
@@ -1563,7 +1560,7 @@ export default function JobDetailDialog({
|
||||
{canResumeReady ? (
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label="Im Dashboard öffnen"
|
||||
label="Im Ripper öffnen"
|
||||
icon="pi pi-window-maximize"
|
||||
severity="info"
|
||||
outlined
|
||||
@@ -1571,7 +1568,7 @@ export default function JobDetailDialog({
|
||||
onClick={() => onResumeReady?.(job)}
|
||||
loading={actionBusy}
|
||||
/>
|
||||
<span className="action-desc">Öffnet den wartenden Job im Dashboard zur Weiterverarbeitung.</span>
|
||||
<span className="action-desc">Öffnet den wartenden Job im Ripper zur Weiterverarbeitung.</span>
|
||||
</div>
|
||||
) : null}
|
||||
{typeof onRestartEncode === 'function' ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,15 +21,37 @@ import otherIndicatorIcon from '../assets/media-other.svg';
|
||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||
import { confirmModal } from '../utils/confirmModal';
|
||||
|
||||
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
|
||||
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.wmv', '.ts', '.m2ts', '.iso', '.m4v', '.flv', '.webm']);
|
||||
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.wav', '.m4a', '.ogg', '.opus']);
|
||||
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.m2ts', '.iso', '.avi', '.mov']);
|
||||
const TERMINAL_JOB_STATUSES = new Set(['DONE', 'FINISHED', 'ERROR', 'CANCELLED']);
|
||||
const DEFAULT_CONVERTER_SCAN_EXTENSIONS = [
|
||||
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
|
||||
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
|
||||
];
|
||||
const VIDEO_OUTPUT_FORMATS = [
|
||||
{ label: 'MKV', value: 'mkv' },
|
||||
{ label: 'MP4', value: 'mp4' },
|
||||
{ label: 'M4V', value: 'm4v' }
|
||||
];
|
||||
|
||||
function parseConverterScanExtensions(value) {
|
||||
const seen = new Set();
|
||||
const parsed = String(value || '')
|
||||
.split(',')
|
||||
.map((item) => String(item || '').trim().toLowerCase())
|
||||
.filter((item) => {
|
||||
if (!item || !DEFAULT_CONVERTER_SCAN_EXTENSIONS.includes(item) || seen.has(item)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(item);
|
||||
return true;
|
||||
});
|
||||
if (parsed.length === 0) {
|
||||
return [...DEFAULT_CONVERTER_SCAN_EXTENSIONS];
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isAudioEntry(e) {
|
||||
if (e.detectedMediaType === 'audio') return true;
|
||||
const p = (e.relPath || '').toLowerCase();
|
||||
@@ -117,11 +139,24 @@ export default function ConverterPage() {
|
||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||
const [videoUserPresets, setVideoUserPresets] = useState([]);
|
||||
const [videoHbPresets, setVideoHbPresets] = useState([]);
|
||||
const [uploadExtensions, setUploadExtensions] = useState(() => [...DEFAULT_CONVERTER_SCAN_EXTENSIONS]);
|
||||
// Entries werden beim Öffnen des Modals gesichert, damit ein außerhalb-Click
|
||||
// die Selection nicht löscht bevor die Jobs erstellt werden
|
||||
const jobEntriesRef = useRef([]);
|
||||
const previousJobStatusesRef = useRef(new Map());
|
||||
|
||||
const loadUploadExtensions = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.getSettings({ forceRefresh: true });
|
||||
const allSettings = (response?.categories || []).flatMap((category) => category?.settings || []);
|
||||
const setting = allSettings.find((item) => String(item?.key || '').trim() === 'converter_scan_extensions');
|
||||
const value = setting?.value ?? setting?.default_value ?? '';
|
||||
setUploadExtensions(parseConverterScanExtensions(value));
|
||||
} catch (_error) {
|
||||
setUploadExtensions([...DEFAULT_CONVERTER_SCAN_EXTENSIONS]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadPresets = async () => {
|
||||
@@ -152,6 +187,10 @@ export default function ConverterPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUploadExtensions();
|
||||
}, [loadUploadExtensions]);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
setLoadingJobs(true);
|
||||
try {
|
||||
@@ -237,7 +276,7 @@ export default function ConverterPage() {
|
||||
|
||||
useWebSocket({
|
||||
onMessage: (message) => {
|
||||
if (!message?.type || !message?.payload) return;
|
||||
if (!message?.type) return;
|
||||
|
||||
if (message.type === 'PIPELINE_PROGRESS') {
|
||||
const payload = message.payload;
|
||||
@@ -266,6 +305,19 @@ export default function ConverterPage() {
|
||||
setExplorerRefreshToken((t) => t + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'SETTINGS_UPDATED') {
|
||||
if (String(message?.payload?.key || '').trim() === 'converter_scan_extensions') {
|
||||
void loadUploadExtensions();
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'SETTINGS_BULK_UPDATED') {
|
||||
const keys = Array.isArray(message?.payload?.keys) ? message.payload.keys : [];
|
||||
if (keys.includes('converter_scan_extensions')) {
|
||||
void loadUploadExtensions();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -856,7 +908,7 @@ export default function ConverterPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dashboard-subpage-content">
|
||||
<div className="ripper-subpage-content">
|
||||
<Toast ref={toastRef} position="top-right" />
|
||||
|
||||
{/* Import-Ordner */}
|
||||
@@ -890,7 +942,7 @@ export default function ConverterPage() {
|
||||
<small>Keine aktiven Converter-Jobs vorhanden. Abgeschlossene Jobs findest du in der Historie.</small>
|
||||
</p>
|
||||
) : (
|
||||
<div className="dashboard-job-list converter-jobs-list">
|
||||
<div className="ripper-job-list converter-jobs-list">
|
||||
{activeJobs.map((job) => {
|
||||
const plan = parseConverterPlan(job);
|
||||
const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
|
||||
@@ -945,7 +997,10 @@ export default function ConverterPage() {
|
||||
|
||||
{/* Upload */}
|
||||
<Card title="Datei-Upload" subTitle="Einzelne Dateien oder ganze Ordner (Album) hochladen">
|
||||
<ConverterUploadPanel onUploaded={handleUploaded} />
|
||||
<ConverterUploadPanel
|
||||
onUploaded={handleUploaded}
|
||||
allowedExtensions={uploadExtensions}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<JobModeDialog
|
||||
@@ -1164,27 +1219,27 @@ function ConverterVideoJobCard({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-job-row"
|
||||
className="ripper-job-row"
|
||||
onClick={onExpand}
|
||||
>
|
||||
{job?.poster_url && job.poster_url !== 'N/A' ? (
|
||||
<img src={job.poster_url} alt={title} className="poster-thumb" />
|
||||
) : (
|
||||
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div>
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Poster</div>
|
||||
)}
|
||||
<div className="dashboard-job-row-content">
|
||||
<div className="dashboard-job-row-main">
|
||||
<strong className="dashboard-job-title-line">
|
||||
<div className="ripper-job-row-content">
|
||||
<div className="ripper-job-row-main">
|
||||
<strong className="ripper-job-title-line">
|
||||
<i className="pi pi-video media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
|
||||
<span>{title}</span>
|
||||
</strong>
|
||||
<small>#{jobId}</small>
|
||||
</div>
|
||||
<div className="dashboard-job-badges">
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} />
|
||||
</div>
|
||||
{hasProgress && (
|
||||
<div className="dashboard-job-row-progress">
|
||||
<div className="ripper-job-row-progress">
|
||||
<ProgressBar value={Math.max(0, Math.min(100, progressValue))} showValue={false} />
|
||||
</div>
|
||||
)}
|
||||
@@ -1195,19 +1250,19 @@ function ConverterVideoJobCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-job-expanded">
|
||||
<div className="dashboard-job-expanded-head">
|
||||
<div className="ripper-job-expanded">
|
||||
<div className="ripper-job-expanded-head">
|
||||
{job?.poster_url && job.poster_url !== 'N/A' ? (
|
||||
<img src={job.poster_url} alt={title} className="poster-thumb" />
|
||||
) : (
|
||||
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div>
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Poster</div>
|
||||
)}
|
||||
<div className="dashboard-job-expanded-title">
|
||||
<strong className="dashboard-job-title-line">
|
||||
<div className="ripper-job-expanded-title">
|
||||
<strong className="ripper-job-title-line">
|
||||
<i className="pi pi-video media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
|
||||
<span>#{jobId} | {title}</span>
|
||||
</strong>
|
||||
<div className="dashboard-job-badges">
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} />
|
||||
<Tag value="Converter" severity="info" />
|
||||
</div>
|
||||
|
||||
@@ -745,7 +745,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!skipConfirm) {
|
||||
const confirmed = await confirmModal({
|
||||
header: 'Review neu starten',
|
||||
message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`,
|
||||
message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`,
|
||||
acceptLabel: 'Review starten',
|
||||
rejectLabel: 'Abbrechen'
|
||||
});
|
||||
@@ -761,7 +761,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Review-Neustart',
|
||||
detail: 'Analyse gestartet. Job ist jetzt im Dashboard verfügbar.',
|
||||
detail: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.',
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
@@ -776,7 +776,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!skipConfirm) {
|
||||
const confirmed = await confirmModal({
|
||||
header: 'CD-Vorprüfung starten',
|
||||
message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Dashboard geöffnet.`,
|
||||
message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`,
|
||||
acceptLabel: 'Vorprüfung starten',
|
||||
rejectLabel: 'Abbrechen'
|
||||
});
|
||||
@@ -790,7 +790,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'CD-Vorprüfung gestartet',
|
||||
detail: 'Job ist jetzt im Dashboard verfügbar — bitte Metadaten und Einstellungen wählen.',
|
||||
detail: 'Job ist jetzt im Ripper verfügbar — bitte Metadaten und Einstellungen wählen.',
|
||||
life: 4000
|
||||
});
|
||||
await load();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { FileUpload } from 'primereact/fileupload';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { api } from '../api/client';
|
||||
@@ -22,11 +21,12 @@ import otherIndicatorIcon from '../assets/media-other.svg';
|
||||
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||
import { confirmModal } from '../utils/confirmModal';
|
||||
import { isConverterJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
|
||||
|
||||
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
||||
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
|
||||
const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING'];
|
||||
const dashboardStatuses = new Set([
|
||||
const ripperStatuses = new Set([
|
||||
'ANALYZING',
|
||||
'METADATA_SELECTION',
|
||||
'WAITING_FOR_USER_DECISION',
|
||||
@@ -479,6 +479,11 @@ function getAnalyzeContext(job) {
|
||||
}
|
||||
|
||||
function resolveMediaType(job) {
|
||||
const centralMediaType = resolveCentralMediaType(job);
|
||||
if (centralMediaType && centralMediaType !== 'other') {
|
||||
return centralMediaType;
|
||||
}
|
||||
|
||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||
const candidates = [
|
||||
job?.mediaType,
|
||||
@@ -533,7 +538,7 @@ function resolveMediaType(job) {
|
||||
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
||||
return 'audiobook';
|
||||
}
|
||||
return 'other';
|
||||
return centralMediaType || 'other';
|
||||
}
|
||||
|
||||
function mediaIndicatorMeta(job) {
|
||||
@@ -906,13 +911,11 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardPage({
|
||||
export default function RipperPage({
|
||||
pipeline,
|
||||
hardwareMonitoring,
|
||||
lastDiscEvent,
|
||||
refreshPipeline,
|
||||
audiobookUpload,
|
||||
onAudiobookUpload,
|
||||
jobsRefreshToken,
|
||||
pendingExpandedJobId,
|
||||
onPendingExpandedJobHandled,
|
||||
@@ -957,9 +960,8 @@ export default function DashboardPage({
|
||||
const [runtimeActionBusyKeys, setRuntimeActionBusyKeys] = useState(() => new Set());
|
||||
const [runtimeRecentClearing, setRuntimeRecentClearing] = useState(false);
|
||||
const [jobsLoading, setJobsLoading] = useState(false);
|
||||
const [dashboardJobs, setDashboardJobs] = useState([]);
|
||||
const [ripperJobs, setRipperJobs] = useState([]);
|
||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||
const [audiobookUploadFile, setAudiobookUploadFile] = useState(null);
|
||||
const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
|
||||
const [activationBytesInput, setActivationBytesInput] = useState('');
|
||||
const [activationBytesBusy, setActivationBytesBusy] = useState(false);
|
||||
@@ -972,8 +974,6 @@ export default function DashboardPage({
|
||||
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
||||
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
|
||||
const toastRef = useRef(null);
|
||||
const audiobookFileUploadRef = useRef(null);
|
||||
const [audiobookUploadStatusVisible, setAudiobookUploadStatusVisible] = useState(false);
|
||||
|
||||
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
|
||||
const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
|
||||
@@ -987,7 +987,7 @@ export default function DashboardPage({
|
||||
[pipeline?.cdDrives]
|
||||
);
|
||||
// Detects when a background job reaches a terminal interactive state via PIPELINE_PROGRESS
|
||||
// (e.g. READY_TO_ENCODE or WAITING_FOR_USER_DECISION), triggering a dashboard jobs reload.
|
||||
// (e.g. READY_TO_ENCODE or WAITING_FOR_USER_DECISION), triggering a ripper jobs reload.
|
||||
const jobProgressInteractiveKey = useMemo(() => Object.entries(pipeline?.jobProgress || {})
|
||||
.filter(([, v]) => {
|
||||
const s = String(v?.state || '').toUpperCase();
|
||||
@@ -1038,42 +1038,15 @@ export default function DashboardPage({
|
||||
}, [storageMetrics]);
|
||||
const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : [];
|
||||
const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : [];
|
||||
const audiobookUploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
|
||||
const audiobookUploadBusy = audiobookUploadPhase === 'uploading' || audiobookUploadPhase === 'processing';
|
||||
const audiobookUploadProgress = Number.isFinite(Number(audiobookUpload?.progressPercent))
|
||||
? Math.max(0, Math.min(100, Number(audiobookUpload.progressPercent)))
|
||||
: 0;
|
||||
const audiobookUploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0);
|
||||
const audiobookUploadTotalBytes = Number(audiobookUpload?.totalBytes || 0);
|
||||
const audiobookUploadFileName = String(audiobookUpload?.fileName || '').trim()
|
||||
|| String(audiobookUploadFile?.name || '').trim()
|
||||
|| null;
|
||||
const audiobookUploadStatusTone = audiobookUploadPhase === 'error'
|
||||
? 'danger'
|
||||
: audiobookUploadPhase === 'completed'
|
||||
? 'success'
|
||||
: audiobookUploadPhase === 'processing'
|
||||
? 'info'
|
||||
: audiobookUploadPhase === 'uploading'
|
||||
? 'warning'
|
||||
: 'secondary';
|
||||
const audiobookUploadStatusLabel = audiobookUploadPhase === 'uploading'
|
||||
? 'Upload läuft'
|
||||
: audiobookUploadPhase === 'processing'
|
||||
? 'Server verarbeitet'
|
||||
: audiobookUploadPhase === 'completed'
|
||||
? 'Bereit'
|
||||
: audiobookUploadPhase === 'error'
|
||||
? 'Fehler'
|
||||
: 'Inaktiv';
|
||||
const isSubpageRoute = location.pathname !== '/';
|
||||
const isRipperMainRoute = location.pathname === '/' || location.pathname === '/ripper';
|
||||
const isSubpageRoute = !isRipperMainRoute;
|
||||
|
||||
const loadDashboardJobs = async () => {
|
||||
const loadRipperJobs = async () => {
|
||||
setJobsLoading(true);
|
||||
try {
|
||||
const [jobsResponse, queueResponse] = await Promise.allSettled([
|
||||
api.getJobs({
|
||||
statuses: Array.from(dashboardStatuses),
|
||||
statuses: Array.from(ripperStatuses),
|
||||
limit: 160,
|
||||
lite: true
|
||||
}),
|
||||
@@ -1085,24 +1058,12 @@ export default function DashboardPage({
|
||||
if (queueResponse.status === 'fulfilled') {
|
||||
setQueueState(normalizeQueue(queueResponse.value?.queue));
|
||||
}
|
||||
const shouldDisplayOnDashboard = (job) => {
|
||||
const rawMediaType = String(job?.media_type || '').trim().toLowerCase();
|
||||
const resolvedMediaType = String(job?.mediaType || '').trim().toLowerCase();
|
||||
const planMediaProfile = String(job?.encodePlan?.mediaProfile || '').trim().toLowerCase();
|
||||
const converterPlanType = String(job?.encodePlan?.converterMediaType || '').trim().toLowerCase();
|
||||
const isConverterJob = (
|
||||
rawMediaType === 'converter'
|
||||
|| resolvedMediaType === 'converter'
|
||||
|| planMediaProfile === 'converter'
|
||||
|| converterPlanType === 'audio'
|
||||
|| converterPlanType === 'video'
|
||||
|| converterPlanType === 'iso'
|
||||
);
|
||||
if (isConverterJob) {
|
||||
const shouldDisplayOnRipper = (job) => {
|
||||
if (isConverterJob(job)) {
|
||||
return false;
|
||||
}
|
||||
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
|
||||
if (!dashboardStatuses.has(normalizedStatus)) {
|
||||
if (!ripperStatuses.has(normalizedStatus)) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedStatus !== 'CANCELLED') {
|
||||
@@ -1112,7 +1073,7 @@ export default function DashboardPage({
|
||||
return !hiddenCancelledReviewOrigins.has(cancelledOrigin);
|
||||
};
|
||||
const next = allJobs
|
||||
.filter((job) => shouldDisplayOnDashboard(job))
|
||||
.filter((job) => shouldDisplayOnRipper(job))
|
||||
.sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0));
|
||||
|
||||
const pinnedJobIds = new Set();
|
||||
@@ -1136,7 +1097,7 @@ export default function DashboardPage({
|
||||
continue;
|
||||
}
|
||||
const job = result.value?.job;
|
||||
if (job && shouldDisplayOnDashboard(job)) {
|
||||
if (job && shouldDisplayOnRipper(job)) {
|
||||
next.unshift(job);
|
||||
}
|
||||
}
|
||||
@@ -1153,7 +1114,7 @@ export default function DashboardPage({
|
||||
deduped.push(job);
|
||||
}
|
||||
|
||||
setDashboardJobs(deduped);
|
||||
setRipperJobs(deduped);
|
||||
|
||||
// Prüfen ob Audiobook-Jobs auf Activation Bytes warten
|
||||
try {
|
||||
@@ -1174,7 +1135,7 @@ export default function DashboardPage({
|
||||
// ignorieren
|
||||
}
|
||||
} catch (_error) {
|
||||
setDashboardJobs([]);
|
||||
setRipperJobs([]);
|
||||
} finally {
|
||||
setJobsLoading(false);
|
||||
}
|
||||
@@ -1245,7 +1206,7 @@ export default function DashboardPage({
|
||||
}, [pipeline?.queue]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboardJobs();
|
||||
void loadRipperJobs();
|
||||
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken, jobProgressInteractiveKey]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1259,13 +1220,13 @@ export default function DashboardPage({
|
||||
if (!requestedJobId) {
|
||||
return;
|
||||
}
|
||||
const hasRequestedJob = dashboardJobs.some((job) => normalizeJobId(job?.id) === requestedJobId);
|
||||
const hasRequestedJob = ripperJobs.some((job) => normalizeJobId(job?.id) === requestedJobId);
|
||||
if (!hasRequestedJob) {
|
||||
return;
|
||||
}
|
||||
setExpandedJobId(requestedJobId);
|
||||
onPendingExpandedJobHandled?.(requestedJobId);
|
||||
}, [pendingExpandedJobId, dashboardJobs, onPendingExpandedJobHandled]);
|
||||
}, [pendingExpandedJobId, ripperJobs, onPendingExpandedJobHandled]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -1308,7 +1269,7 @@ export default function DashboardPage({
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedExpanded = normalizeJobId(expandedJobId);
|
||||
const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
|
||||
const hasExpanded = ripperJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
|
||||
if (hasExpanded) {
|
||||
return;
|
||||
}
|
||||
@@ -1318,30 +1279,16 @@ export default function DashboardPage({
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPipelineJobId && dashboardJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
|
||||
if (currentPipelineJobId && ripperJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
|
||||
setExpandedJobId(currentPipelineJobId);
|
||||
return;
|
||||
}
|
||||
setExpandedJobId(normalizeJobId(dashboardJobs[0]?.id));
|
||||
}, [dashboardJobs, expandedJobId, currentPipelineJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audiobookUploadPhase === 'idle') {
|
||||
setAudiobookUploadStatusVisible(false);
|
||||
return undefined;
|
||||
}
|
||||
setAudiobookUploadStatusVisible(true);
|
||||
if (audiobookUploadPhase === 'completed') {
|
||||
const timer = setTimeout(() => setAudiobookUploadStatusVisible(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [audiobookUploadPhase]);
|
||||
|
||||
setExpandedJobId(normalizeJobId(ripperJobs[0]?.id));
|
||||
}, [ripperJobs, expandedJobId, currentPipelineJobId]);
|
||||
|
||||
const pipelineByJobId = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const job of dashboardJobs) {
|
||||
for (const job of ripperJobs) {
|
||||
const id = normalizeJobId(job?.id);
|
||||
if (!id) {
|
||||
continue;
|
||||
@@ -1349,14 +1296,14 @@ export default function DashboardPage({
|
||||
map.set(id, buildPipelineFromJob(job, pipeline, currentPipelineJobId));
|
||||
}
|
||||
return map;
|
||||
}, [dashboardJobs, pipeline, currentPipelineJobId]);
|
||||
}, [ripperJobs, pipeline, currentPipelineJobId]);
|
||||
|
||||
const buildMetadataContextForJob = (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return null;
|
||||
}
|
||||
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||
const pipelineForJob = pipelineByJobId.get(normalizedJobId) || null;
|
||||
const context = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
||||
? pipelineForJob.context
|
||||
@@ -1401,7 +1348,7 @@ export default function DashboardPage({
|
||||
};
|
||||
}
|
||||
|
||||
const pendingJob = dashboardJobs.find((job) => {
|
||||
const pendingJob = ripperJobs.find((job) => {
|
||||
const normalized = normalizeStatus(job?.status);
|
||||
return normalized === 'METADATA_SELECTION' || normalized === 'WAITING_FOR_USER_DECISION';
|
||||
});
|
||||
@@ -1409,7 +1356,7 @@ export default function DashboardPage({
|
||||
return null;
|
||||
}
|
||||
return buildMetadataContextForJob(pendingJob.id);
|
||||
}, [pipeline, dashboardJobs, pipelineByJobId]);
|
||||
}, [pipeline, ripperJobs, pipelineByJobId]);
|
||||
|
||||
const effectiveMetadataDialogContext = metadataDialogContext
|
||||
|| defaultMetadataDialogContext
|
||||
@@ -1452,7 +1399,7 @@ export default function DashboardPage({
|
||||
try {
|
||||
const response = await api.analyzeDisc();
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
const analyzedJobId = normalizeJobId(response?.result?.jobId);
|
||||
if (analyzedJobId) {
|
||||
setMetadataDialogContext({
|
||||
@@ -1486,7 +1433,7 @@ export default function DashboardPage({
|
||||
try {
|
||||
const response = await api.analyzeDisc(devicePath);
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
const analyzedJobId = normalizeJobId(response?.result?.jobId);
|
||||
if (analyzedJobId) {
|
||||
setMetadataDialogContext({
|
||||
@@ -1553,7 +1500,7 @@ export default function DashboardPage({
|
||||
life: 2800
|
||||
});
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
@@ -1595,7 +1542,7 @@ export default function DashboardPage({
|
||||
|
||||
const handleCancel = async (jobId = null, jobState = null) => {
|
||||
const cancelledJobId = normalizeJobId(jobId) || currentPipelineJobId;
|
||||
const cancelledJob = dashboardJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null;
|
||||
const cancelledJob = ripperJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null;
|
||||
const cancelledState = String(
|
||||
jobState
|
||||
|| cancelledJob?.status
|
||||
@@ -1607,7 +1554,7 @@ export default function DashboardPage({
|
||||
try {
|
||||
await api.cancelPipeline(cancelledJobId);
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
let latestCancelledJob = null;
|
||||
const fetchLatestCancelledJob = async () => {
|
||||
if (!cancelledJobId) {
|
||||
@@ -1701,7 +1648,7 @@ export default function DashboardPage({
|
||||
life: 4000
|
||||
});
|
||||
}
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
await refreshPipeline();
|
||||
setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
|
||||
} catch (error) {
|
||||
@@ -1718,7 +1665,7 @@ export default function DashboardPage({
|
||||
}
|
||||
|
||||
const startOptions = options && typeof options === 'object' ? options : {};
|
||||
const startJobRow = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||
const startJobRow = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||
const mediaType = resolveMediaType(startJobRow);
|
||||
setJobBusy(normalizedJobId, true);
|
||||
try {
|
||||
@@ -1753,7 +1700,7 @@ export default function DashboardPage({
|
||||
: await api.startJob(normalizedJobId);
|
||||
const result = getQueueActionResult(response);
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
if (result.queued) {
|
||||
showQueuedToast(toastRef, 'Start', result);
|
||||
} else {
|
||||
@@ -1766,32 +1713,6 @@ export default function DashboardPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudiobookUpload = async () => {
|
||||
if (!audiobookUploadFile) {
|
||||
showError(new Error('Bitte zuerst eine AAX-Datei auswählen.'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await onAudiobookUpload?.(audiobookUploadFile, { startImmediately: false });
|
||||
const result = response?.result || {};
|
||||
const uploadedJobId = normalizeJobId(result.jobId);
|
||||
if (result.needsActivationBytes && result.checksum) {
|
||||
setActivationBytesInput('');
|
||||
setActivationBytesDialog({ visible: true, checksum: result.checksum, jobId: uploadedJobId });
|
||||
} else if (uploadedJobId) {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Audiobook importiert',
|
||||
detail: `Job #${uploadedJobId} wurde angelegt und wird geoeffnet.`,
|
||||
life: 3200
|
||||
});
|
||||
}
|
||||
setAudiobookUploadFile(null);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveActivationBytes = async () => {
|
||||
const { checksum, jobId } = activationBytesDialog;
|
||||
const bytes = activationBytesInput.trim().toLowerCase();
|
||||
@@ -1806,7 +1727,7 @@ export default function DashboardPage({
|
||||
detail: jobId ? `Job #${jobId} kann jetzt gestartet werden.` : 'Bytes wurden lokal gespeichert.',
|
||||
life: 4000
|
||||
});
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
@@ -1833,7 +1754,7 @@ export default function DashboardPage({
|
||||
const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {});
|
||||
const result = getQueueActionResult(response);
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
if (result.queued) {
|
||||
showQueuedToast(toastRef, 'Audiobook', result);
|
||||
} else {
|
||||
@@ -1870,7 +1791,7 @@ export default function DashboardPage({
|
||||
}
|
||||
await api.confirmEncodeReview(jobId, payload);
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
setExpandedJobId(normalizedJobId);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
@@ -1888,7 +1809,7 @@ export default function DashboardPage({
|
||||
selectedPlaylist: selectedPlaylist || null
|
||||
});
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
setExpandedJobId(normalizedJobId);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
@@ -1906,7 +1827,7 @@ export default function DashboardPage({
|
||||
selectedHandBrakeTitleId: selectedHandBrakeTitleId || null
|
||||
});
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
setExpandedJobId(normalizedJobId);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
@@ -1923,7 +1844,7 @@ export default function DashboardPage({
|
||||
const result = getQueueActionResult(response);
|
||||
const retryJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
if (result.queued) {
|
||||
showQueuedToast(toastRef, 'Retry', result);
|
||||
} else {
|
||||
@@ -1936,12 +1857,12 @@ export default function DashboardPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDashboardJob = async (jobId) => {
|
||||
const handleDeleteRipperJob = async (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
|
||||
const confirmed = await confirmModal({
|
||||
header: 'Job löschen',
|
||||
@@ -1978,7 +1899,7 @@ export default function DashboardPage({
|
||||
keepDetectedDevice: false
|
||||
});
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
setExpandedJobId((prev) => (normalizeJobId(prev) === normalizedJobId ? null : prev));
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
@@ -1994,7 +1915,7 @@ export default function DashboardPage({
|
||||
};
|
||||
|
||||
const handleRestartEncodeWithLastSettings = async (jobId) => {
|
||||
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
|
||||
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
|
||||
const title = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||
if (job?.encodeSuccess) {
|
||||
const confirmed = await confirmModal({
|
||||
@@ -2017,7 +1938,7 @@ export default function DashboardPage({
|
||||
const result = getQueueActionResult(response);
|
||||
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
if (result.queued) {
|
||||
showQueuedToast(toastRef, 'Encode-Neustart', result);
|
||||
} else {
|
||||
@@ -2042,7 +1963,7 @@ export default function DashboardPage({
|
||||
const result = getQueueActionResult(response);
|
||||
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
setExpandedJobId(replacementJobId);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
@@ -2063,7 +1984,7 @@ export default function DashboardPage({
|
||||
const result = getQueueActionResult(response);
|
||||
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
if (result.queued) {
|
||||
showQueuedToast(toastRef, 'CD-Vorprüfung', result);
|
||||
} else {
|
||||
@@ -2213,7 +2134,7 @@ export default function DashboardPage({
|
||||
await api.selectMetadata(payload);
|
||||
}
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
setMetadataDialogReassignMode(false);
|
||||
@@ -2294,7 +2215,7 @@ export default function DashboardPage({
|
||||
try {
|
||||
await api.selectCdMetadata(payload);
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
setCdMetadataDialogVisible(false);
|
||||
setCdMetadataDialogContext(null);
|
||||
} catch (error) {
|
||||
@@ -2320,7 +2241,7 @@ export default function DashboardPage({
|
||||
}
|
||||
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
await loadRipperJobs();
|
||||
if (replacementJobId) {
|
||||
setExpandedJobId(replacementJobId);
|
||||
}
|
||||
@@ -2485,7 +2406,7 @@ export default function DashboardPage({
|
||||
try {
|
||||
const response = normalizedAction === 'next-step'
|
||||
? await api.requestRuntimeNextStep(activityId)
|
||||
: await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Dashboard' });
|
||||
: await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Ripper' });
|
||||
if (response?.snapshot) {
|
||||
setRuntimeActivities(normalizeRuntimeActivitiesPayload(response.snapshot));
|
||||
} else {
|
||||
@@ -2564,77 +2485,8 @@ export default function DashboardPage({
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<div className="dashboard-3col-grid">
|
||||
<div className="dashboard-col dashboard-col-left">
|
||||
<Card title="Audiobook Upload">
|
||||
<FileUpload
|
||||
ref={audiobookFileUploadRef}
|
||||
accept=".aax"
|
||||
maxFileSize={10737418240}
|
||||
customUpload
|
||||
uploadHandler={() => void handleAudiobookUpload()}
|
||||
disabled={audiobookUploadBusy}
|
||||
onSelect={(e) => setAudiobookUploadFile(e.files[0] || null)}
|
||||
onClear={() => setAudiobookUploadFile(null)}
|
||||
onRemove={() => setAudiobookUploadFile(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={audiobookUploadBusy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
emptyTemplate={() => (
|
||||
<div className="aax-drop-zone">
|
||||
<i className="pi pi-headphones aax-drop-icon" />
|
||||
<p>AAX-Datei hier ablegen</p>
|
||||
<small>oder oben „Auswählen" klicken</small>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{audiobookUploadStatusVisible ? (
|
||||
<div className={`audiobook-upload-status tone-${audiobookUploadStatusTone}`}>
|
||||
<div className="audiobook-upload-status-head">
|
||||
<strong>{audiobookUploadStatusLabel}</strong>
|
||||
<Tag value={audiobookUploadStatusLabel} severity={audiobookUploadStatusTone} />
|
||||
</div>
|
||||
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
|
||||
{audiobookUploadFileName ? (
|
||||
<small className="audiobook-upload-file" title={audiobookUploadFileName}>
|
||||
Datei: {audiobookUploadFileName}
|
||||
</small>
|
||||
) : null}
|
||||
<div
|
||||
className="dashboard-job-row-progress audiobook-upload-progress"
|
||||
aria-label={`Audiobook Upload ${Math.round(audiobookUploadProgress)} Prozent`}
|
||||
>
|
||||
<ProgressBar value={audiobookUploadProgress} showValue={false} />
|
||||
<small>
|
||||
{audiobookUploadPhase === 'processing'
|
||||
? '100% | Upload fertig, Job wird vorbereitet ...'
|
||||
: audiobookUploadTotalBytes > 0
|
||||
? `${Math.round(audiobookUploadProgress)}% | ${formatBytes(audiobookUploadLoadedBytes)} / ${formatBytes(audiobookUploadTotalBytes)}`
|
||||
: `${Math.round(audiobookUploadProgress)}%`}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<div className="ripper-3col-grid">
|
||||
<div className="ripper-col ripper-col-left">
|
||||
<Card title="Disk-Information">
|
||||
{/* Per-drive list */}
|
||||
{allDrives.length > 0 ? (
|
||||
@@ -2888,20 +2740,20 @@ export default function DashboardPage({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-col dashboard-col-center">
|
||||
<div className="ripper-col ripper-col-center">
|
||||
{isSubpageRoute ? (
|
||||
<div className="dashboard-subpage-content">
|
||||
<div className="ripper-subpage-content">
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
|
||||
{jobsLoading && dashboardJobs.length === 0 ? (
|
||||
{jobsLoading && ripperJobs.length === 0 ? (
|
||||
<p>Jobs werden geladen ...</p>
|
||||
) : dashboardJobs.length === 0 ? (
|
||||
<p>Keine relevanten Jobs im Dashboard (aktive/fortsetzbare Status).</p>
|
||||
) : ripperJobs.length === 0 ? (
|
||||
<p>Keine relevanten Jobs im Ripper (aktive/fortsetzbare Status).</p>
|
||||
) : (
|
||||
<div className="dashboard-job-list">
|
||||
{dashboardJobs.map((job) => {
|
||||
<div className="ripper-job-list">
|
||||
{ripperJobs.map((job) => {
|
||||
const jobId = normalizeJobId(job?.id);
|
||||
if (!jobId) {
|
||||
return null;
|
||||
@@ -2945,15 +2797,15 @@ export default function DashboardPage({
|
||||
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={jobId} className="dashboard-job-expanded">
|
||||
<div className="dashboard-job-expanded-head">
|
||||
<div key={jobId} className="ripper-job-expanded">
|
||||
<div className="ripper-job-expanded-head">
|
||||
{(job?.poster_url && job.poster_url !== 'N/A') ? (
|
||||
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
|
||||
) : (
|
||||
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
|
||||
<div className="poster-thumb ripper-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
|
||||
)}
|
||||
<div className="dashboard-job-expanded-title">
|
||||
<strong className="dashboard-job-title-line">
|
||||
<div className="ripper-job-expanded-title">
|
||||
<strong className="ripper-job-title-line">
|
||||
<img
|
||||
src={mediaIndicator.src}
|
||||
alt={mediaIndicator.alt}
|
||||
@@ -2962,7 +2814,7 @@ export default function DashboardPage({
|
||||
/>
|
||||
<span>#{jobId} | {jobTitle}</span>
|
||||
</strong>
|
||||
<div className="dashboard-job-badges">
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||
@@ -2992,7 +2844,7 @@ export default function DashboardPage({
|
||||
onCancel={() => handleCancel(jobId, jobState)}
|
||||
onRetry={() => handleRetry(jobId)}
|
||||
onRestartReview={() => handleRestartCdReviewFromRaw(jobId)}
|
||||
onDeleteJob={() => handleDeleteDashboardJob(jobId)}
|
||||
onDeleteJob={() => handleDeleteRipperJob(jobId)}
|
||||
onOpenMetadata={() => {
|
||||
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
||||
? pipelineForJob.context
|
||||
@@ -3024,7 +2876,7 @@ export default function DashboardPage({
|
||||
onStart={(config) => handleAudiobookStart(jobId, config)}
|
||||
onCancel={() => handleCancel(jobId, jobState)}
|
||||
onRetry={() => handleRetry(jobId)}
|
||||
onDeleteJob={() => handleDeleteDashboardJob(jobId)}
|
||||
onDeleteJob={() => handleDeleteRipperJob(jobId)}
|
||||
busy={busyJobIds.has(jobId) || needsBytes}
|
||||
/>
|
||||
</>
|
||||
@@ -3048,7 +2900,7 @@ export default function DashboardPage({
|
||||
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
|
||||
onCancel={handleCancel}
|
||||
onRetry={handleRetry}
|
||||
onDeleteJob={handleDeleteDashboardJob}
|
||||
onDeleteJob={handleDeleteRipperJob}
|
||||
onRemoveFromQueue={handleRemoveQueuedJob}
|
||||
isQueued={isQueued}
|
||||
busy={busyJobIds.has(jobId)}
|
||||
@@ -3062,17 +2914,17 @@ export default function DashboardPage({
|
||||
<button
|
||||
key={jobId}
|
||||
type="button"
|
||||
className="dashboard-job-row"
|
||||
className="ripper-job-row"
|
||||
onClick={() => setExpandedJobId(jobId)}
|
||||
>
|
||||
{job?.poster_url && job.poster_url !== 'N/A' ? (
|
||||
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
|
||||
) : (
|
||||
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
|
||||
<div className="poster-thumb ripper-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
|
||||
)}
|
||||
<div className="dashboard-job-row-content">
|
||||
<div className="dashboard-job-row-main">
|
||||
<strong className="dashboard-job-title-line">
|
||||
<div className="ripper-job-row-content">
|
||||
<div className="ripper-job-row-main">
|
||||
<strong className="ripper-job-title-line">
|
||||
<img
|
||||
src={mediaIndicator.src}
|
||||
alt={mediaIndicator.alt}
|
||||
@@ -3095,7 +2947,7 @@ export default function DashboardPage({
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="dashboard-job-badges">
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||
@@ -3104,7 +2956,7 @@ export default function DashboardPage({
|
||||
: null}
|
||||
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
|
||||
</div>
|
||||
<div className="dashboard-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
|
||||
<div className="ripper-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
|
||||
<ProgressBar value={clampedProgress} showValue={false} />
|
||||
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
|
||||
</div>
|
||||
@@ -3119,7 +2971,7 @@ export default function DashboardPage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-col dashboard-col-right">
|
||||
<div className="ripper-col ripper-col-right">
|
||||
<Card title="Job Queue" subTitle="Elemente können per Drag-and-Drop umsortiert werden.">
|
||||
<div className="pipeline-queue-meta">
|
||||
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
||||
@@ -3128,12 +2980,12 @@ export default function DashboardPage({
|
||||
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio/CD-Jobs überspringen die Film-Queue-Reihenfolge" />}
|
||||
<Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
|
||||
<Tag value={`Audio/CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
|
||||
<Tag value={`Idle: ${queueState?.idleCount || 0}`} severity={(queueState?.idleCount || 0) > 0 ? 'info' : 'success'} />
|
||||
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
|
||||
<Tag value={`Idle: ${queueState?.idleCount || 0}`} severity={(queueState?.idleCount || 0) > 0 ? 'info' : 'success'} />
|
||||
</div>
|
||||
|
||||
<div className="pipeline-queue-grid">
|
||||
<div className="pipeline-queue-col">
|
||||
<div className="pipeline-queue-col queue-col-running">
|
||||
<h4>Laufende Jobs</h4>
|
||||
{queueRunningJobs.length === 0 ? (
|
||||
<small>Keine laufenden Jobs.</small>
|
||||
@@ -3177,7 +3029,7 @@ export default function DashboardPage({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="pipeline-queue-col">
|
||||
<div className="pipeline-queue-col queue-col-idle">
|
||||
<h4>Idle</h4>
|
||||
{queueIdleJobs.length === 0 ? (
|
||||
<small>Keine Idle-Jobs.</small>
|
||||
@@ -3215,7 +3067,7 @@ export default function DashboardPage({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="pipeline-queue-col">
|
||||
<div className="pipeline-queue-col queue-col-queued">
|
||||
<div className="pipeline-queue-col-header">
|
||||
<h4>Warteschlange</h4>
|
||||
<button
|
||||
@@ -3489,9 +3341,9 @@ export default function DashboardPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dashboard-downloads-row">
|
||||
<div className="ripper-downloads-row">
|
||||
<h4>ZIP-Downloads</h4>
|
||||
<div className="dashboard-downloads-meta">
|
||||
<div className="ripper-downloads-meta">
|
||||
{downloadSummary?.activeCount > 0 ? (
|
||||
<Tag icon="pi pi-spinner" value={`${downloadSummary.activeCount} aktiv`} severity="warning" />
|
||||
) : downloadSummary?.totalCount > 0 ? (
|
||||
+186
-40
@@ -13,7 +13,7 @@
|
||||
--rip-muted: #6a4d38;
|
||||
--rip-panel: #fffaf1;
|
||||
--rip-panel-soft: #fdf5e7;
|
||||
--dashboard-side-width: 20rem;
|
||||
--ripper-side-width: 20rem;
|
||||
|
||||
/* PrimeReact theme tokens */
|
||||
--primary-color: var(--rip-brown-600);
|
||||
@@ -318,22 +318,22 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-3col-grid {
|
||||
.ripper-3col-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: var(--dashboard-side-width) minmax(0, 1fr) var(--dashboard-side-width);
|
||||
grid-template-columns: var(--ripper-side-width) minmax(0, 1fr) var(--ripper-side-width);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard-col {
|
||||
.ripper-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-subpage-content {
|
||||
.ripper-subpage-content {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -341,21 +341,121 @@ body {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-subpage-content > * {
|
||||
.ripper-subpage-content > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-inbox-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.job-inbox-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.job-inbox-filter.p-button,
|
||||
.job-inbox-filter-active.p-button {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.job-inbox-search-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.job-inbox-search-input {
|
||||
width: min(40rem, 100%);
|
||||
}
|
||||
|
||||
.job-inbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.job-inbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.job-inbox-row-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.22rem;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.job-inbox-row-main strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-inbox-row-main small {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.job-inbox-row-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.job-inbox-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.job-inbox-pagination {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.job-inbox-pagination .p-paginator {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.job-inbox-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.job-inbox-row-tags,
|
||||
.job-inbox-row-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.job-inbox-pagination .p-paginator {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.dashboard-3col-grid {
|
||||
.ripper-3col-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
|
||||
}
|
||||
.dashboard-col-right {
|
||||
.ripper-col-right {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.dashboard-3col-grid {
|
||||
.ripper-3col-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -436,7 +536,7 @@ body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dashboard-drive-state {
|
||||
.ripper-drive-state {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@@ -812,6 +912,18 @@ body {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.pipeline-queue-col.queue-col-running {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.pipeline-queue-col.queue-col-queued {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.pipeline-queue-col.queue-col-idle {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.pipeline-queue-col h4 {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1164,12 +1276,12 @@ body {
|
||||
height: 1.9rem;
|
||||
}
|
||||
|
||||
.dashboard-job-list {
|
||||
.ripper-job-list {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.dashboard-job-row {
|
||||
.ripper-job-row {
|
||||
width: 100%;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.55rem;
|
||||
@@ -1184,12 +1296,12 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-job-row:hover {
|
||||
.ripper-job-row:hover {
|
||||
border-color: var(--rip-brown-600);
|
||||
background: #fbf0df;
|
||||
}
|
||||
|
||||
.dashboard-job-row-content {
|
||||
.ripper-job-row-content {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -1197,27 +1309,27 @@ body {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard-job-row-main {
|
||||
.ripper-job-row-main {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-job-row-main strong,
|
||||
.dashboard-job-row-main small {
|
||||
.ripper-job-row-main strong,
|
||||
.ripper-job-row-main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-job-title-line {
|
||||
.ripper-job-title-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-job-title-line > span {
|
||||
.ripper-job-title-line > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1231,17 +1343,17 @@ body {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dashboard-job-row-progress {
|
||||
.ripper-job-row-progress {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.dashboard-job-row-progress .p-progressbar {
|
||||
.ripper-job-row-progress .p-progressbar {
|
||||
height: 0.42rem;
|
||||
}
|
||||
|
||||
.dashboard-job-row-progress small {
|
||||
.ripper-job-row-progress small {
|
||||
color: var(--rip-muted);
|
||||
white-space: normal;
|
||||
}
|
||||
@@ -1367,7 +1479,7 @@ body {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.dashboard-job-badges {
|
||||
.ripper-job-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
@@ -1422,7 +1534,7 @@ body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard-job-poster-fallback {
|
||||
.ripper-job-poster-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1431,7 +1543,7 @@ body {
|
||||
background: #f6ebd6;
|
||||
}
|
||||
|
||||
.dashboard-job-expanded {
|
||||
.ripper-job-expanded {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.6rem;
|
||||
@@ -1440,14 +1552,14 @@ body {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.dashboard-job-expanded-head {
|
||||
.ripper-job-expanded-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-job-expanded-title {
|
||||
.ripper-job-expanded-title {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
flex: 1;
|
||||
@@ -1885,7 +1997,27 @@ body {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Per-drive CD sections in Dashboard */
|
||||
.converter-scan-extensions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7.2rem, 1fr));
|
||||
gap: 0.45rem 0.7rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.converter-scan-extension-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.converter-scan-extension-option span {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Per-drive CD sections in Ripper */
|
||||
/* Unified per-drive list (Disk-Information card) */
|
||||
.drive-list {
|
||||
display: flex;
|
||||
@@ -3082,18 +3214,18 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-downloads-row {
|
||||
.ripper-downloads-row {
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-downloads-row h4 {
|
||||
.ripper-downloads-row h4 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.dashboard-downloads-meta {
|
||||
.ripper-downloads-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -3802,15 +3934,15 @@ body {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-job-row {
|
||||
.ripper-job-row {
|
||||
grid-template-columns: 48px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.dashboard-job-badges {
|
||||
.ripper-job-badges {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dashboard-job-expanded-head {
|
||||
.ripper-job-expanded-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -3987,18 +4119,18 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-job-row {
|
||||
.ripper-job-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-job-row .poster-thumb,
|
||||
.dashboard-job-row .dashboard-job-poster-fallback {
|
||||
.ripper-job-row .poster-thumb,
|
||||
.ripper-job-row .ripper-job-poster-fallback {
|
||||
width: 52px;
|
||||
height: 76px;
|
||||
}
|
||||
|
||||
.dashboard-job-row-main strong,
|
||||
.dashboard-job-row-main small {
|
||||
.ripper-job-row-main strong,
|
||||
.ripper-job-row-main small {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@@ -4007,7 +4139,7 @@ body {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.dashboard-job-title-line > span {
|
||||
.ripper-job-title-line > span {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@@ -5248,6 +5380,20 @@ body {
|
||||
border-radius: 0 0 11px 11px;
|
||||
}
|
||||
|
||||
.explorer-loading,
|
||||
.explorer-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.audiobook-output-explorer .audiobook-output-row {
|
||||
grid-template-columns: minmax(180px, 2fr) minmax(96px, 0.8fr) minmax(160px, 1.2fr);
|
||||
}
|
||||
|
||||
/* ── Icon-Button (wie Klangkiste) ────────────────────────────────────────── */
|
||||
|
||||
.icon-button {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
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 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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user