315 lines
13 KiB
C#
315 lines
13 KiB
C#
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;
|
||
}
|
||
}
|