1.0.0 release snapshot
This commit is contained in:
@@ -0,0 +1,571 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import AudiobookUploadPanel from '../components/AudiobookUploadPanel';
|
||||
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
|
||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||
import { resolveMediaType } from '../utils/jobTaxonomy';
|
||||
import { confirmModal } from '../utils/confirmModal';
|
||||
|
||||
const TERMINAL_STATES = new Set(['DONE', 'FINISHED']);
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function hasOwn(source, key) {
|
||||
return source != null && Object.prototype.hasOwnProperty.call(source, key);
|
||||
}
|
||||
|
||||
function normalizeLiveProgressForJob(job) {
|
||||
const live = job?.liveProgress && typeof job.liveProgress === 'object' ? job.liveProgress : null;
|
||||
if (!live) {
|
||||
return null;
|
||||
}
|
||||
const liveContext = live?.context && typeof live.context === 'object' ? live.context : {};
|
||||
return {
|
||||
progress: live?.progress ?? null,
|
||||
eta: live?.eta ?? null,
|
||||
statusText: live?.statusText ?? null,
|
||||
state: live?.state ?? null,
|
||||
currentChapter: liveContext?.currentChapter && typeof liveContext.currentChapter === 'object'
|
||||
? liveContext.currentChapter
|
||||
: null,
|
||||
completedChapterCount: Number.isFinite(Number(liveContext?.completedChapterCount))
|
||||
? Number(liveContext.completedChapterCount)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
function extractUploadJobIdFromResponse(response) {
|
||||
const payload = response && typeof response === 'object' ? response : {};
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
|
||||
return (
|
||||
normalizeJobId(result?.jobId)
|
||||
|| normalizeJobId(payload?.jobId)
|
||||
|| normalizeJobId(result?.id)
|
||||
|| normalizeJobId(payload?.id)
|
||||
|| normalizeJobId(result?.job?.id)
|
||||
|| normalizeJobId(payload?.job?.id)
|
||||
|| null
|
||||
);
|
||||
}
|
||||
|
||||
function parseEncodePlan(job) {
|
||||
if (job?.encodePlan && typeof job.encodePlan === 'object') {
|
||||
return job.encodePlan;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(job?.encode_plan_json || '{}');
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAudiobookMetadata(job, encodePlan) {
|
||||
const handbrakeMeta = job?.handbrakeInfo?.metadata && typeof job.handbrakeInfo.metadata === 'object'
|
||||
? job.handbrakeInfo.metadata
|
||||
: {};
|
||||
const selectedMeta = job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
|
||||
? job.makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
const fallbackMeta = encodePlan?.metadata && typeof encodePlan.metadata === 'object'
|
||||
? encodePlan.metadata
|
||||
: {};
|
||||
const merged = {
|
||||
...fallbackMeta,
|
||||
...selectedMeta,
|
||||
...handbrakeMeta
|
||||
};
|
||||
const chapters = Array.isArray(merged?.chapters)
|
||||
? merged.chapters
|
||||
: (Array.isArray(encodePlan?.chapters) ? encodePlan.chapters : []);
|
||||
|
||||
return {
|
||||
title: String(job?.title || job?.detected_title || merged?.title || '').trim() || null,
|
||||
author: String(merged?.author || merged?.artist || '').trim() || null,
|
||||
narrator: String(merged?.narrator || '').trim() || null,
|
||||
description: String(merged?.description || '').trim() || null,
|
||||
series: String(merged?.series || '').trim() || null,
|
||||
part: Number.isFinite(Number(merged?.part)) ? Math.trunc(Number(merged.part)) : null,
|
||||
year: Number.isFinite(Number(job?.year))
|
||||
? Math.trunc(Number(job.year))
|
||||
: (Number.isFinite(Number(merged?.year)) ? Math.trunc(Number(merged.year)) : null),
|
||||
chapters,
|
||||
durationMs: Number.isFinite(Number(merged?.durationMs)) ? Number(merged.durationMs) : 0,
|
||||
poster: String(job?.poster_url || merged?.poster || '').trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
function buildAudiobookJobMetaLine(jobId, metadata = {}, fallbackYear = null) {
|
||||
const author = String(metadata?.author || metadata?.artist || '').trim() || null;
|
||||
const narrator = String(metadata?.narrator || '').trim() || null;
|
||||
const chapterCount = Array.isArray(metadata?.chapters) ? metadata.chapters.length : 0;
|
||||
const year = Number.isFinite(Number(metadata?.year))
|
||||
? Math.trunc(Number(metadata.year))
|
||||
: (Number.isFinite(Number(fallbackYear)) ? Math.trunc(Number(fallbackYear)) : null);
|
||||
return (
|
||||
`#${jobId}`
|
||||
+ (author ? ` | ${author}` : '')
|
||||
+ (narrator ? ` | ${narrator}` : '')
|
||||
+ (chapterCount > 0 ? ` | ${chapterCount} Kapitel` : '')
|
||||
+ (year ? ` | ${year}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
function buildAudiobookPipeline(job, progress = null) {
|
||||
const encodePlan = parseEncodePlan(job);
|
||||
const selectedMetadata = resolveAudiobookMetadata(job, encodePlan);
|
||||
const reviewData = job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
|
||||
? job.makemkvInfo.selectedMetadata
|
||||
: selectedMetadata;
|
||||
const state = String(progress?.state || job?.status || '').trim().toUpperCase() || 'UNKNOWN';
|
||||
return {
|
||||
state,
|
||||
activeJobId: Number(job?.id) || null,
|
||||
progress: Number.isFinite(Number(progress?.progress)) ? Number(progress.progress) : 0,
|
||||
eta: progress?.eta || null,
|
||||
statusText: progress?.statusText || null,
|
||||
context: {
|
||||
jobId: Number(job?.id) || null,
|
||||
mode: 'audiobook',
|
||||
mediaProfile: 'audiobook',
|
||||
selectedMetadata,
|
||||
audiobookConfig: {
|
||||
format: String(encodePlan?.format || job?.handbrakeInfo?.format || 'mp3').trim().toLowerCase() || 'mp3',
|
||||
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
|
||||
? encodePlan.formatOptions
|
||||
: {},
|
||||
preEncodeScriptIds: Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : [],
|
||||
postEncodeScriptIds: Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : [],
|
||||
preEncodeChainIds: Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [],
|
||||
postEncodeChainIds: Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [],
|
||||
preEncodeItems: Array.isArray(encodePlan?.preEncodeItems) ? encodePlan.preEncodeItems : [],
|
||||
postEncodeItems: Array.isArray(encodePlan?.postEncodeItems) ? encodePlan.postEncodeItems : []
|
||||
},
|
||||
mediaInfoReview: reviewData,
|
||||
outputPath: String(job?.output_path || encodePlan?.outputPath || '').trim() || null,
|
||||
currentChapter: progress?.currentChapter && typeof progress.currentChapter === 'object'
|
||||
? progress.currentChapter
|
||||
: null,
|
||||
completedChapterCount: Number.isFinite(Number(progress?.completedChapterCount))
|
||||
? Number(progress.completedChapterCount)
|
||||
: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function AudiobooksPage({
|
||||
audiobookUpload,
|
||||
onAudiobookUpload,
|
||||
onCancelAudiobookUpload = null
|
||||
}) {
|
||||
const toastRef = useRef(null);
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loadingJobs, setLoadingJobs] = useState(false);
|
||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||
const [jobProgress, setJobProgress] = useState({});
|
||||
const [actionBusyJobIds, setActionBusyJobIds] = useState(() => new Set());
|
||||
|
||||
const setActionBusy = (jobId, busy) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
setActionBusyJobIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (busy) {
|
||||
next.add(normalizedJobId);
|
||||
} else {
|
||||
next.delete(normalizedJobId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
setLoadingJobs(true);
|
||||
try {
|
||||
const response = await api.getAudiobookJobs();
|
||||
const rows = Array.isArray(response?.jobs) ? response.jobs : [];
|
||||
const audiobookRows = rows
|
||||
.filter((job) => resolveMediaType(job) === 'audiobook')
|
||||
.sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0));
|
||||
setJobs(audiobookRows);
|
||||
setJobProgress((prev) => {
|
||||
const next = {};
|
||||
for (const job of audiobookRows) {
|
||||
const jobId = normalizeJobId(job?.id);
|
||||
if (!jobId) {
|
||||
continue;
|
||||
}
|
||||
const status = String(job?.status || '').trim().toUpperCase();
|
||||
const isLiveStatus = ['ANALYZING', 'ENCODING'].includes(status);
|
||||
if (!isLiveStatus) {
|
||||
continue;
|
||||
}
|
||||
const serverProgress = normalizeLiveProgressForJob(job);
|
||||
if (serverProgress) {
|
||||
next[jobId] = serverProgress;
|
||||
continue;
|
||||
}
|
||||
if (prev?.[jobId] && typeof prev[jobId] === 'object') {
|
||||
next[jobId] = prev[jobId];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return audiobookRows;
|
||||
} catch (error) {
|
||||
console.error('AudiobooksPage load jobs error:', error);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadingJobs(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadJobs();
|
||||
const intervalId = setInterval(() => void loadJobs(), 5000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [loadJobs]);
|
||||
|
||||
useWebSocket({
|
||||
onMessage: (message) => {
|
||||
if (!message?.type || !message?.payload) {
|
||||
return;
|
||||
}
|
||||
if (message.type === 'PIPELINE_PROGRESS') {
|
||||
const payload = message.payload;
|
||||
const jobId = normalizeJobId(payload?.activeJobId);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
setJobProgress((prev) => {
|
||||
const previousEntry = prev?.[jobId] && typeof prev[jobId] === 'object' ? prev[jobId] : {};
|
||||
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
|
||||
? payload.contextPatch
|
||||
: null;
|
||||
const nextCurrentChapter = hasOwn(contextPatch, 'currentChapter')
|
||||
? (contextPatch?.currentChapter ?? null)
|
||||
: (previousEntry?.currentChapter ?? null);
|
||||
const nextCompletedChapterCount = hasOwn(contextPatch, 'completedChapterCount')
|
||||
? (Number.isFinite(Number(contextPatch?.completedChapterCount))
|
||||
? Number(contextPatch.completedChapterCount)
|
||||
: null)
|
||||
: (previousEntry?.completedChapterCount ?? null);
|
||||
return {
|
||||
...prev,
|
||||
[jobId]: {
|
||||
progress: payload?.progress ?? previousEntry?.progress ?? null,
|
||||
eta: payload?.eta ?? previousEntry?.eta ?? null,
|
||||
statusText: payload?.statusText ?? previousEntry?.statusText ?? null,
|
||||
state: payload?.state ?? previousEntry?.state ?? null,
|
||||
currentChapter: nextCurrentChapter,
|
||||
completedChapterCount: nextCompletedChapterCount
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
if (
|
||||
message.type === 'PIPELINE_UPDATE'
|
||||
|| message.type === 'PIPELINE_STATE_CHANGED'
|
||||
|| message.type === 'PIPELINE_QUEUE_CHANGED'
|
||||
) {
|
||||
void loadJobs();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const activeJobs = useMemo(
|
||||
() => jobs.filter((job) => !TERMINAL_STATES.has(String(job?.status || '').trim().toUpperCase())),
|
||||
[jobs]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedExpanded = normalizeJobId(expandedJobId);
|
||||
const hasExpanded = activeJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
|
||||
if (hasExpanded) {
|
||||
return;
|
||||
}
|
||||
if (expandedJobId === null) {
|
||||
return;
|
||||
}
|
||||
if (activeJobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
setExpandedJobId(normalizeJobId(activeJobs[0]?.id));
|
||||
}, [activeJobs, expandedJobId]);
|
||||
|
||||
const handleAudiobookStart = async (jobId, audiobookConfig) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
setActionBusy(normalizedJobId, true);
|
||||
try {
|
||||
const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {});
|
||||
const queued = Boolean(response?.result?.queued);
|
||||
toastRef.current?.show({
|
||||
severity: queued ? 'info' : 'success',
|
||||
summary: queued ? 'Job eingereiht' : 'Audiobook gestartet',
|
||||
detail: queued
|
||||
? `Job #${normalizedJobId} wurde in die Warteschlange eingereiht.`
|
||||
: `Job #${normalizedJobId} wurde gestartet.`,
|
||||
life: 3200
|
||||
});
|
||||
await loadJobs();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Start fehlgeschlagen',
|
||||
detail: error?.message || 'Bitte Logs pruefen.',
|
||||
life: 4200
|
||||
});
|
||||
} finally {
|
||||
setActionBusy(normalizedJobId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
setActionBusy(normalizedJobId, true);
|
||||
try {
|
||||
await api.cancelPipeline(normalizedJobId);
|
||||
toastRef.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Abbruch angefordert',
|
||||
detail: `Job #${normalizedJobId} wird abgebrochen.`,
|
||||
life: 2600
|
||||
});
|
||||
await loadJobs();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Abbruch fehlgeschlagen',
|
||||
detail: error?.message || 'Bitte Logs pruefen.',
|
||||
life: 4200
|
||||
});
|
||||
} finally {
|
||||
setActionBusy(normalizedJobId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirmModal({
|
||||
message: `Job #${normalizedJobId} wirklich aus der Historie entfernen?`,
|
||||
header: 'Job loeschen',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClassName: 'p-button-danger',
|
||||
acceptLabel: 'Loeschen',
|
||||
rejectLabel: 'Abbrechen'
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionBusy(normalizedJobId, true);
|
||||
try {
|
||||
await api.deleteJobEntry(normalizedJobId, 'none', { includeRelated: false });
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Job geloescht',
|
||||
detail: `Job #${normalizedJobId} wurde entfernt.`,
|
||||
life: 2800
|
||||
});
|
||||
if (normalizeJobId(expandedJobId) === normalizedJobId) {
|
||||
setExpandedJobId(null);
|
||||
}
|
||||
await loadJobs();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Loeschen fehlgeschlagen',
|
||||
detail: error?.message || 'Bitte Logs pruefen.',
|
||||
life: 4200
|
||||
});
|
||||
} finally {
|
||||
setActionBusy(normalizedJobId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploaded = async (uploadedJobId, response = null) => {
|
||||
const normalizedJobId = normalizeJobId(uploadedJobId)
|
||||
|| extractUploadJobIdFromResponse(response);
|
||||
const rows = await loadJobs();
|
||||
const fallbackJobId = normalizeJobId(rows?.[0]?.id);
|
||||
const targetJobId = normalizedJobId || fallbackJobId;
|
||||
if (targetJobId) {
|
||||
setExpandedJobId(targetJobId);
|
||||
}
|
||||
};
|
||||
|
||||
const jobsCardHeader = (
|
||||
<div className="converter-card-header">
|
||||
<div className="converter-card-title">
|
||||
<span>Audiobook Jobs</span>
|
||||
{activeJobs.length > 0 ? (
|
||||
<Badge value={activeJobs.length} severity="info" />
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
icon={loadingJobs ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
onClick={() => void loadJobs()}
|
||||
disabled={loadingJobs}
|
||||
aria-label="Jobs neu laden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ripper-subpage-content">
|
||||
<Toast ref={toastRef} position="top-right" />
|
||||
|
||||
<Card title="Audiobooks Upload" subTitle="AAX-Dateien importieren und als Audiobook-Job vorbereiten">
|
||||
<AudiobookUploadPanel
|
||||
audiobookUpload={audiobookUpload}
|
||||
onAudiobookUpload={onAudiobookUpload}
|
||||
onCancelUpload={onCancelAudiobookUpload}
|
||||
onUploaded={handleUploaded}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card header={jobsCardHeader}>
|
||||
{activeJobs.length === 0 ? (
|
||||
<p className="converter-jobs-empty-hint">
|
||||
<small>Keine aktiven Audiobook-Jobs vorhanden. Fertige Jobs findest du in der Historie.</small>
|
||||
</p>
|
||||
) : (
|
||||
<div className="ripper-job-list">
|
||||
{activeJobs.map((job) => {
|
||||
const jobId = normalizeJobId(job?.id);
|
||||
if (!jobId) {
|
||||
return null;
|
||||
}
|
||||
const pipelineForJob = buildAudiobookPipeline(job, jobProgress[jobId] || null);
|
||||
const state = String(pipelineForJob?.state || job?.status || '').trim().toUpperCase();
|
||||
const isExpanded = normalizeJobId(expandedJobId) === jobId;
|
||||
const busy = actionBusyJobIds.has(jobId);
|
||||
const title = String(job?.title || job?.detected_title || `Job #${jobId}`).trim();
|
||||
const progressValue = Number.isFinite(Number(pipelineForJob?.progress))
|
||||
? Math.max(0, Math.min(100, Number(pipelineForJob.progress)))
|
||||
: 0;
|
||||
const isRunning = ['ANALYZING', 'ENCODING'].includes(state);
|
||||
const showProgress = isRunning || progressValue > 0;
|
||||
const progressLabel = `${Math.trunc(progressValue)}%`;
|
||||
const etaLabel = String(pipelineForJob?.eta || '').trim();
|
||||
const currentChapter = pipelineForJob?.context?.currentChapter && typeof pipelineForJob.context.currentChapter === 'object'
|
||||
? pipelineForJob.context.currentChapter
|
||||
: null;
|
||||
const chapterLabel = currentChapter?.index && currentChapter?.total
|
||||
? ` | Kapitel ${currentChapter.index}/${currentChapter.total}${currentChapter.title ? `: ${currentChapter.title}` : ''}`
|
||||
: '';
|
||||
const metaLine = buildAudiobookJobMetaLine(jobId, pipelineForJob?.context?.selectedMetadata, job?.year);
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
key={jobId}
|
||||
type="button"
|
||||
className="ripper-job-row"
|
||||
onClick={() => setExpandedJobId(jobId)}
|
||||
>
|
||||
{(job?.poster_url && job.poster_url !== 'N/A') ? (
|
||||
<img src={job.poster_url} alt={title} className="poster-thumb" />
|
||||
) : (
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
|
||||
)}
|
||||
<div className="ripper-job-row-content">
|
||||
<div className="ripper-job-row-main">
|
||||
<strong className="ripper-job-title-line">
|
||||
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
|
||||
<span>{title}</span>
|
||||
</strong>
|
||||
<small>{metaLine}</small>
|
||||
</div>
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
|
||||
</div>
|
||||
{showProgress ? (
|
||||
<div className="ripper-job-row-progress">
|
||||
<ProgressBar value={progressValue} showValue={false} />
|
||||
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<i className="pi pi-angle-down" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={jobId} className="ripper-job-expanded">
|
||||
<div className="ripper-job-expanded-head">
|
||||
{(job?.poster_url && job.poster_url !== 'N/A') ? (
|
||||
<img src={job.poster_url} alt={title} className="poster-thumb" />
|
||||
) : (
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
|
||||
)}
|
||||
<div className="ripper-job-expanded-title">
|
||||
<strong className="ripper-job-title-line">
|
||||
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
|
||||
<span>{title}</span>
|
||||
</strong>
|
||||
<small className="ripper-job-subtitle">{metaLine}</small>
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<Button
|
||||
label="Einklappen"
|
||||
icon="pi pi-angle-up"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => setExpandedJobId(null)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showProgress ? (
|
||||
<div className="ripper-job-row-progress">
|
||||
<ProgressBar value={progressValue} showValue={false} />
|
||||
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
|
||||
</div>
|
||||
) : null}
|
||||
<AudiobookConfigPanel
|
||||
pipeline={pipelineForJob}
|
||||
onStart={(config) => void handleAudiobookStart(jobId, config)}
|
||||
onCancel={() => void handleCancel(jobId)}
|
||||
onDeleteJob={() => void handleDelete(jobId)}
|
||||
busy={busy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
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 { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
import { confirmModal } from '../utils/confirmModal';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: 'Alle Stati', value: '' },
|
||||
{ label: 'Wartend', value: 'queued' },
|
||||
{ label: 'Laufend', value: 'processing' },
|
||||
{ label: 'Bereit', value: 'ready' },
|
||||
{ label: 'Fehlgeschlagen', value: 'failed' }
|
||||
];
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
return date.toLocaleString('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short'
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return '-';
|
||||
}
|
||||
if (parsed === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let unitIndex = 0;
|
||||
let current = parsed;
|
||||
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||
current /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
const digits = unitIndex === 0 ? 0 : 2;
|
||||
return `${current.toFixed(digits)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || '').trim().toLocaleLowerCase('de-DE');
|
||||
}
|
||||
|
||||
function getStatusMeta(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'queued') {
|
||||
return { label: 'Wartend', severity: 'warning' };
|
||||
}
|
||||
if (normalized === 'processing') {
|
||||
return { label: 'Laeuft', severity: 'info' };
|
||||
}
|
||||
if (normalized === 'ready') {
|
||||
return { label: 'Bereit', severity: 'success' };
|
||||
}
|
||||
return { label: 'Fehlgeschlagen', severity: 'danger' };
|
||||
}
|
||||
|
||||
export default function DownloadsPage({ refreshToken = 0 }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloadBusyId, setDownloadBusyId] = useState(null);
|
||||
const [deleteBusyId, setDeleteBusyId] = useState(null);
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const hasActiveItems = useMemo(
|
||||
() => items.some((item) => ['queued', 'processing'].includes(String(item?.status || '').trim().toLowerCase())),
|
||||
[items]
|
||||
);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const searchText = normalizeSearchText(search);
|
||||
return items.filter((item) => {
|
||||
const matchesStatus = !statusFilter || String(item?.status || '').trim().toLowerCase() === statusFilter;
|
||||
if (!matchesStatus) {
|
||||
return false;
|
||||
}
|
||||
if (!searchText) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
item?.displayTitle,
|
||||
item?.archiveName,
|
||||
item?.label,
|
||||
item?.sourcePath,
|
||||
item?.jobId ? `job ${item.jobId}` : ''
|
||||
]
|
||||
.map((value) => normalizeSearchText(value))
|
||||
.join(' ');
|
||||
return haystack.includes(searchText);
|
||||
});
|
||||
}, [items, search, statusFilter]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getDownloads();
|
||||
setItems(Array.isArray(response?.items) ? response.items : []);
|
||||
setSummary(response?.summary && typeof response.summary === 'object' ? response.summary : null);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Downloads konnten nicht geladen werden',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [refreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveItems) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
void load();
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasActiveItems]);
|
||||
|
||||
const handleDownload = async (row) => {
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
setDownloadBusyId(id);
|
||||
try {
|
||||
await api.downloadPreparedArchive(id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'ZIP-Download fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDownloadBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const label = row?.archiveName || `ZIP ${id}`;
|
||||
const confirmed = await confirmModal({
|
||||
header: 'ZIP löschen',
|
||||
message: `"${label}" wirklich loeschen?`,
|
||||
acceptLabel: 'Löschen',
|
||||
rejectLabel: 'Abbrechen',
|
||||
danger: true
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteBusyId(id);
|
||||
try {
|
||||
await api.deleteDownload(id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'ZIP geloescht',
|
||||
detail: `"${label}" wurde entfernt.`,
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Loeschen fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDeleteBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBody = (row) => {
|
||||
const meta = getStatusMeta(row?.status);
|
||||
return <Tag value={meta.label} severity={meta.severity} />;
|
||||
};
|
||||
|
||||
const titleBody = (row) => (
|
||||
<div className="downloads-title-cell">
|
||||
<strong>{row?.displayTitle || '-'}</strong>
|
||||
<small>
|
||||
{row?.jobId ? `Job #${row.jobId}` : 'Ohne Job'} | {row?.label || '-'}
|
||||
</small>
|
||||
{row?.errorMessage ? <small className="downloads-error-text">{row.errorMessage}</small> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const archiveBody = (row) => (
|
||||
<div className="downloads-path-cell">
|
||||
<code>{row?.archiveName || '-'}</code>
|
||||
<small>{row?.downloadDir || '-'}</small>
|
||||
</div>
|
||||
);
|
||||
|
||||
const sourceBody = (row) => (
|
||||
<div className="downloads-path-cell">
|
||||
<code>{row?.sourcePath || '-'}</code>
|
||||
<small>{row?.sourceType === 'file' ? 'Datei' : 'Ordner'}</small>
|
||||
</div>
|
||||
);
|
||||
|
||||
const actionBody = (row) => {
|
||||
const normalizedStatus = String(row?.status || '').trim().toLowerCase();
|
||||
const canDownload = normalizedStatus === 'ready';
|
||||
const canDelete = !['queued', 'processing'].includes(normalizedStatus);
|
||||
const id = String(row?.id || '').trim();
|
||||
|
||||
return (
|
||||
<div className="downloads-actions">
|
||||
<Button
|
||||
label="Download"
|
||||
icon="pi pi-download"
|
||||
size="small"
|
||||
onClick={() => handleDownload(row)}
|
||||
disabled={!canDownload || Boolean(deleteBusyId)}
|
||||
loading={downloadBusyId === id}
|
||||
/>
|
||||
<Button
|
||||
label="Loeschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => handleDelete(row)}
|
||||
disabled={!canDelete || Boolean(downloadBusyId)}
|
||||
loading={deleteBusyId === id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<Card title="Downloadbare Dateien" subTitle="Vorbereitete ZIP-Dateien aus RAW- und Encode-Inhalten">
|
||||
<div className="table-filters">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel, ZIP-Datei oder Pfad"
|
||||
/>
|
||||
<Dropdown
|
||||
value={statusFilter}
|
||||
options={STATUS_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setStatusFilter(event.value || '')}
|
||||
placeholder="Status"
|
||||
/>
|
||||
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||
</div>
|
||||
|
||||
<div className="downloads-summary-tags">
|
||||
<Tag value={`${summary?.activeCount || 0} aktiv`} severity={(summary?.activeCount || 0) > 0 ? 'info' : 'secondary'} />
|
||||
<Tag value={`${summary?.readyCount || 0} bereit`} severity={(summary?.readyCount || 0) > 0 ? 'success' : 'secondary'} />
|
||||
<Tag value={`${summary?.failedCount || 0} Fehler`} severity={(summary?.failedCount || 0) > 0 ? 'danger' : 'secondary'} />
|
||||
</div>
|
||||
|
||||
<div className="table-scroll-wrap table-scroll-wide">
|
||||
<DataTable
|
||||
value={visibleItems}
|
||||
dataKey="id"
|
||||
paginator
|
||||
rows={10}
|
||||
rowsPerPageOptions={[10, 20, 50]}
|
||||
loading={loading}
|
||||
responsiveLayout="scroll"
|
||||
emptyMessage="Keine ZIP-Dateien vorhanden"
|
||||
>
|
||||
<Column header="Status" body={statusBody} style={{ width: '10rem' }} />
|
||||
<Column header="Inhalt" body={titleBody} style={{ minWidth: '18rem' }} />
|
||||
<Column header="ZIP-Datei" body={archiveBody} style={{ minWidth: '18rem' }} />
|
||||
<Column header="Quelle" body={sourceBody} style={{ minWidth: '22rem' }} />
|
||||
<Column header="Erstellt" body={(row) => formatDateTime(row?.createdAt)} style={{ width: '11rem' }} />
|
||||
<Column header="Fertig" body={(row) => formatDateTime(row?.finishedAt)} style={{ width: '11rem' }} />
|
||||
<Column header="Groesse" body={(row) => formatBytes(row?.sizeBytes)} style={{ width: '9rem' }} />
|
||||
<Column header="Aktion" body={actionBody} style={{ width: '14rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import 'chart.js/auto';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Chart } from 'primereact/chart';
|
||||
import { Button } from 'primereact/button';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
|
||||
function clampPercent(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(100, parsed));
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return '-';
|
||||
}
|
||||
return `${Math.round(parsed)}%`;
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const bytes = Number(value);
|
||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||
return '-';
|
||||
}
|
||||
if (bytes === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const normalized = bytes / (1024 ** exponent);
|
||||
return `${normalized.toFixed(normalized >= 100 ? 0 : (normalized >= 10 ? 1 : 2))} ${units[exponent]}`;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '-';
|
||||
}
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return raw;
|
||||
}
|
||||
return new Date(parsed).toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function normalizeHardwareMonitoringPayload(rawPayload) {
|
||||
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
|
||||
return {
|
||||
enabled: Boolean(payload.enabled),
|
||||
intervalMs: Number(payload.intervalMs || 0),
|
||||
updatedAt: payload.updatedAt || null,
|
||||
sample: payload.sample && typeof payload.sample === 'object' ? payload.sample : null,
|
||||
error: payload.error ? String(payload.error) : null
|
||||
};
|
||||
}
|
||||
|
||||
function buildGaugeDataset(value, color) {
|
||||
const safe = clampPercent(value);
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
data: [safe, Math.max(0, 100 - safe)],
|
||||
backgroundColor: [color, 'rgba(255,255,255,0.08)'],
|
||||
borderWidth: 0,
|
||||
hoverOffset: 0
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const gaugeOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '82%',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false
|
||||
}
|
||||
},
|
||||
animation: false
|
||||
};
|
||||
|
||||
const HISTORY_WINDOWS = [
|
||||
{ label: '1h', hours: 1 },
|
||||
{ label: '6h', hours: 6 },
|
||||
{ label: '24h', hours: 24 },
|
||||
{ label: '4d', hours: 96 }
|
||||
];
|
||||
const HISTORY_STACK_BREAKPOINT = 1700;
|
||||
const HISTORY_RESAMPLE_TARGET_POINTS = 720;
|
||||
const HISTORY_INTERPOLATION_MIN_GAP_MS = 5 * 60 * 1000;
|
||||
const HISTORY_INTERPOLATION_MAX_GAP_MS = 45 * 60 * 1000;
|
||||
const HISTORY_INTERPOLATION_GAP_FACTOR = 6;
|
||||
|
||||
const CPU_SERIES_COLOR = '#c43d2f';
|
||||
const GPU_SERIES_COLOR = '#c9961a';
|
||||
const RAM_SERIES_COLOR = '#2e7d4f';
|
||||
|
||||
const historyChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
const first = Array.isArray(items) ? items[0] : null;
|
||||
const rawLabel = first?.label ?? first?.raw?.x ?? first?.parsed?.x ?? '';
|
||||
return formatTickLabel(rawLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 8,
|
||||
color: '#6a4d38',
|
||||
callback: function (value, index, ticks) {
|
||||
const resolvedLabel = typeof this?.getLabelForValue === 'function'
|
||||
? this.getLabelForValue(value)
|
||||
: (
|
||||
Array.isArray(ticks) && ticks[index]
|
||||
? (ticks[index].label ?? ticks[index].value ?? value)
|
||||
: value
|
||||
);
|
||||
return formatTickLabel(resolvedLabel);
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(111,57,34,0.08)'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#6a4d38'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(111,57,34,0.08)'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function toNumberOrNull(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function formatTickLabel(timestampIso) {
|
||||
const parsed = Date.parse(String(timestampIso || ''));
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(parsed);
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDayMarkerLabel(timestampIso) {
|
||||
const parsed = Date.parse(String(timestampIso || ''));
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(parsed);
|
||||
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.`;
|
||||
}
|
||||
|
||||
function toDayKeyFromTimestamp(timestampIso) {
|
||||
const parsed = Date.parse(String(timestampIso || ''));
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(parsed);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function parseTimestampMs(value) {
|
||||
const parsed = Date.parse(String(value || ''));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeHistoryPoints(points = []) {
|
||||
return (Array.isArray(points) ? points : [])
|
||||
.map((point) => {
|
||||
const timestampMs = parseTimestampMs(point?.capturedAt);
|
||||
if (!Number.isFinite(timestampMs)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
capturedAt: new Date(timestampMs).toISOString(),
|
||||
timestampMs,
|
||||
cpuUsagePercent: toNumberOrNull(point?.cpuUsagePercent),
|
||||
ramUsagePercent: toNumberOrNull(point?.ramUsagePercent),
|
||||
gpuUsagePercent: toNumberOrNull(point?.gpuUsagePercent),
|
||||
cpuTemperatureC: toNumberOrNull(point?.cpuTemperatureC),
|
||||
gpuTemperatureC: toNumberOrNull(point?.gpuTemperatureC)
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.timestampMs - right.timestampMs);
|
||||
}
|
||||
|
||||
function interpolateMetric(prevPoint, nextPoint, metricKey, targetMs, maxGapMs) {
|
||||
if (!prevPoint || !nextPoint) {
|
||||
return null;
|
||||
}
|
||||
if (prevPoint.timestampMs === nextPoint.timestampMs) {
|
||||
return toNumberOrNull(prevPoint[metricKey]);
|
||||
}
|
||||
const gapMs = nextPoint.timestampMs - prevPoint.timestampMs;
|
||||
if (!Number.isFinite(gapMs) || gapMs <= 0 || gapMs > maxGapMs) {
|
||||
return null;
|
||||
}
|
||||
const prevValue = toNumberOrNull(prevPoint[metricKey]);
|
||||
const nextValue = toNumberOrNull(nextPoint[metricKey]);
|
||||
if (!Number.isFinite(prevValue) || !Number.isFinite(nextValue)) {
|
||||
return null;
|
||||
}
|
||||
const ratio = (targetMs - prevPoint.timestampMs) / gapMs;
|
||||
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
|
||||
return null;
|
||||
}
|
||||
return Number((prevValue + ((nextValue - prevValue) * ratio)).toFixed(2));
|
||||
}
|
||||
|
||||
function buildResampledHistoryPoints(points = [], historyHours = 24) {
|
||||
const normalized = normalizeHistoryPoints(points);
|
||||
if (normalized.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const historyWindowMs = Math.max(1, Number(historyHours || 24)) * 60 * 60 * 1000;
|
||||
const stepMsRaw = Math.round(historyWindowMs / HISTORY_RESAMPLE_TARGET_POINTS);
|
||||
const stepMs = Math.max(60 * 1000, stepMsRaw);
|
||||
const maxInterpolationGapMs = Math.max(
|
||||
HISTORY_INTERPOLATION_MIN_GAP_MS,
|
||||
Math.min(HISTORY_INTERPOLATION_MAX_GAP_MS, stepMs * HISTORY_INTERPOLATION_GAP_FACTOR)
|
||||
);
|
||||
|
||||
const latestSourceMs = normalized[normalized.length - 1].timestampMs;
|
||||
const windowEndMs = Math.max(Date.now(), latestSourceMs);
|
||||
const windowStartMs = windowEndMs - historyWindowMs;
|
||||
const gridStartMs = Math.floor(windowStartMs / stepMs) * stepMs;
|
||||
const gridEndMs = Math.ceil(windowEndMs / stepMs) * stepMs;
|
||||
const source = normalized.filter((point) => point.timestampMs >= (gridStartMs - maxInterpolationGapMs));
|
||||
|
||||
const keys = [
|
||||
'cpuUsagePercent',
|
||||
'ramUsagePercent',
|
||||
'gpuUsagePercent',
|
||||
'cpuTemperatureC',
|
||||
'gpuTemperatureC'
|
||||
];
|
||||
|
||||
let sourceIndex = 0;
|
||||
const output = [];
|
||||
for (let ts = gridStartMs; ts <= gridEndMs; ts += stepMs) {
|
||||
while (sourceIndex < source.length && source[sourceIndex].timestampMs < ts) {
|
||||
sourceIndex += 1;
|
||||
}
|
||||
const nextPoint = sourceIndex < source.length ? source[sourceIndex] : null;
|
||||
const prevPoint = sourceIndex > 0 ? source[sourceIndex - 1] : null;
|
||||
|
||||
const row = {
|
||||
capturedAt: new Date(ts).toISOString(),
|
||||
timestampMs: ts
|
||||
};
|
||||
|
||||
for (const key of keys) {
|
||||
const nextValue = nextPoint && nextPoint.timestampMs === ts
|
||||
? toNumberOrNull(nextPoint[key])
|
||||
: null;
|
||||
const prevValue = prevPoint && prevPoint.timestampMs === ts
|
||||
? toNumberOrNull(prevPoint[key])
|
||||
: null;
|
||||
row[key] = Number.isFinite(nextValue)
|
||||
? nextValue
|
||||
: (Number.isFinite(prevValue)
|
||||
? prevValue
|
||||
: interpolateMetric(prevPoint, nextPoint, key, ts, maxInterpolationGapMs));
|
||||
}
|
||||
output.push(row);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
const historyDayBoundaryPlugin = {
|
||||
id: 'historyDayBoundary',
|
||||
afterDatasetsDraw(chart, _args, options) {
|
||||
if (options === false) {
|
||||
return;
|
||||
}
|
||||
const xScale = chart?.scales?.x;
|
||||
const area = chart?.chartArea;
|
||||
const labels = Array.isArray(chart?.data?.labels) ? chart.data.labels : [];
|
||||
if (!xScale || !area || labels.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = chart.ctx;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(111,57,34,0.30)';
|
||||
ctx.fillStyle = 'rgba(111,57,34,0.75)';
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
for (let index = 1; index < labels.length; index += 1) {
|
||||
const prevDayKey = toDayKeyFromTimestamp(labels[index - 1]);
|
||||
const nextDayKey = toDayKeyFromTimestamp(labels[index]);
|
||||
if (!prevDayKey || !nextDayKey || prevDayKey === nextDayKey) {
|
||||
continue;
|
||||
}
|
||||
const xPrev = xScale.getPixelForValue(index - 1);
|
||||
const xNext = xScale.getPixelForValue(index);
|
||||
const x = (xPrev + xNext) / 2;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, area.top);
|
||||
ctx.lineTo(x, area.bottom);
|
||||
ctx.stroke();
|
||||
|
||||
const markerLabel = formatDayMarkerLabel(labels[index]);
|
||||
if (markerLabel) {
|
||||
ctx.fillText(markerLabel, x + 4, area.top + 4);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
function buildSeriesToggleButtonStyle(color, active) {
|
||||
if (active) {
|
||||
return {
|
||||
background: color,
|
||||
borderColor: color,
|
||||
color: '#fffaf1'
|
||||
};
|
||||
}
|
||||
return {
|
||||
background: 'transparent',
|
||||
borderColor: color,
|
||||
color
|
||||
};
|
||||
}
|
||||
|
||||
function getHistoryViewportBucket() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'wide';
|
||||
}
|
||||
return window.innerWidth < HISTORY_STACK_BREAKPOINT ? 'narrow' : 'wide';
|
||||
}
|
||||
|
||||
function ChartLegendRow({ items = [] }) {
|
||||
return (
|
||||
<div className="hardware-history-legend-row" aria-hidden="true">
|
||||
{items.map((item) => (
|
||||
<span key={item.label} className="hardware-history-legend-item">
|
||||
<span className="hardware-history-legend-dot" style={{ background: item.color }} />
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GaugeBlock({ title, subtitle, icon, valuePercent, gaugeColor, bars = [], footer = null }) {
|
||||
return (
|
||||
<Card className="hardware-detail-card hardware-detail-card-top">
|
||||
<div className="hardware-detail-card-head">
|
||||
<div className="hardware-detail-card-icon"><i className={`pi ${icon}`} /></div>
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
{subtitle ? <small>{subtitle}</small> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hardware-detail-gauge-layout">
|
||||
<div className="hardware-detail-gauge-wrap">
|
||||
<Chart type="doughnut" data={buildGaugeDataset(valuePercent, gaugeColor)} options={gaugeOptions} className="hardware-detail-gauge-chart" />
|
||||
<div className="hardware-detail-gauge-center">{formatPercent(valuePercent)}</div>
|
||||
</div>
|
||||
<div className="hardware-detail-bar-list">
|
||||
{bars.map((bar) => (
|
||||
<div key={bar.label} className="hardware-detail-bar-item">
|
||||
<div className="hardware-detail-bar-head">
|
||||
<span>{bar.label}</span>
|
||||
<span>{bar.valueLabel}</span>
|
||||
</div>
|
||||
<ProgressBar value={clampPercent(bar.valuePercent)} showValue={false} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HardwarePage({ hardwareMonitoring }) {
|
||||
const navigate = useNavigate();
|
||||
const [historyHours, setHistoryHours] = useState(24);
|
||||
const [historyViewportBucket, setHistoryViewportBucket] = useState(() => getHistoryViewportBucket());
|
||||
const [usageSeriesVisible, setUsageSeriesVisible] = useState({
|
||||
cpu: true,
|
||||
ram: true,
|
||||
gpu: true
|
||||
});
|
||||
const [tempSeriesVisible, setTempSeriesVisible] = useState({
|
||||
cpu: true,
|
||||
gpu: true
|
||||
});
|
||||
const [historyState, setHistoryState] = useState({
|
||||
loading: false,
|
||||
points: [],
|
||||
totalPoints: 0,
|
||||
error: null
|
||||
});
|
||||
const monitoringState = useMemo(
|
||||
() => normalizeHardwareMonitoringPayload(hardwareMonitoring),
|
||||
[hardwareMonitoring]
|
||||
);
|
||||
|
||||
const sample = monitoringState.sample;
|
||||
const cpu = sample?.cpu && typeof sample.cpu === 'object' ? sample.cpu : {};
|
||||
const memory = sample?.memory && typeof sample.memory === 'object' ? sample.memory : {};
|
||||
const gpu = sample?.gpu && typeof sample.gpu === 'object' ? sample.gpu : {};
|
||||
|
||||
const cpuPerCore = Array.isArray(cpu?.perCore) ? cpu.perCore : [];
|
||||
const gpuDevices = Array.isArray(gpu?.devices) ? gpu.devices : [];
|
||||
const primaryGpu = gpuDevices[0] || null;
|
||||
|
||||
const cpuTitle = String(cpu?.name || cpu?.model || cpu?.modelName || cpu?.brand || '').trim() || 'CPU';
|
||||
const ramTitle = String(memory?.name || memory?.vendor || memory?.model || '').trim() || 'Arbeitsspeicher';
|
||||
const gpuTitle = String(primaryGpu?.name || gpu?.name || gpu?.vendor || '').trim() || 'GPU';
|
||||
const historyPoints = Array.isArray(historyState.points) ? historyState.points : [];
|
||||
const resampledHistoryPoints = useMemo(
|
||||
() => buildResampledHistoryPoints(historyPoints, historyHours),
|
||||
[historyPoints, historyHours]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!monitoringState.enabled) {
|
||||
setHistoryState({
|
||||
loading: false,
|
||||
points: [],
|
||||
totalPoints: 0,
|
||||
error: null
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const loadHistory = async (forceRefresh = false) => {
|
||||
setHistoryState((prev) => ({
|
||||
...prev,
|
||||
loading: prev.points.length === 0
|
||||
}));
|
||||
try {
|
||||
const response = await api.getHardwareHistory({
|
||||
hours: historyHours,
|
||||
maxPoints: 900,
|
||||
forceRefresh
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const payload = response?.history && typeof response.history === 'object' ? response.history : {};
|
||||
setHistoryState({
|
||||
loading: false,
|
||||
points: Array.isArray(payload.points) ? payload.points : [],
|
||||
totalPoints: Number(payload.totalPoints || 0),
|
||||
error: null
|
||||
});
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setHistoryState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error?.message || 'Historische Hardware-Daten konnten nicht geladen werden.'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
loadHistory(true);
|
||||
const refreshMs = Math.max(5000, Number(monitoringState.intervalMs || 5000) * 2);
|
||||
const timer = setInterval(() => {
|
||||
loadHistory(true);
|
||||
}, refreshMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [historyHours, monitoringState.enabled, monitoringState.intervalMs]);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = null;
|
||||
const handleResize = () => {
|
||||
if (frame) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
setHistoryViewportBucket((prev) => {
|
||||
const next = getHistoryViewportBucket();
|
||||
return prev === next ? prev : next;
|
||||
});
|
||||
});
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
if (frame) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const historyUsageChartData = useMemo(() => ({
|
||||
labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU',
|
||||
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)),
|
||||
borderColor: CPU_SERIES_COLOR,
|
||||
backgroundColor: CPU_SERIES_COLOR,
|
||||
borderWidth: 2,
|
||||
hidden: !usageSeriesVisible.cpu,
|
||||
pointRadius: 0,
|
||||
tension: 0.22
|
||||
},
|
||||
{
|
||||
label: 'RAM',
|
||||
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)),
|
||||
borderColor: RAM_SERIES_COLOR,
|
||||
backgroundColor: RAM_SERIES_COLOR,
|
||||
borderWidth: 2,
|
||||
hidden: !usageSeriesVisible.ram,
|
||||
pointRadius: 0,
|
||||
tension: 0.22
|
||||
},
|
||||
{
|
||||
label: 'GPU',
|
||||
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)),
|
||||
borderColor: GPU_SERIES_COLOR,
|
||||
backgroundColor: GPU_SERIES_COLOR,
|
||||
borderWidth: 2,
|
||||
hidden: !usageSeriesVisible.gpu,
|
||||
pointRadius: 0,
|
||||
tension: 0.22
|
||||
}
|
||||
]
|
||||
}), [resampledHistoryPoints, usageSeriesVisible]);
|
||||
|
||||
const historyTempChartData = useMemo(() => ({
|
||||
labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU',
|
||||
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)),
|
||||
borderColor: CPU_SERIES_COLOR,
|
||||
backgroundColor: CPU_SERIES_COLOR,
|
||||
borderWidth: 2,
|
||||
hidden: !tempSeriesVisible.cpu,
|
||||
pointRadius: 0,
|
||||
tension: 0.22
|
||||
},
|
||||
{
|
||||
label: 'GPU',
|
||||
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)),
|
||||
borderColor: GPU_SERIES_COLOR,
|
||||
backgroundColor: GPU_SERIES_COLOR,
|
||||
borderWidth: 2,
|
||||
hidden: !tempSeriesVisible.gpu,
|
||||
pointRadius: 0,
|
||||
tension: 0.22
|
||||
}
|
||||
]
|
||||
}), [resampledHistoryPoints, tempSeriesVisible]);
|
||||
|
||||
return (
|
||||
<div className="hardware-detail-page">
|
||||
<Card className="hardware-detail-hero">
|
||||
<div className="hardware-detail-hero-head">
|
||||
<div>
|
||||
<h2>Hardware Monitoring</h2>
|
||||
<small>Ausführliche Live-Ansicht für CPU, RAM und GPU</small>
|
||||
</div>
|
||||
<div className="hardware-detail-hero-tags">
|
||||
<Tag value={monitoringState.enabled ? 'Aktiv' : 'Inaktiv'} severity={monitoringState.enabled ? 'success' : 'warning'} />
|
||||
<Tag value={`Intervall: ${monitoringState.intervalMs > 0 ? `${monitoringState.intervalMs} ms` : '-'}`} severity="secondary" />
|
||||
<Tag value={`Letztes Update: ${formatUpdatedAt(monitoringState.updatedAt)}`} severity="info" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!monitoringState.enabled ? (
|
||||
<Card className="hardware-detail-empty">
|
||||
<h3>Monitoring ist deaktiviert</h3>
|
||||
<p>
|
||||
Aktiviere <code>hardware_monitoring_enabled</code> in den Einstellungen,
|
||||
um die Live-Ansicht auf dieser Seite zu nutzen.
|
||||
</p>
|
||||
<Button
|
||||
label="Zu den Einstellungen"
|
||||
icon="pi pi-cog"
|
||||
onClick={() => navigate('/settings')}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{monitoringState.enabled && !sample ? (
|
||||
<Card className="hardware-detail-empty">
|
||||
<h3>Warte auf erste Messung ...</h3>
|
||||
<p>Die Hardware-Metriken werden gerade aufgebaut.</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{monitoringState.enabled ? (
|
||||
<Card className="hardware-detail-card hardware-history-card">
|
||||
<div className="hardware-detail-card-head hardware-history-card-head">
|
||||
<div>
|
||||
<h3>Historie</h3>
|
||||
<small>
|
||||
Verlauf aus Backend-Log ({historyState.totalPoints} Messpunkte, Ansicht: letzte {historyHours}h)
|
||||
</small>
|
||||
</div>
|
||||
<div className="hardware-history-window-buttons">
|
||||
{HISTORY_WINDOWS.map((window) => (
|
||||
<Button
|
||||
key={`history-window-${window.hours}`}
|
||||
label={window.label}
|
||||
size="small"
|
||||
severity={historyHours === window.hours ? 'primary' : 'secondary'}
|
||||
outlined={historyHours !== window.hours}
|
||||
onClick={() => setHistoryHours(window.hours)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{historyState.error ? <small className="error-text">{historyState.error}</small> : null}
|
||||
{historyState.loading && historyPoints.length === 0 ? (
|
||||
<p>Lade Verlauf ...</p>
|
||||
) : resampledHistoryPoints.length === 0 ? (
|
||||
<p>Noch keine Verlaufsdaten vorhanden.</p>
|
||||
) : (
|
||||
<div className="hardware-history-grid">
|
||||
<div className="hardware-history-chart-wrap">
|
||||
<h4>Auslastung (%)</h4>
|
||||
<div className="hardware-history-series-toggles">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
label="CPU"
|
||||
className="hardware-series-toggle-btn"
|
||||
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, usageSeriesVisible.cpu)}
|
||||
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
label="RAM"
|
||||
className="hardware-series-toggle-btn"
|
||||
style={buildSeriesToggleButtonStyle(RAM_SERIES_COLOR, usageSeriesVisible.ram)}
|
||||
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, ram: !prev.ram }))}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
label="GPU"
|
||||
className="hardware-series-toggle-btn"
|
||||
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, usageSeriesVisible.gpu)}
|
||||
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="hardware-history-chart">
|
||||
<Chart
|
||||
key={`usage-${historyViewportBucket}`}
|
||||
className="hardware-history-chart-canvas"
|
||||
type="line"
|
||||
data={historyUsageChartData}
|
||||
options={historyChartOptions}
|
||||
plugins={[historyDayBoundaryPlugin]}
|
||||
/>
|
||||
</div>
|
||||
<ChartLegendRow
|
||||
items={[
|
||||
{ label: 'CPU', color: CPU_SERIES_COLOR },
|
||||
{ label: 'RAM', color: RAM_SERIES_COLOR },
|
||||
{ label: 'GPU', color: GPU_SERIES_COLOR }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="hardware-history-chart-wrap">
|
||||
<h4>Temperatur (°C)</h4>
|
||||
<div className="hardware-history-series-toggles">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
label="CPU °C"
|
||||
className="hardware-series-toggle-btn"
|
||||
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, tempSeriesVisible.cpu)}
|
||||
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
label="GPU °C"
|
||||
className="hardware-series-toggle-btn"
|
||||
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, tempSeriesVisible.gpu)}
|
||||
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="hardware-history-chart">
|
||||
<Chart
|
||||
key={`temp-${historyViewportBucket}`}
|
||||
className="hardware-history-chart-canvas"
|
||||
type="line"
|
||||
data={historyTempChartData}
|
||||
options={historyChartOptions}
|
||||
plugins={[historyDayBoundaryPlugin]}
|
||||
/>
|
||||
</div>
|
||||
<ChartLegendRow
|
||||
items={[
|
||||
{ label: 'CPU', color: CPU_SERIES_COLOR },
|
||||
{ label: 'GPU', color: GPU_SERIES_COLOR }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{monitoringState.enabled && sample ? (
|
||||
<div className="hardware-detail-grid">
|
||||
<GaugeBlock
|
||||
title="CPU"
|
||||
subtitle={cpuTitle}
|
||||
icon="pi-microchip"
|
||||
valuePercent={cpu?.overallUsagePercent}
|
||||
gaugeColor="#b07a24"
|
||||
bars={[
|
||||
{ label: 'Gesamtauslastung', valuePercent: cpu?.overallUsagePercent, valueLabel: formatPercent(cpu?.overallUsagePercent) },
|
||||
{ label: 'Load Avg 1m', valuePercent: clampPercent(Number(cpu?.loadAverage?.[0]) * 100 / 8), valueLabel: Array.isArray(cpu?.loadAverage) ? String(cpu.loadAverage[0] ?? '-') : '-' }
|
||||
]}
|
||||
footer={(
|
||||
<div className="hardware-detail-core-grid">
|
||||
{cpuPerCore.length === 0 ? <small>Keine Core-Metriken verfügbar.</small> : cpuPerCore.map((core, index) => (
|
||||
<div key={`cpu-core-${core?.index ?? index}`} className="hardware-detail-core-item">
|
||||
<div className="hardware-detail-bar-head">
|
||||
<span>Core {core?.index ?? index}</span>
|
||||
<span>{formatPercent(core?.usagePercent)}</span>
|
||||
</div>
|
||||
<ProgressBar value={clampPercent(core?.usagePercent)} showValue={false} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<GaugeBlock
|
||||
title="RAM"
|
||||
subtitle={ramTitle}
|
||||
icon="pi-server"
|
||||
valuePercent={memory?.usagePercent}
|
||||
gaugeColor="#9f6b1d"
|
||||
bars={[
|
||||
{ label: 'Verwendet', valuePercent: memory?.usagePercent, valueLabel: formatPercent(memory?.usagePercent) },
|
||||
{ label: 'Belegt', valuePercent: memory?.usagePercent, valueLabel: formatBytes(memory?.usedBytes) },
|
||||
{ label: 'Frei', valuePercent: Math.max(0, 100 - clampPercent(memory?.usagePercent)), valueLabel: formatBytes(memory?.freeBytes) }
|
||||
]}
|
||||
footer={(
|
||||
<div className="hardware-detail-meter-wrap">
|
||||
<div className="hardware-detail-meter-head">
|
||||
<span>RAM Nutzung</span>
|
||||
<span>{formatBytes(memory?.usedBytes)} / {formatBytes(memory?.totalBytes)}</span>
|
||||
</div>
|
||||
<ProgressBar value={clampPercent(memory?.usagePercent)} showValue={false} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<GaugeBlock
|
||||
title="GPU"
|
||||
subtitle={gpuTitle}
|
||||
icon="pi-desktop"
|
||||
valuePercent={primaryGpu?.utilizationPercent}
|
||||
gaugeColor="#996521"
|
||||
bars={[
|
||||
{ label: '3D / Compute', valuePercent: primaryGpu?.utilizationPercent, valueLabel: formatPercent(primaryGpu?.utilizationPercent) },
|
||||
{ label: 'Speicher', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: formatPercent(primaryGpu?.memoryUtilizationPercent) },
|
||||
{ label: 'VRAM', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: `${formatBytes(primaryGpu?.memoryUsedBytes)} / ${formatBytes(primaryGpu?.memoryTotalBytes)}` }
|
||||
]}
|
||||
footer={(
|
||||
<div className="hardware-detail-meter-wrap">
|
||||
<div className="hardware-detail-meter-head">
|
||||
<span>GPU Nutzung</span>
|
||||
<span>
|
||||
GPU {primaryGpu?.index ?? 0}
|
||||
{primaryGpu?.name ? ` | ${primaryGpu.name}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={clampPercent(primaryGpu?.utilizationPercent)} showValue={false} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { api } from '../api/client';
|
||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||
import { isSeriesVideoJob } from '../utils/jobTaxonomy';
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function resolveMediaType(row) {
|
||||
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
|
||||
const candidates = [
|
||||
row?.mediaType,
|
||||
row?.media_type,
|
||||
row?.mediaProfile,
|
||||
row?.media_profile,
|
||||
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 (raw === 'converter') {
|
||||
return 'converter';
|
||||
}
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function buildMetadataContextFromJob(row) {
|
||||
const jobId = normalizeJobId(row?.id);
|
||||
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
|
||||
? makemkvInfo.analyzeContext
|
||||
: {};
|
||||
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||
? analyzeContext.selectedMetadata
|
||||
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {});
|
||||
const workflowKindRaw = String(
|
||||
selectedMetadata?.workflowKind
|
||||
|| analyzeContext?.workflowKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw)
|
||||
? 'film'
|
||||
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
|
||||
const rowMediaType = resolveMediaType(row);
|
||||
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
|
||||
const metadataProvider = 'tmdb';
|
||||
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
|
||||
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
|
||||
|
||||
return {
|
||||
jobId,
|
||||
status: row?.status || null,
|
||||
lastState: row?.last_state || null,
|
||||
submitMode: 'assign',
|
||||
mediaProfile: rowMediaType,
|
||||
detectedTitle: row?.detected_title || row?.title || '',
|
||||
selectedMetadata: {
|
||||
...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}),
|
||||
title: row?.title || selectedMetadata?.title || row?.detected_title || '',
|
||||
year: row?.year || selectedMetadata?.year || null,
|
||||
imdbId: row?.imdb_id || selectedMetadata?.imdbId || null,
|
||||
poster: row?.poster_url || selectedMetadata?.poster || null,
|
||||
metadataProvider,
|
||||
workflowKind,
|
||||
tmdbId: selectedMetadata?.tmdbId || null,
|
||||
providerId: selectedMetadata?.providerId || null,
|
||||
metadataKind: selectedMetadata?.metadataKind || (isSeriesDvd ? 'season' : null),
|
||||
seasonNumber,
|
||||
seasonName: selectedMetadata?.seasonName || null,
|
||||
episodeCount: selectedMetadata?.episodeCount || 0,
|
||||
episodes: Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : [],
|
||||
...(discNumber ? { discNumber } : {})
|
||||
},
|
||||
metadataProvider,
|
||||
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
|
||||
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
|
||||
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
|
||||
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
|
||||
seriesDecision: analyzeContext?.seriesDecision || null
|
||||
};
|
||||
}
|
||||
|
||||
export default function TmdbMigrationPage() {
|
||||
const toastRef = useRef(null);
|
||||
const countdownIntervalRef = useRef(null);
|
||||
const nextJobTimeoutRef = useRef(null);
|
||||
const jobsRef = useRef([]);
|
||||
const runningRef = useRef(false);
|
||||
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||
const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
|
||||
const [cooldownRemaining, setCooldownRemaining] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
jobsRef.current = Array.isArray(jobs) ? jobs : [];
|
||||
}, [jobs]);
|
||||
|
||||
useEffect(() => {
|
||||
runningRef.current = running;
|
||||
}, [running]);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
countdownIntervalRef.current = null;
|
||||
}
|
||||
if (nextJobTimeoutRef.current) {
|
||||
clearTimeout(nextJobTimeoutRef.current);
|
||||
nextJobTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
clearTimers();
|
||||
}, [clearTimers]);
|
||||
|
||||
const loadPendingJobs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getTmdbMigrationPendingJobs({ limit: 1000 });
|
||||
const rows = Array.isArray(response?.jobs) ? response.jobs : [];
|
||||
setJobs(rows);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Laden fehlgeschlagen',
|
||||
detail: error?.message || 'TMDb-Migrationsliste konnte nicht geladen werden.'
|
||||
});
|
||||
setJobs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPendingJobs();
|
||||
}, [loadPendingJobs]);
|
||||
|
||||
const openJobInDialog = useCallback((job) => {
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
const context = buildMetadataContextFromJob(job);
|
||||
if (!context?.jobId) {
|
||||
return;
|
||||
}
|
||||
setMetadataDialogContext(context);
|
||||
setMetadataDialogVisible(true);
|
||||
}, []);
|
||||
|
||||
const scheduleNextJob = useCallback((nextJob, seconds = 10) => {
|
||||
clearTimers();
|
||||
const normalizedSeconds = Math.max(0, Math.trunc(Number(seconds || 0)));
|
||||
if (!nextJob) {
|
||||
setRunning(false);
|
||||
setCooldownRemaining(0);
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCooldownRemaining(normalizedSeconds);
|
||||
if (normalizedSeconds > 0) {
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
setCooldownRemaining((prev) => {
|
||||
const next = Number(prev || 0) - 1;
|
||||
return next > 0 ? next : 0;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
nextJobTimeoutRef.current = setTimeout(() => {
|
||||
clearTimers();
|
||||
setCooldownRemaining(0);
|
||||
if (!runningRef.current) {
|
||||
return;
|
||||
}
|
||||
openJobInDialog(nextJob);
|
||||
}, normalizedSeconds * 1000);
|
||||
}, [clearTimers, openJobInDialog]);
|
||||
|
||||
const startSequence = () => {
|
||||
const pending = jobsRef.current;
|
||||
if (!Array.isArray(pending) || pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
clearTimers();
|
||||
setRunning(true);
|
||||
setMetadataDialogVisible(true);
|
||||
setCooldownRemaining(0);
|
||||
openJobInDialog(pending[0]);
|
||||
};
|
||||
|
||||
const stopSequence = useCallback(() => {
|
||||
clearTimers();
|
||||
setRunning(false);
|
||||
setCooldownRemaining(0);
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
}, [clearTimers]);
|
||||
|
||||
const handleMetadataSearch = async (query, options = {}) => {
|
||||
const workflow = String(
|
||||
options?.workflowKind
|
||||
|| metadataDialogContext?.selectedMetadata?.workflowKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
try {
|
||||
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
|
||||
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
|
||||
?? null;
|
||||
const response = workflow === 'series'
|
||||
? await api.searchTmdbSeries(query, tmdbSeasonHint)
|
||||
: await api.searchTmdbMovie(query);
|
||||
return response?.results || [];
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'TMDb-Suche fehlgeschlagen',
|
||||
detail: error?.message || 'Suche fehlgeschlagen.'
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetadataSubmit = async (payload) => {
|
||||
const normalizedJobId = normalizeJobId(payload?.jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMetadataAssignBusy(true);
|
||||
clearTimers();
|
||||
setCooldownRemaining(0);
|
||||
|
||||
try {
|
||||
await api.assignJobMetadata(normalizedJobId, payload || {});
|
||||
|
||||
const remaining = jobsRef.current.filter((row) => normalizeJobId(row?.id) !== normalizedJobId);
|
||||
jobsRef.current = remaining;
|
||||
setJobs(remaining);
|
||||
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Job migriert',
|
||||
detail: `Job #${normalizedJobId} wurde auf TMDb aktualisiert.`,
|
||||
life: 2600
|
||||
});
|
||||
|
||||
if (remaining.length === 0) {
|
||||
setRunning(false);
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Migration abgeschlossen',
|
||||
detail: 'Alle ausstehenden Jobs wurden abgearbeitet.',
|
||||
life: 3200
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runningRef.current) {
|
||||
return;
|
||||
}
|
||||
scheduleNextJob(remaining[0], 10);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Migration fehlgeschlagen',
|
||||
detail: error?.message || 'Metadaten konnten nicht übernommen werden.'
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
setMetadataAssignBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const mediumTagBody = (row) => {
|
||||
const mediaType = resolveMediaType(row);
|
||||
const label = mediaType === 'bluray' ? 'Blu-ray' : mediaType === 'dvd' ? 'DVD' : mediaType === 'converter' ? 'Converter' : 'Other';
|
||||
return <Tag value={label} severity={mediaType === 'converter' ? 'info' : 'warning'} />;
|
||||
};
|
||||
|
||||
const titleBody = (row) => String(row?.title || row?.detected_title || `Job #${row?.id || '-'}`).trim() || '-';
|
||||
|
||||
const countdownProgressValue = useMemo(() => {
|
||||
if (cooldownRemaining <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const elapsed = 10 - Math.min(10, cooldownRemaining);
|
||||
return Math.max(0, Math.min(100, Math.round((elapsed / 10) * 100)));
|
||||
}, [cooldownRemaining]);
|
||||
|
||||
return (
|
||||
<div className="page-grid tmdb-migration-page">
|
||||
<Toast ref={toastRef} position="top-right" />
|
||||
|
||||
<Card title="TMDb Migration (Direktlink)" subTitle="Diese Seite ist nicht in der Navigation verlinkt.">
|
||||
<div className="tmdb-migration-toolbar">
|
||||
<div className="tmdb-migration-toolbar-left">
|
||||
<Tag value={`${jobs.length} offen`} severity={jobs.length > 0 ? 'warning' : 'success'} />
|
||||
{running ? <Tag value="Sequenz aktiv" severity="info" /> : null}
|
||||
</div>
|
||||
<div className="tmdb-migration-toolbar-actions">
|
||||
<Button
|
||||
label="Liste neu laden"
|
||||
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
|
||||
outlined
|
||||
onClick={() => {
|
||||
if (!metadataAssignBusy) {
|
||||
void loadPendingJobs();
|
||||
}
|
||||
}}
|
||||
disabled={metadataAssignBusy}
|
||||
/>
|
||||
{!running ? (
|
||||
<Button
|
||||
label="Start"
|
||||
icon="pi pi-play"
|
||||
onClick={startSequence}
|
||||
disabled={jobs.length === 0 || loading || metadataAssignBusy}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
label="Stop"
|
||||
icon="pi pi-stop"
|
||||
severity="secondary"
|
||||
onClick={stopSequence}
|
||||
disabled={metadataAssignBusy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{running && cooldownRemaining > 0 ? (
|
||||
<div className="tmdb-migration-countdown">
|
||||
<ProgressSpinner style={{ width: '2rem', height: '2rem' }} strokeWidth="5" />
|
||||
<div className="tmdb-migration-countdown-copy">
|
||||
<strong>Nächste Suche startet in {cooldownRemaining}s</strong>
|
||||
<small>API-Wartezeit aktiv (mind. 10 Sekunden zwischen Jobs).</small>
|
||||
</div>
|
||||
<div className="tmdb-migration-countdown-progress" aria-label="Cooldown Fortschritt">
|
||||
<div className="tmdb-migration-countdown-bar" style={{ width: `${countdownProgressValue}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DataTable
|
||||
value={jobs}
|
||||
loading={loading}
|
||||
dataKey="id"
|
||||
emptyMessage="Keine offenen Jobs für TMDb-Migration."
|
||||
className="tmdb-migration-table"
|
||||
rows={50}
|
||||
paginator={jobs.length > 50}
|
||||
>
|
||||
<Column field="id" header="ID" style={{ width: '6rem' }} body={(row) => `#${row?.id || '-'}`} />
|
||||
<Column header="Titel" body={titleBody} />
|
||||
<Column field="year" header="Jahr" style={{ width: '8rem' }} body={(row) => row?.year || '-'} />
|
||||
<Column header="Medium" body={mediumTagBody} style={{ width: '10rem' }} />
|
||||
<Column
|
||||
header="Status"
|
||||
style={{ width: '14rem' }}
|
||||
body={(row) => String(row?.status || row?.last_state || '-').trim() || '-'}
|
||||
/>
|
||||
</DataTable>
|
||||
</Card>
|
||||
|
||||
<MetadataSelectionDialog
|
||||
visible={metadataDialogVisible}
|
||||
context={metadataDialogContext}
|
||||
onHide={stopSequence}
|
||||
onSubmit={handleMetadataSubmit}
|
||||
onSearch={handleMetadataSearch}
|
||||
busy={metadataAssignBusy || (running && cooldownRemaining > 0)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user