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
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Declare silent audio and empty DVB subtitle tracks on status clips with stable transport PIDs.</summary>
public static class BroadcastSlateProgram
{
public static byte[] Apply(byte[] clip, BroadcastTracks tracks, bool clearSubtitles = true)
{
if (clip.Length % 188 != 0) throw new InvalidDataException("Unaligned status clip");
var section = Section(tracks);
using var output = new MemoryStream(clip.Length + 188 * 8);
var cc = 0; var written = false; var cleared = false;
for (var start = 0; start < clip.Length; start += 188)
{
var pid = ((clip[start + 1] & 31) << 8) | clip[start + 2];
if (pid != 4096)
{
output.Write(clip, start, 188);
if (clearSubtitles && !cleared && pid == 256 && (clip[start + 1] & 64) != 0)
{
var p = start + 4 + ((clip[start + 3] & 32) != 0 ? clip[start + 4] + 1 : 0);
if (p + 14 <= start + 188 && clip[p] == 0 && clip[p + 1] == 0 && clip[p + 2] == 1 && (clip[p + 7] & 128) != 0)
{
long pts = ((long)(clip[p + 9] & 14) << 29) | ((long)clip[p + 10] << 22) | ((long)(clip[p + 11] & 254) << 14) | ((long)clip[p + 12] << 7) | (clip[p + 13] >> 1);
for (var i = 0; i < tracks.Output.Length; i++)
if (tracks.Output[i].Codec == "DVBSUB") output.Write(ClearSubtitle(tracks.Input[i], 256 + i, pts));
cleared = true;
}
}
continue;
}
if ((clip[start + 1] & 64) == 0) continue;
written = true;
var offset = 0;
while (offset < section.Length)
{
var packet = new byte[188]; Array.Fill(packet, (byte)255);
packet[0] = 71; packet[1] = (byte)(16 | (offset == 0 ? 64 : 0)); packet[2] = 0; packet[3] = (byte)(16 | (cc++ & 15));
var p = 4; if (offset == 0) packet[p++] = 0;
var count = Math.Min(188 - p, section.Length - offset); section.AsSpan(offset, count).CopyTo(packet.AsSpan(p));
offset += count; output.Write(packet);
}
}
if (!written) throw new InvalidDataException("Status clip has no expected program map");
return output.ToArray();
}
private static byte[] ClearSubtitle(MediaStream stream, int pid, long pts)
{
var extra = (stream as BroadcastMediaStream)?.SubtitleExtraData;
var page = extra is { Length: >= 2 } ? (extra[0] << 8) | extra[1] : 1;
var payload = new byte[] { 32, 0,
15, 20, (byte)(page >> 8), (byte)page, 0, 5, 0, 7, 127, 4, 55,
15, 16, (byte)(page >> 8), (byte)page, 0, 2, 1, 3,
15, 128, (byte)(page >> 8), (byte)page, 0, 0, 255 };
var pes = new List<byte> { 0, 0, 1, 189, 0, (byte)(payload.Length + 8), 128, 128, 5,
(byte)(33L | ((pts >> 29) & 14)), (byte)(pts >> 22), (byte)(1L | ((pts >> 14) & 254)), (byte)(pts >> 7), (byte)(1L | (pts << 1)) };
pes.AddRange(payload);
var packet = Enumerable.Repeat((byte)255, 188).ToArray();
packet[0] = 71; packet[1] = (byte)(64 | (pid >> 8)); packet[2] = (byte)pid; packet[3] = 48;
packet[4] = (byte)(183 - pes.Count); packet[5] = 0; pes.CopyTo(packet, 188 - pes.Count);
return packet;
}
public static byte[] Section(BroadcastTracks tracks)
{
var data = new List<byte> { 2, 176, 0, 0, 1, 193, 0, 0, 225, 0, 240, 0 };
for (var i = 0; i < tracks.Output.Length; i++)
{
var s = tracks.Output[i]; var descriptors = new List<byte>();
var language = (s.Language ?? "und").Split(',')[0];
if (language.Length != 3 || language.Any(c => c < 'a' || c > 'z')) language = "und";
var lang = Encoding.ASCII.GetBytes(language);
var type = s.Type == MediaStreamType.Video ? 27 : s.Type == MediaStreamType.Audio ? 15 : 6;
if (s.Type == MediaStreamType.Audio) { descriptors.AddRange(new byte[] { 10, 4 }); descriptors.AddRange(lang); descriptors.Add((byte)(s.IsHearingImpaired ? 2 : 0)); }
if (s.Type == MediaStreamType.Subtitle)
{
var extra = (tracks.Input[i] as BroadcastMediaStream)?.SubtitleExtraData ?? Array.Empty<byte>();
if (s.Codec == "DVBSUB")
{
descriptors.AddRange(new byte[] { 89, 8 }); descriptors.AddRange(lang); descriptors.Add((byte)(s.IsHearingImpaired ? 32 : 16));
// ffprobe DVB extradata stores composition/ancillary page IDs in big endian.
descriptors.AddRange(extra.Length >= 4 ? extra.Take(4) : new byte[] { 0, 1, 0, 1 });
}
else if (s.Codec == "DVBTXT")
{
descriptors.AddRange(new byte[] { 86, 5 }); descriptors.AddRange(lang);
descriptors.Add(extra.Length >= 2 ? extra[0] : (byte)17); descriptors.Add(extra.Length >= 2 ? extra[1] : (byte)0);
}
}
var pid = 256 + i;
data.Add((byte)type); data.Add((byte)(224 | (pid >> 8))); data.Add((byte)pid);
data.Add((byte)(240 | (descriptors.Count >> 8))); data.Add((byte)descriptors.Count); data.AddRange(descriptors);
}
var length = data.Count - 3 + 4;
if (length > 1021) throw new InvalidDataException("Broadcast program map exceeds MPEG-TS limit");
data[1] = (byte)(176 | (length >> 8)); data[2] = (byte)length;
uint crc = 0xffffffff;
foreach (var value in data) { crc ^= (uint)value << 24; for (var bit = 0; bit < 8; bit++) crc = (crc & 0x80000000) != 0 ? (crc << 1) ^ 0x04c11db7 : crc << 1; }
data.Add((byte)(crc >> 24)); data.Add((byte)(crc >> 16)); data.Add((byte)(crc >> 8)); data.Add((byte)crc);
return data.ToArray();
}
}
+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);
}
+58 -33
View File
@@ -53,42 +53,54 @@ public sealed class ContinuityStreams : IDisposable
public string Id = Guid.NewGuid().ToString("N");
public Source Source;
public Dictionary<string, byte[]> Slates;
public DvbSubtitleVersions SubtitleVersions;
public DateTime LastAccess = DateTime.UtcNow;
}
public async Task<MediaSourceInfo> Open(string relayUrl, string language, CancellationToken token)
public async Task<MediaSourceInfo> Open(string relayUrl, string language, CancellationToken token, BroadcastTracks tracks = null, bool useSlates = true)
{
language = StartupMessages.ResolveLanguage(language, null, "en");
var clips = await CachedSlates(language, token);
tracks ??= BroadcastTracks.Fallback(preserveResolution);
if (tracks.Normalize != useSlates) throw new ArgumentException("Track format does not match continuity mode");
var clips = useSlates ? await CachedSlates(language, token, tracks) : null;
var sourceKey = relayUrl + "|" + useSlates + "|" + tracks.Key;
token.ThrowIfCancellationRequested();
Viewer viewer;
lock (gate)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (viewers.Count >= 64) throw new InvalidOperationException("Continuity viewer limit reached");
if (!sources.TryGetValue(relayUrl, out var source))
if (!sources.TryGetValue(sourceKey, out var source))
{
if (sources.Count >= 4) throw new InvalidOperationException("Continuity channel limit reached");
source = new(this, relayUrl); sources.Add(relayUrl, source);
source = new(this, relayUrl, sourceKey, tracks, useSlates); sources.Add(sourceKey, source);
}
viewer = new() { Source = source, Slates = clips }; viewers.Add(viewer.Id, viewer);
viewer = new() { Source = source, Slates = clips, SubtitleVersions = new(tracks) }; viewers.Add(viewer.Id, viewer);
}
return new MediaSourceInfo {
Id = viewer.Id, Path = $"http://127.0.0.1:{port}/DecoderIntegration/Continuity/{viewer.Id}/index.m3u8",
Protocol = MediaProtocol.Http, Container = "hls", IsInfiniteStream = true, SupportsProbing = false,
SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true, BufferMs = 1000,
MediaStreams = new[] {
new MediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", Profile = "Constrained Baseline", Level = preserveResolution ? null : 42,
Width = preserveResolution ? null : Width, Height = preserveResolution ? null : Height, AverageFrameRate = FrameRate, RealFrameRate = FrameRate, IsInterlaced = false, BitRate = preserveResolution ? null : 12000000, PixelFormat = "yuv420p" },
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Channels = 2, SampleRate = 48000, BitRate = 128000, IsDefault = true }
}
};
try
{
if (!useSlates) await viewer.Source.Ready.Task.WaitAsync(TimeSpan.FromSeconds(35), token);
return new MediaSourceInfo {
Id = viewer.Id, Path = $"http://127.0.0.1:{port}/DecoderIntegration/Continuity/{viewer.Id}/index.m3u8",
Protocol = MediaProtocol.Http, Container = "hls", IsInfiniteStream = true, SupportsProbing = false,
SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true, BufferMs = 1000,
MediaStreams = tracks.Output,
DefaultAudioStreamIndex = tracks.Output.FirstOrDefault(s => s.Type == MediaStreamType.Audio && s.IsDefault)?.Index
?? tracks.Output.FirstOrDefault(s => s.Type == MediaStreamType.Audio)?.Index,
DefaultSubtitleStreamIndex = tracks.Output.FirstOrDefault(s => s.Type == MediaStreamType.Subtitle && s.IsDefault)?.Index
};
}
catch { Close(viewer.Id); throw; }
}
private async Task<Dictionary<string, byte[]>> CachedSlates(string language, CancellationToken token)
private async Task<Dictionary<string, byte[]>> CachedSlates(string language, CancellationToken token, BroadcastTracks tracks = null)
{
var cached = slates.GetOrAdd(language, lang => new(() => GenerateSlates(lang), LazyThreadSafetyMode.ExecutionAndPublication));
tracks ??= BroadcastTracks.Fallback(preserveResolution);
var key = language + "-" + tracks.Key;
if (slates.Count >= 64)
foreach (var stale in slates.Keys.Where(k => k != key).Take(slates.Count - 63)) slates.TryRemove(stale, out _);
var cached = slates.GetOrAdd(key, _ => new(() => GenerateSlates(language, tracks), LazyThreadSafetyMode.ExecutionAndPublication));
Dictionary<string, byte[]> clips;
try { clips = await cached.Value.WaitAsync(token); }
catch { if (cached.IsValueCreated && cached.Value.IsFaulted) slates.TryRemove(new KeyValuePair<string, Lazy<Task<Dictionary<string, byte[]>>>>(language, cached)); throw; }
catch { if (cached.IsValueCreated && cached.Value.IsFaulted) slates.TryRemove(new KeyValuePair<string, Lazy<Task<Dictionary<string, byte[]>>>>(key, cached)); throw; }
return clips;
}
public async Task<byte[]> SlatePreview(string language, string phase, CancellationToken token)
@@ -103,7 +115,7 @@ public sealed class ContinuityStreams : IDisposable
{
if (!viewers.Remove(id, out var viewer)) return;
if (!viewers.Values.Any(v => ReferenceEquals(v.Source, viewer.Source)))
{ sources.Remove(viewer.Source.Url); viewer.Source.Stop(); }
{ sources.Remove(viewer.Source.Key); viewer.Source.Stop(); }
}
}
public string Playlist(string id)
@@ -122,7 +134,9 @@ public sealed class ContinuityStreams : IDisposable
viewer.LastAccess = DateTime.UtcNow;
var segment = viewer.Source.Timeline.Find(sequence);
if (segment == null) return null;
return TransportStreamClock.Rebase(segment.Slate is { } slate ? viewer.Slates[slate] : segment.Video, segment.Start90k);
var transport = TransportStreamClock.Rebase(segment.Slate is { } slate ? viewer.Slates[slate] : segment.Video, segment.Start90k, alignAudio: viewer.Source.UseSlates);
viewer.SubtitleVersions.Apply(transport, sequence);
return transport;
}
}
public object Status() { lock (gate) return new { Sources = sources.Count, Viewers = viewers.Count, Retrying = sources.Values.Count(s => s.Retrying), ProducerAttempts = Interlocked.Read(ref attempts), LiveSegmentsProduced = Interlocked.Read(ref liveSegments) }; }
@@ -136,7 +150,7 @@ public sealed class ContinuityStreams : IDisposable
lock (gate)
{
foreach (var id in viewers.Values.Where(v => DateTime.UtcNow - v.LastAccess > TimeSpan.FromSeconds(60)).Select(v => v.Id).ToArray()) Close(id);
foreach (var source in sources.Values) source.Timeline.Tick(DateTime.UtcNow, source.Retrying);
foreach (var source in sources.Values) if (source.UseSlates) source.Timeline.Tick(DateTime.UtcNow, source.Retrying);
}
}
}
@@ -164,7 +178,7 @@ public sealed class ContinuityStreams : IDisposable
process.ErrorDataReceived += (_, _) => { }; process.OutputDataReceived += (_, _) => { };
process.BeginErrorReadLine(); process.BeginOutputReadLine(); return process;
}
private async Task<Dictionary<string, byte[]>> GenerateSlates(string language)
private async Task<Dictionary<string, byte[]>> GenerateSlates(string language, BroadcastTracks tracks)
{
await slateGate.WaitAsync(lifetime.Token);
var folder = Path.Combine(root, "slates-" + language + "-" + Guid.NewGuid().ToString("N"));
@@ -180,13 +194,13 @@ public sealed class ContinuityStreams : IDisposable
var filter = $"drawbox=x=(iw-160)/2:y=260:w=160:h=100:color=0x58bfee:t=6,drawtext=fontfile={font}:text=TV:fontsize=42:fontcolor=0x58bfee:x=(w-tw)/2:y=285," +
$"drawtext=fontfile={font}:textfile=message.txt:fontsize=42:fontcolor=white:line_spacing=22:x=(w-tw)/2:y=490";
using var process = Start(new[] { "-f", "lavfi", "-i", $"color=c=0x101723:s={Width}x{Height}:r={FrameRate}", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-vf", filter,
"-map", "0:v:0", "-map", "1:a:0", "-t", "6.72" }.Concat(CodecArguments(slate: true)).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-hls_list_size", "0", "-hls_segment_filename", "slate-%d.ts", "slate.m3u8" }), folder);
"-map", "0:v:0" }.Concat(Enumerable.Range(0, tracks.AudioCount).SelectMany(_ => new[] { "-map", "1:a:0" })).Concat(new[] { "-t", "6.72" }).Concat(CodecArguments(slate: true)).Concat(tracks.MetadataArguments(audioVideoOnly: true)).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-hls_list_size", "0", "-hls_segment_filename", "slate-%d.ts", "slate.m3u8" }), folder);
try { await process.WaitForExitAsync(lifetime.Token).WaitAsync(TimeSpan.FromSeconds(20), lifetime.Token); }
finally { if (!process.HasExited) process.Kill(entireProcessTree: true); }
if (process.ExitCode != 0) throw new IOException("FFmpeg slate generation failed");
// Use an interior HLS segment: it has neither AAC encoder priming
// nor the final padding of a newly encoded standalone file.
clips[phase] = await File.ReadAllBytesAsync(Path.Combine(folder, "slate-1.ts"), lifetime.Token);
clips[phase] = BroadcastSlateProgram.Apply(await File.ReadAllBytesAsync(Path.Combine(folder, "slate-1.ts"), lifetime.Token), tracks);
foreach (var file in Directory.GetFiles(folder, "slate*")) File.Delete(file);
}
return clips;
@@ -198,11 +212,14 @@ public sealed class ContinuityStreams : IDisposable
private readonly ContinuityStreams owner;
private readonly CancellationTokenSource lifetime;
private readonly Task task;
public readonly string Url;
public ContinuityTimeline Timeline { get; } = new(DateTime.UtcNow);
public readonly string Url, Key;
public readonly BroadcastTracks Tracks;
public readonly bool UseSlates;
public TaskCompletionSource Ready { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public ContinuityTimeline Timeline { get; }
public volatile bool Retrying;
public Source(ContinuityStreams owner, string url)
{ this.owner = owner; Url = url; lifetime = CancellationTokenSource.CreateLinkedTokenSource(owner.lifetime.Token); task = Task.Run(Run); }
public Source(ContinuityStreams owner, string url, string key, BroadcastTracks tracks, bool useSlates)
{ this.owner = owner; Url = url; Key = key; Tracks = tracks; UseSlates = useSlates; Timeline = new(DateTime.UtcNow, useSlates); lifetime = CancellationTokenSource.CreateLinkedTokenSource(owner.lifetime.Token); task = Task.Run(Run); }
public void Stop() { try { lifetime.Cancel(); } catch (ObjectDisposedException) { } }
private async Task Run()
{
@@ -216,10 +233,15 @@ public sealed class ContinuityStreams : IDisposable
try
{
Directory.CreateDirectory(folder);
var args = new[] { "-rw_timeout", "10000000", "-analyzeduration", "5000000", "-probesize", "4000000", "-i", Url,
"-map", "0:v:0", "-map", "0:a:0", "-sn", "-dn", "-vf",
owner.preserveResolution ? "bwdif=mode=send_field:parity=auto:deint=interlaced" : $"bwdif=mode=send_field:parity=auto:deint=interlaced,scale=w={Width}:h={Height}:force_original_aspect_ratio=decrease,pad={Width}:{Height}:(ow-iw)/2:(oh-ih)/2,setsar=1" }
.Concat(owner.CodecArguments()).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-hls_list_size", "6", "-hls_delete_threshold", "8", "-hls_flags", "delete_segments+temp_file+omit_endlist", "-hls_segment_filename", "segment-%09d.ts", "live.m3u8" });
var args = new[] { "-rw_timeout", "10000000", "-analyzeduration", "5000000", "-probesize", "4000000", "-i", Url }
.Concat(Tracks.MapArguments()).Concat(new[] { "-dn" });
if (UseSlates)
args = args.Concat(new[] { "-vf", owner.preserveResolution ? "bwdif=mode=send_field:parity=auto:deint=interlaced" : $"bwdif=mode=send_field:parity=auto:deint=interlaced,scale=w={Width}:h={Height}:force_original_aspect_ratio=decrease,pad={Width}:{Height}:(ow-iw)/2:(oh-ih)/2,setsar=1" })
.Concat(owner.CodecArguments()).Concat(new[] { "-c:s", "copy" });
else args = args.Concat(new[] { "-c", "copy" });
// MPEG-TS segmenter preserves DVB/teletext subtitles, unlike the HLS muxer's WebVTT-only subtitle output.
args = args.Concat(Tracks.MetadataArguments()).Concat(new[] { "-max_interleave_delta", "1000000", "-f", "segment", "-segment_format", "mpegts", "-segment_time", "2.24",
"-segment_list", "live.m3u8", "-segment_list_type", "m3u8", "-segment_list_size", "6", "-segment_list_flags", "+live", "segment-%09d.ts" });
Interlocked.Increment(ref owner.attempts);
using var process = owner.Start(args, folder);
try
@@ -240,7 +262,10 @@ public sealed class ContinuityStreams : IDisposable
var path = Path.Combine(folder, line);
if (!File.Exists(path) || new FileInfo(path).Length > 32 * 1024 * 1024) continue;
Timeline.Live(await File.ReadAllBytesAsync(path, lifetime.Token), epoch, duration, DateTime.UtcNow);
highest = index; lastOutput = DateTime.UtcNow; delivered = true; Retrying = false; failures = 0; Interlocked.Increment(ref owner.liveSegments);
highest = index;
foreach (var obsolete in Directory.EnumerateFiles(folder, "segment-*.ts"))
if (int.TryParse(Path.GetFileNameWithoutExtension(obsolete).AsSpan(8), out var number) && number < highest - 14) File.Delete(obsolete);
lastOutput = DateTime.UtcNow; delivered = true; Ready.TrySetResult(); Retrying = false; failures = 0; Interlocked.Increment(ref owner.liveSegments);
}
}
if ((DateTime.UtcNow - lastOutput).TotalSeconds > (delivered ? 12 : 35)) throw new IOException("No usable encoded segments");
@@ -18,7 +18,7 @@ public sealed class ContinuityTimeline
private long clock90k = 126000;
private string previousEpoch;
private DateTime lastAppend, lastLive;
public ContinuityTimeline(DateTime now) { lastAppend = now; lastLive = DateTime.MinValue; Add(null, "waiting", "slate", SegmentSeconds, now); Add(null, "waiting", "slate", SegmentSeconds, now); }
public ContinuityTimeline(DateTime now, bool withSlates = true) { lastAppend = now; lastLive = DateTime.MinValue; if (!withSlates) return; Add(null, "waiting", "slate", SegmentSeconds, now); Add(null, "waiting", "slate", SegmentSeconds, now); }
private void Add(byte[] video, string slate, string epoch, double duration, DateTime now)
{
var discontinuity = next > 0 && (slate != null || previousEpoch != epoch);
@@ -30,7 +30,7 @@ public sealed class ContinuityTimeline
}
public void Live(byte[] video, string epoch, double duration, DateTime now)
{
if (video == null || video.Length == 0 || video.Length > 32 * 1024 * 1024 || duration <= 0 || duration > 5) throw new ArgumentException("Invalid live segment");
if (video == null || video.Length == 0 || video.Length > 32 * 1024 * 1024 || duration <= 0 || duration > 30) throw new ArgumentException("Invalid live segment");
lock (gate) { lastLive = now; Add(video, null, epoch, duration, now); }
}
public void Tick(DateTime now, bool retrying)
@@ -45,7 +45,8 @@ public sealed class ContinuityTimeline
lock (gate)
{
var window = segments.TakeLast(4).ToArray();
var text = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:5\n");
var text = new StringBuilder($"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:{Math.Max(5, Math.Ceiling(window.Select(s => s.Duration).DefaultIfEmpty(5).Max()))}\n");
if (window.Length == 0) return text.ToString();
text.Append("#EXT-X-MEDIA-SEQUENCE:").Append(window[0].Sequence).Append('\n');
text.Append("#EXT-X-DISCONTINUITY-SEQUENCE:").Append(window[0].DiscontinuitiesBefore).Append('\n');
foreach (var segment in window)
@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Keep DVB page/region/object versions distinct across independently muxed status and live clips.</summary>
public sealed class DvbSubtitleVersions
{
private readonly HashSet<int> pids;
private readonly Dictionary<(int Pid, int Type, int Id), int> versions = new();
private readonly Dictionary<long, List<(int Position, byte Value)>> plans = new();
public DvbSubtitleVersions(BroadcastTracks tracks) => pids = tracks.Output.Select((s, i) => (s.Codec, Pid: 256 + i)).Where(s => s.Codec == "DVBSUB").Select(s => s.Pid).ToHashSet();
public void Apply(byte[] transport, long sequence)
{
if (pids.Count == 0) return;
if (plans.TryGetValue(sequence, out var known)) { foreach (var edit in known) transport[edit.Position] = edit.Value; return; }
var edits = new List<(int, byte)>();
var packets = new Dictionary<int, List<int>>();
void Flush(int pid, List<int> positions)
{
if (positions.Count < 14) return;
byte At(int index) => transport[positions[index]];
if (At(0) != 0 || At(1) != 0 || At(2) != 1 || At(3) != 189) return;
var length = (At(4) << 8) | At(5);
var end = length > 0 ? Math.Min(positions.Count, length + 6) : positions.Count;
var p = 9 + At(8);
if (p + 2 > end || At(p) != 32) return; p += 2;
while (p + 6 <= end && At(p) == 15)
{
var type = At(p + 1); var size = (At(p + 4) << 8) | At(p + 5); var data = p + 6;
if (data + size > end) break;
var field = type switch { 16 => 1, 17 => 1, 18 => 1, 19 => 2, 20 => 0, _ => -1 };
if (field >= 0 && size > field)
{
var id = type switch { 17 or 18 => At(data), 19 => (At(data) << 8) | At(data + 1), _ => 0 };
var key = (pid, (int)type, id);
if (!versions.ContainsKey(key) && versions.Count >= 4096) throw new InvalidDataException("Too many DVB subtitle objects");
versions.TryGetValue(key, out var next); versions[key] = next + 1;
var index = positions[data + field]; var value = (byte)((transport[index] & 15) | ((next & 15) << 4));
transport[index] = value; edits.Add((index, value));
}
p = data + size;
}
}
for (var start = 0; start + 188 <= transport.Length; start += 188)
{
var pid = ((transport[start + 1] & 31) << 8) | transport[start + 2]; if (!pids.Contains(pid)) continue;
if ((transport[start + 3] & 16) == 0) continue;
var payload = start + 4 + ((transport[start + 3] & 32) != 0 ? transport[start + 4] + 1 : 0);
if (payload >= start + 188) continue;
if ((transport[start + 1] & 64) != 0)
{
if (packets.TryGetValue(pid, out var previous)) Flush(pid, previous);
packets[pid] = new();
}
if (!packets.TryGetValue(pid, out var positions)) continue;
if (positions.Count + 188 > 512 * 1024) throw new InvalidDataException("Oversized DVB subtitle PES");
for (var p = payload; p < start + 188; p++) positions.Add(p);
}
foreach (var packet in packets) Flush(packet.Key, packet.Value);
plans[sequence] = edits;
foreach (var old in plans.Keys.Order().Take(Math.Max(0, plans.Count - 8)).ToArray()) plans.Remove(old);
}
}
@@ -2,8 +2,8 @@
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AssemblyVersion>7.0.13.0</AssemblyVersion>
<FileVersion>7.0.13.0</FileVersion>
<AssemblyVersion>7.0.14.0</AssemblyVersion>
<FileVersion>7.0.14.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
+33 -5
View File
@@ -19,6 +19,7 @@ public sealed class LiveStreamProbe
private readonly Func<string, CancellationToken, Task<string>> runner;
private readonly Dictionary<string, (Task<List<MediaStream>> Task, DateTime Until)> cache = new();
private readonly object gate = new();
private readonly Dictionary<string, (List<MediaStream> Streams, DateTime At)> lastKnown = new();
private readonly SemaphoreSlim slots = new(8);
private readonly TimeSpan timeout;
public string LastError { get; private set; }
@@ -64,6 +65,12 @@ public sealed class LiveStreamProbe
return task.WaitAsync(caller);
}
public List<MediaStream> LastKnown(string url, int? programme = null)
{
if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture);
lock (gate) return lastKnown.TryGetValue(url, out var value) && DateTime.UtcNow - value.At <= TimeSpan.FromHours(6) ? value.Streams.ToList() : null;
}
private async Task Produce(string url, TaskCompletionSource<List<MediaStream>> completion)
{
var success = false;
@@ -71,7 +78,17 @@ public sealed class LiveStreamProbe
try
{
await slots.WaitAsync(deadline.Token);
try { completion.TrySetResult(Parse(await runner(url, deadline.Token), Programme(url))); LastError = null; success = true; }
try
{
var streams = Parse(await runner(url, deadline.Token), Programme(url));
lock (gate)
{
foreach (var stale in lastKnown.Where(x => DateTime.UtcNow - x.Value.At > TimeSpan.FromHours(6)).Select(x => x.Key).ToArray()) lastKnown.Remove(stale);
if (lastKnown.Count >= 256 && !lastKnown.ContainsKey(url)) lastKnown.Remove(lastKnown.OrderBy(x => x.Value.At).First().Key);
lastKnown[url] = (streams, DateTime.UtcNow);
}
completion.TrySetResult(streams); LastError = null; success = true;
}
finally { slots.Release(); }
}
catch (Exception error) { LastError = error.Message; completion.TrySetException(new InvalidOperationException("Decoder returned no usable live stream within the probe deadline.", error)); }
@@ -265,7 +282,7 @@ public sealed class LiveStreamProbe
private static async Task<string> RunFile(string executable, string sample, int? programme, bool extended, CancellationToken token)
{
using var process = new Process { StartInfo = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } };
foreach (var argument in new[] { "-v", "error", "-f", "mpegts", "-analyzeduration", extended ? "20000000" : "3000000", "-probesize", extended ? "25165824" : "4194304", "-i", sample, "-show_streams", "-show_programs", "-of", "json" }) process.StartInfo.ArgumentList.Add(argument);
foreach (var argument in new[] { "-v", "error", "-f", "mpegts", "-analyzeduration", extended ? "20000000" : "3000000", "-probesize", extended ? "25165824" : "4194304", "-i", sample, "-show_streams", "-show_programs", "-show_data", "-of", "json" }) process.StartInfo.ArgumentList.Add(argument);
if (programme != null) { process.StartInfo.ArgumentList.Add("-select_streams"); process.StartInfo.ArgumentList.Add("p:" + programme.Value.ToString(CultureInfo.InvariantCulture)); }
process.Start();
var output = process.StandardOutput.ReadToEndAsync();
@@ -302,7 +319,7 @@ public sealed class LiveStreamProbe
string Text(string field) => item.TryGetProperty(field, out var value) ? value.ToString() : null;
int? Number(string field) => int.TryParse(Text(field), out var value) ? value : null;
var type = Text("codec_type");
if (type != "video" && type != "audio") continue;
if (type != "video" && type != "audio" && type != "subtitle") continue;
var codec = Text("codec_name"); var index = Number("index");
if (string.IsNullOrEmpty(codec) || index == null) continue;
float? Rate(string field)
@@ -312,8 +329,19 @@ public sealed class LiveStreamProbe
if (values.Length == 1) return numerator;
return float.TryParse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator) && denominator != 0 ? numerator / denominator : null;
}
var stream = new MediaStream { Type = type == "video" ? MediaStreamType.Video : MediaStreamType.Audio, Index = index.Value, Codec = codec, Width = Number("width"), Height = Number("height"), Channels = Number("channels"), SampleRate = Number("sample_rate"), BitRate = Number("bit_rate"), Profile = Text("profile"), Level = Number("level"), PixelFormat = Text("pix_fmt"), ColorRange = Text("color_range"), ColorSpace = Text("color_space"), ColorTransfer = Text("color_transfer"), ColorPrimaries = Text("color_primaries"), AverageFrameRate = Rate("avg_frame_rate"), RealFrameRate = Rate("r_frame_rate"), IsInterlaced = Text("field_order") is "tt" or "bb" or "tb" or "bt", ChannelLayout = Text("channel_layout") };
var stream = new BroadcastMediaStream { TransportId = BroadcastTracks.TransportId(Text("id")), SubtitleExtraData = BroadcastTracks.ExtraData(Text("extradata")), Type = type == "video" ? MediaStreamType.Video : type == "audio" ? MediaStreamType.Audio : MediaStreamType.Subtitle, Index = index.Value, Codec = BroadcastTracks.JellyfinCodec(codec), Width = Number("width"), Height = Number("height"), Channels = Number("channels"), SampleRate = Number("sample_rate"), BitRate = Number("bit_rate"), Profile = Text("profile"), Level = Number("level"), PixelFormat = Text("pix_fmt"), ColorRange = Text("color_range"), ColorSpace = Text("color_space"), ColorTransfer = Text("color_transfer"), ColorPrimaries = Text("color_primaries"), AverageFrameRate = Rate("avg_frame_rate"), RealFrameRate = Rate("r_frame_rate"), IsInterlaced = Text("field_order") is "tt" or "bb" or "tb" or "bt", ChannelLayout = Text("channel_layout") };
if (item.TryGetProperty("tags", out var tags) && tags.TryGetProperty("language", out var language)) stream.Language = language.GetString();
if (item.TryGetProperty("tags", out var labels))
{
if (labels.TryGetProperty("title", out var title)) stream.Title = title.GetString();
else if (labels.TryGetProperty("handler_name", out var handler)) stream.Title = handler.GetString();
}
if (item.TryGetProperty("disposition", out var disposition))
{
bool Flag(string key) => disposition.TryGetProperty(key, out var flag) && flag.TryGetInt32(out var value) && value != 0;
stream.IsDefault = Flag("default"); stream.IsForced = Flag("forced");
stream.IsHearingImpaired = Flag("hearing_impaired"); stream.IsVisualImpaired = Flag("visual_impaired"); stream.IsOriginal = Flag("original");
}
result.Add(stream);
}
if (!result.Any(s => s.Type == MediaStreamType.Video && s.Width > 0 && s.Height > 0)) throw new InvalidOperationException("Decoder stream has no identified video.");
@@ -328,6 +356,6 @@ public sealed class LiveStreamProbe
ColorRange = s.ColorRange, ColorSpace = s.ColorSpace, ColorTransfer = s.ColorTransfer,
ColorPrimaries = s.ColorPrimaries, AverageFrameRate = s.AverageFrameRate,
RealFrameRate = s.RealFrameRate, IsInterlaced = s.IsInterlaced,
ChannelLayout = s.ChannelLayout, Language = s.Language
ChannelLayout = s.ChannelLayout, Language = s.Language, Title = s.Title, IsDefault = s.IsDefault, IsForced = s.IsForced, IsHearingImpaired = s.IsHearingImpaired, IsOriginal = s.IsOriginal
};
}
+28 -9
View File
@@ -1226,17 +1226,36 @@ namespace Jellyfin.Plugin.Enigma2
var streamUrl = string.Format("{0}/{1}{2}", baseUrl, channelOid, trancodingUrl);
streamUrl = _streamRelay.Register(streamUrl);
// Background recordings keep the original source: never record an
// endless status slate or guess a viewer language for a timer.
if (Plugin.Instance.Configuration.EnableContinuitySlates && _continuity != null && feedback != null)
{
feedback.Report("waiting_for_stream");
return await _continuity.Open(streamUrl, feedback.Language, cancellationToken).ConfigureAwait(false);
}
var fields = channelOid.Split(':');
var programme = !Plugin.Instance.Configuration.TranscodedStream && fields.Length > 3 && int.TryParse(fields[3], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var sid) ? sid : (int?)null;
var probe = _streamProbe.Get(streamUrl, cancellationToken, programme);
var streams = await (_feedback == null ? probe : _feedback.AwaitStream(probe, feedback, cancellationToken)).ConfigureAwait(false);
var useSlates = Plugin.Instance.Configuration.EnableContinuitySlates && _continuity != null && feedback != null;
List<MediaStream> streams;
try
{
var probe = _streamProbe.Get(streamUrl, cancellationToken, programme);
var known = useSlates ? _streamProbe.LastKnown(streamUrl, programme) : null;
if (known != null && !probe.IsCompleted)
{
streams = known; // Do not delay an outage slate by another full metadata timeout.
_ = probe.ContinueWith(t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
}
else streams = await (_feedback == null ? probe : _feedback.AwaitStream(probe, feedback, cancellationToken)).ConfigureAwait(false);
}
catch (Exception) when (useSlates && !cancellationToken.IsCancellationRequested)
{
streams = _streamProbe.LastKnown(streamUrl, programme);
_logger.LogWarning("[Enigma2] Broadcast track analysis unavailable; keeping continuity with {MetadataState} metadata", streams == null ? "unknown" : "last-known");
}
// Rich programs use one explicitly ordered local transport, so native
// Jellyfin maps the chosen language/subtitle instead of auto-selecting audio and adding -sn.
var richProgram = streams != null && (streams.Count(s => s.Type == MediaStreamType.Audio) > 1 || streams.Any(s => s.Type == MediaStreamType.Subtitle));
if (_continuity != null && (useSlates || richProgram))
{
feedback?.Report("waiting_for_stream");
var tracks = streams == null ? BroadcastTracks.Fallback(Plugin.Instance.Configuration.PreserveSourceResolution)
: new BroadcastTracks(streams, useSlates, Plugin.Instance.Configuration.PreserveSourceResolution);
return await _continuity.Open(streamUrl, feedback?.Language ?? "en", cancellationToken, tracks, useSlates).ConfigureAwait(false);
}
// A live TS may enumerate its EPG/data PID before AV on the next open.
// Jellyfin maps positions in MediaStreams rather than MPEG-TS PID values.
@@ -26,7 +26,7 @@ public static class TransportStreamClock
}
private static bool Pes(byte[] data, int start, int p) => p >= 0 && (data[start + 1] & 64) != 0 && p + 14 <= start + 188
&& data[p] == 0 && data[p + 1] == 0 && data[p + 2] == 1 && (data[p + 7] & 128) != 0;
public static byte[] Rebase(byte[] input, long videoStart90k)
public static byte[] Rebase(byte[] input, long videoStart90k, bool alignAudio = true)
{
if (input.Length % 188 != 0) throw new InvalidDataException("Unaligned MPEG-TS segment");
long? firstVideo = null, firstAudio = null;
@@ -44,7 +44,7 @@ public static class TransportStreamClock
// Independent AAC encoders and HLS boundaries have different priming
// offsets. Anchor the first audio PES of each segment as well, so a
// switch cannot send an audio timestamp backwards by a few milliseconds.
var audioOffset = videoStart90k - (firstAudio ?? firstVideo.Value);
var audioOffset = alignAudio ? videoStart90k - (firstAudio ?? firstVideo.Value) : offset;
using var output = new MemoryStream(input.Length + 188 * 6); var pids = new HashSet<int>();
for (var start = 0; start < input.Length; start += 188)
{