Files
ripster/frontend/src/components/PipelineStatusCard.jsx
T
2026-04-20 08:41:48 +00:00

4144 lines
162 KiB
React

import { useEffect, useMemo, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Button } from 'primereact/button';
import { Dropdown } from 'primereact/dropdown';
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
import { api } from '../api/client';
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal';
const CONVERTER_VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' },
{ label: 'MP4', value: 'mp4' },
{ label: 'M4V', value: 'm4v' }
];
function normalizeTitleId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeTitleIdList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = normalizeTitleId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizePlaylistId(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
const match = raw.match(/(\d{1,5})(?:\.mpls)?$/i);
return match ? String(match[1]).padStart(5, '0') : null;
}
function formatDurationClock(seconds) {
const total = Number(seconds || 0);
if (!Number.isFinite(total) || total <= 0) {
return null;
}
const rounded = Math.max(0, Math.trunc(total));
const h = Math.floor(rounded / 3600);
const m = Math.floor((rounded % 3600) / 60);
const s = rounded % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function normalizeTrackId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeTrackIdList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = normalizeTrackId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function normalizeScriptId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeScriptIdList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = normalizeScriptId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function normalizeChainId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeMediaProfile(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
return 'bluray';
}
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 (raw === 'converter') {
return 'converter';
}
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
return 'other';
}
return null;
}
function resolvePipelineMediaProfile(pipeline, mediaInfoReview) {
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
const device = context?.device && typeof context.device === 'object' ? context.device : {};
const review = mediaInfoReview && typeof mediaInfoReview === 'object' ? mediaInfoReview : {};
const candidates = [
context?.mediaProfile,
context?.media_profile,
review?.mediaProfile,
review?.media_profile,
device?.mediaProfile,
device?.media_profile,
device?.profile,
device?.type
];
for (const candidate of candidates) {
const normalized = normalizeMediaProfile(candidate);
if (normalized) {
return normalized;
}
}
return null;
}
function isBurnedSubtitleTrack(track) {
const flags = Array.isArray(track?.subtitlePreviewFlags)
? track.subtitlePreviewFlags
: (Array.isArray(track?.flags) ? track.flags : []);
const hasBurnedFlag = flags.some((flag) => String(flag || '').trim().toLowerCase() === 'burned');
const summary = `${track?.subtitlePreviewSummary || ''} ${track?.subtitleActionSummary || ''}`;
return Boolean(
track?.subtitlePreviewBurnIn
|| track?.burnIn
|| hasBurnedFlag
|| /burned/i.test(summary)
);
}
function isDuplicateSubtitleTrack(track) {
return Boolean(track?.duplicate);
}
function normalizeSubtitleLanguage(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return 'und';
}
if (raw.length >= 3) {
return raw.slice(0, 3);
}
return raw;
}
function normalizeSubtitleVariantSelectionMap(rawSelection) {
const map = {};
if (!rawSelection || typeof rawSelection !== 'object') {
return map;
}
for (const [language, value] of Object.entries(rawSelection)) {
const normalizedLanguage = normalizeSubtitleLanguage(language);
const full = Boolean(value?.full);
const forced = Boolean(value?.forced);
map[normalizedLanguage] = { full, forced };
}
return map;
}
function normalizeSubtitleLanguageOrder(rawOrder = []) {
const list = Array.isArray(rawOrder) ? rawOrder : [];
const seen = new Set();
const output = [];
for (const value of list) {
const language = normalizeSubtitleLanguage(value);
if (!language || seen.has(language)) {
continue;
}
seen.add(language);
output.push(language);
}
return output;
}
function resolveSubtitleTrackVariantFlags(track) {
const subtitleType = String(track?.subtitleType || '').trim().toLowerCase();
const isForcedOnly = Boolean(
track?.isForcedOnly
?? track?.subtitlePreviewForcedOnly
?? track?.forcedOnly
?? subtitleType === 'forced'
);
const fullHasForced = !isForcedOnly && Boolean(
track?.fullHasForced
?? track?.subtitleFullHasForced
?? track?.hasForcedVariant
);
return { isForcedOnly, fullHasForced };
}
function buildDefaultSubtitleVariantSelection(subtitleTracks, selectedTracks) {
const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : [];
const selected = Array.isArray(selectedTracks) ? selectedTracks : [];
const orderedTracks = [...tracks].sort((a, b) => {
const aId = normalizeTrackId(a?.id) ?? Number.MAX_SAFE_INTEGER;
const bId = normalizeTrackId(b?.id) ?? Number.MAX_SAFE_INTEGER;
if (aId !== bId) {
return aId - bId;
}
return String(a?.language || a?.languageLabel || '').localeCompare(String(b?.language || b?.languageLabel || ''));
});
const order = [];
const orderSeen = new Set();
for (const track of orderedTracks) {
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!orderSeen.has(language)) {
orderSeen.add(language);
order.push(language);
}
}
const selectionMap = {};
for (const track of selected) {
if (isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track)) {
continue;
}
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!selectionMap[language]) {
selectionMap[language] = { full: false, forced: false };
}
const flags = resolveSubtitleTrackVariantFlags(track);
if (flags.isForcedOnly) {
selectionMap[language].forced = true;
} else {
selectionMap[language].full = true;
}
}
return {
subtitleVariantSelection: selectionMap,
subtitleLanguageOrder: order
};
}
function buildDefaultTrackSelection(review) {
const titles = Array.isArray(review?.titles) ? review.titles : [];
const selection = {};
const reviewEncodeInputTitleId = normalizeTitleId(review?.encodeInputTitleId);
const manualSelection = review?.manualTrackSelection && typeof review.manualTrackSelection === 'object'
? review.manualTrackSelection
: null;
const manualTitleId = normalizeTitleId(manualSelection?.titleId);
for (const title of titles) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [];
const isEncodeInputTitle = Boolean(
title?.selectedForEncode
|| title?.encodeInput
|| (reviewEncodeInputTitleId && reviewEncodeInputTitleId === titleId)
);
const audioSelectionSource = isEncodeInputTitle
? audioTracks.filter((track) => Boolean(track?.selectedForEncode))
: audioTracks.filter((track) => Boolean(track?.selectedByRule));
const subtitleSelectionSource = isEncodeInputTitle
? subtitleTracks.filter((track) => Boolean(track?.selectedForEncode))
: subtitleTracks.filter((track) => Boolean(track?.selectedByRule));
const defaultSubtitleVariantSelection = buildDefaultSubtitleVariantSelection(
subtitleTracks,
subtitleSelectionSource
);
const manualSelectionMatchesTitle = Boolean(
isEncodeInputTitle
&& manualSelection
&& (!manualTitleId || manualTitleId === titleId)
);
const manualAudioTrackIds = manualSelectionMatchesTitle
? normalizeTrackIdList(manualSelection?.audioTrackIds || [])
: [];
const manualSubtitleTrackIds = manualSelectionMatchesTitle
? normalizeTrackIdList(
Array.isArray(manualSelection?.subtitleTrackIdsOrdered)
? manualSelection.subtitleTrackIdsOrdered
: manualSelection?.subtitleTrackIds || []
)
: [];
const manualSubtitleVariantSelection = manualSelectionMatchesTitle
? normalizeSubtitleVariantSelectionMap(manualSelection?.subtitleVariantSelection || {})
: {};
const manualSubtitleLanguageOrder = manualSelectionMatchesTitle
? normalizeSubtitleLanguageOrder(manualSelection?.subtitleLanguageOrder || [])
: [];
selection[titleId] = {
audioTrackIds: manualAudioTrackIds.length > 0
? manualAudioTrackIds
: normalizeTrackIdList(audioSelectionSource.map((track) => track?.id)),
subtitleTrackIds: manualSubtitleTrackIds.length > 0
? manualSubtitleTrackIds
: normalizeTrackIdList(
subtitleSelectionSource
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => track?.id)
),
subtitleVariantSelection: Object.keys(manualSubtitleVariantSelection).length > 0
? manualSubtitleVariantSelection
: defaultSubtitleVariantSelection.subtitleVariantSelection,
subtitleLanguageOrder: manualSubtitleLanguageOrder.length > 0
? manualSubtitleLanguageOrder
: defaultSubtitleVariantSelection.subtitleLanguageOrder
};
}
return selection;
}
function defaultTrackSelectionForTitle(review, titleId) {
const defaults = buildDefaultTrackSelection(review);
return defaults[titleId] || defaults[String(titleId)] || {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
}
function buildSettingsMap(categories) {
const map = {};
const list = Array.isArray(categories) ? categories : [];
for (const category of list) {
for (const setting of (Array.isArray(category?.settings) ? category.settings : [])) {
map[setting.key] = setting.value;
}
}
return map;
}
function buildPresetDisplayMap(options) {
const map = {};
const list = Array.isArray(options) ? options : [];
for (const option of list) {
if (!option || option.disabled) {
continue;
}
const value = String(option.value || '').trim();
if (!value) {
continue;
}
const category = String(option.category || '').trim();
map[value] = category ? `${category}/${value}` : value;
}
return map;
}
function transliterateForFilename(input) {
return String(input || '')
.replace(/ä/g, 'ae').replace(/Ä/g, 'Ae')
.replace(/ö/g, 'oe').replace(/Ö/g, 'Oe')
.replace(/ü/g, 'ue').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.replace(/å/g, 'a').replace(/Å/g, 'A')
.replace(/æ/g, 'ae').replace(/Æ/g, 'Ae')
.replace(/ø/g, 'o').replace(/Ø/g, 'O')
.replace(/ð/g, 'd').replace(/Ð/g, 'D')
.replace(/þ/g, 'th').replace(/Þ/g, 'Th')
.normalize('NFD')
.replace(/\p{M}+/gu, '')
.replace(/[^\x00-\x7F]/g, '');
}
function sanitizeFileName(input) {
return transliterateForFilename(String(input || 'untitled'))
.replace(/[^a-zA-Z0-9 ()[\]_+-]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180);
}
function renderTemplate(template, values) {
const parseToken = (rawToken) => {
const token = String(rawToken || '').trim();
if (!token) {
return { format: '', key: '' };
}
const separatorIndex = token.indexOf(':');
if (separatorIndex <= 0) {
return { format: '', key: token };
}
return {
format: token.slice(0, separatorIndex).trim(),
key: token.slice(separatorIndex + 1).trim()
};
};
const applyFormat = (value, format) => {
const normalizedFormat = String(format || '').trim().toLowerCase();
if (!normalizedFormat) {
return String(value);
}
if (normalizedFormat === '0') {
const raw = String(value);
const match = raw.match(/^(-?)(\d+)$/);
if (match) {
const sign = match[1] || '';
const digits = String(match[2] || '');
return `${sign}${digits.padStart(2, '0')}`;
}
}
return String(value);
};
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
const { format, key } = parseToken(keyA || keyB || '');
const value = values[key];
if (value === undefined || value === null || value === '') {
return 'unknown';
}
return applyFormat(value, format);
});
}
function normalizePositiveInt(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
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);
const episodeNumber = normalizePositiveInt(source?.episodeNumber ?? source?.number ?? null);
const seasonNumber = normalizePositiveInt(source?.seasonNumber ?? source?.season ?? null);
if (!episodeId && !episodeNumber) {
return null;
}
return {
episodeId,
episodeNumber,
seasonNumber
};
}
function buildEpisodeReferenceKey(entry = {}) {
const normalized = normalizeEpisodeReference(entry);
if (!normalized) {
return null;
}
if (normalized.episodeId) {
return `id:${normalized.episodeId}`;
}
if (normalized.seasonNumber && normalized.episodeNumber) {
return `se:${normalized.seasonNumber}:${normalized.episodeNumber}`;
}
return normalized.episodeNumber ? `ep:${normalized.episodeNumber}` : null;
}
function collectEpisodeReferenceKeys(entry = {}) {
const normalized = normalizeEpisodeReference(entry);
if (!normalized) {
return [];
}
const keys = [];
if (normalized.episodeId) {
keys.push(`id:${normalized.episodeId}`);
}
if (normalized.seasonNumber && normalized.episodeNumber) {
keys.push(`se:${normalized.seasonNumber}:${normalized.episodeNumber}`);
}
if (normalized.episodeNumber) {
keys.push(`ep:${normalized.episodeNumber}`);
}
return Array.from(new Set(keys));
}
function isSeriesBatchEpisodePlan(plan = null) {
const source = plan && typeof plan === 'object' ? plan : {};
if (Boolean(source?.seriesBatchChild) || Boolean(source?.seriesBatchVirtualEpisode)) {
return true;
}
const parentJobId = normalizePositiveInt(source?.seriesBatchParentJobId);
const childIndex = normalizePositiveInt(source?.seriesBatchChildIndex);
const childCount = normalizePositiveInt(source?.seriesBatchChildCount);
const titleId = normalizeTitleId(source?.seriesBatchTitleId);
return Boolean(parentJobId && (childIndex || childCount || titleId));
}
function collectEpisodeReferencesFromPlan(plan = null) {
const source = plan && typeof plan === 'object' ? plan : {};
const refs = [];
const assignments = source?.episodeAssignments && typeof source.episodeAssignments === 'object'
? Object.values(source.episodeAssignments)
: [];
for (const assignment of assignments) {
const normalized = normalizeEpisodeReference(assignment);
if (normalized) {
refs.push(normalized);
}
const seasonNumber = normalizePositiveInt(assignment?.seasonNumber ?? assignment?.season ?? null);
const episodeStart = normalizePositiveInt(
assignment?.episodeNumberStart
?? assignment?.episodeNoStart
?? assignment?.episodeNumber
?? assignment?.number
?? null
);
const episodeEnd = normalizePositiveInt(
assignment?.episodeNumberEnd
?? assignment?.episodeNoEnd
?? null
);
if (episodeStart && episodeEnd && episodeEnd > episodeStart && (episodeEnd - episodeStart) <= 64) {
for (let number = episodeStart; number <= episodeEnd; number += 1) {
refs.push({
episodeId: null,
episodeNumber: number,
seasonNumber
});
}
}
}
const titles = Array.isArray(source?.titles) ? source.titles : [];
for (const title of titles) {
if (!Boolean(title?.selectedForEncode) && !Boolean(title?.encodeInput)) {
continue;
}
const normalized = normalizeEpisodeReference({
episodeId: title?.episodeId,
episodeNumber: title?.episodeNumber,
seasonNumber: title?.seasonNumber
});
if (normalized) {
refs.push(normalized);
}
}
return refs;
}
function normalizeSeriesLanguage(value) {
const raw = String(value || '').trim();
if (!raw) {
return 'unknown';
}
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';
}
function normalizeSeriesBatchProgress(value, fallback = 0) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
const fallbackValue = Number(fallback);
return Number.isFinite(fallbackValue) ? Math.max(0, Math.min(100, fallbackValue)) : 0;
}
return Math.max(0, Math.min(100, parsed));
}
function formatPercentTwoDecimals(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return '0.00%';
}
return `${parsed.toFixed(2)}%`;
}
function getSeriesBatchTagSeverity(status) {
const normalized = normalizeSeriesBatchStatus(status);
if (normalized === 'FINISHED') {
return 'success';
}
if (normalized === 'ERROR') {
return 'danger';
}
if (normalized === 'CANCELLED') {
return 'warning';
}
if (normalized === 'ENCODING' || normalized === 'RIPPING' || normalized === 'MEDIAINFO_CHECK') {
return 'info';
}
return 'secondary';
}
function medianPositiveInt(values = []) {
const nums = (Array.isArray(values) ? values : [])
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.sort((left, right) => left - right);
if (nums.length === 0) {
return 0;
}
const middle = Math.floor(nums.length / 2);
if (nums.length % 2 === 1) {
return nums[middle];
}
return (nums[middle - 1] + nums[middle]) / 2;
}
function formatTemplateTwoDigit(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return String(value || '').trim() || '00';
}
if (Number.isInteger(numeric)) {
return String(Math.trunc(numeric)).padStart(2, '0');
}
return String(value || '').trim() || String(numeric);
}
function resolveEpisodeAssignmentRange(assignment = null, title = null, fallbackEpisodeNumber = 1, options = {}) {
const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {};
const titleSource = title && typeof title === 'object' ? title : {};
const allowTitleEpisodeNumber = options?.allowTitleEpisodeNumber !== false;
const fallbackNumber = normalizePositiveInt(fallbackEpisodeNumber) || 1;
const episodeStart = normalizePositiveInt(
assignmentSource?.episodeNumberStart
?? assignmentSource?.episodeNoStart
?? assignmentSource?.episodeNumber
?? (allowTitleEpisodeNumber ? titleSource?.episodeNumber : null)
?? fallbackNumber
) || fallbackNumber;
const explicitEpisodeEnd = normalizePositiveInt(
assignmentSource?.episodeNumberEnd
?? assignmentSource?.episodeNoEnd
?? null
);
const explicitSpan = normalizePositiveInt(
assignmentSource?.episodeSpan
?? assignmentSource?.episodeCount
?? null
);
let episodeEnd = explicitEpisodeEnd;
if (!episodeEnd && explicitSpan && explicitSpan > 1) {
episodeEnd = episodeStart + explicitSpan - 1;
}
if (episodeEnd && episodeEnd < episodeStart) {
episodeEnd = episodeStart;
}
const normalizedEnd = episodeEnd || episodeStart;
const episodeSpan = Math.max(1, normalizedEnd - episodeStart + 1);
return {
start: episodeStart,
end: normalizedEnd,
span: episodeSpan,
isMulti: normalizedEnd > episodeStart
};
}
function hasExplicitEpisodeAssignmentRange(assignment = null) {
const source = assignment && typeof assignment === 'object' ? assignment : null;
if (!source) {
return false;
}
const hasStart = normalizePositiveInt(
source?.episodeNumberStart
?? source?.episodeNoStart
?? null
) !== null;
const hasEnd = normalizePositiveInt(
source?.episodeNumberEnd
?? source?.episodeNoEnd
?? null
) !== null;
const hasSpan = normalizePositiveInt(
source?.episodeSpan
?? source?.episodeCount
?? null
) !== null;
const hasEpisodeNumber = normalizePositiveInt(source?.episodeNumber ?? null) !== null;
const hasEpisodeIdStart = normalizePositiveInt(source?.episodeIdStart ?? null) !== null;
const hasRangeToken = String(source?.episodeRange || '').trim().length > 0;
return Boolean(hasStart || hasEnd || hasSpan || hasEpisodeNumber || hasEpisodeIdStart || hasRangeToken);
}
function buildEpisodeRangeToken(start, end) {
const startToken = formatTemplateTwoDigit(start);
const endToken = formatTemplateTwoDigit(end);
if (Number(end) > Number(start)) {
return `${startToken}-${endToken}`;
}
return startToken;
}
function buildEpisodePartsToken(episodeRange = null) {
const start = normalizePositiveInt(episodeRange?.start) || 1;
const end = normalizePositiveInt(episodeRange?.end) || start;
const span = Math.max(1, end - start + 1);
if (span <= 1) {
return '1';
}
if (span === 2) {
return '1+2';
}
return `1-${span}`;
}
function normalizeEpisodeTitleTokenForCompare(value) {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/\p{M}+/gu, '')
.replace(/[^a-z0-9]+/g, '')
.trim();
}
function findCommonEpisodeTitlePrefix(values = []) {
const rows = (Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim())
.filter(Boolean);
if (rows.length === 0) {
return '';
}
if (rows.length === 1) {
return rows[0];
}
const tokenRows = rows.map((value) => value.split(/\s+/).filter(Boolean));
const firstTokens = tokenRows[0];
if (firstTokens.length === 0) {
return '';
}
let prefixLength = 0;
for (let index = 0; index < firstTokens.length; index += 1) {
const tokenKey = normalizeEpisodeTitleTokenForCompare(firstTokens[index]);
if (!tokenKey) {
break;
}
const matchesAll = tokenRows.slice(1).every((tokens) => {
const current = tokens[index];
return normalizeEpisodeTitleTokenForCompare(current) === tokenKey;
});
if (!matchesAll) {
break;
}
prefixLength += 1;
}
if (prefixLength <= 0) {
return '';
}
return firstTokens
.slice(0, prefixLength)
.join(' ')
.replace(/[-–—:.,\s]+$/g, '')
.trim();
}
function stripEpisodePartSuffix(value) {
const source = String(value || '').replace(/\s+/g, ' ').trim();
if (!source) {
return '';
}
// Output cleanup for multi-episode labels:
// remove "Teil/Part" markers and pure numeric/roman suffixes in
// parentheses like "(1)" / "(2)".
const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)|\(\s*(?:\d{1,2}|[ivxlcdm]{1,6})\s*\))\s*$/i;
let normalized = source;
let changed = false;
for (let i = 0; i < 2; i += 1) {
const next = normalized.replace(suffixPattern, '').replace(/[-–—:.,\s]+$/g, '').trim();
if (!next || next === normalized) {
break;
}
normalized = next;
changed = true;
}
return changed ? (normalized || source) : source;
}
function normalizeEpisodeTitleForOutput(value, episodeRange = null) {
const source = String(value || '').replace(/\s+/g, ' ').trim();
if (!source) {
return '';
}
if (!Boolean(episodeRange?.isMulti)) {
return source;
}
const strippedSource = stripEpisodePartSuffix(source) || source;
const rawSegments = source
.split(/\s+(?:\+|\/|\||&)\s+/)
.map((part) => String(part || '').trim())
.filter(Boolean);
if (rawSegments.length < 2) {
return strippedSource;
}
const cleanedSegments = rawSegments
.map((part) => stripEpisodePartSuffix(part) || part)
.map((part) => String(part || '').replace(/[-–—:.,\s]+$/g, '').trim())
.filter(Boolean);
if (cleanedSegments.length === 0) {
return strippedSource;
}
const uniqueSegments = [];
const seen = new Set();
for (const segment of cleanedSegments) {
const key = normalizeEpisodeTitleTokenForCompare(segment);
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
uniqueSegments.push(segment);
}
if (uniqueSegments.length === 1) {
return uniqueSegments[0];
}
const allSegmentsLookLikePartTitles = rawSegments.every((segment) => /\b(?:teil|part|pt)\b/i.test(segment));
if (allSegmentsLookLikePartTitles) {
const commonPrefix = findCommonEpisodeTitlePrefix(cleanedSegments);
if (commonPrefix && commonPrefix.length >= 6) {
return commonPrefix;
}
return uniqueSegments[0] || cleanedSegments[0] || strippedSource;
}
return strippedSource;
}
function resolveTitleDurationSeconds(title = null) {
const fromSeconds = Number(title?.durationSeconds || 0);
if (Number.isFinite(fromSeconds) && fromSeconds > 0) {
return fromSeconds;
}
const fromMinutes = Number(title?.durationMinutes || 0);
if (Number.isFinite(fromMinutes) && fromMinutes > 0) {
return fromMinutes * 60;
}
return 0;
}
function estimateSeriesEpisodeSpanForTitle(title = null, allTitles = []) {
const titleDuration = resolveTitleDurationSeconds(title);
if (!Number.isFinite(titleDuration) || titleDuration <= 0) {
return 1;
}
const durations = (Array.isArray(allTitles) ? allTitles : [])
.map((item) => resolveTitleDurationSeconds(item))
.filter((value) => Number.isFinite(value) && value > 0);
if (durations.length === 0) {
return 1;
}
const medianDuration = medianPositiveInt(durations);
if (!medianDuration) {
return 1;
}
const baselinePool = durations.filter((value) => value <= medianDuration * 1.35);
const baselineDuration = medianPositiveInt(baselinePool.length > 0 ? baselinePool : durations) || medianDuration;
if (!baselineDuration) {
return 1;
}
const ratio = titleDuration / baselineDuration;
const roundedSpan = Math.round(ratio);
if (!Number.isFinite(roundedSpan) || roundedSpan < 2 || roundedSpan > 4) {
return 1;
}
const minRatio = roundedSpan === 2 ? 1.55 : roundedSpan * 0.72;
const maxRatio = roundedSpan * 1.32;
if (ratio < minRatio || ratio > maxRatio) {
return 1;
}
return roundedSpan;
}
function resolveEffectiveEpisodeRangeForTitle(assignment = null, title = null, allTitles = [], fallbackEpisodeNumber = 1) {
const baseRange = resolveEpisodeAssignmentRange(assignment, title, fallbackEpisodeNumber);
if (baseRange.isMulti) {
return baseRange;
}
if (hasExplicitEpisodeAssignmentRange(assignment)) {
return baseRange;
}
const spanHint = estimateSeriesEpisodeSpanForTitle(title, allTitles);
if (spanHint <= 1) {
return baseRange;
}
return {
start: baseRange.start,
end: baseRange.start + spanHint - 1,
span: spanHint,
isMulti: true
};
}
function resolveSeriesTitleOrderForPreview(allTitles = [], selectedTitleIds = [], targetTitleId = null) {
const explicitSelectedIds = normalizeTitleIdList(selectedTitleIds);
const fallbackSelectedIds = explicitSelectedIds.length > 0
? explicitSelectedIds
: normalizeTitleIdList(
(Array.isArray(allTitles) ? allTitles : [])
.filter((item) => Boolean(item?.selectedForEncode || item?.encodeInput))
.map((item) => item?.id)
);
let ordered = fallbackSelectedIds;
const normalizedTarget = normalizeTitleId(targetTitleId);
if (normalizedTarget && !ordered.some((id) => Number(id) === Number(normalizedTarget))) {
ordered = [...ordered, Number(normalizedTarget)];
}
if (ordered.length === 0 && normalizedTarget) {
ordered = [Number(normalizedTarget)];
}
return ordered;
}
function resolveEpisodeRangeForTitleFromAssignments({
assignmentByTitle = {},
allTitles = [],
selectedTitleIds = [],
targetTitle = null,
fallbackEpisodeNumber = 1
}) {
const titles = Array.isArray(allTitles) ? allTitles : [];
const titlesById = new Map();
for (const item of titles) {
const id = normalizeTitleId(item?.id);
if (!id) {
continue;
}
titlesById.set(Number(id), item);
}
const targetTitleId = normalizeTitleId(targetTitle?.id || null);
const orderedTitleIds = resolveSeriesTitleOrderForPreview(titles, selectedTitleIds, targetTitleId);
const fallbackStart = normalizePositiveInt(fallbackEpisodeNumber) || 1;
let cursor = fallbackStart;
let targetRange = null;
for (const rawId of orderedTitleIds) {
const currentId = normalizeTitleId(rawId);
if (!currentId) {
continue;
}
const assignment = assignmentByTitle?.[currentId] || assignmentByTitle?.[String(currentId)] || null;
const currentTitle = titlesById.get(Number(currentId)) || null;
const hasExplicitRange = hasExplicitEpisodeAssignmentRange(assignment);
const range = resolveEpisodeAssignmentRange(
assignment,
currentTitle,
cursor,
{ allowTitleEpisodeNumber: hasExplicitRange }
);
const normalizedEnd = normalizePositiveInt(range?.end);
if (normalizedEnd !== null) {
cursor = normalizedEnd + 1;
}
if (targetTitleId && Number(currentId) === Number(targetTitleId)) {
targetRange = range;
break;
}
}
if (!targetRange) {
const assignment = targetTitleId
? (assignmentByTitle?.[targetTitleId] || assignmentByTitle?.[String(targetTitleId)] || null)
: null;
const hasExplicitRange = hasExplicitEpisodeAssignmentRange(assignment);
targetRange = resolveEpisodeAssignmentRange(
assignment,
targetTitle,
cursor,
{ allowTitleEpisodeNumber: hasExplicitRange }
);
}
return targetRange;
}
function templateUsesLeadingZeroForToken(template, token) {
const source = String(template || '').trim();
const normalizedToken = String(token || '').trim();
if (!source || !normalizedToken) {
return false;
}
const pattern = new RegExp(`\\{\\s*0\\s*:\\s*${normalizedToken}\\s*\\}`, 'i');
return pattern.test(source);
}
function formatEpisodeNumberByTemplate(rawValue, template, token = 'episodeNr') {
const numeric = Number(rawValue);
if (!Number.isFinite(numeric) || numeric <= 0) {
return String(rawValue || '').trim() || '0';
}
if (!Number.isInteger(numeric)) {
return String(numeric);
}
const normalized = String(Math.trunc(numeric));
if (templateUsesLeadingZeroForToken(template, token)) {
return normalized.padStart(2, '0');
}
return normalized;
}
function buildFallbackEpisodeTitleForOutput(episodeRange = null, template = '') {
const start = normalizePositiveInt(episodeRange?.start) || 1;
const end = normalizePositiveInt(episodeRange?.end) || start;
if (end > start) {
const startToken = formatEpisodeNumberByTemplate(start, template, 'episodeNr');
const endToken = formatEpisodeNumberByTemplate(end, template, 'episodeNr');
return `Folge ${startToken}-${endToken}`;
}
const token = formatEpisodeNumberByTemplate(start, template, 'episodeNr');
return `Folge ${token}`;
}
function buildDvdSeriesEpisodeOutputPathPreview(
settings,
mediaProfile,
metadata,
title,
assignment = null,
fallbackJobId = null,
allTitles = [],
assignmentByTitle = {},
selectedTitleIds = []
) {
const normalizedSeriesMediaProfile = String(mediaProfile || '').trim().toLowerCase() === 'bluray'
? 'bluray'
: 'dvd';
const seriesRootDir = String(
(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();
if (!seriesRootDir) {
return null;
}
const seasonNumber = normalizePositiveInt(
assignment?.seasonNumber ?? metadata?.seasonNumber ?? title?.seasonNumber ?? 1
) || 1;
const normalizedSelectedIds = normalizeTitleIdList(selectedTitleIds);
const targetTitleId = normalizeTitleId(title?.id);
const fallbackEpisodeNumber = targetTitleId && normalizedSelectedIds.length > 0
? (normalizedSelectedIds.findIndex((id) => Number(id) === Number(targetTitleId)) + 1)
: 1;
const inferredRange = resolveEpisodeRangeForTitleFromAssignments({
assignmentByTitle,
allTitles,
selectedTitleIds: normalizedSelectedIds,
targetTitle: title,
fallbackEpisodeNumber: fallbackEpisodeNumber > 0 ? fallbackEpisodeNumber : 1
});
const episodeRange = resolveEffectiveEpisodeRangeForTitle(
assignment || {},
title,
allTitles,
inferredRange?.start || (fallbackEpisodeNumber > 0 ? fallbackEpisodeNumber : 1)
);
const effectiveEpisodeRange = episodeRange;
const episodeNumber = effectiveEpisodeRange.start;
const episodeRangeToken = buildEpisodeRangeToken(effectiveEpisodeRange.start, effectiveEpisodeRange.end);
const partsToken = buildEpisodePartsToken(effectiveEpisodeRange);
const discNumber = normalizePositiveInt(metadata?.discNumber ?? assignment?.discNumber ?? null);
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(
(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(
(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}';
const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
const template = effectiveEpisodeRange.isMulti ? multiTemplate : singleTemplate;
const fallbackEpisodeTitle = buildFallbackEpisodeTitleForOutput(effectiveEpisodeRange, template);
const episodeTitleRaw = String(
assignment?.episodeTitle
|| title?.episodeTitle
|| fallbackEpisodeTitle
).trim();
const rendered = renderTemplate(template, {
seriesTitle,
seasonNr: seasonNumber,
episodeNr: episodeNumber,
episodeNoStart: effectiveEpisodeRange.start,
episodeNoEnd: effectiveEpisodeRange.end,
episodeNumberStart: effectiveEpisodeRange.start,
episodeNumberEnd: effectiveEpisodeRange.end,
episodeRange: episodeRangeToken,
episodeSpan: effectiveEpisodeRange.span,
parts: partsToken,
episodeTitle: normalizeEpisodeTitleForOutput(
episodeTitleRaw || fallbackEpisodeTitle,
effectiveEpisodeRange
) || fallbackEpisodeTitle,
discNr: discNumber || '',
year,
language
});
const segments = rendered
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/^\/+|\/+$/g, '')
.split('/')
.map((seg) => sanitizeFileName(seg))
.filter(Boolean);
const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
const folderParts = segments.slice(0, -1);
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}`;
}
return `${root}/${baseName}.${ext}`;
}
function resolveProfiledSetting(settings, key, mediaProfile) {
const profileKey = mediaProfile ? `${key}_${mediaProfile}` : null;
if (profileKey && settings?.[profileKey] != null && settings[profileKey] !== '') {
return settings[profileKey];
}
const fallbackProfiles = mediaProfile === 'bluray'
? ['dvd']
: mediaProfile === 'dvd'
? ['bluray']
: [];
for (const fb of fallbackProfiles) {
const fbKey = `${key}_${fb}`;
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
return settings[fbKey];
}
}
return settings?.[key] ?? null;
}
function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId = null) {
const movieDir = String(resolveProfiledSetting(settings, 'movie_dir', mediaProfile) || '').trim();
if (!movieDir) {
return null;
}
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, author, narrator });
const segments = rendered
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/^\/+|\/+$/g, '')
.split('/')
.map((seg) => sanitizeFileName(seg))
.filter(Boolean);
const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
const folderParts = segments.slice(0, -1);
const rawExt = resolveProfiledSetting(settings, 'output_extension', mediaProfile);
const ext = String(rawExt || 'mkv').trim() || 'mkv';
const root = movieDir.replace(/\/+$/g, '');
if (folderParts.length > 0) {
return `${root}/${folderParts.join('/')}/${baseName}.${ext}`;
}
return `${root}/${baseName}.${ext}`;
}
function buildConverterOutputPathPreview(settings, review, metadata, fallbackJobId = null) {
const explicitPath = String(review?.outputPath || review?.outputDir || '').trim();
if (explicitPath) {
return explicitPath;
}
const movieDir = String(settings?.converter_movie_dir || '').trim();
if (!movieDir) {
return null;
}
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
const year = metadata?.year || new Date().getFullYear();
const template = String(settings?.converter_output_template_video || '{title}').trim() || '{title}';
const rendered = renderTemplate(template, { title, year });
const segments = rendered
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/^\/+|\/+$/g, '')
.split('/')
.map((seg) => sanitizeFileName(seg))
.filter(Boolean);
const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
const folderParts = segments.slice(0, -1);
const ext = String(review?.outputFormat || 'mkv').trim() || 'mkv';
const root = movieDir.replace(/\/+$/g, '');
if (folderParts.length > 0) {
return `${root}/${folderParts.join('/')}/${baseName}.${ext}`;
}
return `${root}/${baseName}.${ext}`;
}
export default function PipelineStatusCard({
jobId = null,
pipeline,
onAnalyze,
onReanalyze,
onOpenMetadata,
onReassignOmdb,
onStart,
onRemoveFromQueue,
onRestartEncode,
onRestartReview,
onConfirmReview,
onSelectPlaylist,
onSelectHandBrakeTitle,
onSubmitRawDecision,
onCancel,
onRetry,
onDeleteJob,
onInputsChanged,
jobRow,
isQueued = false,
busy
}) {
const state = pipeline?.state || 'IDLE';
const stateLabel = getStatusLabel(state);
const progress = Number(pipeline?.progress || 0);
const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK';
const explicitJobId = normalizeJobId(jobId);
const contextJobId = normalizeJobId(pipeline?.context?.jobId);
const retryJobId = explicitJobId || contextJobId;
const queueLocked = Boolean(isQueued && retryJobId);
const orphanSource = String(
jobRow?.makemkvInfo?.source
|| pipeline?.context?.source
|| pipeline?.context?.importSource
|| ''
).trim().toLowerCase();
const isOrphanImport = orphanSource === 'orphan_raw_import';
const isOrphanCancelled = isOrphanImport && (state === 'ERROR' || state === 'CANCELLED');
const ripCompleted = Boolean(
Number(jobRow?.rip_successful || jobRow?.ripSuccessful || pipeline?.context?.ripSuccessful || 0) === 1
|| jobRow?.raw_path
|| pipeline?.context?.rawPath
|| jobRow?.makemkvInfo?.rawPath
|| String(jobRow?.makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
);
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
const discTypeLabel = jobMediaProfile === 'bluray' ? 'Blu-ray' : 'DVD';
const isDiscTitleSelectionRequired = Boolean(
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
&& (mediaInfoReview?.titleSelectionRequired || mediaInfoReview?.handBrakeTitleDecisionRequired)
);
const [liveJobLog, setLiveJobLog] = useState('');
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
const [selectedEncodeTitleIds, setSelectedEncodeTitleIds] = useState([]);
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
const [selectedHandBrakeTitleIdsState, setSelectedHandBrakeTitleIdsState] = useState([]);
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
const [episodeAssignmentsByTitle, setEpisodeAssignmentsByTitle] = useState({});
const [selectedEpisodeFillStart, setSelectedEpisodeFillStart] = useState(null);
const [containerUsedEpisodeKeys, setContainerUsedEpisodeKeys] = useState([]);
const [containerSeasonEpisodes, setContainerSeasonEpisodes] = useState([]);
const [settingsMap, setSettingsMap] = useState({});
const [presetDisplayMap, setPresetDisplayMap] = useState({});
const [scriptCatalog, setScriptCatalog] = useState([]);
const [chainCatalog, setChainCatalog] = useState([]);
// Unified ordered lists: [{type: 'script'|'chain', id: number}]
const [preEncodeItems, setPreEncodeItems] = useState([]);
const [postEncodeItems, setPostEncodeItems] = useState([]);
const [userPresets, setUserPresets] = useState([]);
const [selectedUserPresetId, setSelectedUserPresetId] = useState(null);
const [handBrakePresetOptions, setHandBrakePresetOptions] = useState([]);
const [selectedHandBrakePreset, setSelectedHandBrakePreset] = useState('');
const [selectedOutputFormat, setSelectedOutputFormat] = useState('mkv');
const [converterConfigSaving, setConverterConfigSaving] = useState(false);
const [converterConfigError, setConverterConfigError] = useState(null);
const converterConfigLatestRef = useRef(null);
const converterConfigSaveTimeoutRef = useRef(null);
const selectedMetadataEpisodes = useMemo(() => {
const metadataEpisodes = Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0
? selectedMetadata.episodes
: (Array.isArray(containerSeasonEpisodes) ? containerSeasonEpisodes : []);
const rows = Array.isArray(metadataEpisodes) ? metadataEpisodes : [];
const normalizedRows = rows
.map((item) => {
const episodeId = Number(item?.id ?? item?.episodeId ?? 0);
const episodeIdStart = Number(item?.episodeIdStart ?? item?.idStart ?? item?.id ?? item?.episodeId ?? 0);
const episodeIdEnd = Number(item?.episodeIdEnd ?? item?.idEnd ?? 0);
const episodeNumberStart = Number(
item?.episodeNumberStart
?? item?.numberStart
?? item?.episodeNoStart
?? item?.number
?? item?.episodeNumber
?? 0
);
const episodeNumberEnd = Number(
item?.episodeNumberEnd
?? item?.numberEnd
?? item?.episodeNoEnd
?? 0
);
const episodeSpan = Number(item?.episodeSpan ?? item?.episodeCount ?? 0);
const episodeNumber = Number(item?.number ?? item?.episodeNumber ?? episodeNumberStart ?? 0);
const seasonNumber = Number(item?.seasonNumber ?? item?.season ?? 0);
const title = String(item?.name || item?.title || '').trim();
return {
episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null,
episodeIdStart: Number.isFinite(episodeIdStart) && episodeIdStart > 0 ? Math.trunc(episodeIdStart) : null,
episodeIdEnd: Number.isFinite(episodeIdEnd) && episodeIdEnd > 0 ? Math.trunc(episodeIdEnd) : null,
episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null,
episodeNumberStart: Number.isFinite(episodeNumberStart) && episodeNumberStart > 0 ? Math.trunc(episodeNumberStart) : null,
episodeNumberEnd: Number.isFinite(episodeNumberEnd) && episodeNumberEnd > 0 ? Math.trunc(episodeNumberEnd) : null,
episodeSpan: Number.isFinite(episodeSpan) && episodeSpan > 0 ? Math.trunc(episodeSpan) : null,
seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null,
episodeTitle: title || null
};
})
.filter((item) => item.episodeId || item.episodeNumber !== null)
.sort((a, b) => {
const aSeason = Number.isFinite(a.seasonNumber) ? a.seasonNumber : Number.MAX_SAFE_INTEGER;
const bSeason = Number.isFinite(b.seasonNumber) ? b.seasonNumber : Number.MAX_SAFE_INTEGER;
if (aSeason !== bSeason) {
return aSeason - bSeason;
}
const aEpisode = Number.isFinite(a.episodeNumber) ? a.episodeNumber : Number.MAX_SAFE_INTEGER;
const bEpisode = Number.isFinite(b.episodeNumber) ? b.episodeNumber : Number.MAX_SAFE_INTEGER;
if (aEpisode !== bEpisode) {
return aEpisode - bEpisode;
}
const aId = Number.isFinite(a.episodeId) ? a.episodeId : Number.MAX_SAFE_INTEGER;
const bId = Number.isFinite(b.episodeId) ? b.episodeId : Number.MAX_SAFE_INTEGER;
return aId - bId;
});
if (!Array.isArray(containerUsedEpisodeKeys) || containerUsedEpisodeKeys.length === 0) {
return normalizedRows;
}
const usedKeySet = new Set(containerUsedEpisodeKeys);
return normalizedRows.filter((episode) => {
const keys = collectEpisodeReferenceKeys(episode);
if (keys.length === 0) {
return true;
}
return keys.every((key) => !usedKeySet.has(key));
});
}, [selectedMetadata?.episodes, containerSeasonEpisodes, containerUsedEpisodeKeys]);
useEffect(() => {
let cancelled = false;
const loadSettings = async () => {
try {
const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse, userPresetsResponse] = await Promise.allSettled([
api.getSettings(),
api.getHandBrakePresets(),
api.getScripts(),
api.getScriptChains(),
api.getUserPresets(null, { forceRefresh: true })
]);
if (!cancelled) {
const categories = settingsResponse.status === 'fulfilled'
? (settingsResponse.value?.categories || [])
: [];
setSettingsMap(buildSettingsMap(categories));
const presetOptions = presetsResponse.status === 'fulfilled'
? (presetsResponse.value?.options || [])
: [];
setPresetDisplayMap(buildPresetDisplayMap(presetOptions));
setHandBrakePresetOptions(Array.isArray(presetOptions) ? presetOptions : []);
const scripts = scriptsResponse.status === 'fulfilled'
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
: [];
setScriptCatalog(
scripts.map((item) => ({
id: item?.id,
name: item?.name
}))
);
const chains = chainsResponse.status === 'fulfilled'
? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : [])
: [];
setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name })));
const allUserPresets = userPresetsResponse.status === 'fulfilled'
? (Array.isArray(userPresetsResponse.value?.presets) ? userPresetsResponse.value.presets : [])
: [];
setUserPresets(allUserPresets);
}
} catch (_error) {
if (!cancelled) {
setSettingsMap({});
setPresetDisplayMap({});
setScriptCatalog([]);
setChainCatalog([]);
setUserPresets([]);
setHandBrakePresetOptions([]);
}
}
};
loadSettings();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
const selectedFromReview = normalizeTitleIdList([
...(Array.isArray(mediaInfoReview?.selectedTitleIds) ? mediaInfoReview.selectedTitleIds : []),
...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
|| (isSeriesVideoByMetadata && reviewTitles.length > 1)
)
&& reviewTitles.length > 0
);
const effectiveSelected = defaultAllTitles
? normalizeTitleIdList(reviewTitles.map((title) => title?.id))
: selectedFromReview;
setSelectedEncodeTitleIds(effectiveSelected);
setSelectedEncodeTitleId(effectiveSelected[0] || fromReview || null);
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
const initialEpisodeAssignments = {};
const allReviewTitles = Array.isArray(reviewTitles) ? reviewTitles : [];
for (const title of reviewTitles) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
const episodeId = Number(title?.episodeId || 0);
const episodeNumber = Number(title?.episodeNumber || 0);
const seasonNumber = Number(title?.seasonNumber || 0);
const rawEpisodeTitle = String(title?.episodeTitle || '').trim();
const titleEpisodeRange = resolveEffectiveEpisodeRangeForTitle(
title,
title,
allReviewTitles,
titleId
);
const episodeTitle = rawEpisodeTitle
? normalizeEpisodeTitleForOutput(rawEpisodeTitle, titleEpisodeRange)
: '';
if (!episodeTitle && !episodeId && !episodeNumber && !seasonNumber) {
continue;
}
initialEpisodeAssignments[titleId] = {
episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null,
episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null,
seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null,
episodeTitle: episodeTitle || null
};
}
setEpisodeAssignmentsByTitle(initialEpisodeAssignments);
setSelectedEpisodeFillStart(null);
const normChain = (raw) => (Array.isArray(raw) ? raw : []).map(Number).filter((id) => Number.isFinite(id) && id > 0);
setPreEncodeItems([
...normalizeScriptIdList(mediaInfoReview?.preEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
...normChain(mediaInfoReview?.preEncodeChainIds).map((id) => ({ type: 'chain', id }))
]);
setPostEncodeItems([
...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id }))
]);
const nextOutputFormat = String(mediaInfoReview?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv';
setSelectedOutputFormat(nextOutputFormat);
const userPresetId = Number(mediaInfoReview?.userPreset?.id);
if (Number.isFinite(userPresetId) && userPresetId > 0) {
setSelectedUserPresetId(Math.trunc(userPresetId));
setSelectedHandBrakePreset('');
} else {
setSelectedUserPresetId(null);
const hbPresetName = String(mediaInfoReview?.userPreset?.handbrakePreset || '').trim();
setSelectedHandBrakePreset(hbPresetName);
}
}, [
mediaInfoReview?.encodeInputTitleId,
mediaInfoReview?.generatedAt,
mediaInfoReview?.reviewConfirmedAt,
mediaInfoReview?.prefilledFromPreviousRunAt,
mediaInfoReview?.outputFormat,
mediaInfoReview?.userPreset?.id,
mediaInfoReview?.userPreset?.handbrakePreset,
selectedMetadata?.seasonNumber,
selectedMetadata?.episodes,
selectedMetadata?.metadataProvider,
jobMediaProfile,
retryJobId
]);
useEffect(() => {
if (mediaInfoReview) {
return;
}
setSelectedEncodeTitleIds([]);
setSelectedEncodeTitleId(null);
setTrackSelectionByTitle({});
setEpisodeAssignmentsByTitle({});
setSelectedEpisodeFillStart(null);
setPreEncodeItems([]);
setPostEncodeItems([]);
setSelectedUserPresetId(null);
setSelectedHandBrakePreset('');
}, [mediaInfoReview, retryJobId]);
useEffect(() => {
const normalizedSelectedIds = normalizeTitleIdList(selectedEncodeTitleIds);
const primaryTitleId = normalizeTitleId(selectedEncodeTitleId);
if (normalizedSelectedIds.length === 0) {
if (primaryTitleId !== null) {
setSelectedEncodeTitleId(null);
}
return;
}
if (!primaryTitleId || !normalizedSelectedIds.includes(primaryTitleId)) {
setSelectedEncodeTitleId(normalizedSelectedIds[0]);
}
setTrackSelectionByTitle((prev) => {
const defaults = buildDefaultTrackSelection(mediaInfoReview);
let next = prev;
let changed = false;
for (const titleId of normalizedSelectedIds) {
if (next?.[titleId] || next?.[String(titleId)]) {
continue;
}
const fallback = defaults[titleId] || {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
if (!changed) {
next = { ...(prev || {}) };
changed = true;
}
next[titleId] = fallback;
}
return changed ? next : prev;
});
}, [selectedEncodeTitleId, selectedEncodeTitleIds, mediaInfoReview?.generatedAt]);
const resolveEpisodeFillContext = () => {
const episodeRows = Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [];
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
const selectedTitleIds = normalizeTitleIdList([
...normalizeTitleIdList(selectedEncodeTitleIds),
...(Array.isArray(mediaInfoReview?.selectedTitleIds) ? mediaInfoReview.selectedTitleIds : []),
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
]);
const orderedTitles = reviewTitles
.map((title) => ({
id: normalizeTitleId(title?.id),
title
}))
.filter((row) => row.id !== null);
const orderedSelectedTitleRows = selectedTitleIds.length > 0
? orderedTitles.filter((row) => selectedTitleIds.includes(row.id))
: [];
const fallbackSelectedTitleRows = orderedSelectedTitleRows.length > 0
? orderedSelectedTitleRows
: orderedTitles.filter((row) => selectedTitleIds.includes(row.id));
return {
episodeRows,
reviewTitles,
selectedTitleIds,
fallbackSelectedTitleRows
};
};
const buildGroupedEpisodeUnits = (episodeRows = [], selectedTitleRows = [], reviewTitles = []) => {
const rows = Array.isArray(episodeRows) ? episodeRows : [];
const selectedRows = Array.isArray(selectedTitleRows) ? selectedTitleRows : [];
const allReviewTitles = Array.isArray(reviewTitles) ? reviewTitles : [];
const normalizeEpisodeStart = (episode) => normalizePositiveInt(
episode?.episodeNumberStart
?? episode?.episodeNoStart
?? episode?.episodeNumber
?? null
);
const normalizeEpisodeEnd = (episode, fallbackStart = null) => {
const start = normalizePositiveInt(fallbackStart);
const explicitEnd = normalizePositiveInt(
episode?.episodeNumberEnd
?? episode?.episodeNoEnd
?? null
);
if (explicitEnd && (!start || explicitEnd >= start)) {
return explicitEnd;
}
const explicitSpan = normalizePositiveInt(
episode?.episodeSpan
?? episode?.episodeCount
?? null
);
if (start && explicitSpan && explicitSpan > 1) {
return start + explicitSpan - 1;
}
return start;
};
const titleAnchorsByEpisodeIndex = new Map();
if (rows.length > 0) {
let episodeAnchorCursor = 0;
for (const selectedTitleRow of selectedRows) {
if (episodeAnchorCursor >= rows.length) {
break;
}
const explicitSpan = normalizePositiveInt(
selectedTitleRow?.title?.episodeSpan
?? selectedTitleRow?.title?.episodeCount
?? null
);
const estimatedSpan = estimateSeriesEpisodeSpanForTitle(selectedTitleRow?.title, allReviewTitles);
const span = Math.max(1, explicitSpan || 1, estimatedSpan || 1);
const safeSpan = Math.max(1, Math.min(span, rows.length - episodeAnchorCursor));
titleAnchorsByEpisodeIndex.set(episodeAnchorCursor, { span: safeSpan });
episodeAnchorCursor += safeSpan;
}
}
const groupedEpisodeUnits = [];
let sourceEpisodeIndex = 0;
while (sourceEpisodeIndex < rows.length) {
const startRow = rows[sourceEpisodeIndex];
const rangeStart = normalizeEpisodeStart(startRow)
?? normalizePositiveInt(startRow?.episodeNumber)
?? null;
const explicitRangeEnd = normalizeEpisodeEnd(startRow, rangeStart);
const explicitRangeSpan = (
rangeStart
&& explicitRangeEnd
&& explicitRangeEnd >= rangeStart
)
? Math.max(1, Math.trunc(explicitRangeEnd - rangeStart + 1))
: 1;
if (explicitRangeSpan > 1) {
groupedEpisodeUnits.push({
startRow,
endRow: startRow,
rangeStart,
rangeEnd: explicitRangeEnd || rangeStart,
consumedRows: 1
});
sourceEpisodeIndex += 1;
continue;
}
const anchor = titleAnchorsByEpisodeIndex.get(sourceEpisodeIndex) || null;
const anchorSpan = normalizePositiveInt(anchor?.span) || 1;
if (anchorSpan > 1) {
const endIndex = Math.min(rows.length - 1, sourceEpisodeIndex + anchorSpan - 1);
const endRow = rows[endIndex] || startRow;
const endRowStart = normalizeEpisodeStart(endRow)
?? normalizePositiveInt(endRow?.episodeNumber)
?? null;
const rangeEnd = (
rangeStart
&& endRowStart
&& endRowStart >= rangeStart
)
? endRowStart
: (rangeStart ? (rangeStart + anchorSpan - 1) : endRowStart);
groupedEpisodeUnits.push({
startRow,
endRow,
rangeStart,
rangeEnd,
consumedRows: anchorSpan
});
sourceEpisodeIndex += anchorSpan;
continue;
}
groupedEpisodeUnits.push({
startRow,
endRow: startRow,
rangeStart,
rangeEnd: rangeStart,
consumedRows: 1
});
sourceEpisodeIndex += 1;
}
return groupedEpisodeUnits;
};
const applyEpisodeFillFrom = (startEpisodeRef = null) => {
const { episodeRows, reviewTitles, selectedTitleIds, fallbackSelectedTitleRows } = resolveEpisodeFillContext();
if (episodeRows.length === 0 || selectedTitleIds.length === 0) {
return;
}
if (fallbackSelectedTitleRows.length === 0) {
return;
}
const rawFillRef = String(startEpisodeRef || '').trim();
const normalizedRefMatch = rawFillRef.match(/^fill:([^:]+):title:\d+$/i);
const normalizedRef = normalizedRefMatch && normalizedRefMatch[1]
? String(normalizedRefMatch[1]).trim()
: rawFillRef;
const groupedEpisodeUnits = buildGroupedEpisodeUnits(
episodeRows,
fallbackSelectedTitleRows,
reviewTitles
);
let startUnitIndex = 0;
if (normalizedRef) {
const foundUnitIndex = groupedEpisodeUnits.findIndex((unit) =>
String(unit?.startRow?.episodeIdStart || '') === normalizedRef
|| String(unit?.startRow?.episodeId || '') === normalizedRef
|| String(unit?.startRow?.episodeNumberStart || '') === normalizedRef
|| String(unit?.startRow?.episodeNumber || '') === normalizedRef
);
if (foundUnitIndex >= 0) {
startUnitIndex = foundUnitIndex;
}
}
const nextAssignments = {};
let unitCursor = startUnitIndex;
fallbackSelectedTitleRows.forEach((row) => {
const titleId = row.id;
const unit = groupedEpisodeUnits[unitCursor];
if (!unit || !titleId) {
return;
}
const startRow = unit.startRow || null;
const endRow = unit.endRow || startRow;
const rangeStart = normalizePositiveInt(unit?.rangeStart)
?? normalizeEpisodeStart(startRow)
?? normalizePositiveInt(startRow?.episodeNumber)
?? null;
let rangeEnd = normalizePositiveInt(unit?.rangeEnd)
?? normalizeEpisodeStart(endRow)
?? normalizePositiveInt(endRow?.episodeNumber)
?? rangeStart;
if (rangeStart && rangeEnd && rangeEnd < rangeStart) {
rangeEnd = rangeStart;
}
const normalizedSpan = (
rangeStart
&& rangeEnd
&& rangeEnd >= rangeStart
)
? Math.max(1, Math.trunc(rangeEnd - rangeStart + 1))
: Math.max(1, normalizePositiveInt(unit?.consumedRows) || 1);
const rangeToken = buildEpisodeRangeToken(
rangeStart,
rangeEnd
);
const assignmentRange = {
start: rangeStart,
end: rangeEnd,
span: normalizedSpan,
isMulti: normalizedSpan > 1
};
const episodeTitleRaw = String(startRow?.episodeTitle || '').trim();
const endEpisodeTitleRaw = String(endRow?.episodeTitle || '').trim();
const titleSource = episodeTitleRaw || endEpisodeTitleRaw;
const mergedTitle = titleSource
? normalizeEpisodeTitleForOutput(titleSource, assignmentRange)
: null;
const episodeIdStart = normalizePositiveInt(
startRow?.episodeIdStart
?? startRow?.episodeId
?? null
);
const endRowEpisodeId = normalizePositiveInt(
endRow?.episodeIdStart
?? endRow?.episodeId
?? null
);
const explicitEpisodeIdEnd = normalizePositiveInt(startRow?.episodeIdEnd ?? null);
const episodeIdEnd = endRowEpisodeId || explicitEpisodeIdEnd || episodeIdStart || null;
nextAssignments[titleId] = {
episodeId: episodeIdStart,
episodeNumber: rangeStart,
seasonNumber: startRow?.seasonNumber ?? null,
episodeTitle: mergedTitle,
episodeIdStart: episodeIdStart,
episodeIdEnd,
episodeNumberStart: rangeStart,
episodeNumberEnd: rangeEnd,
episodeSpan: normalizedSpan,
episodeRange: rangeToken,
episodeTitleStart: episodeTitleRaw || null,
episodeTitleEnd: endEpisodeTitleRaw || episodeTitleRaw || null
};
unitCursor += 1;
});
setEpisodeAssignmentsByTitle((prev) => ({
...(prev && typeof prev === 'object' ? prev : {}),
...nextAssignments
}));
};
const episodeFillOptions = useMemo(() => {
const { episodeRows, reviewTitles, fallbackSelectedTitleRows } = resolveEpisodeFillContext();
if (!Array.isArray(episodeRows) || episodeRows.length === 0) {
return [];
}
const groupedUnits = buildGroupedEpisodeUnits(episodeRows, fallbackSelectedTitleRows, reviewTitles);
return groupedUnits
.map((unit) => {
const startRow = unit?.startRow || null;
const value = startRow?.episodeIdStart
|| startRow?.episodeId
|| startRow?.episodeNumberStart
|| startRow?.episodeNumber
|| null;
if (!value) {
return null;
}
return {
value: String(value),
startRef: String(value)
};
})
.filter(Boolean);
}, [selectedMetadataEpisodes, selectedEncodeTitleIds, mediaInfoReview?.titles, mediaInfoReview?.selectedTitleIds]);
useEffect(() => {
if (!Array.isArray(selectedMetadataEpisodes) || selectedMetadataEpisodes.length === 0) {
return;
}
if (!Array.isArray(episodeFillOptions) || episodeFillOptions.length === 0) {
return;
}
const normalizedSelection = String(selectedEpisodeFillStart || '').trim();
const hasValidSelection = Boolean(
normalizedSelection
&& episodeFillOptions.some((option) => String(option?.value || '').trim() === normalizedSelection)
);
const defaultStartRef = hasValidSelection
? normalizedSelection
: (episodeFillOptions[0]?.value || episodeFillOptions[0]?.startRef || null);
if (!defaultStartRef) {
return;
}
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,
selectedEpisodeFillStart
]);
const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired);
const hasSelectedEncodeTitle = normalizeTitleIdList(selectedEncodeTitleIds).length > 0;
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
const hasSelectedPreset = Boolean(selectedUserPresetId) || Boolean(trimmedHandBrakePreset);
const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video';
const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio';
const requiresPresetSelection = showConverterConfig;
// Filter user presets by job media profile ('all' presets always shown)
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
const presetMediaType = normalizeMediaProfile(p?.mediaType) || 'all';
if (!jobMediaProfile) {
return true;
}
// Converter jobs may reuse any user preset; do not restrict by preset media type here.
if (jobMediaProfile === 'converter') {
return true;
}
return presetMediaType === 'all' || presetMediaType === jobMediaProfile;
});
const effectiveUserPresets = filteredUserPresets;
const canStartReadyJob = (isPreRipReview
? Boolean(retryJobId)
: Boolean(retryJobId && encodeInputPath))
&& (!requiresPresetSelection || hasSelectedPreset);
const canRestartEncodeFromLastSettings = Boolean(
(state === 'ERROR' || state === 'CANCELLED')
&& retryJobId
&& pipeline?.context?.canRestartEncodeFromLastSettings
);
const canCancelReview = Boolean(
!running
&& retryJobId
&& (state === 'READY_TO_ENCODE' || state === 'WAITING_FOR_USER_DECISION')
);
const canRestartReviewFromRaw = Boolean(
retryJobId
&& !running
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
);
const allowConverterConfigEdit = showConverterConfig && !queueLocked && state === 'READY_TO_ENCODE';
const converterConfigPayload = useMemo(() => {
if (!showConverterConfig) {
return null;
}
let userPreset = null;
if (selectedUserPresetId) {
const preset = (Array.isArray(userPresets) ? userPresets : [])
.find((p) => Number(p?.id) === Number(selectedUserPresetId));
userPreset = preset
? { id: preset.id, handbrakePreset: preset.handbrake_preset || null, extraArgs: preset.extra_args || '' }
: { id: selectedUserPresetId };
} else if (trimmedHandBrakePreset) {
userPreset = { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' };
}
return {
converterMediaType,
outputFormat: selectedOutputFormat,
userPreset
};
}, [
showConverterConfig,
converterMediaType,
selectedOutputFormat,
selectedUserPresetId,
trimmedHandBrakePreset,
userPresets
]);
const converterConfigSerialized = useMemo(
() => (converterConfigPayload ? JSON.stringify(converterConfigPayload) : null),
[converterConfigPayload]
);
useEffect(() => {
converterConfigLatestRef.current = null;
setConverterConfigError(null);
setConverterConfigSaving(false);
}, [retryJobId]);
useEffect(() => {
if (!allowConverterConfigEdit || !converterConfigPayload || !retryJobId) {
return;
}
if (converterConfigLatestRef.current === null) {
converterConfigLatestRef.current = converterConfigSerialized;
return;
}
if (converterConfigSerialized === converterConfigLatestRef.current) {
return;
}
if (converterConfigSaveTimeoutRef.current) {
clearTimeout(converterConfigSaveTimeoutRef.current);
}
converterConfigSaveTimeoutRef.current = setTimeout(async () => {
setConverterConfigSaving(true);
setConverterConfigError(null);
try {
await api.converterUpdateJobConfig(retryJobId, converterConfigPayload);
converterConfigLatestRef.current = converterConfigSerialized;
onInputsChanged?.(retryJobId, converterConfigPayload);
} catch (error) {
setConverterConfigError(error?.message || 'Konfiguration konnte nicht gespeichert werden.');
} finally {
setConverterConfigSaving(false);
}
}, 650);
return () => {
if (converterConfigSaveTimeoutRef.current) {
clearTimeout(converterConfigSaveTimeoutRef.current);
converterConfigSaveTimeoutRef.current = null;
}
};
}, [
allowConverterConfigEdit,
converterConfigPayload,
converterConfigSerialized,
retryJobId,
onInputsChanged
]);
const waitingPlaylistRows = useMemo(() => {
const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
? playlistAnalysis.evaluatedCandidates
: [];
const rows = evaluated.length > 0
? evaluated
: (Array.isArray(pipeline?.context?.playlistCandidates) ? pipeline.context.playlistCandidates : []);
const normalized = rows
.map((item) => {
const playlistId = normalizePlaylistId(item?.playlistId || item?.playlistFile || item);
if (!playlistId) {
return null;
}
const playlistFile = `${playlistId}.mpls`;
const score = Number(item?.score);
const sequenceCoherence = Number(
item?.structuralMetrics?.sequenceCoherence ?? item?.sequenceCoherence
);
const handBrakeTitleId = Number(item?.handBrakeTitleId);
const durationSecondsRaw = Number(item?.durationSeconds ?? item?.duration ?? 0);
const durationSeconds = Number.isFinite(durationSecondsRaw) && durationSecondsRaw > 0
? Math.trunc(durationSecondsRaw)
: 0;
const durationLabelRaw = String(item?.durationLabel || '').trim();
const durationLabel = durationLabelRaw || formatDurationClock(durationSeconds);
return {
playlistId,
playlistFile,
titleId: Number.isFinite(Number(item?.titleId)) ? Number(item.titleId) : null,
durationSeconds,
durationLabel: durationLabel || null,
score: Number.isFinite(score) ? score : null,
evaluationLabel: item?.evaluationLabel || null,
segmentCommand: item?.segmentCommand
|| `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
segmentFiles: Array.isArray(item?.segmentFiles) ? item.segmentFiles : [],
sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null,
recommended: Boolean(item?.recommended),
handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0
? Math.trunc(handBrakeTitleId)
: null,
audioSummary: item?.audioSummary || null,
audioTrackPreview: Array.isArray(item?.audioTrackPreview) ? item.audioTrackPreview : []
};
})
.filter(Boolean);
const dedup = [];
const seen = new Set();
for (const row of normalized) {
if (seen.has(row.playlistId)) {
continue;
}
seen.add(row.playlistId);
dedup.push(row);
}
return dedup;
}, [playlistAnalysis, pipeline?.context?.playlistCandidates]);
const handBrakeTitleDecisionRequired = state === 'WAITING_FOR_USER_DECISION'
&& Boolean(pipeline?.context?.handBrakeTitleDecisionRequired);
const handBrakeDecisionMode = String(pipeline?.context?.handBrakeDecisionMode || '').trim().toLowerCase();
const isSeriesPlayAllDecisionMode = handBrakeDecisionMode === 'series_playall_or_double';
const handBrakeDecisionSummary = pipeline?.context?.handBrakeDecisionSummary
&& typeof pipeline.context.handBrakeDecisionSummary === 'object'
? pipeline.context.handBrakeDecisionSummary
: null;
const ambiguousLongestTitleId = normalizeTitleId(handBrakeDecisionSummary?.longestCandidateTitleId);
const handBrakeDecisionCandidateTitleIds = useMemo(() => normalizeTitleIdList(
(Array.isArray(pipeline?.context?.handBrakeTitleCandidates)
? pipeline.context.handBrakeTitleCandidates
: []
).map((item) => item?.handBrakeTitleId)
), [pipeline?.context?.handBrakeTitleCandidates]);
const handBrakeDecisionSelectableTitleIds = useMemo(() => {
const explicit = normalizeTitleIdList(pipeline?.context?.handBrakeDecisionSelectableTitleIds);
if (explicit.length > 0) {
return explicit;
}
if (isSeriesPlayAllDecisionMode && ambiguousLongestTitleId) {
return [ambiguousLongestTitleId];
}
return handBrakeDecisionCandidateTitleIds;
}, [
isSeriesPlayAllDecisionMode,
ambiguousLongestTitleId,
handBrakeDecisionCandidateTitleIds,
pipeline?.context?.handBrakeDecisionSelectableTitleIds
]);
const handBrakeDecisionSelectableTitleIdSet = useMemo(
() => new Set(handBrakeDecisionSelectableTitleIds.map((id) => Number(id))),
[handBrakeDecisionSelectableTitleIds]
);
const waitingHandBrakeTitleRows = useMemo(() => {
const rawCandidates = Array.isArray(pipeline?.context?.handBrakeTitleCandidates)
? pipeline.context.handBrakeTitleCandidates
: [];
const rawAllTitles = isSeriesPlayAllDecisionMode && Array.isArray(pipeline?.context?.handBrakeAllTitles)
? pipeline.context.handBrakeAllTitles
: [];
const sourceRows = rawAllTitles.length > 0 ? rawAllTitles : rawCandidates;
const candidateIdSet = new Set(
normalizeTitleIdList(rawCandidates.map((item) => item?.handBrakeTitleId)).map((id) => Number(id))
);
return sourceRows
.map((item) => {
const titleId = normalizeTitleId(item?.handBrakeTitleId);
if (!titleId) {
return null;
}
const durationSeconds = Number(item?.durationSeconds || 0);
const minutes = Math.floor(durationSeconds / 60);
const seconds = durationSeconds % 60;
const durationLabel = `${minutes}:${String(seconds).padStart(2, '0')} min`;
return {
handBrakeTitleId: Math.trunc(titleId),
durationSeconds,
durationLabel,
durationMinutes: Number(item?.durationMinutes || 0),
audioTrackCount: Number(item?.audioTrackCount || 0),
subtitleTrackCount: Number(item?.subtitleTrackCount || 0),
sizeBytes: Number(item?.sizeBytes || 0),
decisionCandidate: candidateIdSet.has(Number(titleId))
};
})
.filter(Boolean)
.sort((a, b) => a.handBrakeTitleId - b.handBrakeTitleId);
}, [
isSeriesPlayAllDecisionMode,
pipeline?.context?.handBrakeAllTitles,
pipeline?.context?.handBrakeTitleCandidates
]);
const visibleWaitingHandBrakeTitleRows = useMemo(() => {
if (!isSeriesPlayAllDecisionMode) {
return waitingHandBrakeTitleRows;
}
return waitingHandBrakeTitleRows.filter((row) => row.decisionCandidate);
}, [isSeriesPlayAllDecisionMode, waitingHandBrakeTitleRows]);
useEffect(() => {
if (!handBrakeTitleDecisionRequired) {
setSelectedHandBrakeTitleIdsState([]);
return;
}
const contextSelectedIds = Array.isArray(pipeline?.context?.selectedHandBrakeTitleIds)
? pipeline.context.selectedHandBrakeTitleIds
: [];
const normalizedContextIds = contextSelectedIds
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value));
if (normalizedContextIds.length > 0) {
setSelectedHandBrakeTitleIdsState(Array.from(new Set(normalizedContextIds)));
return;
}
const singleContextId = Number(pipeline?.context?.selectedHandBrakeTitleId || 0);
if (Number.isFinite(singleContextId) && singleContextId > 0) {
setSelectedHandBrakeTitleIdsState([Math.trunc(singleContextId)]);
return;
}
if (isSeriesPlayAllDecisionMode) {
const lockedDefaultIds = waitingHandBrakeTitleRows
.filter((row) => row.decisionCandidate && !handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId)))
.map((row) => row.handBrakeTitleId);
if (lockedDefaultIds.length > 0) {
setSelectedHandBrakeTitleIdsState(normalizeTitleIdList(lockedDefaultIds));
return;
}
}
const firstSelectable = waitingHandBrakeTitleRows.find((row) => (
!isSeriesPlayAllDecisionMode || handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId))
))?.handBrakeTitleId || null;
setSelectedHandBrakeTitleIdsState(firstSelectable ? [firstSelectable] : []);
}, [
handBrakeTitleDecisionRequired,
isSeriesPlayAllDecisionMode,
retryJobId,
handBrakeDecisionSelectableTitleIdSet,
waitingHandBrakeTitleRows,
pipeline?.context?.selectedHandBrakeTitleId,
pipeline?.context?.selectedHandBrakeTitleIds
]);
useEffect(() => {
if (state !== 'WAITING_FOR_USER_DECISION') {
setSelectedPlaylistId(null);
return;
}
const current = normalizePlaylistId(pipeline?.context?.selectedPlaylist);
if (current) {
setSelectedPlaylistId(current);
return;
}
const recommendedFromRows = waitingPlaylistRows.find((item) => item.recommended)?.playlistId || null;
const recommendedFromAnalysis = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId);
const fallback = waitingPlaylistRows[0]?.playlistId || null;
setSelectedPlaylistId(recommendedFromRows || recommendedFromAnalysis || fallback);
}, [
state,
retryJobId,
waitingPlaylistRows,
playlistAnalysis?.recommendation?.playlistId,
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('');
return undefined;
}
let cancelled = false;
const refresh = async () => {
try {
const response = await api.getJob(retryJobId, { includeLiveLog: true, lite: true });
if (!cancelled) {
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
}
};
void refresh();
const interval = setInterval(refresh, 2500);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [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' && jobMediaProfile !== 'bluray') {
return false;
}
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;
const loadContainerUsedEpisodes = async () => {
if (!isSeriesDvdReview || !retryJobId) {
if (!cancelled) {
setContainerUsedEpisodeKeys([]);
setContainerSeasonEpisodes([]);
}
return;
}
try {
const currentResponse = await api.getJob(retryJobId, { lite: true, forceRefresh: true });
if (cancelled) {
return;
}
const currentJob = currentResponse?.job && typeof currentResponse.job === 'object'
? currentResponse.job
: null;
const currentJobId = normalizeJobId(currentJob?.id || retryJobId);
const normalizedCurrentKind = String(currentJob?.job_kind || '').trim().toLowerCase();
const containerJobId = normalizedCurrentKind === 'dvd_series_container'
? normalizeJobId(currentJob?.id)
: normalizeJobId(currentJob?.parent_job_id);
if (!containerJobId) {
if (!cancelled) {
setContainerUsedEpisodeKeys([]);
setContainerSeasonEpisodes([]);
}
return;
}
const containerResponse = await api.getJob(containerJobId, { lite: true, forceRefresh: true });
if (cancelled) {
return;
}
const containerJob = containerResponse?.job && typeof containerResponse.job === 'object'
? containerResponse.job
: null;
const children = Array.isArray(containerJob?.children) ? containerJob.children : [];
const usedKeys = new Set();
const extractEpisodesFromJob = (jobRow) => {
const mkInfo = jobRow?.makemkvInfo && typeof jobRow.makemkvInfo === 'object'
? jobRow.makemkvInfo
: {};
const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object'
? mkInfo.analyzeContext
: {};
const selectedFromAnalyze = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: {};
const selectedFromRoot = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object'
? mkInfo.selectedMetadata
: {};
const episodes = Array.isArray(selectedFromAnalyze?.episodes)
? selectedFromAnalyze.episodes
: (Array.isArray(selectedFromRoot?.episodes) ? selectedFromRoot.episodes : []);
return Array.isArray(episodes) ? episodes : [];
};
let fallbackEpisodes = extractEpisodesFromJob(containerJob);
for (const child of children) {
const childId = normalizeJobId(child?.id);
if (currentJobId && childId && currentJobId === childId) {
continue;
}
if (isSeriesBatchEpisodePlan(child?.encodePlan)) {
continue;
}
const refs = collectEpisodeReferencesFromPlan(child?.encodePlan);
for (const ref of refs) {
const keys = collectEpisodeReferenceKeys(ref);
for (const key of keys) {
usedKeys.add(key);
}
}
const batchEpisodes = Array.isArray(child?.handbrakeInfo?.seriesBatch?.episodes)
? child.handbrakeInfo.seriesBatch.episodes
: [];
for (const episode of batchEpisodes) {
const keys = collectEpisodeReferenceKeys({
episodeId: episode?.episodeId,
episodeNumber: episode?.episodeNumber,
seasonNumber: episode?.seasonNumber
});
for (const key of keys) {
usedKeys.add(key);
}
}
if ((!Array.isArray(fallbackEpisodes) || fallbackEpisodes.length === 0)) {
const childEpisodes = extractEpisodesFromJob(child);
if (Array.isArray(childEpisodes) && childEpisodes.length > 0) {
fallbackEpisodes = childEpisodes;
}
}
}
if (!cancelled) {
setContainerUsedEpisodeKeys(Array.from(usedKeys));
setContainerSeasonEpisodes(Array.isArray(fallbackEpisodes) ? fallbackEpisodes : []);
}
} catch (_error) {
if (!cancelled) {
setContainerUsedEpisodeKeys([]);
setContainerSeasonEpisodes([]);
}
}
};
void loadContainerUsedEpisodes();
return () => {
cancelled = true;
};
}, [
isSeriesDvdReview,
retryJobId,
mediaInfoReview?.generatedAt,
mediaInfoReview?.reviewConfirmedAt,
selectedMetadata?.tmdbId,
selectedMetadata?.seasonNumber
]);
const seriesBatchContext = useMemo(() => {
const raw = pipeline?.context?.seriesBatch;
if (!raw || typeof raw !== 'object') {
return null;
}
const rawChildren = Array.isArray(raw?.children) ? raw.children : [];
const children = rawChildren.map((child, index) => {
const status = normalizeSeriesBatchStatus(child?.status);
return {
jobId: normalizeJobId(child?.jobId),
title: String(child?.title || '').trim() || `Episode #${index + 1}`,
status,
progress: normalizeSeriesBatchProgress(child?.progress, status === 'FINISHED' ? 100 : 0),
eta: child?.eta ?? null,
episodeIndex: normalizePositiveInt(child?.episodeIndex),
titleId: normalizeTitleId(child?.titleId)
};
});
const totalCountRaw = Number(raw?.totalCount || 0);
const totalCount = Number.isFinite(totalCountRaw) && totalCountRaw > 0
? Math.trunc(totalCountRaw)
: children.length;
const finishedCountRaw = Number(raw?.finishedCount || 0);
const cancelledCountRaw = Number(raw?.cancelledCount || 0);
const errorCountRaw = Number(raw?.errorCount || 0);
const runningCountRaw = Number(raw?.runningCount || 0);
const finishedCount = Number.isFinite(finishedCountRaw) ? Math.max(0, Math.trunc(finishedCountRaw)) : 0;
const cancelledCount = Number.isFinite(cancelledCountRaw) ? Math.max(0, Math.trunc(cancelledCountRaw)) : 0;
const errorCount = Number.isFinite(errorCountRaw) ? Math.max(0, Math.trunc(errorCountRaw)) : 0;
const runningCount = Number.isFinite(runningCountRaw) ? Math.max(0, Math.trunc(runningCountRaw)) : 0;
const aggregateProgress = totalCount > 0
? (
children.reduce((sum, child) => {
const status = normalizeSeriesBatchStatus(child?.status);
if (status === 'FINISHED') {
return sum + 100;
}
return sum + normalizeSeriesBatchProgress(child?.progress, 0);
}, 0) / totalCount
)
: 0;
const overallProgress = normalizeSeriesBatchProgress(raw?.overallProgress, aggregateProgress);
return {
totalCount,
finishedCount,
cancelledCount,
errorCount,
runningCount,
overallProgress,
children
};
}, [pipeline?.context?.seriesBatch]);
const hasSeriesBatchProgress = Boolean(
seriesBatchContext
&& (seriesBatchContext.totalCount > 0 || seriesBatchContext.children.length > 0)
);
const runningSeriesEpisode = hasSeriesBatchProgress
? (seriesBatchContext.children.find((child) => normalizeSeriesBatchStatus(child?.status) === 'RUNNING') || null)
: null;
const primaryProgressValue = hasSeriesBatchProgress
? normalizeSeriesBatchProgress(seriesBatchContext?.overallProgress, progress)
: progress;
const primaryEta = hasSeriesBatchProgress
? (runningSeriesEpisode?.eta || pipeline?.eta || null)
: (pipeline?.eta || null);
const primaryStatusText = hasSeriesBatchProgress
? (
`Serien-Encoding ${primaryProgressValue.toFixed(2)}%`
+ `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}`
)
: (pipeline?.statusText || 'Bereit');
const isSeriesBatchParentReview = Boolean(
mediaInfoReview?.seriesBatchParent
|| pipeline?.context?.mediaInfoReview?.seriesBatchParent
|| hasSeriesBatchProgress
);
const commandOutputPath = useMemo(() => {
if (jobMediaProfile === 'converter') {
const reviewWithOutput = mediaInfoReview
? { ...mediaInfoReview, outputFormat: selectedOutputFormat }
: { outputFormat: selectedOutputFormat };
return buildConverterOutputPathPreview(settingsMap, reviewWithOutput, selectedMetadata, retryJobId);
}
if (isSeriesDvdReview) {
return null;
}
return buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId);
}, [settingsMap, jobMediaProfile, mediaInfoReview, selectedMetadata, retryJobId, selectedOutputFormat, isSeriesDvdReview]);
const commandOutputPathByTitle = useMemo(() => {
if (!isSeriesDvdReview) {
return {};
}
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
const map = {};
for (const title of reviewTitles) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
const assignment = episodeAssignmentsByTitle?.[titleId]
|| episodeAssignmentsByTitle?.[String(titleId)]
|| null;
const path = buildDvdSeriesEpisodeOutputPathPreview(
settingsMap,
jobMediaProfile,
selectedMetadata || {},
title,
assignment,
retryJobId,
Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [],
episodeAssignmentsByTitle,
selectedEncodeTitleIds
);
if (path) {
map[titleId] = path;
}
}
return map;
}, [
isSeriesDvdReview,
mediaInfoReview?.titles,
episodeAssignmentsByTitle,
settingsMap,
jobMediaProfile,
selectedMetadata,
retryJobId,
selectedEncodeTitleIds
]);
const multiEpisodeReviewWarnings = useMemo(() => {
if (!isSeriesDvdReview) {
return [];
}
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
if (reviewTitles.length === 0) {
return [];
}
return reviewTitles
.map((title) => {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
return null;
}
const assignment = episodeAssignmentsByTitle?.[titleId]
|| episodeAssignmentsByTitle?.[String(titleId)]
|| null;
const episodeRange = resolveEffectiveEpisodeRangeForTitle(
assignment,
title,
reviewTitles,
titleId
);
if (!episodeRange.isMulti) {
return null;
}
const episodeRangeToken = buildEpisodeRangeToken(episodeRange.start, episodeRange.end);
const partsToken = buildEpisodePartsToken(episodeRange);
const rawTitle = String(
assignment?.episodeTitle
|| title?.episodeTitle
|| title?.fileName
|| `Episode ${episodeRangeToken}`
).trim() || `Episode ${episodeRangeToken}`;
const normalizedTitle = normalizeEpisodeTitleForOutput(rawTitle, episodeRange);
const normalizedByHeuristic = normalizedTitle !== rawTitle;
const listTitleRaw = String(title?.episodeTitle || '').trim();
const normalizedListTitle = listTitleRaw
? normalizeEpisodeTitleForOutput(listTitleRaw, episodeRange)
: '';
const mismatchWithTitleList = Boolean(
normalizedListTitle
&& normalizeEpisodeTitleTokenForCompare(normalizedTitle) !== normalizeEpisodeTitleTokenForCompare(normalizedListTitle)
);
return {
titleId,
episodeRangeToken,
partsToken,
rawTitle,
normalizedTitle,
normalizedByHeuristic,
normalizedListTitle: normalizedListTitle || null,
mismatchWithTitleList
};
})
.filter(Boolean);
}, [isSeriesDvdReview, mediaInfoReview?.titles, episodeAssignmentsByTitle]);
const episodeRangeConflictWarnings = useMemo(() => {
if (!isSeriesDvdReview) {
return [];
}
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
if (reviewTitles.length === 0) {
return [];
}
const metadataRows = (Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [])
.map((episode) => {
const episodeNumber = normalizePositiveInt(
episode?.episodeNumberStart
?? episode?.episodeNoStart
?? episode?.episodeNumber
?? episode?.number
?? null
);
const episodeTitle = String(
episode?.episodeTitle
|| episode?.name
|| episode?.title
|| ''
).trim();
return {
episodeNumber,
episodeTitle
};
})
.filter((row) => row.episodeNumber);
const titleAnchorSuggestionById = new Map();
if (metadataRows.length > 0) {
let cursor = 0;
for (const title of reviewTitles) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
if (cursor >= metadataRows.length) {
break;
}
const explicitSpan = normalizePositiveInt(
title?.episodeSpan
?? title?.episodeCount
?? null
);
const estimatedSpan = estimateSeriesEpisodeSpanForTitle(title, reviewTitles);
const span = Math.max(1, explicitSpan || 1, estimatedSpan || 1);
const safeSpan = Math.max(1, Math.min(span, metadataRows.length - cursor));
const spanRows = metadataRows.slice(cursor, cursor + safeSpan);
const startNumber = normalizePositiveInt(spanRows[0]?.episodeNumber);
const endNumber = normalizePositiveInt(spanRows[spanRows.length - 1]?.episodeNumber) || startNumber;
const isMulti = Boolean(startNumber && endNumber && endNumber > startNumber);
const sourceTitle = spanRows
.map((row) => String(row?.episodeTitle || '').trim())
.filter(Boolean)
.join(' + ');
const normalizedTitle = sourceTitle
? normalizeEpisodeTitleForOutput(
sourceTitle,
{
start: startNumber || 1,
end: endNumber || startNumber || 1,
span: safeSpan,
isMulti
}
)
: '';
titleAnchorSuggestionById.set(titleId, {
suggestedTitle: normalizedTitle || '(ohne Titel)',
isMulti
});
cursor += safeSpan;
}
}
return reviewTitles
.map((title) => {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
return null;
}
const assignment = episodeAssignmentsByTitle?.[titleId]
|| episodeAssignmentsByTitle?.[String(titleId)]
|| null;
if (!hasExplicitEpisodeAssignmentRange(assignment)) {
return null;
}
const explicitRange = resolveEpisodeAssignmentRange(assignment, title, titleId);
if (!explicitRange || explicitRange.isMulti) {
return null;
}
const spanHint = estimateSeriesEpisodeSpanForTitle(title, reviewTitles);
if (!Number.isFinite(spanHint) || spanHint <= 1) {
return null;
}
const effectiveSpan = Math.max(1, Math.trunc(spanHint));
const suggestedRange = {
start: explicitRange.start,
end: explicitRange.start + effectiveSpan - 1,
span: effectiveSpan,
isMulti: effectiveSpan > 1
};
const suggestedTitle = String(titleAnchorSuggestionById.get(titleId)?.suggestedTitle || '').trim();
return {
titleId,
episodeTitle: String(
assignment?.episodeTitle
|| title?.episodeTitle
|| title?.fileName
|| ''
).trim() || '(ohne Titel)',
suggestedTitle: suggestedTitle || normalizeEpisodeTitleForOutput(
String(assignment?.episodeTitle || title?.episodeTitle || '').trim() || '(ohne Titel)',
suggestedRange
)
};
})
.filter(Boolean);
}, [isSeriesDvdReview, mediaInfoReview?.titles, episodeAssignmentsByTitle, selectedMetadataEpisodes]);
const episodeAssignmentWarningLines = useMemo(() => (
episodeRangeConflictWarnings
.map((item) => {
const currentTitle = String(item?.episodeTitle || '').trim() || '(ohne Titel)';
const suggestionTitle = String(item?.suggestedTitle || '').trim() || '(ohne Titel)';
return `Titel ${item.titleId}: ${currentTitle} --> Vorschlag: ${suggestionTitle} (Heuristik vermutet Doppelfolge)`;
})
.filter(Boolean)
), [episodeRangeConflictWarnings]);
const hasMultiEpisodeInJob = multiEpisodeReviewWarnings.length > 0 || episodeRangeConflictWarnings.length > 0;
const presetDisplayValue = useMemo(() => {
const preset = String(mediaInfoReview?.selectors?.preset || '').trim();
if (!preset) {
return '';
}
return presetDisplayMap[preset] || preset;
}, [mediaInfoReview?.selectors?.preset, presetDisplayMap]);
const converterUserPresetOptions = useMemo(() => {
if (!showConverterConfig) {
return [];
}
const userOptions = (Array.isArray(effectiveUserPresets) ? effectiveUserPresets : [])
.map((preset) => ({
label: String(preset?.name || '').trim(),
value: Number(preset?.id)
}))
.filter((option) => option.label && Number.isFinite(option.value));
return [{ label: 'Kein User-Preset', value: null }, ...userOptions];
}, [showConverterConfig, effectiveUserPresets]);
const converterHandBrakePresetOptions = useMemo(() => {
if (!showConverterConfig) {
return [];
}
const list = Array.isArray(handBrakePresetOptions) ? handBrakePresetOptions : [];
const hbOptions = list
.map((option) => {
const label = String(option?.label || '').trim();
if (!label) {
return null;
}
const disabled = Boolean(option?.disabled);
const lowerLabel = label.toLowerCase();
if (lowerLabel.includes('handbrake cli') || lowerLabel.includes('cli default')) {
return null;
}
if (!disabled && (lowerLabel.includes('kein preset') || lowerLabel.includes('cli-parameter'))) {
return null;
}
const value = disabled
? String(option?.value || label)
: String(option?.value || '').trim();
if (!disabled && !value) {
return null;
}
return {
label,
value,
disabled
};
})
.filter(Boolean);
return [{ label: 'Kein HandBrake-Preset', value: '' }, ...hbOptions];
}, [showConverterConfig, handBrakePresetOptions]);
const buildSelectedTrackSelectionForCurrentTitle = () => {
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
const titlesById = new Map(
reviewTitles
.map((title) => [normalizeTitleId(title?.id), title])
.filter((entry) => entry[0] !== null)
);
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 preferredPrimaryTitleId = normalizeTitleId(selectedEncodeTitleId)
|| normalizeTitleId(mediaInfoReview?.encodeInputTitleId)
|| null;
const encodeTitleId = effectiveSelectedTitleIds.includes(preferredPrimaryTitleId)
? preferredPrimaryTitleId
: (effectiveSelectedTitleIds[0] || null);
const selectedTrackSelection = {};
for (const titleId of effectiveSelectedTitleIds) {
const selectionEntry = trackSelectionByTitle?.[titleId] || trackSelectionByTitle?.[String(titleId)] || null;
const fallbackSelection = defaultTrackSelectionForTitle(mediaInfoReview, titleId);
const effectiveSelection = selectionEntry || fallbackSelection;
const encodeTitle = titlesById.get(titleId) || null;
const blockedSubtitleTrackIds = new Set(
(Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : [])
.filter((track) => isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const availableSubtitleLanguages = new Set(
(Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : [])
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und'))
);
const normalizedVariantSelection = normalizeSubtitleVariantSelectionMap(
effectiveSelection?.subtitleVariantSelection || {}
);
const filteredVariantSelection = {};
for (const [language, value] of Object.entries(normalizedVariantSelection)) {
if (!availableSubtitleLanguages.has(language)) {
continue;
}
filteredVariantSelection[language] = {
full: Boolean(value?.full),
forced: Boolean(value?.forced)
};
}
const normalizedLanguageOrder = normalizeSubtitleLanguageOrder(effectiveSelection?.subtitleLanguageOrder || [])
.filter((language) => availableSubtitleLanguages.has(language));
const hasExplicitVariantSelection = Object.keys(filteredVariantSelection).length > 0;
const filteredSubtitleTrackIds = normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
.filter((id) => !blockedSubtitleTrackIds.has(String(id)));
selectedTrackSelection[titleId] = {
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
subtitleTrackIds: hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds,
subtitleVariantSelection: filteredVariantSelection,
subtitleLanguageOrder: normalizedLanguageOrder
};
}
const episodeAssignments = {};
for (const titleId of effectiveSelectedTitleIds) {
const assignment = episodeAssignmentsByTitle?.[titleId]
|| episodeAssignmentsByTitle?.[String(titleId)]
|| null;
const title = titlesById.get(titleId) || null;
const episodeId = Number(
assignment?.episodeId
|| assignment?.episodeIdStart
|| title?.episodeId
|| 0
);
const episodeNumber = Number(
assignment?.episodeNumber
|| assignment?.episodeNumberStart
|| title?.episodeNumber
|| 0
);
const seasonNumber = Number(assignment?.seasonNumber || title?.seasonNumber || 0);
const episodeIdStart = Number(
assignment?.episodeIdStart
|| assignment?.episodeId
|| 0
);
const episodeIdEnd = Number(
assignment?.episodeIdEnd
|| 0
);
const episodeNumberStart = Number(
assignment?.episodeNumberStart
|| assignment?.episodeNoStart
|| assignment?.episodeNumber
|| title?.episodeNumber
|| 0
);
const episodeNumberEnd = Number(
assignment?.episodeNumberEnd
|| assignment?.episodeNoEnd
|| 0
);
const episodeSpanRaw = Number(assignment?.episodeSpan || assignment?.episodeCount || 0);
const rawEpisodeTitle = String(assignment?.episodeTitle || title?.episodeTitle || '').trim();
const normalizedEpisodeStart = Number.isFinite(episodeNumberStart) && episodeNumberStart > 0
? Math.trunc(episodeNumberStart)
: (Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null);
const normalizedEpisodeEnd = Number.isFinite(episodeNumberEnd) && episodeNumberEnd > 0
? Math.trunc(episodeNumberEnd)
: normalizedEpisodeStart;
const normalizedEpisodeSpan = Number.isFinite(episodeSpanRaw) && episodeSpanRaw > 0
? Math.trunc(episodeSpanRaw)
: (
normalizedEpisodeStart && normalizedEpisodeEnd && normalizedEpisodeEnd >= normalizedEpisodeStart
? (normalizedEpisodeEnd - normalizedEpisodeStart + 1)
: 1
);
const normalizedEpisodeRange = {
start: normalizedEpisodeStart || 1,
end: normalizedEpisodeEnd || normalizedEpisodeStart || 1,
span: normalizedEpisodeSpan,
isMulti: normalizedEpisodeSpan > 1
};
const normalizedEpisodeTitle = rawEpisodeTitle
? normalizeEpisodeTitleForOutput(rawEpisodeTitle, normalizedEpisodeRange)
: '';
if (!normalizedEpisodeTitle && !(episodeId > 0) && !(episodeNumber > 0) && !(seasonNumber > 0)) {
continue;
}
const normalizedRangeToken = normalizedEpisodeStart
? buildEpisodeRangeToken(normalizedEpisodeStart, normalizedEpisodeEnd || normalizedEpisodeStart)
: null;
episodeAssignments[titleId] = {
episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null,
episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null,
seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null,
episodeTitle: normalizedEpisodeTitle || null,
episodeIdStart: Number.isFinite(episodeIdStart) && episodeIdStart > 0 ? Math.trunc(episodeIdStart) : null,
episodeIdEnd: Number.isFinite(episodeIdEnd) && episodeIdEnd > 0 ? Math.trunc(episodeIdEnd) : null,
episodeNumberStart: normalizedEpisodeStart,
episodeNumberEnd: normalizedEpisodeEnd || normalizedEpisodeStart,
episodeSpan: normalizedEpisodeSpan,
episodeRange: normalizedRangeToken,
episodeTitleStart: String(assignment?.episodeTitleStart || '').trim() || null,
episodeTitleEnd: String(assignment?.episodeTitleEnd || '').trim() || null
};
}
const selectedPostScriptIds = postEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
const selectedPreScriptIds = preEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
const selectedPostChainIds = postEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);
const selectedPreChainIds = preEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);
return {
encodeTitleId,
selectedEncodeTitleIds: effectiveSelectedTitleIds,
selectedTrackSelection: Object.keys(selectedTrackSelection).length > 0 ? selectedTrackSelection : null,
episodeAssignments: Object.keys(episodeAssignments).length > 0 ? episodeAssignments : null,
selectedPostScriptIds,
selectedPreScriptIds,
selectedPostChainIds,
selectedPreChainIds,
selectedHandBrakePreset: trimmedHandBrakePreset
};
};
return (
<Card title="Pipeline-Status" subTitle="Live-Zustand und Fortschritt">
<div className="status-row">
<Tag value={stateLabel} severity={getStatusSeverity(state)} />
<span>{primaryStatusText}</span>
</div>
{running && (
<div className="progress-wrap">
<ProgressBar
value={primaryProgressValue}
showValue
displayValueTemplate={(value) => formatPercentTwoDecimals(value)}
/>
<small>
{primaryEta
? (hasSeriesBatchProgress ? `ETA aktuelle Episode ${primaryEta}` : `ETA ${primaryEta}`)
: 'ETA unbekannt'}
</small>
</div>
)}
{state === 'FINISHED' && (
<div className="progress-wrap">
<ProgressBar value={100} showValue />
</div>
)}
{hasSeriesBatchProgress ? (
<div className="series-batch-progress-wrap">
<div className="series-batch-head">
<strong>Serien-Encode Fortschritt</strong>
<Tag
value={`${seriesBatchContext.finishedCount}/${seriesBatchContext.totalCount || seriesBatchContext.children.length} fertig`}
severity={seriesBatchContext.errorCount > 0 ? 'danger' : (seriesBatchContext.cancelledCount > 0 ? 'warning' : 'success')}
/>
</div>
<small>
{`Laufend: ${seriesBatchContext.runningCount} | Fehler: ${seriesBatchContext.errorCount} | Abgebrochen: ${seriesBatchContext.cancelledCount}`}
</small>
<div className="series-batch-episodes">
{seriesBatchContext.children.map((child, index) => (
<div
key={`series-batch-episode-${child.titleId || child.episodeIndex || index}`}
className="series-batch-episode-row"
>
<div className="series-batch-episode-head">
<span className="series-batch-episode-title">{child.title}</span>
<Tag value={getStatusLabel(child.status)} severity={getSeriesBatchTagSeverity(child.status)} />
</div>
<ProgressBar value={child.progress} showValue={false} style={{ height: '5px' }} />
<small>{child.eta ? `${Math.round(child.progress)}% | ETA ${child.eta}` : `${Math.round(child.progress)}%`}</small>
</div>
))}
</div>
</div>
) : null}
<div className="actions-row">
{queueLocked ? (
<Button
label="Aus Queue löschen"
icon="pi pi-times"
severity="danger"
outlined
onClick={() => onRemoveFromQueue?.(retryJobId)}
loading={busy}
disabled={typeof onRemoveFromQueue !== 'function'}
/>
) : (
<>
{(state === 'DISC_DETECTED' || state === 'IDLE') && (
<Button
label="Analyse starten"
icon="pi pi-search"
onClick={onAnalyze}
loading={busy}
/>
)}
{(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION' || isOrphanCancelled)
&& retryJobId
&& typeof onOpenMetadata === 'function'
&& !(jobMediaProfile === 'converter' && !selectedMetadata?.imdbId) ? (
<Button
label="Metadaten öffnen"
icon="pi pi-list"
severity="info"
onClick={() => onOpenMetadata?.(retryJobId)}
loading={busy}
/>
) : null}
{state === 'METADATA_SELECTION' && jobMediaProfile === 'converter' && retryJobId ? (
<Button
label="Review starten"
icon="pi pi-play"
severity="info"
onClick={() => onStart(retryJobId)}
loading={busy}
/>
) : null}
{!running
&& state !== 'METADATA_SELECTION'
&& state !== 'WAITING_FOR_USER_DECISION'
&& state !== 'IDLE'
&& state !== 'DISC_DETECTED'
&& retryJobId
&& typeof onReassignOmdb === 'function'
&& !isOrphanCancelled
&& !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? (
<Button
label="Metadaten neu zuweisen"
icon="pi pi-search"
severity="info"
size="small"
onClick={() => onReassignOmdb?.(retryJobId)}
loading={busy}
/>
) : null}
{state === 'READY_TO_START' && retryJobId ? (
<Button
label={jobMediaProfile === 'converter' ? 'Review starten' : 'Job starten'}
icon="pi pi-play"
severity={jobMediaProfile === 'converter' ? 'info' : 'success'}
onClick={() => onStart(retryJobId)}
loading={busy}
/>
) : null}
{playlistDecisionRequiredBeforeStart && retryJobId && (
<Button
label="Playlist übernehmen"
icon="pi pi-check"
severity="warning"
outlined
onClick={() => onSelectPlaylist?.(retryJobId, selectedPlaylistId)}
loading={busy}
disabled={!normalizePlaylistId(selectedPlaylistId)}
/>
)}
{handBrakeTitleDecisionRequired && retryJobId && (
<Button
label={isSeriesPlayAllDecisionMode ? 'PlayAll/Doppelfolge bestätigen' : `${discTypeLabel} Titel bestätigen`}
icon="pi pi-check"
severity="warning"
outlined
onClick={() => onSelectHandBrakeTitle?.(retryJobId, selectedHandBrakeTitleIdsState)}
loading={busy}
disabled={selectedHandBrakeTitleIdsState.length === 0}
/>
)}
{state === 'READY_TO_ENCODE' && retryJobId ? (
<Button
label={isPreRipReview ? 'Backup + Encoding starten' : 'Encoding starten'}
icon="pi pi-play"
severity="success"
onClick={async () => {
if (hasMultiEpisodeInJob) {
const introText = 'Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich prüfen, bevor das Encoding startet.';
const rows = episodeAssignmentWarningLines;
const modalMessage = rows.length > 0
? `${introText}\n\n${rows.join('\n')}`
: introText;
const confirmed = await confirmModal({
header: 'Episoden-Zuordnung prüfen',
message: modalMessage,
acceptLabel: 'Encoding starten',
rejectLabel: 'Abbrechen',
icon: 'pi pi-exclamation-triangle',
style: { width: '34rem', maxWidth: '92vw' },
breakpoints: {
'960px': '92vw',
'640px': '95vw'
}
});
if (!confirmed) {
return;
}
}
const {
encodeTitleId,
selectedEncodeTitleIds: selectedEncodeTitleIdsForPayload,
selectedTrackSelection,
episodeAssignments,
selectedPostScriptIds,
selectedPreScriptIds,
selectedPostChainIds,
selectedPreChainIds,
selectedHandBrakePreset
} = buildSelectedTrackSelectionForCurrentTitle();
const startPayload = {
ensureConfirmed: true,
selectedEncodeTitleId: encodeTitleId,
selectedEncodeTitleIds: selectedEncodeTitleIdsForPayload,
selectedTrackSelection,
episodeAssignments,
selectedPostEncodeScriptIds: selectedPostScriptIds,
selectedPreEncodeScriptIds: selectedPreScriptIds,
selectedPostEncodeChainIds: selectedPostChainIds,
selectedPreEncodeChainIds: selectedPreChainIds,
selectedHandBrakePreset
};
startPayload.selectedUserPresetId = selectedUserPresetId || null;
await onStart(retryJobId, startPayload);
}}
loading={busy}
disabled={!canStartReadyJob || !canConfirmReview}
/>
) : null}
{(running || canCancelReview) && (
<Button
label="Abbrechen"
icon="pi pi-stop"
severity="danger"
onClick={() => onCancel?.(retryJobId, state)}
loading={busy}
/>
)}
{canRestartReviewFromRaw ? (
<Button
label="Review neu starten"
icon="pi pi-refresh"
severity="info"
outlined
onClick={() => onRestartReview?.(retryJobId)}
loading={busy}
disabled={!retryJobId}
/>
) : null}
{canRestartEncodeFromLastSettings ? (
isSeriesBatchParentReview ? (
<>
<Button
label="Alle neu starten"
icon="pi pi-play"
severity="success"
onClick={() => onRestartEncode?.(retryJobId, { restartMode: 'all' })}
loading={busy}
disabled={!retryJobId}
/>
<Button
label="Neu starten ab Abbruch"
icon="pi pi-forward"
severity="warning"
outlined
onClick={() => onRestartEncode?.(retryJobId, { restartMode: 'from_abort' })}
loading={busy}
disabled={!retryJobId}
/>
</>
) : (
<Button
label="Encode neu starten"
icon="pi pi-play"
severity="success"
onClick={() => onRestartEncode?.(retryJobId, { restartMode: 'all' })}
loading={busy}
disabled={!retryJobId}
/>
)
) : null}
{(state === 'ERROR' || state === 'CANCELLED') && retryJobId && !isOrphanCancelled && !ripCompleted && (
<Button
label="Retry Rippen"
icon="pi pi-refresh"
severity="warning"
onClick={() => onRetry(retryJobId)}
loading={busy}
/>
)}
{(state === 'ERROR' || state === 'CANCELLED') && !isOrphanCancelled && !ripCompleted ? (
<Button
label="Disk-Analyse neu starten"
icon="pi pi-search"
severity="secondary"
onClick={onReanalyze || onAnalyze}
loading={busy}
/>
) : null}
</>
)}
{!running && retryJobId && typeof onDeleteJob === 'function' ? (
<Button
label="Job löschen"
icon="pi pi-trash"
severity="danger"
outlined
onClick={() => onDeleteJob?.(retryJobId)}
loading={busy}
/>
) : null}
</div>
{running ? (
<div className="live-log-block">
<h4>Aktueller Job-Log</h4>
<pre className="log-box">{liveJobLog || 'Noch keine Log-Ausgabe vorhanden.'}</pre>
</div>
) : null}
{rawDecisionRequiredBeforeStart && !queueLocked ? (
<div className="playlist-decision-block">
<h3>RAW Entscheidung erforderlich</h3>
<small>
Vorhandenes Raw zur Disk gefunden. Wie soll weiter verfahren werden?
</small>
<div className="actions-row mt-3">
<Button
label="mit RAW weiter machen"
icon="pi pi-check"
severity="success"
onClick={() => onSubmitRawDecision?.(retryJobId, 'continue')}
loading={busy}
/>
<Button
label="RAW löschen und Rip erneut starten"
icon="pi pi-trash"
severity="danger"
outlined
onClick={() => onSubmitRawDecision?.(retryJobId, 'delete')}
loading={busy}
/>
<Button
label="abbrechen"
icon="pi pi-times"
severity="secondary"
outlined
onClick={() => onSubmitRawDecision?.(retryJobId, 'cancel')}
loading={busy}
/>
</div>
</div>
) : null}
{playlistDecisionRequiredBeforeStart && !queueLocked ? (
<div className="playlist-decision-block">
<h3>Playlist-Auswahl erforderlich</h3>
<small>
Metadaten sind abgeschlossen. Vor Start muss ein Titel/Playlist manuell per Checkbox gewählt werden.
</small>
{waitingPlaylistRows.length > 0 ? (
<div className="playlist-decision-list">
{waitingPlaylistRows.map((row) => (
<div key={row.playlistId} className="playlist-decision-item">
<label className="readonly-check-row">
<input
type="checkbox"
checked={normalizePlaylistId(selectedPlaylistId) === row.playlistId}
disabled={queueLocked}
onChange={() => {
const next = normalizePlaylistId(selectedPlaylistId) === row.playlistId ? null : row.playlistId;
setSelectedPlaylistId(next);
}}
/>
<span>
{row.playlistFile}
{row.titleId !== null ? ` | Titel #${row.titleId}` : ''}
{row.durationLabel ? ` | Dauer ${row.durationLabel}` : ''}
{row.score !== null ? ` | Score ${row.score}` : ''}
{row.recommended ? ' | empfohlen' : ''}
</span>
</label>
{row.evaluationLabel ? <small className="track-action-note">{row.evaluationLabel}</small> : null}
{row.sequenceCoherence !== null ? (
<small className="track-action-note">Sequenz-Kohärenz: {row.sequenceCoherence.toFixed(3)}</small>
) : null}
{row.handBrakeTitleId !== null ? (
<small className="track-action-note">HandBrake Titel: -t {row.handBrakeTitleId}</small>
) : null}
{row.audioSummary ? (
<small className="track-action-note">Audio: {row.audioSummary}</small>
) : null}
{row.segmentCommand ? <small className="track-action-note">Info: {row.segmentCommand}</small> : null}
{Array.isArray(row.audioTrackPreview) && row.audioTrackPreview.length > 0 ? (
<details className="playlist-segment-toggle">
<summary>Audio-Spuren anzeigen ({row.audioTrackPreview.length})</summary>
<pre className="playlist-segment-output">{row.audioTrackPreview.join('\n')}</pre>
</details>
) : null}
{Array.isArray(row.segmentFiles) && row.segmentFiles.length > 0 ? (
<details className="playlist-segment-toggle">
<summary>Segment-Dateien anzeigen ({row.segmentFiles.length})</summary>
<pre className="playlist-segment-output">{row.segmentFiles.join('\n')}</pre>
</details>
) : (
<small className="track-action-note">Keine Segmentliste aus TINFO:26 verfügbar.</small>
)}
</div>
))}
</div>
) : (
<small>Keine Kandidaten gefunden. Bitte Analyse erneut ausführen.</small>
)}
</div>
) : null}
{handBrakeTitleDecisionRequired && !queueLocked ? (
<div className="playlist-decision-block">
<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 ${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">
{visibleWaitingHandBrakeTitleRows.map((row) => {
const isDecisionSelectable = !isSeriesPlayAllDecisionMode
|| handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId));
const isAmbiguousDecisionTitle = isSeriesPlayAllDecisionMode
&& isDecisionSelectable
&& (
Number(row.handBrakeTitleId) === Number(ambiguousLongestTitleId)
|| handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId))
);
const isRecognizedLocked = isSeriesPlayAllDecisionMode
&& row.decisionCandidate
&& !isDecisionSelectable;
const isOutOfDecisionScope = isSeriesPlayAllDecisionMode
&& !row.decisionCandidate;
return (
<div key={row.handBrakeTitleId} className="playlist-decision-item">
<label className="readonly-check-row">
<input
type="checkbox"
checked={selectedHandBrakeTitleIdsState.includes(row.handBrakeTitleId)}
disabled={queueLocked || !isDecisionSelectable}
onChange={() => {
if (!isDecisionSelectable) {
return;
}
setSelectedHandBrakeTitleIdsState((prev) => {
const list = Array.isArray(prev) ? prev : [];
if (list.includes(row.handBrakeTitleId)) {
return list.filter((value) => value !== row.handBrakeTitleId);
}
return [...list, row.handBrakeTitleId];
});
}}
/>
<span>
{isAmbiguousDecisionTitle ? <strong>{`Titel -t ${row.handBrakeTitleId}`}</strong> : `Titel -t ${row.handBrakeTitleId}`}
{isAmbiguousDecisionTitle ? ' | Grenzfalltitel (PlayAll/Doppelfolge)' : ''}
{isRecognizedLocked ? ' | automatisch erkannt (fix)' : ''}
{isOutOfDecisionScope ? ' | nicht entscheidungsrelevant' : ''}
{row.durationLabel ? ` | ${row.durationLabel}` : ''}
{row.audioTrackCount > 0 ? ` | Audio: ${row.audioTrackCount}` : ''}
{row.subtitleTrackCount > 0 ? ` | Untertitel: ${row.subtitleTrackCount}` : ''}
</span>
</label>
</div>
);
})}
</div>
) : (
<small>Keine Kandidaten verfügbar. Bitte Analyse erneut ausführen.</small>
)}
</div>
) : null}
{selectedMetadata && !isDiscTitleSelectionRequired ? (
<div className="pipeline-meta-inline">
{selectedMetadata.poster ? (
<img
src={selectedMetadata.poster}
alt={selectedMetadata.title || 'Poster'}
className={jobMediaProfile === 'cd' || jobMediaProfile === 'audiobook' ? 'poster-large-audio' : 'poster-large'}
/>
) : (
<div className={`${jobMediaProfile === 'cd' || jobMediaProfile === 'audiobook' ? 'poster-large-audio' : 'poster-large'} poster-fallback`}>Kein {jobMediaProfile === 'cd' || jobMediaProfile === 'audiobook' ? 'Cover' : 'Poster'}</div>
)}
<div className="device-meta">
<div>
<strong>Titel:</strong> {selectedMetadata.title || '-'}
</div>
<div>
<strong>Jahr:</strong> {selectedMetadata.year || '-'}
</div>
<div>
<strong>IMDb:</strong> {selectedMetadata.imdbId || '-'}
</div>
<div>
<strong>Status:</strong> {stateLabel}
</div>
</div>
</div>
) : null}
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
<div className="mediainfo-review-block">
{!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.'
: 'Spurauswahl kann direkt übernommen werden. Beim Klick auf "Encoding starten" wird automatisch bestätigt und gestartet.'}
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
</small>
) : null}
{showConverterConfig ? (
<div style={{ marginTop: '0.75rem', marginBottom: '0.75rem', padding: '0.75rem', border: '1px solid var(--surface-border, #e0e0e0)', borderRadius: '6px', background: 'var(--surface-ground, #f8f8f8)' }}>
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>Ausgabeformat</label>
<Dropdown
value={selectedOutputFormat}
options={CONVERTER_VIDEO_OUTPUT_FORMATS}
onChange={(e) => setSelectedOutputFormat(e.value)}
style={{ width: '100%' }}
disabled={!allowConverterConfigEdit}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>User-Preset</label>
<Dropdown
value={selectedUserPresetId}
options={converterUserPresetOptions}
optionLabel="label"
optionValue="value"
onChange={(e) => {
const rawValue = e?.value && typeof e.value === 'object' ? e.value.value : e?.value;
const nextValue = rawValue === null || rawValue === undefined || String(rawValue).trim() === ''
? null
: Number(rawValue);
const normalized = Number.isFinite(nextValue) && nextValue > 0 ? Math.trunc(nextValue) : null;
setSelectedUserPresetId(normalized);
if (normalized) {
setSelectedHandBrakePreset('');
}
}}
style={{ width: '100%' }}
placeholder="User-Preset auswählen …"
showClear
disabled={!allowConverterConfigEdit || Boolean(trimmedHandBrakePreset) || converterUserPresetOptions.length <= 1}
/>
{converterUserPresetOptions.length <= 1 ? (
<small style={{ color: 'var(--text-color-secondary)' }}>Keine User-Presets vorhanden.</small>
) : null}
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>HandBrake-Preset</label>
<Dropdown
value={trimmedHandBrakePreset}
options={converterHandBrakePresetOptions}
optionLabel="label"
optionValue="value"
onChange={(e) => {
const rawValue = e?.value && typeof e.value === 'object' ? e.value.value : e?.value;
const next = String(rawValue || '').trim();
setSelectedHandBrakePreset(next);
if (next) {
setSelectedUserPresetId(null);
}
}}
style={{ width: '100%' }}
placeholder="HandBrake-Preset auswählen …"
filter
optionDisabled="disabled"
showClear
disabled={!allowConverterConfigEdit || Boolean(selectedUserPresetId) || converterHandBrakePresetOptions.length <= 1}
/>
{converterHandBrakePresetOptions.length <= 1 ? (
<small style={{ color: 'var(--text-color-secondary)' }}>Keine HandBrake-Presets geladen.</small>
) : null}
</div>
</div>
{allowConverterConfigEdit && !hasSelectedPreset ? (
<small style={{ display: 'block', marginTop: '0.5rem', color: 'var(--red-600)' }}>
Bitte wähle ein User- oder HandBrake-Preset aus, bevor das Encoding starten kann.
</small>
) : null}
{allowConverterConfigEdit ? (
converterConfigError ? (
<small style={{ display: 'block', marginTop: '0.5rem', color: 'var(--red-600)' }}>
{converterConfigError}
</small>
) : (
<small style={{ display: 'block', marginTop: '0.5rem', color: 'var(--text-color-secondary)' }}>
{converterConfigSaving ? 'Speichere Konfiguration …' : 'Konfiguration gespeichert'}
</small>
)
) : null}
</div>
) : null}
{isSeriesDvdReview && hasMultiEpisodeInJob ? (
<div className="playlist-decision-block">
<h3>Hinweis Episoden-Zuordnung</h3>
<small>
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>
))}
</div>
) : null}
<MediaInfoReviewPanel
review={mediaInfoReview}
presetDisplayValue={presetDisplayValue}
commandOutputPath={commandOutputPath}
commandOutputPathByTitle={commandOutputPathByTitle}
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
allowTitleSelection={(state === 'READY_TO_ENCODE' || state === 'WAITING_FOR_USER_DECISION') && !queueLocked}
compactTitleSelection={isDiscTitleSelectionRequired}
onSelectEncodeTitle={(titleId) => {
const normalizedTitleId = normalizeTitleId(titleId);
setSelectedEncodeTitleId(normalizedTitleId);
if (!normalizedTitleId) {
return;
}
setSelectedEncodeTitleIds((prev) => normalizeTitleIdList([...(Array.isArray(prev) ? prev : []), normalizedTitleId]));
}}
onToggleEncodeTitle={(titleId, checked) => {
const normalizedTitleId = normalizeTitleId(titleId);
if (!normalizedTitleId) {
return;
}
setSelectedEncodeTitleIds((prev) => {
const current = normalizeTitleIdList(prev);
if (checked) {
const next = normalizeTitleIdList([...current, normalizedTitleId]);
setSelectedEncodeTitleId(normalizedTitleId);
return next;
}
const next = current.filter((id) => id !== normalizedTitleId);
setSelectedEncodeTitleId((prevPrimary) => {
const normalizedPrimary = normalizeTitleId(prevPrimary);
if (normalizedPrimary === normalizedTitleId) {
return next[0] || null;
}
return normalizedPrimary;
});
return next;
});
}}
allowTrackSelection={state === 'READY_TO_ENCODE' && !queueLocked}
trackSelectionByTitle={trackSelectionByTitle}
onTrackSelectionChange={(titleId, trackType, trackId, checked) => {
const normalizedTitleId = normalizeTitleId(titleId);
const normalizedTrackId = normalizeTrackId(trackId);
if (!normalizedTitleId || normalizedTrackId === null) {
return;
}
setTrackSelectionByTitle((prev) => {
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
const existing = normalizeTrackIdList(current?.[key] || []);
const next = checked
? normalizeTrackIdList([...existing, normalizedTrackId])
: existing.filter((id) => id !== normalizedTrackId);
return {
...prev,
[normalizedTitleId]: {
...current,
[key]: next
}
};
});
}}
onSubtitleVariantSelectionChange={(titleId, language, variant, checked) => {
const normalizedTitleId = normalizeTitleId(titleId);
const normalizedLanguage = normalizeSubtitleLanguage(language);
const normalizedVariant = String(variant || '').trim().toLowerCase() === 'forced' ? 'forced' : 'full';
if (!normalizedTitleId || !normalizedLanguage) {
return;
}
setTrackSelectionByTitle((prev) => {
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
const currentMap = normalizeSubtitleVariantSelectionMap(current?.subtitleVariantSelection || {});
const nextMap = { ...currentMap };
const currentEntry = nextMap[normalizedLanguage] || { full: false, forced: false };
nextMap[normalizedLanguage] = {
...currentEntry,
[normalizedVariant]: Boolean(checked)
};
const existingOrder = normalizeSubtitleLanguageOrder(current?.subtitleLanguageOrder || []);
const fallbackOrder = normalizeSubtitleLanguageOrder(
defaultTrackSelectionForTitle(mediaInfoReview, normalizedTitleId)?.subtitleLanguageOrder || []
);
let nextOrder = existingOrder.length > 0
? existingOrder
: fallbackOrder;
if (nextOrder.length === 0) {
nextOrder = [normalizedLanguage];
} else if (!nextOrder.includes(normalizedLanguage) && fallbackOrder.includes(normalizedLanguage)) {
nextOrder = fallbackOrder;
} else if (!nextOrder.includes(normalizedLanguage)) {
nextOrder = [...nextOrder, normalizedLanguage];
}
return {
...prev,
[normalizedTitleId]: {
...current,
subtitleVariantSelection: nextMap,
subtitleLanguageOrder: nextOrder
}
};
});
}}
selectedMetadataEpisodes={selectedMetadataEpisodes}
selectedEpisodeFillStart={selectedEpisodeFillStart}
onEpisodeFillStartChange={(episodeRef) => {
const normalizedRef = String(episodeRef || '').trim();
setSelectedEpisodeFillStart(normalizedRef || null);
if (normalizedRef) {
applyEpisodeFillFrom(normalizedRef);
}
}}
episodeAssignmentsByTitle={episodeAssignmentsByTitle}
allowEpisodeAssignments={isSeriesDvdReview}
onEpisodeAssignmentChange={(titleId, patch = {}) => {
const normalizedTitleId = normalizeTitleId(titleId);
if (!normalizedTitleId || !patch || typeof patch !== 'object') {
return;
}
const toNumberOrNull = (value) => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
};
setEpisodeAssignmentsByTitle((prev) => {
const currentMap = prev && typeof prev === 'object' ? prev : {};
const current = currentMap?.[normalizedTitleId] || currentMap?.[String(normalizedTitleId)] || {};
const nextTitle = String(
patch.episodeTitle !== undefined
? patch.episodeTitle
: current.episodeTitle || ''
).trim();
const next = {
...current,
episodeId: patch.episodeId !== undefined
? toNumberOrNull(patch.episodeId)
: toNumberOrNull(current.episodeId),
episodeNumber: patch.episodeNumber !== undefined
? toNumberOrNull(patch.episodeNumber)
: toNumberOrNull(current.episodeNumber),
seasonNumber: patch.seasonNumber !== undefined
? toNumberOrNull(patch.seasonNumber)
: toNumberOrNull(current.seasonNumber),
episodeTitle: nextTitle || null
};
if (next.episodeNumber !== null && next.episodeNumberStart == null) {
next.episodeNumberStart = next.episodeNumber;
}
if (
next.episodeNumberStart !== null
&& next.episodeNumberEnd !== null
&& Number(next.episodeNumberEnd) < Number(next.episodeNumberStart)
) {
next.episodeNumberEnd = next.episodeNumberStart;
}
if (next.episodeNumberStart !== null && next.episodeNumberEnd == null) {
next.episodeNumberEnd = next.episodeNumberStart;
}
if (next.episodeNumberStart !== null) {
const start = Number(next.episodeNumberStart);
const end = Number(next.episodeNumberEnd ?? start);
if (Number.isFinite(start) && start > 0 && Number.isFinite(end) && end > 0) {
next.episodeSpan = Math.max(1, Math.trunc(end - start + 1));
next.episodeRange = buildEpisodeRangeToken(start, end);
}
}
return {
...currentMap,
[normalizedTitleId]: next
};
});
}}
availableScripts={scriptCatalog}
availableChains={chainCatalog}
preEncodeItems={preEncodeItems}
postEncodeItems={postEncodeItems}
userPresets={effectiveUserPresets}
selectedUserPresetId={selectedUserPresetId}
onUserPresetChange={showConverterConfig ? null : (presetId) => setSelectedUserPresetId(presetId)}
selectedHandBrakePreset={selectedHandBrakePreset}
allowEncodeItemSelection={state === 'READY_TO_ENCODE' && !queueLocked}
onAddPreEncodeItem={(itemType) => {
setPreEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
if (itemType === 'chain') {
const selectedSet = new Set(
current
.filter((item) => item?.type === 'chain')
.map((item) => normalizeChainId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const nextCandidate = (Array.isArray(chainCatalog) ? chainCatalog : [])
.map((item) => normalizeChainId(item?.id))
.find((id) => id !== null && !selectedSet.has(String(id)));
if (nextCandidate === undefined || nextCandidate === null) {
return current;
}
return [...current, { type: 'chain', id: nextCandidate }];
}
const selectedSet = new Set(
current
.filter((item) => item?.type === 'script')
.map((item) => normalizeScriptId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
.map((item) => normalizeScriptId(item?.id))
.find((id) => id !== null && !selectedSet.has(String(id)));
if (nextCandidate === undefined || nextCandidate === null) {
return current;
}
return [...current, { type: 'script', id: nextCandidate }];
});
}}
onChangePreEncodeItem={(rowIndex, itemType, nextId) => {
setPreEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const index = Number(rowIndex);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
return current;
}
const type = itemType === 'chain' ? 'chain' : 'script';
if (type === 'chain') {
const normalizedId = normalizeChainId(nextId);
if (normalizedId === null) {
return current;
}
const duplicate = current.some((item, idx) =>
idx !== index
&& item?.type === 'chain'
&& String(normalizeChainId(item?.id)) === String(normalizedId)
);
if (duplicate) {
return current;
}
const next = [...current];
next[index] = { type: 'chain', id: normalizedId };
return next;
}
const normalizedId = normalizeScriptId(nextId);
if (normalizedId === null) {
return current;
}
const duplicate = current.some((item, idx) =>
idx !== index
&& item?.type === 'script'
&& String(normalizeScriptId(item?.id)) === String(normalizedId)
);
if (duplicate) {
return current;
}
const next = [...current];
next[index] = { type: 'script', id: normalizedId };
return next;
});
}}
onRemovePreEncodeItem={(rowIndex) => {
setPreEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const index = Number(rowIndex);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
return current;
}
return current.filter((_, idx) => idx !== index);
});
}}
onReorderPreEncodeItem={(fromIndex, toIndex) => {
setPreEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const from = Number(fromIndex);
const to = Number(toIndex);
if (!Number.isInteger(from) || !Number.isInteger(to)) {
return current;
}
if (from < 0 || to < 0 || from >= current.length || to >= current.length || from === to) {
return current;
}
const next = [...current];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}}
onAddPostEncodeItem={(itemType) => {
setPostEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
if (itemType === 'chain') {
const selectedSet = new Set(
current
.filter((item) => item?.type === 'chain')
.map((item) => normalizeChainId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const nextCandidate = (Array.isArray(chainCatalog) ? chainCatalog : [])
.map((item) => normalizeChainId(item?.id))
.find((id) => id !== null && !selectedSet.has(String(id)));
if (nextCandidate === undefined || nextCandidate === null) {
return current;
}
return [...current, { type: 'chain', id: nextCandidate }];
}
const selectedSet = new Set(
current
.filter((item) => item?.type === 'script')
.map((item) => normalizeScriptId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
.map((item) => normalizeScriptId(item?.id))
.find((id) => id !== null && !selectedSet.has(String(id)));
if (nextCandidate === undefined || nextCandidate === null) {
return current;
}
return [...current, { type: 'script', id: nextCandidate }];
});
}}
onChangePostEncodeItem={(rowIndex, itemType, nextId) => {
setPostEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const index = Number(rowIndex);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
return current;
}
const type = itemType === 'chain' ? 'chain' : 'script';
if (type === 'chain') {
const normalizedId = normalizeChainId(nextId);
if (normalizedId === null) {
return current;
}
const duplicate = current.some((item, idx) =>
idx !== index
&& item?.type === 'chain'
&& String(normalizeChainId(item?.id)) === String(normalizedId)
);
if (duplicate) {
return current;
}
const next = [...current];
next[index] = { type: 'chain', id: normalizedId };
return next;
}
const normalizedId = normalizeScriptId(nextId);
if (normalizedId === null) {
return current;
}
const duplicate = current.some((item, idx) =>
idx !== index
&& item?.type === 'script'
&& String(normalizeScriptId(item?.id)) === String(normalizedId)
);
if (duplicate) {
return current;
}
const next = [...current];
next[index] = { type: 'script', id: normalizedId };
return next;
});
}}
onRemovePostEncodeItem={(rowIndex) => {
setPostEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const index = Number(rowIndex);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
return current;
}
return current.filter((_, idx) => idx !== index);
});
}}
onReorderPostEncodeItem={(fromIndex, toIndex) => {
setPostEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const from = Number(fromIndex);
const to = Number(toIndex);
if (!Number.isInteger(from) || !Number.isInteger(to)) {
return current;
}
if (from < 0 || to < 0 || from >= current.length || to >= current.length || from === to) {
return current;
}
const next = [...current];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}}
/>
</div>
) : null}
</Card>
);
}