From bd7395f045f475d96f14409c0ad043163a49628c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Fri, 23 Oct 2026 23:50:46 +0200 Subject: [PATCH] Retain selectable audio languages and DVB subtitles through live continuity --- .../BroadcastSlateProgram.cs | 107 ++++++++++++++++ Jellyfin.Plugin.Enigma2/BroadcastTracks.cs | 109 +++++++++++++++++ Jellyfin.Plugin.Enigma2/ContinuityStreams.cs | 91 +++++++++----- Jellyfin.Plugin.Enigma2/ContinuityTimeline.cs | 7 +- .../DvbSubtitleVersions.cs | 66 ++++++++++ .../Jellyfin.Plugin.Enigma2.csproj | 4 +- Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs | 38 +++++- Jellyfin.Plugin.Enigma2/LiveTvService.cs | 37 ++++-- .../TransportStreamClock.cs | 4 +- README.md | 17 +++ Tests/BroadcastTrackTests.cs | 38 ++++++ Tests/BroadcastTracksE2E.cs | 114 ++++++++++++++++++ Tests/Program.cs | 2 + Tests/ResolutionE2E.cs | 4 +- Tests/SyntheticBroadcastFixture.cs | 81 +++++++++++++ build.yaml | 2 +- deploy-plugin-only.py | 2 +- 17 files changed, 666 insertions(+), 57 deletions(-) create mode 100644 Jellyfin.Plugin.Enigma2/BroadcastSlateProgram.cs create mode 100644 Jellyfin.Plugin.Enigma2/BroadcastTracks.cs create mode 100644 Jellyfin.Plugin.Enigma2/DvbSubtitleVersions.cs create mode 100644 Tests/BroadcastTrackTests.cs create mode 100644 Tests/BroadcastTracksE2E.cs create mode 100644 Tests/SyntheticBroadcastFixture.cs diff --git a/Jellyfin.Plugin.Enigma2/BroadcastSlateProgram.cs b/Jellyfin.Plugin.Enigma2/BroadcastSlateProgram.cs new file mode 100644 index 0000000..4a67819 --- /dev/null +++ b/Jellyfin.Plugin.Enigma2/BroadcastSlateProgram.cs @@ -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; + +/// Declare silent audio and empty DVB subtitle tracks on status clips with stable transport PIDs. +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 { 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 { 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(); + 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(); + 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(); + } +} diff --git a/Jellyfin.Plugin.Enigma2/BroadcastTracks.cs b/Jellyfin.Plugin.Enigma2/BroadcastTracks.cs new file mode 100644 index 0000000..4549dc2 --- /dev/null +++ b/Jellyfin.Plugin.Enigma2/BroadcastTracks.cs @@ -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; } +} + +/// A stable, explicitly mapped program shared by native negotiation and FFmpeg. +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 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 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 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(); 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(); + 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(); + } + 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); +} diff --git a/Jellyfin.Plugin.Enigma2/ContinuityStreams.cs b/Jellyfin.Plugin.Enigma2/ContinuityStreams.cs index 3243f72..937b085 100644 --- a/Jellyfin.Plugin.Enigma2/ContinuityStreams.cs +++ b/Jellyfin.Plugin.Enigma2/ContinuityStreams.cs @@ -53,42 +53,54 @@ public sealed class ContinuityStreams : IDisposable public string Id = Guid.NewGuid().ToString("N"); public Source Source; public Dictionary Slates; + public DvbSubtitleVersions SubtitleVersions; public DateTime LastAccess = DateTime.UtcNow; } - public async Task Open(string relayUrl, string language, CancellationToken token) + public async Task 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> CachedSlates(string language, CancellationToken token) + private async Task> 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 clips; try { clips = await cached.Value.WaitAsync(token); } - catch { if (cached.IsValueCreated && cached.Value.IsFaulted) slates.TryRemove(new KeyValuePair>>>(language, cached)); throw; } + catch { if (cached.IsValueCreated && cached.Value.IsFaulted) slates.TryRemove(new KeyValuePair>>>(key, cached)); throw; } return clips; } public async Task 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> GenerateSlates(string language) + private async Task> 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"); diff --git a/Jellyfin.Plugin.Enigma2/ContinuityTimeline.cs b/Jellyfin.Plugin.Enigma2/ContinuityTimeline.cs index ac7376b..57ab54e 100644 --- a/Jellyfin.Plugin.Enigma2/ContinuityTimeline.cs +++ b/Jellyfin.Plugin.Enigma2/ContinuityTimeline.cs @@ -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) diff --git a/Jellyfin.Plugin.Enigma2/DvbSubtitleVersions.cs b/Jellyfin.Plugin.Enigma2/DvbSubtitleVersions.cs new file mode 100644 index 0000000..5a57110 --- /dev/null +++ b/Jellyfin.Plugin.Enigma2/DvbSubtitleVersions.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Jellyfin.Plugin.Enigma2; + +/// Keep DVB page/region/object versions distinct across independently muxed status and live clips. +public sealed class DvbSubtitleVersions +{ + private readonly HashSet pids; + private readonly Dictionary<(int Pid, int Type, int Id), int> versions = new(); + private readonly Dictionary> 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>(); + void Flush(int pid, List 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); + } +} diff --git a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj index 870a332..3979056 100644 --- a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj +++ b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj @@ -2,8 +2,8 @@ net10.0 - 7.0.13.0 - 7.0.13.0 + 7.0.14.0 + 7.0.14.0 diff --git a/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs b/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs index 2c70541..0ce1c78 100644 --- a/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs +++ b/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs @@ -19,6 +19,7 @@ public sealed class LiveStreamProbe private readonly Func> runner; private readonly Dictionary> Task, DateTime Until)> cache = new(); private readonly object gate = new(); + private readonly Dictionary 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 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> 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 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 }; } diff --git a/Jellyfin.Plugin.Enigma2/LiveTvService.cs b/Jellyfin.Plugin.Enigma2/LiveTvService.cs index dc1f812..dff31e5 100644 --- a/Jellyfin.Plugin.Enigma2/LiveTvService.cs +++ b/Jellyfin.Plugin.Enigma2/LiveTvService.cs @@ -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 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. diff --git a/Jellyfin.Plugin.Enigma2/TransportStreamClock.cs b/Jellyfin.Plugin.Enigma2/TransportStreamClock.cs index e5cc65f..8030422 100644 --- a/Jellyfin.Plugin.Enigma2/TransportStreamClock.cs +++ b/Jellyfin.Plugin.Enigma2/TransportStreamClock.cs @@ -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(); for (var start = 0; start < input.Length; start += 188) { diff --git a/README.md b/README.md index d28c145..7d9ae70 100644 --- a/README.md +++ b/README.md @@ -118,3 +118,20 @@ Na żądanie użytkownika usunięto sztywny limit Full HD z domyślnego trybu pl Izolowana próba z rzeczywistym FFmpeg potwierdziła przejścia plansza → 720×576, 1280×720, 1920×1080, 2560×1440 i 3840×2160. Odczytano rzeczywiste wymiary zdekodowanych klatek zarówno z MPEG-TS, jak i HLS fMP4, a dla SD również SAR16:15. To test syntetyczny o małej zmienności obrazu, nie dowód wydajności każdego kanału UHD ani test fizycznej aplikacji. Dodatkowa próba z zachowaniem 720p potwierdziła 75 sekund awarii, właściwe języki plansz i odzyskanie obrazu bez ponownego otwierania klienta. Testy wykorzystują również natywny StreamBuilder Jellyfina12.2 do sprawdzenia limitów urządzeń od SD do UHD. 7.0.13 jest kandydatem do odrębnego zatwierdzenia; nie nadpisuje automatycznie wdrożonej7.0.11. Wcześniejsza zgoda dotyczyła tylko nocnego Jellytel. Nie aktywować starego kandydata7.0.12 ograniczonego do Full HD. + + +### 7.0.14 — wybór języka audio i nadawanych napisów + +Analiza programu zachowuje wszystkie ścieżki audio oraz napisy DVB i teletekstowe, języki ISO639, tytuły oraz oznaczenia default/forced/hearing-impaired. Nazwy kodeków napisów są normalizowane do natywnych DVBSUB/DVBTXT Jellyfina, aby bitmapy nie trafiały do ścieżki ekstrakcji tekstu. Natywne menu odtwarzacza wybiera język i napisy; wtyczka nie wymusza pierwszego audio ani napisów na stałe. + +Programy z wieloma ścieżkami lub napisami są przepakowywane do jednego, jawnie uporządkowanego MPEG-TS/HLS. Wejście jest mapowane po PID, a wyjście zawsze ma wideo, wszystkie audio i wszystkie napisy. To usuwa problem -1/automatycznego wyboru/-sn w natywnym mapowaniu Jellyfina i nie uzależnia wyboru od pozycji PID danych EPG. Segmenter MPEG-TS zachowuje napisy DVB/teletekst zamiast ograniczać je do WebVTT. Odbiór nadal korzysta ze współdzielonego relay; analiza i widzowie nie otwierają osobnych połączeń do dekodera. + +Z włączonymi planszami każdy język ma oddzielną ścieżkę AAC stereo. Plansza ma odpowiadające im ścieżki ciszy oraz deklaracje napisów i pustą stronę DVB. Wersje stron, obiektów, regionów i definicji obrazu DVB są porządkowane oddzielnie dla widza, aby pusty napis z planszy nie zablokował późniejszego napisu o tej samej wersji. Powtórne pobranie segmentu używa tego samego planu wersji; bufory i liczba obiektów są ograniczone. Rozmiar obrazu źródła pozostaje zachowany. Po udanej analizie negocjacja dostaje rzeczywiste wymiary kanału, a nie planszy; przy nieznanych metadanych nie zgadujemy rozdzielczości. Tryb nadal koduje wideo do H264 8-bit/50Hz i każde audio do AAC stereo, bez zachowania HDR/5.1. + +Przy wyłączonych planszach bogaty program jest tylko przepakowywany: oryginalne kodeki wideo/audio, liczba kanałów audio i metadane koloru pozostają zachowane. Ścieżka jednego audio bez napisów zachowuje poprzednie zachowanie. Nagrania w tle nie otrzymują plansz. Przepakowanie zmienia nagłówki kontenera i porządkuje wersje napisów; nie jest kopią binarną całego TS. + +Lista ścieżek jest wykrywana przed negocjacją odtwarzania. Ostatnie poprawne metadane są trzymane w pamięci maksymalnie6h dla256 źródeł/programów i pozwalają szybko pokazać planszę podczas awarii. Po zimnym starcie bez wcześniejszych danych i przy niedostępnym źródle nie można poznać jego języków: wtedy plansza używa układu zastępczego, a pełny wybór jest dostępny przy ponownym otwarciu po udanej analizie. Nie są wymyślane języki nieobecnych ścieżek. Ten cache nie zmienia harmonogramu synchronizacji kanałów co6h. + +Kandydat pozostaje oddzielony od aktywnej wersji7.0.11. Nie wdrażać go ani nie rozszerzać nocnego planu Jellytel bez wyraźnego zatwierdzenia użytkownika. + +Weryfikacja7.0.14:182 kontrole .NET przeszły, build i publikacja bez ostrzeżeń. Rzeczywisty FFmpeg na CT100191 z samodzielnie wygenerowanym MPEG-TS potwierdził dwa różne języki audio (440/880Hz), dwa języki bitmap DVB i dodatkowy strumień teletekstu. Trzej odbiorcy (pełen program, drugie audio, drugie audio z napisami) przetrwali12s utraty źródła, zachowując jedno połączenie fizyczne. Sprawdzono rzeczywiste pakiety wszystkich napisów i różnicę pikseli po nałożeniu bitmapy. Wykryty w próbie konflikt wersji pustej i nadawanej strony DVB został usunięty; same pakiety lub poprawny exit FFmpeg nie były uznawane za dowód obrazu. Dodatkowo powtórzono75s awarii z PL/EN oraz SD/720/1080/1440/2160 w TS i fMP4. Testy pozostają syntetyczne, bez dekodera, kont produkcyjnych i fizycznego Android TV. Teletekst sprawdzono do poziomu wykrywania i przekazywania pakietów; renderowanie konkretnej strony na rzeczywistym kliencie wymaga próby po wdrożeniu. Raporty w projekcie Infra: `enigma-diagnostics/startup-feedback-preview/verified/`. diff --git a/Tests/BroadcastTrackTests.cs b/Tests/BroadcastTrackTests.cs new file mode 100644 index 0000000..f28c392 --- /dev/null +++ b/Tests/BroadcastTrackTests.cs @@ -0,0 +1,38 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Enigma2; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Entities; + +public static class BroadcastTrackTests +{ + public static async Task Run(Action check) + { + const string json="""{"streams":[{"index":0,"id":"0x100","codec_type":"video","codec_name":"h264","width":1920,"height":1080},{"index":2,"id":"0x102","codec_type":"audio","codec_name":"ac3","channels":6,"tags":{"language":"pol","title":"Polski"},"disposition":{"default":1}},{"index":3,"id":"0x103","codec_type":"audio","codec_name":"aac","channels":2,"tags":{"language":"eng","title":"English"}},{"index":5,"id":"0x105","codec_type":"subtitle","codec_name":"dvb_subtitle","tags":{"language":"pol","title":"Napisy"},"disposition":{"forced":1,"hearing_impaired":1},"extradata":"\n00000000: 0001 0001 ....\n"},{"index":6,"id":"0x106","codec_type":"subtitle","codec_name":"dvb_teletext","tags":{"language":"eng"}},{"index":1,"codec_type":"data","codec_name":"bin_data"}]}"""; + var parsed=LiveStreamProbe.Parse(json); + check(parsed.Count==5&&parsed.Count(s=>s.Type==MediaStreamType.Subtitle)==2,"probe retains both audio languages and broadcast subtitles while excluding data PIDs"); + check(parsed[3].Codec=="DVBSUB"&&!parsed[3].IsTextSubtitleStream&&parsed[4].Codec=="DVBTXT","native Jellyfin codec names distinguish DVB bitmap and teletext subtitles"); + check(parsed[3].IsForced&&parsed[3].IsHearingImpaired&&parsed[1].IsDefault&&parsed[1].Title=="Polski","language labels and default/forced/hearing-impaired dispositions survive probing"); + check(parsed[3] is BroadcastMediaStream {TransportId:261,SubtitleExtraData:var extra}&&extra.SequenceEqual(new byte[]{0,1,0,1}),"probe preserves transport PIDs and DVB page IDs"); + var layout=new BroadcastTracks(parsed); + check(layout.Output[0].Width==1920&&layout.Output[0].Height==1080&&BroadcastTracks.Fallback().Output[0].Width==null,"native negotiation uses probed channel dimensions, never the status picture dimensions"); + check(layout.Output.Select(s=>s.Index).SequenceEqual(Enumerable.Range(0,5))&&layout.Output[2].Language=="eng","explicitly ordered program exposes distinct stable selectable indices"); + check(layout.Output.Where(s=>s.Type==MediaStreamType.Audio).All(s=>s.Codec=="aac"&&s.Channels==2),"each normalized audio language has its own AAC track"); + check(EncodingHelper.FindIndex(layout.Output,layout.Output[2])==2&&EncodingHelper.GetSubtitleStreamIndexForFfmpeg(new(){MediaStreams=layout.Output},layout.Output[3])==3,"installed Jellyfin resolves alternate audio and subtitle maps without unknown-index auto-selection"); + check(layout.MapArguments().Contains("0:i:261")&&!layout.MapArguments().Contains("-sn"),"producer maps broadcast PIDs instead of unstable probe positions and retains subtitles"); + var untouched=new BroadcastTracks(parsed,false); + check(untouched.Output[1].Codec=="ac3"&&untouched.Output[1].Channels==6,"disabled slates preserve original audio codec and surround channels through remux"); + var section=BroadcastSlateProgram.Section(layout);uint crc=0xffffffff; + foreach(var value in section){crc^=(uint)value<<24;for(var bit=0;bit<8;bit++)crc=(crc&0x80000000)!=0?(crc<<1)^0x04c11db7:crc<<1;} + check(crc==0&§ion.Contains((byte)89)&§ion.Contains((byte)86),"status program map has valid MPEG-TS CRC and DVB/teletext descriptors"); + var subtitleClock=new DvbSubtitleVersions(SyntheticBroadcastFixture.Tracks());var cc=0; + var wire=SyntheticBroadcastFixture.Packets(SyntheticBroadcastFixture.Bitmap(0),126000,259,ref cc).SelectMany(b=>b).ToArray(); + var first=wire.ToArray();subtitleClock.Apply(first,0);var next=wire.ToArray();subtitleClock.Apply(next,1); + check(!first.SequenceEqual(next),"DVB pages, objects and display definitions get fresh versions across status/live boundaries"); + var retry=wire.ToArray();subtitleClock.Apply(retry,1);check(retry.SequenceEqual(next),"repeated HLS segment requests reuse the same DVB version plan"); + var probe=new LiveStreamProbe((_,_)=>Task.FromResult(json));await probe.Get("channel",CancellationToken.None); + check(probe.LastKnown("channel")?.Count==5&&probe.LastKnown("other",1)==null&&probe.LastKnown("channel",2)==null,"last-known track catalogue is isolated by source and programme"); + } +} diff --git a/Tests/BroadcastTracksE2E.cs b/Tests/BroadcastTracksE2E.cs new file mode 100644 index 0000000..780bfec --- /dev/null +++ b/Tests/BroadcastTracksE2E.cs @@ -0,0 +1,114 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Enigma2; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +public static class BroadcastTracksE2E +{ + private static string encoder,output,fixture; + private static volatile bool enabled=true; + private static int active,maximum,connections; + public static async Task Run(string executable,string folder) + { + encoder=executable;output=Path.GetFullPath(folder);if(Directory.Exists(output))throw new Exception("Choose a new output directory");Directory.CreateDirectory(output); + using(var make=Start(new[]{"-f","lavfi","-i","testsrc2=size=640x360:rate=25","-f","lavfi","-i","sine=frequency=440:sample_rate=48000","-f","lavfi","-i","sine=frequency=880:sample_rate=48000","-map","0:v","-map","1:a","-map","2:a","-t","16","-c:v","libx264","-threads","1","-preset","ultrafast","-g","25","-c:a","aac","-ac","2","-metadata:s:a:0","language=pol","-metadata:s:a:1","language=eng","-f","mpegts",Path.Combine(output,"base.ts")},"fixture")) + {await make.WaitForExitAsync();Require(make.ExitCode==0,"synthetic two-language AV fixture");} + fixture=Path.Combine(output,"broadcast.ts");await File.WriteAllBytesAsync(fixture,SyntheticBroadcastFixture.AddSubtitles(await File.ReadAllBytesAsync(Path.Combine(output,"base.ts")))); + var metadata=LiveStreamProbe.Parse(await Probe(fixture)); + Require(metadata.Count(s=>s.Type==MediaBrowser.Model.Entities.MediaStreamType.Audio)==2&&metadata.Count(s=>s.Codec=="DVBSUB")==2&&metadata.Count(s=>s.Codec=="DVBTXT")==1,"actual ffprobe sees two audio, two DVB bitmap languages and a teletext subtitle track"); + using var listener=new TcpListener(IPAddress.Loopback,0);listener.Start();var port=((IPEndPoint)listener.LocalEndpoint).Port;listener.Stop(); + var builder=WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.WebHost.UseUrls($"http://127.0.0.1:{port}"); + using var streams=new ContinuityStreams(encoder,Path.Combine(output,"cache"),port,new StartupMessages(),NullLogger.Instance); + using var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(),port,TimeSpan.FromSeconds(1),TimeSpan.FromSeconds(10),TimeSpan.FromSeconds(5)); + builder.Services.AddSingleton(streams);builder.Services.AddSingleton(relay);builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly); + await using var app=builder.Build();app.MapControllers();app.MapGet("/broadcast.ts",Fixture);await app.StartAsync(); + Process recording=null,english=null,subtitled=null; + try + { + var url=relay.Register($"http://127.0.0.1:{port}/broadcast.ts");var layout=new BroadcastTracks(metadata); + var pl=await streams.Open(url,"pl",CancellationToken.None,layout);var en=await streams.Open(url,"en",CancellationToken.None,layout); + Require(pl.MediaStreams.Count==6&&pl.MediaStreams[2].Language=="eng"&&pl.MediaStreams[4].Codec=="DVBSUB","native source advertises every selectable track before live data"); + await File.WriteAllBytesAsync(Path.Combine(output,"waiting.ts"),streams.Segment(pl.Id,0)); + var waiting=LiveStreamProbe.Parse(await Probe(Path.Combine(output,"waiting.ts"))); + Require(waiting.Count==6&&waiting[2].Language=="eng"&&waiting[4].Language=="eng","waiting clip declares both silent audio tracks and both DVB subtitle tracks"); + recording=Start(new[]{"-xerror","-i",pl.Path,"-t","60","-map","0","-c","copy",Path.Combine(output,"all.ts")},"all-client"); + english=Start(new[]{"-xerror","-i",en.Path,"-t","60","-map","0:v:0","-map","0:2","-c","copy",Path.Combine(output,"english.mkv")},"english-client"); + subtitled=Start(new[]{"-xerror","-i",en.Path,"-filter_complex","[0:v:0][0:s:1]overlay=eof_action=pass:shortest=0[v]","-map","[v]","-map","0:2","-t","60","-c:v","libx264","-threads","1","-preset","ultrafast","-c:a","copy",Path.Combine(output,"subtitled.mkv")},"subtitled-client"); + await Task.Delay(18000);Require(JsonSerializer.SerializeToElement(streams.Status()).GetProperty("LiveSegmentsProduced").GetInt64()>0,"multitrack live segments reach viewers"); + enabled=false;await Task.Delay(12000);enabled=true; + await Task.WhenAll(recording.WaitForExitAsync(),english.WaitForExitAsync(),subtitled.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(65)); + Require(recording.ExitCode==0&&english.ExitCode==0&&subtitled.ExitCode==0,"all-track, selected-English and selected-subtitle clients survive source loss and recovery"); + streams.Close(pl.Id);streams.Close(en.Id);await Task.Delay(2000);Require(active==0&&maximum==1,"two viewer languages keep one receiver subscription and release it"); + using(var decode=Start(new[]{"-xerror","-i",Path.Combine(output,"all.ts"),"-map","0:v","-map","0:a","-f","null","-"},"decode")){await decode.WaitForExitAsync();Require(decode.ExitCode==0,"strict video and both-audio decode across status/live switches");} + var actual=JsonDocument.Parse(await Probe(Path.Combine(output,"all.ts"),true)); + var subtitles=actual.RootElement.GetProperty("streams").EnumerateArray().Where(s=>s.GetProperty("codec_type").GetString()=="subtitle").ToArray(); + Require(subtitles.Length==3&&subtitles.All(s=>int.Parse(s.GetProperty("nb_read_packets").GetString())>0),"actual packets for both DVB languages and teletext survive source loss and recovery"); + var frequencies=new List(); + for(var track=0;track<2;track++) + { + var path=Path.Combine(output,$"audio-{track}.pcm");using var audio=Start(new[]{"-i",Path.Combine(output,"all.ts"),"-map",$"0:a:{track}","-ac","1","-ar","48000","-f","s16le",path},"audio-"+track); + await audio.WaitForExitAsync();Require(audio.ExitCode==0,"extract independently selectable audio "+track);var bytes=await File.ReadAllBytesAsync(path);var crossings=0; + var windows=new List(); + for(var at=0;at+96000<=bytes.Length;at+=96000) + { + var peak=0;crossings=0; + for(var i=at;iat&&BitConverter.ToInt16(bytes,i-2)<0&&value>=0)crossings++;} + if(peak>500)windows.Add(crossings); + } + Require(windows.Count>0,"audio language has non-silent live data "+track);frequencies.Add(windows.Order().ElementAt(windows.Count/2)); + } + Require(Math.Abs(frequencies[0]-440)<12&&Math.Abs(frequencies[1]-880)<12,"audio choices contain distinct Polish/English fixture tones, not duplicate first audio"); + // Exercise Jellyfin's bitmap-burn-in delivery mechanism, independently of a physical client. + using(var overlay=Start(new[]{"-i",Path.Combine(output,"all.ts"),"-filter_complex","[0:v:0][0:s:1]overlay=eof_action=pass:shortest=0[v]","-map","[v]","-t","60","-f","null","-"},"subtitle-overlay")) + {await overlay.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(45));Require(overlay.ExitCode==0,"selected DVB bitmap subtitles can be decoded and overlaid");} + var difference=Path.Combine(output,"subtitle-difference.gray"); + using(var pixels=Start(new[]{"-i",Path.Combine(output,"all.ts"),"-filter_complex","[0:v:0]split[a][b];[b][0:s:1]overlay=eof_action=pass:shortest=0[sub];[a][sub]blend=all_mode=difference,crop=96:32:64:300,format=gray[v]","-map","[v]","-t","60","-f","rawvideo",difference},"subtitle-pixels")) + {await pixels.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(45));Require(pixels.ExitCode==0,"compare selected subtitle render against the same video frames");} + var rendered=await File.ReadAllBytesAsync(difference);Require(rendered.Count(b=>b>40)>96*32,"selected subtitle bitmap changes actual rendered pixels"); + // With slates disabled the ordered transport only remuxes original codecs. + var direct=await streams.Open(url,"en",CancellationToken.None,new BroadcastTracks(metadata,false),false); + using(var client=Start(new[]{"-xerror","-i",direct.Path,"-t","5","-map","0:v","-map","0:a:1","-c","copy",Path.Combine(output,"passthrough.ts")},"passthrough")) + {try{await client.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30));Require(client.ExitCode==0,"disabled-slates multitrack remux supports alternate audio");}finally{if(!client.HasExited)client.Kill(true);streams.Close(direct.Id);}} + await Task.Delay(2000); + var report=new{Passed=true,SyntheticOnly=true,ReceiverConnectionsOpened=0,AudioLanguages=new[]{"pol","eng"},SubtitleLanguages=new[]{"pol","eng"},AudioFrequencies=frequencies,SourceFailureSeconds=12,MaximumConcurrentSourceConnections=maximum,RemainingConnections=active,SourceConnections=connections,SubtitlePacketsVerified=true,TeletextPacketsVerified=true,SubtitleOverlayDecoded=true,SubtitleRenderedPixelsVerified=true,SelectedSubtitleLiveClientVerified=true,PhysicalAndroidTvTested=false,ActualJellyfinPlayerTested=false}; + await File.WriteAllTextAsync(Path.Combine(output,"result.json"),JsonSerializer.Serialize(report,new JsonSerializerOptions{WriteIndented=true}));Console.WriteLine(JsonSerializer.Serialize(report)); + } + finally{if(recording is{HasExited:false})recording.Kill(true);if(english is{HasExited:false})english.Kill(true);if(subtitled is{HasExited:false})subtitled.Kill(true);recording?.Dispose();english?.Dispose();subtitled?.Dispose();streams.Dispose();await app.StopAsync();} + } + private static async Task Probe(string file,bool packets=false) + { + var info=new ProcessStartInfo(Path.Combine(Path.GetDirectoryName(encoder),"ffprobe")){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true}; + foreach(var arg in new[]{"-v","error","-analyzeduration","5000000","-probesize","8000000","-show_streams","-show_data","-of","json"})info.ArgumentList.Add(arg);if(packets)info.ArgumentList.Add("-count_packets");info.ArgumentList.Add(file); + using var process=Process.Start(info);var text=process.StandardOutput.ReadToEndAsync();var errors=process.StandardError.ReadToEndAsync();await process.WaitForExitAsync();Require(process.ExitCode==0,"real FFmpeg track probe "+Path.GetFileName(file));var error=await errors;if(error.Length>0)await File.WriteAllTextAsync(Path.Combine(output,"probe-"+Path.GetFileName(file)+".log"),error);return await text; + } + private static Process Start(IEnumerable args,string name) + { + var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardError=true,RedirectStandardOutput=true}; + foreach(var arg in new[]{"-hide_banner","-loglevel","warning","-nostdin","-threads","1","-filter_threads","1"}.Concat(args))info.ArgumentList.Add(arg); + var process=Process.Start(info);process.ErrorDataReceived+=(_,e)=>{if(e.Data!=null)lock(typeof(BroadcastTracksE2E))File.AppendAllText(Path.Combine(output,name+".log"),e.Data+"\n");};process.BeginErrorReadLine();return process; + } + private static async Task Fixture(HttpContext context) + { + Interlocked.Increment(ref connections);if(!enabled){context.Response.StatusCode=503;return;}var count=Interlocked.Increment(ref active);maximum=Math.Max(maximum,count); + var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true}; + foreach(var arg in new[]{"-hide_banner","-loglevel","error","-nostdin","-re","-stream_loop","-1","-i",fixture,"-map","0","-c","copy","-f","mpegts","pipe:1"})info.ArgumentList.Add(arg); + using var process=Process.Start(info);process.ErrorDataReceived+=(_,_)=>{};process.BeginErrorReadLine();context.Response.ContentType="video/mp2t"; + try{var buffer=new byte[65536];while(enabled&&!context.RequestAborted.IsCancellationRequested){var n=await process.StandardOutput.BaseStream.ReadAsync(buffer,context.RequestAborted);if(n==0)break;await context.Response.Body.WriteAsync(buffer.AsMemory(0,n),context.RequestAborted);await context.Response.Body.FlushAsync(context.RequestAborted);}} + catch(OperationCanceledException){}catch(IOException){} + finally{if(!process.HasExited)process.Kill(true);await process.WaitForExitAsync();Interlocked.Decrement(ref active);context.Abort();} + } + private static void Require(bool condition,string text){if(!condition)throw new Exception(text);Console.WriteLine("PASS "+text);} +} diff --git a/Tests/Program.cs b/Tests/Program.cs index a66755b..a3dc9e2 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -18,12 +18,14 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; +if (args.Length == 3 && args[0] == "--broadcast-tracks-e2e") { await BroadcastTracksE2E.Run(args[1], args[2]); return; } if (args.Length == 3 && args[0] == "--native-continuity-e2e") { await ContinuityE2E.Run(args[1], args[2], true, true); return; } if (args.Length == 3 && args[0] == "--resolution-e2e") { await ResolutionE2E.Run(args[1], args[2]); return; } if (args.Length == 3 && (args[0] == "--continuity-e2e" || args[0] == "--long-continuity-e2e")) { await ContinuityE2E.Run(args[1], args[2], args[0].StartsWith("--long")); return; } var time=DateTime.UtcNow; void Check(bool ok,string name){if(!ok)throw new Exception(name);Console.WriteLine("PASS "+name);} ChannelReferenceTests.Run(Check); +await BroadcastTrackTests.Run(Check); var entry=new InternetEpg.Entry("Barwy szczęścia",time,"https://ptv-prod.wphcdn.cloud/media/a.jpg"); Check(InternetEpg.Match(new[]{entry},"Barwy szczęścia",time.AddMinutes(5))==entry,"matching channel programme time"); Check(InternetEpg.Match(new[]{entry},"Inny program",time)==null,"reject different title"); diff --git a/Tests/ResolutionE2E.cs b/Tests/ResolutionE2E.cs index 0dc4f73..310ed66 100644 --- a/Tests/ResolutionE2E.cs +++ b/Tests/ResolutionE2E.cs @@ -30,7 +30,8 @@ public static class ResolutionE2E if (Directory.Exists(output)) throw new Exception("Choose a new output directory"); Directory.CreateDirectory(output); using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.WebHost.UseUrls($"http://127.0.0.1:{port}"); - using var manager = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), NullLogger.Instance, preserveResolution: true); + using var logging = LoggerFactory.Create(b => b.AddSimpleConsole().SetMinimumLevel(LogLevel.Warning)); + using var manager = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), logging.CreateLogger(), preserveResolution: true); using var relay = new LiveStreamRelay(new System.Net.Http.HttpClient(), port, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5)); builder.Services.AddSingleton(manager); builder.Services.AddSingleton(relay); builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly); await using var app = builder.Build(); app.MapControllers(); app.MapGet("/fixture/{size}.ts", Fixture); await app.StartAsync(); @@ -49,6 +50,7 @@ public static class ResolutionE2E try { await Task.WhenAll(recording.WaitForExitAsync(), packaging.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(65)); + Console.WriteLine("Source status: " + JsonSerializer.Serialize(manager.Status())); Require(recording.ExitCode == 0 && packaging.ExitCode == 0, "strict slate/live read and fMP4 packaging: " + size); } finally diff --git a/Tests/SyntheticBroadcastFixture.cs b/Tests/SyntheticBroadcastFixture.cs new file mode 100644 index 0000000..14a54a9 --- /dev/null +++ b/Tests/SyntheticBroadcastFixture.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using Jellyfin.Plugin.Enigma2; +using MediaBrowser.Model.Entities; + +/// Self-authored DVB bitmap fixtures; no broadcaster samples or receiver connection. +public static class SyntheticBroadcastFixture +{ + public static BroadcastTracks Tracks(bool normalize = true) => new(new MediaStream[] { + new BroadcastMediaStream { Type=MediaStreamType.Video,Index=0,TransportId=256,Codec="h264",Width=640,Height=360 }, + new BroadcastMediaStream { Type=MediaStreamType.Audio,Index=1,TransportId=257,Codec="aac",Language="pol",Title="Polski",Channels=2,SampleRate=48000,IsDefault=true }, + new BroadcastMediaStream { Type=MediaStreamType.Audio,Index=2,TransportId=258,Codec="aac",Language="eng",Title="English",Channels=2,SampleRate=48000 }, + new BroadcastMediaStream { Type=MediaStreamType.Subtitle,Index=3,TransportId=259,Codec="DVBSUB",Language="pol",SubtitleExtraData=new byte[]{0,1,0,1},IsHearingImpaired=true }, + new BroadcastMediaStream { Type=MediaStreamType.Subtitle,Index=4,TransportId=260,Codec="DVBSUB",Language="eng",SubtitleExtraData=new byte[]{0,1,0,1},IsForced=true }, + new BroadcastMediaStream { Type=MediaStreamType.Subtitle,Index=5,TransportId=261,Codec="DVBTXT",Language="pol",SubtitleExtraData=new byte[]{16,136} } + },normalize); + private static long Pts(byte[] b,int p) => ((long)(b[p]&14)<<29)|((long)b[p+1]<<22)|((long)(b[p+2]&254)<<14)|((long)b[p+3]<<7)|((long)b[p+4]>>1); + private static byte[] Stamp(long t) => new[]{(byte)(33L|((t>>29)&14)),(byte)(t>>22),(byte)(1L|((t>>14)&254)),(byte)(t>>7),(byte)(1L|(t<<1))}; + public static byte[] AddSubtitles(byte[] input) + { + input=BroadcastSlateProgram.Apply(input,Tracks(),false); + using var output=new MemoryStream();long next=0;var counters=new int[3]; + for(var pos=0;pospos+188||input[p]!=0||input[p+1]!=0||input[p+2]!=1||(input[p+7]&128)==0)continue; + var pts=Pts(input,p+9);if(pts{0,0,1,189};var length=bitmap.Length+8; + pes.Add((byte)(length>>8));pes.Add((byte)length);pes.AddRange(new byte[]{128,128,5});pes.AddRange(Stamp(pts));pes.AddRange(bitmap); + var result=new List(); + for(var i=0;i>8)|(i==0?64:0));packet[2]=(byte)pid; + var count=Math.Min(184,pes.Count-i);var p=4; + if(count<184){packet[3]=(byte)(48|(counter++&15));packet[4]=(byte)(183-count);if(packet[4]>0)packet[5]=0;p+=184-count;} + else packet[3]=(byte)(16|(counter++&15)); + pes.CopyTo(i,packet,p,count);i+=count;result.Add(packet); + } + return result.ToArray(); + } + private static byte[] Teletext() + { + // A self-authored page 888, using standard Hamming-address and odd-parity text bytes. + byte Reverse(byte value) { byte result=0;for(var bit=0;bit<8;bit++)result=(byte)((result<<1)|((value>>bit)&1));return result; } + var hamming=new byte[]{21,2,73,94,100,115,56,47,208,199,140,155,161,182,253,234}; + byte Character(char c) { var value=(byte)(c&127);if(System.Numerics.BitOperations.PopCount((uint)value)%2==0)value|=128;return Reverse(value); } + var result=new List{16}; + void Row(int row,byte[] text){result.AddRange(new byte[]{3,44,199,228,Reverse(hamming[(row&1)<<3]),Reverse(hamming[row>>1])});result.AddRange(text);} + var header=new List();foreach(var n in new[]{8,8,0,8,0,8,0,0})header.Add(Reverse(hamming[n]));header.AddRange(Enumerable.Repeat(Character(' '),32));Row(0,header.ToArray()); + Row(1,"SYNTHETIC TELETEXT SUBTITLE".PadRight(40).Select(Character).ToArray());return result.ToArray(); + } + public static byte[] Bitmap(int variant) + { + var result=new List{32,0}; + void Segment(byte type,byte[] data){result.AddRange(new byte[]{15,type,0,1,(byte)(data.Length>>8),(byte)data.Length});result.AddRange(data);} + Segment(20,new byte[]{0,2,127,1,103}); // display 640 x 360 + Segment(16,new byte[]{2,11,0,255,0,64,1,44}); + Segment(17,new byte[]{0,7,0,96,0,32,39,0,0,3,0,0,0,0,240,0}); + var field=new List(); + for(var y=0;y<16;y++) + { + field.Add(16); + for(var x=0;x<24;x++)field.Add((byte)((x/4+y/4+variant)%2==0?85:170)); + field.Add(0);field.Add(240); + } + var data=new List{0,0,0,(byte)(field.Count>>8),(byte)field.Count,(byte)(field.Count>>8),(byte)field.Count}; + data.AddRange(field);data.AddRange(field);Segment(19,data.ToArray());Segment(128,Array.Empty());result.Add(255); + return result.ToArray(); + } +} diff --git a/build.yaml b/build.yaml index 5f5cdd1..566de16 100644 --- a/build.yaml +++ b/build.yaml @@ -8,7 +8,7 @@ guid: "193f29f9-ea6c-4595-a6f6-55e79d7c590a" # The version of the plugin, starting at 1. Note that the AssemblyVersion # and FileVersion flags in the `.csproj` file must have two additional # fiels, e.g. 1.0.0 to be built correctly. -version: "7.0.13.0" # Please increment with each pull request +version: "7.0.14.0" # Please increment with each pull request # The supported Jellyfin version, usually the earliest binary-compatible # version. Based on the Jellyfin components from NuGet. diff --git a/deploy-plugin-only.py b/deploy-plugin-only.py index b873cbe..113959e 100644 --- a/deploy-plugin-only.py +++ b/deploy-plugin-only.py @@ -13,7 +13,7 @@ import urllib.error import urllib.request import uuid -VERSION = '7.0.13.0' +VERSION = '7.0.14.0' ABI = '12.2.0.0' GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a' DLL = 'Jellyfin.Plugin.Enigma2.dll'