using System.Text; using System.Text.RegularExpressions; using EmbyToolbox.Models; namespace EmbyToolbox.Services; /// Сопоставление snapshot с текущим файлом: Type + Source + Language + номер дорожки в группе; видео/аудио/субтитры упорядочены по общему рангу. public sealed class TrackSettingsSnapshotService { private const string LogModule = "conversion.snapshot"; /// Только пробельные символы (включая переносы). private static readonly Regex WhitespaceCollapse = new(@"\s+", RegexOptions.Compiled); /// Символы кавычек и типографских кавычек. private static readonly Regex QuoteStrip = new(@"[""'`´«»„“”‚‘’]", RegexOptions.Compiled); /// Спецсимволы помимо букв/цифр/пробела. private static readonly Regex NoiseStrip = new(@"[^\p{L}\p{N}\s]", RegexOptions.Compiled); private TrackSettingsSnapshot? _lastSnapshot; private readonly LoggingService? _logging; public TrackSettingsSnapshotService(LoggingService? logging = null) { _logging = logging; } /// /// batchScopeRootOptional: корень добавления («добавить каталог», общий предок файлов дропом); пустое — сохранять только каталог файла как область точного попадания. /// public void SaveSnapshot(string filePath, IReadOnlyList trackPlans, string? batchScopeRootOptional) { var scopeDirectory = SnapshotScopePaths.GetVideoScopeDirectory(filePath); var normalizedBatch = string.IsNullOrWhiteSpace(batchScopeRootOptional) ? null : SnapshotScopePaths.NormalizeScopeDirectory(batchScopeRootOptional); _lastSnapshot = new TrackSettingsSnapshot { FilePath = filePath, ScopeDirectory = scopeDirectory, ScopeBatchRoot = normalizedBatch, Signature = BuildSignature(trackPlans), Items = trackPlans.ToArray() }; _logging?.Info($"snapshot сохранен: {filePath} ({trackPlans.Count} дор.), scope:{scopeDirectory}, batchRoot:{(normalizedBatch ?? "-")}", LogModule); } public SnapshotApplyResult TryApplySnapshot( IReadOnlyList currentTrackPlans, string currentVideoFullPath, string? currentBatchScopeRootOptional) { if (_lastSnapshot is null) { return new SnapshotApplyResult(false, SnapshotApplyDegree.None, SnapshotApplyReason.NoSnapshot, null); } if (!IsAllowedSnapshotScope(currentVideoFullPath, currentBatchScopeRootOptional)) { _logging?.Info( $"snapshot: область не совпадает — не применяем (текущая папка: {SnapshotScopePaths.GetVideoScopeDirectory(currentVideoFullPath)}; сохранены: {_lastSnapshot.ScopeDirectory}, batch:{_lastSnapshot.ScopeBatchRoot ?? "-"}) — источник {_lastSnapshot.FilePath}", LogModule); return new SnapshotApplyResult(false, SnapshotApplyDegree.None, SnapshotApplyReason.ScopeMismatch, null); } var snapItems = _lastSnapshot.Items; var currents = AssignBucketKeys(currentTrackPlans); var snapMap = BuildSnapshotLookup(snapItems); var rowResults = new List(currents.Count); foreach (var (curItem, bucketKey) in currents) { if (snapMap.TryGetValue(bucketKey, out var src)) { rowResults.Add(new TrackMatchResult { CurrentOrder = curItem.Order, IsMatched = true, Strategy = MatchingStrategy.OrdinalByTypeSourceLanguage, HadAmbiguousCandidates = false, SourceItem = src, ResolvedAction = src.Action }); LogTitleSoftMismatchIfAny(curItem, src); } else { rowResults.Add(new TrackMatchResult { CurrentOrder = curItem.Order, IsMatched = false, Strategy = MatchingStrategy.None, HadAmbiguousCandidates = false, SourceItem = null, ResolvedAction = curItem.Action }); } } var matchedCount = rowResults.Count(r => r.IsMatched); SnapshotApplyDegree degree; if (matchedCount == 0) { degree = SnapshotApplyDegree.None; } else if (matchedCount == currentTrackPlans.Count && matchedCount == snapItems.Count) { degree = SnapshotApplyDegree.Full; } else { degree = SnapshotApplyDegree.Partial; } var appliedAny = matchedCount > 0; WriteApplyLog(currentTrackPlans.Count, snapItems.Count, matchedCount, degree); return new SnapshotApplyResult(appliedAny, degree, SnapshotApplyReason.Success, rowResults); } /// Не менее двух дорожек с языком und: сопоставление только по очередности внутри группы Kind+Source+und. public static bool TracksHaveRiskyMultipleUndTracks(IReadOnlyList trackPlans) => trackPlans .GroupBy(t => (t.StreamKind, t.Source, Lang: NormalizeLanguage(t.Language))) .Any(static g => g.Key.Lang == "und" && g.Count() > 1); private bool IsAllowedSnapshotScope(string currentVideoFullPath, string? currentBatchScopeOptional) { if (_lastSnapshot is null) { return false; } var snapScope = string.IsNullOrWhiteSpace(_lastSnapshot.ScopeDirectory) ? SnapshotScopePaths.GetVideoScopeDirectory(_lastSnapshot.FilePath) : SnapshotScopePaths.NormalizeScopeDirectory(_lastSnapshot.ScopeDirectory); var curScope = SnapshotScopePaths.GetVideoScopeDirectory(currentVideoFullPath); if (string.Equals(curScope, snapScope, StringComparison.OrdinalIgnoreCase)) { return true; } var snapBatch = SnapshotScopePaths.NormalizeScopeBatchRootNullable(_lastSnapshot.ScopeBatchRoot); var curBatch = SnapshotScopePaths.NormalizeScopeBatchRootNullable(currentBatchScopeOptional); if (snapBatch.Length != 0 && curBatch.Length != 0 && string.Equals(snapBatch, curBatch, StringComparison.OrdinalIgnoreCase)) { return true; } return false; } /// Нормализация Title только для доп. сравнения (не ключ сопоставления). public static string NormalizeTitleFingerprint(string? title) { if (string.IsNullOrWhiteSpace(title)) { return string.Empty; } var sb = new StringBuilder(title.Trim()); sb.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' '); var s = QuoteStrip.Replace(sb.ToString(), string.Empty); s = NoiseStrip.Replace(s, string.Empty); s = WhitespaceCollapse.Replace(s, " ").Trim(); return string.IsNullOrEmpty(s) ? string.Empty : s.ToLowerInvariant(); } private void LogTitleSoftMismatchIfAny(TrackSettingsSnapshotItem current, TrackSettingsSnapshotItem snapshot) { var a = NormalizeTitleFingerprint(snapshot.Title); var b = NormalizeTitleFingerprint(current.Title); if (a.Length != 0 && b.Length != 0 && !string.Equals(a, b, StringComparison.Ordinal)) { _logging?.Debug($"snapshot: title отличается (доп. проверка), ключ не затронут. порядок {current.Order}", LogModule); } } private void WriteApplyLog(int currentCount, int snapCount, int matched, SnapshotApplyDegree degree) { var prev = _lastSnapshot?.FilePath ?? "?"; if (degree == SnapshotApplyDegree.Full) { _logging?.Info( $"snapshot: применено по порядку дорожек (полное, {matched}/{currentCount}); источник {prev}", LogModule); return; } if (degree == SnapshotApplyDegree.Partial) { _logging?.Info( $"snapshot: применено частично ({matched}/{currentCount} дорожек, в snapshot было {snapCount}); источник {prev}", LogModule); return; } if (currentCount != snapCount) { _logging?.Info( $"snapshot: не применено — не удалось сопоставить ни одной дорожки (snapshot {snapCount}, текущее {currentCount}); источник {prev}", LogModule); } else { _logging?.Info( $"snapshot: не применено — не удалось сопоставить дорожки (тип язык или номер в группе отличается); источник {prev}", LogModule); } } internal readonly record struct BucketKey(MediaStreamKind Kind, SourceKind Source, string LangNorm, int OrdinalInGroup); private static IReadOnlyList<(TrackSettingsSnapshotItem Item, BucketKey Key)> AssignBucketKeys( IReadOnlyList items) { var sorted = items.OrderBy(x => StreamKindRank(x.StreamKind)).ThenBy(x => x.Order).ToList(); var counts = new Dictionary<(MediaStreamKind, SourceKind, string), int>(); var list = new List<(TrackSettingsSnapshotItem Item, BucketKey Key)>(sorted.Count); foreach (var it in sorted) { var lang = NormalizeLanguage(it.Language); var gKey = (it.StreamKind, it.Source, lang); var ordinal = counts.GetValueOrDefault(gKey, 0) + 1; counts[gKey] = ordinal; var bk = new BucketKey(it.StreamKind, it.Source, lang, ordinal); list.Add((it, bk)); } list.Sort((a, b) => a.Item.Order.CompareTo(b.Item.Order)); return list; } private static Dictionary BuildSnapshotLookup( IReadOnlyList snapItems) { var dict = new Dictionary(); foreach (var (item, key) in AssignBucketKeys(snapItems)) { dict[key] = item; } return dict; } private static int StreamKindRank(MediaStreamKind k) => k switch { MediaStreamKind.Video => 0, MediaStreamKind.Audio => 1, MediaStreamKind.Subtitle => 2, MediaStreamKind.Attachment => 3, MediaStreamKind.Data => 4, _ => 99 }; private static TrackStructureSignature BuildSignature(IReadOnlyList trackPlans) => new() { Tracks = trackPlans .Select(x => new TrackStructureSignatureItem { Order = x.Order, StreamKind = x.StreamKind, Source = x.Source, Codec = (x.Codec ?? string.Empty).Trim(), Language = NormalizeLanguage(x.Language), Title = NormalizeTitleFingerprint(x.Title) }) .ToArray() }; /// Rus/eng/und и т.д. public static string NormalizeLanguage(string? language) { var raw = (language ?? string.Empty).Trim(); if (raw.Length == 0) { return "und"; } var t = raw.ToLowerInvariant(); if (t is "und" or "unknown" or "?" or "??") { return "und"; } if (t.Length == 2) { return t switch { "ru" => "rus", "en" => "eng", "uk" => "ukr", "de" => "deu", "fr" => "fra", "es" => "spa", "it" => "ita", "ja" => "jpn", "ko" => "kor", "zh" => "zho", _ => t }; } return t; } }