1.0.0 1.0.0 final
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled

This commit is contained in:
2026-06-12 09:48:45 +02:00
parent 04acda6400
commit c6d884dc3f
16 changed files with 1517 additions and 268 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "1.0.0-rc5",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "1.0.0-rc5",
"version": "1.0.0",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "1.0.0-rc5",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
+6
View File
@@ -1169,6 +1169,12 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('playlist_tmdb_runtime_subtract_percent', 'Tools', 'TMDb Runtime-Abzug für Playlistfilter (%)', 'number', 1, 'Zieht optional einen Prozentwert von der TMDb-Laufzeit ab, um kürzere alternative Filmfassungen weiterhin als Playlist-Kandidaten zuzulassen. Mindesttoleranz nach unten bleibt immer 2 Minuten.', '0', '[]', '{"min":0,"max":100}', 211)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('playlist_tmdb_runtime_subtract_percent', '0')`);
await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_lower_pct'`);
await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_lower_pct'`);
await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_upper_pct'`);
+394 -31
View File
@@ -43,6 +43,7 @@ const REVIEW_REFRESH_SETTING_PREFIXES = [
];
const REVIEW_REFRESH_SETTING_KEYS = new Set([
'makemkv_min_length_minutes',
'playlist_tmdb_runtime_subtract_percent',
'handbrake_preset',
'handbrake_extra_args',
'mediainfo_extra_args',
@@ -102,6 +103,7 @@ const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000;
const tmdbSeriesSearchCache = new Map();
const PROCESS_LOG_RESET_TERMINAL_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']);
const PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS = 120;
const PLAYLIST_RUNTIME_SUBTRACT_PERCENT_SETTING_KEY = 'playlist_tmdb_runtime_subtract_percent';
function nowIso() {
return new Date().toISOString();
@@ -228,6 +230,24 @@ function resolveSeriesDiscLabel(mediaProfile) {
return 'Disc';
}
function resolveReviewTrackLanguageSettings(settings = {}, mediaProfile = null, isSeriesSelection = false) {
const source = settings && typeof settings === 'object' ? settings : {};
const normalizedMediaProfile = normalizeMediaProfile(mediaProfile);
if (normalizedMediaProfile !== 'bluray' && normalizedMediaProfile !== 'dvd') {
return source;
}
const variant = isSeriesSelection ? 'series' : 'movie';
const audioKey = `handbrake_review_audio_languages_${normalizedMediaProfile}_${variant}`;
const subtitleKey = `handbrake_review_subtitle_languages_${normalizedMediaProfile}_${variant}`;
return {
...source,
handbrake_review_audio_languages: source[audioKey] ?? null,
handbrake_review_subtitle_languages: source[subtitleKey] ?? null
};
}
function normalizeMovieFingerprintLabel(value) {
return String(value || '')
.normalize('NFKD')
@@ -3369,6 +3389,202 @@ function extractMovieRuntimeMinutesFromMetadata(selectedMetadata = null) {
return null;
}
function uniqueNormalizedPlaylistIds(values = []) {
const seen = new Set();
const normalized = [];
for (const value of (Array.isArray(values) ? values : [])) {
const playlistId = normalizePlaylistId(value);
if (!playlistId || seen.has(playlistId)) {
continue;
}
seen.add(playlistId);
normalized.push(playlistId);
}
return normalized;
}
function extractPlaylistAnalysisPlaylistId(item = null) {
return normalizePlaylistId(item?.playlistId || item?.playlistFile || null);
}
function extractPlaylistAnalysisDurationSeconds(item = null) {
const rawSeconds = Number(item?.durationSeconds ?? item?.duration ?? 0);
return Number.isFinite(rawSeconds) && rawSeconds > 0
? Math.trunc(rawSeconds)
: 0;
}
function applyTmdbRuntimeFilterToPlaylistAnalysis(
playlistAnalysis,
{
selectedMetadata = null,
settings = null,
disableMinLengthFilter = false
} = {}
) {
const analysis = playlistAnalysis && typeof playlistAnalysis === 'object'
? playlistAnalysis
: null;
if (!analysis) {
return {
playlistAnalysis: analysis,
applied: false,
reason: 'missing_analysis'
};
}
const runtimeMinutes = extractMovieRuntimeMinutesFromMetadata(selectedMetadata);
if (!Number.isFinite(runtimeMinutes) || runtimeMinutes <= 0) {
return {
playlistAnalysis: analysis,
applied: false,
reason: 'missing_runtime'
};
}
const minLengthMinutes = disableMinLengthFilter
? 0
: Number(settings?.makemkv_min_length_minutes ?? analysis?.minLengthMinutes ?? 0);
const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0
? Math.round(minLengthMinutes * 60)
: 0;
const configuredPercentRaw = Number(settings?.[PLAYLIST_RUNTIME_SUBTRACT_PERCENT_SETTING_KEY] ?? 0);
const configuredPercent = Number.isFinite(configuredPercentRaw) && configuredPercentRaw > 0
? configuredPercentRaw
: 0;
const expectedSeconds = Math.round(runtimeMinutes * 60);
const configuredDeductionSeconds = configuredPercent > 0
? Math.round(expectedSeconds * (configuredPercent / 100))
: 0;
const deductionSeconds = Math.max(
PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS,
configuredDeductionSeconds
);
const lowerBoundSeconds = Math.max(minLengthSeconds, expectedSeconds - deductionSeconds);
const keepPlaylistIds = new Set(
[
...(Array.isArray(analysis?.evaluatedCandidates) ? analysis.evaluatedCandidates : []),
...(Array.isArray(analysis?.candidates) ? analysis.candidates : []),
...(Array.isArray(analysis?.titles) ? analysis.titles : [])
]
.filter((item) => extractPlaylistAnalysisDurationSeconds(item) >= lowerBoundSeconds)
.map((item) => extractPlaylistAnalysisPlaylistId(item))
.filter(Boolean)
);
const filterRows = (rows, { keepWithoutPlaylist = false } = {}) => (
(Array.isArray(rows) ? rows : []).filter((item) => {
const playlistId = extractPlaylistAnalysisPlaylistId(item);
if (!playlistId) {
return keepWithoutPlaylist;
}
return keepPlaylistIds.has(playlistId);
})
);
const filteredTitles = filterRows(analysis?.titles, { keepWithoutPlaylist: true });
const filteredCandidates = filterRows(analysis?.candidates);
const filteredEvaluatedCandidates = filterRows(analysis?.evaluatedCandidates);
const filteredCandidatePlaylistsAll = uniqueNormalizedPlaylistIds(
filteredCandidates.map((item) => extractPlaylistAnalysisPlaylistId(item))
);
const filteredCandidatePlaylistsRanked = uniqueNormalizedPlaylistIds(
filteredEvaluatedCandidates.map((item) => extractPlaylistAnalysisPlaylistId(item))
);
const filteredCandidatePlaylists = filteredCandidatePlaylistsAll.length > 1
? uniqueNormalizedPlaylistIds([...filteredCandidatePlaylistsRanked, ...filteredCandidatePlaylistsAll])
: [];
const filteredDuplicateDurationGroups = (Array.isArray(analysis?.duplicateDurationGroups) ? analysis.duplicateDurationGroups : [])
.map((group) => filterRows(group))
.filter((group) => group.length > 1);
const recommendationPlaylistId = normalizePlaylistId(analysis?.recommendation?.playlistId);
const recommendationSurvives = recommendationPlaylistId && keepPlaylistIds.has(recommendationPlaylistId);
const fallbackRecommendation = filteredEvaluatedCandidates[0] || filteredCandidates[0] || null;
const nextRecommendation = recommendationSurvives
? analysis.recommendation
: (fallbackRecommendation
? {
titleId: Number.isFinite(Number(fallbackRecommendation?.titleId))
? Math.trunc(Number(fallbackRecommendation.titleId))
: null,
playlistId: extractPlaylistAnalysisPlaylistId(fallbackRecommendation),
score: Number.isFinite(Number(fallbackRecommendation?.score))
? Number(fallbackRecommendation.score)
: 0,
reason: Array.isArray(fallbackRecommendation?.reasons) && fallbackRecommendation.reasons.length > 0
? fallbackRecommendation.reasons.join('; ')
: (String(fallbackRecommendation?.evaluationLabel || '').trim() || 'höchster Struktur-Score')
}
: null);
const nextPlaylistToTitleId = Object.fromEntries(
Object.entries(
analysis?.playlistToTitleId && typeof analysis.playlistToTitleId === 'object'
? analysis.playlistToTitleId
: {}
).filter(([key]) => keepPlaylistIds.has(normalizePlaylistId(key)))
);
const nextPlaylistAliasMap = Object.fromEntries(
Object.entries(
analysis?.playlistAliasMap && typeof analysis.playlistAliasMap === 'object'
? analysis.playlistAliasMap
: {}
).filter(([key]) => keepPlaylistIds.has(normalizePlaylistId(key)))
);
const nextPlaylistSegments = Object.fromEntries(
Object.entries(
analysis?.playlistSegments && typeof analysis.playlistSegments === 'object'
? analysis.playlistSegments
: {}
).filter(([key]) => keepPlaylistIds.has(normalizePlaylistId(key)))
);
const filteredAnalysis = {
...analysis,
titles: filteredTitles,
candidates: filteredCandidates,
duplicateDurationGroups: filteredDuplicateDurationGroups,
obfuscationDetected: filteredDuplicateDurationGroups.length > 0,
manualDecisionRequired: filteredCandidatePlaylists.length > 1,
manualDecisionReason: filteredCandidatePlaylists.length > 1
? (filteredDuplicateDurationGroups.length > 0
? 'multiple_similar_candidates'
: 'multiple_candidates_after_min_length')
: null,
candidatePlaylists: filteredCandidatePlaylists,
candidatePlaylistFiles: filteredCandidatePlaylists.map((item) => `${item}.mpls`),
playlistToTitleId: nextPlaylistToTitleId,
playlistAliasMap: nextPlaylistAliasMap,
recommendation: nextRecommendation,
evaluatedCandidates: filteredEvaluatedCandidates,
playlistSegments: nextPlaylistSegments,
structuralAnalysis: {
...(analysis?.structuralAnalysis && typeof analysis.structuralAnalysis === 'object'
? analysis.structuralAnalysis
: {}),
analyzedPlaylists: Object.keys(nextPlaylistSegments).length
}
};
return {
playlistAnalysis: filteredAnalysis,
applied: true,
runtimeMinutes,
expectedSeconds,
minLengthSeconds,
configuredPercent,
configuredDeductionSeconds,
deductionSeconds,
lowerBoundSeconds,
sourceCount: uniqueNormalizedPlaylistIds(
(Array.isArray(analysis?.candidates) ? analysis.candidates : [])
.map((item) => extractPlaylistAnalysisPlaylistId(item))
).length,
resultCount: filteredCandidatePlaylistsAll.length
};
}
function resolvePlaylistByRuntimeAutoMatch({ playlistCandidates = [], selectedMetadata = null } = {}) {
const runtimeMinutes = extractMovieRuntimeMinutesFromMetadata(selectedMetadata);
if (!Number.isFinite(runtimeMinutes) || runtimeMinutes <= 0) {
@@ -7274,6 +7490,8 @@ function buildPlaylistCandidates(playlistAnalysis) {
);
const score = Number(source?.score);
const sequenceCoherence = Number(source?.structuralMetrics?.sequenceCoherence);
const correctedCoherence = Number(source?.correctedCoherence ?? source?.structuralMetrics?.correctedCoherence);
const legacySequenceCoherence = Number(source?.legacySequenceCoherence ?? source?.structuralMetrics?.legacySequenceCoherence);
const titleId = Number(source?.titleId ?? source?.id);
const handBrakeTitleId = Number(source?.handBrakeTitleId);
const durationSecondsRaw = Number(source?.durationSeconds ?? source?.duration ?? 0);
@@ -7329,6 +7547,14 @@ function buildPlaylistCandidates(playlistAnalysis) {
recommended: Boolean(source?.recommended),
evaluationLabel: source?.evaluationLabel || null,
sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null,
correctedCoherence: Number.isFinite(correctedCoherence) ? correctedCoherence : null,
legacySequenceCoherence: Number.isFinite(legacySequenceCoherence) ? legacySequenceCoherence : null,
clusterId: String(source?.clusterId || '').trim() || null,
clusterSize: Number(source?.clusterSize || 0) || null,
commonRuns: Array.isArray(source?.commonRuns) ? source.commonRuns : [],
variantSpans: Array.isArray(source?.variantSpans) ? source.variantSpans : [],
overlapEntries: Array.isArray(source?.overlapEntries) ? source.overlapEntries : [],
positionReason: String(source?.positionReason || '').trim() || null,
segmentCommand: source?.segmentCommand
|| segmentEntry?.segmentCommand
|| `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
@@ -7344,6 +7570,79 @@ function buildPlaylistCandidates(playlistAnalysis) {
});
}
function formatPlaylistCandidateRunsForLog(commonRuns) {
return (Array.isArray(commonRuns) ? commonRuns : [])
.map((run) => {
const start = Number(run?.startSegment);
const end = Number(run?.endSegment);
if (!Number.isFinite(start) || !Number.isFinite(end)) {
return null;
}
return `${String(Math.trunc(start)).padStart(5, '0')}-${String(Math.trunc(end)).padStart(5, '0')}`;
})
.filter(Boolean)
.join(' | ');
}
function formatPlaylistCandidateVariantSpansForLog(variantSpans) {
return (Array.isArray(variantSpans) ? variantSpans : [])
.map((span) => {
const before = Number.isFinite(Number(span?.beforeSegment))
? String(Math.trunc(Number(span.beforeSegment))).padStart(5, '0')
: 'START';
const after = Number.isFinite(Number(span?.afterSegment))
? String(Math.trunc(Number(span.afterSegment))).padStart(5, '0')
: 'END';
const segments = (Array.isArray(span?.segments) ? span.segments : [])
.map((segment) => Number(segment))
.filter((segment) => Number.isFinite(segment))
.map((segment) => String(Math.trunc(segment)).padStart(5, '0'));
if (segments.length === 0) {
return null;
}
return `${before}->${after}: [${segments.join(', ')}]`;
})
.filter(Boolean)
.join(' | ');
}
function formatPlaylistCandidateOverlapForLog(overlapEntries) {
return (Array.isArray(overlapEntries) ? overlapEntries : [])
.map((entry) => {
const otherPlaylistId = normalizePlaylistId(entry?.otherPlaylistId);
const ratio = Number(entry?.ratioToSmaller || 0);
if (!otherPlaylistId) {
return null;
}
return `${otherPlaylistId}.mpls=${ratio.toFixed(3)}`;
})
.filter(Boolean)
.join(', ');
}
function buildPlaylistCandidateDiagnosticLine(candidate) {
const playlistFile = toPlaylistFile(candidate?.playlistId) || `Titel #${candidate?.titleId || '-'}`;
const durationLabel = String(candidate?.durationLabel || '').trim() || formatDurationClock(candidate?.durationSeconds) || '-';
const scoreLabel = Number(Number(candidate?.score)).toFixed(2);
const legacyCoherence = Number(candidate?.legacySequenceCoherence || 0).toFixed(3);
const correctedCoherence = Number(candidate?.correctedCoherence || candidate?.sequenceCoherence || 0).toFixed(3);
const clusterLabel = candidate?.clusterId
? `${candidate.clusterId}${candidate?.clusterSize ? `(${candidate.clusterSize})` : ''}`
: 'standalone';
const overlapLabel = formatPlaylistCandidateOverlapForLog(candidate?.overlapEntries) || '-';
const commonRunsLabel = formatPlaylistCandidateRunsForLog(candidate?.commonRuns) || '-';
const variantSpansLabel = formatPlaylistCandidateVariantSpansForLog(candidate?.variantSpans) || '-';
const handBrakeTitleId = Number(candidate?.handBrakeTitleId);
const titleLabel = Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0
? `HB #${Math.trunc(handBrakeTitleId)}`
: `MakeMKV #${candidate?.titleId ?? '-'}`;
const recommendedLabel = candidate?.recommended ? ' | empfohlen' : '';
const reasonLabel = String(candidate?.positionReason || candidate?.evaluationLabel || '').trim() || '-';
return `${playlistFile} | ${titleLabel} | Dauer ${durationLabel} | Score ${scoreLabel} | altKoh ${legacyCoherence} | neuKoh ${correctedCoherence}`
+ ` | Cluster ${clusterLabel} | Overlap ${overlapLabel} | CommonRuns ${commonRunsLabel} | VariantSpans ${variantSpansLabel}`
+ `${recommendedLabel} | Grund: ${reasonLabel}`;
}
function buildHandBrakeAudioTrackPreview(titleInfo) {
const tracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : [];
return tracks
@@ -9702,6 +10001,7 @@ function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds,
function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = null) {
const hasReviewTitles = reviewPlan && Array.isArray(reviewPlan?.titles) && reviewPlan.titles.length > 0;
const hasPreviousTitles = previousPlan && Array.isArray(previousPlan?.titles) && previousPlan.titles.length > 0;
const previousReviewConfirmed = Boolean(previousPlan?.reviewConfirmed);
if (!hasReviewTitles || !hasPreviousTitles) {
return {
plan: reviewPlan,
@@ -9731,7 +10031,7 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n
const previousSelectedTitle = findSelectedTitleInPlan(previousPlan);
const nextSelectedTitle = findSelectedTitleInPlan(nextPlan);
let trackSelectionApplied = false;
if (previousSelectedTitle && nextSelectedTitle) {
if (previousReviewConfirmed && previousSelectedTitle && nextSelectedTitle) {
const previousSelectedAudioTracks = (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : [])
.filter((track) => Boolean(track?.selectedForEncode));
const previousSelectedSubtitleTracks = (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : [])
@@ -18571,9 +18871,14 @@ class PipelineService extends EventEmitter {
if (playlistAnalysis && handBrakePlaylistScan) {
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
}
let playlistRuntimeFilter = {
applied: false,
reason: 'not_evaluated'
};
const selectedPlaylistSource = (forcePlaylistReselection || forceFreshAnalyze)
? (options?.selectedPlaylist || null)
: (options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null);
const selectedPlaylistExplicit = Boolean(normalizePlaylistId(options?.selectedPlaylist || null));
let selectedPlaylistId = normalizePlaylistId(
selectedPlaylistSource
);
@@ -18584,6 +18889,14 @@ class PipelineService extends EventEmitter {
const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {});
const disableAnalyzeMinLengthFilter = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext);
const isSeriesBackupReview = disableAnalyzeMinLengthFilter;
if (playlistAnalysis && !isSeriesBackupReview) {
playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, {
selectedMetadata,
settings,
disableMinLengthFilter: disableAnalyzeMinLengthFilter
});
playlistAnalysis = playlistRuntimeFilter.playlistAnalysis;
}
const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId);
const preSelectedHandBrakeTitleIds = normalizeReviewTitleIdList(
options?.selectedHandBrakeTitleIds
@@ -18694,11 +19007,30 @@ class PipelineService extends EventEmitter {
{}
);
playlistAnalysis = analyzed || null;
if (playlistAnalysis && !isSeriesBackupReview) {
playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, {
selectedMetadata,
settings,
disableMinLengthFilter: disableAnalyzeMinLengthFilter
});
playlistAnalysis = playlistRuntimeFilter.playlistAnalysis;
}
analyzedFromFreshRun = true;
}
const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired);
let playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
if (selectedPlaylistId && playlistCandidates.length > 0) {
const isKnownPlaylistAfterRuntimeFilter = playlistCandidates.some((item) => item.playlistId === selectedPlaylistId);
if (!isKnownPlaylistAfterRuntimeFilter && !selectedPlaylistExplicit) {
await historyService.appendLog(
jobId,
'SYSTEM',
`Vorherige Playlist-Auswahl ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId} passt nicht mehr zur aktuellen TMDb-Runtime-Filterung und wurde verworfen.`
);
selectedPlaylistId = null;
}
}
let selectedTitleFromPlaylist = selectedPlaylistId
? pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId)
: null;
@@ -18765,6 +19097,14 @@ class PipelineService extends EventEmitter {
if (preparedCache) {
handBrakePlaylistScan = preparedCache;
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
if (playlistAnalysis && !isSeriesBackupReview) {
playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, {
selectedMetadata,
settings,
disableMinLengthFilter: disableAnalyzeMinLengthFilter
});
playlistAnalysis = playlistRuntimeFilter.playlistAnalysis;
}
playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
await historyService.appendLog(
jobId,
@@ -18803,6 +19143,14 @@ class PipelineService extends EventEmitter {
}
} else {
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
if (playlistAnalysis && !isSeriesBackupReview) {
playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, {
selectedMetadata,
settings,
disableMinLengthFilter: disableAnalyzeMinLengthFilter
});
playlistAnalysis = playlistRuntimeFilter.playlistAnalysis;
}
playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
await historyService.appendLog(
jobId,
@@ -18814,6 +19162,22 @@ class PipelineService extends EventEmitter {
}
}
if (playlistRuntimeFilter.applied) {
const runtimeLabel = `${playlistRuntimeFilter.runtimeMinutes} min`;
const lowerBoundLabel = formatDurationClock(playlistRuntimeFilter.lowerBoundSeconds)
|| `${playlistRuntimeFilter.lowerBoundSeconds}s`;
const deductionLabel = playlistRuntimeFilter.configuredPercent > 0
? `${Number(playlistRuntimeFilter.configuredPercent).toFixed(2).replace(/\.00$/, '')}%`
: '2 min Standardtoleranz';
await historyService.appendLog(
jobId,
'SYSTEM',
`TMDb-Runtime-Playlistfilter: ${runtimeLabel}, Untergrenze ${lowerBoundLabel} `
+ `(Abzug ${deductionLabel}, min. ${formatDurationClock(PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS)}). `
+ `${playlistRuntimeFilter.sourceCount} -> ${playlistRuntimeFilter.resultCount} Playlist-Kandidaten.`
);
}
let selectedPlaylistByRuntimeAutoMatch = false;
const shouldTryRuntimeAutoMatch = Boolean(
playlistDecisionRequired
@@ -19110,7 +19474,7 @@ class PipelineService extends EventEmitter {
size: Number(primaryTitleInfo?.sizeBytes || 0)
}],
mediaInfoByPath: syntheticMediaInfoByPath,
settings,
settings: resolveReviewTrackLanguageSettings(settings, mediaProfile, true),
presetProfile,
playlistAnalysis,
preferredEncodeTitleId: null,
@@ -19350,17 +19714,7 @@ class PipelineService extends EventEmitter {
await historyService.appendLog(jobId, 'SYSTEM', 'Mehrere mögliche Haupttitel erkannt!');
await historyService.appendLog(jobId, 'SYSTEM', decisionContext.detailText);
for (const candidate of evaluated) {
const playlistFile = toPlaylistFile(candidate?.playlistId) || `Titel #${candidate?.titleId || '-'}`;
const score = Number(candidate?.score);
const scoreLabel = Number.isFinite(score) ? score.toFixed(0) : '-';
const durationLabel = String(candidate?.durationLabel || '').trim() || formatDurationClock(candidate?.durationSeconds) || '-';
const recommendedLabel = candidate?.recommended ? ' (empfohlen)' : '';
const evaluationLabel = candidate?.evaluationLabel ? ` | ${candidate.evaluationLabel}` : '';
await historyService.appendLog(
jobId,
'SYSTEM',
`${playlistFile} -> Dauer ${durationLabel} | Score ${scoreLabel}${recommendedLabel}${evaluationLabel}`
);
await historyService.appendLog(jobId, 'SYSTEM', buildPlaylistCandidateDiagnosticLine(candidate));
}
await historyService.appendLog(
jobId,
@@ -19744,7 +20098,11 @@ class PipelineService extends EventEmitter {
size: Number(candidate?.titleInfo?.sizeBytes || 0)
})),
mediaInfoByPath: syntheticMediaInfoByPath,
settings,
settings: resolveReviewTrackLanguageSettings(
settings,
mediaProfile,
isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext)
),
presetProfile,
playlistAnalysis,
preferredEncodeTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview,
@@ -19786,7 +20144,7 @@ class PipelineService extends EventEmitter {
const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null;
const selectedByRule = Boolean(sourceMeta?.selectedByRule ?? track?.selectedByRule);
const selectedByRule = Boolean(track?.selectedByRule ?? sourceMeta?.selectedByRule);
const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate);
const subtitleType = String(
sourceMeta?.subtitleType
@@ -19811,42 +20169,42 @@ class PipelineService extends EventEmitter {
sourceConfidence = 'low';
}
}
const subtitlePreviewBurnIn = Boolean(sourceMeta?.subtitlePreviewBurnIn ?? track?.subtitlePreviewBurnIn);
const subtitlePreviewBurnIn = Boolean(track?.subtitlePreviewBurnIn ?? sourceMeta?.subtitlePreviewBurnIn);
const subtitlePreviewForced = Boolean(
sourceMeta?.subtitlePreviewForced
?? track?.subtitlePreviewForced
track?.subtitlePreviewForced
?? sourceMeta?.subtitlePreviewForced
?? (selectedByRule && subtitleType === 'forced')
);
const subtitlePreviewForcedOnly = Boolean(
sourceMeta?.subtitlePreviewForcedOnly
?? track?.subtitlePreviewForcedOnly
track?.subtitlePreviewForcedOnly
?? sourceMeta?.subtitlePreviewForcedOnly
);
const isForcedOnly = Boolean(
sourceMeta?.isForcedOnly
?? track?.isForcedOnly
track?.isForcedOnly
?? sourceMeta?.isForcedOnly
?? sourceMeta?.forcedOnly
?? track?.forcedOnly
?? subtitlePreviewForcedOnly
?? subtitleType === 'forced'
);
const fullHasForced = !isForcedOnly && Boolean(
sourceMeta?.fullHasForced
?? track?.fullHasForced
track?.fullHasForced
?? sourceMeta?.fullHasForced
?? sourceMeta?.subtitleFullHasForced
?? track?.subtitleFullHasForced
);
const subtitlePreviewDefaultTrack = Boolean(
sourceMeta?.subtitlePreviewDefaultTrack
?? track?.subtitlePreviewDefaultTrack
track?.subtitlePreviewDefaultTrack
?? sourceMeta?.subtitlePreviewDefaultTrack
?? sourceMeta?.defaultFlag
?? track?.defaultFlag
);
const subtitlePreviewFlags = Array.isArray(sourceMeta?.subtitlePreviewFlags)
? sourceMeta.subtitlePreviewFlags
: (Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []);
const subtitlePreviewFlags = Array.isArray(track?.subtitlePreviewFlags)
? track.subtitlePreviewFlags
: (Array.isArray(sourceMeta?.subtitlePreviewFlags) ? sourceMeta.subtitlePreviewFlags : []);
const subtitlePreviewSummary = String(
sourceMeta?.subtitlePreviewSummary
|| track?.subtitlePreviewSummary
track?.subtitlePreviewSummary
|| sourceMeta?.subtitlePreviewSummary
|| (selectedByRule
? 'Übernehmen'
: (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'))
@@ -20050,6 +20408,9 @@ class PipelineService extends EventEmitter {
`Playlist-Empfehlung: ${recommendationFile}`
);
}
for (const candidate of playlistCandidates) {
await historyService.appendLog(jobId, 'SYSTEM', buildPlaylistCandidateDiagnosticLine(candidate));
}
}
const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId);
@@ -24032,6 +24393,7 @@ class PipelineService extends EventEmitter {
makemkv_min_length_minutes: 0
}
: settings);
reviewSettings = resolveReviewTrackLanguageSettings(reviewSettings, mediaProfile, effectiveIsSeriesDvdReview);
await historyService.appendLog(
jobId,
'SYSTEM',
@@ -24181,6 +24543,7 @@ class PipelineService extends EventEmitter {
...reviewSettings,
makemkv_min_length_minutes: 0
};
reviewSettings = resolveReviewTrackLanguageSettings(reviewSettings, mediaProfile, true);
await historyService.appendLog(
jobId,
'SYSTEM',
+8
View File
@@ -150,6 +150,14 @@ const PROFILED_SETTINGS = {
bluray: 'handbrake_extra_args_bluray',
dvd: 'handbrake_extra_args_dvd'
},
handbrake_review_audio_languages: {
bluray: 'handbrake_review_audio_languages_bluray',
dvd: 'handbrake_review_audio_languages_dvd'
},
handbrake_review_subtitle_languages: {
bluray: 'handbrake_review_subtitle_languages_bluray',
dvd: 'handbrake_review_subtitle_languages_dvd'
},
output_extension: {
bluray: 'output_extension_bluray',
dvd: 'output_extension_dvd'
+52 -8
View File
@@ -33,6 +33,7 @@ const ISO2_TO_3_LANGUAGE = {
ro: 'ron',
uk: 'ukr',
ja: 'jpn',
jp: 'jpn',
ko: 'kor',
zh: 'zho',
ar: 'ara'
@@ -873,6 +874,29 @@ function normalizeBurnBehavior(raw) {
return 'none';
}
function hasConfiguredLanguageSelection(rawValue) {
return parseList(rawValue, normalizeSelectionLanguage)
.filter((item) => item !== 'none' && item !== 'any')
.length > 0;
}
function hasExplicitPresetTrackSelection(profile = {}, trackType = 'audio') {
const source = String(profile?.source || '').trim().toLowerCase();
if (source !== 'preset-export') {
return false;
}
if (trackType === 'audio') {
const behavior = String(profile?.audioTrackSelectionBehavior || '').trim().toLowerCase();
const languages = Array.isArray(profile?.audioLanguages) ? profile.audioLanguages : [];
return behavior === 'all' || behavior === 'language' || behavior === 'none' || languages.length > 0;
}
const behavior = String(profile?.subtitleTrackSelectionBehavior || '').trim().toLowerCase();
const languages = Array.isArray(profile?.subtitleLanguages) ? profile.subtitleLanguages : [];
return behavior === 'all' || behavior === 'language' || behavior === 'first' || languages.length > 0;
}
function buildBaseTrackSelectors(settings, presetProfile = null) {
const profile = presetProfile && typeof presetProfile === 'object' ? presetProfile : {};
const audioLanguages = Array.isArray(profile.audioLanguages)
@@ -881,6 +905,14 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
const subtitleLanguages = Array.isArray(profile.subtitleLanguages)
? profile.subtitleLanguages.map((item) => normalizeSelectionLanguage(item)).filter(Boolean)
: [];
const configuredAudioLanguages = parseList(settings?.handbrake_review_audio_languages, normalizeSelectionLanguage)
.filter((item) => item !== 'none' && item !== 'any');
const configuredSubtitleLanguages = parseList(settings?.handbrake_review_subtitle_languages, normalizeSelectionLanguage)
.filter((item) => item !== 'none' && item !== 'any');
const useConfiguredAudioLanguages = configuredAudioLanguages.length > 0 && !hasExplicitPresetTrackSelection(profile, 'audio');
const useConfiguredSubtitleLanguages = configuredSubtitleLanguages.length > 0 && !hasExplicitPresetTrackSelection(profile, 'subtitle');
const effectiveAudioLanguages = useConfiguredAudioLanguages ? configuredAudioLanguages : audioLanguages;
const effectiveSubtitleLanguages = useConfiguredSubtitleLanguages ? configuredSubtitleLanguages : subtitleLanguages;
const audioEncoders = Array.isArray(profile.audioEncoders)
? profile.audioEncoders.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
: [];
@@ -896,6 +928,18 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
const baseAudioMode = normalizeTrackSelectionMode(profile.audioTrackSelectionBehavior, 'audio');
const baseSubtitleMode = normalizeTrackSelectionMode(profile.subtitleTrackSelectionBehavior, 'subtitle');
const effectiveAudioMode = useConfiguredAudioLanguages && !hasConfiguredLanguageSelection(profile?.audioLanguages)
? 'language'
: baseAudioMode;
const effectiveSubtitleMode = useConfiguredSubtitleLanguages && !hasConfiguredLanguageSelection(profile?.subtitleLanguages)
? 'language'
: baseSubtitleMode;
const audioSelectionSource = useConfiguredAudioLanguages
? 'settings'
: (profile.source === 'preset-export' ? 'preset' : 'default');
const subtitleSelectionSource = useConfiguredSubtitleLanguages
? 'settings'
: (profile.source === 'preset-export' ? 'preset' : 'default');
return {
preset: settings?.handbrake_preset || null,
@@ -903,11 +947,11 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
presetProfileSource: profile.source || 'fallback',
presetProfileMessage: profile.message || null,
audio: {
mode: baseAudioMode,
languages: audioLanguages.filter((item) => item !== 'none'),
mode: effectiveAudioMode,
languages: effectiveAudioLanguages.filter((item) => item !== 'none'),
explicitIds: [],
firstOnly: baseAudioMode === 'first',
selectionSource: profile.source === 'preset-export' ? 'preset' : 'default',
firstOnly: effectiveAudioMode === 'first',
selectionSource: audioSelectionSource,
encoders: audioEncoders,
encoderSource: audioEncoders.length > 0 ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default',
copyMask: normalizedCopyMask.length > 0 ? normalizedCopyMask : [...DEFAULT_AUDIO_COPY_MASK],
@@ -916,11 +960,11 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
fallbackSource: profile.audioFallback ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default'
},
subtitle: {
mode: baseSubtitleMode,
languages: subtitleLanguages.filter((item) => item !== 'none'),
mode: effectiveSubtitleMode,
languages: effectiveSubtitleLanguages.filter((item) => item !== 'none'),
explicitIds: [],
firstOnly: baseSubtitleMode === 'first',
selectionSource: profile.source === 'preset-export' ? 'preset' : 'default',
firstOnly: effectiveSubtitleMode === 'first',
selectionSource: subtitleSelectionSource,
// Do not auto-burn subtitle tracks from exported preset metadata.
// Burn-in should only be activated via explicit CLI args/selection.
burnBehavior: 'none',
+667 -11
View File
@@ -2,6 +2,8 @@ const LARGE_JUMP_THRESHOLD = 20;
const DEFAULT_DURATION_SIMILARITY_SECONDS = 90;
const RAW_MIRROR_DURATION_TOLERANCE_SECONDS = 2;
const RAW_MIRROR_SIZE_TOLERANCE_BYTES = 64 * 1024 * 1024;
const BRANCHING_OVERLAP_THRESHOLD = 0.6;
const BRANCHING_AUDIO_SIMILARITY_THRESHOLD = 0.75;
function parseDurationSeconds(raw) {
const text = String(raw || '').trim();
@@ -548,7 +550,330 @@ function buildSimilarityGroups(candidates, durationSimilaritySeconds) {
);
}
function computeSegmentMetrics(segmentNumbers) {
function clamp01(value) {
const numeric = Number(value || 0);
if (!Number.isFinite(numeric)) {
return 0;
}
return Math.min(1, Math.max(0, numeric));
}
function normalizeTrackSignaturePart(value, fallback = 'na') {
const text = String(value || '').trim().toLowerCase();
return text || fallback;
}
function buildAudioSignatureTokens(title) {
const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : [];
if (audioTracks.length === 0) {
const count = Number(title?.audioTrackCount || 0);
return count > 0 ? [`count:${count}`] : [];
}
return uniqueOrdered(audioTracks.map((track) => (
`${normalizeTrackSignaturePart(track?.language || track?.languageLabel || 'und', 'und')}`
+ `|${normalizeTrackSignaturePart(track?.format)}`
+ `|${normalizeTrackSignaturePart(track?.channels)}`
)));
}
function computeTokenJaccardSimilarity(leftTokens, rightTokens) {
const left = new Set((Array.isArray(leftTokens) ? leftTokens : []).map((value) => String(value || '').trim()).filter(Boolean));
const right = new Set((Array.isArray(rightTokens) ? rightTokens : []).map((value) => String(value || '').trim()).filter(Boolean));
if (left.size === 0 && right.size === 0) {
return 1;
}
if (left.size === 0 || right.size === 0) {
return 0;
}
let shared = 0;
for (const token of left) {
if (right.has(token)) {
shared += 1;
}
}
const unionSize = new Set([...left, ...right]).size;
return unionSize > 0 ? Number((shared / unionSize).toFixed(4)) : 0;
}
function computeSegmentOverlapInfo(leftSegments, rightSegments) {
const leftNumbers = Array.isArray(leftSegments)
? leftSegments.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
: [];
const rightNumbers = Array.isArray(rightSegments)
? rightSegments.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
: [];
const leftSet = new Set(leftNumbers);
const rightSet = new Set(rightNumbers);
if (leftSet.size === 0 || rightSet.size === 0) {
return {
sharedCount: 0,
ratioToSmaller: 0,
ratioToLarger: 0,
jaccard: 0
};
}
let sharedCount = 0;
for (const value of leftSet) {
if (rightSet.has(value)) {
sharedCount += 1;
}
}
const smaller = Math.max(1, Math.min(leftSet.size, rightSet.size));
const larger = Math.max(1, Math.max(leftSet.size, rightSet.size));
const unionSize = new Set([...leftSet, ...rightSet]).size;
return {
sharedCount,
ratioToSmaller: Number((sharedCount / smaller).toFixed(4)),
ratioToLarger: Number((sharedCount / larger).toFixed(4)),
jaccard: unionSize > 0 ? Number((sharedCount / unionSize).toFixed(4)) : 0
};
}
function buildDisjointSet(size) {
const parent = Array.from({ length: Math.max(0, Number(size || 0)) }, (_, index) => index);
const rank = Array.from({ length: parent.length }, () => 0);
const find = (value) => {
if (parent[value] !== value) {
parent[value] = find(parent[value]);
}
return parent[value];
};
const union = (left, right) => {
const rootLeft = find(left);
const rootRight = find(right);
if (rootLeft === rootRight) {
return;
}
if (rank[rootLeft] < rank[rootRight]) {
parent[rootLeft] = rootRight;
return;
}
if (rank[rootLeft] > rank[rootRight]) {
parent[rootRight] = rootLeft;
return;
}
parent[rootRight] = rootLeft;
rank[rootLeft] += 1;
};
return { find, union };
}
function longestCommonSubsequence(leftValues, rightValues) {
const left = Array.isArray(leftValues) ? leftValues : [];
const right = Array.isArray(rightValues) ? rightValues : [];
if (left.length === 0 || right.length === 0) {
return [];
}
const dp = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0));
for (let i = left.length - 1; i >= 0; i -= 1) {
for (let j = right.length - 1; j >= 0; j -= 1) {
if (left[i] === right[j]) {
dp[i][j] = dp[i + 1][j + 1] + 1;
} else {
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
}
const output = [];
let i = 0;
let j = 0;
while (i < left.length && j < right.length) {
if (left[i] === right[j]) {
output.push(left[i]);
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
i += 1;
} else {
j += 1;
}
}
return output;
}
function buildFallbackCoreSequence(clusterTitles) {
const titles = Array.isArray(clusterTitles) ? clusterTitles : [];
const minimumFrequency = Math.max(2, Math.ceil(titles.length * 0.75));
const frequencyMap = new Map();
const positionMap = new Map();
for (const title of titles) {
const numbers = Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : [];
const seenInTitle = new Set();
numbers.forEach((segment, index) => {
if (!Number.isFinite(segment)) {
return;
}
const normalized = Math.trunc(segment);
if (!frequencyMap.has(normalized)) {
frequencyMap.set(normalized, 0);
positionMap.set(normalized, []);
}
if (!seenInTitle.has(normalized)) {
frequencyMap.set(normalized, Number(frequencyMap.get(normalized) || 0) + 1);
seenInTitle.add(normalized);
}
positionMap.get(normalized).push(index);
});
}
return Array.from(frequencyMap.entries())
.filter(([, frequency]) => Number(frequency || 0) >= minimumFrequency)
.map(([segment]) => {
const positions = positionMap.get(segment) || [];
const averagePosition = positions.length > 0
? positions.reduce((sum, value) => sum + Number(value || 0), 0) / positions.length
: Number.MAX_SAFE_INTEGER;
return {
segment,
averagePosition
};
})
.sort((a, b) => a.averagePosition - b.averagePosition || a.segment - b.segment)
.map((entry) => entry.segment);
}
function buildClusterCoreSequence(clusterTitles) {
const titles = Array.isArray(clusterTitles) ? clusterTitles : [];
const sequences = titles
.map((title) => Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : [])
.filter((sequence) => sequence.length > 0);
if (sequences.length === 0) {
return [];
}
let coreSequence = sequences[0].slice();
for (let index = 1; index < sequences.length; index += 1) {
coreSequence = longestCommonSubsequence(coreSequence, sequences[index]);
if (coreSequence.length === 0) {
break;
}
}
if (coreSequence.length >= Math.max(2, Math.floor(sequences[0].length / 3))) {
return coreSequence;
}
return buildFallbackCoreSequence(clusterTitles);
}
function makeVariantBoundaryKey(beforeSegment, afterSegment) {
const before = Number.isFinite(Number(beforeSegment)) ? String(Math.trunc(Number(beforeSegment))).padStart(5, '0') : 'START';
const after = Number.isFinite(Number(afterSegment)) ? String(Math.trunc(Number(afterSegment))).padStart(5, '0') : 'END';
return `${before}->${after}`;
}
function formatVariantSpanLabel(span) {
if (!span || !Array.isArray(span.segments) || span.segments.length === 0) {
return null;
}
const before = Number.isFinite(Number(span.beforeSegment))
? String(Math.trunc(Number(span.beforeSegment))).padStart(5, '0')
: 'START';
const after = Number.isFinite(Number(span.afterSegment))
? String(Math.trunc(Number(span.afterSegment))).padStart(5, '0')
: 'END';
const segmentList = span.segments
.map((segment) => String(Math.trunc(Number(segment))).padStart(5, '0'))
.join(', ');
return `${before}->${after}: [${segmentList}]`;
}
function extractVariantSpansForTitle(segmentNumbers, coreSequence) {
const numbers = Array.isArray(segmentNumbers)
? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
: [];
const core = Array.isArray(coreSequence)
? coreSequence.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
: [];
if (numbers.length === 0 || core.length === 0) {
return [];
}
const coreIndexBySegment = new Map(core.map((segment, index) => [segment, index]));
const spans = [];
let matchedCoreIndex = -1;
let currentSpan = [];
const flushCurrentSpan = (afterSegment = null) => {
if (currentSpan.length === 0) {
return;
}
spans.push({
beforeSegment: matchedCoreIndex >= 0 ? core[matchedCoreIndex] : null,
afterSegment,
segments: currentSpan.slice(),
boundaryKey: makeVariantBoundaryKey(
matchedCoreIndex >= 0 ? core[matchedCoreIndex] : null,
afterSegment
)
});
currentSpan = [];
};
for (const segment of numbers) {
const coreIndex = coreIndexBySegment.has(segment) ? coreIndexBySegment.get(segment) : -1;
const expectedCoreIndex = matchedCoreIndex + 1;
if (coreIndex === expectedCoreIndex) {
flushCurrentSpan(segment);
matchedCoreIndex = coreIndex;
continue;
}
if (coreIndex > expectedCoreIndex) {
flushCurrentSpan(core[expectedCoreIndex] ?? segment);
matchedCoreIndex = coreIndex;
continue;
}
currentSpan.push(segment);
}
flushCurrentSpan(matchedCoreIndex + 1 < core.length ? core[matchedCoreIndex + 1] : null);
return spans;
}
function buildClusterCommonRuns(coreSequence, titleSpanRows) {
const core = Array.isArray(coreSequence)
? coreSequence.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
: [];
if (core.length === 0) {
return [];
}
const boundaries = new Set();
for (const row of titleSpanRows || []) {
for (const span of row?.variantSpans || []) {
if (!Number.isFinite(Number(span?.beforeSegment)) || !Number.isFinite(Number(span?.afterSegment))) {
continue;
}
boundaries.add(makeVariantBoundaryKey(span.beforeSegment, span.afterSegment));
}
}
const runs = [];
let startIndex = 0;
for (let index = 0; index < core.length - 1; index += 1) {
const boundaryKey = makeVariantBoundaryKey(core[index], core[index + 1]);
if (!boundaries.has(boundaryKey)) {
continue;
}
const runSegments = core.slice(startIndex, index + 1);
if (runSegments.length > 0) {
runs.push({
startSegment: runSegments[0],
endSegment: runSegments[runSegments.length - 1],
length: runSegments.length,
segments: runSegments
});
}
startIndex = index + 1;
}
const tailSegments = core.slice(startIndex);
if (tailSegments.length > 0) {
runs.push({
startSegment: tailSegments[0],
endSegment: tailSegments[tailSegments.length - 1],
length: tailSegments.length,
segments: tailSegments
});
}
return runs;
}
function computeLegacySegmentMetrics(segmentNumbers) {
const numbers = Array.isArray(segmentNumbers)
? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
: [];
@@ -627,17 +952,272 @@ function computeSegmentMetrics(segmentNumbers) {
};
}
function computeStandaloneSegmentMetrics(title) {
const legacyMetrics = computeLegacySegmentMetrics(title?.segmentNumbers);
const transitions = Math.max(1, legacyMetrics.segmentCount - 1);
const directRatio = legacyMetrics.directSequenceSteps / transitions;
const nonBackwardRatio = 1 - (legacyMetrics.backwardJumps / transitions);
const boundedJumpRatio = 1 - (legacyMetrics.largeJumps / transitions);
const antiAlternatingRatio = 1 - legacyMetrics.alternatingRatio;
const correctedCoherence = clamp01(
(directRatio * 0.18)
+ (clamp01(nonBackwardRatio) * 0.34)
+ (clamp01(boundedJumpRatio) * 0.34)
+ (clamp01(antiAlternatingRatio) * 0.14)
);
return {
...legacyMetrics,
clusterId: null,
clusterSize: 1,
averageSegmentOverlap: 0,
commonRuns: [],
commonRunCount: 0,
sharedCoreCount: 0,
sharedCoreRatio: 0,
sharedAdjacencyRatio: 0,
variantSpans: [],
variantSpanCount: 0,
variantSegmentCount: 0,
singleSegmentVariantSpans: 0,
multiSegmentVariantSpans: 0,
variantComplexity: 0,
legacySequenceCoherence: legacyMetrics.sequenceCoherence,
legacyStructureScore: legacyMetrics.score,
correctedCoherence: Number(correctedCoherence.toFixed(4)),
sequenceCoherence: Number(correctedCoherence.toFixed(4)),
score: Number((correctedCoherence * 35).toFixed(4)),
coherenceMode: 'standalone',
positionReason: 'Einzelkandidat oder kein Branching-Cluster erkannt.'
};
}
function buildClusterContexts(titles, options = {}) {
const rows = (Array.isArray(titles) ? titles : []).map((title, index) => ({
...title,
_clusterIndex: index,
_audioSignatureTokens: buildAudioSignatureTokens(title)
}));
if (rows.length === 0) {
return [];
}
const durationTolerance = Math.max(
0,
Math.round(Number(options.durationSimilaritySeconds || DEFAULT_DURATION_SIMILARITY_SECONDS))
);
const dsu = buildDisjointSet(rows.length);
for (let leftIndex = 0; leftIndex < rows.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < rows.length; rightIndex += 1) {
const left = rows[leftIndex];
const right = rows[rightIndex];
const durationClose = Math.abs(Number(left?.durationSeconds || 0) - Number(right?.durationSeconds || 0)) <= durationTolerance;
if (!durationClose) {
continue;
}
const audioSimilarity = computeTokenJaccardSimilarity(left._audioSignatureTokens, right._audioSignatureTokens);
if (audioSimilarity < BRANCHING_AUDIO_SIMILARITY_THRESHOLD) {
continue;
}
const overlapInfo = computeSegmentOverlapInfo(left?.segmentNumbers, right?.segmentNumbers);
if (overlapInfo.ratioToSmaller < BRANCHING_OVERLAP_THRESHOLD) {
continue;
}
dsu.union(leftIndex, rightIndex);
}
}
const grouped = new Map();
rows.forEach((row, index) => {
const root = dsu.find(index);
if (!grouped.has(root)) {
grouped.set(root, []);
}
grouped.get(root).push(row);
});
return Array.from(grouped.values())
.filter((clusterRows) => clusterRows.length > 1)
.sort((left, right) => right.length - left.length)
.map((clusterRows, clusterIndex) => {
const clusterId = `branching-${clusterIndex + 1}`;
const pairwiseOverlap = {};
for (const row of clusterRows) {
const playlistId = normalizePlaylistId(row?.playlistId);
if (!playlistId) {
continue;
}
pairwiseOverlap[playlistId] = {};
}
for (let i = 0; i < clusterRows.length; i += 1) {
for (let j = i + 1; j < clusterRows.length; j += 1) {
const left = clusterRows[i];
const right = clusterRows[j];
const leftPlaylistId = normalizePlaylistId(left?.playlistId);
const rightPlaylistId = normalizePlaylistId(right?.playlistId);
const overlapInfo = computeSegmentOverlapInfo(left?.segmentNumbers, right?.segmentNumbers);
if (leftPlaylistId && rightPlaylistId) {
pairwiseOverlap[leftPlaylistId][rightPlaylistId] = overlapInfo;
pairwiseOverlap[rightPlaylistId][leftPlaylistId] = overlapInfo;
}
}
}
const coreSequence = buildClusterCoreSequence(clusterRows);
const titleSpanRows = clusterRows.map((row) => {
const variantSpans = extractVariantSpansForTitle(row?.segmentNumbers, coreSequence);
const variantSegmentCount = variantSpans.reduce((sum, span) => sum + span.segments.length, 0);
const singleSegmentVariantSpans = variantSpans.filter((span) => span.segments.length === 1).length;
const multiSegmentVariantSpans = variantSpans.filter((span) => span.segments.length > 1).length;
const variantComplexity = variantSpans.reduce((sum, span) => sum + (span.segments.length ** 2), 0);
const playlistId = normalizePlaylistId(row?.playlistId);
const overlapEntries = playlistId && pairwiseOverlap[playlistId]
? Object.entries(pairwiseOverlap[playlistId]).map(([otherPlaylistId, info]) => ({
otherPlaylistId,
ratioToSmaller: Number(info?.ratioToSmaller || 0),
sharedCount: Number(info?.sharedCount || 0)
}))
: [];
const averageSegmentOverlap = overlapEntries.length > 0
? overlapEntries.reduce((sum, entry) => sum + Number(entry.ratioToSmaller || 0), 0) / overlapEntries.length
: 0;
return {
row,
variantSpans,
variantSegmentCount,
singleSegmentVariantSpans,
multiSegmentVariantSpans,
variantComplexity,
overlapEntries,
averageSegmentOverlap: Number(averageSegmentOverlap.toFixed(4))
};
});
const commonRuns = buildClusterCommonRuns(coreSequence, titleSpanRows);
const maxVariantComplexity = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantComplexity || 0)), 0);
const maxVariantSegmentCount = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantSegmentCount || 0)), 0);
const maxVariantSpanCount = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantSpans.length || 0)), 0);
const legacyStructureScores = titleSpanRows.map((row) => Number(computeLegacySegmentMetrics(row.row?.segmentNumbers).score || 0));
const minLegacyStructureScore = legacyStructureScores.length > 0 ? Math.min(...legacyStructureScores) : 0;
const maxLegacyStructureScore = legacyStructureScores.length > 0 ? Math.max(...legacyStructureScores) : 0;
const titlesWithMetrics = titleSpanRows.map((spanRow, index) => {
const legacyMetrics = computeLegacySegmentMetrics(spanRow.row?.segmentNumbers);
const sharedCoreCount = coreSequence.length;
const segmentCount = Math.max(1, Number(legacyMetrics.segmentCount || 0));
const sharedCoreRatio = sharedCoreCount > 0 ? sharedCoreCount / segmentCount : 0;
const variantSpanCount = spanRow.variantSpans.length;
const variantComplexityNorm = maxVariantComplexity > 0
? Number(spanRow.variantComplexity || 0) / maxVariantComplexity
: 0;
const variantSegmentNorm = maxVariantSegmentCount > 0
? Number(spanRow.variantSegmentCount || 0) / maxVariantSegmentCount
: 0;
const variantSpanNorm = maxVariantSpanCount > 0
? Number(variantSpanCount || 0) / maxVariantSpanCount
: 0;
const singleSegmentVariantRatio = variantSpanCount > 0
? spanRow.singleSegmentVariantSpans / variantSpanCount
: 1;
const multiSegmentPenalty = variantSpanCount > 0
? spanRow.multiSegmentVariantSpans / variantSpanCount
: 0;
const legacyRange = maxLegacyStructureScore - minLegacyStructureScore;
const legacyTieHint = legacyRange > 0
? clamp01((Number(legacyMetrics.score || 0) - minLegacyStructureScore) / legacyRange)
: 0;
const correctedCoherence = clamp01(
(sharedCoreRatio * 0.38)
+ (spanRow.averageSegmentOverlap * 0.12)
+ (singleSegmentVariantRatio * 0.22)
+ ((1 - variantComplexityNorm) * 0.16)
+ ((1 - variantSegmentNorm) * 0.08)
+ ((1 - variantSpanNorm) * 0.04)
+ (legacyTieHint * 0.02)
- (multiSegmentPenalty * 0.08)
);
let positionReason = 'nahe Branching-Variante';
if (spanRow.multiSegmentVariantSpans === 0 && spanRow.variantSegmentCount <= Math.max(1, variantSpanCount)) {
positionReason = 'strukturell einfachster Basis-/Referenzpfad im Cluster';
} else if (spanRow.multiSegmentVariantSpans > 0) {
positionReason = 'Variante mit zusätzlichen Mehrsegment-Branches';
}
return {
...spanRow.row,
...legacyMetrics,
clusterId,
clusterSize: clusterRows.length,
averageSegmentOverlap: spanRow.averageSegmentOverlap,
overlapEntries: spanRow.overlapEntries,
commonRuns,
commonRunCount: commonRuns.length,
sharedCoreCount,
sharedCoreRatio: Number(sharedCoreRatio.toFixed(4)),
sharedAdjacencyRatio: coreSequence.length > 1 ? 1 : 0,
variantSpans: spanRow.variantSpans,
variantSpanCount,
variantSegmentCount: spanRow.variantSegmentCount,
singleSegmentVariantSpans: spanRow.singleSegmentVariantSpans,
multiSegmentVariantSpans: spanRow.multiSegmentVariantSpans,
variantComplexity: spanRow.variantComplexity,
legacySequenceCoherence: legacyMetrics.sequenceCoherence,
legacyStructureScore: legacyMetrics.score,
correctedCoherence: Number(correctedCoherence.toFixed(4)),
sequenceCoherence: Number(correctedCoherence.toFixed(4)),
score: Number((((correctedCoherence * 60) + Math.min(18, (clusterRows.length - 1) * 9))).toFixed(4)),
coherenceMode: 'cluster_relative',
positionReason,
_legacyTieHint: legacyTieHint,
_clusterSortIndex: index
};
});
const variantBoundaries = uniqueOrdered(
titleSpanRows
.flatMap((row) => row.variantSpans.map((span) => formatVariantSpanLabel(span)))
.filter(Boolean)
);
return {
id: clusterId,
size: clusterRows.length,
coreSequence,
commonRuns,
variantBoundaries,
pairwiseOverlap,
titles: titlesWithMetrics
};
});
}
function computeSegmentMetrics(title, clusterMetricsByPlaylist = null) {
const playlistId = normalizePlaylistId(title?.playlistId);
if (playlistId && clusterMetricsByPlaylist && clusterMetricsByPlaylist[playlistId]) {
return clusterMetricsByPlaylist[playlistId];
}
return computeStandaloneSegmentMetrics(title);
}
function buildEvaluationLabel(metrics) {
if (!metrics || metrics.segmentCount === 0) {
return 'Keine Segmentliste aus TINFO:26 verfügbar';
}
if (String(metrics?.coherenceMode || '') === 'cluster_relative' && Number(metrics?.clusterSize || 0) > 1) {
if (metrics.multiSegmentVariantSpans === 0 && metrics.variantSegmentCount <= Math.max(1, Number(metrics.variantSpanCount || 0))) {
return 'strukturell sauberster Basis-/Referenzpfad im Branching-Cluster';
}
if (metrics.correctedCoherence >= 0.6) {
return 'nahe Branching-Variante mit plausibler Segmentstruktur';
}
return 'komplexere Branching-Variante - Sichtprüfung empfohlen';
}
if (metrics.alternatingRatio >= 0.55 && metrics.alternatingPairs >= 3) {
return 'Fake-Struktur (alternierendes Sprungmuster)';
return 'Auffällige Segmentstruktur - Sichtprüfung empfohlen';
}
if (metrics.backwardJumps > 0 || metrics.largeJumps > 0) {
return 'Auffällige Segmentreihenfolge';
if (metrics.correctedCoherence < 0.45) {
return 'unklare Segmentstruktur - Sichtprüfung empfohlen';
}
return 'wahrscheinlich korrekt (lineare Segmentfolge)';
return 'wahrscheinlich konsistente Segmentstruktur';
}
function normalizeRelativeScore(value, maxValue) {
@@ -652,7 +1232,7 @@ function normalizeRelativeScore(value, maxValue) {
return Math.min(1, Math.max(0, numericValue / numericMax));
}
function scoreCandidates(groupTitles) {
function scoreCandidates(groupTitles, options = {}) {
const titles = Array.isArray(groupTitles) ? groupTitles : [];
if (titles.length === 0) {
return [];
@@ -670,20 +1250,70 @@ function scoreCandidates(groupTitles) {
(max, title) => Math.max(max, Number(title?.chapters || 0)),
0
);
const clusterContexts = buildClusterContexts(titles, options);
const clusterMetricsByPlaylist = {};
const clusterSummaryRows = [];
for (const clusterContext of clusterContexts) {
const playlistIds = [];
for (const metrics of clusterContext.titles || []) {
const playlistId = normalizePlaylistId(metrics?.playlistId);
if (playlistId) {
clusterMetricsByPlaylist[playlistId] = metrics;
playlistIds.push(playlistId);
}
}
clusterSummaryRows.push({
id: clusterContext.id,
size: clusterContext.size,
playlistIds: uniqueOrdered(playlistIds),
commonRuns: clusterContext.commonRuns,
variantBoundaries: clusterContext.variantBoundaries
});
}
return titles
.map((title) => {
const metrics = computeSegmentMetrics(title.segmentNumbers);
const metrics = computeSegmentMetrics(title, clusterMetricsByPlaylist);
const structureScore = Number(metrics.score || 0);
const durationScore = normalizeRelativeScore(title.durationSeconds, maxDurationSeconds) * 20;
const audioScore = normalizeRelativeScore(title.audioTrackCount, maxAudioTrackCount) * 4;
const chapterScore = normalizeRelativeScore(title.chapters, maxChapterCount) * 6;
const totalScore = structureScore + durationScore + audioScore + chapterScore;
const overlapSummary = Array.isArray(metrics?.overlapEntries)
? metrics.overlapEntries
.map((entry) => `${entry.otherPlaylistId}:${Number(entry.ratioToSmaller || 0).toFixed(3)}`)
.join(', ')
: '';
const commonRunsSummary = Array.isArray(metrics?.commonRuns)
? metrics.commonRuns
.map((run) => `${String(run.startSegment).padStart(5, '0')}-${String(run.endSegment).padStart(5, '0')}`)
.join(' | ')
: '';
const variantSummary = Array.isArray(metrics?.variantSpans)
? metrics.variantSpans
.map((span) => formatVariantSpanLabel(span))
.filter(Boolean)
.join(' | ')
: '';
const reasons = [
`structure_score=${structureScore.toFixed(2)}`,
`duration_score=${durationScore.toFixed(2)}`,
`audio_score=${audioScore.toFixed(2)}`,
`chapter_score=${chapterScore.toFixed(2)}`,
`legacy_structure_score=${Number(metrics.legacyStructureScore || 0).toFixed(2)}`,
`legacy_sequence_coherence=${Number(metrics.legacySequenceCoherence || 0).toFixed(3)}`,
`corrected_coherence=${Number(metrics.correctedCoherence || 0).toFixed(3)}`,
`cluster=${metrics.clusterId || 'standalone'}${metrics.clusterSize > 1 ? `(${metrics.clusterSize})` : ''}`,
`avg_segment_overlap=${Number(metrics.averageSegmentOverlap || 0).toFixed(3)}`,
`core_coverage=${Number(metrics.sharedCoreRatio || 0).toFixed(3)}`,
`variant_spans=${Number(metrics.variantSpanCount || 0)}`,
`variant_segments=${Number(metrics.variantSegmentCount || 0)}`,
`single_segment_spans=${Number(metrics.singleSegmentVariantSpans || 0)}`,
`multi_segment_spans=${Number(metrics.multiSegmentVariantSpans || 0)}`,
`common_runs=${commonRunsSummary || '-'}`,
`variant_boundaries=${variantSummary || '-'}`,
`segment_overlap_map=${overlapSummary || '-'}`,
`position_reason=${metrics.positionReason || '-'}`,
`sequence_steps=${metrics.directSequenceSteps}`,
`sequence_coherence=${metrics.sequenceCoherence.toFixed(3)}`,
`backward_jumps=${metrics.backwardJumps}`,
@@ -696,19 +1326,31 @@ function scoreCandidates(groupTitles) {
score: Number(totalScore.toFixed(4)),
reasons,
structuralMetrics: metrics,
evaluationLabel: buildEvaluationLabel(metrics)
evaluationLabel: buildEvaluationLabel(metrics),
correctedCoherence: Number(metrics.correctedCoherence || 0),
legacySequenceCoherence: Number(metrics.legacySequenceCoherence || 0),
legacyStructureScore: Number(metrics.legacyStructureScore || 0),
clusterId: metrics.clusterId || null,
clusterSize: Number(metrics.clusterSize || 1),
averageSegmentOverlap: Number(metrics.averageSegmentOverlap || 0),
commonRuns: Array.isArray(metrics.commonRuns) ? metrics.commonRuns : [],
variantSpans: Array.isArray(metrics.variantSpans) ? metrics.variantSpans : [],
overlapEntries: Array.isArray(metrics.overlapEntries) ? metrics.overlapEntries : [],
positionReason: metrics.positionReason || null
};
})
.sort((a, b) =>
b.score - a.score
|| b.structuralMetrics.sequenceCoherence - a.structuralMetrics.sequenceCoherence
|| b.legacyStructureScore - a.legacyStructureScore
|| b.durationSeconds - a.durationSeconds
|| b.sizeBytes - a.sizeBytes
|| a.titleId - b.titleId
)
.map((item, index) => ({
...item,
recommended: index === 0
recommended: index === 0,
clusterSummary: clusterSummaryRows.find((row) => row.id === item.clusterId) || null
}));
}
@@ -809,11 +1451,24 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {})
const multipleCandidatesDetected = candidatePlaylistsAll.length > 1;
const manualDecisionRequired = multipleCandidatesDetected;
const decisionPool = manualDecisionRequired ? playlistBackedCandidates : [];
const evaluatedCandidates = decisionPool.length > 0 ? scoreCandidates(decisionPool) : [];
const evaluatedCandidates = decisionPool.length > 0
? scoreCandidates(decisionPool, { durationSimilaritySeconds })
: [];
const recommendation = evaluatedCandidates[0] || null;
const candidatePlaylists = manualDecisionRequired ? candidatePlaylistsAll : [];
const rankedCandidatePlaylists = uniqueOrdered(
evaluatedCandidates.map((item) => normalizePlaylistId(item?.playlistId)).filter(Boolean)
);
const candidatePlaylists = manualDecisionRequired
? uniqueOrdered([...rankedCandidatePlaylists, ...candidatePlaylistsAll])
: [];
const playlistSegments = buildPlaylistSegmentMap(decisionPool);
const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitlesWithAliases);
const branchingClusters = uniqueOrdered(
evaluatedCandidates
.map((item) => item?.clusterSummary)
.filter((value) => value && typeof value === 'object')
.map((value) => JSON.stringify(value))
).map((value) => JSON.parse(value));
return {
generatedAt: new Date().toISOString(),
@@ -845,6 +1500,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {})
: null,
evaluatedCandidates,
playlistSegments,
branchingClusters,
structuralAnalysis: {
method: 'makemkv_tinfo_26',
sourceCommand: 'makemkvcon -r info disc:0 --robot',
@@ -0,0 +1,100 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { analyzePlaylistObfuscation } = require('../src/utils/playlistAnalysis');
function buildTitleLines({
titleId,
playlistId,
duration = '01:34:41',
chapters = 20,
audioTrackCount = 3,
subtitleTrackCount = 2,
segmentNumbers = []
}) {
const lines = [
`MSG:3016,0,0,"${playlistId}.mpls","${titleId}"`,
`TINFO:${titleId},16,0,"${playlistId}.mpls"`,
`TINFO:${titleId},9,0,"${duration}"`,
`TINFO:${titleId},8,0,"${chapters}"`,
`TINFO:${titleId},26,0,"${segmentNumbers.join(', ')}"`
];
for (let index = 0; index < audioTrackCount; index += 1) {
lines.push(`SINFO:${titleId},${index},1,0,"Audio"`);
lines.push(`SINFO:${titleId},${index},3,0,"eng"`);
lines.push(`SINFO:${titleId},${index},4,0,"English"`);
lines.push(`SINFO:${titleId},${index},6,0,"DTS-HD MA"`);
lines.push(`SINFO:${titleId},${index},40,0,"5.1 ch"`);
}
for (let index = 0; index < subtitleTrackCount; index += 1) {
const streamIndex = audioTrackCount + index;
lines.push(`SINFO:${titleId},${streamIndex},1,0,"Subtitle"`);
lines.push(`SINFO:${titleId},${streamIndex},3,0,"eng"`);
lines.push(`SINFO:${titleId},${streamIndex},4,0,"English"`);
lines.push(`SINFO:${titleId},${streamIndex},6,0,"PGS"`);
}
return lines;
}
test('branching cluster prefers the structurally simplest reference playlist', () => {
const lines = [
...buildTitleLines({
titleId: 0,
playlistId: '00802',
segmentNumbers: [
866, 868, 829, 848, 830, 849, 831, 850, 832, 851,
833, 852, 834, 853, 835, 854, 869, 870, 872, 855,
837, 856, 838, 857, 839, 858, 840, 859, 841, 860,
842, 861, 843, 862, 844, 863, 845, 864, 846
]
}),
...buildTitleLines({
titleId: 1,
playlistId: '00800',
segmentNumbers: [
847, 829, 848, 830, 849, 831, 850, 832, 851, 833,
852, 834, 853, 835, 854, 836, 855, 837, 856, 838,
857, 839, 858, 840, 859, 841, 860, 842, 861, 843,
862, 844, 863, 845, 864, 846
]
}),
...buildTitleLines({
titleId: 3,
playlistId: '00804',
segmentNumbers: [
867, 868, 829, 848, 830, 849, 831, 850, 832, 851,
833, 852, 834, 853, 835, 854, 869, 871, 872, 855,
837, 856, 838, 857, 839, 858, 840, 859, 841, 860,
842, 861, 843, 862, 844, 863, 845, 864, 846
]
})
];
const analysis = analyzePlaylistObfuscation(lines, 60, {
durationSimilaritySeconds: 90
});
assert.equal(analysis?.recommendation?.playlistId, '00800');
assert.deepEqual(
(analysis?.evaluatedCandidates || []).map((item) => item.playlistId),
['00800', '00804', '00802']
);
assert.deepEqual(analysis?.candidatePlaylists || [], ['00800', '00804', '00802']);
const preferred = (analysis?.evaluatedCandidates || [])[0];
assert.equal(preferred?.recommended, true);
assert.equal(preferred?.evaluationLabel, 'strukturell sauberster Basis-/Referenzpfad im Branching-Cluster');
const variantSummary = (preferred?.variantSpans || []).map((span) => ({
before: span.beforeSegment,
after: span.afterSegment,
segments: span.segments
}));
assert.deepEqual(variantSummary, [
{ before: null, after: 829, segments: [847] },
{ before: 854, after: 855, segments: [836] }
]);
});