0.16.1-1 release snapshot
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 AudiobookOutputExplorer from '../components/AudiobookOutputExplorer';
|
||||
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 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 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
|
||||
: {}
|
||||
},
|
||||
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
|
||||
}) {
|
||||
const toastRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
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 explorerRefreshToken = 0;
|
||||
|
||||
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 = { ...prev };
|
||||
for (const job of audiobookRows) {
|
||||
const jobId = normalizeJobId(job?.id);
|
||||
if (!jobId) {
|
||||
continue;
|
||||
}
|
||||
const status = String(job?.status || '').trim().toUpperCase();
|
||||
if (!['ANALYZING', 'ENCODING'].includes(status)) {
|
||||
delete next[jobId];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('AudiobooksPage load jobs error:', error);
|
||||
} 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) => ({
|
||||
...prev,
|
||||
[jobId]: {
|
||||
progress: payload?.progress ?? null,
|
||||
eta: payload?.eta ?? null,
|
||||
statusText: payload?.statusText ?? null,
|
||||
state: payload?.state ?? null,
|
||||
currentChapter: payload?.contextPatch?.currentChapter ?? null,
|
||||
completedChapterCount: payload?.contextPatch?.completedChapterCount ?? null
|
||||
}
|
||||
}));
|
||||
}
|
||||
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.cancelJob(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 handleRetry = async (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
setActionBusy(normalizedJobId, true);
|
||||
try {
|
||||
const response = await api.retryJob(normalizedJobId);
|
||||
const retryJobId = normalizeJobId(response?.retryJob?.id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Retry erstellt',
|
||||
detail: retryJobId
|
||||
? `Neuer Job #${retryJobId} wurde angelegt.`
|
||||
: `Retry fuer Job #${normalizedJobId} wurde angelegt.`,
|
||||
life: 3200
|
||||
});
|
||||
await loadJobs();
|
||||
if (retryJobId) {
|
||||
setExpandedJobId(retryJobId);
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Retry 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) => {
|
||||
const normalizedJobId = normalizeJobId(uploadedJobId);
|
||||
await loadJobs();
|
||||
if (normalizedJobId) {
|
||||
setExpandedJobId(normalizedJobId);
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
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 showProgress = ['ANALYZING', 'ENCODING'].includes(state) && progressValue > 0;
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
key={jobId}
|
||||
type="button"
|
||||
className="ripper-job-row"
|
||||
onClick={() => setExpandedJobId(jobId)}
|
||||
>
|
||||
<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>#{jobId}</small>
|
||||
</div>
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
|
||||
<Tag value="Audiobook" severity="info" />
|
||||
</div>
|
||||
{showProgress ? (
|
||||
<div className="ripper-job-row-progress">
|
||||
<ProgressBar value={progressValue} showValue={false} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={jobId} className="ripper-job-expanded">
|
||||
<div className="ripper-job-expanded-head">
|
||||
<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>#{jobId} | {title}</span>
|
||||
</strong>
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
|
||||
<Tag value="Audiobook" severity="info" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<Button
|
||||
label="Im Ripper"
|
||||
icon="pi pi-arrow-right"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => navigate('/ripper')}
|
||||
/>
|
||||
<Button
|
||||
label="Einklappen"
|
||||
icon="pi pi-angle-up"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => setExpandedJobId(null)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AudiobookConfigPanel
|
||||
pipeline={pipelineForJob}
|
||||
onStart={(config) => void handleAudiobookStart(jobId, config)}
|
||||
onCancel={() => void handleCancel(jobId)}
|
||||
onRetry={() => void handleRetry(jobId)}
|
||||
onDeleteJob={() => void handleDelete(jobId)}
|
||||
busy={busy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Audiobook Output Explorer"
|
||||
subTitle="Read-only Explorer auf dem Audiobook-Ausgabeordner"
|
||||
>
|
||||
<AudiobookOutputExplorer refreshToken={explorerRefreshToken} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
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 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 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) => (
|
||||
<Button
|
||||
label="Job anlegen"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
onClick={() => handleImportOrphanRaw(row)}
|
||||
loading={orphanImportBusyPath === row.rawPath}
|
||||
disabled={Boolean(orphanImportBusyPath)}
|
||||
/>
|
||||
);
|
||||
|
||||
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)}
|
||||
/>
|
||||
<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={{ width: '10rem' }} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Paginator } from 'primereact/paginator';
|
||||
import { api } from '../api/client';
|
||||
import { classifyJob } from '../utils/jobTaxonomy';
|
||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||
|
||||
const ACTIVE_STATUSES = [
|
||||
'ANALYZING',
|
||||
'METADATA_SELECTION',
|
||||
'WAITING_FOR_USER_DECISION',
|
||||
'READY_TO_START',
|
||||
'MEDIAINFO_CHECK',
|
||||
'READY_TO_ENCODE',
|
||||
'RIPPING',
|
||||
'ENCODING',
|
||||
'CD_METADATA_SELECTION',
|
||||
'CD_READY_TO_RIP',
|
||||
'CD_ANALYZING',
|
||||
'CD_RIPPING',
|
||||
'CD_ENCODING'
|
||||
];
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'active', label: 'Aktiv' },
|
||||
{ key: 'errors', label: 'Fehler/Abbruch' }
|
||||
];
|
||||
|
||||
const VIEW_FILTERS = [
|
||||
{ key: 'all', label: 'Alle Jobs' },
|
||||
{ key: 'ripper', label: 'Ripper-View' },
|
||||
{ key: 'converter', label: 'Converter-View' },
|
||||
{ key: 'audiobook', label: 'Audiobooks' }
|
||||
];
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
return parsed.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function getKindLabel(meta) {
|
||||
if (meta.family === 'converter') {
|
||||
if (meta.converterMediaType === 'audio') {
|
||||
return 'Converter Audio';
|
||||
}
|
||||
if (meta.converterMediaType === 'iso') {
|
||||
return 'Converter ISO';
|
||||
}
|
||||
return 'Converter Video';
|
||||
}
|
||||
if (meta.family === 'audiobook') {
|
||||
return 'Audiobook';
|
||||
}
|
||||
if (meta.mediaType === 'bluray') {
|
||||
return 'Blu-ray';
|
||||
}
|
||||
if (meta.mediaType === 'dvd') {
|
||||
return 'DVD';
|
||||
}
|
||||
if (meta.mediaType === 'cd') {
|
||||
return 'CD';
|
||||
}
|
||||
return 'Sonstiges';
|
||||
}
|
||||
|
||||
function getKindSeverity(meta) {
|
||||
if (meta.family === 'converter') {
|
||||
return 'info';
|
||||
}
|
||||
if (meta.family === 'audiobook') {
|
||||
return 'warning';
|
||||
}
|
||||
if (meta.mediaType === 'bluray' || meta.mediaType === 'dvd') {
|
||||
return 'success';
|
||||
}
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
export default function JobsInboxPage() {
|
||||
const navigate = useNavigate();
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [viewFilter, setViewFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [queuedJobIds, setQueuedJobIds] = useState(new Set());
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
|
||||
const [first, setFirst] = useState(0);
|
||||
const [rows, setRows] = useState(25);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query = { limit: 500, lite: true };
|
||||
if (statusFilter === 'active') {
|
||||
query.statuses = ACTIVE_STATUSES;
|
||||
} else if (statusFilter === 'errors') {
|
||||
query.statuses = ['ERROR', 'CANCELLED'];
|
||||
}
|
||||
|
||||
const [jobsResponse, queueResponse] = await Promise.allSettled([
|
||||
api.getJobs(query),
|
||||
api.getPipelineQueue()
|
||||
]);
|
||||
|
||||
const nextJobs = jobsResponse.status === 'fulfilled' && Array.isArray(jobsResponse.value?.jobs)
|
||||
? jobsResponse.value.jobs
|
||||
: [];
|
||||
setJobs(nextJobs);
|
||||
|
||||
if (queueResponse.status === 'fulfilled') {
|
||||
const rows = Array.isArray(queueResponse.value?.queue?.queuedJobs)
|
||||
? queueResponse.value.queue.queuedJobs
|
||||
: [];
|
||||
setQueuedJobIds(new Set(rows.map((item) => normalizeJobId(item?.jobId)).filter(Boolean)));
|
||||
} else {
|
||||
setQueuedJobIds(new Set());
|
||||
}
|
||||
setLastUpdatedAt(new Date().toISOString());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
const interval = setInterval(() => {
|
||||
loadJobs().catch(() => null);
|
||||
}, 7000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadJobs]);
|
||||
|
||||
const jobsWithMeta = useMemo(() => (
|
||||
jobs.map((job) => ({
|
||||
job,
|
||||
meta: classifyJob(job)
|
||||
}))
|
||||
), [jobs]);
|
||||
|
||||
const viewCounts = useMemo(() => {
|
||||
let all = 0;
|
||||
let ripper = 0;
|
||||
let converter = 0;
|
||||
let audiobook = 0;
|
||||
for (const item of jobsWithMeta) {
|
||||
all += 1;
|
||||
if (item.meta.family === 'converter') {
|
||||
converter += 1;
|
||||
} else {
|
||||
ripper += 1;
|
||||
}
|
||||
if (item.meta.family === 'audiobook') {
|
||||
audiobook += 1;
|
||||
}
|
||||
}
|
||||
return { all, ripper, converter, audiobook };
|
||||
}, [jobsWithMeta]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalizedSearch = String(search || '').trim().toLowerCase();
|
||||
return jobsWithMeta.filter(({ job, meta }) => {
|
||||
if (viewFilter === 'ripper' && meta.family === 'converter') {
|
||||
return false;
|
||||
}
|
||||
if (viewFilter === 'converter' && meta.family !== 'converter') {
|
||||
return false;
|
||||
}
|
||||
if (viewFilter === 'audiobook' && meta.family !== 'audiobook') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
job?.title,
|
||||
job?.detected_title,
|
||||
job?.imdb_id,
|
||||
job?.status,
|
||||
job?.job_kind,
|
||||
job?.media_type
|
||||
]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return haystack.includes(normalizedSearch);
|
||||
});
|
||||
}, [jobsWithMeta, search, viewFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setFirst(0);
|
||||
}, [viewFilter, statusFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredRows.length === 0) {
|
||||
if (first !== 0) {
|
||||
setFirst(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (first >= filteredRows.length) {
|
||||
const pageStart = Math.max(0, Math.floor((filteredRows.length - 1) / rows) * rows);
|
||||
setFirst(pageStart);
|
||||
}
|
||||
}, [filteredRows.length, first, rows]);
|
||||
|
||||
const pagedRows = useMemo(
|
||||
() => filteredRows.slice(first, first + rows),
|
||||
[filteredRows, first, rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Job Inbox"
|
||||
subTitle="Zentrale Jobliste mit denselben Datensätzen für Ripper-, Converter- und Audiobook-Sicht."
|
||||
>
|
||||
<div className="job-inbox-toolbar">
|
||||
<div className="job-inbox-filter-row">
|
||||
{VIEW_FILTERS.map((filter) => {
|
||||
const isActive = filter.key === viewFilter;
|
||||
const count = viewCounts[filter.key] ?? 0;
|
||||
return (
|
||||
<Button
|
||||
key={filter.key}
|
||||
label={`${filter.label} (${count})`}
|
||||
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
|
||||
outlined={!isActive}
|
||||
onClick={() => setViewFilter(filter.key)}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="job-inbox-filter-row">
|
||||
{STATUS_FILTERS.map((filter) => {
|
||||
const isActive = filter.key === statusFilter;
|
||||
return (
|
||||
<Button
|
||||
key={filter.key}
|
||||
label={filter.label}
|
||||
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
|
||||
outlined={!isActive}
|
||||
onClick={() => setStatusFilter(filter.key)}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
label="Aktualisieren"
|
||||
icon="pi pi-refresh"
|
||||
outlined
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
loadJobs().catch(() => null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="job-inbox-search-row">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel, Status oder IMDB-ID"
|
||||
className="job-inbox-search-input"
|
||||
/>
|
||||
<small>
|
||||
Letztes Update: {formatUpdatedAt(lastUpdatedAt)} | Treffer: {filteredRows.length}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="job-inbox-list">
|
||||
{loading && filteredRows.length === 0 ? (
|
||||
<p>Inbox wird geladen ...</p>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<p>Keine Jobs für den aktuellen Filter gefunden.</p>
|
||||
) : (
|
||||
pagedRows.map(({ job, meta }) => {
|
||||
const jobId = normalizeJobId(job?.id);
|
||||
if (!jobId) {
|
||||
return null;
|
||||
}
|
||||
const isQueued = queuedJobIds.has(jobId);
|
||||
const normalizedStatus = normalizeStatus(job?.status);
|
||||
const targetPath = meta.family === 'converter' ? '/converter' : '/ripper';
|
||||
const jobTitle = String(job?.title || job?.detected_title || '').trim() || `Job #${jobId}`;
|
||||
return (
|
||||
<article key={jobId} className="job-inbox-row">
|
||||
<div className="job-inbox-row-main">
|
||||
<strong>#{jobId} | {jobTitle}</strong>
|
||||
<small>
|
||||
Status: {getStatusLabel(normalizedStatus, { queued: isQueued })}
|
||||
{' | '}
|
||||
Aktualisiert: {formatUpdatedAt(job?.updated_at || job?.created_at || null)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="job-inbox-row-tags">
|
||||
<Tag value={getKindLabel(meta)} severity={getKindSeverity(meta)} />
|
||||
<Tag
|
||||
value={getStatusLabel(normalizedStatus, { queued: isQueued })}
|
||||
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
|
||||
/>
|
||||
</div>
|
||||
<div className="job-inbox-row-actions">
|
||||
<Button
|
||||
label={meta.family === 'converter' ? 'Im Converter öffnen' : 'Im Ripper öffnen'}
|
||||
icon={meta.family === 'converter' ? 'pi pi-external-link' : 'pi pi-arrow-right'}
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => navigate(targetPath)}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredRows.length > 0 ? (
|
||||
<div className="job-inbox-pagination">
|
||||
<Paginator
|
||||
first={first}
|
||||
rows={rows}
|
||||
totalRecords={filteredRows.length}
|
||||
rowsPerPageOptions={[25, 50, 100]}
|
||||
onPageChange={(event) => {
|
||||
setFirst(event.first);
|
||||
setRows(event.rows);
|
||||
}}
|
||||
template="PrevPageLink PageLinks NextPageLink RowsPerPageDropdown CurrentPageReport"
|
||||
currentPageReportTemplate="{first} - {last} von {totalRecords}"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user