0.10.0 Audbile Prototype

This commit is contained in:
2026-03-14 13:35:23 +00:00
parent 5d79a34905
commit e56cff43a9
22 changed files with 1667 additions and 148 deletions

View File

@@ -78,11 +78,13 @@ function afterMutationInvalidate(prefixes = []) {
}
async function request(path, options = {}) {
const isFormDataBody = typeof FormData !== 'undefined' && options?.body instanceof FormData;
const mergedHeaders = {
...(isFormDataBody ? {} : { 'Content-Type': 'application/json' }),
...(options.headers || {})
};
const response = await fetch(`${API_BASE}${path}`, {
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
headers: mergedHeaders,
...options
});
@@ -301,6 +303,24 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async uploadAudiobook(file, payload = {}) {
const formData = new FormData();
if (file) {
formData.append('file', file);
}
if (payload?.format) {
formData.append('format', String(payload.format));
}
if (payload?.startImmediately !== undefined) {
formData.append('startImmediately', String(payload.startImmediately));
}
const result = await request('/pipeline/audiobook/upload', {
method: 'POST',
body: formData
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async selectMetadata(payload) {
const result = await request('/pipeline/select-metadata', {
method: 'POST',

View File

@@ -20,6 +20,8 @@ const GENERAL_TOOL_KEYS = new Set([
'makemkv_min_length_minutes',
'mediainfo_command',
'handbrake_command',
'ffmpeg_command',
'ffprobe_command',
'handbrake_restart_delete_incomplete_output',
'script_test_timeout_ms'
]);
@@ -122,6 +124,12 @@ function buildToolSections(settings) {
description: 'Profil-spezifische Settings für DVD.',
settings: []
};
const audiobookBucket = {
id: 'audiobook',
title: 'Audiobook',
description: 'Profil-spezifische Settings für Audiobooks.',
settings: []
};
const fallbackBucket = {
id: 'other',
title: 'Weitere Tool-Settings',
@@ -143,13 +151,18 @@ function buildToolSections(settings) {
dvdBucket.settings.push(setting);
continue;
}
if (key.endsWith('_audiobook')) {
audiobookBucket.settings.push(setting);
continue;
}
fallbackBucket.settings.push(setting);
}
const sections = [
generalBucket,
blurayBucket,
dvdBucket
dvdBucket,
audiobookBucket
].filter((item) => item.settings.length > 0);
if (fallbackBucket.settings.length > 0) {
sections.push(fallbackBucket);
@@ -161,6 +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 LOG_PATH_KEYS = ['log_dir'];
function buildSectionsForCategory(categoryName, settings) {
@@ -369,11 +383,14 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const bluraySettings = list.filter((s) => BLURAY_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && BLURAY_PATH_KEYS.includes(s.key.replace('_owner', ''))));
const dvdSettings = list.filter((s) => DVD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DVD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
const cdSettings = list.filter((s) => CD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && CD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
const audiobookSettings = list.filter((s) => AUDIOBOOK_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && AUDIOBOOK_PATH_KEYS.includes(s.key.replace('_owner', ''))));
const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
const defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd';
const defaultAudiobookRaw = effectivePaths?.defaults?.audiobookRaw || 'data/output/audiobook-raw';
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
const ep = effectivePaths || {};
const blurayRaw = ep.bluray?.raw || defaultRaw;
@@ -382,6 +399,8 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const dvdMovies = ep.dvd?.movies || defaultMovies;
const cdRaw = ep.cd?.raw || defaultCd;
const cdMovies = ep.cd?.movies || cdRaw;
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
const audiobookMovies = ep.audiobook?.movies || defaultAudiobookMovies;
const isDefault = (path, def) => path === def;
@@ -435,6 +454,17 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
{isDefault(cdMovies, cdRaw) && <span className="path-default-badge">Standard</span>}
</td>
</tr>
<tr>
<td><strong>Audiobook</strong></td>
<td>
<code>{audiobookRaw}</code>
{isDefault(audiobookRaw, defaultAudiobookRaw) && <span className="path-default-badge">Standard</span>}
</td>
<td>
<code>{audiobookMovies}</code>
{isDefault(audiobookMovies, defaultAudiobookMovies) && <span className="path-default-badge">Standard</span>}
</td>
</tr>
</tbody>
</table>
</div>
@@ -468,6 +498,15 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
dirtyKeys={dirtyKeys}
onChange={onChange}
/>
<PathMediumCard
title="Audiobook"
pathSettings={audiobookSettings}
settingsByKey={settingsByKey}
values={values}
errors={errors}
dirtyKeys={dirtyKeys}
onChange={onChange}
/>
</div>
{/* Log-Ordner */}

View File

@@ -231,6 +231,9 @@ function resolveMediaType(job) {
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
return 'cd';
}
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
}
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
if (statusCandidates.some((v) => String(v || '').trim().toUpperCase().startsWith('CD_'))) {
@@ -247,6 +250,12 @@ 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') {
return 'audiobook';
}
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
return 'audiobook';
}
return 'other';
}
@@ -308,6 +317,26 @@ 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 chapters = Array.isArray(selectedMetadata?.chapters)
? selectedMetadata.chapters
: (Array.isArray(makemkvInfo?.chapters) ? makemkvInfo.chapters : []);
const format = String(job?.handbrakeInfo?.format || encodePlan?.format || '').trim().toLowerCase() || null;
return {
author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null,
narrator: String(selectedMetadata?.narrator || '').trim() || null,
series: String(selectedMetadata?.series || '').trim() || null,
part: String(selectedMetadata?.part || '').trim() || null,
chapterCount: chapters.length,
formatLabel: format ? format.toUpperCase() : null
};
}
function statusBadgeMeta(status, queued = false) {
const normalized = String(status || '').trim().toUpperCase();
const label = getStatusLabel(normalized, { queued });
@@ -404,6 +433,9 @@ export default function JobDetailDialog({
&& !running
&& typeof onResumeReady === 'function'
);
const mediaType = resolveMediaType(job);
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const hasConfirmedPlan = Boolean(
job?.encodePlan
&& Array.isArray(job?.encodePlan?.titles)
@@ -416,6 +448,7 @@ export default function JobDetailDialog({
job?.rawStatus?.exists
&& job?.rawStatus?.isEmpty !== true
&& !running
&& mediaType !== 'audiobook'
&& typeof onRestartReview === 'function'
);
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
@@ -424,9 +457,8 @@ export default function JobDetailDialog({
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
const logTruncated = Boolean(logMeta?.truncated);
const mediaType = resolveMediaType(job);
const isCd = mediaType === 'cd';
const cdDetails = isCd ? resolveCdDetails(job) : null;
const audiobookDetails = isAudiobook ? resolveAudiobookDetails(job) : null;
const canRetry = isCd && !running && typeof onRetry === 'function';
const mediaTypeLabel = mediaType === 'bluray'
? 'Blu-ray'
@@ -434,7 +466,7 @@ export default function JobDetailDialog({
? 'DVD'
: isCd
? 'Audio CD'
: 'Sonstiges Medium';
: (isAudiobook ? 'Audiobook' : 'Sonstiges Medium');
const mediaTypeIcon = mediaType === 'bluray'
? blurayIndicatorIcon
: mediaType === 'dvd'
@@ -535,7 +567,7 @@ export default function JobDetailDialog({
) : (
<>
<section className="job-meta-block job-meta-block-film">
<h4>Film-Infos</h4>
<h4>{isAudiobook ? 'Audiobook-Infos' : 'Film-Infos'}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Titel:</strong>
@@ -545,14 +577,45 @@ export default function JobDetailDialog({
<strong>Jahr:</strong>
<span>{job.year || '-'}</span>
</div>
<div className="job-meta-item">
<strong>IMDb:</strong>
<span>{job.imdb_id || '-'}</span>
</div>
<div className="job-meta-item">
<strong>OMDb Match:</strong>
<BoolState value={job.selected_from_omdb} />
</div>
{isAudiobook ? (
<>
<div className="job-meta-item">
<strong>Autor:</strong>
<span>{audiobookDetails?.author || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Sprecher:</strong>
<span>{audiobookDetails?.narrator || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Serie:</strong>
<span>{audiobookDetails?.series || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Teil:</strong>
<span>{audiobookDetails?.part || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Kapitel:</strong>
<span>{audiobookDetails?.chapterCount || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Format:</strong>
<span>{audiobookDetails?.formatLabel || '-'}</span>
</div>
</>
) : (
<>
<div className="job-meta-item">
<strong>IMDb:</strong>
<span>{job.imdb_id || '-'}</span>
</div>
<div className="job-meta-item">
<strong>OMDb Match:</strong>
<BoolState value={job.selected_from_omdb} />
</div>
</>
)}
<div className="job-meta-item">
<strong>Medium:</strong>
<span className="job-step-cell">
@@ -563,35 +626,37 @@ export default function JobDetailDialog({
</div>
</section>
<section className="job-meta-block job-meta-block-film">
<h4>OMDb Details</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Regisseur:</strong>
<span>{omdbField(omdbInfo?.Director)}</span>
{!isAudiobook ? (
<section className="job-meta-block job-meta-block-film">
<h4>OMDb Details</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Regisseur:</strong>
<span>{omdbField(omdbInfo?.Director)}</span>
</div>
<div className="job-meta-item">
<strong>Schauspieler:</strong>
<span>{omdbField(omdbInfo?.Actors)}</span>
</div>
<div className="job-meta-item">
<strong>Laufzeit:</strong>
<span>{omdbField(omdbInfo?.Runtime)}</span>
</div>
<div className="job-meta-item">
<strong>Genre:</strong>
<span>{omdbField(omdbInfo?.Genre)}</span>
</div>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(omdbInfo?.imdbRating)}</span>
</div>
</div>
<div className="job-meta-item">
<strong>Schauspieler:</strong>
<span>{omdbField(omdbInfo?.Actors)}</span>
</div>
<div className="job-meta-item">
<strong>Laufzeit:</strong>
<span>{omdbField(omdbInfo?.Runtime)}</span>
</div>
<div className="job-meta-item">
<strong>Genre:</strong>
<span>{omdbField(omdbInfo?.Genre)}</span>
</div>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(omdbInfo?.imdbRating)}</span>
</div>
</div>
</section>
</section>
) : null}
</>
)}
</div>
@@ -631,7 +696,7 @@ export default function JobDetailDialog({
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
</div>
<div>
<strong>{isCd ? 'Audio-Dateien vorhanden:' : 'Movie Datei vorhanden:'}</strong> <BoolState value={job.outputStatus?.exists} />
<strong>{isCd ? 'Audio-Dateien vorhanden:' : (isAudiobook ? 'Audiobook-Datei vorhanden:' : 'Movie Datei vorhanden:')}</strong> <BoolState value={job.outputStatus?.exists} />
</div>
{isCd ? (
<div>
@@ -640,7 +705,7 @@ export default function JobDetailDialog({
) : (
<>
<div>
<strong>Backup erfolgreich:</strong> <BoolState value={job?.backupSuccess} />
<strong>{isAudiobook ? 'Import erfolgreich:' : 'Backup erfolgreich:'}</strong> <BoolState value={job?.backupSuccess} />
</div>
<div>
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
@@ -653,7 +718,7 @@ export default function JobDetailDialog({
</div>
</section>
{!isCd && (hasConfiguredSelection || encodePlanUserPreset) ? (
{!isCd && !isAudiobook && (hasConfiguredSelection || encodePlanUserPreset) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Hinterlegte Encode-Auswahl</h4>
<div className="job-configured-selection-grid">
@@ -683,13 +748,13 @@ export default function JobDetailDialog({
<section className="job-meta-block job-meta-block-full">
<h4>Ausgeführter Encode-Befehl</h4>
<div className="handbrake-command-preview">
<small><strong>HandBrakeCLI (tatsächlich gestartet):</strong></small>
<small><strong>{isAudiobook ? 'FFmpeg' : 'HandBrakeCLI'} (tatsächlich gestartet):</strong></small>
<pre>{executedHandBrakeCommand}</pre>
</div>
</section>
) : null}
{!isCd && (job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
{!isCd && !isAudiobook && (job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Skripte</h4>
<div className="script-results-grid">
@@ -700,14 +765,14 @@ export default function JobDetailDialog({
) : null}
<div className="job-json-grid">
{!isCd ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : 'MakeMKV Info'} value={job.makemkvInfo} />
{!isCd ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
{!isCd && !isAudiobook ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : 'MakeMKV Info')} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : 'Encode Plan'} value={job.encodePlan} />
{!isCd ? <JsonView title="HandBrake Info" value={job.handbrakeInfo} /> : null}
{!isCd ? <JsonView title={isAudiobook ? 'FFmpeg Info' : 'HandBrake Info'} value={job.handbrakeInfo} /> : null}
</div>
{!isCd && job.encodePlan ? (
{!isCd && !isAudiobook && job.encodePlan ? (
<>
<h4>Mediainfo-Prüfung (Auswertung)</h4>
<MediaInfoReviewPanel
@@ -740,7 +805,7 @@ export default function JobDetailDialog({
/>
) : (
<>
{!isCd ? (
{!isCd && !isAudiobook ? (
<Button
label="OMDb neu zuordnen"
icon="pi pi-search"

View File

@@ -109,6 +109,9 @@ function normalizeMediaProfile(value) {
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
return 'dvd';
}
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
return 'other';
}
@@ -234,8 +237,8 @@ function sanitizeFileName(input) {
}
function renderTemplate(template, values) {
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
const value = values[key.trim()];
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
const value = values[(keyA || keyB || '').trim()];
if (value === undefined || value === null || value === '') {
return 'unknown';
}
@@ -248,7 +251,11 @@ function resolveProfiledSetting(settings, key, mediaProfile) {
if (profileKey && settings?.[profileKey] != null && settings[profileKey] !== '') {
return settings[profileKey];
}
const fallbackProfiles = mediaProfile === 'bluray' ? ['dvd'] : ['bluray'];
const fallbackProfiles = mediaProfile === 'bluray'
? ['dvd']
: mediaProfile === 'dvd'
? ['bluray']
: [];
for (const fb of fallbackProfiles) {
const fbKey = `${key}_${fb}`;
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
@@ -265,12 +272,14 @@ function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId
}
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
const author = metadata?.author || metadata?.artist || 'unknown';
const narrator = metadata?.narrator || 'unknown';
const year = metadata?.year || new Date().getFullYear();
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
const DEFAULT_TEMPLATE = '${title} (${year})/${title} (${year})';
const rawTemplate = resolveProfiledSetting(settings, 'output_template', mediaProfile);
const template = String(rawTemplate || DEFAULT_TEMPLATE).trim() || DEFAULT_TEMPLATE;
const rendered = renderTemplate(template, { title, year, imdbId });
const rendered = renderTemplate(template, { title, year, imdbId, author, narrator });
const segments = rendered
.replace(/\\/g, '/')
.replace(/\/+/g, '/')

View File

@@ -377,6 +377,9 @@ function resolveMediaType(job) {
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
return 'cd';
}
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
}
const statusCandidates = [
job?.status,
@@ -397,6 +400,12 @@ 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') {
return 'audiobook';
}
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
return 'audiobook';
}
return 'other';
}
@@ -411,6 +420,9 @@ function mediaIndicatorMeta(job) {
if (mediaType === 'cd') {
return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
}
if (mediaType === 'audiobook') {
return { mediaType, src: otherIndicatorIcon, alt: 'Audiobook', title: 'Audiobook' };
}
return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' };
}
@@ -448,6 +460,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
const resolvedMediaType = resolveMediaType(job);
const analyzeContext = getAnalyzeContext(job);
const normalizePlanIdList = (values) => {
const list = Array.isArray(values) ? values : [];
@@ -575,15 +588,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 selectedMetadata = {
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
year: cdSelectedMeta?.year ?? job?.year ?? null,
mbId: resolvedCdMbId,
coverUrl: resolvedCdCoverUrl,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || resolvedCdCoverUrl || null
};
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,
year: audiobookSelectedMeta?.year ?? job?.year ?? null,
poster: job?.poster_url || null
}
: {
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
year: cdSelectedMeta?.year ?? job?.year ?? null,
mbId: resolvedCdMbId,
coverUrl: resolvedCdCoverUrl,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || resolvedCdCoverUrl || null
};
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
const inputPath = isPreRip
@@ -623,13 +647,14 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const canRestartReviewFromRaw = Boolean(
job?.raw_path
&& !processingStates.includes(jobStatus)
&& resolvedMediaType !== 'audiobook'
);
const computedContext = {
jobId,
rawPath: job?.raw_path || null,
outputPath: job?.output_path || null,
detectedTitle: job?.detected_title || null,
mediaProfile: resolveMediaType(job),
mediaProfile: resolvedMediaType,
lastState,
devicePath,
cdparanoiaCmd,
@@ -751,6 +776,8 @@ export default function DashboardPage({
const [jobsLoading, setJobsLoading] = useState(false);
const [dashboardJobs, setDashboardJobs] = useState([]);
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [audiobookUploadFile, setAudiobookUploadFile] = useState(null);
const [audiobookUploadBusy, setAudiobookUploadBusy] = useState(false);
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
@@ -1245,6 +1272,8 @@ export default function DashboardPage({
}
const startOptions = options && typeof options === 'object' ? options : {};
const startJobRow = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const mediaType = resolveMediaType(startJobRow);
setJobBusy(normalizedJobId, true);
try {
if (startOptions.ensureConfirmed) {
@@ -1270,7 +1299,9 @@ export default function DashboardPage({
}
await api.confirmEncodeReview(normalizedJobId, confirmPayload);
}
const response = await api.startJob(normalizedJobId);
const response = mediaType === 'audiobook'
? await api.startJob(normalizedJobId)
: await api.startJob(normalizedJobId);
const result = getQueueActionResult(response);
await refreshPipeline();
await loadDashboardJobs();
@@ -1286,6 +1317,39 @@ export default function DashboardPage({
}
};
const handleAudiobookUpload = async () => {
if (!audiobookUploadFile) {
showError(new Error('Bitte zuerst eine AAX-Datei auswählen.'));
return;
}
setAudiobookUploadBusy(true);
try {
const response = await api.uploadAudiobook(audiobookUploadFile, { startImmediately: true });
const result = getQueueActionResult(response);
const uploadedJobId = normalizeJobId(response?.result?.jobId);
await refreshPipeline();
await loadDashboardJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Audiobook', result);
} else {
toastRef.current?.show({
severity: 'success',
summary: 'Audiobook importiert',
detail: uploadedJobId ? `Job #${uploadedJobId} wurde angelegt.` : 'Audiobook wurde importiert.',
life: 3200
});
}
if (uploadedJobId) {
setExpandedJobId(uploadedJobId);
}
setAudiobookUploadFile(null);
} catch (error) {
showError(error);
} finally {
setAudiobookUploadBusy(false);
}
};
const handleConfirmReview = async (
jobId,
selectedEncodeTitleId = null,
@@ -1944,6 +2008,35 @@ export default function DashboardPage({
)}
</Card>
<Card title="Audiobook Upload" subTitle="AAX-Datei hochladen, ins RAW-Verzeichnis übernehmen und direkt mit dem in den Settings gewählten Zielformat starten.">
<div className="actions-row">
<input
key={audiobookUploadFile ? `${audiobookUploadFile.name}-${audiobookUploadFile.size}` : 'audiobook-upload-input'}
type="file"
accept=".aax"
onChange={(event) => {
const nextFile = event.target?.files?.[0] || null;
setAudiobookUploadFile(nextFile);
}}
disabled={audiobookUploadBusy}
/>
<Button
label="Audiobook hochladen"
icon="pi pi-upload"
onClick={() => {
void handleAudiobookUpload();
}}
loading={audiobookUploadBusy}
disabled={!audiobookUploadFile}
/>
</div>
<small>
{audiobookUploadFile
? `Ausgewählt: ${audiobookUploadFile.name}`
: 'Unterstützt im MVP: AAX-Upload. Das Ausgabeformat wird aus den Audiobook-Settings gelesen.'}
</small>
</Card>
<Card title="Job Queue" subTitle="Starts werden nach Typ- und Gesamtlimit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
<div className="pipeline-queue-meta">
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />

View File

@@ -46,6 +46,9 @@ function resolveMediaType(row) {
if (['cd', 'audio_cd'].includes(raw)) {
return 'cd';
}
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
}
return 'other';
}
@@ -698,13 +701,13 @@ export default function DatabasePage() {
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
const alt = mediaType === 'bluray'
? 'Blu-ray'
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium'));
const title = mediaType === 'bluray'
? 'Blu-ray'
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium'));
const label = mediaType === 'bluray'
? 'Blu-ray'
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges');
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges'));
return (
<span className="job-step-cell">
<img src={src} alt={alt} title={title} className="media-indicator-icon" />
@@ -781,7 +784,7 @@ export default function DatabasePage() {
<Card
title="RAW ohne Historie"
subTitle="Ordner in den konfigurierten RAW-Pfaden (raw_dir sowie raw_dir_{bluray,dvd,other}) ohne zugehörigen Job können hier importiert werden"
subTitle="Ordner in den konfigurierten RAW-Pfaden (raw_dir sowie raw_dir_{bluray,dvd,cd,audiobook,other}) ohne zugehörigen Job können hier importiert werden"
>
<div className="table-filters">
<Button

View File

@@ -24,6 +24,7 @@ const MEDIA_FILTER_OPTIONS = [
{ label: 'Blu-ray', value: 'bluray' },
{ label: 'DVD', value: 'dvd' },
{ label: 'Audio CD', value: 'cd' },
{ label: 'Audiobook', value: 'audiobook' },
{ label: 'Sonstiges', value: 'other' }
];
@@ -80,6 +81,9 @@ function resolveMediaType(row) {
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
return 'cd';
}
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
}
const statusCandidates = [
row?.status,
@@ -100,6 +104,12 @@ 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') {
return 'audiobook';
}
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
return 'audiobook';
}
return 'other';
}
@@ -129,6 +139,14 @@ function resolveMediaTypeMeta(row) {
alt: 'Audio CD'
};
}
if (mediaType === 'audiobook') {
return {
mediaType,
icon: otherIndicatorIcon,
label: 'Audiobook',
alt: 'Audiobook'
};
}
return {
mediaType,
icon: otherIndicatorIcon,
@@ -216,12 +234,47 @@ function resolveCdDetails(row) {
};
}
function resolveAudiobookDetails(row) {
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
const selectedMetadata = row?.makemkvInfo?.selectedMetadata && typeof row.makemkvInfo.selectedMetadata === 'object'
? row.makemkvInfo.selectedMetadata
: (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {});
const chapters = Array.isArray(selectedMetadata?.chapters)
? selectedMetadata.chapters
: (Array.isArray(row?.makemkvInfo?.chapters) ? row.makemkvInfo.chapters : []);
const format = String(
row?.handbrakeInfo?.format
|| encodePlan?.format
|| ''
).trim().toLowerCase() || null;
return {
author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null,
narrator: String(selectedMetadata?.narrator || '').trim() || null,
chapterCount: chapters.length,
formatLabel: format ? format.toUpperCase() : null
};
}
function getOutputLabelForRow(row) {
return resolveMediaType(row) === 'cd' ? 'Audio-Dateien' : 'Movie-Datei(en)';
const mediaType = resolveMediaType(row);
if (mediaType === 'cd') {
return 'Audio-Dateien';
}
if (mediaType === 'audiobook') {
return 'Audiobook-Datei(en)';
}
return 'Movie-Datei(en)';
}
function getOutputShortLabelForRow(row) {
return resolveMediaType(row) === 'cd' ? 'Audio' : 'Movie';
const mediaType = resolveMediaType(row);
if (mediaType === 'cd') {
return 'Audio';
}
if (mediaType === 'audiobook') {
return 'Audiobook';
}
return 'Movie';
}
function normalizeJobId(value) {
@@ -760,7 +813,7 @@ export default function HistoryPage() {
if (row?.poster_url && row.poster_url !== 'N/A') {
return <img src={row.poster_url} alt={title} className={className} loading="lazy" />;
}
return <div className="history-dv-poster-fallback">{mediaMeta.mediaType === 'cd' ? 'Kein Cover' : 'Kein Poster'}</div>;
return <div className="history-dv-poster-fallback">{['cd', 'audiobook'].includes(mediaMeta.mediaType) ? 'Kein Cover' : 'Kein Poster'}</div>;
};
const renderPresenceChip = (label, available) => (
@@ -803,6 +856,32 @@ export default function HistoryPage() {
));
}
if (resolveMediaType(row) === 'audiobook') {
const audiobookDetails = resolveAudiobookDetails(row);
const infoItems = [];
if (audiobookDetails.author) {
infoItems.push({ key: 'author', label: 'Autor', value: audiobookDetails.author });
}
if (audiobookDetails.narrator) {
infoItems.push({ key: 'narrator', label: 'Sprecher', value: audiobookDetails.narrator });
}
if (audiobookDetails.chapterCount > 0) {
infoItems.push({ key: 'chapters', label: 'Kapitel', value: String(audiobookDetails.chapterCount) });
}
if (audiobookDetails.formatLabel) {
infoItems.push({ key: 'format', label: 'Format', value: audiobookDetails.formatLabel });
}
if (infoItems.length === 0) {
return <span className="history-dv-subtle">Keine Audiobook-Details</span>;
}
return infoItems.map((item) => (
<span key={`${row?.id}-${item.key}`} className="history-dv-rating-chip">
<strong>{item.label}</strong>
<span>{item.value}</span>
</span>
));
}
const ratings = resolveRatings(row);
if (ratings.length === 0) {
return <span className="history-dv-subtle">Keine Ratings</span>;