0.13.0 DVD Series

This commit is contained in:
2026-04-10 18:44:41 +00:00
parent 43702b0138
commit 974b7bfad3
36 changed files with 9666 additions and 739 deletions
+533 -62
View File
@@ -411,6 +411,46 @@ function shellQuote(value) {
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,
@@ -433,7 +473,12 @@ function buildHandBrakeCommandPreview({
const extraArgs = presetOverride !== null
? String(presetOverride.extraArgs || '').trim()
: String(review?.selectors?.extraArgs || '').trim();
const rawMappedTitleId = Number(review?.handBrakeTitleId);
const rawMappedTitleId = Number(
title?.handBrakeTitleId
?? title?.titleIndex
?? title?.id
?? review?.handBrakeTitleId
);
const mappedTitleId = Number.isFinite(rawMappedTitleId) && rawMappedTitleId > 0
? Math.trunc(rawMappedTitleId)
: null;
@@ -463,11 +508,12 @@ function buildHandBrakeCommandPreview({
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(commandOutputPath || '').trim() || '<encode-output>'
String(sanitizedOutputPath || '').trim() || '<encode-output>'
];
if (mappedTitleId !== null) {
@@ -1195,13 +1241,22 @@ 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 = [],
@@ -1225,10 +1280,68 @@ export default function MediaInfoReviewPanel({
}
const titles = review.titles || [];
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(review.encodeInputTitleId);
const encodeInputTitle = titles.find((item) => item.id === currentSelectedId) || null;
const processedFiles = Number(review.processedFiles || titles.length || 0);
const totalFiles = Number(review.totalFiles || titles.length || 0);
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 episodeNumber = Number(episode?.episodeNumber ?? episode?.number ?? 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,
episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null,
seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null,
episodeTitle: episodeTitle || null
};
})
.filter((episode) => episode.episodeId || episode.episodeNumber !== null);
const episodeFillOptions = normalizedEpisodeRows.map((episode) => {
const seasonLabel = episode.seasonNumber !== null ? `S${String(episode.seasonNumber).padStart(2, '0')}` : 'S??';
const episodeLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : 'E??';
const titleLabel = episode.episodeTitle || '(ohne Titel)';
const value = String(episode.episodeId || episode.episodeNumber || '');
return {
label: `${seasonLabel}${episodeLabel} - ${titleLabel}`,
value
};
}).filter((option) => option.value);
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)';
@@ -1280,9 +1393,170 @@ export default function MediaInfoReviewPanel({
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">
{allowUserPresetSelection && (
{!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
@@ -1315,9 +1589,10 @@ export default function MediaInfoReviewPanel({
)}
</div>
)}
<div className="media-review-meta">
<div>
<strong>Preset:</strong>{' '}
{!compactTitleSelection ? (
<div className="media-review-meta">
<div>
<strong>Preset:</strong>{' '}
{effectivePresetOverride
? (effectivePresetOverride.handbrakePreset || '(kein Preset)')
: presetLabel}
@@ -1345,13 +1620,14 @@ export default function MediaInfoReviewPanel({
<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>
</div>
) : null}
{review.partial ? (
{!compactTitleSelection && review.partial ? (
<small>Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert.</small>
) : null}
{playlistRecommendation ? (
{!compactTitleSelection && playlistRecommendation ? (
<div className="playlist-recommendation-box">
<small>
<strong>Empfehlung:</strong> {playlistRecommendation.playlistFile || '-'}
@@ -1361,7 +1637,7 @@ export default function MediaInfoReviewPanel({
</div>
) : null}
{Array.isArray(review.notes) && review.notes.length > 0 ? (
{!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>
@@ -1370,7 +1646,7 @@ export default function MediaInfoReviewPanel({
) : null}
{/* Pre-Encode Items (scripts + chains unified) */}
{(allowEncodeItemSelection || preEncodeItems.length > 0) ? (
{!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? (
<div className="post-script-box">
<h4>Pre-Encode Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
@@ -1491,7 +1767,8 @@ export default function MediaInfoReviewPanel({
) : null}
{/* Post-Encode Items (scripts + chains unified) */}
<div className="post-script-box">
{!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>
@@ -1606,22 +1883,140 @@ export default function MediaInfoReviewPanel({
) : null}
</div>
) : null}
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
</div>
<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={String(selectedEpisodeFillStart || '').trim() || null}
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.
</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">
{titles.length === 0 ? (
{displayTitles.length === 0 ? (
<p>Keine Titel analysiert.</p>
) : titles.map((title) => {
const titleEligible = title?.eligibleForEncode !== false;
) : displayTitles.map((title) => {
const normalizedTitleId = normalizeTitleId(title.id);
const titleChecked = allowTitleSelection
? (currentSelectedId !== null
? currentSelectedId === normalizeTitleId(title.id)
? (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))
@@ -1691,21 +2086,52 @@ export default function MediaInfoReviewPanel({
<input
type="checkbox"
checked={titleChecked}
onChange={() => {
if (!allowTitleSelection || typeof onSelectEncodeTitle !== 'function') {
onChange={(event) => {
if (!allowTitleSelection) {
return;
}
onSelectEncodeTitle(normalizeTitleId(title.id));
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)} | {formatBytes(title.sizeBytes)}
#{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>
@@ -1729,40 +2155,85 @@ export default function MediaInfoReviewPanel({
</div>
) : null}
<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>
{titleChecked ? (() => {
{!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,
@@ -1770,7 +2241,7 @@ export default function MediaInfoReviewPanel({
selectedSubtitleTrackIds,
selectedSubtitleVariantSelection,
selectedSubtitleLanguageOrder,
commandOutputPath,
commandOutputPath: resolvedCommandOutputPath,
presetOverride: effectivePresetOverride
});
return (