import { useEffect, useMemo, 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 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';
}
}
return 'other';
}
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function getQueueActionResult(response) {
return response?.result && typeof response.result === 'object' ? response.result : {};
}
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 [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);
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 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);
}
};
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' ? (
) : (
-
);
const titleBody = (row) => (