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;
}
}