330 lines
12 KiB
C#
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);
|