358 lines
11 KiB
React
358 lines
11 KiB
React
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>
|
|
);
|
|
}
|