emby-toolbox/EmbyToolbox/Services/ForcedSubtitleDetector.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

330 lines
12 KiB
C#

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<string>(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<IReadOnlyDictionary<int, SubtitleTrackStats>> 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<int, SubtitleTrackStats>();
}
try
{
using var doc = JsonDocument.Parse(probe.Json);
if (!doc.RootElement.TryGetProperty("packets", out var packets) || packets.ValueKind != JsonValueKind.Array)
{
return new Dictionary<int, SubtitleTrackStats>();
}
var aggregate = new Dictionary<int, SubtitleStatsAccumulator>();
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<int, SubtitleTrackStats>(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<int, SubtitleTrackStats>();
}
}
public async Task<MediaAnalysisResult> 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<MediaStreamInfo>(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);