Initial commit mit MkDocs-Dokumentation
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user