0.11.0 Multi LW
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.2-25",
|
||||
"version": "0.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.2-25",
|
||||
"version": "0.11.0",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.2-25",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+31
-1
@@ -110,6 +110,7 @@ function App() {
|
||||
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
|
||||
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
||||
const [expertMode, setExpertMode] = useState(false);
|
||||
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
||||
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
|
||||
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
||||
@@ -232,6 +233,13 @@ function App() {
|
||||
setDownloadSummary(response?.summary || null);
|
||||
})
|
||||
.catch(() => null);
|
||||
api.getSettings()
|
||||
.then((response) => {
|
||||
const allSettings = (response?.categories || []).flatMap((c) => c.settings || []);
|
||||
const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value;
|
||||
setExpertMode(val === 'true' || val === true);
|
||||
})
|
||||
.catch(() => null);
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
@@ -307,6 +315,27 @@ function App() {
|
||||
setHardwareMonitoring(message.payload || null);
|
||||
}
|
||||
|
||||
if (message.type === 'SETTINGS_UPDATED') {
|
||||
const setting = message.payload;
|
||||
if (setting?.key === 'ui_expert_mode') {
|
||||
const val = setting?.value;
|
||||
setExpertMode(val === 'true' || val === true);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'SETTINGS_BULK_UPDATED') {
|
||||
const keys = message.payload?.keys || [];
|
||||
if (keys.includes('ui_expert_mode')) {
|
||||
api.getSettings({ forceRefresh: true })
|
||||
.then((response) => {
|
||||
const allSettings = (response?.categories || []).flatMap((c) => c.settings || []);
|
||||
const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value;
|
||||
setExpertMode(val === 'true' || val === true);
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'DOWNLOADS_UPDATED') {
|
||||
const summary = message.payload?.summary && typeof message.payload.summary === 'object'
|
||||
? message.payload.summary
|
||||
@@ -346,7 +375,8 @@ function App() {
|
||||
{ label: 'Dashboard', path: '/' },
|
||||
{ label: 'Settings', path: '/settings' },
|
||||
{ label: 'Historie', path: '/history' },
|
||||
{ label: 'Downloads', path: '/downloads' }
|
||||
{ label: 'Downloads', path: '/downloads' },
|
||||
...(expertMode ? [{ label: 'Database', path: '/database' }] : [])
|
||||
];
|
||||
const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
|
||||
const showAudiobookUploadBanner = uploadPhase !== 'idle';
|
||||
|
||||
@@ -470,9 +470,11 @@ export const api = {
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
},
|
||||
async analyzeDisc() {
|
||||
async analyzeDisc(devicePath = null) {
|
||||
const body = devicePath ? { devicePath } : {};
|
||||
const result = await request('/pipeline/analyze', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
@@ -645,13 +647,6 @@ export const api = {
|
||||
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');
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { InputNumber } from 'primereact/inputnumber';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Button } from 'primereact/button';
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
@@ -87,6 +88,64 @@ function toBoolean(value) {
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
function DriveDevicesEditor({ value, onChange, settingKey }) {
|
||||
const parseDevices = (val) => {
|
||||
try {
|
||||
const parsed = JSON.parse(val || '[]');
|
||||
return Array.isArray(parsed) ? parsed.map(String) : [];
|
||||
} catch (_e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const devices = parseDevices(value);
|
||||
|
||||
const updateDevices = (newDevices) => {
|
||||
onChange?.(settingKey, JSON.stringify(newDevices));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="drive-devices-editor">
|
||||
{devices.length === 0 && (
|
||||
<p className="drive-devices-empty">Keine expliziten Laufwerke konfiguriert.</p>
|
||||
)}
|
||||
{devices.map((path, idx) => (
|
||||
<div key={idx} className="drive-device-row">
|
||||
<InputText
|
||||
value={path}
|
||||
placeholder="/dev/sr0"
|
||||
onChange={(e) => {
|
||||
const next = [...devices];
|
||||
next[idx] = e.target.value;
|
||||
updateDevices(next);
|
||||
}}
|
||||
className="drive-device-input"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
onClick={() => updateDevices(devices.filter((_, i) => i !== idx))}
|
||||
type="button"
|
||||
aria-label="Laufwerk entfernen"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
label="Laufwerk hinzufügen"
|
||||
severity="secondary"
|
||||
text
|
||||
size="small"
|
||||
onClick={() => updateDevices([...devices, ''])}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldHideSettingByExpertMode(settingKey, expertModeEnabled) {
|
||||
const key = normalizeSettingKey(settingKey);
|
||||
if (!key) {
|
||||
@@ -245,7 +304,7 @@ function SettingField({
|
||||
</label>
|
||||
)}
|
||||
|
||||
{setting.type === 'string' || setting.type === 'path' ? (
|
||||
{(setting.type === 'string' || setting.type === 'path') && setting.key !== 'drive_devices' ? (
|
||||
<InputText
|
||||
id={setting.key}
|
||||
value={value ?? ''}
|
||||
@@ -253,6 +312,14 @@ function SettingField({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.key === 'drive_devices' ? (
|
||||
<DriveDevicesEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
settingKey={setting.key}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'number' ? (
|
||||
<InputNumber
|
||||
id={setting.key}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||
import { getStatusLabel } from '../utils/statusPresentation';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const CD_FORMAT_LABELS = {
|
||||
flac: 'FLAC',
|
||||
@@ -529,15 +527,6 @@ export default function JobDetailDialog({
|
||||
deleteEntryBusy = false,
|
||||
downloadBusyTarget = null
|
||||
}) {
|
||||
const [expertMode, setExpertMode] = useState(false);
|
||||
useEffect(() => {
|
||||
api.getSettings().then((res) => {
|
||||
const allSettings = (res?.categories || []).flatMap((c) => c.settings || []);
|
||||
const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value;
|
||||
setExpertMode(val === 'true' || val === true);
|
||||
}).catch(() => { });
|
||||
}, []);
|
||||
|
||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
||||
const showFinalLog = !running;
|
||||
@@ -825,13 +814,6 @@ export default function JobDetailDialog({
|
||||
{job.error_message ? (
|
||||
<div><strong>Fehler:</strong> {job.error_message}</div>
|
||||
) : null}
|
||||
{expertMode ? (
|
||||
<div>
|
||||
<Link to={`/database?table=jobs&id=${job.id}`} className="job-db-link" onClick={onHide}>
|
||||
<i className="pi pi-database" /> DB-Eintrag ansehen
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -948,6 +930,10 @@ export default function JobDetailDialog({
|
||||
const selectedSet = Array.isArray(job.encodePlan?.selectedTracks) && job.encodePlan.selectedTracks.length > 0
|
||||
? new Set(job.encodePlan.selectedTracks.map(Number))
|
||||
: null;
|
||||
const encodeResultMap = new Map(
|
||||
(Array.isArray(job.handbrakeInfo?.tracks) ? job.handbrakeInfo.tracks : [])
|
||||
.map((r) => [Number(r.position), r])
|
||||
);
|
||||
if (tracksRaw.length === 0) return <p className="job-meta-subtle">Keine Tracks vorhanden.</p>;
|
||||
return (
|
||||
<div className="track-group">
|
||||
@@ -962,11 +948,13 @@ export default function JobDetailDialog({
|
||||
? `${Math.floor(durationSec / 60)}:${String(Math.floor(durationSec % 60)).padStart(2, '0')} min`
|
||||
: '-';
|
||||
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
|
||||
const ripDone = job?.ripSuccessful != null;
|
||||
const encodeResult = encodeResultMap.get(pos);
|
||||
const encodeLabel = !selected ? 'Nicht übernommen'
|
||||
: ripDone ? (job.ripSuccessful ? 'Erfolgreich' : 'Fehler')
|
||||
: 'Übernommen';
|
||||
const encodeClass = !selected ? '' : ripDone ? (job.ripSuccessful ? 'tone-ok' : 'tone-no') : '';
|
||||
: encodeResult ? (encodeResult.success ? 'Erfolgreich' : 'Fehler')
|
||||
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'Erfolgreich' : 'Fehler') : 'Ausgewählt');
|
||||
const encodeClass = !selected ? ''
|
||||
: encodeResult ? (encodeResult.success ? 'tone-ok' : 'tone-no')
|
||||
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'tone-ok' : 'tone-no') : '');
|
||||
return (
|
||||
<div key={pos} className="track-item">
|
||||
<span>#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel}</span>
|
||||
|
||||
@@ -1036,23 +1036,23 @@ export default function DashboardPage({
|
||||
}
|
||||
}, [pipeline?.state, metadataDialogVisible, metadataDialogContext?.jobId]);
|
||||
|
||||
// Auto-open CD metadata dialog when pipeline enters CD_METADATA_SELECTION
|
||||
// Auto-open CD metadata dialog when any drive enters CD_METADATA_SELECTION
|
||||
useEffect(() => {
|
||||
const currentState = String(pipeline?.state || '').trim().toUpperCase();
|
||||
if (currentState === 'CD_METADATA_SELECTION') {
|
||||
const ctx = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : null;
|
||||
if (ctx?.jobId && !cdMetadataDialogVisible) {
|
||||
const cdDrives = pipeline?.cdDrives;
|
||||
if (!cdDrives || typeof cdDrives !== 'object') return;
|
||||
for (const drive of Object.values(cdDrives)) {
|
||||
const driveState = String(drive?.state || '').trim().toUpperCase();
|
||||
const ctx = drive?.context && typeof drive.context === 'object' ? drive.context : null;
|
||||
if (driveState === 'CD_METADATA_SELECTION' && ctx?.jobId && !cdMetadataDialogVisible) {
|
||||
setCdMetadataDialogContext(ctx);
|
||||
setCdMetadataDialogVisible(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentState === 'CD_READY_TO_RIP') {
|
||||
const ctx = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : null;
|
||||
if (ctx?.jobId) {
|
||||
if (driveState === 'CD_READY_TO_RIP' && ctx?.jobId) {
|
||||
setCdRipPanelJobId(ctx.jobId);
|
||||
}
|
||||
}
|
||||
}, [pipeline?.state, pipeline?.context?.jobId]);
|
||||
}, [pipeline?.cdDrives]);
|
||||
|
||||
useEffect(() => {
|
||||
setQueueState(normalizeQueue(pipeline?.queue));
|
||||
@@ -1314,6 +1314,19 @@ export default function DashboardPage({
|
||||
await handleAnalyze();
|
||||
};
|
||||
|
||||
const handleAnalyzeForDrive = async (devicePath) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.analyzeDisc(devicePath);
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRescan = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -1969,7 +1982,11 @@ export default function DashboardPage({
|
||||
}
|
||||
};
|
||||
|
||||
const device = lastDiscEvent || pipeline?.context?.device;
|
||||
// CD drives are tracked per-drive in pipeline.cdDrives — exclude them from the single-drive display
|
||||
const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null;
|
||||
const contextDevice = pipeline?.context?.device;
|
||||
const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null);
|
||||
const cdDriveEntries = Object.entries(pipeline?.cdDrives || {});
|
||||
const isDriveActive = driveActiveStates.includes(state);
|
||||
const canRescan = !isDriveActive;
|
||||
const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state));
|
||||
@@ -2236,6 +2253,74 @@ export default function DashboardPage({
|
||||
disabled={!canOpenMetadataModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per-drive CD sections */}
|
||||
{cdDriveEntries.length > 0 && (
|
||||
<div className="cd-drives-section">
|
||||
{cdDriveEntries.map(([drivePath, drive]) => {
|
||||
const driveState = String(drive?.state || '').trim().toUpperCase();
|
||||
const driveDevice = drive?.device || {};
|
||||
const driveJobId = normalizeJobId(drive?.jobId);
|
||||
const driveCtx = drive?.context && typeof drive.context === 'object' ? drive.context : {};
|
||||
const cdStateSeverity = driveState === 'FINISHED' ? 'success'
|
||||
: driveState === 'ERROR' || driveState === 'CANCELLED' ? 'danger'
|
||||
: ['CD_RIPPING', 'CD_ENCODING'].includes(driveState) ? 'warning'
|
||||
: 'info';
|
||||
return (
|
||||
<div key={drivePath} className="cd-drive-item">
|
||||
<div className="cd-drive-header">
|
||||
<strong>{driveDevice.model || 'CD-Laufwerk'}</strong>
|
||||
<code className="cd-drive-path">{drivePath}</code>
|
||||
<Tag value={driveState} severity={cdStateSeverity} />
|
||||
</div>
|
||||
{driveDevice.discLabel && (
|
||||
<div className="cd-drive-disc-label">
|
||||
<strong>Disc:</strong> {driveDevice.discLabel}
|
||||
</div>
|
||||
)}
|
||||
<div className="cd-drive-actions">
|
||||
{driveState === 'DISC_DETECTED' && (
|
||||
<Button
|
||||
label="CD analysieren"
|
||||
icon="pi pi-search"
|
||||
size="small"
|
||||
onClick={() => handleAnalyzeForDrive(drivePath)}
|
||||
loading={busy}
|
||||
disabled={busy}
|
||||
/>
|
||||
)}
|
||||
{driveState === 'CD_METADATA_SELECTION' && (
|
||||
<Button
|
||||
label="Metadaten auswählen"
|
||||
icon="pi pi-list"
|
||||
size="small"
|
||||
severity="info"
|
||||
onClick={() => {
|
||||
setCdMetadataDialogContext({ ...driveCtx, jobId: driveJobId });
|
||||
setCdMetadataDialogVisible(true);
|
||||
}}
|
||||
disabled={!driveJobId}
|
||||
/>
|
||||
)}
|
||||
{(driveState === 'ERROR' || driveState === 'CANCELLED') && driveJobId && (
|
||||
<Button
|
||||
label="Erneut analysieren"
|
||||
icon="pi pi-refresh"
|
||||
size="small"
|
||||
severity="warning"
|
||||
onClick={() => handleAnalyzeForDrive(drivePath)}
|
||||
loading={busy}
|
||||
disabled={busy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Non-CD / legacy single-drive display */}
|
||||
<div className="dashboard-drive-state">
|
||||
{device
|
||||
? <Tag value="Disk eingelegt" severity="success" icon="pi pi-circle-fill" />
|
||||
@@ -2260,7 +2345,7 @@ export default function DashboardPage({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p>Aktuell keine Disk erkannt.</p>
|
||||
cdDriveEntries.length === 0 ? <p>Aktuell keine Disk erkannt.</p> : null
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,115 +1,34 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
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';
|
||||
import CdMetadataDialog from '../components/CdMetadataDialog';
|
||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusSeverity,
|
||||
normalizeStatus,
|
||||
STATUS_FILTER_OPTIONS
|
||||
} from '../utils/statusPresentation';
|
||||
|
||||
function resolveMediaType(row) {
|
||||
const candidates = [
|
||||
row?.mediaType,
|
||||
row?.media_type,
|
||||
row?.mediaProfile,
|
||||
row?.media_profile,
|
||||
row?.encodePlan?.mediaProfile,
|
||||
row?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
row?.makemkvInfo?.mediaProfile,
|
||||
row?.mediainfoInfo?.mediaProfile
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const raw = String(candidate || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (['cd', 'audio_cd'].includes(raw)) {
|
||||
return 'cd';
|
||||
}
|
||||
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||
return 'audiobook';
|
||||
}
|
||||
function formatDetectedMediaType(value) {
|
||||
const mediaType = String(value || '').trim().toLowerCase();
|
||||
if (mediaType === 'bluray') {
|
||||
return 'Blu-ray';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
if (mediaType === 'dvd') {
|
||||
return 'DVD';
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function getQueueActionResult(response) {
|
||||
return response?.result && typeof response.result === 'object' ? response.result : {};
|
||||
if (mediaType === 'cd') {
|
||||
return 'CD';
|
||||
}
|
||||
if (mediaType === 'audiobook') {
|
||||
return 'Audiobook';
|
||||
}
|
||||
return 'Sonstiges';
|
||||
}
|
||||
|
||||
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 [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||
const [cdMetadataDialogBusy, setCdMetadataDialogBusy] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
||||
const [deleteEntryBusyJobId, setDeleteEntryBusyJobId] = useState(null);
|
||||
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
|
||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||
const toastRef = useRef(null);
|
||||
const queuedJobIdSet = useMemo(() => {
|
||||
const next = new Set();
|
||||
for (const value of Array.isArray(queuedJobIds) ? queuedJobIds : []) {
|
||||
const id = normalizeJobId(value);
|
||||
if (id) {
|
||||
next.add(id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}, [queuedJobIds]);
|
||||
|
||||
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);
|
||||
@@ -123,357 +42,9 @@ export default function DatabasePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadQueue = async () => {
|
||||
try {
|
||||
const response = await api.getPipelineQueue();
|
||||
const queuedRows = Array.isArray(response?.queue?.queuedJobs) ? response.queue.queuedJobs : [];
|
||||
const queuedIds = queuedRows
|
||||
.map((item) => normalizeJobId(item?.jobId))
|
||||
.filter(Boolean);
|
||||
setQueuedJobIds(queuedIds);
|
||||
} catch (_error) {
|
||||
setQueuedJobIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await Promise.all([loadRows(), loadOrphans(), loadQueue()]);
|
||||
};
|
||||
|
||||
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 handleRestartEncode = async (row) => {
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
if (row?.encodeSuccess) {
|
||||
const confirmed = window.confirm(
|
||||
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` +
|
||||
'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const response = await api.restartEncodeWithLastSettings(row.id);
|
||||
const result = getQueueActionResult(response);
|
||||
if (result.queued) {
|
||||
const queuePosition = Number(result?.queuePosition || 0);
|
||||
toastRef.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Encode-Neustart in Queue',
|
||||
detail: queuePosition > 0
|
||||
? `Job wurde auf Position ${queuePosition} eingeplant.`
|
||||
: 'Job wurde in die Warteschlange eingeplant.',
|
||||
life: 3500
|
||||
});
|
||||
} else {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Encode-Neustart gestartet',
|
||||
detail: 'Letzte bestätigte Einstellungen werden verwendet.',
|
||||
life: 3500
|
||||
});
|
||||
}
|
||||
await load();
|
||||
await refreshDetailIfOpen(row.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartReview = async (row) => {
|
||||
setActionBusy(true);
|
||||
try {
|
||||
await api.restartReviewFromRaw(row.id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Review-Neustart gestartet',
|
||||
detail: 'Die Titel-/Spurprüfung wird aus dem RAW neu berechnet.',
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
await refreshDetailIfOpen(row.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromQueue = async (row) => {
|
||||
const jobId = normalizeJobId(row?.id || row);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionBusy(true);
|
||||
try {
|
||||
await api.cancelPipeline(jobId);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Aus Queue entfernt',
|
||||
detail: `Job #${jobId} wurde aus der Warteschlange entfernt.`,
|
||||
life: 3200
|
||||
});
|
||||
await load();
|
||||
await refreshDetailIfOpen(jobId);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Queue-Entfernung fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeReady = async (row) => {
|
||||
setActionBusy(true);
|
||||
try {
|
||||
await api.resumeReadyJob(row.id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Job ins Dashboard geladen',
|
||||
detail: 'Job ist wieder im Dashboard aktiv.',
|
||||
life: 3200
|
||||
});
|
||||
await load();
|
||||
await refreshDetailIfOpen(row.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Laden fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
void loadOrphans();
|
||||
}, []);
|
||||
|
||||
const handleImportOrphanRaw = async (row) => {
|
||||
const target = row?.rawPath || row?.folderName || '-';
|
||||
@@ -511,7 +82,7 @@ export default function DatabasePage() {
|
||||
life: 3500
|
||||
});
|
||||
}
|
||||
await load();
|
||||
await loadOrphans();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
@@ -523,204 +94,15 @@ export default function DatabasePage() {
|
||||
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 handleMusicBrainzSearch = async (query) => {
|
||||
try {
|
||||
const response = await api.searchMusicBrainz(query);
|
||||
return response.results || [];
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz Suche fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleMusicBrainzReleaseFetch = async (mbId) => {
|
||||
try {
|
||||
const response = await api.getMusicBrainzRelease(mbId);
|
||||
return response.release || null;
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz Release fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const openCdMetadataAssignDialog = (row) => {
|
||||
if (!row?.id) {
|
||||
return;
|
||||
}
|
||||
const makemkvInfo = row.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||
const tocTracks = Array.isArray(makemkvInfo.tracks) ? makemkvInfo.tracks : [];
|
||||
const selectedMetadata = makemkvInfo.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
setCdMetadataDialogContext({
|
||||
jobId: row.id,
|
||||
detectedTitle: row.title || row.detected_title || selectedMetadata.title || '',
|
||||
tracks: tocTracks
|
||||
});
|
||||
setCdMetadataDialogVisible(true);
|
||||
};
|
||||
|
||||
const handleCdMetadataAssignSubmit = async (payload) => {
|
||||
const jobId = Number(payload?.jobId || cdMetadataDialogContext?.jobId || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCdMetadataDialogBusy(true);
|
||||
try {
|
||||
const response = await api.assignJobCdMetadata(jobId, payload);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'CD-Metadaten aktualisiert',
|
||||
detail: `Job #${jobId} wurde aktualisiert.`,
|
||||
life: 3500
|
||||
});
|
||||
setCdMetadataDialogVisible(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: 'CD-Metadaten fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
setCdMetadataDialogBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
const normalizedStatus = normalizeStatus(row?.status);
|
||||
const rowId = normalizeJobId(row?.id);
|
||||
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
|
||||
return (
|
||||
<Tag
|
||||
value={getStatusLabel(row?.status, { queued: isQueued })}
|
||||
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const mediaBody = (row) => {
|
||||
const mediaType = resolveMediaType(row);
|
||||
const src = mediaType === 'bluray'
|
||||
? blurayIndicatorIcon
|
||||
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
|
||||
const alt = mediaType === 'bluray'
|
||||
? 'Blu-ray'
|
||||
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium'));
|
||||
const title = mediaType === 'bluray'
|
||||
? 'Blu-ray'
|
||||
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium'));
|
||||
const label = mediaType === 'bluray'
|
||||
? 'Blu-ray'
|
||||
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges'));
|
||||
return (
|
||||
<span className="job-step-cell">
|
||||
<img src={src} alt={alt} title={title} className="media-indicator-icon" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const orphanTitleBody = (row) => (
|
||||
<div>
|
||||
<div><strong>{row.title || '-'}</strong></div>
|
||||
<small>{row.year || '-'} | {row.imdbId || '-'}</small>
|
||||
</div>
|
||||
);
|
||||
const orphanMediaBody = (row) => (
|
||||
<Tag value={formatDetectedMediaType(row?.detectedMediaType)} />
|
||||
);
|
||||
const orphanPathBody = (row) => (
|
||||
<div className="orphan-path-cell">
|
||||
{row.rawPath}
|
||||
@@ -733,7 +115,7 @@ export default function DatabasePage() {
|
||||
size="small"
|
||||
onClick={() => handleImportOrphanRaw(row)}
|
||||
loading={orphanImportBusyPath === row.rawPath}
|
||||
disabled={Boolean(orphanImportBusyPath) || Boolean(actionBusy)}
|
||||
disabled={Boolean(orphanImportBusyPath)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -741,50 +123,9 @@ export default function DatabasePage() {
|
||||
<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={STATUS_FILTER_OPTIONS}
|
||||
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="Medium" body={mediaBody} style={{ width: '10rem' }} />
|
||||
<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 den konfigurierten RAW-Pfaden (raw_dir sowie raw_dir_{bluray,dvd,cd,audiobook,other}) ohne zugehörigen Job können hier importiert werden"
|
||||
title="Gefundene RAW-Einträge"
|
||||
subTitle="Es werden nur RAW-Ordner ohne zugehörigen Historienjob aus den konfigurierten RAW-Pfaden angezeigt."
|
||||
>
|
||||
<div className="table-filters">
|
||||
<Button
|
||||
@@ -802,12 +143,13 @@ export default function DatabasePage() {
|
||||
value={orphanRows}
|
||||
dataKey="rawPath"
|
||||
paginator
|
||||
rows={5}
|
||||
rows={10}
|
||||
loading={orphanLoading}
|
||||
emptyMessage="Keine verwaisten RAW-Ordner gefunden"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="folderName" header="RAW-Ordner" style={{ minWidth: '18rem' }} />
|
||||
<Column header="Medium" body={orphanMediaBody} style={{ width: '10rem' }} />
|
||||
<Column header="Titel" body={orphanTitleBody} style={{ minWidth: '14rem' }} />
|
||||
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
|
||||
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
|
||||
@@ -816,56 +158,6 @@ export default function DatabasePage() {
|
||||
</DataTable>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<JobDetailDialog
|
||||
visible={detailVisible}
|
||||
job={selectedJob}
|
||||
detailLoading={detailLoading}
|
||||
onLoadLog={handleLoadLog}
|
||||
logLoadingMode={logLoadingMode}
|
||||
onHide={() => {
|
||||
setDetailVisible(false);
|
||||
setDetailLoading(false);
|
||||
setLogLoadingMode(null);
|
||||
}}
|
||||
onAssignOmdb={openMetadataAssignDialog}
|
||||
onAssignCdMetadata={openCdMetadataAssignDialog}
|
||||
onResumeReady={handleResumeReady}
|
||||
onRestartEncode={handleRestartEncode}
|
||||
onRestartReview={handleRestartReview}
|
||||
onReencode={handleReencode}
|
||||
onDeleteFiles={handleDeleteFiles}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
onRemoveFromQueue={handleRemoveFromQueue}
|
||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||
omdbAssignBusy={metadataDialogBusy}
|
||||
cdMetadataAssignBusy={cdMetadataDialogBusy}
|
||||
actionBusy={actionBusy}
|
||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||
deleteEntryBusy={deleteEntryBusyJobId === selectedJob?.id}
|
||||
/>
|
||||
|
||||
<MetadataSelectionDialog
|
||||
visible={metadataDialogVisible}
|
||||
context={metadataDialogContext || {}}
|
||||
onHide={() => setMetadataDialogVisible(false)}
|
||||
onSubmit={handleMetadataAssignSubmit}
|
||||
onSearch={handleOmdbSearch}
|
||||
busy={metadataDialogBusy}
|
||||
/>
|
||||
|
||||
<CdMetadataDialog
|
||||
visible={cdMetadataDialogVisible}
|
||||
context={cdMetadataDialogContext || {}}
|
||||
onHide={() => {
|
||||
setCdMetadataDialogVisible(false);
|
||||
setCdMetadataDialogContext(null);
|
||||
}}
|
||||
onSubmit={handleCdMetadataAssignSubmit}
|
||||
onSearch={handleMusicBrainzSearch}
|
||||
onFetchRelease={handleMusicBrainzReleaseFetch}
|
||||
busy={cdMetadataDialogBusy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1723,6 +1723,72 @@ body {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Drive devices list editor (Settings page) */
|
||||
.drive-devices-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.drive-devices-empty {
|
||||
color: var(--rip-muted);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.drive-device-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.drive-device-input {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Per-drive CD sections in Dashboard */
|
||||
.cd-drives-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--rip-border);
|
||||
}
|
||||
|
||||
.cd-drive-item {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--rip-panel-soft);
|
||||
}
|
||||
|
||||
.cd-drive-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cd-drive-path {
|
||||
font-size: 0.8rem;
|
||||
color: var(--rip-muted);
|
||||
}
|
||||
|
||||
.cd-drive-disc-label {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cd-drive-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notification-toggle-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user