emby-toolbox/EmbyToolbox/Services/TrackSettingsSnapshotService.cs
Emby Toolbox 6264b487fe Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:33:47 +05:00

315 lines
13 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text;
using System.Text.RegularExpressions;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Сопоставление snapshot с текущим файлом: Type + Source + Language + номер дорожки в группе; видео/аудио/субтитры упорядочены по общему рангу.</summary>
public sealed class TrackSettingsSnapshotService
{
private const string LogModule = "conversion.snapshot";
/// <summary>Только пробельные символы (включая переносы).</summary>
private static readonly Regex WhitespaceCollapse = new(@"\s+", RegexOptions.Compiled);
/// <summary>Символы кавычек и типографских кавычек.</summary>
private static readonly Regex QuoteStrip = new(@"[""'`´«»„“”‚‘’]", RegexOptions.Compiled);
/// <summary>Спецсимволы помимо букв/цифр/пробела.</summary>
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;
}
/// <summary>
/// batchScopeRootOptional: корень добавления («добавить каталог», общий предок файлов дропом); пустое — сохранять только каталог файла как область точного попадания.
/// </summary>
public void SaveSnapshot(string filePath, IReadOnlyList<TrackSettingsSnapshotItem> 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<TrackSettingsSnapshotItem> 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<TrackMatchResult>(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);
}
/// <summary>Не менее двух дорожек с языком <c>und</c>: сопоставление только по очередности внутри группы Kind+Source+und.</summary>
public static bool TracksHaveRiskyMultipleUndTracks(IReadOnlyList<TrackSettingsSnapshotItem> 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;
}
/// <summary>Нормализация Title только для доп. сравнения (не ключ сопоставления).</summary>
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<TrackSettingsSnapshotItem> 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<BucketKey, TrackSettingsSnapshotItem> BuildSnapshotLookup(
IReadOnlyList<TrackSettingsSnapshotItem> snapItems)
{
var dict = new Dictionary<BucketKey, TrackSettingsSnapshotItem>();
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<TrackSettingsSnapshotItem> 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()
};
/// <summary>Rus/eng/und и т.д.</summary>
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;
}
}