Files
ripster/frontend/src/pages/AudiobooksPage.jsx
T
2026-06-12 11:17:44 +02:00

572 lines
22 KiB
React

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>
);
}