From 11a34a5195a3bc26c5be966166bb0bae3a7a3f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Sun, 25 Oct 2026 16:59:56 +0100 Subject: [PATCH] Preserve native broadcast video and add patient shared stream startup --- .../BroadcastSlateProgram.cs | 9 +- Jellyfin.Plugin.Enigma2/BroadcastTracks.cs | 18 +-- .../Configuration/PluginConfiguration.cs | 2 + .../Configuration/configPage.html | 16 ++- Jellyfin.Plugin.Enigma2/ContinuityStreams.cs | 59 +++++---- Jellyfin.Plugin.Enigma2/ContinuityTimeline.cs | 32 ++++- .../Jellyfin.Plugin.Enigma2.csproj | 4 +- Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs | 85 +++++++++--- Jellyfin.Plugin.Enigma2/LiveStreamRelay.cs | 11 +- Jellyfin.Plugin.Enigma2/LiveTvService.cs | 4 +- .../LosslessSlateProfile.cs | 48 +++++++ Jellyfin.Plugin.Enigma2/PlaybackFeedback.cs | 6 +- Jellyfin.Plugin.Enigma2/StartupBudget.cs | 10 ++ .../TransportStreamClock.cs | 25 +++- README.integration.md | 30 +++++ Tests/BroadcastTrackTests.cs | 2 +- Tests/ContinuityE2E.cs | 9 +- Tests/LosslessE2E.cs | 123 ++++++++++++++++++ Tests/LosslessSlateTests.cs | 63 +++++++++ Tests/PatientProbeTests.cs | 70 ++++++++++ Tests/PatientStartupE2E.cs | 74 +++++++++++ Tests/Program.cs | 4 + Tests/ResolutionE2E.cs | 18 ++- deploy-plugin-only.py | 62 ++++++++- test-web-bridge.cjs | 11 +- test-web-player.cjs | 44 +------ web/decoder-channel-playback.js | 8 +- 27 files changed, 708 insertions(+), 139 deletions(-) create mode 100644 Jellyfin.Plugin.Enigma2/LosslessSlateProfile.cs create mode 100644 Jellyfin.Plugin.Enigma2/StartupBudget.cs create mode 100644 Tests/LosslessE2E.cs create mode 100644 Tests/LosslessSlateTests.cs create mode 100644 Tests/PatientProbeTests.cs create mode 100644 Tests/PatientStartupE2E.cs diff --git a/Jellyfin.Plugin.Enigma2/BroadcastSlateProgram.cs b/Jellyfin.Plugin.Enigma2/BroadcastSlateProgram.cs index 4a67819..ed57aad 100644 --- a/Jellyfin.Plugin.Enigma2/BroadcastSlateProgram.cs +++ b/Jellyfin.Plugin.Enigma2/BroadcastSlateProgram.cs @@ -27,9 +27,10 @@ public static class BroadcastSlateProgram 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); + long pts = ((long)(clip[p + 9] & 14) << 29) | ((long)clip[p + 10] << 22) | ((long)(clip[p + 11] & 254) << 14) | ((long)clip[p + 12] << 7) | ((long)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)); + if (tracks.Output[i].Codec == "DVBSUB") output.Write(ClearSubtitle(tracks.Input[i], 256 + i, pts, + tracks.Input[0].Width ?? ContinuityStreams.Width, tracks.Input[0].Height ?? ContinuityStreams.Height)); cleared = true; } } @@ -50,12 +51,12 @@ public static class BroadcastSlateProgram 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) + private static byte[] ClearSubtitle(MediaStream stream, int pid, long pts, int width, int height) { 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, 20, (byte)(page >> 8), (byte)page, 0, 5, 0, (byte)((width - 1) >> 8), (byte)(width - 1), (byte)((height - 1) >> 8), (byte)(height - 1), 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, diff --git a/Jellyfin.Plugin.Enigma2/BroadcastTracks.cs b/Jellyfin.Plugin.Enigma2/BroadcastTracks.cs index 4549dc2..ad2c8c8 100644 --- a/Jellyfin.Plugin.Enigma2/BroadcastTracks.cs +++ b/Jellyfin.Plugin.Enigma2/BroadcastTracks.cs @@ -13,6 +13,8 @@ namespace Jellyfin.Plugin.Enigma2; // Probe-only transport details; the public Jellyfin contract remains MediaStream. public sealed class BroadcastMediaStream : MediaStream { + public string SampleAspectRatio { get; set; } + public string FrameRateRational { get; set; } public int? TransportId { get; set; } public bool IsVisualImpaired { get; set; } public byte[] SubtitleExtraData { get; set; } @@ -27,9 +29,9 @@ public sealed class BroadcastTracks 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) + public BroadcastTracks(IEnumerable streams, bool normalize = false, bool preserveResolution = true) { - Normalize = normalize; + Normalize = false; // Legacy argument retained for compatibility; channel AV is never normalized. var all = streams.ToArray(); Input = all.Where(s => s.Type == MediaStreamType.Video).Take(1) .Concat(all.Where(s => s.Type == MediaStreamType.Audio)) @@ -41,21 +43,13 @@ public sealed class BroadcastTracks 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, + s.Profile, s.PixelFormat, s.AverageFrameRate, s.RealFrameRate, s.IsInterlaced, s.ColorRange, s.ColorSpace, s.ColorTransfer, s.ColorPrimaries, s.SampleRate, s.ChannelLayout, + Sar = (s as BroadcastMediaStream)?.SampleAspectRatio, Rate = (s as BroadcastMediaStream)?.FrameRateRational, Pid = (s as BroadcastMediaStream)?.TransportId, Extra = (s as BroadcastMediaStream)?.SubtitleExtraData }))))).ToLowerInvariant(); } diff --git a/Jellyfin.Plugin.Enigma2/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Enigma2/Configuration/PluginConfiguration.cs index dbe0e52..d1a119a 100644 --- a/Jellyfin.Plugin.Enigma2/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.Enigma2/Configuration/PluginConfiguration.cs @@ -34,6 +34,8 @@ namespace Jellyfin.Plugin.Enigma2.Configuration public bool ShowNativeStartupMessages { get; set; } = true; public bool EnableContinuitySlates { get; set; } = false; public bool PreserveSourceResolution { get; set; } = true; + public int StreamStartupWaitSeconds { get; set; } = 90; + public int StreamStallWaitSeconds { get; set; } = 30; public PluginConfiguration() diff --git a/Jellyfin.Plugin.Enigma2/Configuration/configPage.html b/Jellyfin.Plugin.Enigma2/Configuration/configPage.html index 41c6d03..9f229aa 100644 --- a/Jellyfin.Plugin.Enigma2/Configuration/configPage.html +++ b/Jellyfin.Plugin.Enigma2/Configuration/configPage.html @@ -157,7 +157,17 @@ Podtrzymuj stream planszą FFmpeg podczas oczekiwania i przerw -
Ponawia odbiór aż do zatrzymania przez widza. Jeden proces odbioru na kanał; język planszy według interfejsu użytkownika, następnie profilu i ustawień Jellyfina. Zachowuje rozdzielczość obrazu źródła, również 4K, przy kodowaniu H264 50 kl./s i AAC stereo. Wymaga CPU; nie zachowuje HDR, dźwięku przestrzennego ani dodatkowych ścieżek. Nie dotyczy nagrań.
+
Obraz i dźwięk kanału są kopiowane bez przekodowania: bez limitu 50 kl./s, zmiany rozdzielczości, HDR i dźwięku przestrzennego. Plansza jest dostępna dla rozpoznanego progresywnego H264 8-bit z AAC LC stereo 48 kHz; ma rozmiar i klatkaż źródła oraz wymaga zgodnych granic klatek obrazu i dźwięku. Pozostałe formaty oraz źródła bez metadanych używają zwykłego oczekiwania, bez obniżania jakości. Język planszy według interfejsu, profilu i ustawień Jellyfina. Nie dotyczy nagrań.
+
+ + +
Domyślnie 90, zakres 25–180. Ponawia analizę świeżej próbki przy jednym połączeniu dekodera. Anulowanie ostatniego widza kończy próbę. Android TV może mieć własny, krótszy limit.
+
+
+ + +
Domyślnie 30, zakres 8–60. Dotyczy braku danych; nie potwierdza poprawnego obrazu w napływających pakietach.
+