using System.Globalization; using System.Linq; using System.Text.Json; using EmbyToolbox.Models; namespace EmbyToolbox.Services; public sealed class ForcedSubtitleDetector { private const int ForcedEventsThreshold = 200; private const double ForcedCoverageThreshold = 0.10; private static readonly string[] MarkerTokens = [ "forced", "force", "форс", "форсированные", "signs", "songs", "signs & songs", "надписи" ]; private readonly FfprobeService _ffprobe; private readonly LoggingService _logging; public ForcedSubtitleDetector(FfprobeService ffprobe, LoggingService logging) { _ffprobe = ffprobe; _logging = logging; } public bool IsForcedSubtitle(MediaStreamInfo track, double? mediaDuration) => !string.IsNullOrWhiteSpace(DetectReason(track, mediaDuration)); public string? DetectReason(MediaStreamInfo track, double? mediaDuration) { if (track.Kind != MediaStreamKind.Subtitle) { return null; } var reasons = new List(4); if (track.IsForcedByDisposition) { reasons.Add("disposition"); } if (ContainsMarker(track.Title)) { reasons.Add("title"); } if (ContainsMarker(track.FileNameTag)) { reasons.Add("filename"); } var stats = CalculateSubtitleStats(track, mediaDuration); if (stats.EventCount is { } count && count < ForcedEventsThreshold) { reasons.Add("events"); } if (stats.Coverage is { } coverage && coverage < ForcedCoverageThreshold) { reasons.Add("coverage"); } return reasons.Count == 0 ? null : string.Join("/", reasons.Distinct(StringComparer.Ordinal)); } public SubtitleTrackStats CalculateSubtitleStats(MediaStreamInfo track, double? mediaDuration) { var events = track.SubtitleEventCount; var coverage = track.SubtitleCoverage; if (coverage is null && mediaDuration is > 0 && track.DurationSeconds is { } streamDuration && streamDuration > 0) { coverage = streamDuration / mediaDuration.Value; } return new SubtitleTrackStats(events, coverage); } public async Task> CalculateSubtitleStats( string videoPath, double? mediaDuration, CancellationToken cancellationToken = default) { var probe = await _ffprobe.AnalyzeSubtitlePacketsAsync(videoPath, cancellationToken).ConfigureAwait(false); if (!probe.IsSuccess) { _logging.Debug($"forced subtitle stats skipped: {probe.Error}", "subtitle.forced"); return new Dictionary(); } try { using var doc = JsonDocument.Parse(probe.Json); if (!doc.RootElement.TryGetProperty("packets", out var packets) || packets.ValueKind != JsonValueKind.Array) { return new Dictionary(); } var aggregate = new Dictionary(); foreach (var packet in packets.EnumerateArray()) { if (!packet.TryGetProperty("stream_index", out var streamIndexRaw)) { continue; } var streamIndex = ParseInt(streamIndexRaw); if (streamIndex is null) { continue; } if (!aggregate.TryGetValue(streamIndex.Value, out var current)) { current = new SubtitleStatsAccumulator(); } current.EventCount++; if (packet.TryGetProperty("duration_time", out var durationTimeRaw)) { var duration = ParseDouble(durationTimeRaw); if (duration is > 0) { current.TotalDurationSeconds += duration.Value; } } aggregate[streamIndex.Value] = current; } var result = new Dictionary(aggregate.Count); foreach (var (streamIndex, stats) in aggregate) { double? coverage = null; if (mediaDuration is > 0 && stats.TotalDurationSeconds > 0) { coverage = stats.TotalDurationSeconds / mediaDuration.Value; } result[streamIndex] = new SubtitleTrackStats(stats.EventCount, coverage); } return result; } catch (Exception ex) { _logging.Warning($"forced subtitle stats parse failed: {ex.Message}", "subtitle.forced", ex); return new Dictionary(); } } public async Task ApplyDetectionAsync( string videoPath, MediaAnalysisResult media, CancellationToken cancellationToken = default) { if (media.SubtitleStreams.Count == 0) { return media; } var statsByStream = await CalculateSubtitleStats(videoPath, media.GetEffectiveDurationSeconds(), cancellationToken).ConfigureAwait(false); var updatedAll = new List(media.AllStreams.Count); foreach (var stream in media.AllStreams) { if (stream.Kind != MediaStreamKind.Subtitle) { updatedAll.Add(stream); continue; } statsByStream.TryGetValue(stream.Index, out var stats); var withStats = new MediaStreamInfo { Index = stream.Index, Kind = stream.Kind, CodecName = stream.CodecName, Language = stream.Language, Title = stream.Title, IsDefault = stream.IsDefault, BitRateBps = stream.BitRateBps, Profile = stream.Profile, Encoder = stream.Encoder, Channels = stream.Channels, SampleRateHz = stream.SampleRateHz, Width = stream.Width, Height = stream.Height, AverageFrameRate = stream.AverageFrameRate, FrameRate = stream.FrameRate, PixelFormat = stream.PixelFormat, ColorSpace = stream.ColorSpace, ColorPrimaries = stream.ColorPrimaries, ColorTransfer = stream.ColorTransfer, SubtitleFormat = stream.SubtitleFormat, FileNameTag = stream.FileNameTag, IsForcedByDisposition = stream.IsForcedByDisposition, SubtitleEventCount = stats.EventCount, SubtitleCoverage = stats.Coverage, AttachmentDeclaredFileName = stream.AttachmentDeclaredFileName, AttachmentDeclaredMimeType = stream.AttachmentDeclaredMimeType, DurationSeconds = stream.DurationSeconds }; var reason = DetectReason(withStats, media.GetEffectiveDurationSeconds()); var isForced = !string.IsNullOrWhiteSpace(reason); var updated = new MediaStreamInfo { Index = withStats.Index, Kind = withStats.Kind, CodecName = withStats.CodecName, Language = withStats.Language, Title = withStats.Title, IsDefault = withStats.IsDefault, BitRateBps = withStats.BitRateBps, Profile = withStats.Profile, Encoder = withStats.Encoder, Channels = withStats.Channels, SampleRateHz = withStats.SampleRateHz, Width = withStats.Width, Height = withStats.Height, AverageFrameRate = withStats.AverageFrameRate, FrameRate = withStats.FrameRate, PixelFormat = withStats.PixelFormat, ColorSpace = withStats.ColorSpace, ColorPrimaries = withStats.ColorPrimaries, ColorTransfer = withStats.ColorTransfer, SubtitleFormat = withStats.SubtitleFormat, FileNameTag = withStats.FileNameTag, IsForcedByDisposition = withStats.IsForcedByDisposition, IsForced = isForced, ForcedDetectionReason = reason, SubtitleEventCount = withStats.SubtitleEventCount, SubtitleCoverage = withStats.SubtitleCoverage, AttachmentDeclaredFileName = withStats.AttachmentDeclaredFileName, AttachmentDeclaredMimeType = withStats.AttachmentDeclaredMimeType, DurationSeconds = withStats.DurationSeconds }; if (isForced) { _logging.Debug($"forced subtitle detected: {BuildTrackLabel(updated)}", "subtitle.forced"); _logging.Debug($"forced detection reason: {reason}", "subtitle.forced"); } updatedAll.Add(updated); } return new MediaAnalysisResult { ContainerFormat = media.ContainerFormat, FormatName = media.FormatName, FormatBitRateBps = media.FormatBitRateBps, DurationSeconds = media.DurationSeconds, SourceVideoBitrateBps = media.SourceVideoBitrateBps, AllStreams = updatedAll, VideoStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Video).ToList(), AudioStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Audio).ToList(), SubtitleStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Subtitle).ToList(), DataStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Data).ToList() }; } private static bool ContainsMarker(string? input) { if (string.IsNullOrWhiteSpace(input)) { return false; } var normalized = input.Trim().ToLowerInvariant(); return MarkerTokens.Any(normalized.Contains); } private static int? ParseInt(JsonElement value) { if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) { return number; } if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) { return parsed; } return null; } private static double? ParseDouble(JsonElement value) { if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number)) { return number; } if (value.ValueKind == JsonValueKind.String && double.TryParse(value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)) { return parsed; } return null; } private static string BuildTrackLabel(MediaStreamInfo stream) { var title = string.IsNullOrWhiteSpace(stream.Title) ? "-" : stream.Title; var file = string.IsNullOrWhiteSpace(stream.FileNameTag) ? "-" : stream.FileNameTag; return $"#{stream.Index} | title={title} | file={file}"; } private struct SubtitleStatsAccumulator { public int EventCount; public double TotalDurationSeconds; } } public readonly record struct SubtitleTrackStats(int? EventCount, double? Coverage);