0.12.0-16 misc fixes

This commit is contained in:
2026-03-30 14:37:23 +00:00
parent 76b020d5c0
commit 5284dd2a43
30 changed files with 6955 additions and 896 deletions
+519 -15
View File
@@ -3,6 +3,16 @@ const { splitArgs } = require('./commandLine');
const DEFAULT_AUDIO_COPY_MASK = ['aac', 'ac3', 'eac3', 'truehd', 'dts', 'dtshd', 'mp3', 'flac'];
const DEFAULT_AUDIO_FALLBACK = 'av_aac';
const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({
low: 1,
medium: 2,
high: 3
});
const FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD = 0.35;
const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35;
const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220;
const FORCED_SUBTITLE_MIN_EVENT_GAP = 12;
const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024;
const ISO2_TO_3_LANGUAGE = {
de: 'deu',
en: 'eng',
@@ -271,7 +281,7 @@ function parseMediaInfoFile(mediaInfoJson, fileInfo, index) {
channels: item?.Channels || item?.Channel_s_ || null
}));
const subtitleTracks = tracks
const subtitleTracksRaw = tracks
.filter((item) => {
const type = String(item?.['@type'] || '').toLowerCase();
return type === 'text' || type === 'subtitle';
@@ -282,8 +292,14 @@ function parseMediaInfoFile(mediaInfoJson, fileInfo, index) {
language: normalizeLanguage(item?.Language || item?.Language_String3 || item?.Language_String || 'und'),
languageLabel: item?.Language_String3 || item?.Language || item?.Language_String || 'und',
title: item?.Title || null,
format: item?.Format || null
format: item?.Format || null,
defaultFlag: parseBooleanFlag(item?.Default ?? item?.Default_String ?? item?.IsDefault ?? item?.isDefault),
forcedFlag: parseBooleanFlagNullable(item?.Forced ?? item?.Forced_String ?? item?.IsForced ?? item?.isForced),
sdhFlag: parseSubtitleSdhFlag(item),
eventCount: parseSubtitleEventCount(item),
streamSizeBytes: parseSubtitleStreamSizeBytes(item)
}));
const subtitleTracks = annotateSubtitleTracks(subtitleTracksRaw);
const videoTracks = tracks
.filter((item) => String(item?.['@type'] || '').toLowerCase() === 'video')
@@ -350,6 +366,467 @@ function parseTrackIdList(raw) {
.filter((item) => Number.isFinite(item));
}
function parseBooleanFlag(value) {
return parseBooleanFlagNullable(value) === true;
}
function parseBooleanFlagNullable(value) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (raw === 'yes' || raw === 'true' || raw === '1') {
return true;
}
if (raw === 'no' || raw === 'false' || raw === '0') {
return false;
}
return null;
}
function parseSubtitleForcedFlag(track) {
if (!track || typeof track !== 'object') {
return null;
}
const candidates = [
track?.forcedFlag,
track?.forced,
track?.Forced,
track?.isForced,
track?.IsForced,
track?.forced_only,
track?.forcedOnly,
track?.Attributes?.Forced
];
for (const candidate of candidates) {
const parsed = parseBooleanFlagNullable(candidate);
if (parsed === true || parsed === false) {
return parsed;
}
}
return null;
}
function parseSubtitleSdhFlag(track) {
if (!track || typeof track !== 'object') {
return null;
}
const candidates = [
track?.sdhFlag,
track?.hearingImpaired,
track?.HearingImpaired,
track?.isHearingImpaired,
track?.IsHearingImpaired,
track?.Hearing_Impaired,
track?.closedCaptions,
track?.ClosedCaptions,
track?.Attributes?.HearingImpaired,
track?.Attributes?.ClosedCaptions
];
for (const candidate of candidates) {
const parsed = parseBooleanFlagNullable(candidate);
if (parsed === true || parsed === false) {
return parsed;
}
}
const serviceKind = String(track?.serviceKind || track?.ServiceKind || '').trim().toLowerCase();
if (!serviceKind) {
return null;
}
if (serviceKind.includes('sdh') || serviceKind.includes('cc') || serviceKind.includes('hearing')) {
return true;
}
return null;
}
function parseSubtitleEventCount(track) {
const candidates = [
track?.CountOfEvents,
track?.countOfEvents,
track?.EventCount,
track?.eventCount,
track?.ElementCount,
track?.elementCount
];
for (const candidate of candidates) {
const numeric = Number(candidate);
if (Number.isFinite(numeric) && numeric >= 0) {
return Math.trunc(numeric);
}
}
return null;
}
function parseSubtitleStreamSizeBytes(track) {
const numericCandidates = [
track?.StreamSize,
track?.streamSize,
track?.StreamSize_Original,
track?.streamSizeOriginal,
track?.Bytes,
track?.bytes
];
for (const candidate of numericCandidates) {
const numeric = Number(candidate);
if (Number.isFinite(numeric) && numeric > 0) {
return Math.trunc(numeric);
}
}
const textCandidates = [
track?.StreamSize_String,
track?.streamSizeString,
track?.StreamSize_Original_String,
track?.streamSizeOriginalString,
track?.Size_String,
track?.sizeString
];
for (const candidate of textCandidates) {
const text = String(candidate || '').trim();
if (!text) {
continue;
}
const match = text.match(/([0-9]+(?:[.,][0-9]+)?)\s*([kmgt]?b)/i);
if (!match) {
continue;
}
const value = Number(String(match[1]).replace(',', '.'));
if (!Number.isFinite(value) || value <= 0) {
continue;
}
const unit = String(match[2] || 'b').toLowerCase();
const factorByUnit = {
b: 1,
kb: 1024,
mb: 1024 ** 2,
gb: 1024 ** 3,
tb: 1024 ** 4
};
const factor = factorByUnit[unit] || 1;
return Math.max(0, Math.trunc(value * factor));
}
return null;
}
function normalizeSubtitleConfidence(raw) {
const value = String(raw || '').trim().toLowerCase();
if (value === 'high' || value === 'medium' || value === 'low') {
return value;
}
return 'low';
}
function subtitleConfidenceScore(raw) {
return SUBTITLE_CONFIDENCE_SCORES[normalizeSubtitleConfidence(raw)] || 0;
}
function collectSubtitleText(track) {
return [
track?.title,
track?.description,
track?.name,
track?.format,
track?.label
]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ');
}
function isLikelyBitmapSubtitleFormat(track) {
const text = [
track?.format,
track?.codec,
track?.codecName,
track?.title,
track?.description,
track?.name,
track?.label
]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ');
if (!text) {
return false;
}
return (
/\bpgs\b/.test(text)
|| /\bhdmv\b/.test(text)
|| /\bsup\b/.test(text)
|| /\bvobsub\b/.test(text)
|| /\bdvd[-_\s]?sub/.test(text)
|| /\bdvb[-_\s]?sub/.test(text)
);
}
function isLikelySdhSubtitleTrack(track) {
const text = collectSubtitleText(track);
if (!text) {
return false;
}
return (
/\bsdh\b/.test(text)
|| /\bcc\b/.test(text)
|| /\bhoh\b/.test(text)
|| /\bcaptions?\b/.test(text)
|| /hard[-\s]?of[-\s]?hearing/.test(text)
|| /hearing\s+impaired/.test(text)
|| /(?:gehörlos|hoergeschaedigt|hörgeschädigt)/.test(text)
);
}
function isLikelyForcedSubtitleTrack(track) {
const text = collectSubtitleText(track);
if (!text) {
return false;
}
if (isLikelySdhSubtitleTrack(track) || /\bnot forced\b/.test(text)) {
return false;
}
return (
/\bforced(?:\s+only)?\b/.test(text)
|| /nur\s+erzwungen/.test(text)
|| /\berzwungen\b/.test(text)
);
}
function compareSubtitleTracksByForcedHeuristic(a, b) {
const aSdh = Number(Boolean(a?.sdhLikely));
const bSdh = Number(Boolean(b?.sdhLikely));
if (aSdh !== bSdh) {
return aSdh - bSdh;
}
const aDefault = Number(Boolean(a?.defaultFlag));
const bDefault = Number(Boolean(b?.defaultFlag));
if (aDefault !== bDefault) {
return aDefault - bDefault;
}
const aEventKnown = Number.isFinite(a?.eventCount) ? 0 : 1;
const bEventKnown = Number.isFinite(b?.eventCount) ? 0 : 1;
if (aEventKnown !== bEventKnown) {
return aEventKnown - bEventKnown;
}
if (aEventKnown === 0 && a.eventCount !== b.eventCount) {
return a.eventCount - b.eventCount;
}
const aSizeKnown = Number.isFinite(a?.streamSizeBytes) ? 0 : 1;
const bSizeKnown = Number.isFinite(b?.streamSizeBytes) ? 0 : 1;
if (aSizeKnown !== bSizeKnown) {
return aSizeKnown - bSizeKnown;
}
if (aSizeKnown === 0 && a.streamSizeBytes !== b.streamSizeBytes) {
return a.streamSizeBytes - b.streamSizeBytes;
}
const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
if (aTrackId !== bTrackId) {
return aTrackId - bTrackId;
}
return a.originalIndex - b.originalIndex;
}
function compareSubtitleTracksForDedup(a, b) {
const aSdh = Number(Boolean(a?.sdhLikely));
const bSdh = Number(Boolean(b?.sdhLikely));
if (aSdh !== bSdh) {
return aSdh - bSdh;
}
const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
if (defaultDiff !== 0) {
return defaultDiff;
}
const confidenceDiff = subtitleConfidenceScore(b?.sourceConfidence) - subtitleConfidenceScore(a?.sourceConfidence);
if (confidenceDiff !== 0) {
return confidenceDiff;
}
const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
if (aTrackId !== bTrackId) {
return aTrackId - bTrackId;
}
return a.originalIndex - b.originalIndex;
}
function isHeuristicForcedSubtitleCandidate(languageEntries, candidate) {
if (!candidate) {
return false;
}
if (!isLikelyBitmapSubtitleFormat(candidate)) {
return false;
}
const comparable = (Array.isArray(languageEntries) ? languageEntries : [])
.filter((entry) => entry !== candidate && !entry.sdhLikely && isLikelyBitmapSubtitleFormat(entry));
if (comparable.length === 0) {
return false;
}
const candidateEventCount = Number(candidate?.eventCount);
const comparableEventCounts = comparable
.map((entry) => Number(entry?.eventCount))
.filter((value) => Number.isFinite(value) && value > 0);
const maxComparableEventCount = comparableEventCounts.length > 0
? Math.max(...comparableEventCounts)
: null;
const hasEventSignal = Number.isFinite(candidateEventCount)
&& candidateEventCount >= 0
&& Number.isFinite(maxComparableEventCount)
&& maxComparableEventCount > 0
&& candidateEventCount <= FORCED_SUBTITLE_MAX_EVENT_COUNT
&& (candidateEventCount / maxComparableEventCount) <= FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD
&& (maxComparableEventCount - candidateEventCount) >= FORCED_SUBTITLE_MIN_EVENT_GAP;
const candidateStreamSize = Number(candidate?.streamSizeBytes);
const comparableSizes = comparable
.map((entry) => Number(entry?.streamSizeBytes))
.filter((value) => Number.isFinite(value) && value > 0);
const maxComparableSize = comparableSizes.length > 0
? Math.max(...comparableSizes)
: null;
const hasSizeSignal = Number.isFinite(candidateStreamSize)
&& candidateStreamSize > 0
&& Number.isFinite(maxComparableSize)
&& maxComparableSize > 0
&& (candidateStreamSize / maxComparableSize) <= FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD
&& (maxComparableSize - candidateStreamSize) >= FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES;
const signalCount = Number(hasEventSignal) + Number(hasSizeSignal);
if (signalCount === 0) {
return false;
}
if (candidate.defaultFlag && signalCount < 2) {
return false;
}
return true;
}
function annotateSubtitleTracks(subtitleTracks) {
const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : [];
if (tracks.length === 0) {
return [];
}
const entries = tracks.map((track, index) => ({
...track,
language: normalizeLanguage(track?.language || track?.languageLabel || 'und'),
defaultFlag: Boolean(track?.defaultFlag),
forcedFlag: parseSubtitleForcedFlag(track),
forcedTrack: false,
sourceConfidence: null,
confidenceSource: 'heuristic',
duplicate: false,
selected: false,
subtitleType: 'full',
forcedAvailable: false,
forcedSourceTrackIds: [],
sdhLikely: (parseSubtitleSdhFlag(track) === true) || Boolean(track?.sdhLikely) || isLikelySdhSubtitleTrack(track),
originalIndex: index
}));
const byLanguage = new Map();
for (const entry of entries) {
if (!byLanguage.has(entry.language)) {
byLanguage.set(entry.language, []);
}
byLanguage.get(entry.language).push(entry);
}
for (const languageEntries of byLanguage.values()) {
for (const entry of languageEntries) {
const forcedByFlag = entry.forcedFlag === true;
const forcedBlockedByFlag = entry.forcedFlag === false;
const forcedByTitle = !entry.sdhLikely && !forcedBlockedByFlag && isLikelyForcedSubtitleTrack(entry);
if (forcedByFlag) {
entry.forcedTrack = true;
entry.sourceConfidence = 'high';
entry.confidenceSource = 'explicit_flag';
} else if (forcedByTitle) {
entry.forcedTrack = true;
entry.sourceConfidence = 'medium';
entry.confidenceSource = 'title';
}
}
if (!languageEntries.some((entry) => entry.forcedTrack)) {
const candidates = languageEntries
.filter((entry) => !entry.sdhLikely && entry.forcedFlag !== false)
.sort(compareSubtitleTracksByForcedHeuristic);
const forcedCandidate = candidates[0] || null;
if (forcedCandidate && isHeuristicForcedSubtitleCandidate(languageEntries, forcedCandidate)) {
forcedCandidate.forcedTrack = true;
forcedCandidate.sourceConfidence = 'low';
forcedCandidate.confidenceSource = 'heuristic';
}
}
for (const entry of languageEntries) {
if (!entry.forcedTrack) {
continue;
}
entry.sourceConfidence = normalizeSubtitleConfidence(entry.sourceConfidence || 'low');
}
const forcedEntries = languageEntries.filter((entry) => entry.forcedTrack);
const fullEntries = languageEntries.filter((entry) => !entry.forcedTrack && !entry.sdhLikely);
const sdhEntries = languageEntries.filter((entry) => !entry.forcedTrack && entry.sdhLikely);
const forcedWinner = forcedEntries.length > 0 ? [...forcedEntries].sort(compareSubtitleTracksForDedup)[0] : null;
const fullWinner = fullEntries.length > 0 ? [...fullEntries].sort(compareSubtitleTracksForDedup)[0] : null;
const sdhWinner = sdhEntries.length > 0 ? [...sdhEntries].sort(compareSubtitleTracksForDedup)[0] : null;
const forcedSourceTrackIds = forcedEntries
.map((entry) => Number(entry?.sourceTrackId ?? entry?.id))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value));
const forcedAvailable = Boolean(forcedWinner);
for (const entry of languageEntries) {
const typeWinner = entry.forcedTrack
? forcedWinner
: (entry.sdhLikely ? sdhWinner : fullWinner);
entry.duplicate = Boolean(typeWinner && typeWinner !== entry);
if (entry.forcedTrack) {
entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
} else if (entry.sdhLikely) {
entry.selected = Boolean(!fullWinner && typeWinner && typeWinner === entry && !entry.duplicate);
} else {
entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
}
entry.subtitleType = entry.forcedTrack ? 'forced' : 'full';
entry.forcedAvailable = forcedAvailable;
entry.forcedSourceTrackIds = forcedSourceTrackIds;
const fullHasForced = !entry.forcedTrack && Boolean(
entry.fullHasForced
?? entry.subtitleFullHasForced
?? entry.hasForcedVariant
);
const explicitForcedOnly = entry.isForcedOnly ?? entry.forcedOnly ?? entry.subtitlePreviewForcedOnly;
entry.isForcedOnly = typeof explicitForcedOnly === 'boolean'
? explicitForcedOnly
: (entry.forcedTrack && !fullHasForced);
entry.fullHasForced = fullHasForced;
}
}
return entries
.sort((a, b) => a.originalIndex - b.originalIndex)
.map((entry) => {
const { originalIndex, ...rest } = entry;
return rest;
});
}
function parseEncoderList(raw) {
return String(raw || '')
.split(',')
@@ -638,7 +1115,10 @@ function buildTrackSelectors(settings, presetProfile) {
function selectTrackIds(tracks, selector, trackType) {
const available = Array.isArray(tracks) ? tracks : [];
if (available.length === 0) {
const selectable = trackType === 'subtitle'
? available.filter((track) => !Boolean(track?.duplicate))
: available;
if (selectable.length === 0) {
return [];
}
@@ -648,13 +1128,13 @@ function selectTrackIds(tracks, selector, trackType) {
if (selector.mode === 'all') {
if (selector.firstOnly) {
return [available[0].id];
return [selectable[0].id];
}
return available.map((track) => track.id);
return selectable.map((track) => track.id);
}
if (selector.mode === 'explicit') {
const explicit = available
const explicit = selectable
.filter((track) => selector.explicitIds.includes(track.id))
.map((track) => track.id);
if (selector.firstOnly) {
@@ -664,7 +1144,7 @@ function selectTrackIds(tracks, selector, trackType) {
}
if (selector.mode === 'language') {
const matches = available.filter((track) => selector.languages.includes(track.language));
const matches = selectable.filter((track) => selector.languages.includes(track.language));
if (selector.firstOnly) {
return matches.length > 0 ? [matches[0].id] : [];
}
@@ -672,11 +1152,11 @@ function selectTrackIds(tracks, selector, trackType) {
}
if (selector.mode === 'first') {
return [available[0].id];
return [selectable[0].id];
}
if (trackType === 'audio') {
return [available[0].id];
return [selectable[0].id];
}
return [];
@@ -916,21 +1396,45 @@ function buildMediainfoReview({
const normalizedSubtitle = title.subtitleTracks.map((track) => {
const selectedByRule = selectedSubtitleIds.includes(track.id);
const subtitleFlags = computeSubtitleFlags(track.id, selectedSubtitleIds, trackSelectors.subtitle);
const inferredForced = Boolean(
track?.forcedTrack
|| String(track?.subtitleType || '').trim().toLowerCase() === 'forced'
);
const inferredForcedOnly = Boolean(
track?.isForcedOnly
?? track?.forcedOnly
?? track?.subtitlePreviewForcedOnly
?? inferredForced
);
const inferredDefault = Boolean(track?.defaultFlag);
const subtitlePreviewFlags = [];
if (subtitleFlags.burned) {
subtitlePreviewFlags.push('burned');
}
if (subtitleFlags.forced || inferredForced) {
subtitlePreviewFlags.push('forced');
}
if (subtitleFlags.forcedOnly || inferredForcedOnly) {
subtitlePreviewFlags.push('forced-only');
}
if (subtitleFlags.default || inferredDefault) {
subtitlePreviewFlags.push('default');
}
const subtitlePreviewSummary = !selectedByRule
? 'Nicht übernommen'
: (subtitleFlags.flags.length > 0
? `Übernehmen (${subtitleFlags.flags.join(', ')})`
: (subtitlePreviewFlags.length > 0
? `Übernehmen (${subtitlePreviewFlags.join(', ')})`
: 'Übernehmen');
return {
...track,
selectedByRule,
subtitlePreviewSummary,
subtitlePreviewFlags: subtitleFlags.flags,
subtitlePreviewFlags: selectedByRule ? subtitlePreviewFlags : [],
subtitlePreviewBurnIn: subtitleFlags.burned,
subtitlePreviewForced: subtitleFlags.forced,
subtitlePreviewForcedOnly: subtitleFlags.forcedOnly,
subtitlePreviewDefaultTrack: subtitleFlags.default
subtitlePreviewForced: selectedByRule ? (subtitleFlags.forced || inferredForced) : false,
subtitlePreviewForcedOnly: selectedByRule ? (subtitleFlags.forcedOnly || inferredForcedOnly) : false,
subtitlePreviewDefaultTrack: selectedByRule ? (subtitleFlags.default || inferredDefault) : false
};
});
+16 -3
View File
@@ -44,13 +44,26 @@ function sanitizeFileNameWithExtension(input) {
}
function renderTemplate(template, values) {
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
const val = values[key.trim()];
const rawSource = String(template || '${title} (${year})');
const resolveToken = (rawKey) => {
const key = String(rawKey || '').trim();
const val = values[key];
if (val === undefined || val === null || val === '') {
return 'unknown';
}
return String(val);
});
};
const source = (
/[{}]/.test(rawSource)
? rawSource
: rawSource.replace(/\b(trackNr|trackNumber|artist|album|title|year)\b/g, '{$1}')
);
// Support both ${key} and legacy {key} placeholders (+ recovered bare tokens).
return source
.replace(/\$\{([^}]+)\}/g, (_m, key) => resolveToken(key))
.replace(/\{([^{}$]+)\}/g, (_m, key) => resolveToken(key));
}
function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) {