0.14.0 BluRay Series Flow

This commit is contained in:
2026-04-20 08:41:48 +00:00
parent 010c96461c
commit 9890971ff3
23 changed files with 2282 additions and 310 deletions
+221 -42
View File
@@ -486,6 +486,34 @@ function normalizePositiveInt(value) {
return Math.trunc(parsed);
}
function hasSeriesMetadataShape(source = null) {
const metadata = source && typeof source === 'object' ? source : {};
const seasonNumber = normalizePositiveInt(metadata?.seasonNumber ?? metadata?.season ?? null);
const episodes = Array.isArray(metadata?.episodes) ? metadata.episodes : [];
const metadataProvider = String(metadata?.metadataProvider || '').trim().toLowerCase();
const workflowKind = String(metadata?.workflowKind || metadata?.kind || '').trim().toLowerCase();
return Boolean(
seasonNumber
|| episodes.length > 0
|| metadataProvider === 'tmdb'
|| metadataProvider === 'themoviedb'
|| workflowKind === 'series'
|| workflowKind === 'season'
);
}
function hasSeriesEpisodeAssignmentHints(titles = []) {
const reviewTitles = Array.isArray(titles) ? titles : [];
return reviewTitles.some((title) => {
const seasonNumber = normalizePositiveInt(title?.seasonNumber ?? title?.season ?? null);
const episodeNumber = normalizePositiveInt(title?.episodeNumber ?? title?.number ?? null);
const episodeId = normalizePositiveInt(title?.episodeId ?? null);
const episodeTitle = String(title?.episodeTitle || '').trim();
const episodeTitleStart = String(title?.episodeTitleStart || '').trim();
return Boolean(seasonNumber || episodeNumber || episodeId || episodeTitle || episodeTitleStart);
});
}
function normalizeEpisodeReference(entry = {}) {
const source = entry && typeof entry === 'object' ? entry : {};
const episodeId = normalizePositiveInt(source?.episodeId ?? source?.id ?? null);
@@ -605,6 +633,20 @@ function normalizeSeriesLanguage(value) {
return raw;
}
function normalizeSeriesTitleForOutputPath(value, fallback = 'series') {
const raw = String(value || '').trim();
if (!raw) {
return String(fallback || 'series').trim() || 'series';
}
const stripped = raw
.replace(/\s*-\s*S\d{1,2}E\d{1,3}(?:-\d{1,3})?\b.*$/i, '')
.trim();
if (stripped) {
return stripped;
}
return raw;
}
function normalizeSeriesBatchStatus(value) {
return String(value || '').trim().toUpperCase() || 'UNKNOWN';
}
@@ -1061,6 +1103,7 @@ function buildFallbackEpisodeTitleForOutput(episodeRange = null, template = '')
function buildDvdSeriesEpisodeOutputPathPreview(
settings,
mediaProfile,
metadata,
title,
assignment = null,
@@ -1069,9 +1112,13 @@ function buildDvdSeriesEpisodeOutputPathPreview(
assignmentByTitle = {},
selectedTitleIds = []
) {
const normalizedSeriesMediaProfile = String(mediaProfile || '').trim().toLowerCase() === 'bluray'
? 'bluray'
: 'dvd';
const seriesRootDir = String(
settings?.series_dir_dvd
|| settings?.movie_dir_dvd
(normalizedSeriesMediaProfile === 'bluray'
? (settings?.series_dir_bluray || settings?.series_dir_dvd || settings?.movie_dir_bluray || settings?.movie_dir_dvd)
: (settings?.series_dir_dvd || settings?.series_dir_bluray || settings?.movie_dir_dvd || settings?.movie_dir_bluray))
|| settings?.movie_dir
|| ''
).trim();
@@ -1105,21 +1152,29 @@ function buildDvdSeriesEpisodeOutputPathPreview(
const episodeRangeToken = buildEpisodeRangeToken(effectiveEpisodeRange.start, effectiveEpisodeRange.end);
const partsToken = buildEpisodePartsToken(effectiveEpisodeRange);
const discNumber = normalizePositiveInt(metadata?.discNumber ?? assignment?.discNumber ?? null);
const seriesTitleRaw = String(
metadata?.title
|| metadata?.seriesTitle
|| (fallbackJobId ? `job-${fallbackJobId}` : 'series')
).trim();
const seriesTitle = seriesTitleRaw || 'series';
const seriesTitle = normalizeSeriesTitleForOutputPath(
metadata?.seriesTitle
|| metadata?.title
|| (fallbackJobId ? `job-${fallbackJobId}` : 'series'),
fallbackJobId ? `job-${fallbackJobId}` : 'series'
);
const year = String(metadata?.year || new Date().getFullYear()).trim();
const language = normalizeSeriesLanguage(settings?.dvd_series_language || metadata?.language || null);
const singleTemplateRaw = String(
settings?.output_template_dvd_series_episode
(normalizedSeriesMediaProfile === 'bluray'
? settings?.output_template_bluray_series_episode
: settings?.output_template_dvd_series_episode)
|| settings?.output_template_dvd_series_episode
|| settings?.output_template_bluray_series_episode
|| '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'
).trim();
const multiTemplateRaw = String(
settings?.output_template_dvd_series_multi_episode
(normalizedSeriesMediaProfile === 'bluray'
? settings?.output_template_bluray_series_multi_episode
: settings?.output_template_dvd_series_multi_episode)
|| settings?.output_template_dvd_series_multi_episode
|| settings?.output_template_bluray_series_multi_episode
|| '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'
).trim();
const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}';
@@ -1160,7 +1215,15 @@ function buildDvdSeriesEpisodeOutputPathPreview(
.filter(Boolean);
const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
const folderParts = segments.slice(0, -1);
const ext = String(settings?.output_extension_dvd || settings?.output_extension || 'mkv').trim() || 'mkv';
const ext = String(
(normalizedSeriesMediaProfile === 'bluray'
? settings?.output_extension_bluray
: settings?.output_extension_dvd)
|| settings?.output_extension_dvd
|| settings?.output_extension_bluray
|| settings?.output_extension
|| 'mkv'
).trim() || 'mkv';
const root = seriesRootDir.replace(/\/+$/g, '');
if (folderParts.length > 0) {
return `${root}/${folderParts.join('/')}/${baseName}.${ext}`;
@@ -1304,8 +1367,9 @@ export default function PipelineStatusCard({
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
const isDvdTitleSelectionRequired = Boolean(
jobMediaProfile === 'dvd'
const discTypeLabel = jobMediaProfile === 'bluray' ? 'Blu-ray' : 'DVD';
const isDiscTitleSelectionRequired = Boolean(
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
&& (mediaInfoReview?.titleSelectionRequired || mediaInfoReview?.handBrakeTitleDecisionRequired)
);
const [liveJobLog, setLiveJobLog] = useState('');
@@ -1467,8 +1531,21 @@ export default function PipelineStatusCard({
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id),
...(fromReview ? [fromReview] : [])
]);
const isSeriesVideoByMetadata = Boolean(
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
&& (
hasSeriesMetadataShape(selectedMetadata)
|| hasSeriesMetadataShape(mediaInfoReview?.selectedMetadata)
|| hasSeriesEpisodeAssignmentHints(reviewTitles)
|| Boolean(mediaInfoReview?.seriesBatchParent)
)
);
const defaultAllTitles = Boolean(
(mediaInfoReview?.titleSelectionRequired || mediaInfoReview?.handBrakeTitleDecisionRequired)
(
mediaInfoReview?.titleSelectionRequired
|| mediaInfoReview?.handBrakeTitleDecisionRequired
|| (isSeriesVideoByMetadata && reviewTitles.length > 1)
)
&& reviewTitles.length > 0
);
const effectiveSelected = defaultAllTitles
@@ -1537,6 +1614,10 @@ export default function PipelineStatusCard({
mediaInfoReview?.outputFormat,
mediaInfoReview?.userPreset?.id,
mediaInfoReview?.userPreset?.handbrakePreset,
selectedMetadata?.seasonNumber,
selectedMetadata?.episodes,
selectedMetadata?.metadataProvider,
jobMediaProfile,
retryJobId
]);
@@ -1877,16 +1958,6 @@ export default function PipelineStatusCard({
if (!Array.isArray(selectedMetadataEpisodes) || selectedMetadataEpisodes.length === 0) {
return;
}
const hasAnyEpisodeAssignment = Object.values(episodeAssignmentsByTitle || {}).some((assignment) => {
const episodeId = Number(assignment?.episodeId || 0);
const episodeNumber = Number(assignment?.episodeNumber || 0);
const seasonNumber = Number(assignment?.seasonNumber || 0);
const episodeTitle = String(assignment?.episodeTitle || '').trim();
return Boolean(episodeTitle || episodeId > 0 || episodeNumber > 0 || seasonNumber > 0);
});
if (hasAnyEpisodeAssignment) {
return;
}
if (!Array.isArray(episodeFillOptions) || episodeFillOptions.length === 0) {
return;
}
@@ -1903,11 +1974,64 @@ export default function PipelineStatusCard({
}
if (!hasValidSelection) {
setSelectedEpisodeFillStart(String(defaultStartRef));
// Keep dropdown + episodentitel in sync when options change (e.g. Disc 2
// after already used episodes were loaded from the container).
applyEpisodeFillFrom(defaultStartRef);
return;
}
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
const fallbackSelectedIds = normalizeTitleIdList([
...selectedEncodeTitleIds,
selectedEncodeTitleId,
mediaInfoReview?.encodeInputTitleId,
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
]);
const selectedTitleIdsInReviewOrder = reviewTitles
.map((title) => normalizeTitleId(title?.id))
.filter((id) => id !== null && fallbackSelectedIds.includes(id));
const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0
? selectedTitleIdsInReviewOrder
: fallbackSelectedIds;
const hasEpisodeAssignmentPayload = (assignment) => {
if (!assignment || typeof assignment !== 'object') {
return false;
}
const episodeId = Number(assignment?.episodeId || assignment?.episodeIdStart || 0);
const episodeNumber = Number(assignment?.episodeNumber || assignment?.episodeNumberStart || 0);
const seasonNumber = Number(assignment?.seasonNumber || 0);
const episodeTitle = String(assignment?.episodeTitle || '').trim();
return Boolean(episodeTitle || episodeId > 0 || episodeNumber > 0 || seasonNumber > 0);
};
const hasAnyEpisodeAssignment = effectiveSelectedTitleIds.some((titleId) => {
const assignment = episodeAssignmentsByTitle?.[titleId]
|| episodeAssignmentsByTitle?.[String(titleId)]
|| null;
return hasEpisodeAssignmentPayload(assignment);
});
const hasCompleteEpisodeAssignments = effectiveSelectedTitleIds.length > 0
&& effectiveSelectedTitleIds.every((titleId) => {
const assignment = episodeAssignmentsByTitle?.[titleId]
|| episodeAssignmentsByTitle?.[String(titleId)]
|| null;
return hasEpisodeAssignmentPayload(assignment);
});
if (hasCompleteEpisodeAssignments) {
return;
}
if (hasAnyEpisodeAssignment && normalizedSelection) {
return;
}
applyEpisodeFillFrom(defaultStartRef);
}, [
selectedMetadataEpisodes,
selectedEncodeTitleIds,
selectedEncodeTitleId,
mediaInfoReview?.titles,
mediaInfoReview?.encodeInputTitleId,
mediaInfoReview?.generatedAt,
episodeAssignmentsByTitle,
episodeFillOptions,
@@ -2247,6 +2371,16 @@ export default function PipelineStatusCard({
pipeline?.context?.selectedPlaylist
]);
const seriesBatchRunningChildJobIdHint = useMemo(() => {
const seriesBatch = pipeline?.context?.seriesBatch;
if (!seriesBatch || typeof seriesBatch !== 'object') {
return null;
}
const children = Array.isArray(seriesBatch?.children) ? seriesBatch.children : [];
const runningChild = children.find((child) => normalizeSeriesBatchStatus(child?.status) === 'RUNNING') || null;
return normalizeJobId(runningChild?.jobId);
}, [pipeline?.context?.seriesBatch]);
useEffect(() => {
if (!running || !retryJobId) {
setLiveJobLog('');
@@ -2257,7 +2391,38 @@ export default function PipelineStatusCard({
try {
const response = await api.getJob(retryJobId, { includeLiveLog: true, lite: true });
if (!cancelled) {
setLiveJobLog(response?.job?.log || '');
const liveJob = response?.job && typeof response.job === 'object'
? response.job
: null;
const childRows = Array.isArray(liveJob?.children) ? liveJob.children : [];
const liveHintChildId = normalizeJobId(seriesBatchRunningChildJobIdHint);
const isSeriesBatchParentRun = Boolean(
liveHintChildId
|| liveJob?.encodePlan?.seriesBatchParent
|| String(liveJob?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'series_batch'
);
if (isSeriesBatchParentRun) {
const activeChildStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'RUNNING']);
let activeChild = liveHintChildId
? (childRows.find((child) => normalizeJobId(child?.id) === liveHintChildId) || null)
: null;
if (activeChild) {
const hintChildStatus = String(activeChild?.status || activeChild?.last_state || '').trim().toUpperCase();
if (!activeChildStates.has(hintChildStatus)) {
activeChild = null;
}
}
if (!activeChild) {
activeChild = childRows.find((child) => (
activeChildStates.has(String(child?.status || child?.last_state || '').trim().toUpperCase())
)) || null;
}
setLiveJobLog(String(activeChild?.log || '').trim());
return;
}
setLiveJobLog(String(liveJob?.log || '').trim());
}
} catch (_err) {
// ignore transient polling errors
@@ -2269,22 +2434,34 @@ export default function PipelineStatusCard({
cancelled = true;
clearInterval(interval);
};
}, [running, retryJobId]);
}, [running, retryJobId, seriesBatchRunningChildJobIdHint]);
const manualDecisionState = pipeline?.context?.manualDecisionState || null;
const rawDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && manualDecisionState === 'waiting_for_raw_decision';
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired && !rawDecisionRequiredBeforeStart;
const isSeriesDvdReview = useMemo(() => {
if (jobMediaProfile !== 'dvd') {
if (jobMediaProfile !== 'dvd' && jobMediaProfile !== 'bluray') {
return false;
}
const seasonNumberRaw = selectedMetadata?.seasonNumber ?? null;
const seasonNumber = Number(String(seasonNumberRaw ?? '').replace(',', '.'));
const hasSeasonNumber = Number.isFinite(seasonNumber) && seasonNumber > 0;
const hasEpisodeList = Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0;
const metadataProvider = String(selectedMetadata?.metadataProvider || '').trim().toLowerCase();
return hasSeasonNumber || hasEpisodeList || metadataProvider === 'tmdb' || metadataProvider === 'themoviedb';
}, [jobMediaProfile, selectedMetadata?.seasonNumber, selectedMetadata?.episodes, selectedMetadata?.metadataProvider]);
return Boolean(
hasSeriesMetadataShape(selectedMetadata)
|| hasSeriesMetadataShape(mediaInfoReview?.selectedMetadata)
|| hasSeriesEpisodeAssignmentHints(mediaInfoReview?.titles)
|| Boolean(mediaInfoReview?.seriesBatchParent)
);
}, [
jobMediaProfile,
selectedMetadata?.seasonNumber,
selectedMetadata?.episodes,
selectedMetadata?.metadataProvider,
selectedMetadata?.workflowKind,
mediaInfoReview?.selectedMetadata?.seasonNumber,
mediaInfoReview?.selectedMetadata?.episodes,
mediaInfoReview?.selectedMetadata?.metadataProvider,
mediaInfoReview?.selectedMetadata?.workflowKind,
mediaInfoReview?.titles,
mediaInfoReview?.seriesBatchParent
]);
useEffect(() => {
let cancelled = false;
@@ -2515,6 +2692,7 @@ export default function PipelineStatusCard({
|| null;
const path = buildDvdSeriesEpisodeOutputPathPreview(
settingsMap,
jobMediaProfile,
selectedMetadata || {},
title,
assignment,
@@ -2533,6 +2711,7 @@ export default function PipelineStatusCard({
mediaInfoReview?.titles,
episodeAssignmentsByTitle,
settingsMap,
jobMediaProfile,
selectedMetadata,
retryJobId,
selectedEncodeTitleIds
@@ -3091,7 +3270,7 @@ export default function PipelineStatusCard({
{handBrakeTitleDecisionRequired && retryJobId && (
<Button
label={isSeriesPlayAllDecisionMode ? 'PlayAll/Doppelfolge bestätigen' : 'DVD Titel bestätigen'}
label={isSeriesPlayAllDecisionMode ? 'PlayAll/Doppelfolge bestätigen' : `${discTypeLabel} Titel bestätigen`}
icon="pi pi-check"
severity="warning"
outlined
@@ -3353,11 +3532,11 @@ export default function PipelineStatusCard({
{handBrakeTitleDecisionRequired && !queueLocked ? (
<div className="playlist-decision-block">
<h3>{isSeriesPlayAllDecisionMode ? 'Serien-Grenzfall: PlayAll oder Doppelfolge?' : 'DVD Titel-Auswahl erforderlich'}</h3>
<h3>{isSeriesPlayAllDecisionMode ? 'Serien-Grenzfall: PlayAll oder Doppelfolge?' : `${discTypeLabel} Titelauswahl erforderlich`}</h3>
<small>
{isSeriesPlayAllDecisionMode
? 'Min. ein Titel passt zeitlich zur Summe der kürzeren Episoden. Bitte durch Auswahl entscheiden, ob PlayAll Multi-Episode (nicht anhaken) oder Doppelfolge (anhaken).'
: 'Mehrere DVD-Titel gefunden, die die Mindestlänge erfüllen. Bitte einen oder mehrere Episoden-Titel auswählen.'}
: `Mehrere ${discTypeLabel}-Titel gefunden, die die Mindestlänge erfüllen. Bitte einen oder mehrere Episodentitel auswählen.`}
</small>
{visibleWaitingHandBrakeTitleRows.length > 0 ? (
<div className="playlist-decision-list">
@@ -3415,7 +3594,7 @@ export default function PipelineStatusCard({
</div>
) : null}
{selectedMetadata && !isDvdTitleSelectionRequired ? (
{selectedMetadata && !isDiscTitleSelectionRequired ? (
<div className="pipeline-meta-inline">
{selectedMetadata.poster ? (
<img
@@ -3445,8 +3624,8 @@ export default function PipelineStatusCard({
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
<div className="mediainfo-review-block">
{!isDvdTitleSelectionRequired ? <h3>Titel-/Spurprüfung</h3> : null}
{state === 'READY_TO_ENCODE' && !queueLocked && !isDvdTitleSelectionRequired ? (
{!isDiscTitleSelectionRequired ? <h3>Titel-/Spurprüfung</h3> : null}
{state === 'READY_TO_ENCODE' && !queueLocked && !isDiscTitleSelectionRequired ? (
<small>
{isPreRipReview
? 'Spurauswahl kann direkt übernommen werden. Beim Klick auf "Backup + Encoding starten" wird automatisch bestätigt und gestartet.'
@@ -3543,7 +3722,7 @@ export default function PipelineStatusCard({
<div className="playlist-decision-block">
<h3>Hinweis Episoden-Zuordnung</h3>
<small>
Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich pruefen, bevor das Encoding startet.
Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich prüfen, bevor das Encoding startet.
</small>
{episodeAssignmentWarningLines.map((line, index) => (
<small key={`episode-assignment-warning-${index}`}>{line}</small>
@@ -3558,7 +3737,7 @@ export default function PipelineStatusCard({
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
allowTitleSelection={(state === 'READY_TO_ENCODE' || state === 'WAITING_FOR_USER_DECISION') && !queueLocked}
compactTitleSelection={isDvdTitleSelectionRequired}
compactTitleSelection={isDiscTitleSelectionRequired}
onSelectEncodeTitle={(titleId) => {
const normalizedTitleId = normalizeTitleId(titleId);
setSelectedEncodeTitleId(normalizedTitleId);