0.10.0 Audbile Prototype
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, '/')
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user