Initial commit mit MkDocs-Dokumentation
This commit is contained in:
5
frontend/.env.example
Normal file
5
frontend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# Optional: komplett explizite API-Basis (sonst /api via Vite-Proxy)
|
||||
# VITE_API_BASE=http://10.10.10.24:3001/api
|
||||
|
||||
# Optional: expliziter WS-Endpunkt (sonst ws(s)://<host>/ws)
|
||||
# VITE_WS_URL=ws://10.10.10.24:3001/ws
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ripster</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1713
frontend/package-lock.json
generated
Normal file
1713
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.12"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 280 KiB |
98
frontend/src/App.jsx
Normal file
98
frontend/src/App.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button } from 'primereact/button';
|
||||
import { api } from './api/client';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import DatabasePage from './pages/DatabasePage';
|
||||
|
||||
function App() {
|
||||
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const refreshPipeline = async () => {
|
||||
const response = await api.getPipelineState();
|
||||
setPipeline(response.pipeline);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshPipeline().catch(() => null);
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
onMessage: (message) => {
|
||||
if (message.type === 'PIPELINE_STATE_CHANGED') {
|
||||
setPipeline(message.payload);
|
||||
}
|
||||
|
||||
if (message.type === 'PIPELINE_PROGRESS') {
|
||||
setPipeline((prev) => ({
|
||||
...prev,
|
||||
...message.payload
|
||||
}));
|
||||
}
|
||||
|
||||
if (message.type === 'DISC_DETECTED') {
|
||||
setLastDiscEvent(message.payload?.device || null);
|
||||
}
|
||||
|
||||
if (message.type === 'DISC_REMOVED') {
|
||||
setLastDiscEvent(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const nav = [
|
||||
{ label: 'Dashboard', path: '/' },
|
||||
{ label: 'Settings', path: '/settings' },
|
||||
{ label: 'Historie', path: '/history' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<div className="brand-block">
|
||||
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
|
||||
<div className="brand-copy">
|
||||
<h1>Ripster</h1>
|
||||
<p>Disc Ripping Control Center</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="nav-buttons">
|
||||
{nav.map((item) => (
|
||||
<Button
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<DashboardPage
|
||||
pipeline={pipeline}
|
||||
lastDiscEvent={lastDiscEvent}
|
||||
refreshPipeline={refreshPipeline}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/history" element={<DatabasePage />} />
|
||||
<Route path="/database" element={<DatabasePage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
175
frontend/src/api/client.js
Normal file
175
frontend/src/api/client.js
Normal file
@@ -0,0 +1,175 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorPayload = null;
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
errorPayload = await response.json();
|
||||
message = errorPayload?.error?.message || message;
|
||||
} catch (_error) {
|
||||
// ignore parse errors
|
||||
}
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
error.details = errorPayload?.error?.details || null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getSettings() {
|
||||
return request('/settings');
|
||||
},
|
||||
updateSetting(key, value) {
|
||||
return request(`/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value })
|
||||
});
|
||||
},
|
||||
updateSettingsBulk(settings) {
|
||||
return request('/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ settings })
|
||||
});
|
||||
},
|
||||
testPushover(payload = {}) {
|
||||
return request('/settings/pushover/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
getPipelineState() {
|
||||
return request('/pipeline/state');
|
||||
},
|
||||
analyzeDisc() {
|
||||
return request('/pipeline/analyze', {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
rescanDisc() {
|
||||
return request('/pipeline/rescan-disc', {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
searchOmdb(q) {
|
||||
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
|
||||
},
|
||||
selectMetadata(payload) {
|
||||
return request('/pipeline/select-metadata', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
startJob(jobId) {
|
||||
return request(`/pipeline/start/${jobId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
confirmEncodeReview(jobId, payload = {}) {
|
||||
return request(`/pipeline/confirm-encode/${jobId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
},
|
||||
cancelPipeline() {
|
||||
return request('/pipeline/cancel', {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
retryJob(jobId) {
|
||||
return request(`/pipeline/retry/${jobId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
resumeReadyJob(jobId) {
|
||||
return request(`/pipeline/resume-ready/${jobId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
reencodeJob(jobId) {
|
||||
return request(`/pipeline/reencode/${jobId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
restartEncodeWithLastSettings(jobId) {
|
||||
return request(`/pipeline/restart-encode/${jobId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
getJobs(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.status) query.set('status', params.status);
|
||||
if (params.search) query.set('search', params.search);
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return request(`/history${suffix}`);
|
||||
},
|
||||
getDatabaseRows(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.status) query.set('status', params.status);
|
||||
if (params.search) query.set('search', params.search);
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return request(`/history/database${suffix}`);
|
||||
},
|
||||
getOrphanRawFolders() {
|
||||
return request('/history/orphan-raw');
|
||||
},
|
||||
importOrphanRawFolder(rawPath) {
|
||||
return request('/history/orphan-raw/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rawPath })
|
||||
});
|
||||
},
|
||||
assignJobOmdb(jobId, payload = {}) {
|
||||
return request(`/history/${jobId}/omdb/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
},
|
||||
deleteJobFiles(jobId, target = 'both') {
|
||||
return request(`/history/${jobId}/delete-files`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target })
|
||||
});
|
||||
},
|
||||
deleteJobEntry(jobId, target = 'none') {
|
||||
return request(`/history/${jobId}/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target })
|
||||
});
|
||||
},
|
||||
getJob(jobId, options = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (options.includeLiveLog) {
|
||||
query.set('includeLiveLog', '1');
|
||||
}
|
||||
if (options.includeLogs) {
|
||||
query.set('includeLogs', '1');
|
||||
}
|
||||
if (options.includeAllLogs) {
|
||||
query.set('includeAllLogs', '1');
|
||||
}
|
||||
if (Number.isFinite(Number(options.logTailLines)) && Number(options.logTailLines) > 0) {
|
||||
query.set('logTailLines', String(Math.trunc(Number(options.logTailLines))));
|
||||
}
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return request(`/history/${jobId}${suffix}`);
|
||||
}
|
||||
};
|
||||
|
||||
export { API_BASE };
|
||||
11
frontend/src/assets/media-bluray.svg
Normal file
11
frontend/src/assets/media-bluray.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Blu-ray">
|
||||
<defs>
|
||||
<linearGradient id="brg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#2f9bff"/>
|
||||
<stop offset="100%" stop-color="#0a3f86"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="30" fill="url(#brg)"/>
|
||||
<circle cx="32" cy="32" r="24" fill="none" stroke="#9cd4ff" stroke-width="2.5" opacity="0.7"/>
|
||||
<text x="32" y="38" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="18" font-weight="700" fill="#ffffff">BR</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 588 B |
13
frontend/src/assets/media-disc.svg
Normal file
13
frontend/src/assets/media-disc.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Disc">
|
||||
<defs>
|
||||
<radialGradient id="cdg" cx="40%" cy="35%" r="70%">
|
||||
<stop offset="0%" stop-color="#ffffff"/>
|
||||
<stop offset="70%" stop-color="#d8dde5"/>
|
||||
<stop offset="100%" stop-color="#8f98a6"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="30" fill="url(#cdg)"/>
|
||||
<circle cx="32" cy="32" r="9" fill="#f7f9fc" stroke="#9ca6b5" stroke-width="2"/>
|
||||
<path d="M15 25 A20 20 0 0 1 48 18" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" opacity="0.85"/>
|
||||
<text x="32" y="54" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="11" font-weight="700" fill="#3f4a5d">CD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 741 B |
39
frontend/src/components/DiscDetectedDialog.jsx
Normal file
39
frontend/src/components/DiscDetectedDialog.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
|
||||
export default function DiscDetectedDialog({ visible, device, onHide, onAnalyze, busy }) {
|
||||
return (
|
||||
<Dialog
|
||||
header="Neue Disk erkannt"
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
style={{ width: '32rem', maxWidth: '96vw' }}
|
||||
className="disc-detected-dialog"
|
||||
breakpoints={{ '768px': '96vw', '560px': '98vw' }}
|
||||
modal
|
||||
>
|
||||
<p>
|
||||
Laufwerk: <strong>{device?.path || 'unbekannt'}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Disk-Label: <strong>{device?.discLabel || 'n/a'}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Laufwerks-Label: <strong>{device?.label || 'n/a'}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Modell: <strong>{device?.model || 'n/a'}</strong>
|
||||
</p>
|
||||
|
||||
<div className="dialog-actions">
|
||||
<Button label="Schließen" severity="secondary" onClick={onHide} text />
|
||||
<Button
|
||||
label="Analyse starten"
|
||||
icon="pi pi-search"
|
||||
onClick={onAnalyze}
|
||||
loading={busy}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
224
frontend/src/components/DynamicSettingsForm.jsx
Normal file
224
frontend/src/components/DynamicSettingsForm.jsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Tag } from 'primereact/tag';
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeSettingKey(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildToolSections(settings) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
const definitions = [
|
||||
{
|
||||
id: 'makemkv',
|
||||
title: 'MakeMKV',
|
||||
description: 'Disc-Analyse und Rip-Einstellungen.',
|
||||
match: (key) => key.startsWith('makemkv_')
|
||||
},
|
||||
{
|
||||
id: 'mediainfo',
|
||||
title: 'MediaInfo',
|
||||
description: 'Track-Analyse und zusätzliche mediainfo Parameter.',
|
||||
match: (key) => key.startsWith('mediainfo_')
|
||||
},
|
||||
{
|
||||
id: 'handbrake',
|
||||
title: 'HandBrake',
|
||||
description: 'Preset, Encoding-CLI und HandBrake-Optionen.',
|
||||
match: (key) => key.startsWith('handbrake_')
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
title: 'Output',
|
||||
description: 'Container-Format und Dateinamen-Template.',
|
||||
match: (key) => key === 'output_extension' || key === 'filename_template'
|
||||
}
|
||||
];
|
||||
|
||||
const buckets = definitions.map((item) => ({
|
||||
...item,
|
||||
settings: []
|
||||
}));
|
||||
const fallbackBucket = {
|
||||
id: 'other',
|
||||
title: 'Weitere Tool-Settings',
|
||||
description: null,
|
||||
settings: []
|
||||
};
|
||||
|
||||
for (const setting of list) {
|
||||
const key = normalizeSettingKey(setting?.key);
|
||||
let assigned = false;
|
||||
for (const bucket of buckets) {
|
||||
if (bucket.match(key)) {
|
||||
bucket.settings.push(setting);
|
||||
assigned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!assigned) {
|
||||
fallbackBucket.settings.push(setting);
|
||||
}
|
||||
}
|
||||
|
||||
const sections = buckets.filter((item) => item.settings.length > 0);
|
||||
if (fallbackBucket.settings.length > 0) {
|
||||
sections.push(fallbackBucket);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function buildSectionsForCategory(categoryName, settings) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
const normalizedCategory = normalizeText(categoryName);
|
||||
if (normalizedCategory === 'tools') {
|
||||
const sections = buildToolSections(list);
|
||||
if (sections.length > 0) {
|
||||
return sections;
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
title: null,
|
||||
description: null,
|
||||
settings: list
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export default function DynamicSettingsForm({
|
||||
categories,
|
||||
values,
|
||||
errors,
|
||||
dirtyKeys,
|
||||
onChange
|
||||
}) {
|
||||
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (safeCategories.length === 0) {
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
if (activeIndex < 0 || activeIndex >= safeCategories.length) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}, [activeIndex, safeCategories.length]);
|
||||
|
||||
if (safeCategories.length === 0) {
|
||||
return <p>Keine Kategorien vorhanden.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TabView
|
||||
className="settings-tabview"
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={(event) => setActiveIndex(Number(event.index || 0))}
|
||||
scrollable
|
||||
>
|
||||
{safeCategories.map((category, categoryIndex) => (
|
||||
<TabPanel
|
||||
key={`${category.category || 'category'}-${categoryIndex}`}
|
||||
header={category.category || `Kategorie ${categoryIndex + 1}`}
|
||||
>
|
||||
{(() => {
|
||||
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
|
||||
const grouped = sections.length > 1;
|
||||
|
||||
return (
|
||||
<div className="settings-sections">
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={`${category?.category || 'category'}-${section.id}`}
|
||||
className={`settings-section${grouped ? ' grouped' : ''}`}
|
||||
>
|
||||
{section.title ? (
|
||||
<div className="settings-section-head">
|
||||
<h4>{section.title}</h4>
|
||||
{section.description ? <small>{section.description}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="settings-grid">
|
||||
{(section.settings || []).map((setting) => {
|
||||
const value = values?.[setting.key];
|
||||
const error = errors?.[setting.key] || null;
|
||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||
|
||||
return (
|
||||
<div key={setting.key} className="setting-row">
|
||||
<label htmlFor={setting.key}>
|
||||
{setting.label}
|
||||
{setting.required && <span className="required">*</span>}
|
||||
</label>
|
||||
|
||||
{setting.type === 'string' || setting.type === 'path' ? (
|
||||
<InputText
|
||||
id={setting.key}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange?.(setting.key, event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'number' ? (
|
||||
<InputNumber
|
||||
id={setting.key}
|
||||
value={value ?? 0}
|
||||
onValueChange={(event) => onChange?.(setting.key, event.value)}
|
||||
mode="decimal"
|
||||
useGrouping={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'boolean' ? (
|
||||
<InputSwitch
|
||||
id={setting.key}
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'select' ? (
|
||||
<Dropdown
|
||||
id={setting.key}
|
||||
value={value}
|
||||
options={setting.options}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<small>{setting.description || ''}</small>
|
||||
{error ? (
|
||||
<small className="error-text">{error}</small>
|
||||
) : (
|
||||
<Tag
|
||||
value={dirty ? 'Ungespeichert' : 'Gespeichert'}
|
||||
severity={dirty ? 'warning' : 'success'}
|
||||
className="saved-tag"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabView>
|
||||
);
|
||||
}
|
||||
230
frontend/src/components/JobDetailDialog.jsx
Normal file
230
frontend/src/components/JobDetailDialog.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Button } from 'primereact/button';
|
||||
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
||||
|
||||
function JsonView({ title, value }) {
|
||||
return (
|
||||
<div>
|
||||
<h4>{title}</h4>
|
||||
<pre className="json-box">{value ? JSON.stringify(value, null, 2) : '-'}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobDetailDialog({
|
||||
visible,
|
||||
job,
|
||||
onHide,
|
||||
detailLoading = false,
|
||||
onLoadLog,
|
||||
logLoadingMode = null,
|
||||
onAssignOmdb,
|
||||
onReencode,
|
||||
onDeleteFiles,
|
||||
onDeleteEntry,
|
||||
omdbAssignBusy = false,
|
||||
actionBusy = false,
|
||||
reencodeBusy = false,
|
||||
deleteEntryBusy = false
|
||||
}) {
|
||||
const mkDone = !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
||||
const showFinalLog = !running;
|
||||
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && mkDone && !running);
|
||||
const canDeleteEntry = !running;
|
||||
const logCount = Number(job?.log_count || 0);
|
||||
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
||||
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
||||
const logTruncated = Boolean(logMeta?.truncated);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header={`Job #${job?.id || ''}`}
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
style={{ width: '70rem', maxWidth: '96vw' }}
|
||||
className="job-detail-dialog"
|
||||
breakpoints={{ '1440px': '94vw', '1024px': '96vw', '640px': '98vw' }}
|
||||
modal
|
||||
>
|
||||
{!job ? null : (
|
||||
<>
|
||||
{detailLoading ? <p>Details werden geladen ...</p> : null}
|
||||
|
||||
<div className="job-head-row">
|
||||
{job.poster_url && job.poster_url !== 'N/A' ? (
|
||||
<img src={job.poster_url} alt={job.title || 'Poster'} className="poster-large" />
|
||||
) : (
|
||||
<div className="poster-large poster-fallback">Kein Poster</div>
|
||||
)}
|
||||
|
||||
<div className="job-meta-grid">
|
||||
<div>
|
||||
<strong>Titel:</strong> {job.title || job.detected_title || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Jahr:</strong> {job.year || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>IMDb:</strong> {job.imdb_id || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>OMDb Match:</strong>{' '}
|
||||
<Tag value={job.selected_from_omdb ? 'Ja' : 'Nein'} severity={job.selected_from_omdb ? 'success' : 'secondary'} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong> <Tag value={job.status} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Start:</strong> {job.start_time || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Ende:</strong> {job.end_time || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>RAW Pfad:</strong> {job.raw_path || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Output:</strong> {job.output_path || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Mediainfo bestätigt:</strong> {job.encode_review_confirmed ? 'ja' : 'nein'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>RAW vorhanden:</strong> {job.rawStatus?.exists ? 'ja' : 'nein'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>RAW leer:</strong> {job.rawStatus?.isEmpty === null ? '-' : job.rawStatus?.isEmpty ? 'ja' : 'nein'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Movie Datei vorhanden:</strong> {job.outputStatus?.exists ? 'ja' : 'nein'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Movie-Dir leer:</strong> {job.movieDirStatus?.isEmpty === null ? '-' : job.movieDirStatus?.isEmpty ? 'ja' : 'nein'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Fehler:</strong> {job.error_message || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="job-json-grid">
|
||||
<JsonView title="OMDb Info" value={job.omdbInfo} />
|
||||
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />
|
||||
<JsonView title="HandBrake Info" value={job.handbrakeInfo} />
|
||||
<JsonView title="Mediainfo Info" value={job.mediainfoInfo} />
|
||||
<JsonView title="Encode Plan" value={job.encodePlan} />
|
||||
</div>
|
||||
|
||||
{job.encodePlan ? (
|
||||
<>
|
||||
<h4>Mediainfo-Prüfung (Auswertung)</h4>
|
||||
<MediaInfoReviewPanel review={job.encodePlan} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<h4>Aktionen</h4>
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="OMDb neu zuordnen"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
onClick={() => onAssignOmdb?.(job)}
|
||||
loading={omdbAssignBusy}
|
||||
disabled={running}
|
||||
/>
|
||||
<Button
|
||||
label="RAW neu encodieren"
|
||||
icon="pi pi-cog"
|
||||
severity="info"
|
||||
size="small"
|
||||
onClick={() => onReencode?.(job)}
|
||||
loading={reencodeBusy}
|
||||
disabled={!canReencode}
|
||||
/>
|
||||
<Button
|
||||
label="RAW löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteFiles?.(job, 'raw')}
|
||||
loading={actionBusy}
|
||||
disabled={!job.rawStatus?.exists}
|
||||
/>
|
||||
<Button
|
||||
label="Movie löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteFiles?.(job, 'movie')}
|
||||
loading={actionBusy}
|
||||
disabled={!job.outputStatus?.exists}
|
||||
/>
|
||||
<Button
|
||||
label="Beides löschen"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
size="small"
|
||||
onClick={() => onDeleteFiles?.(job, 'both')}
|
||||
loading={actionBusy}
|
||||
disabled={!job.rawStatus?.exists && !job.outputStatus?.exists}
|
||||
/>
|
||||
<Button
|
||||
label="Historieneintrag löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteEntry?.(job)}
|
||||
loading={deleteEntryBusy}
|
||||
disabled={!canDeleteEntry}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h4>Log</h4>
|
||||
{showFinalLog ? (
|
||||
<>
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label={logLoaded ? 'Tail neu laden (800)' : 'Tail laden (800)'}
|
||||
icon="pi pi-download"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onLoadLog?.(job, 'tail')}
|
||||
loading={logLoadingMode === 'tail'}
|
||||
/>
|
||||
<Button
|
||||
label="Vollständiges Log laden"
|
||||
icon="pi pi-list"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onLoadLog?.(job, 'all')}
|
||||
loading={logLoadingMode === 'all'}
|
||||
disabled={logCount <= 0}
|
||||
/>
|
||||
<small>{`Log-Zeilen: ${logCount}`}</small>
|
||||
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
|
||||
</div>
|
||||
{logLoaded ? (
|
||||
<pre className="log-box">{job.log || ''}</pre>
|
||||
) : (
|
||||
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>Live-Log wird nur im Dashboard während laufender Analyse/Rip/Encode angezeigt.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
827
frontend/src/components/MediaInfoReviewPanel.jsx
Normal file
827
frontend/src/components/MediaInfoReviewPanel.jsx
Normal file
@@ -0,0 +1,827 @@
|
||||
function formatDuration(minutes) {
|
||||
const value = Number(minutes || 0);
|
||||
if (!Number.isFinite(value)) {
|
||||
return '-';
|
||||
}
|
||||
return `${value.toFixed(2)} min`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '-';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = value;
|
||||
let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${size.toFixed(2)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function normalizeTrackId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeTrackIdList(values) {
|
||||
const list = Array.isArray(values) ? values : [];
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const value of list) {
|
||||
const normalized = normalizeTrackId(value);
|
||||
if (normalized === null) {
|
||||
continue;
|
||||
}
|
||||
const key = String(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function splitArgs(input) {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const args = [];
|
||||
let current = '';
|
||||
let quote = null;
|
||||
let escaping = false;
|
||||
|
||||
for (const ch of input) {
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '\\') {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(ch)) {
|
||||
if (current.length > 0) {
|
||||
args.push(current);
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
args.push(current);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
const AUDIO_SELECTION_KEYS_WITH_VALUE = new Set(['-a', '--audio', '--audio-lang-list']);
|
||||
const AUDIO_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-audio', '--first-audio']);
|
||||
const SUBTITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-s', '--subtitle', '--subtitle-lang-list']);
|
||||
const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-subtitle']);
|
||||
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||
|
||||
function removeSelectionArgs(extraArgs) {
|
||||
const args = Array.isArray(extraArgs) ? extraArgs : [];
|
||||
const filtered = [];
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const token = String(args[i] || '');
|
||||
const key = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
|
||||
|
||||
const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key);
|
||||
const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key);
|
||||
const isSubtitleWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key)
|
||||
|| SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key);
|
||||
const isSubtitleFlagOnly = SUBTITLE_SELECTION_KEYS_FLAG_ONLY.has(key);
|
||||
const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key);
|
||||
const skip = isAudioWithValue || isAudioFlagOnly || isSubtitleWithValue || isSubtitleFlagOnly || isTitleWithValue;
|
||||
|
||||
if (!skip) {
|
||||
filtered.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((isAudioWithValue || isSubtitleWithValue || isTitleWithValue) && !token.includes('=')) {
|
||||
const nextToken = String(args[i + 1] || '');
|
||||
if (nextToken && !nextToken.startsWith('-')) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
const raw = String(value ?? '');
|
||||
if (raw.length === 0) {
|
||||
return "''";
|
||||
}
|
||||
if (/^[A-Za-z0-9_./:=,+-]+$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return `'${raw.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildHandBrakeCommandPreview({
|
||||
review,
|
||||
title,
|
||||
selectedAudioTrackIds,
|
||||
selectedSubtitleTrackIds,
|
||||
commandOutputPath = null
|
||||
}) {
|
||||
const inputPath = String(title?.filePath || review?.encodeInputPath || '').trim();
|
||||
const handBrakeCmd = String(
|
||||
review?.selectors?.handbrakeCommand
|
||||
|| review?.selectors?.handBrakeCommand
|
||||
|| 'HandBrakeCLI'
|
||||
).trim() || 'HandBrakeCLI';
|
||||
const preset = String(review?.selectors?.preset || '').trim();
|
||||
const extraArgs = String(review?.selectors?.extraArgs || '').trim();
|
||||
const rawMappedTitleId = Number(review?.handBrakeTitleId);
|
||||
const mappedTitleId = Number.isFinite(rawMappedTitleId) && rawMappedTitleId > 0
|
||||
? Math.trunc(rawMappedTitleId)
|
||||
: null;
|
||||
|
||||
const selectedSubtitleSet = new Set(normalizeTrackIdList(selectedSubtitleTrackIds).map((id) => String(id)));
|
||||
const selectedSubtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).filter((track) => {
|
||||
const id = normalizeTrackId(track?.id);
|
||||
return id !== null && selectedSubtitleSet.has(String(id));
|
||||
});
|
||||
|
||||
const subtitleBurnTrackId = normalizeTrackIdList(
|
||||
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewBurnIn || track?.burnIn)).map((track) => track?.id)
|
||||
)[0] || null;
|
||||
const subtitleDefaultTrackId = normalizeTrackIdList(
|
||||
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id)
|
||||
)[0] || null;
|
||||
const subtitleForcedTrackId = normalizeTrackIdList(
|
||||
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewForced || track?.forced)).map((track) => track?.id)
|
||||
)[0] || null;
|
||||
const subtitleForcedOnly = selectedSubtitleTracks.some((track) => Boolean(track?.subtitlePreviewForcedOnly || track?.forcedOnly));
|
||||
|
||||
const baseArgs = [
|
||||
'-i',
|
||||
inputPath || '<encode-input>',
|
||||
'-o',
|
||||
String(commandOutputPath || '').trim() || '<encode-output>'
|
||||
];
|
||||
|
||||
if (mappedTitleId !== null) {
|
||||
baseArgs.push('-t', String(mappedTitleId));
|
||||
}
|
||||
|
||||
if (preset) {
|
||||
baseArgs.push('-Z', preset);
|
||||
}
|
||||
|
||||
const filteredExtra = removeSelectionArgs(splitArgs(extraArgs));
|
||||
const overrideArgs = [
|
||||
'-a',
|
||||
normalizeTrackIdList(selectedAudioTrackIds).join(',') || 'none',
|
||||
'-s',
|
||||
normalizeTrackIdList(selectedSubtitleTrackIds).join(',') || 'none'
|
||||
];
|
||||
|
||||
if (subtitleBurnTrackId !== null) {
|
||||
overrideArgs.push(`--subtitle-burned=${subtitleBurnTrackId}`);
|
||||
}
|
||||
if (subtitleDefaultTrackId !== null) {
|
||||
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
|
||||
}
|
||||
if (subtitleForcedTrackId !== null) {
|
||||
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
|
||||
} else if (subtitleForcedOnly) {
|
||||
overrideArgs.push('--subtitle-forced');
|
||||
}
|
||||
|
||||
const finalArgs = [...baseArgs, ...filteredExtra, ...overrideArgs];
|
||||
return `${handBrakeCmd} ${finalArgs.map((arg) => shellQuote(arg)).join(' ')}`;
|
||||
}
|
||||
|
||||
function toLang2(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return 'und';
|
||||
}
|
||||
const map = {
|
||||
en: 'en',
|
||||
eng: 'en',
|
||||
de: 'de',
|
||||
deu: 'de',
|
||||
ger: 'de',
|
||||
tr: 'tr',
|
||||
tur: 'tr',
|
||||
fr: 'fr',
|
||||
fra: 'fr',
|
||||
fre: 'fr',
|
||||
es: 'es',
|
||||
spa: 'es',
|
||||
it: 'it',
|
||||
ita: 'it'
|
||||
};
|
||||
if (map[raw]) {
|
||||
return map[raw];
|
||||
}
|
||||
if (raw.length === 2) {
|
||||
return raw;
|
||||
}
|
||||
if (raw.length >= 3) {
|
||||
return raw.slice(0, 2);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function simplifyCodec(type, value, hint = null) {
|
||||
const raw = String(value || '').trim();
|
||||
const hintRaw = String(hint || '').trim();
|
||||
const lower = raw.toLowerCase();
|
||||
const merged = `${raw} ${hintRaw}`.toLowerCase();
|
||||
if (!raw) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (type === 'subtitle') {
|
||||
if (merged.includes('pgs')) {
|
||||
return 'PGS';
|
||||
}
|
||||
return raw.toUpperCase();
|
||||
}
|
||||
|
||||
if (merged.includes('dts-hd ma') || merged.includes('dts hd ma')) {
|
||||
return 'DTS-HD MA';
|
||||
}
|
||||
if (merged.includes('dts-hd hra') || merged.includes('dts hd hra')) {
|
||||
return 'DTS-HD HRA';
|
||||
}
|
||||
if (merged.includes('dts-hd') || merged.includes('dts hd')) {
|
||||
return 'DTS-HD';
|
||||
}
|
||||
if (merged.includes('dts') || merged.includes('dca')) {
|
||||
return 'DTS';
|
||||
}
|
||||
if (merged.includes('truehd')) {
|
||||
return 'TRUEHD';
|
||||
}
|
||||
if (merged.includes('e-ac-3') || merged.includes('eac3') || merged.includes('dd+')) {
|
||||
return 'E-AC-3';
|
||||
}
|
||||
if (merged.includes('ac-3') || merged.includes('ac3') || merged.includes('dolby digital')) {
|
||||
return 'AC-3';
|
||||
}
|
||||
|
||||
const numeric = Number(raw);
|
||||
if (Number.isFinite(numeric)) {
|
||||
if (numeric === 262144) {
|
||||
return 'DTS-HD';
|
||||
}
|
||||
if (numeric === 131072) {
|
||||
return 'DTS';
|
||||
}
|
||||
}
|
||||
|
||||
return raw.toUpperCase();
|
||||
}
|
||||
|
||||
function extractAudioVariant(hint) {
|
||||
const raw = String(hint || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const paren = raw.match(/\(([^)]+)\)/);
|
||||
if (!paren) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = paren[1]
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const extras = parts.filter((item) => {
|
||||
const lower = item.toLowerCase();
|
||||
if (lower.includes('dts') || lower.includes('ac3') || lower.includes('e-ac3') || lower.includes('eac3')) {
|
||||
return false;
|
||||
}
|
||||
if (/\d+(?:\.\d+)?\s*ch/i.test(item)) {
|
||||
return false;
|
||||
}
|
||||
if (/\d+\s*kbps/i.test(lower)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return extras.join(', ');
|
||||
}
|
||||
|
||||
function channelCount(rawValue) {
|
||||
const raw = String(rawValue || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (raw.includes('7.1')) {
|
||||
return 8;
|
||||
}
|
||||
if (raw.includes('5.1')) {
|
||||
return 6;
|
||||
}
|
||||
if (raw.includes('stereo') || raw.includes('2.0') || raw.includes('downmix')) {
|
||||
return 2;
|
||||
}
|
||||
if (raw.includes('mono') || raw.includes('1.0')) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const numeric = Number(raw);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
if (Math.abs(numeric - 7.1) < 0.2) {
|
||||
return 8;
|
||||
}
|
||||
if (Math.abs(numeric - 5.1) < 0.2) {
|
||||
return 6;
|
||||
}
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
|
||||
const match = raw.match(/(\d+)\s*ch/);
|
||||
if (match) {
|
||||
const value = Number(match[1]);
|
||||
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function audioChannelLabel(rawValue) {
|
||||
const raw = String(rawValue || '').trim().toLowerCase();
|
||||
const count = channelCount(rawValue);
|
||||
|
||||
if (raw.includes('7.1') || count === 8) {
|
||||
return 'Surround 7.1';
|
||||
}
|
||||
if (raw.includes('5.1') || count === 6) {
|
||||
return 'Surround 5.1';
|
||||
}
|
||||
if (raw.includes('stereo') || raw.includes('2.0') || raw.includes('downmix') || count === 2) {
|
||||
return 'Stereo';
|
||||
}
|
||||
if (count === 1) {
|
||||
return 'Mono';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIO_FALLBACK_PREVIEW = 'av_aac';
|
||||
|
||||
function mapTrackToCopyCodec(track) {
|
||||
const raw = [
|
||||
track?.codecToken,
|
||||
track?.format,
|
||||
track?.codecName,
|
||||
track?.description,
|
||||
track?.title
|
||||
]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw.includes('e-ac-3') || raw.includes('eac3') || raw.includes('dd+')) {
|
||||
return 'eac3';
|
||||
}
|
||||
if (raw.includes('ac-3') || raw.includes('ac3') || raw.includes('dolby digital')) {
|
||||
return 'ac3';
|
||||
}
|
||||
if (raw.includes('truehd')) {
|
||||
return 'truehd';
|
||||
}
|
||||
if (raw.includes('dts-hd') || raw.includes('dtshd')) {
|
||||
return 'dtshd';
|
||||
}
|
||||
if (raw.includes('dca') || raw.includes('dts')) {
|
||||
return 'dts';
|
||||
}
|
||||
if (raw.includes('aac')) {
|
||||
return 'aac';
|
||||
}
|
||||
if (raw.includes('flac')) {
|
||||
return 'flac';
|
||||
}
|
||||
if (raw.includes('mp3') || raw.includes('mpeg audio')) {
|
||||
return 'mp3';
|
||||
}
|
||||
if (raw.includes('opus')) {
|
||||
return 'opus';
|
||||
}
|
||||
if (raw.includes('pcm') || raw.includes('lpcm')) {
|
||||
return 'lpcm';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveAudioEncoderPreviewLabel(track, encoderToken, copyMask, fallbackEncoder) {
|
||||
const normalizedToken = String(encoderToken || '').trim().toLowerCase();
|
||||
if (!normalizedToken || normalizedToken === 'preset-default') {
|
||||
return 'Preset-Default (HandBrake)';
|
||||
}
|
||||
|
||||
if (normalizedToken.startsWith('copy')) {
|
||||
const sourceCodec = mapTrackToCopyCodec(track);
|
||||
const explicitCopyCodec = normalizedToken.includes(':')
|
||||
? normalizedToken.split(':').slice(1).join(':').trim().toLowerCase()
|
||||
: null;
|
||||
const normalizedCopyMask = Array.isArray(copyMask)
|
||||
? copyMask.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
let canCopy = false;
|
||||
if (explicitCopyCodec) {
|
||||
canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec);
|
||||
} else if (sourceCodec && normalizedCopyMask.length > 0) {
|
||||
canCopy = normalizedCopyMask.includes(sourceCodec);
|
||||
}
|
||||
|
||||
if (canCopy) {
|
||||
return `Copy (${sourceCodec || track?.format || 'Quelle'})`;
|
||||
}
|
||||
|
||||
const fallback = String(fallbackEncoder || DEFAULT_AUDIO_FALLBACK_PREVIEW).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK_PREVIEW;
|
||||
return `Fallback Transcode (${fallback})`;
|
||||
}
|
||||
|
||||
return `Transcode (${normalizedToken})`;
|
||||
}
|
||||
|
||||
function buildAudioActionPreviewSummary(track, selectedIndex, audioSelector) {
|
||||
const selector = audioSelector && typeof audioSelector === 'object' ? audioSelector : {};
|
||||
const availableEncoders = Array.isArray(selector.encoders) ? selector.encoders : [];
|
||||
let encoderPlan = [];
|
||||
|
||||
if (selector.encoderSource === 'args' && availableEncoders.length > 0) {
|
||||
const safeIndex = Number.isFinite(selectedIndex) && selectedIndex >= 0 ? selectedIndex : 0;
|
||||
encoderPlan = [availableEncoders[Math.min(safeIndex, availableEncoders.length - 1)]];
|
||||
} else if (availableEncoders.length > 0) {
|
||||
encoderPlan = [...availableEncoders];
|
||||
} else {
|
||||
encoderPlan = ['preset-default'];
|
||||
}
|
||||
|
||||
const labels = encoderPlan
|
||||
.map((token) => resolveAudioEncoderPreviewLabel(track, token, selector.copyMask, selector.fallbackEncoder))
|
||||
.filter(Boolean);
|
||||
|
||||
return labels.join(' + ') || 'Übernehmen';
|
||||
}
|
||||
|
||||
function TrackList({
|
||||
title,
|
||||
tracks,
|
||||
type = 'generic',
|
||||
allowSelection = false,
|
||||
selectedTrackIds = [],
|
||||
onToggleTrack = null,
|
||||
audioSelector = null
|
||||
}) {
|
||||
const selectedIds = normalizeTrackIdList(selectedTrackIds);
|
||||
const checkedTrackOrder = (Array.isArray(tracks) ? tracks : [])
|
||||
.map((track) => normalizeTrackId(track?.id))
|
||||
.filter((trackId, index) => {
|
||||
if (trackId === null) {
|
||||
return false;
|
||||
}
|
||||
if (allowSelection) {
|
||||
return selectedIds.includes(trackId);
|
||||
}
|
||||
const track = tracks[index];
|
||||
return Boolean(track?.selectedForEncode);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4>{title}</h4>
|
||||
{!tracks || tracks.length === 0 ? (
|
||||
<p>Keine Einträge.</p>
|
||||
) : (
|
||||
<div className="media-track-list">
|
||||
{tracks.map((track) => {
|
||||
const trackId = normalizeTrackId(track.id);
|
||||
const checked = allowSelection
|
||||
? (trackId !== null && selectedIds.includes(trackId))
|
||||
: Boolean(track.selectedForEncode);
|
||||
const selectedIndex = trackId !== null
|
||||
? checkedTrackOrder.indexOf(trackId)
|
||||
: -1;
|
||||
const actionInfo = type === 'audio'
|
||||
? (checked
|
||||
? (() => {
|
||||
const base = String(track.encodePreviewSummary || track.encodeActionSummary || '').trim();
|
||||
const staleUnselectedSummary = /^nicht übernommen$/i.test(base);
|
||||
if (staleUnselectedSummary) {
|
||||
return buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
|
||||
}
|
||||
return base || buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
|
||||
})()
|
||||
: 'Nicht übernommen')
|
||||
: type === 'subtitle'
|
||||
? (checked
|
||||
? (() => {
|
||||
const base = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
|
||||
return /^nicht übernommen$/i.test(base) ? 'Übernehmen' : (base || 'Übernehmen');
|
||||
})()
|
||||
: 'Nicht übernommen')
|
||||
: null;
|
||||
const subtitleFlags = type === 'subtitle' && checked
|
||||
? (Array.isArray(track.subtitlePreviewFlags)
|
||||
? track.subtitlePreviewFlags
|
||||
: (Array.isArray(track.flags) ? track.flags : []))
|
||||
: [];
|
||||
|
||||
const displayLanguage = toLang2(track.language || track.languageLabel || 'und');
|
||||
const displayHint = track.description || track.title;
|
||||
const displayCodec = simplifyCodec(type, track.format, displayHint);
|
||||
const displayChannelCount = channelCount(track.channels);
|
||||
const displayAudioTitle = audioChannelLabel(track.channels);
|
||||
const audioVariant = type === 'audio' ? extractAudioVariant(displayHint) : '';
|
||||
const burned = type === 'subtitle' && checked
|
||||
? Boolean(
|
||||
track.subtitlePreviewBurnIn
|
||||
|| track.burnIn
|
||||
|| subtitleFlags.includes('burned')
|
||||
|| /burned/i.test(String(track.subtitlePreviewSummary || track.subtitleActionSummary || ''))
|
||||
)
|
||||
: false;
|
||||
|
||||
let displayText = `#${track.id} | ${displayLanguage} | ${displayCodec}`;
|
||||
if (type === 'audio') {
|
||||
if (displayChannelCount !== null) {
|
||||
displayText += ` | ${displayChannelCount}ch`;
|
||||
}
|
||||
if (displayAudioTitle) {
|
||||
displayText += ` | ${displayAudioTitle}`;
|
||||
}
|
||||
if (audioVariant) {
|
||||
displayText += ` | ${audioVariant}`;
|
||||
}
|
||||
}
|
||||
if (type === 'subtitle' && burned) {
|
||||
displayText += ' | burned';
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${title}-${track.id}`} className="media-track-item">
|
||||
<label className="readonly-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
if (!allowSelection || typeof onToggleTrack !== 'function' || trackId === null) {
|
||||
return;
|
||||
}
|
||||
onToggleTrack(trackId, event.target.checked);
|
||||
}}
|
||||
readOnly={!allowSelection}
|
||||
disabled={!allowSelection}
|
||||
/>
|
||||
<span>{displayText}</span>
|
||||
</label>
|
||||
{actionInfo ? <small className="track-action-note">Encode: {actionInfo}</small> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTitleId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
export default function MediaInfoReviewPanel({
|
||||
review,
|
||||
commandOutputPath = null,
|
||||
selectedEncodeTitleId = null,
|
||||
allowTitleSelection = false,
|
||||
onSelectEncodeTitle = null,
|
||||
allowTrackSelection = false,
|
||||
trackSelectionByTitle = {},
|
||||
onTrackSelectionChange = null
|
||||
}) {
|
||||
if (!review) {
|
||||
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
||||
}
|
||||
|
||||
const titles = review.titles || [];
|
||||
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(review.encodeInputTitleId);
|
||||
const encodeInputTitle = titles.find((item) => item.id === currentSelectedId) || null;
|
||||
const processedFiles = Number(review.processedFiles || titles.length || 0);
|
||||
const totalFiles = Number(review.totalFiles || titles.length || 0);
|
||||
const playlistRecommendation = review.playlistRecommendation || null;
|
||||
|
||||
return (
|
||||
<div className="media-review-wrap">
|
||||
<div className="media-review-meta">
|
||||
<div><strong>Preset:</strong> {review.selectors?.preset || '-'}</div>
|
||||
<div><strong>Extra Args:</strong> {review.selectors?.extraArgs || '(keine)'}</div>
|
||||
<div><strong>Preset-Profil:</strong> {review.selectors?.presetProfileSource || '-'}</div>
|
||||
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
|
||||
<div><strong>Encode Input:</strong> {encodeInputTitle?.fileName || '-'}</div>
|
||||
<div><strong>Audio Auswahl:</strong> {review.selectors?.audio?.mode || '-'}</div>
|
||||
<div><strong>Audio Encoder:</strong> {(review.selectors?.audio?.encoders || []).join(', ') || 'Preset-Default'}</div>
|
||||
<div><strong>Audio Copy-Mask:</strong> {(review.selectors?.audio?.copyMask || []).join(', ') || '-'}</div>
|
||||
<div><strong>Audio Fallback:</strong> {review.selectors?.audio?.fallbackEncoder || '-'}</div>
|
||||
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</div>
|
||||
<div><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.forcedOnly ? 'forced-only' : '-'}{review.selectors?.subtitle?.burnBehavior === 'first' ? ' + burned(first)' : ''}</div>
|
||||
</div>
|
||||
|
||||
{review.partial ? (
|
||||
<small>Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert.</small>
|
||||
) : null}
|
||||
|
||||
{playlistRecommendation ? (
|
||||
<div className="playlist-recommendation-box">
|
||||
<small>
|
||||
<strong>Empfehlung:</strong> {playlistRecommendation.playlistFile || '-'}
|
||||
{playlistRecommendation.reviewTitleId ? ` (Titel #${playlistRecommendation.reviewTitleId})` : ''}
|
||||
</small>
|
||||
{playlistRecommendation.reason ? <small>{playlistRecommendation.reason}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{Array.isArray(review.notes) && review.notes.length > 0 ? (
|
||||
<div className="media-review-notes">
|
||||
{review.notes.map((note, idx) => (
|
||||
<small key={`${idx}-${note}`}>{note}</small>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h4>Titel</h4>
|
||||
<div className="media-title-list">
|
||||
{titles.length === 0 ? (
|
||||
<p>Keine Titel analysiert.</p>
|
||||
) : titles.map((title) => {
|
||||
const titleEligible = title?.eligibleForEncode !== false;
|
||||
const titleChecked = allowTitleSelection
|
||||
? currentSelectedId === normalizeTitleId(title.id)
|
||||
: Boolean(title.selectedForEncode);
|
||||
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
|
||||
const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedByRule))
|
||||
.map((track) => normalizeTrackId(track?.id))
|
||||
.filter((id) => id !== null);
|
||||
const defaultSubtitleTrackIds = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedByRule))
|
||||
.map((track) => normalizeTrackId(track?.id))
|
||||
.filter((id) => id !== null);
|
||||
const selectedAudioTrackIds = normalizeTrackIdList(
|
||||
Array.isArray(titleSelectionEntry?.audioTrackIds)
|
||||
? titleSelectionEntry.audioTrackIds
|
||||
: defaultAudioTrackIds
|
||||
);
|
||||
const selectedSubtitleTrackIds = normalizeTrackIdList(
|
||||
Array.isArray(titleSelectionEntry?.subtitleTrackIds)
|
||||
? titleSelectionEntry.subtitleTrackIds
|
||||
: defaultSubtitleTrackIds
|
||||
);
|
||||
const allowTrackSelectionForTitle = Boolean(
|
||||
allowTrackSelection
|
||||
&& allowTitleSelection
|
||||
&& titleChecked
|
||||
&& titleEligible
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={title.id} className="media-title-block">
|
||||
<label className="readonly-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={titleChecked}
|
||||
onChange={() => {
|
||||
if (!allowTitleSelection || typeof onSelectEncodeTitle !== 'function') {
|
||||
return;
|
||||
}
|
||||
onSelectEncodeTitle(normalizeTitleId(title.id));
|
||||
}}
|
||||
readOnly={!allowTitleSelection}
|
||||
disabled={!allowTitleSelection || !titleEligible}
|
||||
/>
|
||||
<span>
|
||||
#{title.id} | {title.fileName} | {formatDuration(title.durationMinutes)} | {formatBytes(title.sizeBytes)}
|
||||
{title.encodeInput ? ' | Encode-Input' : ''}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{title.playlistFile || title.playlistEvaluationLabel || title.playlistSegmentCommand ? (
|
||||
<div className="playlist-info-box">
|
||||
<small>
|
||||
<strong>Playlist:</strong> {title.playlistFile || '-'}
|
||||
{title.playlistRecommended ? ' | empfohlen' : ''}
|
||||
</small>
|
||||
{title.playlistEvaluationLabel ? (
|
||||
<small><strong>Bewertung:</strong> {title.playlistEvaluationLabel}</small>
|
||||
) : null}
|
||||
{title.playlistSegmentCommand ? (
|
||||
<small><strong>Analyse-Command:</strong> {title.playlistSegmentCommand}</small>
|
||||
) : null}
|
||||
{Array.isArray(title.playlistSegmentFiles) && title.playlistSegmentFiles.length > 0 ? (
|
||||
<details className="playlist-segment-toggle">
|
||||
<summary>Segment-Dateien anzeigen ({title.playlistSegmentFiles.length})</summary>
|
||||
<pre className="playlist-segment-output">{title.playlistSegmentFiles.join('\n')}</pre>
|
||||
</details>
|
||||
) : (
|
||||
<small>Segment-Ausgabe: keine m2ts-Einträge gefunden.</small>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="media-track-grid">
|
||||
<TrackList
|
||||
title={`Tonspuren (Titel #${title.id})`}
|
||||
tracks={title.audioTracks || []}
|
||||
type="audio"
|
||||
allowSelection={allowTrackSelectionForTitle}
|
||||
selectedTrackIds={selectedAudioTrackIds}
|
||||
audioSelector={review?.selectors?.audio || null}
|
||||
onToggleTrack={(trackId, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
onTrackSelectionChange(title.id, 'audio', trackId, checked);
|
||||
}}
|
||||
/>
|
||||
<TrackList
|
||||
title={`Subtitles (Titel #${title.id})`}
|
||||
tracks={title.subtitleTracks || []}
|
||||
type="subtitle"
|
||||
allowSelection={allowTrackSelectionForTitle}
|
||||
selectedTrackIds={selectedSubtitleTrackIds}
|
||||
onToggleTrack={(trackId, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{titleChecked ? (() => {
|
||||
const commandPreview = buildHandBrakeCommandPreview({
|
||||
review,
|
||||
title,
|
||||
selectedAudioTrackIds,
|
||||
selectedSubtitleTrackIds,
|
||||
commandOutputPath
|
||||
});
|
||||
return (
|
||||
<div className="handbrake-command-preview">
|
||||
<small><strong>Finaler HandBrakeCLI-Befehl (Preview):</strong></small>
|
||||
<pre>{commandPreview}</pre>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
frontend/src/components/MetadataSelectionDialog.jsx
Normal file
172
frontend/src/components/MetadataSelectionDialog.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
|
||||
export default function MetadataSelectionDialog({
|
||||
visible,
|
||||
context,
|
||||
onHide,
|
||||
onSubmit,
|
||||
onSearch,
|
||||
busy
|
||||
}) {
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [manualTitle, setManualTitle] = useState('');
|
||||
const [manualYear, setManualYear] = useState('');
|
||||
const [manualImdb, setManualImdb] = useState('');
|
||||
const [extraResults, setExtraResults] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedMetadata = context?.selectedMetadata || {};
|
||||
const defaultTitle = selectedMetadata.title || context?.detectedTitle || '';
|
||||
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
|
||||
const defaultImdb = selectedMetadata.imdbId || '';
|
||||
|
||||
setSelected(null);
|
||||
setQuery(defaultTitle);
|
||||
setManualTitle(defaultTitle);
|
||||
setManualYear(defaultYear);
|
||||
setManualImdb(defaultImdb);
|
||||
setExtraResults([]);
|
||||
}, [visible, context]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const base = context?.omdbCandidates || [];
|
||||
const all = [...base, ...extraResults];
|
||||
const map = new Map();
|
||||
|
||||
all.forEach((item) => {
|
||||
if (item?.imdbId) {
|
||||
map.set(item.imdbId, item);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
}, [context, extraResults]);
|
||||
|
||||
const titleWithPosterBody = (row) => (
|
||||
<div className="omdb-row">
|
||||
{row.poster && row.poster !== 'N/A' ? (
|
||||
<img src={row.poster} alt={row.title} className="poster-thumb-lg" />
|
||||
) : (
|
||||
<div className="poster-thumb-lg poster-fallback">-</div>
|
||||
)}
|
||||
<div>
|
||||
<div><strong>{row.title}</strong></div>
|
||||
<small>{row.year} | {row.imdbId}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
const results = await onSearch(query.trim());
|
||||
setExtraResults(results || []);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const payload = selected
|
||||
? {
|
||||
jobId: context.jobId,
|
||||
title: selected.title,
|
||||
year: selected.year,
|
||||
imdbId: selected.imdbId,
|
||||
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
|
||||
fromOmdb: true
|
||||
}
|
||||
: {
|
||||
jobId: context.jobId,
|
||||
title: manualTitle,
|
||||
year: manualYear,
|
||||
imdbId: manualImdb,
|
||||
poster: null,
|
||||
fromOmdb: false
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header="Metadaten auswählen"
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
style={{ width: '52rem', maxWidth: '95vw' }}
|
||||
className="metadata-selection-dialog"
|
||||
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
|
||||
modal
|
||||
>
|
||||
<div className="search-row">
|
||||
<InputText
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Titel suchen"
|
||||
/>
|
||||
<Button label="OMDb Suche" icon="pi pi-search" onClick={handleSearch} loading={busy} />
|
||||
</div>
|
||||
|
||||
<div className="table-scroll-wrap table-scroll-medium">
|
||||
<DataTable
|
||||
value={rows}
|
||||
selectionMode="single"
|
||||
selection={selected}
|
||||
onSelectionChange={(event) => setSelected(event.value)}
|
||||
dataKey="imdbId"
|
||||
size="small"
|
||||
scrollable
|
||||
scrollHeight="22rem"
|
||||
emptyMessage="Keine Treffer"
|
||||
responsiveLayout="stack"
|
||||
breakpoint="960px"
|
||||
>
|
||||
<Column header="Titel" body={titleWithPosterBody} />
|
||||
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
|
||||
<Column field="imdbId" header="IMDb" style={{ width: '10rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<h4>Manuelle Eingabe</h4>
|
||||
<div className="metadata-grid">
|
||||
<InputText
|
||||
value={manualTitle}
|
||||
onChange={(event) => setManualTitle(event.target.value)}
|
||||
placeholder="Titel"
|
||||
disabled={!!selected}
|
||||
/>
|
||||
<InputText
|
||||
value={manualYear}
|
||||
onChange={(event) => setManualYear(event.target.value)}
|
||||
placeholder="Jahr"
|
||||
disabled={!!selected}
|
||||
/>
|
||||
<InputText
|
||||
value={manualImdb}
|
||||
onChange={(event) => setManualImdb(event.target.value)}
|
||||
placeholder="IMDb-ID"
|
||||
disabled={!!selected}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dialog-actions">
|
||||
<Button label="Abbrechen" severity="secondary" text onClick={onHide} />
|
||||
<Button
|
||||
label="Auswahl übernehmen"
|
||||
icon="pi pi-play"
|
||||
onClick={handleSubmit}
|
||||
loading={busy}
|
||||
disabled={!selected && !manualTitle.trim() && !manualImdb.trim()}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
598
frontend/src/components/PipelineStatusCard.jsx
Normal file
598
frontend/src/components/PipelineStatusCard.jsx
Normal file
@@ -0,0 +1,598 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Button } from 'primereact/button';
|
||||
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const severityMap = {
|
||||
IDLE: 'success',
|
||||
DISC_DETECTED: 'info',
|
||||
ANALYZING: 'warning',
|
||||
METADATA_SELECTION: 'warning',
|
||||
WAITING_FOR_USER_DECISION: 'warning',
|
||||
READY_TO_START: 'info',
|
||||
MEDIAINFO_CHECK: 'warning',
|
||||
READY_TO_ENCODE: 'info',
|
||||
RIPPING: 'warning',
|
||||
ENCODING: 'warning',
|
||||
FINISHED: 'success',
|
||||
ERROR: 'danger'
|
||||
};
|
||||
|
||||
function normalizeTitleId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizePlaylistId(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const match = raw.match(/(\d{1,5})(?:\.mpls)?$/i);
|
||||
return match ? String(match[1]).padStart(5, '0') : null;
|
||||
}
|
||||
|
||||
function normalizeTrackId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeTrackIdList(values) {
|
||||
const list = Array.isArray(values) ? values : [];
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const value of list) {
|
||||
const normalized = normalizeTrackId(value);
|
||||
if (normalized === null) {
|
||||
continue;
|
||||
}
|
||||
const key = String(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildDefaultTrackSelection(review) {
|
||||
const titles = Array.isArray(review?.titles) ? review.titles : [];
|
||||
const selection = {};
|
||||
|
||||
for (const title of titles) {
|
||||
const titleId = normalizeTitleId(title?.id);
|
||||
if (!titleId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selection[titleId] = {
|
||||
audioTrackIds: normalizeTrackIdList(
|
||||
(Array.isArray(title?.audioTracks) ? title.audioTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedByRule))
|
||||
.map((track) => track?.id)
|
||||
),
|
||||
subtitleTrackIds: normalizeTrackIdList(
|
||||
(Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedByRule))
|
||||
.map((track) => track?.id)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
function defaultTrackSelectionForTitle(review, titleId) {
|
||||
const defaults = buildDefaultTrackSelection(review);
|
||||
return defaults[titleId] || defaults[String(titleId)] || { audioTrackIds: [], subtitleTrackIds: [] };
|
||||
}
|
||||
|
||||
function buildSettingsMap(categories) {
|
||||
const map = {};
|
||||
const list = Array.isArray(categories) ? categories : [];
|
||||
for (const category of list) {
|
||||
for (const setting of (Array.isArray(category?.settings) ? category.settings : [])) {
|
||||
map[setting.key] = setting.value;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function sanitizeFileName(input) {
|
||||
return String(input || 'untitled')
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 180);
|
||||
}
|
||||
|
||||
function renderTemplate(template, values) {
|
||||
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
|
||||
const value = values[key.trim()];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
function buildOutputPathPreview(settings, metadata, fallbackJobId = null) {
|
||||
const movieDir = String(settings?.movie_dir || '').trim();
|
||||
if (!movieDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
||||
const year = metadata?.year || new Date().getFullYear();
|
||||
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
||||
const template = settings?.filename_template || '${title} (${year})';
|
||||
const folderName = sanitizeFileName(renderTemplate('${title} (${year})', { title, year, imdbId }));
|
||||
const baseName = sanitizeFileName(renderTemplate(template, { title, year, imdbId }));
|
||||
const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv';
|
||||
const root = movieDir.replace(/\/+$/g, '');
|
||||
return `${root}/${folderName}/${baseName}.${ext}`;
|
||||
}
|
||||
|
||||
export default function PipelineStatusCard({
|
||||
pipeline,
|
||||
onAnalyze,
|
||||
onReanalyze,
|
||||
onStart,
|
||||
onRestartEncode,
|
||||
onConfirmReview,
|
||||
onSelectPlaylist,
|
||||
onCancel,
|
||||
onRetry,
|
||||
busy,
|
||||
liveJobLog = ''
|
||||
}) {
|
||||
const state = pipeline?.state || 'IDLE';
|
||||
const progress = Number(pipeline?.progress || 0);
|
||||
const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK';
|
||||
const retryJobId = pipeline?.context?.jobId;
|
||||
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
|
||||
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
|
||||
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
|
||||
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
|
||||
const reviewConfirmed = Boolean(pipeline?.context?.reviewConfirmed || mediaInfoReview?.reviewConfirmed);
|
||||
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
|
||||
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
|
||||
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
|
||||
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
|
||||
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
|
||||
const [settingsMap, setSettingsMap] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.getSettings();
|
||||
if (!cancelled) {
|
||||
setSettingsMap(buildSettingsMap(response?.categories || []));
|
||||
}
|
||||
} catch (_error) {
|
||||
if (!cancelled) {
|
||||
setSettingsMap({});
|
||||
}
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
|
||||
setSelectedEncodeTitleId(fromReview);
|
||||
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
|
||||
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentTitleId = normalizeTitleId(selectedEncodeTitleId);
|
||||
if (!currentTitleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTrackSelectionByTitle((prev) => {
|
||||
if (prev?.[currentTitleId] || prev?.[String(currentTitleId)]) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const defaults = buildDefaultTrackSelection(mediaInfoReview);
|
||||
const fallback = defaults[currentTitleId] || { audioTrackIds: [], subtitleTrackIds: [] };
|
||||
return {
|
||||
...prev,
|
||||
[currentTitleId]: fallback
|
||||
};
|
||||
});
|
||||
}, [selectedEncodeTitleId, mediaInfoReview?.generatedAt]);
|
||||
|
||||
const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired);
|
||||
const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId));
|
||||
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
|
||||
const canStartReadyJob = isPreRipReview
|
||||
? Boolean(retryJobId)
|
||||
: Boolean(retryJobId && encodeInputPath);
|
||||
const canRestartEncodeFromLastSettings = Boolean(
|
||||
state === 'ERROR'
|
||||
&& retryJobId
|
||||
&& pipeline?.context?.canRestartEncodeFromLastSettings
|
||||
);
|
||||
|
||||
const waitingPlaylistRows = useMemo(() => {
|
||||
const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
|
||||
? playlistAnalysis.evaluatedCandidates
|
||||
: [];
|
||||
|
||||
const rows = evaluated.length > 0
|
||||
? evaluated
|
||||
: (Array.isArray(pipeline?.context?.playlistCandidates) ? pipeline.context.playlistCandidates : []);
|
||||
|
||||
const normalized = rows
|
||||
.map((item) => {
|
||||
const playlistId = normalizePlaylistId(item?.playlistId || item?.playlistFile || item);
|
||||
if (!playlistId) {
|
||||
return null;
|
||||
}
|
||||
const playlistFile = `${playlistId}.mpls`;
|
||||
const score = Number(item?.score);
|
||||
const sequenceCoherence = Number(
|
||||
item?.structuralMetrics?.sequenceCoherence ?? item?.sequenceCoherence
|
||||
);
|
||||
const handBrakeTitleId = Number(item?.handBrakeTitleId);
|
||||
return {
|
||||
playlistId,
|
||||
playlistFile,
|
||||
titleId: Number.isFinite(Number(item?.titleId)) ? Number(item.titleId) : null,
|
||||
score: Number.isFinite(score) ? score : null,
|
||||
evaluationLabel: item?.evaluationLabel || null,
|
||||
segmentCommand: item?.segmentCommand
|
||||
|| `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
|
||||
segmentFiles: Array.isArray(item?.segmentFiles) ? item.segmentFiles : [],
|
||||
sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null,
|
||||
recommended: Boolean(item?.recommended),
|
||||
handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0
|
||||
? Math.trunc(handBrakeTitleId)
|
||||
: null,
|
||||
audioSummary: item?.audioSummary || null,
|
||||
audioTrackPreview: Array.isArray(item?.audioTrackPreview) ? item.audioTrackPreview : []
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const dedup = [];
|
||||
const seen = new Set();
|
||||
for (const row of normalized) {
|
||||
if (seen.has(row.playlistId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(row.playlistId);
|
||||
dedup.push(row);
|
||||
}
|
||||
return dedup;
|
||||
}, [playlistAnalysis, pipeline?.context?.playlistCandidates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'WAITING_FOR_USER_DECISION') {
|
||||
setSelectedPlaylistId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = normalizePlaylistId(pipeline?.context?.selectedPlaylist);
|
||||
if (current) {
|
||||
setSelectedPlaylistId(current);
|
||||
return;
|
||||
}
|
||||
|
||||
const recommendedFromRows = waitingPlaylistRows.find((item) => item.recommended)?.playlistId || null;
|
||||
const recommendedFromAnalysis = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId);
|
||||
const fallback = waitingPlaylistRows[0]?.playlistId || null;
|
||||
setSelectedPlaylistId(recommendedFromRows || recommendedFromAnalysis || fallback);
|
||||
}, [
|
||||
state,
|
||||
retryJobId,
|
||||
waitingPlaylistRows,
|
||||
playlistAnalysis?.recommendation?.playlistId,
|
||||
pipeline?.context?.selectedPlaylist
|
||||
]);
|
||||
|
||||
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION';
|
||||
const commandOutputPath = useMemo(
|
||||
() => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId),
|
||||
[settingsMap, selectedMetadata, retryJobId]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card title="Pipeline Status" subTitle="Live Zustand und Fortschritt">
|
||||
<div className="status-row">
|
||||
<Tag value={state} severity={severityMap[state] || 'secondary'} />
|
||||
<span>{pipeline?.statusText || 'Bereit'}</span>
|
||||
</div>
|
||||
|
||||
{running && (
|
||||
<div className="progress-wrap">
|
||||
<ProgressBar value={progress} showValue />
|
||||
<small>{pipeline?.eta ? `ETA ${pipeline.eta}` : 'ETA unbekannt'}</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'FINISHED' && (
|
||||
<div className="progress-wrap">
|
||||
<ProgressBar value={100} showValue />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="actions-row">
|
||||
{(state === 'DISC_DETECTED' || state === 'IDLE') && (
|
||||
<Button
|
||||
label="Analyse starten"
|
||||
icon="pi pi-search"
|
||||
onClick={onAnalyze}
|
||||
loading={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'READY_TO_START' && retryJobId && (
|
||||
<Button
|
||||
label="Job starten"
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={() => onStart(retryJobId)}
|
||||
loading={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'READY_TO_ENCODE' && retryJobId && (
|
||||
<Button
|
||||
label="Auswahl bestätigen"
|
||||
icon="pi pi-check"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => {
|
||||
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId);
|
||||
const selectionEntry = encodeTitleId
|
||||
? (trackSelectionByTitle?.[encodeTitleId] || trackSelectionByTitle?.[String(encodeTitleId)] || null)
|
||||
: null;
|
||||
const fallbackSelection = encodeTitleId
|
||||
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
|
||||
: { audioTrackIds: [], subtitleTrackIds: [] };
|
||||
const effectiveSelection = selectionEntry || fallbackSelection;
|
||||
const selectedTrackSelection = encodeTitleId
|
||||
? {
|
||||
[encodeTitleId]: {
|
||||
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
|
||||
subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
|
||||
}
|
||||
}
|
||||
: null;
|
||||
|
||||
onConfirmReview(retryJobId, encodeTitleId, selectedTrackSelection);
|
||||
}}
|
||||
loading={busy}
|
||||
disabled={reviewConfirmed || !canConfirmReview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{playlistDecisionRequiredBeforeStart && retryJobId && (
|
||||
<Button
|
||||
label="Playlist übernehmen"
|
||||
icon="pi pi-check"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => onSelectPlaylist?.(retryJobId, selectedPlaylistId)}
|
||||
loading={busy}
|
||||
disabled={!normalizePlaylistId(selectedPlaylistId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'READY_TO_ENCODE' && retryJobId && (
|
||||
<Button
|
||||
label={isPreRipReview ? 'Backup + Encode starten' : 'Encode starten'}
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={() => onStart(retryJobId)}
|
||||
loading={busy}
|
||||
disabled={!canStartReadyJob || !reviewConfirmed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{running && (
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
icon="pi pi-stop"
|
||||
severity="danger"
|
||||
onClick={onCancel}
|
||||
loading={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canRestartEncodeFromLastSettings ? (
|
||||
<Button
|
||||
label="Encode neu starten"
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={() => onRestartEncode?.(retryJobId)}
|
||||
loading={busy}
|
||||
disabled={!retryJobId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state === 'ERROR' && retryJobId && (
|
||||
<Button
|
||||
label="Retry Rippen"
|
||||
icon="pi pi-refresh"
|
||||
severity="warning"
|
||||
onClick={() => onRetry(retryJobId)}
|
||||
loading={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' ? (
|
||||
<Button
|
||||
label="Disk-Analyse neu starten"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
onClick={onReanalyze || onAnalyze}
|
||||
loading={busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{running ? (
|
||||
<div className="live-log-block">
|
||||
<h4>Aktueller Job-Log</h4>
|
||||
<pre className="log-box">{liveJobLog || 'Noch keine Log-Ausgabe vorhanden.'}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{playlistDecisionRequiredBeforeStart ? (
|
||||
<div className="playlist-decision-block">
|
||||
<h3>Playlist-Auswahl erforderlich</h3>
|
||||
<small>
|
||||
Metadaten sind abgeschlossen. Vor Start muss ein Titel/Playlist manuell per Checkbox gewählt werden.
|
||||
</small>
|
||||
{waitingPlaylistRows.length > 0 ? (
|
||||
<div className="playlist-decision-list">
|
||||
{waitingPlaylistRows.map((row) => (
|
||||
<div key={row.playlistId} className="playlist-decision-item">
|
||||
<label className="readonly-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={normalizePlaylistId(selectedPlaylistId) === row.playlistId}
|
||||
onChange={() => {
|
||||
const next = normalizePlaylistId(selectedPlaylistId) === row.playlistId ? null : row.playlistId;
|
||||
setSelectedPlaylistId(next);
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{row.playlistFile}
|
||||
{row.titleId !== null ? ` | Titel #${row.titleId}` : ''}
|
||||
{row.score !== null ? ` | Score ${row.score}` : ''}
|
||||
{row.recommended ? ' | empfohlen' : ''}
|
||||
</span>
|
||||
</label>
|
||||
{row.evaluationLabel ? <small className="track-action-note">{row.evaluationLabel}</small> : null}
|
||||
{row.sequenceCoherence !== null ? (
|
||||
<small className="track-action-note">Sequenz-Kohärenz: {row.sequenceCoherence.toFixed(3)}</small>
|
||||
) : null}
|
||||
{row.handBrakeTitleId !== null ? (
|
||||
<small className="track-action-note">HandBrake Titel: -t {row.handBrakeTitleId}</small>
|
||||
) : null}
|
||||
{row.audioSummary ? (
|
||||
<small className="track-action-note">Audio: {row.audioSummary}</small>
|
||||
) : null}
|
||||
{row.segmentCommand ? <small className="track-action-note">Info: {row.segmentCommand}</small> : null}
|
||||
{Array.isArray(row.audioTrackPreview) && row.audioTrackPreview.length > 0 ? (
|
||||
<details className="playlist-segment-toggle">
|
||||
<summary>Audio-Spuren anzeigen ({row.audioTrackPreview.length})</summary>
|
||||
<pre className="playlist-segment-output">{row.audioTrackPreview.join('\n')}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{Array.isArray(row.segmentFiles) && row.segmentFiles.length > 0 ? (
|
||||
<details className="playlist-segment-toggle">
|
||||
<summary>Segment-Dateien anzeigen ({row.segmentFiles.length})</summary>
|
||||
<pre className="playlist-segment-output">{row.segmentFiles.join('\n')}</pre>
|
||||
</details>
|
||||
) : (
|
||||
<small className="track-action-note">Keine Segmentliste aus TINFO:26 verfügbar.</small>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<small>Keine Kandidaten gefunden. Bitte Analyse erneut ausführen.</small>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedMetadata ? (
|
||||
<div className="pipeline-meta-inline">
|
||||
{selectedMetadata.poster ? (
|
||||
<img
|
||||
src={selectedMetadata.poster}
|
||||
alt={selectedMetadata.title || 'Poster'}
|
||||
className="poster-large"
|
||||
/>
|
||||
) : (
|
||||
<div className="poster-large poster-fallback">Kein Poster</div>
|
||||
)}
|
||||
<div className="device-meta">
|
||||
<div>
|
||||
<strong>Titel:</strong> {selectedMetadata.title || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Jahr:</strong> {selectedMetadata.year || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>IMDb:</strong> {selectedMetadata.imdbId || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong> {state}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) ? (
|
||||
<div className="mediainfo-review-block">
|
||||
<h3>Titel-/Spurprüfung</h3>
|
||||
{state === 'READY_TO_ENCODE' && !reviewConfirmed ? (
|
||||
<small>
|
||||
{isPreRipReview
|
||||
? 'Backup/Rip + Encode ist gesperrt, bis die Spurauswahl bestätigt wurde.'
|
||||
: 'Encode ist gesperrt, bis die Titel-/Spurauswahl bestätigt wurde.'}
|
||||
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
|
||||
</small>
|
||||
) : null}
|
||||
<MediaInfoReviewPanel
|
||||
review={mediaInfoReview}
|
||||
commandOutputPath={commandOutputPath}
|
||||
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
||||
allowTitleSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
||||
onSelectEncodeTitle={(titleId) => setSelectedEncodeTitleId(normalizeTitleId(titleId))}
|
||||
allowTrackSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
||||
trackSelectionByTitle={trackSelectionByTitle}
|
||||
onTrackSelectionChange={(titleId, trackType, trackId, checked) => {
|
||||
const normalizedTitleId = normalizeTitleId(titleId);
|
||||
const normalizedTrackId = normalizeTrackId(trackId);
|
||||
if (!normalizedTitleId || normalizedTrackId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTrackSelectionByTitle((prev) => {
|
||||
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
|
||||
audioTrackIds: [],
|
||||
subtitleTrackIds: []
|
||||
};
|
||||
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
|
||||
const existing = normalizeTrackIdList(current?.[key] || []);
|
||||
const next = checked
|
||||
? normalizeTrackIdList([...existing, normalizedTrackId])
|
||||
: existing.filter((id) => id !== normalizedTrackId);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[normalizedTitleId]: {
|
||||
...current,
|
||||
[key]: next
|
||||
}
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
62
frontend/src/hooks/useWebSocket.js
Normal file
62
frontend/src/hooks/useWebSocket.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
function buildWsUrl() {
|
||||
if (import.meta.env.VITE_WS_URL) {
|
||||
return import.meta.env.VITE_WS_URL;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
export function useWebSocket({ onMessage }) {
|
||||
const onMessageRef = useRef(onMessage);
|
||||
onMessageRef.current = onMessage;
|
||||
|
||||
useEffect(() => {
|
||||
let socket;
|
||||
let reconnectTimer;
|
||||
let isUnmounted = false;
|
||||
|
||||
const connect = () => {
|
||||
if (isUnmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket = new WebSocket(buildWsUrl());
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
onMessageRef.current?.(message);
|
||||
} catch (error) {
|
||||
// ignore invalid json
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (!isUnmounted) {
|
||||
reconnectTimer = setTimeout(connect, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (socket && socket.readyState !== WebSocket.CLOSED) {
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
}
|
||||
if (socket && socket.readyState !== WebSocket.CLOSED) {
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
21
frontend/src/main.jsx
Normal file
21
frontend/src/main.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import 'primereact/resources/themes/lara-light-amber/theme.css';
|
||||
import 'primereact/resources/primereact.min.css';
|
||||
import 'primeicons/primeicons.css';
|
||||
import './styles/app.css';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
629
frontend/src/pages/DashboardPage.jsx
Normal file
629
frontend/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,629 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { api } from '../api/client';
|
||||
import PipelineStatusCard from '../components/PipelineStatusCard';
|
||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
|
||||
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'];
|
||||
const dashboardStatuses = new Set([
|
||||
'ANALYZING',
|
||||
'METADATA_SELECTION',
|
||||
'WAITING_FOR_USER_DECISION',
|
||||
'READY_TO_START',
|
||||
'MEDIAINFO_CHECK',
|
||||
'READY_TO_ENCODE',
|
||||
'RIPPING',
|
||||
'ENCODING',
|
||||
'ERROR'
|
||||
]);
|
||||
const statusSeverityMap = {
|
||||
IDLE: 'secondary',
|
||||
DISC_DETECTED: 'info',
|
||||
ANALYZING: 'warning',
|
||||
METADATA_SELECTION: 'warning',
|
||||
WAITING_FOR_USER_DECISION: 'warning',
|
||||
READY_TO_START: 'info',
|
||||
MEDIAINFO_CHECK: 'warning',
|
||||
READY_TO_ENCODE: 'info',
|
||||
RIPPING: 'warning',
|
||||
ENCODING: 'warning',
|
||||
FINISHED: 'success',
|
||||
ERROR: 'danger'
|
||||
};
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function getAnalyzeContext(job) {
|
||||
return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
|
||||
? job.makemkvInfo.analyzeContext
|
||||
: {};
|
||||
}
|
||||
|
||||
function resolveMediaType(job) {
|
||||
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
||||
return raw === 'bluray' ? 'bluray' : 'disc';
|
||||
}
|
||||
|
||||
function mediaIndicatorMeta(job) {
|
||||
const mediaType = resolveMediaType(job);
|
||||
return mediaType === 'bluray'
|
||||
? {
|
||||
mediaType,
|
||||
src: blurayIndicatorIcon,
|
||||
alt: 'Blu-ray',
|
||||
title: 'Blu-ray'
|
||||
}
|
||||
: {
|
||||
mediaType,
|
||||
src: discIndicatorIcon,
|
||||
alt: 'Disc',
|
||||
title: 'CD/sonstiges Medium'
|
||||
};
|
||||
}
|
||||
|
||||
function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
const jobId = normalizeJobId(job?.id);
|
||||
if (
|
||||
jobId
|
||||
&& currentPipelineJobId
|
||||
&& jobId === currentPipelineJobId
|
||||
&& String(currentPipeline?.state || '').trim().toUpperCase() !== 'IDLE'
|
||||
) {
|
||||
return currentPipeline;
|
||||
}
|
||||
|
||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||
const analyzeContext = getAnalyzeContext(job);
|
||||
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||||
const inputPath = isPreRip
|
||||
? null
|
||||
: (job?.encode_input_path || encodePlan?.encodeInputPath || null);
|
||||
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed);
|
||||
const hasEncodableTitle = isPreRip
|
||||
? Boolean(encodePlan?.encodeInputTitleId)
|
||||
: Boolean(inputPath || job?.raw_path);
|
||||
const jobStatus = String(job?.status || job?.last_state || 'IDLE').trim().toUpperCase() || 'IDLE';
|
||||
const lastState = String(job?.last_state || '').trim().toUpperCase();
|
||||
const errorText = String(job?.error_message || '').trim().toUpperCase();
|
||||
const hasEncodePlan = Boolean(encodePlan && Array.isArray(encodePlan?.titles) && encodePlan.titles.length > 0);
|
||||
const looksLikeEncodingError = jobStatus === 'ERROR' && (
|
||||
errorText.includes('ENCODING')
|
||||
|| errorText.includes('HANDBRAKE')
|
||||
|| lastState === 'ENCODING'
|
||||
|| Boolean(job?.handbrakeInfo)
|
||||
);
|
||||
const canRestartEncodeFromLastSettings = Boolean(
|
||||
hasEncodePlan
|
||||
&& reviewConfirmed
|
||||
&& hasEncodableTitle
|
||||
&& (
|
||||
jobStatus === 'READY_TO_ENCODE'
|
||||
|| jobStatus === 'ENCODING'
|
||||
|| looksLikeEncodingError
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
state: jobStatus,
|
||||
activeJobId: jobId,
|
||||
progress: Number.isFinite(Number(job?.progress)) ? Number(job.progress) : 0,
|
||||
eta: job?.eta || null,
|
||||
statusText: job?.status_text || job?.error_message || null,
|
||||
context: {
|
||||
jobId,
|
||||
inputPath,
|
||||
hasEncodableTitle,
|
||||
reviewConfirmed,
|
||||
mode,
|
||||
sourceJobId: encodePlan?.sourceJobId || null,
|
||||
selectedMetadata: {
|
||||
title: job?.title || job?.detected_title || null,
|
||||
year: job?.year || null,
|
||||
imdbId: job?.imdb_id || null,
|
||||
poster: job?.poster_url || null
|
||||
},
|
||||
mediaInfoReview: encodePlan,
|
||||
playlistAnalysis: analyzeContext.playlistAnalysis || null,
|
||||
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
|
||||
playlistCandidates: Array.isArray(analyzeContext?.playlistAnalysis?.evaluatedCandidates)
|
||||
? analyzeContext.playlistAnalysis.evaluatedCandidates
|
||||
: [],
|
||||
selectedPlaylist: analyzeContext.selectedPlaylist || null,
|
||||
selectedTitleId: analyzeContext.selectedTitleId ?? null,
|
||||
canRestartEncodeFromLastSettings
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||
const [liveJobLog, setLiveJobLog] = useState('');
|
||||
const [jobsLoading, setJobsLoading] = useState(false);
|
||||
const [dashboardJobs, setDashboardJobs] = useState([]);
|
||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
|
||||
const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
|
||||
const isProcessing = processingStates.includes(state);
|
||||
|
||||
const loadDashboardJobs = async () => {
|
||||
setJobsLoading(true);
|
||||
try {
|
||||
const response = await api.getJobs();
|
||||
const allJobs = Array.isArray(response?.jobs) ? response.jobs : [];
|
||||
const next = allJobs
|
||||
.filter((job) => dashboardStatuses.has(String(job?.status || '').trim().toUpperCase()))
|
||||
.sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0));
|
||||
|
||||
if (currentPipelineJobId && !next.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
|
||||
try {
|
||||
const active = await api.getJob(currentPipelineJobId);
|
||||
if (active?.job) {
|
||||
next.unshift(active.job);
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore; dashboard still shows available rows
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const deduped = [];
|
||||
for (const job of next) {
|
||||
const id = normalizeJobId(job?.id);
|
||||
if (!id || seen.has(String(id))) {
|
||||
continue;
|
||||
}
|
||||
seen.add(String(id));
|
||||
deduped.push(job);
|
||||
}
|
||||
|
||||
setDashboardJobs(deduped);
|
||||
} catch (_error) {
|
||||
setDashboardJobs([]);
|
||||
} finally {
|
||||
setJobsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (pipeline?.state !== 'METADATA_SELECTION' && pipeline?.state !== 'WAITING_FOR_USER_DECISION') {
|
||||
setMetadataDialogVisible(false);
|
||||
}
|
||||
}, [pipeline?.state]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboardJobs();
|
||||
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedExpanded = normalizeJobId(expandedJobId);
|
||||
const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
|
||||
if (hasExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Respect explicit user collapse.
|
||||
if (expandedJobId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPipelineJobId && dashboardJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
|
||||
setExpandedJobId(currentPipelineJobId);
|
||||
return;
|
||||
}
|
||||
setExpandedJobId(normalizeJobId(dashboardJobs[0]?.id));
|
||||
}, [dashboardJobs, expandedJobId, currentPipelineJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentPipelineJobId || !isProcessing) {
|
||||
setLiveJobLog('');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const refreshLiveLog = async () => {
|
||||
try {
|
||||
const response = await api.getJob(currentPipelineJobId, { includeLiveLog: true });
|
||||
if (!cancelled) {
|
||||
setLiveJobLog(response?.job?.log || '');
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore transient polling errors to avoid noisy toasts while background polling
|
||||
}
|
||||
};
|
||||
|
||||
void refreshLiveLog();
|
||||
const interval = setInterval(refreshLiveLog, 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [currentPipelineJobId, isProcessing]);
|
||||
|
||||
const pipelineByJobId = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const job of dashboardJobs) {
|
||||
const id = normalizeJobId(job?.id);
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
map.set(id, buildPipelineFromJob(job, pipeline, currentPipelineJobId));
|
||||
}
|
||||
return map;
|
||||
}, [dashboardJobs, pipeline, currentPipelineJobId]);
|
||||
|
||||
const showError = (error) => {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Fehler',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.analyzeDisc();
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReanalyze = async () => {
|
||||
const hasActiveJob = Boolean(pipeline?.context?.jobId || pipeline?.activeJobId);
|
||||
if (hasActiveJob && !['IDLE', 'DISC_DETECTED', 'FINISHED'].includes(state)) {
|
||||
const confirmed = window.confirm(
|
||||
'Aktuellen Ablauf verwerfen und die Disk ab der ersten MakeMKV-Analyse neu starten?'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await handleAnalyze();
|
||||
};
|
||||
|
||||
const handleRescan = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await api.rescanDisc();
|
||||
const emitted = response?.result?.emitted || 'none';
|
||||
toastRef.current?.show({
|
||||
severity: emitted === 'discInserted' ? 'success' : 'info',
|
||||
summary: 'Laufwerk neu gelesen',
|
||||
detail:
|
||||
emitted === 'discInserted'
|
||||
? 'Disk-Event wurde neu ausgelöst.'
|
||||
: 'Kein Medium erkannt.',
|
||||
life: 2800
|
||||
});
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.cancelPipeline();
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartJob = async (jobId) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.startJob(jobId);
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
setExpandedJobId(normalizeJobId(jobId));
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReview = async (jobId, selectedEncodeTitleId = null, selectedTrackSelection = null) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.confirmEncodeReview(jobId, {
|
||||
selectedEncodeTitleId,
|
||||
selectedTrackSelection
|
||||
});
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
setExpandedJobId(normalizeJobId(jobId));
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectPlaylist = async (jobId, selectedPlaylist = null) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.selectMetadata({
|
||||
jobId,
|
||||
selectedPlaylist: selectedPlaylist || null
|
||||
});
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
setExpandedJobId(normalizeJobId(jobId));
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async (jobId) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.retryJob(jobId);
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
setExpandedJobId(normalizeJobId(jobId));
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartEncodeWithLastSettings = async (jobId) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.restartEncodeWithLastSettings(jobId);
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
setExpandedJobId(normalizeJobId(jobId));
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOmdbSearch = async (query) => {
|
||||
try {
|
||||
const response = await api.searchOmdb(query);
|
||||
return response.results || [];
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetadataSubmit = async (payload) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.selectMetadata(payload);
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
setMetadataDialogVisible(false);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const device = lastDiscEvent || pipeline?.context?.device;
|
||||
const canReanalyze = !processingStates.includes(state);
|
||||
const canOpenMetadataModal = pipeline?.state === 'METADATA_SELECTION' || pipeline?.state === 'WAITING_FOR_USER_DECISION';
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
|
||||
{jobsLoading ? (
|
||||
<p>Jobs werden geladen ...</p>
|
||||
) : dashboardJobs.length === 0 ? (
|
||||
<p>Keine relevanten Jobs im Dashboard (aktive/fortsetzbare Status).</p>
|
||||
) : (
|
||||
<div className="dashboard-job-list">
|
||||
{dashboardJobs.map((job) => {
|
||||
const jobId = normalizeJobId(job?.id);
|
||||
if (!jobId) {
|
||||
return null;
|
||||
}
|
||||
const isExpanded = normalizeJobId(expandedJobId) === jobId;
|
||||
const isCurrentSession = currentPipelineJobId === jobId && state !== 'IDLE';
|
||||
const isResumable = String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' && !isCurrentSession;
|
||||
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0));
|
||||
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
||||
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||
const mediaIndicator = mediaIndicatorMeta(job);
|
||||
const rawProgress = Number(pipelineForJob?.progress ?? 0);
|
||||
const clampedProgress = Number.isFinite(rawProgress)
|
||||
? Math.max(0, Math.min(100, rawProgress))
|
||||
: 0;
|
||||
const progressLabel = `${Math.round(clampedProgress)}%`;
|
||||
const etaLabel = String(pipelineForJob?.eta || '').trim();
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={jobId} className="dashboard-job-expanded">
|
||||
<div className="dashboard-job-expanded-head">
|
||||
<div className="dashboard-job-expanded-title">
|
||||
<strong className="dashboard-job-title-line">
|
||||
<img
|
||||
src={mediaIndicator.src}
|
||||
alt={mediaIndicator.alt}
|
||||
title={mediaIndicator.title}
|
||||
className="media-indicator-icon"
|
||||
/>
|
||||
<span>#{jobId} | {jobTitle}</span>
|
||||
</strong>
|
||||
<div className="dashboard-job-badges">
|
||||
<Tag value={String(job?.status || '-')} severity={statusSeverityMap[String(job?.status || '').trim().toUpperCase()] || 'secondary'} />
|
||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||
{String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE'
|
||||
? <Tag value={reviewConfirmed ? 'Review bestätigt' : 'Review offen'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Einklappen"
|
||||
icon="pi pi-angle-up"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => setExpandedJobId(null)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<PipelineStatusCard
|
||||
pipeline={pipelineForJob}
|
||||
onAnalyze={handleAnalyze}
|
||||
onReanalyze={handleReanalyze}
|
||||
onStart={handleStartJob}
|
||||
onRestartEncode={handleRestartEncodeWithLastSettings}
|
||||
onConfirmReview={handleConfirmReview}
|
||||
onSelectPlaylist={handleSelectPlaylist}
|
||||
onCancel={handleCancel}
|
||||
onRetry={handleRetry}
|
||||
busy={busy}
|
||||
liveJobLog={isCurrentSession ? liveJobLog : ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={jobId}
|
||||
type="button"
|
||||
className="dashboard-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">Kein Poster</div>
|
||||
)}
|
||||
<div className="dashboard-job-row-main">
|
||||
<strong className="dashboard-job-title-line">
|
||||
<img
|
||||
src={mediaIndicator.src}
|
||||
alt={mediaIndicator.alt}
|
||||
title={mediaIndicator.title}
|
||||
className="media-indicator-icon"
|
||||
/>
|
||||
<span>{jobTitle}</span>
|
||||
</strong>
|
||||
<small>
|
||||
#{jobId}
|
||||
{job?.year ? ` | ${job.year}` : ''}
|
||||
{job?.imdb_id ? ` | ${job.imdb_id}` : ''}
|
||||
</small>
|
||||
<div className="dashboard-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
|
||||
<ProgressBar value={clampedProgress} showValue={false} />
|
||||
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-job-badges">
|
||||
<Tag value={String(job?.status || '-')} severity={statusSeverityMap[String(job?.status || '').trim().toUpperCase()] || 'secondary'} />
|
||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||
{String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE'
|
||||
? <Tag value={reviewConfirmed ? 'Bestätigt' : 'Unbestätigt'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
||||
: null}
|
||||
</div>
|
||||
<i className="pi pi-angle-down" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Disk-Information">
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="Laufwerk neu lesen"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
onClick={handleRescan}
|
||||
loading={busy}
|
||||
/>
|
||||
<Button
|
||||
label="Disk neu analysieren"
|
||||
icon="pi pi-search"
|
||||
severity="warning"
|
||||
onClick={handleReanalyze}
|
||||
loading={busy}
|
||||
disabled={!canReanalyze}
|
||||
/>
|
||||
<Button
|
||||
label="Metadaten-Modal öffnen"
|
||||
icon="pi pi-list"
|
||||
onClick={() => setMetadataDialogVisible(true)}
|
||||
disabled={!canOpenMetadataModal}
|
||||
/>
|
||||
</div>
|
||||
{device ? (
|
||||
<div className="device-meta">
|
||||
<div>
|
||||
<strong>Pfad:</strong> {device.path || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Modell:</strong> {device.model || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Disk-Label:</strong> {device.discLabel || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Laufwerks-Label:</strong> {device.label || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Mount:</strong> {device.mountpoint || '-'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p>Aktuell keine Disk erkannt.</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<MetadataSelectionDialog
|
||||
visible={metadataDialogVisible}
|
||||
context={pipeline?.context || {}}
|
||||
onHide={() => setMetadataDialogVisible(false)}
|
||||
onSubmit={handleMetadataSubmit}
|
||||
onSearch={handleOmdbSearch}
|
||||
busy={busy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
557
frontend/src/pages/DatabasePage.jsx
Normal file
557
frontend/src/pages/DatabasePage.jsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
import JobDetailDialog from '../components/JobDetailDialog';
|
||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Alle', value: '' },
|
||||
{ label: 'FINISHED', value: 'FINISHED' },
|
||||
{ label: 'ERROR', value: 'ERROR' },
|
||||
{ label: 'WAITING_FOR_USER_DECISION', value: 'WAITING_FOR_USER_DECISION' },
|
||||
{ label: 'READY_TO_START', value: 'READY_TO_START' },
|
||||
{ label: 'READY_TO_ENCODE', value: 'READY_TO_ENCODE' },
|
||||
{ label: 'MEDIAINFO_CHECK', value: 'MEDIAINFO_CHECK' },
|
||||
{ label: 'RIPPING', value: 'RIPPING' },
|
||||
{ label: 'ENCODING', value: 'ENCODING' },
|
||||
{ label: 'ANALYZING', value: 'ANALYZING' },
|
||||
{ label: 'METADATA_SELECTION', value: 'METADATA_SELECTION' }
|
||||
];
|
||||
|
||||
function statusSeverity(status) {
|
||||
if (status === 'FINISHED') return 'success';
|
||||
if (status === 'ERROR') return 'danger';
|
||||
if (status === 'READY_TO_START' || status === 'READY_TO_ENCODE') return 'info';
|
||||
if (status === 'WAITING_FOR_USER_DECISION') return 'warning';
|
||||
if (status === 'RIPPING' || status === 'ENCODING' || status === 'ANALYZING' || status === 'MEDIAINFO_CHECK') return 'warning';
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
export default function DatabasePage() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [orphanRows, setOrphanRows] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [orphanLoading, setOrphanLoading] = useState(false);
|
||||
const [selectedJob, setSelectedJob] = useState(null);
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [logLoadingMode, setLogLoadingMode] = useState(null);
|
||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||
const [metadataDialogBusy, setMetadataDialogBusy] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
||||
const [deleteEntryBusyJobId, setDeleteEntryBusyJobId] = useState(null);
|
||||
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const loadRows = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getDatabaseRows({ search, status });
|
||||
setRows(response.rows || []);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrphans = async () => {
|
||||
setOrphanLoading(true);
|
||||
try {
|
||||
const response = await api.getOrphanRawFolders();
|
||||
setOrphanRows(response.rows || []);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'RAW-Prüfung fehlgeschlagen', detail: error.message });
|
||||
} finally {
|
||||
setOrphanLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await Promise.all([loadRows(), loadOrphans()]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
load();
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailVisible || !selectedJob?.id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const shouldPoll =
|
||||
['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(selectedJob.status) ||
|
||||
(selectedJob.status === 'READY_TO_ENCODE' && !selectedJob.encodePlan);
|
||||
|
||||
if (!shouldPoll) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const refreshDetail = async () => {
|
||||
try {
|
||||
const response = await api.getJob(selectedJob.id, { includeLogs: false });
|
||||
if (!cancelled) {
|
||||
setSelectedJob(response.job);
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore polling errors; user can manually refresh
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(refreshDetail, 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [detailVisible, selectedJob?.id, selectedJob?.status, selectedJob?.encodePlan]);
|
||||
|
||||
const openDetail = async (row) => {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedJob({
|
||||
...row,
|
||||
logs: [],
|
||||
log: '',
|
||||
logMeta: {
|
||||
loaded: false,
|
||||
total: Number(row?.log_count || 0),
|
||||
returned: 0,
|
||||
truncated: false
|
||||
}
|
||||
});
|
||||
setDetailVisible(true);
|
||||
setDetailLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.getJob(jobId, { includeLogs: false });
|
||||
setSelectedJob(response.job);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshDetailIfOpen = async (jobId) => {
|
||||
if (!detailVisible || !selectedJob || selectedJob.id !== jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await api.getJob(jobId, { includeLogs: false });
|
||||
setSelectedJob(response.job);
|
||||
};
|
||||
|
||||
const handleLoadLog = async (job, mode = 'tail') => {
|
||||
const jobId = Number(job?.id || selectedJob?.id || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLogLoadingMode(mode);
|
||||
try {
|
||||
const response = await api.getJob(jobId, {
|
||||
includeLogs: true,
|
||||
includeAllLogs: mode === 'all',
|
||||
logTailLines: mode === 'all' ? null : 800
|
||||
});
|
||||
setSelectedJob(response.job);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Log konnte nicht geladen werden', detail: error.message });
|
||||
} finally {
|
||||
setLogLoadingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFiles = async (row, target) => {
|
||||
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? 'Movie-Datei(en)' : 'RAW + Movie';
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const response = await api.deleteJobFiles(row.id, target);
|
||||
const summary = response.summary || {};
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Dateien gelöscht',
|
||||
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, MOVIE: ${summary.movie?.filesDeleted ?? 0}`,
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
await refreshDetailIfOpen(row.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReencode = async (row) => {
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
const confirmed = window.confirm(`Re-Encode aus RAW für "${title}" starten? Der bestehende Job wird aktualisiert.`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReencodeBusyJobId(row.id);
|
||||
try {
|
||||
const response = await api.reencodeJob(row.id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Re-Encode gestartet',
|
||||
detail: 'Bestehender Job wurde in die Mediainfo-Prüfung gesetzt.',
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
await refreshDetailIfOpen(row.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Re-Encode fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setReencodeBusyJobId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const mapDeleteChoice = (value) => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'raw') return 'raw';
|
||||
if (normalized === 'fertig') return 'movie';
|
||||
if (normalized === 'beides') return 'both';
|
||||
if (normalized === 'nichts') return 'none';
|
||||
if (normalized === 'movie') return 'movie';
|
||||
if (normalized === 'both') return 'both';
|
||||
if (normalized === 'none') return 'none';
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async (row) => {
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
const choiceRaw = window.prompt(
|
||||
`Was soll beim Löschen von "${title}" mit gelöscht werden?\n` +
|
||||
'- raw\n' +
|
||||
'- fertig\n' +
|
||||
'- beides\n' +
|
||||
'- nichts',
|
||||
'nichts'
|
||||
);
|
||||
|
||||
if (choiceRaw === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = mapDeleteChoice(choiceRaw);
|
||||
if (!target) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Ungültige Eingabe',
|
||||
detail: 'Bitte genau eine Option verwenden: raw, fertig, beides, nichts.',
|
||||
life: 4200
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Historieneintrag "${title}" wirklich löschen? Auswahl: ${target === 'movie' ? 'fertig' : target}`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteEntryBusyJobId(row.id);
|
||||
try {
|
||||
const response = await api.deleteJobEntry(row.id, target);
|
||||
const rawDeleted = response?.fileSummary?.raw?.filesDeleted ?? 0;
|
||||
const movieDeleted = response?.fileSummary?.movie?.filesDeleted ?? 0;
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Historieneintrag gelöscht',
|
||||
detail: `Dateien entfernt: RAW ${rawDeleted}, Fertig ${movieDeleted}`,
|
||||
life: 4200
|
||||
});
|
||||
if (selectedJob?.id === row.id) {
|
||||
setDetailVisible(false);
|
||||
setSelectedJob(null);
|
||||
}
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Löschen fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
setDeleteEntryBusyJobId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportOrphanRaw = async (row) => {
|
||||
const target = row?.rawPath || row?.folderName || '-';
|
||||
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOrphanImportBusyPath(row.rawPath);
|
||||
try {
|
||||
const response = await api.importOrphanRawFolder(row.rawPath);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Job angelegt',
|
||||
detail: `Historieneintrag #${response?.job?.id || '-'} wurde erstellt.`,
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Import fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setOrphanImportBusyPath(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOmdbSearch = async (query) => {
|
||||
try {
|
||||
const response = await api.searchOmdb(query);
|
||||
return response.results || [];
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'OMDb Suche fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const openMetadataAssignDialog = (row) => {
|
||||
if (!row?.id) {
|
||||
return;
|
||||
}
|
||||
const detectedTitle = row.title || row.detected_title || '';
|
||||
const imdbId = String(row.imdb_id || '').trim();
|
||||
const seedRows = imdbId
|
||||
? [{
|
||||
title: row.title || row.detected_title || detectedTitle || imdbId,
|
||||
year: row.year || '',
|
||||
imdbId,
|
||||
type: 'movie',
|
||||
poster: row.poster_url || null
|
||||
}]
|
||||
: [];
|
||||
|
||||
setMetadataDialogContext({
|
||||
jobId: row.id,
|
||||
detectedTitle,
|
||||
selectedMetadata: {
|
||||
title: row.title || row.detected_title || '',
|
||||
year: row.year || '',
|
||||
imdbId,
|
||||
poster: row.poster_url || null
|
||||
},
|
||||
omdbCandidates: seedRows
|
||||
});
|
||||
setMetadataDialogVisible(true);
|
||||
};
|
||||
|
||||
const handleMetadataAssignSubmit = async (payload) => {
|
||||
const jobId = Number(payload?.jobId || metadataDialogContext?.jobId || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMetadataDialogBusy(true);
|
||||
try {
|
||||
const response = await api.assignJobOmdb(jobId, payload);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'OMDb-Zuordnung aktualisiert',
|
||||
detail: `Job #${jobId} wurde aktualisiert.`,
|
||||
life: 3500
|
||||
});
|
||||
setMetadataDialogVisible(false);
|
||||
await load();
|
||||
if (detailVisible && selectedJob?.id === jobId && response?.job) {
|
||||
setSelectedJob(response.job);
|
||||
} else {
|
||||
await refreshDetailIfOpen(jobId);
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'OMDb-Zuordnung fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
setMetadataDialogBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const posterBody = (row) =>
|
||||
row.poster_url && row.poster_url !== 'N/A' ? (
|
||||
<img src={row.poster_url} alt={row.title || row.detected_title || 'Poster'} className="poster-thumb" />
|
||||
) : (
|
||||
<span>-</span>
|
||||
);
|
||||
|
||||
const titleBody = (row) => (
|
||||
<div>
|
||||
<div><strong>{row.title || row.detected_title || '-'}</strong></div>
|
||||
<small>{row.year || '-'} | {row.imdb_id || '-'}</small>
|
||||
</div>
|
||||
);
|
||||
|
||||
const stateBody = (row) => <Tag value={row.status} severity={statusSeverity(row.status)} />;
|
||||
const orphanTitleBody = (row) => (
|
||||
<div>
|
||||
<div><strong>{row.title || '-'}</strong></div>
|
||||
<small>{row.year || '-'} | {row.imdbId || '-'}</small>
|
||||
</div>
|
||||
);
|
||||
const orphanPathBody = (row) => (
|
||||
<div className="orphan-path-cell">
|
||||
{row.rawPath}
|
||||
</div>
|
||||
);
|
||||
const orphanActionBody = (row) => (
|
||||
<Button
|
||||
label="Job anlegen"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
onClick={() => handleImportOrphanRaw(row)}
|
||||
loading={orphanImportBusyPath === row.rawPath}
|
||||
disabled={Boolean(orphanImportBusyPath) || Boolean(actionBusy)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<Card title="Historie & Datenbank" subTitle="Kompakte Übersicht, Details im Job-Modal">
|
||||
<div className="table-filters">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel oder IMDb"
|
||||
/>
|
||||
<Dropdown
|
||||
value={status}
|
||||
options={statusOptions}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setStatus(event.value)}
|
||||
placeholder="Status"
|
||||
/>
|
||||
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||
</div>
|
||||
|
||||
<div className="table-scroll-wrap table-scroll-wide">
|
||||
<DataTable
|
||||
value={rows}
|
||||
dataKey="id"
|
||||
paginator
|
||||
rows={10}
|
||||
loading={loading}
|
||||
onRowClick={(event) => openDetail(event.data)}
|
||||
className="clickable-table"
|
||||
emptyMessage="Keine Einträge"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="id" header="ID" style={{ width: '6rem' }} />
|
||||
<Column header="Bild" body={posterBody} style={{ width: '7rem' }} />
|
||||
<Column header="Titel" body={titleBody} style={{ minWidth: '18rem' }} />
|
||||
<Column header="Status" body={stateBody} style={{ width: '11rem' }} />
|
||||
<Column field="start_time" header="Start" style={{ width: '16rem' }} />
|
||||
<Column field="end_time" header="Ende" style={{ width: '16rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="RAW ohne Historie" subTitle="Ordner in raw_dir ohne zugehörigen Job können hier importiert werden">
|
||||
<div className="table-filters">
|
||||
<Button
|
||||
label="RAW prüfen"
|
||||
icon="pi pi-search"
|
||||
onClick={loadOrphans}
|
||||
loading={orphanLoading}
|
||||
disabled={Boolean(orphanImportBusyPath)}
|
||||
/>
|
||||
<Tag value={`${orphanRows.length} gefunden`} severity={orphanRows.length > 0 ? 'warning' : 'success'} />
|
||||
</div>
|
||||
|
||||
<div className="table-scroll-wrap table-scroll-wide">
|
||||
<DataTable
|
||||
value={orphanRows}
|
||||
dataKey="rawPath"
|
||||
paginator
|
||||
rows={5}
|
||||
loading={orphanLoading}
|
||||
emptyMessage="Keine verwaisten RAW-Ordner gefunden"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="folderName" header="RAW-Ordner" style={{ minWidth: '18rem' }} />
|
||||
<Column header="Titel" body={orphanTitleBody} style={{ minWidth: '14rem' }} />
|
||||
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
|
||||
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
|
||||
<Column field="lastModifiedAt" header="Geändert" style={{ width: '16rem' }} />
|
||||
<Column header="Aktion" body={orphanActionBody} style={{ width: '10rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<JobDetailDialog
|
||||
visible={detailVisible}
|
||||
job={selectedJob}
|
||||
detailLoading={detailLoading}
|
||||
onLoadLog={handleLoadLog}
|
||||
logLoadingMode={logLoadingMode}
|
||||
onHide={() => {
|
||||
setDetailVisible(false);
|
||||
setDetailLoading(false);
|
||||
setLogLoadingMode(null);
|
||||
}}
|
||||
onAssignOmdb={openMetadataAssignDialog}
|
||||
onReencode={handleReencode}
|
||||
onDeleteFiles={handleDeleteFiles}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
omdbAssignBusy={metadataDialogBusy}
|
||||
actionBusy={actionBusy}
|
||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||
deleteEntryBusy={deleteEntryBusyJobId === selectedJob?.id}
|
||||
/>
|
||||
|
||||
<MetadataSelectionDialog
|
||||
visible={metadataDialogVisible}
|
||||
context={metadataDialogContext || {}}
|
||||
onHide={() => setMetadataDialogVisible(false)}
|
||||
onSubmit={handleMetadataAssignSubmit}
|
||||
onSearch={handleOmdbSearch}
|
||||
busy={metadataDialogBusy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
frontend/src/pages/HistoryPage.jsx
Normal file
197
frontend/src/pages/HistoryPage.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
import JobDetailDialog from '../components/JobDetailDialog';
|
||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Alle', value: '' },
|
||||
{ label: 'FINISHED', value: 'FINISHED' },
|
||||
{ label: 'ERROR', value: 'ERROR' },
|
||||
{ label: 'WAITING_FOR_USER_DECISION', value: 'WAITING_FOR_USER_DECISION' },
|
||||
{ label: 'READY_TO_START', value: 'READY_TO_START' },
|
||||
{ label: 'READY_TO_ENCODE', value: 'READY_TO_ENCODE' },
|
||||
{ label: 'MEDIAINFO_CHECK', value: 'MEDIAINFO_CHECK' },
|
||||
{ label: 'RIPPING', value: 'RIPPING' },
|
||||
{ label: 'ENCODING', value: 'ENCODING' },
|
||||
{ label: 'ANALYZING', value: 'ANALYZING' },
|
||||
{ label: 'METADATA_SELECTION', value: 'METADATA_SELECTION' }
|
||||
];
|
||||
|
||||
function resolveMediaType(row) {
|
||||
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
||||
return raw === 'bluray' ? 'bluray' : 'disc';
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [selectedJob, setSelectedJob] = useState(null);
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [logLoadingMode, setLogLoadingMode] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getJobs({ search, status });
|
||||
setJobs(response.jobs || []);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
load();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, status]);
|
||||
|
||||
const openDetail = async (row) => {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedJob({
|
||||
...row,
|
||||
logs: [],
|
||||
log: '',
|
||||
logMeta: {
|
||||
loaded: false,
|
||||
total: Number(row?.log_count || 0),
|
||||
returned: 0,
|
||||
truncated: false
|
||||
}
|
||||
});
|
||||
setDetailVisible(true);
|
||||
setDetailLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.getJob(jobId, { includeLogs: false });
|
||||
setSelectedJob(response.job);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadLog = async (job, mode = 'tail') => {
|
||||
const jobId = Number(job?.id || selectedJob?.id || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLogLoadingMode(mode);
|
||||
try {
|
||||
const response = await api.getJob(jobId, {
|
||||
includeLogs: true,
|
||||
includeAllLogs: mode === 'all',
|
||||
logTailLines: mode === 'all' ? null : 800
|
||||
});
|
||||
setSelectedJob(response.job);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Log konnte nicht geladen werden', detail: error.message });
|
||||
} finally {
|
||||
setLogLoadingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBody = (row) => <Tag value={row.status} />;
|
||||
const mkBody = (row) => row.makemkvInfo ? `${row.makemkvInfo.status || '-'} ${typeof row.makemkvInfo.lastProgress === 'number' ? `${row.makemkvInfo.lastProgress.toFixed(1)}%` : ''}` : '-';
|
||||
const hbBody = (row) => row.handbrakeInfo ? `${row.handbrakeInfo.status || '-'} ${typeof row.handbrakeInfo.lastProgress === 'number' ? `${row.handbrakeInfo.lastProgress.toFixed(1)}%` : ''}` : '-';
|
||||
const mediaBody = (row) => {
|
||||
const mediaType = resolveMediaType(row);
|
||||
const src = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
|
||||
const alt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
|
||||
const title = mediaType === 'bluray' ? 'Blu-ray' : 'CD/sonstiges Medium';
|
||||
return <img src={src} alt={alt} title={title} className="media-indicator-icon" />;
|
||||
};
|
||||
const posterBody = (row) =>
|
||||
row.poster_url && row.poster_url !== 'N/A' ? (
|
||||
<img src={row.poster_url} alt={row.title || row.detected_title || 'Poster'} className="poster-thumb" />
|
||||
) : (
|
||||
<span>-</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<Card title="Historie" subTitle="Alle Jobs mit Details und Logs">
|
||||
<div className="table-filters">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel oder IMDb"
|
||||
/>
|
||||
<Dropdown
|
||||
value={status}
|
||||
options={statusOptions}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setStatus(event.value)}
|
||||
placeholder="Status"
|
||||
/>
|
||||
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||
</div>
|
||||
|
||||
<div className="table-scroll-wrap table-scroll-wide">
|
||||
<DataTable
|
||||
value={jobs}
|
||||
dataKey="id"
|
||||
paginator
|
||||
rows={10}
|
||||
loading={loading}
|
||||
onRowClick={(event) => openDetail(event.data)}
|
||||
className="clickable-table"
|
||||
emptyMessage="Keine Einträge"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="id" header="#" style={{ width: '5rem' }} />
|
||||
<Column header="Medium" body={mediaBody} style={{ width: '6rem' }} />
|
||||
<Column header="Poster" body={posterBody} style={{ width: '7rem' }} />
|
||||
<Column field="title" header="Titel" body={(row) => row.title || row.detected_title || '-'} />
|
||||
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
|
||||
<Column field="imdb_id" header="IMDb" style={{ width: '10rem' }} />
|
||||
<Column field="status" header="Status" body={statusBody} style={{ width: '12rem' }} />
|
||||
<Column header="MakeMKV" body={mkBody} style={{ width: '12rem' }} />
|
||||
<Column header="HandBrake" body={hbBody} style={{ width: '12rem' }} />
|
||||
<Column field="start_time" header="Start" style={{ width: '16rem' }} />
|
||||
<Column field="end_time" header="Ende" style={{ width: '16rem' }} />
|
||||
<Column field="output_path" header="Output" />
|
||||
</DataTable>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<JobDetailDialog
|
||||
visible={detailVisible}
|
||||
job={selectedJob}
|
||||
detailLoading={detailLoading}
|
||||
onLoadLog={handleLoadLog}
|
||||
logLoadingMode={logLoadingMode}
|
||||
onHide={() => {
|
||||
setDetailVisible(false);
|
||||
setDetailLoading(false);
|
||||
setLogLoadingMode(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
frontend/src/pages/SettingsPage.jsx
Normal file
204
frontend/src/pages/SettingsPage.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||||
|
||||
function buildValuesMap(categories) {
|
||||
const next = {};
|
||||
for (const category of categories || []) {
|
||||
for (const setting of category.settings || []) {
|
||||
next[setting.key] = setting.value;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isSameValue(a, b) {
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
return Number(a) === Number(b);
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingPushover, setTestingPushover] = useState(false);
|
||||
const [initialValues, setInitialValues] = useState({});
|
||||
const [draftValues, setDraftValues] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getSettings();
|
||||
const nextCategories = response.categories || [];
|
||||
const values = buildValuesMap(nextCategories);
|
||||
setCategories(nextCategories);
|
||||
setInitialValues(values);
|
||||
setDraftValues(values);
|
||||
setErrors({});
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const dirtyKeys = useMemo(() => {
|
||||
const keys = new Set();
|
||||
const allKeys = new Set([...Object.keys(initialValues), ...Object.keys(draftValues)]);
|
||||
for (const key of allKeys) {
|
||||
if (!isSameValue(initialValues[key], draftValues[key])) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}, [initialValues, draftValues]);
|
||||
|
||||
const hasUnsavedChanges = dirtyKeys.size > 0;
|
||||
|
||||
const handleFieldChange = (key, value) => {
|
||||
setDraftValues((prev) => ({ ...prev, [key]: value }));
|
||||
setErrors((prev) => ({ ...prev, [key]: null }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasUnsavedChanges) {
|
||||
toastRef.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Settings',
|
||||
detail: 'Keine Änderungen zum Speichern.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const patch = {};
|
||||
for (const key of dirtyKeys) {
|
||||
patch[key] = draftValues[key];
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await api.updateSettingsBulk(patch);
|
||||
setInitialValues((prev) => ({ ...prev, ...patch }));
|
||||
setErrors({});
|
||||
const reviewRefresh = response?.reviewRefresh || null;
|
||||
const reviewRefreshHint = reviewRefresh?.triggered
|
||||
? ' Mediainfo-Prüfung wird mit den neuen Settings automatisch neu berechnet.'
|
||||
: '';
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Settings',
|
||||
detail: `${Object.keys(patch).length} Änderung(en) gespeichert.${reviewRefreshHint}`
|
||||
});
|
||||
} catch (error) {
|
||||
let detail = error?.message || 'Unbekannter Fehler';
|
||||
if (Array.isArray(error?.details)) {
|
||||
const nextErrors = {};
|
||||
for (const item of error.details) {
|
||||
if (item?.key) {
|
||||
nextErrors[item.key] = item.message || 'Ungültiger Wert';
|
||||
}
|
||||
}
|
||||
setErrors(nextErrors);
|
||||
detail = 'Mindestens ein Feld ist ungültig.';
|
||||
}
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Speichern fehlgeschlagen', detail });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
setDraftValues(initialValues);
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
const handlePushoverTest = async () => {
|
||||
setTestingPushover(true);
|
||||
try {
|
||||
const response = await api.testPushover();
|
||||
const sent = response?.result?.sent;
|
||||
if (sent) {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'PushOver',
|
||||
detail: 'Testnachricht wurde versendet.'
|
||||
});
|
||||
} else {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'PushOver',
|
||||
detail: `Nicht versendet (${response?.result?.reason || 'unbekannt'}).`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'PushOver Fehler', detail: error.message });
|
||||
} finally {
|
||||
setTestingPushover(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<Card title="Einstellungen" subTitle="Änderungen werden erst beim Speichern in die Datenbank übernommen">
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="Änderungen speichern"
|
||||
icon="pi pi-save"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!hasUnsavedChanges}
|
||||
/>
|
||||
<Button
|
||||
label="Änderungen verwerfen"
|
||||
icon="pi pi-undo"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={handleDiscard}
|
||||
disabled={!hasUnsavedChanges || saving}
|
||||
/>
|
||||
<Button
|
||||
label="Neu laden"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
onClick={load}
|
||||
loading={loading}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button
|
||||
label="PushOver Test"
|
||||
icon="pi pi-send"
|
||||
severity="info"
|
||||
onClick={handlePushoverTest}
|
||||
loading={testingPushover}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p>Lade Settings ...</p>
|
||||
) : (
|
||||
<DynamicSettingsForm
|
||||
categories={categories}
|
||||
values={draftValues}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
974
frontend/src/styles/app.css
Normal file
974
frontend/src/styles/app.css
Normal file
@@ -0,0 +1,974 @@
|
||||
:root {
|
||||
--rip-ink: #2f180f;
|
||||
--rip-brown-900: #3a1d12;
|
||||
--rip-brown-800: #552919;
|
||||
--rip-brown-700: #6f3922;
|
||||
--rip-brown-600: #8e4d2d;
|
||||
--rip-gold-400: #d49c56;
|
||||
--rip-gold-300: #e7c17e;
|
||||
--rip-gold-200: #f2dcad;
|
||||
--rip-cream-100: #fbf2df;
|
||||
--rip-cream-50: #fffaf0;
|
||||
--rip-border: #d9bc8d;
|
||||
--rip-muted: #6a4d38;
|
||||
--rip-panel: #fffaf1;
|
||||
--rip-panel-soft: #fdf5e7;
|
||||
|
||||
/* PrimeReact theme tokens */
|
||||
--primary-color: var(--rip-brown-600);
|
||||
--primary-color-text: #fff7e9;
|
||||
--primary-50: #fef6ec;
|
||||
--primary-100: #f3d9b2;
|
||||
--primary-200: #e7bd87;
|
||||
--primary-300: #daa05c;
|
||||
--primary-400: #cd8331;
|
||||
--primary-500: #b56a2a;
|
||||
--primary-600: #965724;
|
||||
--primary-700: #77431c;
|
||||
--primary-800: #583015;
|
||||
--primary-900: #391e0d;
|
||||
--green-50: #fcf7ee;
|
||||
--green-100: #f4dec0;
|
||||
--green-200: #ebc594;
|
||||
--green-300: #e1ac68;
|
||||
--green-400: #d8933d;
|
||||
--green-500: #bf7832;
|
||||
--green-600: #9f642a;
|
||||
--green-700: #7f4f21;
|
||||
--green-800: #603a18;
|
||||
--green-900: #402510;
|
||||
--yellow-50: #fff8e9;
|
||||
--yellow-100: #f9e2b3;
|
||||
--yellow-200: #f2cb7f;
|
||||
--yellow-300: #ebb54c;
|
||||
--yellow-400: #e49e19;
|
||||
--yellow-500: #ca8616;
|
||||
--yellow-600: #a66f12;
|
||||
--yellow-700: #83580e;
|
||||
--yellow-800: #60410a;
|
||||
--yellow-900: #3d2a06;
|
||||
--blue-50: #f8f4ee;
|
||||
--blue-100: #e7d8c0;
|
||||
--blue-200: #d7bd96;
|
||||
--blue-300: #c7a16d;
|
||||
--blue-400: #b78643;
|
||||
--blue-500: #9d6d38;
|
||||
--blue-600: #825a2e;
|
||||
--blue-700: #664623;
|
||||
--blue-800: #4b3319;
|
||||
--blue-900: #301f0f;
|
||||
--red-50: #fdf2ed;
|
||||
--red-100: #f3cec0;
|
||||
--red-200: #eaa995;
|
||||
--red-300: #e18569;
|
||||
--red-400: #d7613e;
|
||||
--red-500: #bf4f33;
|
||||
--red-600: #9f422a;
|
||||
--red-700: #7f3521;
|
||||
--red-800: #602818;
|
||||
--red-900: #401b10;
|
||||
--surface-ground: #f2e7d1;
|
||||
--surface-section: var(--rip-cream-100);
|
||||
--surface-card: var(--rip-panel);
|
||||
--surface-overlay: #fffaf2;
|
||||
--surface-border: var(--rip-border);
|
||||
--surface-hover: #f4e7d0;
|
||||
--focus-ring: 0 0 0 0.2rem rgba(182, 107, 44, 0.35);
|
||||
--text-color: var(--rip-ink);
|
||||
--text-color-secondary: var(--rip-muted);
|
||||
--highlight-bg: var(--rip-gold-200);
|
||||
--highlight-text-color: var(--rip-brown-900);
|
||||
|
||||
font-family: 'Source Sans 3', 'Segoe UI', sans-serif;
|
||||
color: var(--rip-ink);
|
||||
background: radial-gradient(circle at 12% 8%, #f8e3bc 0%, #f8ecd4 35%, #fdf8ee 68%, #fffdf8 100%);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: inherit;
|
||||
color: var(--rip-ink);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 0.95rem 1.25rem;
|
||||
border-bottom: 1px solid var(--rip-border);
|
||||
background: linear-gradient(105deg, #f1d09b 0%, #e1af6d 32%, #d89d57 60%, #be7441 100%);
|
||||
box-shadow: 0 8px 18px rgba(58, 29, 18, 0.12);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 5px 9px rgba(58, 29, 18, 0.28));
|
||||
}
|
||||
|
||||
.brand-copy h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.05;
|
||||
color: var(--rip-brown-900);
|
||||
}
|
||||
|
||||
.brand-copy p {
|
||||
margin: 0.28rem 0 0;
|
||||
font-size: 0.92rem;
|
||||
color: var(--rip-brown-700);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-btn.p-button {
|
||||
border-color: rgba(58, 29, 18, 0.36);
|
||||
color: var(--rip-brown-900);
|
||||
}
|
||||
|
||||
.nav-btn.p-button:not(.p-button-outlined) {
|
||||
background: var(--rip-brown-700);
|
||||
border-color: var(--rip-brown-700);
|
||||
color: #fff8eb;
|
||||
}
|
||||
|
||||
.nav-btn.p-button.p-button-outlined {
|
||||
background: rgba(255, 247, 232, 0.45);
|
||||
}
|
||||
|
||||
.nav-btn.p-button.p-button-outlined:hover {
|
||||
background: rgba(255, 247, 232, 0.78);
|
||||
border-color: var(--rip-brown-700);
|
||||
color: var(--rip-brown-900);
|
||||
}
|
||||
|
||||
.nav-btn-active.p-button {
|
||||
box-shadow: 0 0 0 1px rgba(58, 29, 18, 0.3);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
width: min(1280px, 96vw);
|
||||
margin: 1rem auto 2rem;
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-grid .p-card,
|
||||
.page-grid .p-card .p-card-body,
|
||||
.page-grid .p-card .p-card-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-row span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard-job-list {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.dashboard-job-row {
|
||||
width: 100%;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr) auto auto;
|
||||
gap: 0.7rem;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
color: var(--rip-ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-job-row:hover {
|
||||
border-color: var(--rip-brown-600);
|
||||
background: #fbf0df;
|
||||
}
|
||||
|
||||
.dashboard-job-row-main {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-job-row-main strong,
|
||||
.dashboard-job-row-main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-job-title-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-job-title-line > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-indicator-icon {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dashboard-job-row-progress {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.dashboard-job-row-progress .p-progressbar {
|
||||
height: 0.42rem;
|
||||
}
|
||||
|
||||
.dashboard-job-row-progress small {
|
||||
color: var(--rip-muted);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.dashboard-job-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dashboard-job-poster-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.56rem;
|
||||
color: var(--rip-muted);
|
||||
background: #f6ebd6;
|
||||
}
|
||||
|
||||
.dashboard-job-expanded {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.6rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.dashboard-job-expanded-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-job-expanded-title {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.playlist-waiting-box {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.playlist-title-choice-list {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.playlist-title-choice {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.playlist-title-choice label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.playlist-title-choice input {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.playlist-recommendation-box {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--rip-panel-soft);
|
||||
padding: 0.5rem 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.playlist-selection-box {
|
||||
margin-top: 0.9rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.playlist-selection-box h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.playlist-candidate-list {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.metadata-grid {
|
||||
margin-top: 0.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.settings-tabview {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-tabview .p-tabview-panels {
|
||||
padding: 1rem 0 0;
|
||||
}
|
||||
|
||||
.settings-sections {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.settings-section.grouped {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.55rem;
|
||||
background: #fff7ea;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-section-head {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.settings-section-head h4 {
|
||||
margin: 0;
|
||||
color: var(--rip-brown-800);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.settings-section-head small {
|
||||
color: var(--rip-muted);
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--rip-panel-soft);
|
||||
}
|
||||
|
||||
.setting-row .saved-tag {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #9d261b;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #9d261b;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-filters {
|
||||
margin-bottom: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 16rem auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.table-scroll-wrap {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.table-scroll-wrap .p-datatable {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-scroll-wrap .p-datatable-wrapper {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-scroll-wrap .p-datatable-table {
|
||||
min-width: 44rem;
|
||||
}
|
||||
|
||||
.table-scroll-wrap.table-scroll-medium .p-datatable-table {
|
||||
min-width: 54rem;
|
||||
}
|
||||
|
||||
.table-scroll-wrap.table-scroll-wide .p-datatable-table {
|
||||
min-width: 72rem;
|
||||
}
|
||||
|
||||
.clickable-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.job-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.job-meta-grid > div,
|
||||
.device-meta > div,
|
||||
.selected-meta > div,
|
||||
.pipeline-meta-inline .device-meta > div {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.job-head-row {
|
||||
display: grid;
|
||||
grid-template-columns: 11rem 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.job-json-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.json-box {
|
||||
background: #2a140d;
|
||||
color: #f8e2b8;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.poster-thumb {
|
||||
width: 46px;
|
||||
height: 68px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.35rem;
|
||||
border: 1px solid #cba266;
|
||||
}
|
||||
|
||||
.poster-thumb-lg {
|
||||
width: 64px;
|
||||
height: 92px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.35rem;
|
||||
border: 1px solid #cba266;
|
||||
background: #f6ebd6;
|
||||
}
|
||||
|
||||
.omdb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.omdb-row > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.orphan-path-cell {
|
||||
max-width: 36rem;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.selected-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 12rem 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pipeline-meta-inline {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 12rem 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.poster-large {
|
||||
width: 100%;
|
||||
height: 16rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #cba266;
|
||||
background: #f6ebd6;
|
||||
}
|
||||
|
||||
.poster-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--rip-muted);
|
||||
}
|
||||
|
||||
.log-box {
|
||||
background: #231109;
|
||||
color: #f6ddb2;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
max-height: 25rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.live-log-block {
|
||||
margin-top: 1rem;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.live-log-block h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.live-log-block .log-box {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.playlist-decision-block {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
background: var(--rip-panel-soft);
|
||||
}
|
||||
|
||||
.playlist-decision-block h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.playlist-decision-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ready-job-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.ready-job-item {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ready-job-item > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.playlist-decision-item {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
background: var(--rip-panel);
|
||||
}
|
||||
|
||||
.mediainfo-review-block {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--rip-border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.mediainfo-review-block h3 {
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
|
||||
.media-review-wrap {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.media-review-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.4rem 0.9rem;
|
||||
}
|
||||
|
||||
.media-review-notes {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.media-title-list,
|
||||
.media-track-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.media-track-item {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.track-action-note {
|
||||
margin-left: 1.7rem;
|
||||
color: var(--rip-muted);
|
||||
}
|
||||
|
||||
.media-title-block {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.playlist-info-box {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
background: var(--rip-panel);
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.playlist-segment-output {
|
||||
margin: 0;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 0.35rem;
|
||||
background: #f7ecd7;
|
||||
color: var(--rip-brown-900);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.playlist-segment-toggle {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.playlist-segment-toggle > summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
color: var(--rip-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.readonly-check-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.readonly-check-row input {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.readonly-check-row span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.media-track-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.handbrake-command-preview {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
background: var(--rip-panel);
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.handbrake-command-preview pre {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 0.35rem;
|
||||
background: #f7ecd7;
|
||||
color: var(--rip-brown-900);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.job-detail-dialog .p-dialog-content,
|
||||
.metadata-selection-dialog .p-dialog-content,
|
||||
.disc-detected-dialog .p-dialog-content {
|
||||
max-height: min(80vh, 56rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.job-detail-dialog,
|
||||
.metadata-selection-dialog,
|
||||
.disc-detected-dialog {
|
||||
max-width: calc(100vw - 0.75rem);
|
||||
}
|
||||
|
||||
.job-detail-dialog .p-dialog-header-title,
|
||||
.metadata-selection-dialog .p-dialog-header-title,
|
||||
.disc-detected-dialog .p-dialog-header-title {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-header {
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.brand-copy h1 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.brand-copy p {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.metadata-grid,
|
||||
.device-meta,
|
||||
.media-review-meta,
|
||||
.media-track-grid,
|
||||
.job-meta-grid,
|
||||
.table-filters,
|
||||
.job-head-row,
|
||||
.job-json-grid,
|
||||
.selected-meta,
|
||||
.pipeline-meta-inline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-job-row {
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-job-badges {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dashboard-job-expanded-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.orphan-path-cell {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.metadata-selection-dialog .p-datatable-wrapper {
|
||||
max-height: 16rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-main {
|
||||
width: min(1280px, 98vw);
|
||||
}
|
||||
|
||||
.table-filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.actions-row .p-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.dialog-actions .p-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-job-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-job-row .poster-thumb,
|
||||
.dashboard-job-row .dashboard-job-poster-fallback {
|
||||
width: 52px;
|
||||
height: 76px;
|
||||
}
|
||||
|
||||
.dashboard-job-row-main strong,
|
||||
.dashboard-job-row-main small {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.dashboard-job-title-line > span {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.job-detail-dialog .p-dialog-header,
|
||||
.metadata-selection-dialog .p-dialog-header,
|
||||
.disc-detected-dialog .p-dialog-header {
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.job-detail-dialog .p-dialog-content,
|
||||
.metadata-selection-dialog .p-dialog-content,
|
||||
.disc-detected-dialog .p-dialog-content {
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
max-height: 78vh;
|
||||
}
|
||||
}
|
||||
51
frontend/vite.config.js
Normal file
51
frontend/vite.config.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const publicOrigin = (process.env.VITE_PUBLIC_ORIGIN || '').trim();
|
||||
const parsedAllowedHosts = (process.env.VITE_ALLOWED_HOSTS || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const allowedHosts = parsedAllowedHosts.length > 0 ? parsedAllowedHosts : true;
|
||||
|
||||
let hmr = undefined;
|
||||
if (publicOrigin) {
|
||||
const url = new URL(publicOrigin);
|
||||
const defaultClientPort = url.port
|
||||
? Number(url.port)
|
||||
: (url.protocol === 'https:' ? 443 : 80);
|
||||
|
||||
hmr = {
|
||||
protocol: process.env.VITE_HMR_PROTOCOL || (url.protocol === 'https:' ? 'wss' : 'ws'),
|
||||
host: process.env.VITE_HMR_HOST || url.hostname,
|
||||
clientPort: Number(process.env.VITE_HMR_CLIENT_PORT || defaultClientPort)
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
origin: publicOrigin || undefined,
|
||||
allowedHosts,
|
||||
hmr,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://127.0.0.1:3001',
|
||||
ws: true,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
strictPort: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user