Retain selectable audio languages and DVB subtitles through live continuity

This commit is contained in:
2026-10-23 23:50:46 +02:00
parent 9a056b3070
commit bd7395f045
17 changed files with 666 additions and 57 deletions
+109
View File
@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.Enigma2;
// Probe-only transport details; the public Jellyfin contract remains MediaStream.
public sealed class BroadcastMediaStream : MediaStream
{
public int? TransportId { get; set; }
public bool IsVisualImpaired { get; set; }
public byte[] SubtitleExtraData { get; set; }
}
/// <summary>A stable, explicitly mapped program shared by native negotiation and FFmpeg.</summary>
public sealed class BroadcastTracks
{
public MediaStream[] Input { get; }
public MediaStream[] Output { get; }
public bool Normalize { get; }
public string Key { get; }
public int AudioCount => Input.Count(s => s.Type == MediaStreamType.Audio);
public bool HasSubtitles => Input.Any(s => s.Type == MediaStreamType.Subtitle);
public BroadcastTracks(IEnumerable<MediaStream> streams, bool normalize = true, bool preserveResolution = true)
{
Normalize = normalize;
var all = streams.ToArray();
Input = all.Where(s => s.Type == MediaStreamType.Video).Take(1)
.Concat(all.Where(s => s.Type == MediaStreamType.Audio))
.Concat(all.Where(s => s.Type == MediaStreamType.Subtitle)).ToArray();
if (Input.Length == 0 || Input[0].Type != MediaStreamType.Video || Input.Length > 48)
throw new ArgumentException("Invalid broadcast program layout");
if (Input.Any(s => s.Type == MediaStreamType.Subtitle && s.Codec is not ("DVBSUB" or "DVBTXT")))
throw new NotSupportedException("Unsupported broadcast subtitle codec; tracks cannot be silently discarded");
Output = Input.Select((s, i) => {
var copy = LiveStreamProbe.ParseLiveMetadata(s); copy.Index = i;
if (s is BroadcastMediaStream { IsVisualImpaired: true } && string.IsNullOrWhiteSpace(copy.Title)) copy.Title = "Audio description";
if (normalize && copy.Type == MediaStreamType.Video)
{
copy.Codec = "h264"; copy.Width = preserveResolution ? s.Width : ContinuityStreams.Width;
copy.Height = preserveResolution ? s.Height : ContinuityStreams.Height; copy.Level = null;
copy.Profile = "Constrained Baseline"; copy.PixelFormat = "yuv420p"; copy.IsInterlaced = false;
copy.AverageFrameRate = copy.RealFrameRate = ContinuityStreams.FrameRate;
copy.ColorRange = copy.ColorSpace = copy.ColorTransfer = copy.ColorPrimaries = null; copy.BitRate = null;
}
if (normalize && copy.Type == MediaStreamType.Audio)
{ copy.Codec = "aac"; copy.Channels = 2; copy.SampleRate = 48000; copy.BitRate = 128000; copy.Profile = "LC"; copy.ChannelLayout = "stereo"; }
return copy;
}).ToArray();
// Stable per-channel track cache; no URLs, credentials or user identities in its key.
Key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(Input.Select(s => new {
s.Type, s.Index, s.Codec, s.Language, s.Title, s.IsDefault, s.IsForced, s.IsHearingImpaired, s.Channels, s.Width, s.Height,
Pid = (s as BroadcastMediaStream)?.TransportId, Extra = (s as BroadcastMediaStream)?.SubtitleExtraData
}))))).ToLowerInvariant();
}
public IEnumerable<string> MapArguments()
{
for (var i = 0; i < Input.Length; i++)
{
yield return "-map";
yield return Input[i] is BroadcastMediaStream { TransportId: { } pid }
? "0:i:" + pid.ToString(CultureInfo.InvariantCulture) : Input[i].Index >= 0 ? "0:" + Input[i].Index.ToString(CultureInfo.InvariantCulture)
: "0:" + (Input[i].Type == MediaStreamType.Video ? "v" : Input[i].Type == MediaStreamType.Audio ? "a" : "s") + ":" + Input.Take(i).Count(s => s.Type == Input[i].Type);
yield return "-streamid"; yield return i.ToString(CultureInfo.InvariantCulture) + ":" + (256 + i).ToString(CultureInfo.InvariantCulture);
}
}
public IEnumerable<string> MetadataArguments(bool audioVideoOnly = false)
{
for (var i = 0; i < Output.Length; i++)
{
var s = Output[i];
if (audioVideoOnly && s.Type == MediaStreamType.Subtitle) continue;
if (!string.IsNullOrWhiteSpace(s.Language)) { yield return "-metadata:s:" + i; yield return "language=" + s.Language; }
if (!string.IsNullOrWhiteSpace(s.Title)) { yield return "-metadata:s:" + i; yield return "title=" + s.Title; }
yield return "-disposition:" + i;
var flags = new List<string>(); if (s.IsDefault) flags.Add("default"); if (s.IsForced) flags.Add("forced");
if (s.IsHearingImpaired) flags.Add("hearing_impaired");
yield return flags.Count == 0 ? "0" : string.Join('+', flags);
}
}
public static string JellyfinCodec(string codec) => codec switch { "dvb_subtitle" => "DVBSUB", "dvb_teletext" => "DVBTXT", "hdmv_pgs_subtitle" => "PGSSUB", "dvd_subtitle" => "DVDSUB", "subrip" => "srt", "ass" => "ssa", _ => codec };
public static int? TransportId(string value)
{
if (value != null && value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(value.AsSpan(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hex) && hex < 8191 && hex >= 0) return hex;
return int.TryParse(value, out var dec) && dec >= 0 && dec < 8191 ? dec : null;
}
public static byte[] ExtraData(string value)
{
if (value == null || value.Length > 8192) return Array.Empty<byte>();
var hex = new StringBuilder();
foreach (var line in value.Split('\n'))
{
var match = Regex.Match(line, @"^[0-9a-fA-F]{8}: ([0-9a-fA-F ]+?)(?: |$)");
if (match.Success) hex.Append(match.Groups[1].Value.Replace(" ", ""));
}
return hex.Length % 2 == 0 ? Convert.FromHexString(hex.ToString()) : Array.Empty<byte>();
}
public static BroadcastTracks Fallback(bool preserveResolution = true) => new(new[] {
new MediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264" },
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", IsDefault = true }
}, true, preserveResolution);
}