0.13.0 DVD Series

This commit is contained in:
2026-04-10 18:44:41 +00:00
parent 43702b0138
commit 974b7bfad3
36 changed files with 9666 additions and 739 deletions
+665 -52
View File
@@ -6,6 +6,7 @@ import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation';
import { isSeriesDvdJob } from '../utils/jobTaxonomy';
const CD_FORMAT_LABELS = {
flac: 'FLAC',
@@ -190,6 +191,98 @@ function buildExecutedHandBrakeCommand(handbrakeInfo) {
return `${cmd} ${args.map((arg) => shellQuote(arg)).join(' ')}`.trim();
}
function normalizeSeriesEpisodeStatus(status) {
const normalized = String(status || '').trim().toUpperCase();
if (['QUEUED', 'RUNNING', 'FINISHED', 'ERROR', 'CANCELLED'].includes(normalized)) {
return normalized;
}
return 'QUEUED';
}
function seriesEpisodeStatusMeta(status) {
const normalized = normalizeSeriesEpisodeStatus(status);
if (normalized === 'FINISHED') {
return { label: 'Fertig', severity: 'success' };
}
if (normalized === 'RUNNING') {
return { label: 'Läuft', severity: 'info' };
}
if (normalized === 'ERROR') {
return { label: 'Fehler', severity: 'danger' };
}
if (normalized === 'CANCELLED') {
return { label: 'Abgebrochen', severity: 'warning' };
}
return { label: 'Wartend', severity: 'secondary' };
}
function formatDateTimeOrDash(value) {
const text = String(value || '').trim();
if (!text) {
return '-';
}
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) {
return text;
}
return parsed.toLocaleString();
}
function mergeSeriesBatchEpisodes(job) {
const planEpisodes = Array.isArray(job?.encodePlan?.seriesBatchEpisodes)
? job.encodePlan.seriesBatchEpisodes
: [];
const hbEpisodes = Array.isArray(job?.handbrakeInfo?.seriesBatch?.episodes)
? job.handbrakeInfo.seriesBatch.episodes
: [];
const map = new Map();
const append = (list, sourcePriority) => {
for (const item of list) {
if (!item || typeof item !== 'object') {
continue;
}
const episodeIndex = normalizePositiveInteger(item?.episodeIndex);
const titleId = normalizePositiveInteger(item?.titleId);
const key = `${episodeIndex || 0}:${titleId || 0}`;
const existing = map.get(key);
if (!existing) {
map.set(key, {
...item,
episodeIndex,
titleId,
sourcePriority
});
continue;
}
if (sourcePriority >= Number(existing.sourcePriority || 0)) {
map.set(key, {
...existing,
...item,
episodeIndex: episodeIndex || existing.episodeIndex || null,
titleId: titleId || existing.titleId || null,
sourcePriority
});
}
}
};
append(planEpisodes, 1);
append(hbEpisodes, 2);
return Array.from(map.values())
.map((entry, index) => ({
...entry,
episodeIndex: normalizePositiveInteger(entry?.episodeIndex) || (index + 1),
titleId: normalizePositiveInteger(entry?.titleId) || null,
progress: Number.isFinite(Number(entry?.progress))
? Math.max(0, Math.min(100, Number(entry.progress)))
: 0,
status: normalizeSeriesEpisodeStatus(entry?.status)
}))
.sort((left, right) => left.episodeIndex - right.episodeIndex);
}
function buildConfiguredScriptAndChainSelection(job) {
const plan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' ? job.handbrakeInfo : {};
@@ -505,6 +598,147 @@ function omdbRottenTomatoesScore(omdbInfo) {
return omdbField(entry?.Value);
}
function normalizeMetadataProvider(value) {
return String(value || '').trim().toLowerCase() || 'omdb';
}
function resolveJobMetadataContext(job) {
const mkInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object'
? job.makemkvInfo
: {};
const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object'
? mkInfo.analyzeContext
: {};
const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: {};
const selectedMetadata = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object'
? mkInfo.selectedMetadata
: {};
const encodeMetadata = job?.encodePlan?.metadata && typeof job.encodePlan.metadata === 'object'
? job.encodePlan.metadata
: {};
const mergedSelectedMetadata = {
...analyzeSelectedMetadata,
...selectedMetadata,
...encodeMetadata
};
const metadataProvider = normalizeMetadataProvider(
mergedSelectedMetadata?.metadataProvider
|| analyzeContext?.metadataProvider
|| 'omdb'
);
return {
analyzeContext,
selectedMetadata: mergedSelectedMetadata,
metadataProvider
};
}
function uniqueNameList(values, maxItems = 10) {
const source = Array.isArray(values) ? values : [];
const limit = Math.max(1, Number(maxItems || 10));
const output = [];
const seen = new Set();
for (const item of source) {
const name = String(item?.name || item || '').trim();
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(name);
if (output.length >= limit) {
break;
}
}
return output;
}
function formatRuntimeLabel(value) {
if (Array.isArray(value)) {
const values = value
.map((entry) => Number(entry))
.filter((entry) => Number.isFinite(entry) && entry > 0)
.map((entry) => Math.trunc(entry));
if (values.length === 0) {
return null;
}
const min = Math.min(...values);
const max = Math.max(...values);
return min === max ? `${min} min` : `${min}-${max} min`;
}
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return `${Math.trunc(numeric)} min`;
}
const text = String(value || '').trim();
return text || null;
}
function resolveMetadataDetailsForDisplay(job, omdbInfo = {}) {
const metadataContext = resolveJobMetadataContext(job);
const provider = metadataContext.metadataProvider;
const selectedMetadata = metadataContext.selectedMetadata;
const tmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
? selectedMetadata.tmdbDetails
: {};
const isTmdbProvider = provider === 'tmdb' || provider === 'themoviedb';
if (isTmdbProvider) {
const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null;
const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null;
const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null;
const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime);
const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage);
const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0
? ratingNumber.toFixed(1)
: null;
const imdbId = String(selectedMetadata?.imdbId || tmdbDetails?.imdbId || '').trim() || null;
const hasMatch = Boolean(selectedMetadata?.tmdbId || tmdbDetails?.tmdbId);
return {
provider: 'tmdb',
title: 'TMDb Details',
matchLabel: 'TMDb Match',
hasMatch,
imdbId,
director: director || '-',
actors: actors || '-',
runtime: runtime || '-',
genre: genre || '-',
tmdbRating: tmdbRating || '-'
};
}
return {
provider: 'omdb',
title: 'OMDb Details',
matchLabel: 'OMDb Match',
hasMatch: Boolean(job?.selected_from_omdb),
imdbId: String(job?.imdb_id || '').trim() || null,
director: omdbField(omdbInfo?.Director),
actors: omdbField(omdbInfo?.Actors),
runtime: omdbField(omdbInfo?.Runtime),
genre: omdbField(omdbInfo?.Genre),
rottenTomatoes: omdbRottenTomatoesScore(omdbInfo),
imdbRating: omdbField(omdbInfo?.imdbRating)
};
}
function resolveSeriesDiscNumber(job) {
const metadataContext = resolveJobMetadataContext(job);
return normalizePositiveInteger(
metadataContext?.selectedMetadata?.discNumber
?? metadataContext?.analyzeContext?.seriesLookupHint?.discNumber
?? job?.encodePlan?.discNumber
?? null
);
}
function BoolState({ value }) {
const isTrue = Boolean(value);
return isTrue ? (
@@ -518,6 +752,33 @@ function BoolState({ value }) {
);
}
function TriState({ existing = 0, expected = 0, labels = {} }) {
const safeExpected = Number.isFinite(Number(expected)) ? Number(expected) : 0;
const safeExisting = Number.isFinite(Number(existing)) ? Number(existing) : 0;
if (safeExpected <= 0) {
return <BoolState value={safeExisting > 0} />;
}
if (safeExisting <= 0) {
return (
<span className="job-step-inline-no" title={labels.none || `Nein (${safeExisting}/${safeExpected})`}>
<i className="pi pi-times-circle" aria-hidden="true" />
</span>
);
}
if (safeExisting < safeExpected) {
return (
<span className="job-step-inline-warn" title={labels.partial || `Teilweise (${safeExisting}/${safeExpected})`}>
<i className="pi pi-exclamation-circle" aria-hidden="true" />
</span>
);
}
return (
<span className="job-step-inline-ok" title={labels.full || `Ja (${safeExisting}/${safeExpected})`}>
<i className="pi pi-check-circle" aria-hidden="true" />
</span>
);
}
function resolveSelectionStateMeta({ selected, outcome }) {
if (!selected) {
return {
@@ -616,6 +877,7 @@ export default function JobDetailDialog({
logLoadingMode = null,
onAssignOmdb,
onAssignCdMetadata,
onAcknowledgeError,
onResumeReady,
onRestartEncode,
onRestartReview,
@@ -631,6 +893,7 @@ export default function JobDetailDialog({
isQueued = false,
omdbAssignBusy = false,
cdMetadataAssignBusy = false,
acknowledgeErrorBusy = false,
actionBusy = false,
cancelBusy = false,
reencodeBusy = false,
@@ -639,16 +902,39 @@ export default function JobDetailDialog({
downloadFolderBusyPath = null
}) {
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
// RAW-Import aus Datenbank, der noch nie encodiert wurde → nur "Neu einlesen" erlaubt
const isUnencodedOrphanImport = Boolean(
job?.makemkvInfo?.source === 'orphan_raw_import' && !job?.handbrakeInfo
);
const statusUpper = String(job?.status || '').trim().toUpperCase();
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(statusUpper);
const softCancelable = ['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP'].includes(statusUpper);
const showCancelAction = (running || softCancelable) && typeof onCancel === 'function';
const showFinalLog = !running;
const mediaType = resolveMediaType(job);
const isDvdSeries = mediaType === 'dvd' && isSeriesDvdJob(job);
const seriesBatchEpisodes = isDvdSeries ? mergeSeriesBatchEpisodes(job) : [];
const seriesEpisodeAssignments = isDvdSeries && job?.encodePlan?.episodeAssignments && typeof job.encodePlan.episodeAssignments === 'object'
? job.encodePlan.episodeAssignments
: {};
const seriesBatchSummary = seriesBatchEpisodes.reduce((acc, episode) => {
const status = normalizeSeriesEpisodeStatus(episode?.status);
if (status === 'FINISHED') {
acc.finished += 1;
} else if (status === 'ERROR') {
acc.error += 1;
} else if (status === 'CANCELLED') {
acc.cancelled += 1;
} else if (status === 'RUNNING') {
acc.running += 1;
} else {
acc.queued += 1;
}
return acc;
}, {
total: seriesBatchEpisodes.length,
finished: 0,
error: 0,
cancelled: 0,
running: 0,
queued: 0
});
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const isConverter = mediaType === 'converter';
@@ -811,7 +1097,7 @@ export default function JobDetailDialog({
const mediaTypeLabel = mediaType === 'bluray'
? 'Blu-ray'
: mediaType === 'dvd'
? 'DVD'
? (isDvdSeries ? 'DVD Serie' : 'DVD')
: isCd
? 'Audio CD'
: (isAudiobook ? 'Audiobook' : (isConverter ? `Converter ${converterMediaTypeLabel}` : 'Sonstiges Medium'));
@@ -823,6 +1109,27 @@ export default function JobDetailDialog({
const mediaTypeAlt = mediaTypeLabel;
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const metadataContext = resolveJobMetadataContext(job);
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
const childJobs = Array.isArray(job?.children) ? job.children : [];
const isSeriesContainer = isDvdSeries && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
const seriesChildSummary = job?.seriesChildSummary || null;
const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null;
const seriesEncodeSummary = isSeriesContainer ? seriesChildSummary?.encode : null;
const displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null;
const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info';
const metadataJsonValue = metadataDetails.provider === 'tmdb'
? (metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null)
: job?.omdbInfo;
const metadataAssignProvider = isDvdSeries
? 'tmdb'
: (metadataDetails.provider === 'tmdb' ? 'tmdb' : 'omdb');
const metadataAssignButtonLabel = metadataAssignProvider === 'tmdb' ? 'TMDb neu zuweisen' : 'OMDb neu zuweisen';
const metadataAssignActionDescription = metadataAssignProvider === 'tmdb'
? 'Öffnet die TMDb-Suche, um Serien-Metadaten (Titel, Staffel, Episoden, Poster) neu zuzuweisen.'
: 'Öffnet die OMDb-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.';
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0
@@ -832,8 +1139,13 @@ export default function JobDetailDialog({
? job.encodePlan.userPreset
: null;
const reviewSelectedEncodeTitleId = job?.encodePlan?.encodeInputTitleId ?? null;
const encodePlanTitles = Array.isArray(job?.encodePlan?.titles) ? job.encodePlan.titles : [];
const selectedEncodeTitles = encodePlanTitles.filter(
(title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId)
);
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
const canDownloadRaw = Boolean(job?.raw_path && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
@@ -979,12 +1291,24 @@ export default function JobDetailDialog({
) : (
<>
<section className="job-meta-block job-meta-block-film">
<h4>{isAudiobook ? 'Audiobook-Infos' : 'Film-Infos'}</h4>
<h4>{isAudiobook ? 'Audiobook-Infos' : (isDvdSeries ? 'Serieninfos' : 'Film-Infos')}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Titel:</strong>
<span>{job.title || job.detected_title || '-'}</span>
</div>
{isDvdSeries ? (
<div className="job-meta-item">
<strong>Staffel:</strong>
<span>{metadataContext?.selectedMetadata?.seasonNumber ?? '-'}</span>
</div>
) : null}
{isDvdSeries ? (
<div className="job-meta-item">
<strong>{isSeriesContainer ? 'Container' : 'Disk'}:</strong>
<span>{isSeriesContainer ? 'Ja' : (seriesDiscNumber ? `Disk ${seriesDiscNumber}` : '-')}</span>
</div>
) : null}
<div className="job-meta-item">
<strong>Jahr:</strong>
<span>{job.year || '-'}</span>
@@ -1024,11 +1348,17 @@ export default function JobDetailDialog({
<>
<div className="job-meta-item">
<strong>IMDb:</strong>
<span>{job.imdb_id || '-'}</span>
<span>{displayedImdbId || '-'}</span>
</div>
{isDvdSeries ? (
<div className="job-meta-item">
<strong>TMDb:</strong>
<span>{metadataContext?.selectedMetadata?.tmdbId || '-'}</span>
</div>
) : null}
<div className="job-meta-item">
<strong>OMDb Match:</strong>
<BoolState value={job.selected_from_omdb} />
<strong>{metadataDetails.matchLabel}:</strong>
<BoolState value={metadataDetails.hasMatch} />
</div>
</>
)}
@@ -1044,32 +1374,41 @@ export default function JobDetailDialog({
{!isAudiobook ? (
<section className="job-meta-block job-meta-block-film">
<h4>OMDb Details</h4>
<h4>{metadataDetails.title}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Regisseur:</strong>
<span>{omdbField(omdbInfo?.Director)}</span>
<span>{omdbField(metadataDetails.director)}</span>
</div>
<div className="job-meta-item">
<strong>Schauspieler:</strong>
<span>{omdbField(omdbInfo?.Actors)}</span>
<span>{omdbField(metadataDetails.actors)}</span>
</div>
<div className="job-meta-item">
<strong>Laufzeit:</strong>
<span>{omdbField(omdbInfo?.Runtime)}</span>
<span>{omdbField(metadataDetails.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>
<span>{omdbField(metadataDetails.genre)}</span>
</div>
{metadataDetails.provider === 'tmdb' ? (
<div className="job-meta-item">
<strong>TMDb Rating:</strong>
<span>{omdbField(metadataDetails.tmdbRating)}</span>
</div>
) : (
<>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbField(metadataDetails.rottenTomatoes)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(metadataDetails.imdbRating)}</span>
</div>
</>
)}
</div>
</section>
) : null}
@@ -1101,9 +1440,17 @@ export default function JobDetailDialog({
</>
) : (
<>
<span><strong>{isAudiobook || isConverter ? 'Import:' : 'Backup:'}</strong> <BoolState value={job?.backupSuccess} /></span>
<span><strong>{isAudiobook || isConverter ? 'Import:' : 'Backup:'}</strong> {isSeriesContainer && seriesBackupSummary ? (
<TriState existing={seriesBackupSummary.existing} expected={seriesBackupSummary.expected} />
) : (
<BoolState value={job?.backupSuccess} />
)}</span>
<span className="job-infos-sep">|</span>
<span><strong>Encode:</strong> <BoolState value={job?.encodeSuccess} /></span>
<span><strong>Encode:</strong> {isSeriesContainer && seriesEncodeSummary ? (
<TriState existing={seriesEncodeSummary.existing} expected={seriesEncodeSummary.expected} />
) : (
<BoolState value={job?.encodeSuccess} />
)}</span>
</>
)}
</div>
@@ -1115,11 +1462,29 @@ export default function JobDetailDialog({
{/* Zeile 3+4: Pfade */}
<PathField
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
value={isCd ? (job.raw_path || job.output_path) : job.raw_path}
value={isSeriesContainer ? null : (isCd ? (job.raw_path || job.output_path) : resolvedRawPath)}
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
downloadDisabled={!canDownloadRaw}
downloadLoading={downloadBusyTarget === 'raw'}
/>
{isSeriesContainer && childJobs.length > 0 ? (
childJobs.map((child) => {
const childDiscNumber = resolveSeriesDiscNumber(child);
const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:';
const childRawPath = child?.raw_path || child?.output_path || null;
const canDownloadChildRaw = Boolean(childRawPath && child?.rawStatus?.exists && typeof onDownloadArchive === 'function');
return (
<PathField
key={`child-raw-${child.id}`}
label={childLabel}
value={childRawPath}
onDownload={canDownloadChildRaw ? () => onDownloadArchive?.(child, 'raw') : null}
downloadDisabled={!canDownloadChildRaw}
downloadLoading={downloadBusyTarget === 'raw'}
/>
);
})
) : null}
{outputFolders.length > 0 ? (
outputFolders.map((folder, idx) => {
const folderPath = String(folder?.output_path || '').trim();
@@ -1149,7 +1514,23 @@ export default function JobDetailDialog({
/>
)}
{job.error_message ? (
<div><strong>Fehler:</strong> {job.error_message}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<span><strong>Fehler:</strong> {job.error_message}</span>
{typeof onAcknowledgeError === 'function' ? (
<Button
icon="pi pi-check-circle"
rounded
text
size="small"
severity="success"
title="Fehler quittieren"
aria-label="Fehler quittieren"
onClick={() => onAcknowledgeError(job)}
loading={acknowledgeErrorBusy}
disabled={acknowledgeErrorBusy}
/>
) : null}
</div>
) : null}
</div>
</section>
@@ -1219,6 +1600,119 @@ export default function JobDetailDialog({
</section>
) : null}
{isDvdSeries && seriesBatchEpisodes.length > 0 ? (
<section className="job-meta-block job-meta-block-full">
<h4>Serien-Episoden</h4>
<div className="series-batch-progress-wrap">
<div className="series-batch-head">
<div>
<strong>Gesamt:</strong>{' '}
{`${seriesBatchSummary.finished}/${seriesBatchSummary.total} fertig`}
{seriesBatchSummary.running > 0 ? ` | laufend: ${seriesBatchSummary.running}` : ''}
{seriesBatchSummary.queued > 0 ? ` | wartend: ${seriesBatchSummary.queued}` : ''}
{seriesBatchSummary.error > 0 ? ` | fehler: ${seriesBatchSummary.error}` : ''}
{seriesBatchSummary.cancelled > 0 ? ` | abgebrochen: ${seriesBatchSummary.cancelled}` : ''}
</div>
</div>
<div className="series-batch-episodes">
{seriesBatchEpisodes.map((episode) => {
const assignment = seriesEpisodeAssignments[String(episode?.titleId)]
|| seriesEpisodeAssignments[Number(episode?.titleId)]
|| null;
const statusMeta = seriesEpisodeStatusMeta(episode?.status);
const episodeLabel = String(
episode?.label
|| assignment?.episodeTitle
|| `Episode ${episode?.episodeIndex || '?'}`
).trim();
const episodeCode = (() => {
const seasonNo = normalizePositiveInteger(assignment?.seasonNumber ?? episode?.seasonNumber);
const episodeNoRaw = Number(assignment?.episodeNumber ?? episode?.episodeNumber);
const episodeNo = Number.isFinite(episodeNoRaw) && episodeNoRaw > 0
? episodeNoRaw
: null;
if (!seasonNo && !episodeNo) {
return null;
}
const seasonToken = seasonNo ? String(seasonNo).padStart(2, '0') : '--';
const episodeToken = episodeNo
? (
Number.isInteger(episodeNo)
? String(Math.trunc(episodeNo)).padStart(2, '0')
: String(episodeNo)
)
: '--';
return `S${seasonToken}E${episodeToken}`;
})();
const trackSelection = episode?.trackSelection && typeof episode.trackSelection === 'object'
? episode.trackSelection
: null;
const audioTrackIds = Array.isArray(trackSelection?.audioTrackIds) ? trackSelection.audioTrackIds : [];
const subtitleTrackIds = Array.isArray(trackSelection?.subtitleTrackIds) ? trackSelection.subtitleTrackIds : [];
const subtitleForcedIndexes = Array.isArray(trackSelection?.subtitleForcedTrackIndexes)
? trackSelection.subtitleForcedTrackIndexes
: [];
const episodeCommand = buildExecutedHandBrakeCommand(episode?.handbrakeInfo);
const boundedProgress = Number.isFinite(Number(episode?.progress))
? Math.max(0, Math.min(100, Number(episode.progress)))
: 0;
return (
<details key={`series-episode-${episode.episodeIndex}-${episode.titleId || 'na'}`} className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">
{`#${episode?.episodeIndex || '?'} | ${episodeLabel}${episodeCode ? ` | ${episodeCode}` : ''}${seriesDiscNumber ? ` | Disk ${seriesDiscNumber}` : ''}`}
</span>
<Tag value={statusMeta.label} severity={statusMeta.severity} />
</summary>
<div className="track-group">
<div className="track-item">
<span><strong>Titel-ID:</strong> {episode?.titleId || '-'}</span>
</div>
<div className="track-item">
<span><strong>Fortschritt:</strong> {`${boundedProgress.toFixed(2)}%`}</span>
</div>
<div className="track-item">
<span><strong>Gestartet:</strong> {formatDateTimeOrDash(episode?.startedAt)}</span>
</div>
<div className="track-item">
<span><strong>Beendet:</strong> {formatDateTimeOrDash(episode?.finishedAt)}</span>
</div>
<div className="track-item">
<span><strong>Audio-Spuren:</strong> {audioTrackIds.length > 0 ? audioTrackIds.join(', ') : '-'}</span>
</div>
<div className="track-item">
<span><strong>Subtitle-Spuren:</strong> {subtitleTrackIds.length > 0 ? subtitleTrackIds.join(', ') : '-'}</span>
</div>
<div className="track-item">
<span><strong>Subtitle Forced-Index:</strong> {subtitleForcedIndexes.length > 0 ? subtitleForcedIndexes.join(', ') : '-'}</span>
</div>
{episode?.outputPath ? (
<div className="track-item">
<span><strong>Output:</strong> {episode.outputPath}</span>
</div>
) : null}
{episode?.error ? (
<div className="track-item">
<span><strong>Fehler:</strong> {episode.error}</span>
</div>
) : null}
{episodeCommand ? (
<div className="track-item">
<span><strong>Command:</strong> {episodeCommand}</span>
</div>
) : null}
<div className="track-item">
<small className="track-action-note">Hinweis: Alle Episoden-Logs liegen im Hauptjob-Log.</small>
</div>
</div>
</details>
);
})}
</div>
</div>
</section>
) : null}
{!isCd && !isAudiobook && !isConverter && (hasConfiguredSelection || encodePlanUserPreset || job.encodePlan?.minLengthMinutes != null || Array.isArray(job.encodePlan?.titles)) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Encode-Konfiguration</h4>
@@ -1257,12 +1751,74 @@ export default function JobDetailDialog({
</div>
) : null}
{/* Spurauswahl read-only */}
{job.encodePlan && Array.isArray(job.encodePlan.titles) && job.encodePlan.titles.length > 0 ? (
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{job.encodePlan.titles
.filter((t) => t.selectedForEncode || t.encodeInput || String(t.id) === String(reviewSelectedEncodeTitleId))
.map((title) => {
{selectedEncodeTitles.length > 0 ? (
isDvdSeries ? (
<details className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{seriesDiscLabel}</span>
</summary>
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{selectedEncodeTitles.map((title) => {
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
return (
<div key={title.id} className="track-title-block">
<div className="track-title-row">
<span>#{title.id} | {title.fileName} | {title.durationMinutes != null ? `${Number(title.durationMinutes).toFixed(2)} min` : '-'}</span>
</div>
<div className="track-groups-row">
{audioTracks.length > 0 ? (
<div className="track-group">
<div className="track-group-label">Tonspuren (Titel #{title.id})</div>
{audioTracks.map((track) => {
const lang = trackLang(track.language || track.languageLabel);
const codec = trackCodec('audio', track.format, track.description || track.title);
const chLayout = trackChLayout(track.channels);
let displayText = `#${track.id} | ${lang} | ${codec}`;
if (chLayout) displayText += ` | ${chLayout}`;
const actionInfo = track.selectedForEncode
? (String(track.encodePreviewSummary || track.encodeActionSummary || '').trim() || 'Copy')
: 'Nicht übernommen';
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
<small className="track-action-note">Encode: {actionInfo}</small>
</div>
);
})}
</div>
) : null}
{subtitleTracks.length > 0 ? (
<div className="track-group">
<div className="track-group-label">Subtitles (Titel #{title.id})</div>
{subtitleTracks.map((track) => {
const lang = trackLang(track.language || track.languageLabel);
const codec = trackCodec('subtitle', track.format);
const displayText = `#${track.id} | ${lang} | ${codec}`;
const rawAction = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
const actionInfo = track.selectedForEncode
? (/^nicht übernommen$/i.test(rawAction) ? 'Übernehmen' : (rawAction || 'Übernehmen'))
: 'Nicht übernommen';
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
<small className="track-action-note">Encode: {actionInfo}</small>
</div>
);
})}
</div>
) : null}
</div>
</div>
);
})}
</div>
</details>
) : (
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{selectedEncodeTitles.map((title) => {
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
return (
@@ -1315,9 +1871,9 @@ export default function JobDetailDialog({
</div>
</div>
);
})
}
</div>
})}
</div>
)
) : null}
</section>
) : null}
@@ -1421,15 +1977,55 @@ export default function JobDetailDialog({
<section className="job-meta-block job-meta-block-full">
<h4>Logs</h4>
<div className="job-json-grid">
{!isCd && !isAudiobook && !isConverter ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
</div>
{isSeriesContainer && childJobs.length > 0 ? (
<>
<div className="job-json-grid">
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
</div>
{childJobs.map((child) => {
const childDiscNumber = resolveSeriesDiscNumber(child);
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
return (
<details key={`child-log-${child.id}`} className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{childDiscLabel}</span>
</summary>
<div className="job-json-grid">
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={child.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={child.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={child.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={child.handbrakeInfo} />
</div>
</details>
);
})}
</>
) : isDvdSeries ? (
<details className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{seriesDiscLabel}</span>
</summary>
<div className="job-json-grid">
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
</div>
</details>
) : (
<div className="job-json-grid">
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
</div>
)}
</section>
@@ -1461,7 +2057,24 @@ export default function JobDetailDialog({
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
</div>
{logLoaded ? (
<pre className="log-box">{job.log || ''}</pre>
isSeriesContainer && childJobs.length > 0 ? (
<div className="log-box">
{childJobs.map((child) => {
const childDiscNumber = resolveSeriesDiscNumber(child);
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
return (
<details key={`child-log-text-${child.id}`} className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{childDiscLabel}</span>
</summary>
<pre>{child.log || ''}</pre>
</details>
);
})}
</div>
) : (
<pre className="log-box">{job.log || ''}</pre>
)
) : (
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
)}
@@ -1595,7 +2208,7 @@ export default function JobDetailDialog({
size="small"
onClick={() => onRestartReview?.(job)}
loading={actionBusy}
disabled={!canRestartReview || isUnencodedOrphanImport}
disabled={!canRestartReview}
/>
<span className="action-desc">{isAudiobook
? 'Öffnet die Kapitel-Auswahl erneut, um Kapitel anzupassen bevor der Encode startet.'
@@ -1610,9 +2223,9 @@ export default function JobDetailDialog({
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => isUnencodedOrphanImport ? onRestartReview?.(job) : onReencode?.(job)}
loading={isUnencodedOrphanImport ? actionBusy : reencodeBusy}
disabled={isUnencodedOrphanImport ? !canRestartReview : !canReencode}
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode}
/>
<span className="action-desc">{isAudiobook
? 'Liest die AAX-Datei neu ein und öffnet danach die Kapitel-Auswahl. Sinnvoll wenn sich die Quelldatei geändert hat.'
@@ -1629,7 +2242,7 @@ export default function JobDetailDialog({
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item">
<Button
label="OMDB neu zuweisen"
label={metadataAssignButtonLabel}
icon="pi pi-search"
severity="secondary"
outlined
@@ -1638,7 +2251,7 @@ export default function JobDetailDialog({
loading={omdbAssignBusy}
disabled={running}
/>
<span className="action-desc">Öffnet die OMDB-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.</span>
<span className="action-desc">{metadataAssignActionDescription}</span>
</div>
</div>
) : null}