Files
ripster/frontend/src/components/MediaInfoReviewPanel.jsx
T
2026-04-12 19:43:56 +00:00

2541 lines
97 KiB
React

import { Button } from 'primereact/button';
import { Dropdown } from 'primereact/dropdown';
function formatDuration(minutes) {
const value = Number(minutes || 0);
if (!Number.isFinite(value)) {
return '-';
}
return `${value.toFixed(2)} min`;
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) {
return '-';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = value;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(2)} ${units[index]}`;
}
function normalizeTrackId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizePositiveInt(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function formatTwoDigit(value) {
const normalized = normalizePositiveInt(value);
if (normalized === null) {
return '??';
}
return String(normalized).padStart(2, '0');
}
function buildEpisodeRangeToken(start, end) {
const startNormalized = normalizePositiveInt(start);
const endNormalized = normalizePositiveInt(end);
if (!startNormalized) {
return '??';
}
if (!endNormalized || endNormalized <= startNormalized) {
return formatTwoDigit(startNormalized);
}
return `${formatTwoDigit(startNormalized)}-${formatTwoDigit(endNormalized)}`;
}
function buildEpisodePartsToken(start, end) {
const startNormalized = normalizePositiveInt(start) || 1;
const endNormalized = normalizePositiveInt(end) || startNormalized;
const span = Math.max(1, endNormalized - startNormalized + 1);
if (span <= 1) {
return '1';
}
if (span === 2) {
return '1+2';
}
return `1-${span}`;
}
function stripEpisodePartSuffix(value) {
const source = String(value || '').replace(/\s+/g, ' ').trim();
if (!source) {
return '';
}
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*$/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 normalizeEpisodeFillTitle(value, isMulti = false) {
const source = String(value || '').replace(/\s+/g, ' ').trim();
if (!source) {
return '';
}
if (!isMulti) {
return source;
}
return stripEpisodePartSuffix(source) || source;
}
function resolveTitleDurationSecondsForFill(title = null) {
const durationSeconds = Number(title?.durationSeconds || 0);
if (Number.isFinite(durationSeconds) && durationSeconds > 0) {
return durationSeconds;
}
const durationMinutes = Number(title?.durationMinutes || 0);
if (Number.isFinite(durationMinutes) && durationMinutes > 0) {
return durationMinutes * 60;
}
return 0;
}
function medianPositiveNumber(values = []) {
const numbers = (Array.isArray(values) ? values : [])
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.sort((a, b) => a - b);
if (numbers.length === 0) {
return 0;
}
const middle = Math.floor(numbers.length / 2);
if (numbers.length % 2 === 1) {
return numbers[middle];
}
return (numbers[middle - 1] + numbers[middle]) / 2;
}
function estimateEpisodeSpanForFill(title = null, allTitles = []) {
const titleDuration = resolveTitleDurationSecondsForFill(title);
if (!Number.isFinite(titleDuration) || titleDuration <= 0) {
return 1;
}
const durations = (Array.isArray(allTitles) ? allTitles : [])
.map((item) => resolveTitleDurationSecondsForFill(item))
.filter((value) => Number.isFinite(value) && value > 0);
if (durations.length === 0) {
return 1;
}
const medianDuration = medianPositiveNumber(durations);
if (!medianDuration) {
return 1;
}
const baselinePool = durations.filter((value) => value <= medianDuration * 1.35);
const baselineDuration = medianPositiveNumber(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 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 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 normalizeTrackIdSequence(values, options = {}) {
const list = Array.isArray(values) ? values : [];
const dedupe = options?.dedupe !== false;
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = normalizeTrackId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (dedupe && seen.has(key)) {
continue;
}
if (dedupe) {
seen.add(key);
}
output.push(normalized);
}
return output;
}
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 normalizeSubtitleLanguageOrder(rawOrder = []) {
const list = Array.isArray(rawOrder) ? rawOrder : [];
const seen = new Set();
const output = [];
for (const item of list) {
const language = normalizeSubtitleLanguage(item);
if (!language || seen.has(language)) {
continue;
}
seen.add(language);
output.push(language);
}
return output;
}
function normalizeSubtitleVariantSelectionMap(rawSelection) {
const map = {};
if (!rawSelection || typeof rawSelection !== 'object') {
return map;
}
for (const [language, value] of Object.entries(rawSelection)) {
const normalizedLanguage = normalizeSubtitleLanguage(language);
map[normalizedLanguage] = {
full: Boolean(value?.full),
forced: Boolean(value?.forced)
};
}
return map;
}
function normalizeSubtitleConfidenceValue(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'high' || raw === 'medium' || raw === 'low') {
return raw;
}
return 'low';
}
function subtitleConfidenceScore(value) {
const normalized = normalizeSubtitleConfidenceValue(value);
if (normalized === 'high') {
return 3;
}
if (normalized === 'medium') {
return 2;
}
return 1;
}
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 compareForcedSubtitleCandidate(a, b) {
const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
if (defaultDiff !== 0) {
return defaultDiff;
}
const confidenceDiff = subtitleConfidenceScore(b?.confidence) - subtitleConfidenceScore(a?.confidence);
if (confidenceDiff !== 0) {
return confidenceDiff;
}
if (a.id !== b.id) {
return a.id - b.id;
}
return a.originalIndex - b.originalIndex;
}
function compareFullSubtitleCandidate(a, b) {
const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
if (defaultDiff !== 0) {
return defaultDiff;
}
if (a.id !== b.id) {
return a.id - b.id;
}
return a.originalIndex - b.originalIndex;
}
function resolveDeterministicSubtitleCliSelectionForPreview({
subtitleTracks,
subtitleVariantSelection,
subtitleLanguageOrder
}) {
const tracks = (Array.isArray(subtitleTracks) ? subtitleTracks : [])
.map((track, index) => {
const id = normalizeTrackId(track?.id ?? track?.sourceTrackId);
if (id === null) {
return null;
}
if (isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track)) {
return null;
}
const flags = resolveSubtitleTrackVariantFlags(track);
return {
id,
language: normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und'),
isForcedOnly: flags.isForcedOnly,
fullHasForced: flags.fullHasForced,
defaultFlag: Boolean(track?.defaultFlag ?? track?.subtitlePreviewDefaultTrack ?? track?.defaultTrack),
confidence: normalizeSubtitleConfidenceValue(track?.sourceConfidence || track?.confidence),
selectedForEncode: Boolean(track?.selectedForEncode),
originalIndex: index
};
})
.filter(Boolean);
const grouped = new Map();
for (const track of tracks) {
if (!grouped.has(track.language)) {
grouped.set(track.language, []);
}
grouped.get(track.language).push(track);
}
const normalizedVariantSelection = normalizeSubtitleVariantSelectionMap(subtitleVariantSelection || {});
const hasExplicitVariantSelection = Object.keys(normalizedVariantSelection).length > 0;
const normalizedLanguageOrder = normalizeSubtitleLanguageOrder(subtitleLanguageOrder || []);
const sortedLanguages = Array.from(grouped.entries())
.map(([language, languageTracks]) => ({
language,
minId: languageTracks.reduce((minValue, track) => Math.min(minValue, track.id), Number.MAX_SAFE_INTEGER)
}))
.sort((a, b) => a.minId - b.minId || a.language.localeCompare(b.language))
.map((item) => item.language);
const order = [];
const seen = new Set();
const pushLanguage = (language) => {
if (!grouped.has(language) || seen.has(language)) {
return;
}
seen.add(language);
order.push(language);
};
normalizedLanguageOrder.forEach(pushLanguage);
sortedLanguages.forEach(pushLanguage);
const subtitleTrackIds = [];
const subtitleForcedTrackIndexes = [];
for (const language of order) {
const languageTracks = grouped.get(language) || [];
const forcedOnly = languageTracks.filter((track) => track.isForcedOnly).sort(compareForcedSubtitleCandidate)[0] || null;
const fullTracks = languageTracks.filter((track) => !track.isForcedOnly).sort(compareFullSubtitleCandidate);
const bestFull = fullTracks[0] || null;
const bestFullHasForced = fullTracks.filter((track) => track.fullHasForced).sort(compareFullSubtitleCandidate)[0] || null;
const explicit = normalizedVariantSelection[language] || null;
const fallbackFull = languageTracks.some((track) => track.selectedForEncode && !track.isForcedOnly) || Boolean(bestFull);
const fallbackForced = languageTracks.some((track) => track.selectedForEncode && track.isForcedOnly) || Boolean(forcedOnly);
const requestedFull = explicit
? Boolean(explicit.full)
: (hasExplicitVariantSelection ? false : fallbackFull);
const requestedForced = explicit
? Boolean(explicit.forced)
: (hasExplicitVariantSelection ? false : fallbackForced);
if (!requestedFull && !requestedForced) {
continue;
}
if (forcedOnly) {
subtitleTrackIds.push(forcedOnly.id);
if (requestedFull && bestFull) {
subtitleTrackIds.push(bestFull.id);
}
continue;
}
if (bestFullHasForced) {
if (requestedFull && requestedForced) {
subtitleTrackIds.push(bestFullHasForced.id);
subtitleTrackIds.push(bestFullHasForced.id);
subtitleForcedTrackIndexes.push(subtitleTrackIds.length);
} else if (requestedForced) {
subtitleTrackIds.push(bestFullHasForced.id);
subtitleForcedTrackIndexes.push(subtitleTrackIds.length);
} else if (requestedFull) {
subtitleTrackIds.push(bestFullHasForced.id);
}
continue;
}
if (requestedFull && bestFull) {
subtitleTrackIds.push(bestFull.id);
}
}
return {
subtitleTrackIds,
subtitleForcedTrackIndexes
};
}
function splitArgs(input) {
if (!input || typeof input !== 'string') {
return [];
}
const args = [];
let current = '';
let quote = null;
let escaping = false;
for (const ch of input) {
if (escaping) {
current += ch;
escaping = false;
continue;
}
if (ch === '\\') {
escaping = true;
continue;
}
if (quote) {
if (ch === quote) {
quote = null;
} else {
current += ch;
}
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current.length > 0) {
args.push(current);
current = '';
}
continue;
}
current += ch;
}
if (current.length > 0) {
args.push(current);
}
return args;
}
const AUDIO_SELECTION_KEYS_WITH_VALUE = new Set(['-a', '--audio', '--audio-lang-list']);
const AUDIO_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-audio', '--first-audio']);
const SUBTITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-s', '--subtitle', '--subtitle-lang-list']);
const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-subtitle']);
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
function removeSelectionArgs(extraArgs) {
const args = Array.isArray(extraArgs) ? extraArgs : [];
const filtered = [];
for (let i = 0; i < args.length; i += 1) {
const token = String(args[i] || '');
const key = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key);
const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key);
const isSubtitleWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key)
|| SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key);
const isSubtitleFlagOnly = SUBTITLE_SELECTION_KEYS_FLAG_ONLY.has(key);
const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key);
const skip = isAudioWithValue || isAudioFlagOnly || isSubtitleWithValue || isSubtitleFlagOnly || isTitleWithValue;
if (!skip) {
filtered.push(token);
continue;
}
if ((isAudioWithValue || isSubtitleWithValue || isTitleWithValue) && !token.includes('=')) {
const nextToken = String(args[i + 1] || '');
if (nextToken && !nextToken.startsWith('-')) {
i += 1;
}
}
}
return filtered;
}
function shellQuote(value) {
const raw = String(value ?? '');
if (raw.length === 0) {
return "''";
}
if (/^[A-Za-z0-9_./:=,+-]+$/.test(raw)) {
return raw;
}
return `'${raw.replace(/'/g, `'"'"'`)}'`;
}
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 sanitizePathSegment(value) {
return transliterateForFilename(String(value || ''))
.replace(/[^a-zA-Z0-9 ().[\]_+-]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function sanitizeOutputPathForCommand(pathValue) {
const raw = String(pathValue || '').trim();
if (!raw) {
return null;
}
const normalizedPath = raw.replace(/\\/g, '/');
const hasLeadingSlash = normalizedPath.startsWith('/');
const segments = normalizedPath
.split('/')
.map((segment) => sanitizePathSegment(segment))
.filter(Boolean);
if (segments.length === 0) {
return hasLeadingSlash ? '/' : null;
}
return `${hasLeadingSlash ? '/' : ''}${segments.join('/')}`;
}
function buildHandBrakeCommandPreview({
review,
title,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
selectedSubtitleVariantSelection = {},
selectedSubtitleLanguageOrder = [],
commandOutputPath = null,
presetOverride = null
}) {
const inputPath = String(title?.filePath || review?.encodeInputPath || '').trim();
const handBrakeCmd = String(
review?.selectors?.handbrakeCommand
|| review?.selectors?.handBrakeCommand
|| 'HandBrakeCLI'
).trim() || 'HandBrakeCLI';
const preset = presetOverride !== null
? String(presetOverride.handbrakePreset || '').trim()
: String(review?.selectors?.preset || '').trim();
const extraArgs = presetOverride !== null
? String(presetOverride.extraArgs || '').trim()
: String(review?.selectors?.extraArgs || '').trim();
const rawMappedTitleId = Number(
title?.handBrakeTitleId
?? title?.titleIndex
?? title?.id
?? review?.handBrakeTitleId
);
const mappedTitleId = Number.isFinite(rawMappedTitleId) && rawMappedTitleId > 0
? Math.trunc(rawMappedTitleId)
: null;
const resolvedSubtitleSelection = resolveDeterministicSubtitleCliSelectionForPreview({
subtitleTracks: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [],
subtitleVariantSelection: selectedSubtitleVariantSelection,
subtitleLanguageOrder: selectedSubtitleLanguageOrder
});
const normalizedSelectedVariantSelection = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
const hasExplicitSelectedVariantSelection = Object.keys(normalizedSelectedVariantSelection).length > 0;
const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIds.length > 0
? resolvedSubtitleSelection.subtitleTrackIds
: (hasExplicitSelectedVariantSelection
? []
: normalizeTrackIdSequence(selectedSubtitleTrackIds, { dedupe: false }));
const selectedSubtitleSet = new Set(normalizeTrackIdList(subtitleTrackIds).map((id) => String(id)));
const selectedSubtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).filter((track) => {
const id = normalizeTrackId(track?.id);
return id !== null && selectedSubtitleSet.has(String(id));
});
const subtitleBurnTrackId = normalizeTrackIdList(
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewBurnIn || track?.burnIn)).map((track) => track?.id)
)[0] || null;
const subtitleDefaultTrackId = normalizeTrackIdList(
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id)
)[0] || null;
const sanitizedOutputPath = sanitizeOutputPathForCommand(commandOutputPath);
const baseArgs = [
'-i',
inputPath || '<encode-input>',
'-o',
String(sanitizedOutputPath || '').trim() || '<encode-output>'
];
if (mappedTitleId !== null) {
baseArgs.push('-t', String(mappedTitleId));
}
if (preset) {
baseArgs.push('-Z', preset);
}
const filteredExtra = removeSelectionArgs(splitArgs(extraArgs));
const overrideArgs = [
'-a',
normalizeTrackIdList(selectedAudioTrackIds).join(',') || 'none',
'-s',
subtitleTrackIds.length > 0 ? subtitleTrackIds.join(',') : 'none'
];
if (subtitleBurnTrackId !== null) {
overrideArgs.push(`--subtitle-burned=${subtitleBurnTrackId}`);
}
if (subtitleDefaultTrackId !== null) {
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
}
if (resolvedSubtitleSelection.subtitleForcedTrackIndexes.length > 0) {
overrideArgs.push(`--subtitle-forced=${resolvedSubtitleSelection.subtitleForcedTrackIndexes.join(',')}`);
}
const finalArgs = [...baseArgs, ...filteredExtra, ...overrideArgs];
return `${handBrakeCmd} ${finalArgs.map((arg) => shellQuote(arg)).join(' ')}`;
}
function toLang2(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return 'und';
}
const map = {
en: 'en',
eng: 'en',
de: 'de',
deu: 'de',
ger: 'de',
tr: 'tr',
tur: 'tr',
fr: 'fr',
fra: 'fr',
fre: 'fr',
es: 'es',
spa: 'es',
it: 'it',
ita: 'it'
};
if (map[raw]) {
return map[raw];
}
if (raw.length === 2) {
return raw;
}
if (raw.length >= 3) {
return raw.slice(0, 2);
}
return raw;
}
function simplifyCodec(type, value, hint = null) {
const raw = String(value || '').trim();
const hintRaw = String(hint || '').trim();
const lower = raw.toLowerCase();
const merged = `${raw} ${hintRaw}`.toLowerCase();
if (!raw) {
return '-';
}
if (type === 'subtitle') {
if (merged.includes('pgs')) {
return 'PGS';
}
return raw.toUpperCase();
}
if (merged.includes('dts-hd ma') || merged.includes('dts hd ma')) {
return 'DTS-HD MA';
}
if (merged.includes('dts-hd hra') || merged.includes('dts hd hra')) {
return 'DTS-HD HRA';
}
if (merged.includes('dts-hd') || merged.includes('dts hd')) {
return 'DTS-HD';
}
if (merged.includes('dts') || merged.includes('dca')) {
return 'DTS';
}
if (merged.includes('truehd')) {
return 'TRUEHD';
}
if (merged.includes('e-ac-3') || merged.includes('eac3') || merged.includes('dd+')) {
return 'E-AC-3';
}
if (merged.includes('ac-3') || merged.includes('ac3') || merged.includes('dolby digital')) {
return 'AC-3';
}
const numeric = Number(raw);
if (Number.isFinite(numeric)) {
if (numeric === 262144) {
return 'DTS-HD';
}
if (numeric === 131072) {
return 'DTS';
}
}
return raw.toUpperCase();
}
function extractAudioVariant(hint) {
const raw = String(hint || '').trim();
if (!raw) {
return '';
}
const paren = raw.match(/\(([^)]+)\)/);
if (!paren) {
return '';
}
const parts = paren[1]
.split(',')
.map((item) => item.trim())
.filter(Boolean);
const extras = parts.filter((item) => {
const lower = item.toLowerCase();
if (lower.includes('dts') || lower.includes('ac3') || lower.includes('e-ac3') || lower.includes('eac3')) {
return false;
}
if (/\d+(?:\.\d+)?\s*ch/i.test(item)) {
return false;
}
if (/\d+\s*kbps/i.test(lower)) {
return false;
}
return true;
});
return extras.join(', ');
}
function channelCount(rawValue) {
const raw = String(rawValue || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (raw.includes('7.1')) {
return 8;
}
if (raw.includes('5.1')) {
return 6;
}
if (raw.includes('stereo') || raw.includes('2.0') || raw.includes('downmix')) {
return 2;
}
if (raw.includes('mono') || raw.includes('1.0')) {
return 1;
}
const numeric = Number(raw);
if (Number.isFinite(numeric) && numeric > 0) {
if (Math.abs(numeric - 7.1) < 0.2) {
return 8;
}
if (Math.abs(numeric - 5.1) < 0.2) {
return 6;
}
return Math.trunc(numeric);
}
const match = raw.match(/(\d+)\s*ch/);
if (match) {
const value = Number(match[1]);
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : null;
}
return null;
}
function audioChannelLabel(rawValue) {
const raw = String(rawValue || '').trim().toLowerCase();
const count = channelCount(rawValue);
if (raw.includes('7.1') || count === 8) {
return 'Surround 7.1';
}
if (raw.includes('5.1') || count === 6) {
return 'Surround 5.1';
}
if (raw.includes('stereo') || raw.includes('2.0') || raw.includes('downmix') || count === 2) {
return 'Stereo';
}
if (count === 1) {
return 'Mono';
}
return '';
}
const DEFAULT_AUDIO_FALLBACK_PREVIEW = 'av_aac';
function mapTrackToCopyCodec(track) {
const raw = [
track?.codecToken,
track?.format,
track?.codecName,
track?.description,
track?.title
]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ');
if (!raw) {
return null;
}
if (raw.includes('e-ac-3') || raw.includes('eac3') || raw.includes('dd+')) {
return 'eac3';
}
if (raw.includes('ac-3') || raw.includes('ac3') || raw.includes('dolby digital')) {
return 'ac3';
}
if (raw.includes('truehd')) {
return 'truehd';
}
if (raw.includes('dts-hd') || raw.includes('dtshd')) {
return 'dtshd';
}
if (raw.includes('dca') || raw.includes('dts')) {
return 'dts';
}
if (raw.includes('aac')) {
return 'aac';
}
if (raw.includes('flac')) {
return 'flac';
}
if (raw.includes('mp3') || raw.includes('mpeg audio')) {
return 'mp3';
}
if (raw.includes('opus')) {
return 'opus';
}
if (raw.includes('pcm') || raw.includes('lpcm')) {
return 'lpcm';
}
return null;
}
function resolveAudioEncoderPreviewLabel(track, encoderToken, copyMask, fallbackEncoder) {
const normalizedToken = String(encoderToken || '').trim().toLowerCase();
if (!normalizedToken || normalizedToken === 'preset-default') {
return 'Preset-Default (HandBrake)';
}
if (normalizedToken.startsWith('copy')) {
const sourceCodec = mapTrackToCopyCodec(track);
const explicitCopyCodec = normalizedToken.includes(':')
? normalizedToken.split(':').slice(1).join(':').trim().toLowerCase()
: null;
const normalizedCopyMask = Array.isArray(copyMask)
? copyMask.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
: [];
let canCopy = false;
let effectiveCodec = sourceCodec;
if (explicitCopyCodec) {
canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec);
} else if (sourceCodec && normalizedCopyMask.length > 0) {
canCopy = normalizedCopyMask.includes(sourceCodec);
// DTS-HD MA contains an embedded DTS core. When dtshd is not in the copy
// mask but dts is, HandBrake will extract and copy the DTS core layer.
if (!canCopy && sourceCodec === 'dtshd' && normalizedCopyMask.includes('dts')) {
canCopy = true;
effectiveCodec = 'dts';
}
}
if (canCopy) {
return `Copy (${effectiveCodec || track?.format || 'Quelle'})`;
}
const fallback = String(fallbackEncoder || DEFAULT_AUDIO_FALLBACK_PREVIEW).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK_PREVIEW;
return `Fallback Transcode (${fallback})`;
}
return `Transcode (${normalizedToken})`;
}
function parseAudioSelectorFromArgs(extraArgsString, baseSelector) {
const base = baseSelector && typeof baseSelector === 'object' ? baseSelector : {};
const args = String(extraArgsString || '').trim();
if (!args) {
return base;
}
// Tokenize: split on whitespace but respect quoted strings
const tokens = [];
let current = '';
let inQuote = null;
for (let i = 0; i < args.length; i++) {
const ch = args[i];
if (inQuote) {
if (ch === inQuote) {
inQuote = null;
} else {
current += ch;
}
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === ' ' || ch === '\t') {
if (current) {
tokens.push(current);
current = '';
}
} else {
current += ch;
}
}
if (current) {
tokens.push(current);
}
const result = { ...base };
const getNext = (i) => (i + 1 < tokens.length ? tokens[i + 1] : null);
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
// Support both --flag=value and --flag value
const eqIdx = token.indexOf('=');
const flag = eqIdx !== -1 ? token.slice(0, eqIdx) : token;
const inlineVal = eqIdx !== -1 ? token.slice(eqIdx + 1) : null;
const getValue = () => {
if (inlineVal !== null) return inlineVal;
const next = getNext(i);
if (next && !next.startsWith('-')) {
i++;
return next;
}
return null;
};
if (flag === '--aencoder') {
const val = getValue();
if (val) {
result.encoders = val.split(',').map((s) => s.trim()).filter(Boolean);
result.encoderSource = 'args';
}
} else if (flag === '--audio-copy-mask') {
const val = getValue();
if (val) {
result.copyMask = val.split(',').map((s) => s.trim()).filter(Boolean);
}
} else if (flag === '--audio-fallback') {
const val = getValue();
if (val) {
result.fallbackEncoder = val.trim();
}
}
}
return result;
}
function buildAudioActionPreviewSummary(track, selectedIndex, audioSelector) {
const selector = audioSelector && typeof audioSelector === 'object' ? audioSelector : {};
const availableEncoders = Array.isArray(selector.encoders) ? selector.encoders : [];
let encoderPlan = [];
if (selector.encoderSource === 'args' && availableEncoders.length > 0) {
const safeIndex = Number.isFinite(selectedIndex) && selectedIndex >= 0 ? selectedIndex : 0;
encoderPlan = [availableEncoders[Math.min(safeIndex, availableEncoders.length - 1)]];
} else if (availableEncoders.length > 0) {
encoderPlan = [...availableEncoders];
} else {
encoderPlan = ['preset-default'];
}
const labels = encoderPlan
.map((token) => resolveAudioEncoderPreviewLabel(track, token, selector.copyMask, selector.fallbackEncoder))
.filter(Boolean);
return labels.join(' + ') || 'Übernehmen';
}
function TrackList({
title,
tracks,
type = 'generic',
allowSelection = false,
selectedTrackIds = [],
selectedSubtitleVariantSelection = {},
selectedSubtitleLanguageOrder = [],
onToggleTrack = null,
onToggleSubtitleVariant = null,
audioSelector = null
}) {
const orderedTracks = (Array.isArray(tracks) ? [...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 || '').localeCompare(String(b?.language || ''));
});
if (type === 'subtitle') {
const selectedVariants = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
const sortedSubtitleTracks = orderedTracks
.filter((track) => !isDuplicateSubtitleTrack(track));
const renderVariantRow = ({
key,
checked,
disabled,
onChange,
text,
confidence = null,
indent = false
}) => {
const confidenceColor = confidence === 'high'
? '#2e7d32'
: confidence === 'medium'
? '#f57c00'
: '#c62828';
return (
<div key={key} className="media-track-item" style={indent ? { paddingLeft: '1.4rem' } : null}>
<label className="readonly-check-row">
<input
type="checkbox"
checked={checked}
onChange={onChange}
readOnly={disabled}
disabled={disabled}
/>
<span>
{text}
{confidence ? <span style={{ color: confidenceColor, marginLeft: '0.35rem' }}></span> : null}
</span>
</label>
</div>
);
};
const resolveSubtitleActionInfo = (track, selected) => {
const base = String(track?.subtitlePreviewSummary || track?.subtitleActionSummary || '').trim();
if (allowSelection) {
return selected ? 'Übernehmen' : 'Nicht übernommen';
}
return base || (selected ? 'Übernehmen' : 'Nicht übernommen');
};
return (
<div>
<h4>{title}</h4>
{!tracks || tracks.length === 0 ? (
<p>Keine Einträge.</p>
) : (
<div className="media-track-list">
{sortedSubtitleTracks.map((track) => {
const burned = isBurnedSubtitleTrack(track);
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
const displayLanguage = toLang2(track?.language || track?.languageLabel || 'und');
const displayCodec = simplifyCodec('subtitle', track?.format, track?.description || track?.title);
const flags = resolveSubtitleTrackVariantFlags(track);
const confidenceRaw = String(track?.sourceConfidence || '').trim().toLowerCase();
const confidence = confidenceRaw === 'high' || confidenceRaw === 'medium' || confidenceRaw === 'low'
? confidenceRaw
: 'low';
const languageSelection = selectedVariants[language] || { full: false, forced: false };
if (flags.isForcedOnly) {
const checked = allowSelection ? Boolean(languageSelection.forced) : Boolean(track?.selectedForEncode);
const disabled = !allowSelection || burned;
const actionInfo = resolveSubtitleActionInfo(track, checked);
return (
<div key={`${title}-${track.id}-forced-only-group`} className="media-track-item">
{renderVariantRow({
key: `${title}-${track.id}-forced-only`,
checked,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'forced', event.target.checked);
},
text: `#${track.id} | ${displayLanguage} | Automatische Übersetzungen (${displayCodec})`,
confidence
})}
<small className="track-action-note">Untertitel: {actionInfo}</small>
</div>
);
}
if (flags.fullHasForced) {
const checkedFull = allowSelection ? Boolean(languageSelection.full) : Boolean(track?.selectedForEncode);
const checkedForced = allowSelection ? Boolean(languageSelection.forced) : false;
const disabled = !allowSelection || burned;
const actionInfo = resolveSubtitleActionInfo(track, checkedFull || checkedForced);
return (
<div key={`${title}-${track.id}-combo`} className="media-track-item">
<div className="media-track-item">
<small className="track-action-note">{`#${track.id} | ${displayLanguage}`}</small>
</div>
{renderVariantRow({
key: `${title}-${track.id}-combo-full`,
checked: checkedFull,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'full', event.target.checked);
},
text: `Komplette Untertitel (${displayCodec})`,
indent: true
})}
{renderVariantRow({
key: `${title}-${track.id}-combo-forced`,
checked: checkedForced,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'forced', event.target.checked);
},
text: `Automatische Übersetzungen (${displayCodec})`,
confidence,
indent: true
})}
<small className="track-action-note">Untertitel: {actionInfo}</small>
</div>
);
}
const checked = allowSelection ? Boolean(languageSelection.full) : Boolean(track?.selectedForEncode);
const disabled = !allowSelection || burned;
const actionInfo = resolveSubtitleActionInfo(track, checked);
return (
<div key={`${title}-${track.id}-full-group`} className="media-track-item">
{renderVariantRow({
key: `${title}-${track.id}-full`,
checked,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'full', event.target.checked);
},
text: `#${track.id} | ${displayLanguage} | Komplette Untertitel (${displayCodec})`
})}
<small className="track-action-note">Untertitel: {actionInfo}</small>
</div>
);
})}
</div>
)}
<div className="subtitle-footnote-list">
<small className="track-action-note">
Automatische Übersetzungen werden nur eingeblendet, wenn im Film eine andere Sprache gesprochen wird. Komplette Untertitel zeigen alle Dialoge an. Blu-ray Untertitel liegen meist als PGS (Bildformat) vor.
</small>
<small className="track-action-note">
Confidence-Index (nur für Automatische Übersetzungen): <span style={{ color: '#2e7d32' }}></span> high = explizites Track-Signal erkannt, <span style={{ color: '#f57c00' }}></span> medium = "forced" im Titel erkannt, <span style={{ color: '#c62828' }}></span> low = heuristische Einschätzung.
</small>
</div>
</div>
);
}
const selectedIds = normalizeTrackIdList(selectedTrackIds);
const checkedTrackOrder = orderedTracks
.map((track) => normalizeTrackId(track?.id))
.filter((trackId, index) => {
if (trackId === null) {
return false;
}
if (allowSelection) {
return selectedIds.includes(trackId);
}
const track = orderedTracks[index];
return Boolean(track?.selectedForEncode);
});
return (
<div>
<h4>{title}</h4>
{!tracks || tracks.length === 0 ? (
<p>Keine Einträge.</p>
) : (
<div className="media-track-list">
{orderedTracks.map((track) => {
const trackId = normalizeTrackId(track.id);
const checked = allowSelection
? (trackId !== null && selectedIds.includes(trackId))
: Boolean(track.selectedForEncode);
const selectedIndex = trackId !== null
? checkedTrackOrder.indexOf(trackId)
: -1;
const actionInfo = type === 'audio'
? (checked
? (() => {
// When encoder comes from user preset extraArgs, always recompute
// (server-stored summary reflects a different/default preset)
if (audioSelector?.encoderSource === 'args') {
return buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
}
const base = String(track.encodePreviewSummary || track.encodeActionSummary || '').trim();
const staleUnselectedSummary = /^nicht übernommen$/i.test(base);
if (staleUnselectedSummary) {
return buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
}
return base || buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
})()
: 'Nicht übernommen')
: null;
const displayLanguage = toLang2(track.language || track.languageLabel || 'und');
const displayHint = track.description || track.title;
const displayCodec = simplifyCodec(type, track.format, displayHint);
const displayChannelCount = channelCount(track.channels);
const displayAudioTitle = audioChannelLabel(track.channels);
const audioVariant = type === 'audio' ? extractAudioVariant(displayHint) : '';
const disabled = !allowSelection;
let displayText = `#${track.id} | ${displayLanguage} | ${displayCodec}`;
if (type === 'audio') {
if (displayChannelCount !== null) {
displayText += ` | ${displayChannelCount}ch`;
}
if (displayAudioTitle) {
displayText += ` | ${displayAudioTitle}`;
}
if (audioVariant) {
displayText += ` | ${audioVariant}`;
}
}
return (
<div key={`${title}-${track.id}`} className="media-track-item">
<label className="readonly-check-row">
<input
type="checkbox"
checked={checked}
onChange={(event) => {
if (disabled || typeof onToggleTrack !== 'function' || trackId === null) {
return;
}
onToggleTrack(trackId, event.target.checked);
}}
readOnly={disabled}
disabled={disabled}
/>
<span>
{displayText}
</span>
</label>
{actionInfo ? <small className="track-action-note">Encode: {actionInfo}</small> : null}
</div>
);
})}
</div>
)}
</div>
);
}
function normalizeTitleId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeScriptId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeChainId(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;
}
export default function MediaInfoReviewPanel({
review,
presetDisplayValue = '',
commandOutputPath = null,
commandOutputPathByTitle = {},
selectedEncodeTitleId = null,
selectedEncodeTitleIds = [],
allowTitleSelection = false,
compactTitleSelection = false,
onSelectEncodeTitle = null,
onToggleEncodeTitle = null,
allowTrackSelection = false,
trackSelectionByTitle = {},
onTrackSelectionChange = null,
onSubtitleVariantSelectionChange = null,
selectedMetadataEpisodes = [],
selectedEpisodeFillStart = null,
onEpisodeFillStartChange = null,
episodeAssignmentsByTitle = {},
onEpisodeAssignmentChange = null,
availableScripts = [],
availableChains = [],
preEncodeItems = [],
postEncodeItems = [],
allowEncodeItemSelection = false,
onAddPreEncodeItem = null,
onChangePreEncodeItem = null,
onRemovePreEncodeItem = null,
onReorderPreEncodeItem = null,
onAddPostEncodeItem = null,
onChangePostEncodeItem = null,
onRemovePostEncodeItem = null,
onReorderPostEncodeItem = null,
userPresets = [],
selectedUserPresetId = null,
onUserPresetChange = null,
selectedHandBrakePreset = ''
}) {
if (!review) {
return <p>Keine Mediainfo-Daten vorhanden.</p>;
}
const titles = review.titles || [];
const candidateTitles = compactTitleSelection && Array.isArray(review?.handBrakeTitleCandidates)
? review.handBrakeTitleCandidates
.map((candidate) => {
const id = normalizeTitleId(candidate?.handBrakeTitleId);
if (id === null) return null;
const durationSeconds = Number(candidate?.durationSeconds || 0);
const durationMinutes = Number.isFinite(durationSeconds) && durationSeconds > 0
? Number((durationSeconds / 60).toFixed(2))
: Number(candidate?.durationMinutes || 0);
return {
id,
fileName: `Titel #${id}`,
durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0,
durationMinutes: Number.isFinite(durationMinutes) ? durationMinutes : 0,
sizeBytes: Number(candidate?.sizeBytes || 0),
audioTrackCount: Number(candidate?.audioTrackCount || 0),
subtitleTrackCount: Number(candidate?.subtitleTrackCount || 0),
audioTracks: [],
subtitleTracks: [],
selectedForEncode: false
};
})
.filter(Boolean)
: null;
const displayTitles = candidateTitles && candidateTitles.length > 0 ? candidateTitles : titles;
const selectedTitleIds = normalizeTrackIdList([
...normalizeTrackIdList(selectedEncodeTitleIds),
...normalizeTrackIdList(review?.selectedTitleIds || []),
...displayTitles.filter((item) => Boolean(item?.selectedForEncode)).map((item) => item?.id)
]);
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId)
|| normalizeTitleId(review.encodeInputTitleId)
|| selectedTitleIds[0]
|| null;
const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id)));
const encodeInputTitle = displayTitles.find((item) => item.id === currentSelectedId) || null;
const normalizedEpisodeRows = (Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [])
.map((episode) => {
const episodeId = Number(episode?.episodeId ?? episode?.id ?? 0);
const episodeIdStart = Number(episode?.episodeIdStart ?? episode?.idStart ?? episode?.episodeId ?? episode?.id ?? 0);
const episodeIdEnd = Number(episode?.episodeIdEnd ?? episode?.idEnd ?? 0);
const episodeNumberStart = Number(
episode?.episodeNumberStart
?? episode?.numberStart
?? episode?.episodeNoStart
?? episode?.episodeNumber
?? episode?.number
?? 0
);
const episodeNumberEnd = Number(
episode?.episodeNumberEnd
?? episode?.numberEnd
?? episode?.episodeNoEnd
?? 0
);
const episodeSpan = Number(episode?.episodeSpan ?? episode?.episodeCount ?? 0);
const episodeNumber = Number(episode?.episodeNumber ?? episode?.number ?? episodeNumberStart ?? 0);
const seasonNumber = Number(episode?.seasonNumber ?? episode?.season ?? 0);
const episodeTitle = String(episode?.episodeTitle || episode?.name || episode?.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: episodeTitle || null
};
})
.filter((episode) => episode.episodeId || episode.episodeNumber !== null);
const selectedTitlesForFill = selectedTitleIds.length > 0
? displayTitles.filter((title) => selectedTitleIdSet.has(String(normalizeTitleId(title?.id))))
: displayTitles.filter((title) => Boolean(title?.selectedForEncode));
const titleAnchorsByEpisodeIndex = (() => {
const anchors = new Map();
const rows = Array.isArray(normalizedEpisodeRows) ? normalizedEpisodeRows : [];
if (rows.length === 0) {
return anchors;
}
let cursor = 0;
for (const title of selectedTitlesForFill) {
if (cursor >= rows.length) {
break;
}
const explicitSpan = normalizePositiveInt(
title?.episodeSpan
?? title?.episodeCount
?? null
);
const estimatedSpan = estimateEpisodeSpanForFill(title, selectedTitlesForFill);
const span = Math.max(1, explicitSpan || 1, estimatedSpan || 1);
const safeSpan = Math.max(1, Math.min(span, rows.length - cursor));
anchors.set(cursor, { span: safeSpan });
cursor += safeSpan;
}
return anchors;
})();
const fallbackEpisodeFillOptions = (() => {
const rows = Array.isArray(normalizedEpisodeRows) ? normalizedEpisodeRows : [];
const options = [];
let index = 0;
while (index < rows.length) {
const startRow = rows[index];
const seasonNumber = normalizePositiveInt(startRow?.seasonNumber ?? null);
const seasonLabel = seasonNumber !== null ? `S${String(seasonNumber).padStart(2, '0')}` : 'S??';
const rangeStart = normalizePositiveInt(
startRow?.episodeNumberStart
?? startRow?.episodeNoStart
?? startRow?.episodeNumber
?? null
);
const explicitRangeEnd = normalizePositiveInt(
startRow?.episodeNumberEnd
?? startRow?.episodeNoEnd
?? null
);
const explicitRangeSpan = normalizePositiveInt(
startRow?.episodeSpan
?? startRow?.episodeCount
?? null
);
let consumedRows = 1;
let rangeEnd = explicitRangeEnd || rangeStart;
if (
rangeStart
&& (
(explicitRangeEnd && explicitRangeEnd > rangeStart)
|| (explicitRangeSpan && explicitRangeSpan > 1)
)
) {
const effectiveSpan = explicitRangeEnd && explicitRangeEnd > rangeStart
? Math.max(1, explicitRangeEnd - rangeStart + 1)
: Math.max(1, explicitRangeSpan || 1);
rangeEnd = rangeStart + effectiveSpan - 1;
consumedRows = 1;
} else {
const anchor = titleAnchorsByEpisodeIndex.get(index) || null;
const anchorSpan = normalizePositiveInt(anchor?.span) || 1;
if (anchorSpan > 1) {
const endIndex = Math.min(rows.length - 1, index + anchorSpan - 1);
const endRow = rows[endIndex];
const endRowStart = normalizePositiveInt(
endRow?.episodeNumberStart
?? endRow?.episodeNoStart
?? endRow?.episodeNumber
?? null
);
rangeEnd = endRowStart || (rangeStart ? (rangeStart + anchorSpan - 1) : null);
consumedRows = anchorSpan;
}
}
if (rangeStart && rangeEnd && rangeEnd < rangeStart) {
rangeEnd = rangeStart;
}
const isMulti = Boolean(rangeStart && rangeEnd && rangeEnd > rangeStart);
const episodeRangeToken = buildEpisodeRangeToken(rangeStart, rangeEnd);
const partsToken = buildEpisodePartsToken(rangeStart, rangeEnd);
const baseTitle = String(startRow?.episodeTitle || '(ohne Titel)').trim() || '(ohne Titel)';
const normalizedTitle = normalizeEpisodeFillTitle(baseTitle, isMulti);
const titleLabel = isMulti
? `${normalizedTitle} (Teil ${partsToken})`
: normalizedTitle;
const episodeLabel = rangeStart ? `E${episodeRangeToken}` : 'E??';
const value = String(
startRow?.episodeIdStart
|| startRow?.episodeId
|| rangeStart
|| ''
);
if (value) {
options.push({
label: `${seasonLabel}${episodeLabel} - ${titleLabel}`,
value,
startRef: value
});
}
index += Math.max(1, consumedRows);
}
return options;
})();
const episodeFillOptions = fallbackEpisodeFillOptions;
const selectedEpisodeFillValue = (() => {
const raw = String(selectedEpisodeFillStart || '').trim();
if (!raw) {
return null;
}
const direct = episodeFillOptions.find((option) => String(option?.value || '').trim() === raw);
if (direct) {
return direct.value;
}
const byStartRef = episodeFillOptions.find((option) => String(option?.startRef || '').trim() === raw);
if (byStartRef) {
return byStartRef.value;
}
const tokenMatch = raw.match(/^fill:([^:]+):title:\d+$/i);
if (tokenMatch && tokenMatch[1]) {
const byTokenRef = episodeFillOptions.find((option) => String(option?.startRef || '').trim() === String(tokenMatch[1]).trim());
if (byTokenRef) {
return byTokenRef.value;
}
}
return null;
})();
const processedFiles = Number(review.processedFiles || displayTitles.length || 0);
const totalFiles = Number(review.totalFiles || displayTitles.length || 0);
const playlistRecommendation = review.playlistRecommendation || null;
const rawPreset = String(review.selectors?.preset || '').trim();
const presetLabel = String(presetDisplayValue || rawPreset).trim() || '(kein Preset)';
// User preset resolution
const normalizedUserPresets = Array.isArray(userPresets) ? userPresets : [];
const selectedUserPreset = selectedUserPresetId
? normalizedUserPresets.find((p) => Number(p.id) === Number(selectedUserPresetId)) || null
: null;
const hasUserPresets = normalizedUserPresets.length > 0;
const allowUserPresetSelection = hasUserPresets && typeof onUserPresetChange === 'function' && allowEncodeItemSelection;
const isUserPresetActive = Boolean(selectedUserPreset);
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
const isHandBrakePresetActive = !isUserPresetActive && Boolean(trimmedHandBrakePreset);
const effectivePresetOverride = isUserPresetActive
? { handbrakePreset: selectedUserPreset.handbrakePreset || '', extraArgs: selectedUserPreset.extraArgs || '' }
: (isHandBrakePresetActive ? { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' } : null);
const effectiveAudioSelector = effectivePresetOverride?.extraArgs
? parseAudioSelectorFromArgs(effectivePresetOverride.extraArgs, review?.selectors?.audio)
: (review?.selectors?.audio || null);
const presetSourceLabel = isUserPresetActive
? `User-Preset: ${selectedUserPreset?.name || 'Preset'}`
: (isHandBrakePresetActive ? 'HandBrake-Preset' : '');
const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : [])
.map((item) => ({
id: normalizeScriptId(item?.id),
name: String(item?.name || '').trim()
}))
.filter((item) => item.id !== null && item.name.length > 0);
const scriptById = new Map(scriptCatalog.map((item) => [item.id, item]));
const chainCatalog = (Array.isArray(availableChains) ? availableChains : [])
.map((item) => ({ id: normalizeChainId(item?.id), name: String(item?.name || '').trim() }))
.filter((item) => item.id !== null && item.name.length > 0);
const chainById = new Map(chainCatalog.map((item) => [item.id, item]));
const makeHandleDrop = (items, onReorder) => (event, targetIndex) => {
if (!allowEncodeItemSelection || typeof onReorder !== 'function' || items.length < 2) {
return;
}
event.preventDefault();
const fromIndex = Number(event.dataTransfer?.getData('text/plain'));
if (!Number.isInteger(fromIndex)) {
return;
}
onReorder(fromIndex, targetIndex);
};
const handlePreDrop = makeHandleDrop(preEncodeItems, onReorderPreEncodeItem);
const handlePostDrop = makeHandleDrop(postEncodeItems, onReorderPostEncodeItem);
const resolveTitleSelectionState = (title) => {
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
const selectableSubtitleTrackIds = subtitleTracks
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const selectableSubtitleTrackIdSet = new Set(selectableSubtitleTrackIds.map((id) => String(id)));
const sortedSelectableSubtitleTracks = subtitleTracks
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.slice()
.sort((a, b) => (normalizeTrackId(a?.id) || Number.MAX_SAFE_INTEGER) - (normalizeTrackId(b?.id) || Number.MAX_SAFE_INTEGER));
const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const defaultSubtitleTrackIds = subtitleTracks
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const defaultSubtitleVariantSelection = {};
const defaultSubtitleLanguageOrder = [];
const subtitleLanguageOrderSeen = new Set();
for (const track of sortedSelectableSubtitleTracks) {
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!subtitleLanguageOrderSeen.has(language)) {
subtitleLanguageOrderSeen.add(language);
defaultSubtitleLanguageOrder.push(language);
}
if (!Boolean(track?.selectedByRule)) {
continue;
}
if (!defaultSubtitleVariantSelection[language]) {
defaultSubtitleVariantSelection[language] = { full: false, forced: false };
}
const flags = resolveSubtitleTrackVariantFlags(track);
if (flags.isForcedOnly) {
defaultSubtitleVariantSelection[language].forced = true;
} else {
defaultSubtitleVariantSelection[language].full = true;
}
}
const selectedAudioTrackIds = normalizeTrackIdList(
Array.isArray(titleSelectionEntry?.audioTrackIds)
? titleSelectionEntry.audioTrackIds
: defaultAudioTrackIds
);
const selectedSubtitleTrackIds = normalizeTrackIdList(
Array.isArray(titleSelectionEntry?.subtitleTrackIds)
? titleSelectionEntry.subtitleTrackIds
: defaultSubtitleTrackIds
).filter((id) => selectableSubtitleTrackIdSet.has(String(id)));
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
);
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
? titleSelectionEntry.subtitleLanguageOrder
: defaultSubtitleLanguageOrder
);
return {
titleSelectionEntry,
subtitleTracks,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
selectedSubtitleVariantSelection,
selectedSubtitleLanguageOrder
};
};
const normalizeTrackSignatureText = (value) => String(value || '').trim().toLowerCase();
const buildAudioSignature = (track) => [
normalizeTrackSignatureText(track?.language || track?.languageLabel || 'und'),
normalizeTrackSignatureText(track?.format || track?.codecName || ''),
normalizeTrackSignatureText(track?.channels || ''),
normalizeTrackSignatureText(track?.description || track?.title || '')
].join('|');
const buildSubtitleSignature = (track) => {
const flags = resolveSubtitleTrackVariantFlags(track);
return [
normalizeTrackSignatureText(track?.language || track?.languageLabel || 'und'),
normalizeTrackSignatureText(track?.format || track?.codecName || ''),
flags.isForcedOnly ? 'forced' : 'full',
flags.fullHasForced ? 'with_forced_variant' : 'plain'
].join('|');
};
const selectedTitlesForGlobal = selectedTitleIds.length > 0
? titles.filter((title) => selectedTitleIdSet.has(String(normalizeTitleId(title?.id))))
: titles;
const masterGlobalTitle = selectedTitlesForGlobal[0] || titles[0] || null;
const globalMasterSelectionState = masterGlobalTitle ? resolveTitleSelectionState(masterGlobalTitle) : null;
const globalAudioTrackMaps = new Map();
const globalSubtitleLanguagesByTitle = new Map();
const globalTrackCompatibility = (() => {
if (!masterGlobalTitle || selectedTitlesForGlobal.length < 2) {
return { audio: false, subtitle: false };
}
const masterAudioTracks = Array.isArray(masterGlobalTitle.audioTracks) ? masterGlobalTitle.audioTracks : [];
const masterSubtitleTracks = (Array.isArray(masterGlobalTitle.subtitleTracks) ? masterGlobalTitle.subtitleTracks : [])
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track));
const masterAudioSignatures = masterAudioTracks.map((track) => buildAudioSignature(track)).sort();
const masterSubtitleSignatures = masterSubtitleTracks.map((track) => buildSubtitleSignature(track)).sort();
const masterSubtitleLanguages = Array.from(new Set(masterSubtitleTracks.map((track) =>
normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und')
))).sort();
let audioCompatible = true;
let subtitleCompatible = true;
for (const title of selectedTitlesForGlobal) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
const audioMap = new Map();
const titleAudioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
const titleAudioSignatures = titleAudioTracks.map((track) => {
const signature = buildAudioSignature(track);
const trackId = normalizeTrackId(track?.id);
if (trackId !== null && !audioMap.has(signature)) {
audioMap.set(signature, trackId);
}
return signature;
}).sort();
globalAudioTrackMaps.set(titleId, audioMap);
if (titleAudioSignatures.join('||') !== masterAudioSignatures.join('||')) {
audioCompatible = false;
}
const subtitleTracks = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track));
const subtitleSignatures = subtitleTracks.map((track) => buildSubtitleSignature(track)).sort();
const subtitleLanguages = Array.from(new Set(subtitleTracks.map((track) =>
normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und')
))).sort();
globalSubtitleLanguagesByTitle.set(titleId, subtitleLanguages);
if (subtitleSignatures.join('||') !== masterSubtitleSignatures.join('||')) {
subtitleCompatible = false;
}
if (subtitleLanguages.join('||') !== masterSubtitleLanguages.join('||')) {
subtitleCompatible = false;
}
}
return { audio: audioCompatible, subtitle: subtitleCompatible };
})();
const showGlobalEpisodeTrackControls = Boolean(
allowTrackSelection
&& allowTitleSelection
&& masterGlobalTitle
&& selectedTitlesForGlobal.length > 1
&& globalTrackCompatibility.audio
&& globalTrackCompatibility.subtitle
);
const useEpisodeTrackAccordion = Boolean(
masterGlobalTitle
&& selectedTitlesForGlobal.length > 1
&& globalTrackCompatibility.audio
&& globalTrackCompatibility.subtitle
);
return (
<div className="media-review-wrap">
{!compactTitleSelection && allowUserPresetSelection && (
<div className="user-preset-selector" style={{ marginBottom: '0.75rem', padding: '0.75rem', border: '1px solid var(--surface-border, #e0e0e0)', borderRadius: '6px', background: 'var(--surface-ground, #f8f8f8)' }}>
<label style={{ display: 'block', marginBottom: '0.4rem', fontWeight: 600 }}>
Encode-Preset auswählen
</label>
<Dropdown
value={selectedUserPresetId ? Number(selectedUserPresetId) : null}
options={[
{ label: '(Einstellungen-Fallback)', value: null },
...normalizedUserPresets.map((p) => ({
label: `${p.name}${p.mediaType !== 'all' ? ` [${p.mediaType === 'bluray' ? 'Blu-ray' : p.mediaType === 'dvd' ? 'DVD' : 'Sonstiges'}]` : ''}`,
value: Number(p.id)
}))
]}
onChange={(e) => onUserPresetChange(e.value)}
placeholder="(Einstellungen-Fallback)"
style={{ width: '100%' }}
/>
{selectedUserPreset && (
<div style={{ marginTop: '0.4rem', fontSize: '0.8rem', opacity: 0.8 }}>
{selectedUserPreset.handbrakePreset
? <span><strong>-Z</strong> {selectedUserPreset.handbrakePreset}</span>
: <span style={{ opacity: 0.6 }}>(kein Preset-Name)</span>}
{selectedUserPreset.extraArgs && (
<span style={{ marginLeft: '1rem' }}><strong>Args:</strong> {selectedUserPreset.extraArgs}</span>
)}
{selectedUserPreset.description && (
<span style={{ marginLeft: '1rem', opacity: 0.7 }}>{selectedUserPreset.description}</span>
)}
</div>
)}
</div>
)}
{!compactTitleSelection ? (
<div className="media-review-meta">
<div>
<strong>Preset:</strong>{' '}
{effectivePresetOverride
? (effectivePresetOverride.handbrakePreset || '(kein Preset)')
: presetLabel}
{effectivePresetOverride && presetSourceLabel && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
({presetSourceLabel})
</span>
)}
</div>
<div>
<strong>Extra Args:</strong>{' '}
{effectivePresetOverride
? (effectivePresetOverride.extraArgs || '(keine)')
: (review.selectors?.extraArgs || '(keine)')}
{effectivePresetOverride && isUserPresetActive && !selectedUserPreset?.extraArgs && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>(aus User-Preset)</span>
)}
</div>
<div><strong>Preset-Profil:</strong> {effectivePresetOverride ? (isUserPresetActive ? 'user-preset' : 'handbrake-preset') : (review.selectors?.presetProfileSource || '-')}</div>
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
<div><strong>Encode Input:</strong> {encodeInputTitle?.fileName || '-'}</div>
<div><strong>Audio Auswahl:</strong> {effectiveAudioSelector?.mode || '-'}</div>
<div><strong>Audio Encoder:</strong> {(effectiveAudioSelector?.encoders || []).join(', ') || 'Preset-Default'}</div>
<div><strong>Audio Copy-Mask:</strong> {(effectiveAudioSelector?.copyMask || []).join(', ') || '-'}</div>
<div><strong>Audio Fallback:</strong> {effectiveAudioSelector?.fallbackEncoder || '-'}</div>
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</div>
<div><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.burnBehavior === 'first' ? 'burned(first)' : '-'}</div>
</div>
) : null}
{!compactTitleSelection && review.partial ? (
<small>Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert.</small>
) : null}
{!compactTitleSelection && playlistRecommendation ? (
<div className="playlist-recommendation-box">
<small>
<strong>Empfehlung:</strong> {playlistRecommendation.playlistFile || '-'}
{playlistRecommendation.reviewTitleId ? ` (Titel #${playlistRecommendation.reviewTitleId})` : ''}
</small>
{playlistRecommendation.reason ? <small>{playlistRecommendation.reason}</small> : null}
</div>
) : null}
{!compactTitleSelection && Array.isArray(review.notes) && review.notes.length > 0 ? (
<div className="media-review-notes">
{review.notes.map((note, idx) => (
<small key={`${idx}-${note}`}>{note}</small>
))}
</div>
) : null}
{/* Pre-Encode Items (scripts + chains unified) */}
{!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? (
<div className="post-script-box">
<h4>Pre-Encode Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{preEncodeItems.length === 0 ? (
<small>Keine Pre-Encode Ausführungen ausgewählt.</small>
) : null}
{preEncodeItems.map((item, rowIndex) => {
const isScript = item.type === 'script';
const canDrag = allowEncodeItemSelection && preEncodeItems.length > 1;
const scriptObj = isScript ? (scriptById.get(normalizeScriptId(item.id)) || null) : null;
const chainObj = !isScript ? (chainById.get(Number(item.id)) || null) : null;
const name = isScript
? (scriptObj?.name || `Skript #${item.id}`)
: (chainObj?.name || `Kette #${item.id}`);
const usedScriptIds = new Set(
preEncodeItems
.filter((it, i) => it.type === 'script' && i !== rowIndex)
.map((it) => normalizeScriptId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
preEncodeItems
.filter((it, i) => it.type === 'chain' && i !== rowIndex)
.map((it) => normalizeChainId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((s) => ({
label: s.name,
value: s.id,
disabled: usedScriptIds.has(String(s.id))
}));
const chainOptions = chainCatalog.map((c) => ({
label: c.name,
value: c.id,
disabled: usedChainIds.has(String(c.id))
}));
return (
<div
key={`pre-item-${rowIndex}-${item.type}-${item.id}`}
className={`post-script-row${allowEncodeItemSelection ? ' editable' : ''}`}
onDragOver={(event) => {
if (!canDrag) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}}
onDrop={(event) => handlePreDrop(event, rowIndex)}
>
{allowEncodeItemSelection ? (
<>
<span
className={`post-script-drag-handle pi pi-bars${canDrag ? '' : ' disabled'}`}
title={canDrag ? 'Ziehen zum Umordnen' : 'Mindestens zwei Einträge zum Umordnen'}
draggable={canDrag}
onDragStart={(event) => {
if (!canDrag) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(rowIndex));
}}
/>
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
{isScript ? (
<Dropdown
value={normalizeScriptId(item.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePreEncodeItem?.(rowIndex, 'script', event.value)}
className="full-width"
/>
) : (
<Dropdown
value={normalizeChainId(item.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePreEncodeItem?.(rowIndex, 'chain', event.value)}
className="full-width"
/>
)}
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePreEncodeItem?.(rowIndex)} />
</>
) : (
<small><i className={`pi ${isScript ? 'pi-code' : 'pi-link'}`} /> {`${rowIndex + 1}. ${name}`}</small>
)}
</div>
);
})}
{allowEncodeItemSelection ? (
<div className="encode-item-add-row">
{scriptCatalog.length > preEncodeItems.filter((i) => i.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => onAddPreEncodeItem?.('script')}
/>
) : null}
{chainCatalog.length > preEncodeItems.filter((i) => i.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => onAddPreEncodeItem?.('chain')}
/>
) : null}
</div>
) : null}
<small>Ausführung vor dem Encoding, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small>
</div>
) : null}
{/* Post-Encode Items (scripts + chains unified) */}
{!compactTitleSelection ? (
<div className="post-script-box">
<h4>Post-Encode Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{postEncodeItems.length === 0 ? (
<small>Keine Post-Encode Ausführungen ausgewählt.</small>
) : null}
{postEncodeItems.map((item, rowIndex) => {
const isScript = item.type === 'script';
const canDrag = allowEncodeItemSelection && postEncodeItems.length > 1;
const scriptObj = isScript ? (scriptById.get(normalizeScriptId(item.id)) || null) : null;
const chainObj = !isScript ? (chainById.get(Number(item.id)) || null) : null;
const name = isScript
? (scriptObj?.name || `Skript #${item.id}`)
: (chainObj?.name || `Kette #${item.id}`);
const usedScriptIds = new Set(
postEncodeItems
.filter((it, i) => it.type === 'script' && i !== rowIndex)
.map((it) => normalizeScriptId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
postEncodeItems
.filter((it, i) => it.type === 'chain' && i !== rowIndex)
.map((it) => normalizeChainId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((s) => ({
label: s.name,
value: s.id,
disabled: usedScriptIds.has(String(s.id))
}));
const chainOptions = chainCatalog.map((c) => ({
label: c.name,
value: c.id,
disabled: usedChainIds.has(String(c.id))
}));
return (
<div
key={`post-item-${rowIndex}-${item.type}-${item.id}`}
className={`post-script-row${allowEncodeItemSelection ? ' editable' : ''}`}
onDragOver={(event) => {
if (!canDrag) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}}
onDrop={(event) => handlePostDrop(event, rowIndex)}
>
{allowEncodeItemSelection ? (
<>
<span
className={`post-script-drag-handle pi pi-bars${canDrag ? '' : ' disabled'}`}
title={canDrag ? 'Ziehen zum Umordnen' : 'Mindestens zwei Einträge zum Umordnen'}
draggable={canDrag}
onDragStart={(event) => {
if (!canDrag) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(rowIndex));
}}
/>
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
{isScript ? (
<Dropdown
value={normalizeScriptId(item.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePostEncodeItem?.(rowIndex, 'script', event.value)}
className="full-width"
/>
) : (
<Dropdown
value={normalizeChainId(item.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePostEncodeItem?.(rowIndex, 'chain', event.value)}
className="full-width"
/>
)}
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePostEncodeItem?.(rowIndex)} />
</>
) : (
<small><i className={`pi ${isScript ? 'pi-code' : 'pi-link'}`} /> {`${rowIndex + 1}. ${name}`}</small>
)}
</div>
);
})}
{allowEncodeItemSelection ? (
<div className="encode-item-add-row">
{scriptCatalog.length > postEncodeItems.filter((i) => i.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => onAddPostEncodeItem?.('script')}
/>
) : null}
{chainCatalog.length > postEncodeItems.filter((i) => i.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => onAddPostEncodeItem?.('chain')}
/>
) : null}
</div>
) : null}
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
</div>
) : null}
<h4>Titel</h4>
{episodeFillOptions.length > 0 ? (
<div className="episode-fill-box">
<div className="episode-fill-field">
<label>Episodentitel befüllen ab Episode</label>
<Dropdown
value={selectedEpisodeFillValue}
options={episodeFillOptions}
optionLabel="label"
optionValue="value"
onChange={(event) => onEpisodeFillStartChange?.(event?.value || null)}
placeholder="Episode auswählen …"
disabled={!allowTitleSelection}
style={{ width: '100%' }}
filter
showClear
/>
<small>
Startet bei der gewählten Episode und befüllt alle ausgewählten DVD-Titel der Reihe nach.
Multi-Episoden werden als Bereich (z.B. E16-17 | Teil 1+2) angezeigt.
</small>
</div>
</div>
) : null}
{showGlobalEpisodeTrackControls ? (
<div className="episode-global-selection-box">
<h4>Spurauswahl für alle Episoden</h4>
<small>Gilt für alle aktuell gewählten Episoden. Einzelne Episoden können danach weiterhin manuell überschrieben werden.</small>
<div className="media-track-grid">
{globalTrackCompatibility.audio && globalMasterSelectionState ? (
<TrackList
title="Tonspuren (alle Episoden)"
tracks={masterGlobalTitle?.audioTracks || []}
type="audio"
allowSelection={allowTrackSelection}
selectedTrackIds={globalMasterSelectionState.selectedAudioTrackIds}
audioSelector={effectiveAudioSelector}
onToggleTrack={(masterTrackId, checked) => {
if (!allowTrackSelection || typeof onTrackSelectionChange !== 'function') {
return;
}
const masterTrack = (Array.isArray(masterGlobalTitle?.audioTracks) ? masterGlobalTitle.audioTracks : [])
.find((track) => normalizeTrackId(track?.id) === normalizeTrackId(masterTrackId));
if (!masterTrack) {
return;
}
const signature = buildAudioSignature(masterTrack);
for (const title of selectedTitlesForGlobal) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
const titleTrackMap = globalAudioTrackMaps.get(titleId);
const mappedTrackId = titleTrackMap?.get(signature) ?? null;
if (!mappedTrackId) {
continue;
}
onTrackSelectionChange(titleId, 'audio', mappedTrackId, checked);
}
}}
/>
) : (
<div>
<h4>Tonspuren (alle Episoden)</h4>
<small>Globale Auswahl nicht verfügbar, da die Tonspur-Layouts zwischen Episoden abweichen.</small>
</div>
)}
{globalTrackCompatibility.subtitle && globalMasterSelectionState ? (
<TrackList
title="Subtitles (alle Episoden)"
tracks={(Array.isArray(masterGlobalTitle?.subtitleTracks) ? masterGlobalTitle.subtitleTracks : [])
.filter((track) => !isBurnedSubtitleTrack(track))}
type="subtitle"
allowSelection={allowTrackSelection}
selectedTrackIds={globalMasterSelectionState.selectedSubtitleTrackIds}
selectedSubtitleVariantSelection={globalMasterSelectionState.selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={globalMasterSelectionState.selectedSubtitleLanguageOrder}
onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelection || typeof onSubtitleVariantSelectionChange !== 'function') {
return;
}
const normalizedLanguage = normalizeSubtitleLanguage(language);
for (const title of selectedTitlesForGlobal) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
const subtitleLanguages = globalSubtitleLanguagesByTitle.get(titleId) || [];
if (!subtitleLanguages.includes(normalizedLanguage)) {
continue;
}
onSubtitleVariantSelectionChange(titleId, normalizedLanguage, variant, checked);
}
}}
/>
) : (
<div>
<h4>Subtitles (alle Episoden)</h4>
<small>Globale Auswahl nicht verfügbar, da die Untertitel-Layouts zwischen Episoden abweichen.</small>
</div>
)}
</div>
</div>
) : null}
<div className="media-title-list">
{displayTitles.length === 0 ? (
<p>Keine Titel analysiert.</p>
) : displayTitles.map((title) => {
const normalizedTitleId = normalizeTitleId(title.id);
const titleChecked = allowTitleSelection
? (selectedTitleIdSet.size > 0
? selectedTitleIdSet.has(String(normalizedTitleId))
: Boolean(title.selectedForEncode))
: Boolean(title.selectedForEncode);
const titleIsPrimary = currentSelectedId !== null && currentSelectedId === normalizedTitleId;
const titleEpisodeAssignment = episodeAssignmentsByTitle?.[normalizedTitleId]
|| episodeAssignmentsByTitle?.[String(normalizedTitleId)]
|| null;
const episodeInputValue = String(
titleEpisodeAssignment?.episodeTitle
|| title?.episodeTitle
|| ''
);
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
const audioCount = Number.isFinite(Number(title?.audioTrackCount))
? Number(title.audioTrackCount)
: (Array.isArray(title.audioTracks) ? title.audioTracks.length : 0);
const subtitleCount = Number.isFinite(Number(title?.subtitleTrackCount))
? Number(title.subtitleTrackCount)
: subtitleTracks.length;
const selectableSubtitleTrackIds = subtitleTracks
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const selectableSubtitleTrackIdSet = new Set(selectableSubtitleTrackIds.map((id) => String(id)));
const sortedSelectableSubtitleTracks = subtitleTracks
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.slice()
.sort((a, b) => (normalizeTrackId(a?.id) || Number.MAX_SAFE_INTEGER) - (normalizeTrackId(b?.id) || Number.MAX_SAFE_INTEGER));
const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const defaultSubtitleTrackIds = subtitleTracks
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const defaultSubtitleVariantSelection = {};
const defaultSubtitleLanguageOrder = [];
const subtitleLanguageOrderSeen = new Set();
for (const track of sortedSelectableSubtitleTracks) {
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!subtitleLanguageOrderSeen.has(language)) {
subtitleLanguageOrderSeen.add(language);
defaultSubtitleLanguageOrder.push(language);
}
if (!Boolean(track?.selectedByRule)) {
continue;
}
if (!defaultSubtitleVariantSelection[language]) {
defaultSubtitleVariantSelection[language] = { full: false, forced: false };
}
const flags = resolveSubtitleTrackVariantFlags(track);
if (flags.isForcedOnly) {
defaultSubtitleVariantSelection[language].forced = true;
} else {
defaultSubtitleVariantSelection[language].full = true;
}
}
const selectedAudioTrackIds = normalizeTrackIdList(
Array.isArray(titleSelectionEntry?.audioTrackIds)
? titleSelectionEntry.audioTrackIds
: defaultAudioTrackIds
);
const selectedSubtitleTrackIds = normalizeTrackIdList(
Array.isArray(titleSelectionEntry?.subtitleTrackIds)
? titleSelectionEntry.subtitleTrackIds
: defaultSubtitleTrackIds
).filter((id) => selectableSubtitleTrackIdSet.has(String(id)));
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
);
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
? titleSelectionEntry.subtitleLanguageOrder
: defaultSubtitleLanguageOrder
);
const allowTrackSelectionForTitle = Boolean(
allowTrackSelection
&& allowTitleSelection
&& titleChecked
);
return (
<div key={title.id} className="media-title-block">
<label className="readonly-check-row">
<input
type="checkbox"
checked={titleChecked}
onChange={(event) => {
if (!allowTitleSelection) {
return;
}
const checked = Boolean(event?.target?.checked);
if (typeof onToggleEncodeTitle === 'function') {
onToggleEncodeTitle(normalizedTitleId, checked);
}
if (checked && typeof onSelectEncodeTitle === 'function') {
onSelectEncodeTitle(normalizedTitleId);
}
}}
readOnly={!allowTitleSelection}
disabled={!allowTitleSelection}
/>
<span>
#{title.id} | {title.fileName} | {formatDuration(title.durationMinutes)}
{audioCount > 0 ? ` | Audio: ${audioCount}` : ''}
{subtitleCount > 0 ? ` | Untertitel: ${subtitleCount}` : ''}
{title.encodeInput ? ' | Encode-Input' : ''}
{titleIsPrimary ? ' | Primär' : ''}
</span>
</label>
{titleChecked ? (
<div className="episode-title-input-row">
<label htmlFor={`episode-title-${title.id}`}>Episodentitel</label>
<input
id={`episode-title-${title.id}`}
className="p-inputtext p-component"
type="text"
value={episodeInputValue}
onChange={(event) => {
if (!allowTitleSelection || typeof onEpisodeAssignmentChange !== 'function') {
return;
}
onEpisodeAssignmentChange(normalizedTitleId, {
episodeTitle: event?.target?.value || ''
});
}}
placeholder="Episodentitel"
disabled={!allowTitleSelection}
/>
</div>
) : null}
{title.playlistFile || title.playlistEvaluationLabel || title.playlistSegmentCommand ? (
<div className="playlist-info-box">
<small>
<strong>Playlist:</strong> {title.playlistFile || '-'}
{title.playlistRecommended ? ' | empfohlen' : ''}
</small>
{title.playlistEvaluationLabel ? (
<small><strong>Bewertung:</strong> {title.playlistEvaluationLabel}</small>
) : null}
{title.playlistSegmentCommand ? (
<small><strong>Analyse-Command:</strong> {title.playlistSegmentCommand}</small>
) : null}
{Array.isArray(title.playlistSegmentFiles) && title.playlistSegmentFiles.length > 0 ? (
<details className="playlist-segment-toggle">
<summary>Segment-Dateien anzeigen ({title.playlistSegmentFiles.length})</summary>
<pre className="playlist-segment-output">{title.playlistSegmentFiles.join('\n')}</pre>
</details>
) : (
<small>Segment-Ausgabe: keine m2ts-Einträge gefunden.</small>
)}
</div>
) : null}
{!compactTitleSelection && useEpisodeTrackAccordion ? (
<details className="episode-track-accordion">
<summary>Tonspuren und Untertitel</summary>
<div className="media-track-grid">
<TrackList
title={`Tonspuren (Titel #${title.id})`}
tracks={title.audioTracks || []}
type="audio"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedAudioTrackIds}
audioSelector={effectiveAudioSelector}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'audio', trackId, checked);
}}
/>
<TrackList
title={`Subtitles (Titel #${title.id})`}
tracks={allowTrackSelectionForTitle
? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track))
: subtitleTracks}
type="subtitle"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedSubtitleTrackIds}
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
return;
}
onSubtitleVariantSelectionChange(title.id, language, variant, checked);
}}
/>
</div>
</details>
) : (!compactTitleSelection ? (
<div className="media-track-grid">
<TrackList
title={`Tonspuren (Titel #${title.id})`}
tracks={title.audioTracks || []}
type="audio"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedAudioTrackIds}
audioSelector={effectiveAudioSelector}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'audio', trackId, checked);
}}
/>
<TrackList
title={`Subtitles (Titel #${title.id})`}
tracks={allowTrackSelectionForTitle
? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track))
: subtitleTracks}
type="subtitle"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedSubtitleTrackIds}
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
return;
}
onSubtitleVariantSelectionChange(title.id, language, variant, checked);
}}
/>
</div>
) : null)}
{!compactTitleSelection && titleChecked ? (() => {
const resolvedCommandOutputPath = String(
commandOutputPathByTitle?.[normalizeTitleId(title?.id)]
|| commandOutputPathByTitle?.[String(normalizeTitleId(title?.id))]
|| commandOutputPath
|| ''
).trim() || null;
const commandPreview = buildHandBrakeCommandPreview({
review,
title,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
selectedSubtitleVariantSelection,
selectedSubtitleLanguageOrder,
commandOutputPath: resolvedCommandOutputPath,
presetOverride: effectivePresetOverride
});
return (
<div className="handbrake-command-preview">
<small><strong>Finaler HandBrakeCLI-Befehl (Preview):</strong></small>
<pre>{commandPreview}</pre>
</div>
);
})() : null}
</div>
);
})}
</div>
</div>
);
}