Files
ripster/frontend/src/pages/DatabasePage.jsx
T
2026-06-12 11:17:44 +02:00

257 lines
8.9 KiB
React

import { useEffect, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { confirmModal } from '../utils/confirmModal';
function formatDetectedMediaType(value) {
const mediaType = String(value || '').trim().toLowerCase();
if (mediaType === 'bluray') {
return 'Blu-ray';
}
if (mediaType === 'dvd') {
return 'DVD';
}
if (mediaType === 'cd') {
return 'CD';
}
if (mediaType === 'audiobook') {
return 'Audiobook';
}
return 'Sonstiges';
}
export default function DatabasePage() {
const navigate = useNavigate();
const [orphanRows, setOrphanRows] = useState([]);
const [orphanLoading, setOrphanLoading] = useState(false);
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
const [orphanDeleteBusyPath, setOrphanDeleteBusyPath] = useState(null);
const toastRef = useRef(null);
const orphanLoadRequestRef = useRef(0);
const loadOrphans = async (options = {}) => {
const requestId = orphanLoadRequestRef.current + 1;
orphanLoadRequestRef.current = requestId;
const silent = Boolean(options?.silent);
if (!silent) {
setOrphanLoading(true);
}
try {
const response = await api.getOrphanRawFolders();
if (orphanLoadRequestRef.current !== requestId) {
return;
}
setOrphanRows(Array.isArray(response?.rows) ? response.rows : []);
} catch (error) {
if (orphanLoadRequestRef.current !== requestId) {
return;
}
toastRef.current?.show({ severity: 'error', summary: 'RAW-Prüfung fehlgeschlagen', detail: error.message });
} finally {
if (orphanLoadRequestRef.current === requestId && !silent) {
setOrphanLoading(false);
}
}
};
useEffect(() => {
void loadOrphans();
}, []);
const handleImportOrphanRaw = async (row) => {
const target = row?.rawPath || row?.folderName || '-';
const confirmed = await confirmModal({
header: 'Historienjob anlegen',
message: `Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`,
acceptLabel: 'Job anlegen',
rejectLabel: 'Abbrechen'
});
if (!confirmed) {
return;
}
setOrphanImportBusyPath(row.rawPath);
try {
const response = await api.importOrphanRawFolder(row.rawPath);
const newJobId = response?.job?.id;
const activationStarted = Boolean(response?.activation?.started);
const activationError = String(response?.activationError || '').trim();
const importedRawPath = String(row?.rawPath || '').trim();
if (importedRawPath) {
// Immediate UX feedback: imported orphan should disappear without a full page reload.
setOrphanRows((previousRows) => (
(Array.isArray(previousRows) ? previousRows : []).filter((entry) => String(entry?.rawPath || '').trim() !== importedRawPath)
));
}
toastRef.current?.show({
severity: 'success',
summary: activationStarted ? 'Analyse gestartet' : 'Job angelegt',
detail: activationStarted
? (newJobId
? `Job #${newJobId} läuft jetzt im Ripper-View.`
: 'Job läuft jetzt im Ripper-View.')
: (newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.'),
life: 3500
});
if (activationError) {
toastRef.current?.show({
severity: 'warn',
summary: 'Analyse-Start fehlgeschlagen',
detail: activationError,
life: 5200
});
}
// Some workflows update job linkage milliseconds later (e.g. follow-up analyze start).
// Recheck a few times in background to converge UI without manual refresh.
const refreshDelaysMs = [0, 600, 1800];
for (const delayMs of refreshDelaysMs) {
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
await loadOrphans({ silent: true });
}
// Jump to the Ripper view immediately after creating a job from /database.
navigate('/ripper');
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Import fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setOrphanImportBusyPath(null);
}
};
const handleDeleteOrphanRaw = async (row) => {
const target = String(row?.rawPath || row?.folderName || '-').trim() || '-';
const confirmed = await confirmModal({
header: 'RAW löschen',
message:
`RAW-Ordner "${target}" wirklich löschen?\n` +
'Der Ordner wird dauerhaft aus dem Dateisystem entfernt und verschwindet aus der /database-Liste.',
acceptLabel: 'RAW löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
setOrphanDeleteBusyPath(row.rawPath);
try {
const response = await api.deleteOrphanRawFolder(row.rawPath);
const deletedRawPath = String(response?.rawPath || row?.rawPath || '').trim();
const deletedFiles = Number(response?.filesDeleted || 0);
const deletedDirs = Number(response?.dirsRemoved || 0);
if (deletedRawPath) {
setOrphanRows((previousRows) => (
(Array.isArray(previousRows) ? previousRows : []).filter((entry) => String(entry?.rawPath || '').trim() !== deletedRawPath)
));
}
toastRef.current?.show({
severity: 'success',
summary: 'RAW gelöscht',
detail: `Ordner entfernt | Dateien: ${deletedFiles}, Ordner: ${deletedDirs}`,
life: 3500
});
await loadOrphans({ silent: true });
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschen fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setOrphanDeleteBusyPath(null);
}
};
const orphanTitleBody = (row) => (
<div>
<div><strong>{row.title || '-'}</strong></div>
<small>{row.year || '-'} | {row.imdbId || '-'}</small>
</div>
);
const orphanMediaBody = (row) => (
<Tag value={formatDetectedMediaType(row?.detectedMediaType)} />
);
const orphanPathBody = (row) => (
<div className="orphan-path-cell">
{row.rawPath}
</div>
);
const orphanActionBody = (row) => (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<Button
label="Job anlegen"
icon="pi pi-plus"
size="small"
onClick={() => handleImportOrphanRaw(row)}
loading={orphanImportBusyPath === row.rawPath}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
<Button
label="RAW löschen"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
onClick={() => handleDeleteOrphanRaw(row)}
loading={orphanDeleteBusyPath === row.rawPath}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
</div>
);
return (
<div className="page-grid">
<Toast ref={toastRef} />
<Card
title="Gefundene RAW-Einträge"
subTitle="Es werden RAW-Ordner ohne zugehörigen Historienjob aus Settings- und Default-RAW-Pfaden angezeigt."
>
<div className="table-filters">
<Button
label="RAW prüfen"
icon="pi pi-search"
onClick={loadOrphans}
loading={orphanLoading}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
<Tag value={`${orphanRows.length} gefunden`} severity={orphanRows.length > 0 ? 'warning' : 'success'} />
</div>
<div className="table-scroll-wrap table-scroll-wide">
<DataTable
value={orphanRows}
dataKey="rawPath"
paginator
rows={10}
loading={orphanLoading}
emptyMessage="Keine verwaisten RAW-Ordner gefunden"
responsiveLayout="scroll"
>
<Column field="folderName" header="RAW-Ordner" style={{ minWidth: '18rem' }} />
<Column header="Medium" body={orphanMediaBody} style={{ width: '10rem' }} />
<Column header="Titel" body={orphanTitleBody} style={{ minWidth: '14rem' }} />
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
<Column field="lastModifiedAt" header="Geändert" style={{ width: '16rem' }} />
<Column header="Aktion" body={orphanActionBody} style={{ minWidth: '18rem' }} />
</DataTable>
</div>
</Card>
</div>
);
}