0.10.0-8 Audbile Meta

This commit is contained in:
2026-03-15 09:22:46 +00:00
parent 25d5339ada
commit 9c0af285ea
19 changed files with 1318 additions and 146 deletions

View File

@@ -84,6 +84,7 @@ function App() {
const [lastDiscEvent, setLastDiscEvent] = useState(null);
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null);
const location = useLocation();
const navigate = useNavigate();
@@ -151,6 +152,7 @@ function App() {
const uploadedJobId = normalizeJobId(response?.result?.jobId);
await refreshPipeline().catch(() => null);
setDashboardJobsRefreshToken((prev) => prev + 1);
setHistoryJobsRefreshToken((prev) => prev + 1);
if (uploadedJobId) {
setPendingDashboardJobId(uploadedJobId);
}
@@ -391,7 +393,7 @@ function App() {
}
/>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="/database" element={<DatabasePage />} />
</Routes>
</main>

View File

@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { Slider } from 'primereact/slider';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
import { InputText } from 'primereact/inputtext';
import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
@@ -49,6 +51,35 @@ function formatChapterTime(secondsValue) {
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function truncateDescription(value, maxLength = 220) {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
if (!normalized || normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength).trim()}...`;
}
function normalizeChapterTitle(value, index) {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
return normalized || `Kapitel ${index}`;
}
function normalizeEditableChapters(chapters = []) {
const source = Array.isArray(chapters) ? chapters : [];
return source.map((chapter, index) => {
const safeIndex = Number(chapter?.index);
const resolvedIndex = Number.isFinite(safeIndex) && safeIndex > 0 ? Math.trunc(safeIndex) : index + 1;
return {
index: resolvedIndex,
title: normalizeChapterTitle(chapter?.title, resolvedIndex),
startSeconds: Number(chapter?.startSeconds || 0),
endSeconds: Number(chapter?.endSeconds || 0),
startMs: Number(chapter?.startMs || 0),
endMs: Number(chapter?.endMs || 0)
};
});
}
function FormatField({ field, value, onChange, disabled }) {
if (field.type === 'slider') {
return (
@@ -111,6 +142,8 @@ export default function AudiobookConfigPanel({
: (Array.isArray(context?.chapters) ? context.chapters : []);
const [format, setFormat] = useState(initialFormat);
const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions));
const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters));
const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false);
useEffect(() => {
const nextFormat = normalizeFormat(audiobookConfig?.format);
@@ -118,6 +151,10 @@ export default function AudiobookConfigPanel({
setFormatOptions(buildFormatOptions(nextFormat, audiobookConfig?.formatOptions));
}, [jobId, audiobookConfig?.format, JSON.stringify(audiobookConfig?.formatOptions || {})]);
useEffect(() => {
setEditableChapters(normalizeEditableChapters(chapters));
}, [jobId, JSON.stringify(chapters || [])]);
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3;
const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED');
const isRunning = state === 'ENCODING';
@@ -125,6 +162,9 @@ export default function AudiobookConfigPanel({
const outputPath = String(context?.outputPath || '').trim() || null;
const statusLabel = getStatusLabel(state);
const statusSeverity = getStatusSeverity(state);
const description = String(metadata?.description || '').trim();
const descriptionPreview = truncateDescription(description);
const posterUrl = String(metadata?.poster || '').trim() || null;
const visibleFields = useMemo(
() => (Array.isArray(schema?.fields) ? schema.fields.filter((field) => isFieldVisible(field, formatOptions)) : []),
@@ -134,19 +174,45 @@ export default function AudiobookConfigPanel({
return (
<div className="audiobook-config-panel">
<div className="audiobook-config-head">
<div className="device-meta">
<div><strong>Titel:</strong> {metadata?.title || '-'}</div>
<div><strong>Autor:</strong> {metadata?.author || '-'}</div>
<div><strong>Sprecher:</strong> {metadata?.narrator || '-'}</div>
<div><strong>Serie:</strong> {metadata?.series || '-'}</div>
<div><strong>Teil:</strong> {metadata?.part || '-'}</div>
<div><strong>Jahr:</strong> {metadata?.year || '-'}</div>
<div><strong>Kapitel:</strong> {chapters.length || '-'}</div>
<div className="audiobook-config-summary">
{posterUrl ? (
<div className="audiobook-config-cover">
<img src={posterUrl} alt={metadata?.title || 'Audiobook Cover'} />
</div>
) : null}
<div className="device-meta">
<div><strong>Titel:</strong> {metadata?.title || '-'}</div>
<div><strong>Autor:</strong> {metadata?.author || '-'}</div>
<div><strong>Sprecher:</strong> {metadata?.narrator || '-'}</div>
<div><strong>Serie:</strong> {metadata?.series || '-'}</div>
<div><strong>Teil:</strong> {metadata?.part || '-'}</div>
<div><strong>Jahr:</strong> {metadata?.year || '-'}</div>
<div><strong>Kapitel:</strong> {editableChapters.length || '-'}</div>
{descriptionPreview ? (
<div className="audiobook-description-preview">
<strong>Beschreibung:</strong>
<span>{descriptionPreview}</span>
{description.length > descriptionPreview.length ? (
<Button
type="button"
label="Vollständig anzeigen"
icon="pi pi-external-link"
text
size="small"
onClick={() => setDescriptionDialogVisible(true)}
/>
) : null}
</div>
) : null}
</div>
</div>
<div className="audiobook-config-tags">
<Tag value={statusLabel} severity={statusSeverity} />
<Tag value={`Format: ${format.toUpperCase()}`} severity="info" />
{metadata?.durationMs ? <Tag value={`Dauer: ${Math.round(Number(metadata.durationMs) / 60000)} min`} severity="secondary" /> : null}
{posterUrl ? <Tag value="Cover erkannt" severity="success" /> : null}
</div>
</div>
@@ -184,23 +250,36 @@ export default function AudiobookConfigPanel({
))}
<small>
Metadaten und Kapitel werden aus der AAX-Datei gelesen. Erst nach Klick auf Start wird `ffmpeg` ausgeführt.
<code>m4b</code> erzeugt eine Datei mit bearbeitbaren Kapiteln. <code>mp3</code> und <code>flac</code> werden kapitelweise als einzelne Dateien erzeugt.
</small>
</div>
<div className="audiobook-config-chapters">
<h4>Kapitelvorschau</h4>
{chapters.length === 0 ? (
<h4>Kapitel</h4>
{editableChapters.length === 0 ? (
<small>Keine Kapitel in der Quelle erkannt.</small>
) : (
<div className="audiobook-chapter-list">
{chapters.map((chapter, index) => (
<div key={`${chapter?.index || index}-${chapter?.title || ''}`} className="audiobook-chapter-row">
<strong>#{chapter?.index || index + 1}</strong>
<span>{chapter?.title || `Kapitel ${index + 1}`}</span>
<small>
{formatChapterTime(chapter?.startSeconds)} - {formatChapterTime(chapter?.endSeconds)}
</small>
{editableChapters.map((chapter, index) => (
<div key={`${chapter.index}-${index}`} className="audiobook-chapter-row audiobook-chapter-row-editable">
<div className="audiobook-chapter-row-head">
<strong>#{chapter.index || index + 1}</strong>
<small>
{formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)}
</small>
</div>
<InputText
value={chapter.title}
onChange={(event) => {
const nextTitle = event.target.value;
setEditableChapters((prev) => prev.map((entry, entryIndex) => (
entryIndex === index
? { ...entry, title: nextTitle }
: entry
)));
}}
disabled={busy || isRunning}
/>
</div>
))}
</div>
@@ -227,7 +306,18 @@ export default function AudiobookConfigPanel({
label={state === 'READY_TO_START' ? 'Encoding starten' : 'Mit diesen Einstellungen starten'}
icon="pi pi-play"
severity="success"
onClick={() => onStart?.({ format, formatOptions })}
onClick={() => onStart?.({
format,
formatOptions,
chapters: editableChapters.map((chapter, index) => ({
index: chapter.index || index + 1,
title: normalizeChapterTitle(chapter.title, chapter.index || index + 1),
startSeconds: chapter.startSeconds,
endSeconds: chapter.endSeconds,
startMs: chapter.startMs,
endMs: chapter.endMs
}))
})}
loading={busy}
disabled={!jobId}
/>
@@ -256,6 +346,17 @@ export default function AudiobookConfigPanel({
/>
) : null}
</div>
<Dialog
header="Beschreibung"
visible={descriptionDialogVisible}
style={{ width: 'min(48rem, 92vw)' }}
onHide={() => setDescriptionDialogVisible(false)}
>
<div className="audiobook-description-dialog">
<p>{description || 'Keine Beschreibung vorhanden.'}</p>
</div>
</Dialog>
</div>
);
}

View File

@@ -174,7 +174,7 @@ function buildToolSections(settings) {
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'audiobook_raw_template'];
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'output_chapter_template_audiobook', 'audiobook_raw_template'];
const LOG_PATH_KEYS = ['log_dir'];
function buildSectionsForCategory(categoryName, settings) {

View File

@@ -250,7 +250,7 @@ function resolveMediaType(job) {
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
return 'cd';
}
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(job?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
return 'audiobook';
}
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
@@ -320,9 +320,12 @@ function resolveCdDetails(job) {
function resolveAudiobookDetails(job) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {});
const selectedMetadata = {
...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {}),
...(encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {})
};
const chapters = Array.isArray(selectedMetadata?.chapters)
? selectedMetadata.chapters
: (Array.isArray(makemkvInfo?.chapters) ? makemkvInfo.chapters : []);
@@ -713,7 +716,7 @@ export default function JobDetailDialog({
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
</div>
<div>
<strong>{isCd ? 'Audio-Dateien vorhanden:' : (isAudiobook ? 'Audiobook-Datei vorhanden:' : 'Movie Datei vorhanden:')}</strong> <BoolState value={job.outputStatus?.exists} />
<strong>{isCd ? 'Audio-Dateien vorhanden:' : (isAudiobook ? (job.outputStatus?.isDirectory ? 'Audiobook-Dateien vorhanden:' : 'Audiobook-Datei vorhanden:') : 'Movie Datei vorhanden:')}</strong> <BoolState value={job.outputStatus?.exists} />
</div>
{isCd ? (
<div>

View File

@@ -403,7 +403,7 @@ function resolveMediaType(job) {
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
return 'cd';
}
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(job?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
return 'audiobook';
}
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
@@ -591,20 +591,26 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
? `${job.raw_path}/track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`
: '<temp>/trackNN.cdda.wav';
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
const audiobookSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {});
const audiobookSelectedMeta = {
...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {}),
...(encodePlan?.metadata && typeof encodePlan.metadata === 'object'
? encodePlan.metadata
: {})
};
const selectedMetadata = resolvedMediaType === 'audiobook'
? {
title: audiobookSelectedMeta?.title || job?.title || job?.detected_title || null,
author: audiobookSelectedMeta?.author || audiobookSelectedMeta?.artist || null,
narrator: audiobookSelectedMeta?.narrator || null,
description: audiobookSelectedMeta?.description || null,
series: audiobookSelectedMeta?.series || null,
part: audiobookSelectedMeta?.part || null,
year: audiobookSelectedMeta?.year ?? job?.year ?? null,
chapters: Array.isArray(audiobookSelectedMeta?.chapters) ? audiobookSelectedMeta.chapters : [],
durationMs: audiobookSelectedMeta?.durationMs || 0,
poster: job?.poster_url || null
poster: audiobookSelectedMeta?.poster || job?.poster_url || null
}
: {
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,

View File

@@ -105,7 +105,7 @@ function resolveMediaType(row) {
if (Array.isArray(row?.makemkvInfo?.tracks) && row.makemkvInfo.tracks.length > 0) {
return 'cd';
}
if (String(row?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(row?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
return 'audiobook';
}
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
@@ -347,7 +347,7 @@ function formatDateTime(value) {
});
}
export default function HistoryPage() {
export default function HistoryPage({ refreshToken = 0 }) {
const location = useLocation();
const navigate = useNavigate();
const [jobs, setJobs] = useState([]);
@@ -437,7 +437,7 @@ export default function HistoryPage() {
}, 300);
return () => clearTimeout(timer);
}, [search, status]);
}, [search, status, refreshToken]);
useEffect(() => {
const params = new URLSearchParams(location.search);

View File

@@ -3538,6 +3538,31 @@ body {
flex-wrap: wrap;
}
.audiobook-config-summary {
display: flex;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.audiobook-config-cover {
width: 120px;
min-width: 120px;
aspect-ratio: 1 / 1;
border-radius: 14px;
overflow: hidden;
border: 1px solid var(--surface-border, #d8d3c6);
background: var(--surface-ground, #f6f1e8);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
}
.audiobook-config-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.audiobook-config-tags {
display: flex;
flex-wrap: wrap;
@@ -3557,6 +3582,17 @@ body {
gap: 0.85rem;
}
.audiobook-description-preview {
display: grid;
gap: 0.25rem;
margin-top: 0.25rem;
}
.audiobook-description-preview .p-button {
justify-self: flex-start;
padding-left: 0;
}
.audiobook-config-chapters h4 {
margin: 0;
}
@@ -3582,6 +3618,23 @@ body {
color: var(--rip-muted, #666);
}
.audiobook-chapter-row-head {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.audiobook-chapter-row-editable {
gap: 0.55rem;
}
.audiobook-description-dialog p {
margin: 0;
white-space: pre-wrap;
line-height: 1.6;
}
.audiobook-output-path {
padding: 0.75rem 0.85rem;
border-radius: 10px;
@@ -3591,6 +3644,15 @@ body {
}
@media (max-width: 980px) {
.audiobook-config-summary {
width: 100%;
}
.audiobook-config-cover {
width: 96px;
min-width: 96px;
}
.audiobook-config-grid {
grid-template-columns: 1fr;
}