40 changed files with 2814 additions and 101 deletions
@@ -0,0 +1,108 @@
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) | ((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,
tracks.Input[0].Width ?? ContinuityStreams.Width, tracks.Input[0].Height ?? ContinuityStreams.Height));
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, 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, (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<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();
}
}
+103
View File
@@ -0,0 +1,103 @@
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 string SampleAspectRatio { get; set; }
public string FrameRateRational { get; set; }
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 = false, bool preserveResolution = true)
{
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))
.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";
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();
}
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);
}
@@ -31,6 +31,11 @@ namespace Jellyfin.Plugin.Enigma2.Configuration
public bool EnableVuRecordings { get; set; } = false;
public bool PreferH264ForWebLiveTv { get; set; } = true;
public bool EnableAutomaticChannelSync { get; set; } = true;
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()
@@ -153,6 +153,26 @@
<span>Preferuj H264 przy transkodowaniu telewizji w przeglądarce</span>
</label>
<div class="fieldDescription">Ogranicza transkodowanie kanałów Enigma2 w aplikacji webowej do H264. Ułatwia odtwarzanie na urządzeniach bez wydajnego dekodowania AV1. Zmiana dotyczy kolejnego uruchomienia kanału.</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkEnableContinuitySlates" />
<span>Podtrzymuj stream planszą FFmpeg podczas oczekiwania i przerw</span>
</label>
<div class="fieldDescription">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ń.</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtStreamStartupWaitSeconds">Czas oczekiwania na obraz (sekundy)</label>
<input is="emby-input" type="number" min="25" max="180" id="txtStreamStartupWaitSeconds" />
<div class="fieldDescription">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.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtStreamStallWaitSeconds">Tolerowana cisza strumienia (sekundy)</label>
<input is="emby-input" type="number" min="8" max="60" id="txtStreamStallWaitSeconds" />
<div class="fieldDescription">Domyślnie 30, zakres 8–60. Dotyczy braku danych; nie potwierdza poprawnego obrazu w napływających pakietach.</div>
</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkShowNativeStartupMessages" />
<span>Pokazuj komunikaty uruchamiania kanału w Android TV</span>
</label>
<div class="fieldDescription">Krótkie komunikaty o oczekiwaniu, gotowości lub błędzie. Wyłącznie na urządzeniu, które uruchomiło kanał. W przeglądarce moduł wtyczki pokazuje osobny ekran oczekiwania.</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkDebugLogging" />
<span>Enable Enigma2 debug logging</span>
@@ -201,6 +221,10 @@
document.getElementById('chkEnableVuRecordings').checked = config.EnableVuRecordings || false;
document.getElementById('chkPreferH264ForWebLiveTv').checked = config.PreferH264ForWebLiveTv !== false;
document.getElementById('chkEnableAutomaticChannelSync').checked = config.EnableAutomaticChannelSync !== false;
document.getElementById('chkShowNativeStartupMessages').checked = config.ShowNativeStartupMessages !== false;
document.getElementById('chkEnableContinuitySlates').checked = config.EnableContinuitySlates === true;
document.getElementById('txtStreamStartupWaitSeconds').value = config.StreamStartupWaitSeconds ?? 90;
document.getElementById('txtStreamStallWaitSeconds').value = config.StreamStallWaitSeconds ?? 30;
Dashboard.hideLoadingMsg();
});
@@ -230,6 +254,10 @@
config.EnableVuRecordings = document.getElementById('chkEnableVuRecordings').checked;
config.PreferH264ForWebLiveTv = document.getElementById('chkPreferH264ForWebLiveTv').checked;
config.EnableAutomaticChannelSync = document.getElementById('chkEnableAutomaticChannelSync').checked;
config.ShowNativeStartupMessages = document.getElementById('chkShowNativeStartupMessages').checked;
config.EnableContinuitySlates = document.getElementById('chkEnableContinuitySlates').checked;
config.StreamStartupWaitSeconds = Math.max(25, Math.min(180, Number(document.getElementById('txtStreamStartupWaitSeconds').value) || 90));
config.StreamStallWaitSeconds = Math.max(8, Math.min(60, Number(document.getElementById('txtStreamStallWaitSeconds').value) || 30));
ApiClient.updatePluginConfiguration(Enigma2ConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
@@ -0,0 +1,334 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Plugin-owned FFmpeg failover. One producer per channel; per-viewer language and lifetime.</summary>
public sealed class ContinuityStreams : IDisposable
{
private readonly string root;
private Func<string> encoderPath;
private readonly int port;
private readonly StartupMessages messages;
private readonly ILogger logger;
private readonly object gate = new();
private readonly Dictionary<string, Source> sources = new();
private readonly Dictionary<string, Viewer> viewers = new();
private readonly ConcurrentDictionary<string, Lazy<Task<Dictionary<string, byte[]>>>> slates = new();
private readonly CancellationTokenSource lifetime = new();
private readonly Task sweeper;
private readonly SemaphoreSlim slateGate = new(1, 1);
private bool disposed;
private long attempts, liveSegments;
public const int Width = 1920, Height = 1080, FrameRate = 50;
public ContinuityStreams(IMediaEncoder encoder, IApplicationPaths paths, IServerApplicationHost host, StartupMessages messages, ILogger<ContinuityStreams> logger)
: this(encoder.EncoderPath, Path.Combine(paths.CachePath, "enigma2-continuity-v1"), host.HttpPort, messages, logger, Plugin.Instance?.Configuration?.PreserveSourceResolution ?? true) { encoderPath = () => encoder.EncoderPath; }
// Explicit inputs support an isolated FFmpeg/Kestrel test without a receiver or server configuration changes.
public ContinuityStreams(string encoder, string root, int port, StartupMessages messages, ILogger logger, bool preserveResolution = true)
{
encoderPath = () => encoder; this.root = Path.GetFullPath(root); this.port = port; this.messages = messages; this.logger = logger;
Directory.CreateDirectory(this.root); sweeper = Task.Run(Sweep);
}
private sealed class Viewer
{
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, BroadcastTracks tracks = null, bool useSlates = true)
{
language = StartupMessages.ResolveLanguage(language, null, "en");
tracks ??= BroadcastTracks.Fallback();
var profile = useSlates ? LosslessSlateProfile.Create(tracks) : null;
useSlates = profile != null;
Dictionary<string, byte[]> clips = null;
if (useSlates)
{
try { clips = await CachedSlates(language, token, tracks); }
catch (Exception error) when (error is not OperationCanceledException && !token.IsCancellationRequested)
{ useSlates = false; logger.LogWarning("[Enigma2] Status picture unavailable ({FailureType}); preserving the original broadcast", error.GetType().Name); }
}
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(sourceKey, out var source))
{
if (sources.Count >= 4) throw new InvalidOperationException("Continuity channel limit reached");
source = new(this, relayUrl, sourceKey, tracks, useSlates); sources.Add(sourceKey, source);
}
viewer = new() { Source = source, Slates = clips, SubtitleVersions = new(tracks) }; viewers.Add(viewer.Id, viewer);
}
try
{
if (!useSlates) await viewer.Source.Ready.Task.WaitAsync(TimeSpan.FromSeconds(StartupBudget.Seconds), 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, BroadcastTracks tracks = null)
{
tracks ??= PreviewTracks();
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[]>>>>(key, cached)); throw; }
return clips;
}
public async Task<byte[]> SlatePreview(string language, string phase, CancellationToken token)
{
language = StartupMessages.ResolveLanguage(language, null, "en");
if (phase != "waiting" && phase != "retrying") throw new ArgumentException("Unknown slate phase");
return TransportStreamClock.Rebase((await CachedSlates(language, token))[phase], 126000);
}
public void Close(string id)
{
lock (gate)
{
if (!viewers.Remove(id, out var viewer)) return;
if (!viewers.Values.Any(v => ReferenceEquals(v.Source, viewer.Source)))
{ sources.Remove(viewer.Source.Key); viewer.Source.Stop(); }
}
}
public string Playlist(string id)
{
lock (gate)
{
if (!viewers.TryGetValue(id, out var viewer)) return null;
viewer.LastAccess = DateTime.UtcNow; return viewer.Source.Timeline.Playlist();
}
}
public byte[] Segment(string id, long sequence)
{
lock (gate)
{
if (!viewers.TryGetValue(id, out var viewer)) return null;
viewer.LastAccess = DateTime.UtcNow;
var segment = viewer.Source.Timeline.Find(sequence);
if (segment == null) return null;
var transport = TransportStreamClock.Rebase(segment.Slate is { } slate ? viewer.Slates[slate] : segment.Video, segment.Start90k,
alignAudio: segment.Slate != null, offset90k: segment.ClockOffset90k);
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) }; }
private async Task Sweep()
{
try
{
while (!lifetime.IsCancellationRequested)
{
await Task.Delay(100, lifetime.Token);
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) if (source.UseSlates) source.Timeline.Tick(DateTime.UtcNow, source.Retrying);
}
}
}
catch (OperationCanceledException) { }
}
public void Dispose()
{
lock (gate)
{
if (disposed) return; disposed = true; lifetime.Cancel();
foreach (var source in sources.Values) source.Stop(); sources.Clear(); viewers.Clear();
}
}
private static BroadcastTracks PreviewTracks() => new(new MediaStream[] {
new BroadcastMediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", Width = Width, Height = Height,
Profile = "Constrained Baseline", PixelFormat = "yuv420p", AverageFrameRate = FrameRate, SampleAspectRatio = "1:1", FrameRateRational = "50/1" },
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Profile = "LC", Channels = 2, SampleRate = 48000, IsDefault = true }
});
// These arguments encode the generated status image only. Live AV always uses -c copy.
private static IEnumerable<string> SlateCodecArguments(LosslessSlateProfile profile) => new[] {
"-c:v", "libx264", "-threads", "2", "-preset", "ultrafast", "-tune", "zerolatency", "-crf", "20",
"-profile:v", profile.Profile, "-pix_fmt", "yuv420p", "-r", profile.RateArgument,
"-g", profile.Gop.ToString(CultureInfo.InvariantCulture), "-keyint_min", profile.Gop.ToString(CultureInfo.InvariantCulture), "-sc_threshold", "0",
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k" };
private Process Start(IEnumerable<string> arguments, string folder)
{
var info = new ProcessStartInfo(encoderPath()) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, WorkingDirectory = folder };
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "1", "-threads", "2" }.Concat(arguments)) info.ArgumentList.Add(arg);
var process = Process.Start(info) ?? throw new IOException("FFmpeg did not start");
// Drain without retaining lines: FFmpeg errors may contain private input URLs.
process.ErrorDataReceived += (_, _) => { }; process.OutputDataReceived += (_, _) => { };
process.BeginErrorReadLine(); process.BeginOutputReadLine(); return process;
}
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"));
try
{
Directory.CreateDirectory(folder);
var profile = LosslessSlateProfile.Create(tracks) ?? throw new InvalidOperationException("No lossless status profile");
var seconds = profile.SegmentSeconds.ToString("0.########", CultureInfo.InvariantCulture);
var font = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
if (!File.Exists(font)) throw new FileNotFoundException("Continuity slates require a Unicode DejaVu Sans font");
var clips = new Dictionary<string, byte[]>();
foreach (var phase in new[] { "waiting", "retrying" })
{
await File.WriteAllTextAsync(Path.Combine(folder, "message.txt"), messages.Text(language, "slate_" + phase), lifetime.Token);
var size = Math.Max(12, profile.Height / 28);
var filter = $"setsar={profile.Aspect},drawtext=fontfile={font}:text=TV:fontsize={size}:fontcolor=0x58bfee:x=(w-tw)/2:y=h*0.25," +
$"drawtext=fontfile={font}:textfile=message.txt:fontsize={size}:fontcolor=white:line_spacing={size / 2}:x=(w-tw)/2:y=h*0.45";
using var process = Start(new[] { "-f", "lavfi", "-i", $"color=c=0x101723:s={profile.Width}x{profile.Height}:r={profile.RateArgument}", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-vf", filter,
"-map", "0:v:0" }.Concat(Enumerable.Range(0, tracks.AudioCount).SelectMany(_ => new[] { "-map", "1:a:0" })).Concat(new[] { "-t", (profile.SegmentSeconds * 3).ToString("0.########", CultureInfo.InvariantCulture) }).Concat(SlateCodecArguments(profile)).Concat(tracks.MetadataArguments(audioVideoOnly: true)).Concat(new[] { "-f", "hls", "-hls_time", seconds, "-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] = 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;
}
finally { try { if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); } finally { slateGate.Release(); } }
}
private sealed class Source
{
private readonly ContinuityStreams owner;
private readonly CancellationTokenSource lifetime;
private readonly Task task;
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, string key, BroadcastTracks tracks, bool useSlates)
{ this.owner = owner; Url = url; Key = key; Tracks = tracks; UseSlates = useSlates; Timeline = new(DateTime.UtcNow, useSlates, LosslessSlateProfile.Create(tracks)?.SegmentSeconds ?? ContinuityTimeline.SegmentSeconds); lifetime = CancellationTokenSource.CreateLinkedTokenSource(owner.lifetime.Token); task = Task.Run(Run); }
public void Stop() { try { lifetime.Cancel(); } catch (ObjectDisposedException) { } }
private async Task Run()
{
var failures = 0;
try
{
while (!lifetime.IsCancellationRequested)
{
var epoch = Guid.NewGuid().ToString("N"); var folder = Path.Combine(owner.root, "source-" + epoch);
var delivered = false;
try
{
Directory.CreateDirectory(folder);
var args = new[] { "-rw_timeout", (StartupBudget.StallSeconds * 1000000L).ToString(CultureInfo.InvariantCulture), "-analyzeduration", "5000000", "-probesize", "4000000", "-i", Url }
.Concat(Tracks.MapArguments()).Concat(new[] { "-dn" });
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
{
var highest = -1; // Stream copy has no encoder-priming segment to discard.
var lastOutput = DateTime.UtcNow;
while (!process.HasExited && !lifetime.IsCancellationRequested)
{
var playlist = Path.Combine(folder, "live.m3u8");
if (File.Exists(playlist))
{
var lines = await File.ReadAllLinesAsync(playlist, lifetime.Token); double duration = 0;
foreach (var line in lines)
{
if (line.StartsWith("#EXTINF:", StringComparison.Ordinal)) double.TryParse(line.Substring(8).TrimEnd(','), NumberStyles.Float, CultureInfo.InvariantCulture, out duration);
var match = Regex.Match(line, @"^segment-(\d{9})\.ts$");
if (!match.Success || !int.TryParse(match.Groups[1].Value, out var index) || index <= highest) continue;
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;
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 ? StartupBudget.StallSeconds + 5 : StartupBudget.Seconds)) throw new IOException("No usable encoded segments");
await Task.Delay(250, lifetime.Token);
}
}
finally
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
if (!lifetime.IsCancellationRequested && process.ExitCode != 0) owner.logger.LogWarning("[Enigma2] Continuity producer exited with code {ExitCode}; retrying while retaining the viewer stream", process.ExitCode);
}
}
catch (Exception error) when (!lifetime.IsCancellationRequested)
{ owner.logger.LogWarning("[Enigma2] Continuity producer retry ({FailureType}); keeping the viewer stream open", error.GetType().Name); }
finally { try { if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); } catch (IOException) { owner.logger.LogWarning("[Enigma2] Could not remove an inactive continuity cache directory"); } }
Retrying = true; failures++;
await Task.Delay(TimeSpan.FromSeconds(Math.Min(10, 3 * failures)), lifetime.Token);
}
}
catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { }
catch (Exception error) { owner.logger.LogError("[Enigma2] Continuity worker stopped ({FailureType})", error.GetType().Name); }
finally { lifetime.Dispose(); }
}
}
}
[ApiController]
[Route("DecoderIntegration/Continuity")]
public sealed class ContinuityController : ControllerBase
{
private readonly ContinuityStreams streams;
public ContinuityController(ContinuityStreams streams) { this.streams = streams; }
private bool Local => HttpContext.Connection.RemoteIpAddress is { } remote
&& (IPAddress.IsLoopback(remote) || remote.IsIPv4MappedToIPv6 && IPAddress.IsLoopback(remote.MapToIPv4()))
&& !Request.Headers.ContainsKey("Forwarded") && !Request.Headers.ContainsKey("X-Forwarded-For") && !Request.Headers.ContainsKey("X-Real-IP");
[HttpGet("{id}/index.m3u8")]
[AllowAnonymous]
public IActionResult Playlist(string id)
{
if (!Local) return StatusCode(403);
Response.Headers.CacheControl = "no-store";
return streams.Playlist(id) is { } text ? Content(text, "application/vnd.apple.mpegurl") : NotFound();
}
[HttpGet("{id}/{sequence:long}.ts")]
[AllowAnonymous]
public IActionResult Segment(string id, long sequence)
{
if (!Local) return StatusCode(403);
Response.Headers.CacheControl = "no-store";
return streams.Segment(id, sequence) is { } bytes ? File(bytes, "video/mp2t") : NotFound();
}
}
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Bounded, never-ending HLS timeline shared by language-specific viewers.</summary>
public sealed class ContinuityTimeline
{
// 112 frames at 50 Hz == 105 AAC frames at 48 kHz: exact AV boundary.
public const double SegmentSeconds = 2.24;
public sealed record Segment(long Sequence, long DiscontinuitiesBefore, bool Discontinuity, double Duration, byte[] Video, string Slate, long Start90k, long? ClockOffset90k = null);
private readonly double slateSeconds;
private readonly List<Segment> segments = new();
private readonly object gate = new();
private long next, discontinuities;
private long clock90k = 126000;
private string previousEpoch;
private string clockEpoch;
private long sourceOffset90k, sourceWrap90k, lastDecode90k;
private DateTime lastAppend, lastLive;
public ContinuityTimeline(DateTime now, bool withSlates = true, double slateSeconds = SegmentSeconds) { this.slateSeconds = slateSeconds; lastAppend = now; lastLive = DateTime.MinValue; if (!withSlates) return; Add(null, "waiting", "slate", slateSeconds, now); Add(null, "waiting", "slate", slateSeconds, now); }
private void Add(byte[] video, string slate, string epoch, double duration, DateTime now, long? offset = null, long? start = null, long? end = null)
{
var discontinuity = next > 0 && (slate != null || previousEpoch != epoch);
segments.Add(new(next++, discontinuities, discontinuity, duration, video, slate, start ?? clock90k, offset));
clock90k = end ?? clock90k + (long)Math.Round(duration * 90000);
if (discontinuity) discontinuities++;
previousEpoch = epoch; lastAppend = now;
while (segments.Count > 8) segments.RemoveAt(0);
}
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 > 30) throw new ArgumentException("Invalid live segment");
lock (gate)
{
lastLive = now;
var bounds = TransportStreamClock.ClockBounds(video);
if (bounds == null) { Add(video, null, epoch, duration, now); return; }
var times = bounds.Value;
// Keep one offset for an entire producer epoch. Anchoring each segment
// to its first PTS corrupts DTS order when copied video has B-frames.
if (clockEpoch != epoch || previousEpoch != epoch)
{ clockEpoch = epoch; sourceWrap90k = 0; sourceOffset90k = clock90k + 9000 - times.Earliest; }
else if (times.Decode + sourceWrap90k < lastDecode90k - (1L << 32)) sourceWrap90k += 1L << 33;
lastDecode90k = times.Decode + sourceWrap90k;
var offset = sourceOffset90k + sourceWrap90k;
var end = Math.Max(times.Decode + (long)Math.Round(duration * 90000), times.Latest + 9000) + offset;
Add(video, null, epoch, duration, now, offset, times.Decode + offset, end);
}
}
public void Tick(DateTime now, bool retrying)
{
lock (gate)
if ((now - lastAppend).TotalSeconds >= 2 && (now - lastLive).TotalSeconds >= 5)
Add(null, retrying ? "retrying" : "waiting", "slate", slateSeconds, now);
}
public Segment Find(long sequence) { lock (gate) return segments.FirstOrDefault(s => s.Sequence == sequence); }
public string Playlist()
{
lock (gate)
{
var window = segments.TakeLast(4).ToArray();
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)
{
if (segment.Discontinuity) text.Append("#EXT-X-DISCONTINUITY\n");
text.Append("#EXTINF:").Append(segment.Duration.ToString("0.000000", CultureInfo.InvariantCulture)).Append(",\n");
text.Append(segment.Sequence).Append(".ts\n");
}
return text.ToString(); // Never ENDLIST: only the viewer closes playback.
}
}
}
@@ -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.11.0</AssemblyVersion>
<FileVersion>7.0.11.0</FileVersion>
<AssemblyVersion>7.0.15.0</AssemblyVersion>
<FileVersion>7.0.15.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
@@ -30,5 +30,5 @@
<PackageReference Include="Jellyfin.Model" Version="12.2.0" />
</ItemGroup>
<ItemGroup><FrameworkReference Include="Microsoft.AspNetCore.App" /><EmbeddedResource Include="Configuration/channels.html" /></ItemGroup>
<ItemGroup><FrameworkReference Include="Microsoft.AspNetCore.App" /><EmbeddedResource Include="Configuration/channels.html" /><EmbeddedResource Include="Localization/startup-messages.json" /></ItemGroup>
</Project>
+98 -23
View File
@@ -17,16 +17,28 @@ namespace Jellyfin.Plugin.Enigma2;
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 sealed class ProbeEntry
{
public readonly TaskCompletionSource<List<MediaStream>> Completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public readonly CancellationTokenSource Stop = new();
public DateTime Until = DateTime.MaxValue;
public int Waiters;
public bool Abandoned;
public Task<List<MediaStream>> Task => Completion.Task;
}
private readonly Dictionary<string, ProbeEntry> 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;
private readonly Func<TimeSpan> timeout;
public string LastError { get; private set; }
public LiveStreamProbe(IMediaEncoder encoder) : this((url, token) => Run(encoder.ProbePath, url, token)) { }
public LiveStreamProbe(Func<string, CancellationToken, Task<string>> runner, TimeSpan? timeout = null) { this.runner = runner; this.timeout = timeout ?? TimeSpan.FromSeconds(25); }
public LiveStreamProbe(IMediaEncoder encoder) : this((url, token) => Run(encoder.ProbePath, url, token))
{ timeout = () => TimeSpan.FromSeconds(StartupBudget.Seconds); }
public LiveStreamProbe(Func<string, CancellationToken, Task<string>> runner, TimeSpan? timeout = null) { this.runner = runner; this.timeout = () => timeout ?? TimeSpan.FromSeconds(25); }
// The HTTP and file-analysis seams let tests exercise the actual capture, retry and cleanup.
public LiveStreamProbe(HttpClient client, Func<string, int?, bool, CancellationToken, Task<string>> fileProbe, SamplingLimits limits = null, TimeSpan? timeout = null)
: this((url, token) => CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), timeout) { }
public LiveStreamProbe(HttpClient client, Func<string, int?, bool, CancellationToken, Task<string>> fileProbe, SamplingLimits limits = null, TimeSpan? timeout = null, bool patient = false, TimeSpan? retryDelay = null)
: this((url, token) => patient ? Patient(() => CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), token, retryDelay)
: CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), timeout) { }
public sealed record SamplingLimits(TimeSpan InitialDuration, TimeSpan MaximumDuration, int InitialBytes, int MaximumBytes)
{
@@ -48,40 +60,92 @@ public sealed class LiveStreamProbe
public Task<List<MediaStream>> Get(string url, CancellationToken caller, int? programme = null)
{
if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture);
Task<List<MediaStream>> task;
caller.ThrowIfCancellationRequested();
ProbeEntry entry;
lock (gate)
{
if (cache.TryGetValue(url, out var existing) && existing.Until > DateTime.UtcNow) task = existing.Task;
if (cache.TryGetValue(url, out var existing) && existing.Until > DateTime.UtcNow && !existing.Abandoned && !existing.Stop.IsCancellationRequested) entry = existing;
else
{
foreach (var expired in cache.Where(x => x.Value.Until <= DateTime.UtcNow).Select(x => x.Key).ToArray()) cache.Remove(expired);
var completion = new TaskCompletionSource<List<MediaStream>>(TaskCreationOptions.RunContinuationsAsynchronously);
task = completion.Task;
cache[url] = (task, DateTime.MaxValue);
_ = Produce(url, completion);
foreach (var expired in cache.Where(x => x.Value.Until <= DateTime.UtcNow).Select(x => x.Key).ToArray())
{ cache[expired].Stop.Dispose(); cache.Remove(expired); }
entry = new(); cache[url] = entry;
_ = Produce(url, entry);
}
entry.Waiters++;
}
return AwaitShared(entry, caller);
}
private async Task<List<MediaStream>> AwaitShared(ProbeEntry entry, CancellationToken caller)
{
try { return await entry.Task.WaitAsync(caller); }
finally
{
bool stop;
lock (gate) { entry.Waiters--; stop = entry.Waiters == 0 && !entry.Task.IsCompleted; if (stop) entry.Abandoned = true; }
if (stop) entry.Stop.Cancel(); // Last viewer left; do not occupy a tuner for the remaining budget.
}
return task.WaitAsync(caller);
}
private async Task Produce(string url, TaskCompletionSource<List<MediaStream>> completion)
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, ProbeEntry entry)
{
var success = false;
using var deadline = new CancellationTokenSource(timeout);
var completion = entry.Completion;
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(entry.Stop.Token);
deadline.CancelAfter(timeout());
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) when (entry.Stop.IsCancellationRequested) { completion.TrySetCanceled(entry.Stop.Token); }
catch (Exception error) { LastError = error.Message; completion.TrySetException(new InvalidOperationException("Decoder returned no usable live stream within the probe deadline.", error)); }
finally { lock (gate) cache[url] = (completion.Task, DateTime.UtcNow.AddSeconds(success ? 30 : 3)); }
finally { lock (gate) { entry.Until = DateTime.UtcNow.AddSeconds(success ? 30 : 3); if (entry.Stop.IsCancellationRequested && cache.TryGetValue(url, out var current) && ReferenceEquals(current, entry)) cache.Remove(url); } }
}
private static async Task<string> Run(string executable, string url, CancellationToken token)
{
using var client = new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3) }) { Timeout = Timeout.InfiniteTimeSpan };
return await CaptureAndProbe(client, (sample, programme, extended, cancellation) => RunFile(executable, sample, programme, extended, cancellation), SamplingLimits.Default, url, token);
Task<string> Sample() => CaptureAndProbe(client, (sample, programme, extended, cancellation) => RunFile(executable, sample, programme, extended, cancellation), SamplingLimits.Default, url, token);
// Production always uses the local shared relay. Reopening its HTTP subscription
// keeps the same receiver connection and a fresh bounded sample window.
return Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.IsLoopback && uri.AbsolutePath.StartsWith("/DecoderIntegration/Live/", StringComparison.Ordinal)
? await Patient(Sample, token) : await Sample();
}
private static async Task<string> Patient(Func<Task<string>> sample, CancellationToken token, TimeSpan? retryDelay = null)
{
Exception last = null; var windows = 0;
try
{
while (true)
{
token.ThrowIfCancellationRequested(); windows++;
try { return await sample(); }
catch (InvalidOperationException error) when (new[] { "video_parameters_unavailable", "missing_expected_programme", "missing_video", "capture_http_failure", "capture_empty", "probe_process_failed" }.Any(c => error.Message.Contains("cause=" + c, StringComparison.Ordinal)))
{ last = error; }
await Task.Delay(retryDelay ?? TimeSpan.FromSeconds(1), token);
}
}
catch (OperationCanceledException) when (last != null)
{ throw new InvalidOperationException($"Decoder startup budget exhausted after {windows} sample windows; last: {last.Message}", last); }
}
private static int? Programme(string url)
@@ -265,7 +329,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 +366,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 +376,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 { SampleAspectRatio = Text("sample_aspect_ratio"), FrameRateRational = Text("avg_frame_rate"), 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 +403,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
};
}
+6 -5
View File
@@ -23,13 +23,14 @@ public sealed class LiveStreamRelay : IDisposable
private readonly object gate = new();
private int opened;
private readonly TimeSpan idleGrace;
private readonly TimeSpan startupTimeout;
private readonly TimeSpan readTimeout;
public LiveStreamRelay(IServerApplicationHost host) : this(new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3), AllowAutoRedirect = false }), host.HttpPort) { }
private Func<TimeSpan> startupBudget, readBudget;
private TimeSpan startupTimeout => startupBudget();
private TimeSpan readTimeout => readBudget();
public LiveStreamRelay(IServerApplicationHost host) : this(new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3), AllowAutoRedirect = false }), host.HttpPort) { startupBudget = () => TimeSpan.FromSeconds(StartupBudget.Seconds); readBudget = () => TimeSpan.FromSeconds(StartupBudget.StallSeconds); }
public LiveStreamRelay(HttpClient client, int port, TimeSpan? idleGrace = null, TimeSpan? startupTimeout = null, TimeSpan? readTimeout = null)
{
this.startupTimeout = startupTimeout ?? TimeSpan.FromSeconds(20);
this.readTimeout = readTimeout ?? TimeSpan.FromSeconds(8);
startupBudget = () => startupTimeout ?? TimeSpan.FromSeconds(20);
readBudget = () => readTimeout ?? TimeSpan.FromSeconds(8);
if (this.startupTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(startupTimeout));
if (this.readTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(readTimeout));
this.client = client; client.Timeout = Timeout.InfiniteTimeSpan; this.port = port; this.idleGrace = idleGrace ?? TimeSpan.FromSeconds(15);
+49 -4
View File
@@ -36,6 +36,8 @@ namespace Jellyfin.Plugin.Enigma2
private readonly LiveStreamProbe _streamProbe;
private readonly LiveStreamRelay _streamRelay;
private readonly ILibraryManager _library;
private readonly PlaybackFeedback _feedback;
private readonly ContinuityStreams _continuity;
private string tvBouquetSRef;
private List<ChannelInfo> tvChannelInfos = new List<ChannelInfo>();
@@ -46,13 +48,15 @@ namespace Jellyfin.Plugin.Enigma2
public static LiveTvService Instance { get; private set; }
public LiveTvService(ILogger<LiveTvService> logger, IHttpClientFactory httpClientFactory, InternetEpg internetEpg, LiveStreamProbe streamProbe, LiveStreamRelay streamRelay, ILibraryManager library)
public LiveTvService(ILogger<LiveTvService> logger, IHttpClientFactory httpClientFactory, InternetEpg internetEpg, LiveStreamProbe streamProbe, LiveStreamRelay streamRelay, ILibraryManager library, PlaybackFeedback feedback = null, ContinuityStreams continuity = null)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_streamProbe = streamProbe;
_streamRelay = streamRelay;
_library = library;
_feedback = feedback;
_continuity = continuity;
Instance = this;
}
@@ -1143,7 +1147,15 @@ namespace Jellyfin.Plugin.Enigma2
public async Task<MediaSourceInfo> GetChannelStream(string channelOid, string mediaSourceId, CancellationToken cancellationToken)
{
_logger.LogInformation("[Enigma2] Start GetChannelStream");
PlaybackFeedback.Attempt feedback = null;
if (_feedback != null)
{
try { feedback = await _feedback.Begin(channelOid, cancellationToken).ConfigureAwait(false); }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { /* Feedback is optional and must not affect playback. */ }
}
try
{
var requestedReference = channelOid;
IEnumerable<ChannelInfo> currentChannels = Array.Empty<ChannelInfo>();
using (var lookupDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
@@ -1216,12 +1228,40 @@ namespace Jellyfin.Plugin.Enigma2
streamUrl = _streamRelay.Register(streamUrl);
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 streams = await _streamProbe.Get(streamUrl, cancellationToken, programme).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");
}
if (streams == null) throw new InvalidOperationException("Broadcast metadata unavailable; refusing a guessed status format");
// 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 = new BroadcastTracks(streams);
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.
// Its supported unknown-index path selects AV automatically and omits data.
streams = streams.Select(s => LiveStreamProbe.ParseLiveMetadata(s)).ToList();
feedback?.Report("ready");
return new MediaSourceInfo
{
@@ -1235,7 +1275,11 @@ namespace Jellyfin.Plugin.Enigma2
SupportsDirectPlay = false,
MediaStreams = streams
};
throw new ResourceNotFoundException(string.Format("Could not stream channel {0}", channelOid));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ feedback?.Report("cancelled"); throw; }
catch
{ feedback?.Report("failed"); throw; }
}
@@ -1628,6 +1672,7 @@ namespace Jellyfin.Plugin.Enigma2
public async Task CloseLiveStream(string id, CancellationToken cancellationToken)
{
_continuity?.Close(id);
await Task.Factory.StartNew<string>(() =>
{
_logger.LogDebug("[Enigma2] LiveTvService.CloseLiveStream: closed stream for subscriptionId: {id}", id);
@@ -0,0 +1,128 @@
{
"en": {
"header": "Television",
"preparing": "Preparing channel…",
"checking_stream": "Waiting for video and audio…",
"waiting_for_stream": "Still waiting for a usable stream…",
"ready": "Stream detected. Starting player…",
"failed": "Unable to prepare the stream.",
"buffering": "Buffering video…",
"help": "The receiver may need several seconds. You can cancel or press Esc.",
"cancel": "Stop and return to channels",
"elapsed": "Waiting: {seconds} s",
"timeout": "The channel is not providing video. Returning to the channel list.",
"play_error": "Unable to start the channel.",
"slate_waiting": "Waiting for the stream…\nThe receiver is preparing video and audio.",
"slate_failed": "Unable to receive the channel.\nReturn to the list and try again.",
"native_preparing": "Starting channel. The receiver may need several seconds.",
"native_waiting": "Still waiting for usable video and audio. Press Back to return.",
"native_ready": "Stream detected. Starting player.",
"native_failed": "Unable to prepare the stream. Return to channels and try again.",
"slate_retrying": "The stream was interrupted.\nRetrying channel reception…"
},
"pl": {
"header": "Telewizja",
"preparing": "Przygotowywanie kanału…",
"checking_stream": "Oczekiwanie na obraz i dźwięk…",
"waiting_for_stream": "Nadal czekamy na użyteczny strumień…",
"ready": "Strumień rozpoznany. Uruchamianie odtwarzacza…",
"failed": "Nie udało się przygotować strumienia.",
"buffering": "Buforowanie obrazu…",
"help": "Dekoder może potrzebować kilkunastu sekund. Możesz anulować lub nacisnąć Esc.",
"cancel": "Zatrzymaj i wróć do kanałów",
"elapsed": "Czas oczekiwania: {seconds} s",
"timeout": "Kanał nie przesyła obrazu. Powrót do listy kanałów.",
"play_error": "Nie udało się uruchomić kanału.",
"slate_waiting": "Oczekiwanie na strumień…\nDekoder przygotowuje obraz i dźwięk.",
"slate_failed": "Nie udało się odebrać kanału.\nWróć do listy i spróbuj ponownie.",
"native_preparing": "Uruchamianie kanału. Dekoder może potrzebować kilkunastu sekund.",
"native_waiting": "Nadal czekamy na użyteczny obraz i dźwięk. Możesz wrócić przyciskiem Wstecz.",
"native_ready": "Strumień rozpoznany. Uruchamianie odtwarzacza.",
"native_failed": "Nie udało się przygotować strumienia. Wróć do kanałów i spróbuj ponownie.",
"slate_retrying": "Strumień został przerwany.\nPonawianie odbioru kanału…"
},
"de": {
"header": "Fernsehen",
"preparing": "Sender wird vorbereitet…",
"checking_stream": "Warten auf Bild und Ton…",
"waiting_for_stream": "Weiterhin warten auf einen nutzbaren Stream…",
"ready": "Stream erkannt. Player wird gestartet…",
"failed": "Der Stream konnte nicht vorbereitet werden.",
"buffering": "Video wird gepuffert…",
"help": "Der Receiver benötigt möglicherweise einige Sekunden. Abbrechen oder Esc drücken.",
"cancel": "Stoppen und zur Senderliste zurückkehren",
"elapsed": "Wartezeit: {seconds} s",
"timeout": "Der Sender liefert kein Bild. Zurück zur Senderliste.",
"play_error": "Der Sender konnte nicht gestartet werden.",
"slate_waiting": "Warten auf den Stream…\nDer Receiver bereitet Bild und Ton vor.",
"slate_failed": "Senderempfang nicht möglich.\nZur Liste zurückkehren und erneut versuchen.",
"native_preparing": "Sender wird gestartet. Der Receiver benötigt möglicherweise einige Sekunden.",
"native_waiting": "Weiterhin warten auf nutzbares Bild und Ton. Mit Zurück zur Liste wechseln.",
"native_ready": "Stream erkannt. Player wird gestartet.",
"native_failed": "Der Stream konnte nicht vorbereitet werden. Zur Senderliste zurückkehren und erneut versuchen.",
"slate_retrying": "Der Stream wurde unterbrochen.\nSenderempfang wird erneut versucht…"
},
"fr": {
"header": "Télévision",
"preparing": "Préparation de la chaîne…",
"checking_stream": "En attente de l’image et du son…",
"waiting_for_stream": "Toujours en attente d’un flux utilisable…",
"ready": "Flux détecté. Démarrage du lecteur…",
"failed": "Impossible de préparer le flux.",
"buffering": "Mise en mémoire tampon…",
"help": "Le décodeur peut avoir besoin de quelques secondes. Vous pouvez annuler ou appuyer sur Échap.",
"cancel": "Arrêter et revenir aux chaînes",
"elapsed": "Attente : {seconds} s",
"timeout": "La chaîne ne fournit pas d’image. Retour à la liste des chaînes.",
"play_error": "Impossible de démarrer la chaîne.",
"slate_waiting": "En attente du flux…\nLe décodeur prépare l’image et le son.",
"slate_failed": "Impossible de recevoir la chaîne.\nRevenez à la liste et réessayez.",
"native_preparing": "Démarrage de la chaîne. Le décodeur peut avoir besoin de quelques secondes.",
"native_waiting": "Toujours en attente de l’image et du son. Appuyez sur Retour pour revenir.",
"native_ready": "Flux détecté. Démarrage du lecteur.",
"native_failed": "Impossible de préparer le flux. Revenez aux chaînes et réessayez.",
"slate_retrying": "Le flux a été interrompu.\nNouvelle tentative de réception…"
},
"es": {
"header": "Televisión",
"preparing": "Preparando el canal…",
"checking_stream": "Esperando imagen y sonido…",
"waiting_for_stream": "Seguimos esperando un flujo válido…",
"ready": "Flujo detectado. Iniciando el reproductor…",
"failed": "No se pudo preparar el flujo.",
"buffering": "Cargando vídeo…",
"help": "El receptor puede necesitar varios segundos. Puedes cancelar o pulsar Esc.",
"cancel": "Detener y volver a los canales",
"elapsed": "Espera: {seconds} s",
"timeout": "El canal no proporciona imagen. Volviendo a la lista de canales.",
"play_error": "No se pudo iniciar el canal.",
"slate_waiting": "Esperando el flujo…\nEl receptor está preparando imagen y sonido.",
"slate_failed": "No se pudo recibir el canal.\nVuelve a la lista e inténtalo de nuevo.",
"native_preparing": "Iniciando el canal. El receptor puede necesitar varios segundos.",
"native_waiting": "Seguimos esperando imagen y sonido. Pulsa Atrás para volver.",
"native_ready": "Flujo detectado. Iniciando el reproductor.",
"native_failed": "No se pudo preparar el flujo. Vuelve a los canales e inténtalo de nuevo.",
"slate_retrying": "El flujo se ha interrumpido.\nReintentando la recepción del canal…"
},
"uk": {
"header": "Телебачення",
"preparing": "Підготовка каналу…",
"checking_stream": "Очікування зображення та звуку…",
"waiting_for_stream": "Триває очікування придатного потоку…",
"ready": "Потік виявлено. Запуск програвача…",
"failed": "Не вдалося підготувати потік.",
"buffering": "Буферизація відео…",
"help": "Приймачу може знадобитися кілька секунд. Можна скасувати або натиснути Esc.",
"cancel": "Зупинити та повернутися до каналів",
"elapsed": "Очікування: {seconds} с",
"timeout": "Канал не передає зображення. Повернення до списку каналів.",
"play_error": "Не вдалося запустити канал.",
"slate_waiting": "Очікування потоку…\nПриймач готує зображення та звук.",
"slate_failed": "Не вдалося прийняти канал.\nПоверніться до списку та спробуйте ще раз.",
"native_preparing": "Запуск каналу. Приймачу може знадобитися кілька секунд.",
"native_waiting": "Триває очікування зображення та звуку. Натисніть Назад, щоб повернутися.",
"native_ready": "Потік виявлено. Запуск програвача.",
"native_failed": "Не вдалося підготувати потік. Поверніться до каналів та спробуйте ще раз.",
"slate_retrying": "Потік перервано.\nПовторна спроба приймання каналу…"
}
}
@@ -0,0 +1,48 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Only the status picture is encoded. Unsupported programs stay entirely in stream-copy mode.</summary>
public sealed class LosslessSlateProfile
{
public int Width { get; private init; }
public int Height { get; private init; }
public double Rate { get; private init; }
public string RateArgument { get; private init; }
public string Aspect { get; private init; }
public string Profile { get; private init; }
public int Gop { get; private init; }
public double SegmentSeconds => Gop / Rate;
public static LosslessSlateProfile Create(BroadcastTracks tracks)
{
var video = tracks.Input[0];
// A guessed codec/layout is unsafe during cold-start outages. Never convert the channel to fit a slate.
if (video.Codec != "h264" || video.PixelFormat != "yuv420p" || video.IsInterlaced
|| video.Width is not (> 0 and <= 16384) || video.Height is not (> 0 and <= 16384)
|| video.Width % 2 != 0 || video.Height % 2 != 0
|| video.ColorTransfer is "smpte2084" or "arib-std-b67"
|| tracks.AudioCount == 0 || tracks.AudioCount > 8
|| tracks.Input.Where(s => s.Type == MediaStreamType.Audio).Any(s => s.Codec != "aac" || s.Profile != "LC" || s.Channels != 2 || s.SampleRate != 48000)) return null;
var profile = video.Profile switch { "Constrained Baseline" or "Baseline" => "baseline", "Main" => "main", "High" => "high", _ => null };
var rate = (double)(video.AverageFrameRate ?? video.RealFrameRate ?? 0);
var aspect = (video as BroadcastMediaStream)?.SampleAspectRatio;
if (profile == null || !double.IsFinite(rate) || rate <= 0 || rate > 240
|| aspect == null || !Regex.IsMatch(aspect, @"^[1-9][0-9]{0,5}:[1-9][0-9]{0,5}$")) return null;
var rational = (video as BroadcastMediaStream)?.FrameRateRational;
if (rational == null || !Regex.IsMatch(rational, @"^[1-9][0-9]{0,8}/[1-9][0-9]{0,8}$"))
rational = rate.ToString("0.########", CultureInfo.InvariantCulture);
// Repeating a cached clip must end on complete video AND AAC frames.
// If that needs a long clip (e.g. 23.976/59.94), retain the original
// broadcast without a status clip rather than rounding its cadence.
var gop = Enumerable.Range(Math.Max(1, (int)(rate * 1.5)), Math.Max(1, (int)(rate * 1.5) + 2))
.Where(n => Math.Abs(n / rate * 48000 / 1024 - Math.Round(n / rate * 48000 / 1024)) < 0.00001)
.OrderBy(n => Math.Abs(n / rate - ContinuityTimeline.SegmentSeconds)).FirstOrDefault();
if (gop == 0) return null;
return new() { Width = video.Width.Value, Height = video.Height.Value, Rate = rate,
RateArgument = rational, Aspect = aspect.Replace(':', '/'), Profile = profile, Gop = gop };
}
}
+170
View File
@@ -0,0 +1,170 @@
using Jellyfin.Data;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Per-user/device startup feedback; no tuner connections or stream URLs.</summary>
public sealed class PlaybackFeedback
{
private readonly IHttpContextAccessor contexts;
private readonly IAuthorizationContext authorization;
private readonly ISessionManager sessions;
private readonly StartupMessages messages;
private readonly object gate = new();
private readonly Dictionary<(Guid User, string Device, string Reference), Attempt> attempts = new();
public PlaybackFeedback(IHttpContextAccessor contexts, IAuthorizationContext authorization, ISessionManager sessions, StartupMessages messages = null)
{ this.contexts = contexts; this.authorization = authorization; this.sessions = sessions; this.messages = messages ?? new StartupMessages(); }
public async Task<Attempt> Begin(string reference, CancellationToken token)
{
// Scheduled recordings/background probes have no requesting viewer.
// Never guess a destination or broadcast to all sessions of a user.
var context = contexts.HttpContext;
if (context == null) return null;
AuthorizationInfo auth;
try { auth = await authorization.GetAuthorizationInfo(context).WaitAsync(TimeSpan.FromSeconds(1), token); }
catch (Exception error) when (error is not OperationCanceledException || !token.IsCancellationRequested) { return null; }
if (auth.UserId == Guid.Empty || string.IsNullOrEmpty(auth.DeviceId) || auth.IsApiKey
|| auth.User == null || !auth.User.HasPermission(PermissionKind.EnableLiveTvAccess)) return null;
string destination = null;
var matches = sessions.Sessions.Where(s => s.UserId == auth.UserId && s.DeviceId == auth.DeviceId && s.IsActive).Take(2).ToArray();
if ((Plugin.Instance?.Configuration?.ShowNativeStartupMessages ?? true) && matches.Length == 1 && IsAndroidTv(matches[0].Client)
&& matches[0].SupportedCommands?.Any(c => c == GeneralCommandType.DisplayMessage) == true) destination = matches[0].Id;
var key = (auth.UserId, auth.DeviceId, reference);
var language = messages.Resolve(auth.UserId, messages.ClientLanguage(auth.UserId, auth.DeviceId), context.Request.Headers.AcceptLanguage.ToString());
var attempt = new Attempt(this, key, destination, language, token);
lock (gate)
{
foreach (var expired in attempts.Where(x => DateTime.UtcNow - x.Value.UpdatedUtc > TimeSpan.FromSeconds(StartupBudget.WebSeconds + 15)).Select(x => x.Key).ToArray()) attempts.Remove(expired);
if (attempts.Count >= 512 && !attempts.ContainsKey(key)) attempts.Remove(attempts.OrderBy(x => x.Value.UpdatedUtc).First().Key);
attempts[key] = attempt;
}
attempt.Report("preparing");
return attempt;
}
public static bool IsAndroidTv(string client) => (client ?? "").Replace(" ", "").Contains("AndroidTV", StringComparison.OrdinalIgnoreCase);
public object Snapshot(Guid user, string device, string reference)
{
lock (gate)
{
if (!attempts.TryGetValue((user, device ?? "", reference), out var attempt) || DateTime.UtcNow - attempt.UpdatedUtc > TimeSpan.FromSeconds(StartupBudget.WebSeconds + 15))
return new { Available = false, Phase = "preparing" };
return new { Available = true, attempt.Phase, attempt.StartedUtc, attempt.UpdatedUtc };
}
}
public async Task<T> AwaitStream<T>(Task<T> stream, Attempt attempt, CancellationToken token)
{
if (attempt == null) return await stream;
attempt.Report("checking_stream");
if (await Task.WhenAny(stream, Task.Delay(TimeSpan.FromSeconds(6), token)) != stream)
{
token.ThrowIfCancellationRequested();
attempt.Report("waiting_for_stream");
}
return await stream;
}
private bool Current(Attempt attempt)
{ lock (gate) return attempts.TryGetValue(attempt.Key, out var current) && ReferenceEquals(current, attempt); }
public sealed class Attempt
{
private readonly PlaybackFeedback owner;
internal readonly (Guid User, string Device, string Reference) Key;
private readonly string destination;
private readonly string language;
public string Language => language;
private readonly CancellationToken token;
private Task notifications = Task.CompletedTask;
private int notificationCount;
public DateTime StartedUtc { get; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; private set; } = DateTime.UtcNow;
public string Phase { get; private set; } = "preparing";
internal Attempt(PlaybackFeedback owner, (Guid, string, string) key, string destination, string language, CancellationToken token)
{ this.owner = owner; Key = key; this.destination = destination; this.language = language; this.token = token; }
public void Report(string phase)
{
lock (owner.gate)
{
if (!owner.Current(this)) return;
Phase = phase; UpdatedUtc = DateTime.UtcNow;
var message = phase switch
{
"preparing" => owner.messages.Text(language, "native_preparing"),
"waiting_for_stream" => owner.messages.Text(language, "native_waiting"),
"ready" => owner.messages.Text(language, "native_ready"),
"failed" => owner.messages.Text(language, "native_failed"),
_ => null
};
if (destination == null || message == null || token.IsCancellationRequested || notificationCount >= 3) return;
notificationCount++;
notifications = notifications.ContinueWith(async _ =>
{
if (token.IsCancellationRequested || !owner.Current(this)) return;
// Sending status must never hold up or fail actual playback.
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(token);
deadline.CancelAfter(TimeSpan.FromMilliseconds(500));
try
{
var target = owner.sessions.Sessions.FirstOrDefault(s => s.Id == destination && s.UserId == Key.User && s.DeviceId == Key.Device && s.IsActive);
if (target != null) await owner.sessions.SendMessageCommand(destination, destination,
new MessageCommand { Header = owner.messages.Text(language, "header"), Text = message, TimeoutMs = 3000 }, deadline.Token).WaitAsync(deadline.Token);
}
catch { /* Optional feedback cannot break the channel. */ }
}, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default).Unwrap();
}
}
}
}
[ApiController]
[Route("DecoderIntegration/PlaybackProgress")]
[Authorize(Policy = "LiveTvAccess")]
public sealed class PlaybackFeedbackController : ControllerBase
{
private readonly PlaybackFeedback feedback;
private readonly ILibraryManager library;
private readonly IUserManager users;
private readonly IAuthorizationContext authorization;
private readonly StartupMessages messages;
public PlaybackFeedbackController(PlaybackFeedback feedback, ILibraryManager library, IUserManager users, IAuthorizationContext authorization, StartupMessages messages = null)
{ this.feedback = feedback; this.library = library; this.users = users; this.authorization = authorization; this.messages = messages ?? new StartupMessages(); }
[HttpGet("/DecoderIntegration/StartupMessages")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<IActionResult> Messages([FromQuery] string language = null)
{
if (!Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value, out var id) || users.GetUserById(id) is not { } user) return Unauthorized();
if (!user.HasPermission(PermissionKind.EnableLiveTvAccess)) return Forbid();
var selected = messages.Resolve(id, language, Request.Headers.AcceptLanguage.ToString());
var auth = await authorization.GetAuthorizationInfo(HttpContext);
if (auth.UserId == id && !auth.IsApiKey) messages.RememberClientLanguage(id, auth.DeviceId, language);
return Ok(new { Language = selected, Texts = messages.Texts(selected), StartupTimeoutSeconds = StartupBudget.WebSeconds });
}
[HttpGet]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<IActionResult> Get([FromQuery] Guid channelId)
{
if (!Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value, out var id)) return Unauthorized();
var user = users.GetUserById(id);
if (user == null) return Unauthorized();
if (!user.HasPermission(PermissionKind.EnableLiveTvAccess)) return Forbid();
var item = channelId == Guid.Empty ? null : library.GetItemById<BaseItem>(channelId, user);
if (item is not LiveTvChannel channel || !string.Equals(channel.ServiceName, "Enigma2", StringComparison.OrdinalIgnoreCase)) return NotFound();
var auth = await authorization.GetAuthorizationInfo(HttpContext);
if (auth.UserId != id || string.IsNullOrEmpty(auth.DeviceId)) return Unauthorized();
return Ok(feedback.Snapshot(id, auth.DeviceId, channel.ExternalId));
}
}
@@ -21,6 +21,10 @@ namespace Jellyfin.Plugin.Enigma2
serviceCollection.AddSingleton<InternetEpg>();
serviceCollection.AddSingleton<LiveStreamProbe>();
serviceCollection.AddSingleton<LiveStreamRelay>();
serviceCollection.AddHttpContextAccessor();
serviceCollection.AddSingleton<StartupMessages>();
serviceCollection.AddSingleton<ContinuityStreams>();
serviceCollection.AddSingleton<PlaybackFeedback>();
serviceCollection.AddSingleton<PreferencesStore>();
serviceCollection.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, NativeChannelFilter>();
serviceCollection.AddSingleton<ILiveTvService, LiveTvService>();
+10
View File
@@ -0,0 +1,10 @@
using System;
namespace Jellyfin.Plugin.Enigma2;
public static class StartupBudget
{
public static int Seconds => Math.Clamp(Plugin.Instance?.Configuration?.StreamStartupWaitSeconds ?? 90, 25, 180);
public static int StallSeconds => Math.Clamp(Plugin.Instance?.Configuration?.StreamStallWaitSeconds ?? 30, 8, 60);
public static int WebSeconds => Seconds + 45;
}
@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>One catalogue for web status, native messages and cached FFmpeg slates.</summary>
public sealed class StartupMessages
{
private static readonly Dictionary<string, Dictionary<string, string>> Catalogue = Load();
private readonly IServerConfigurationManager configuration;
private readonly IDisplayPreferencesManager preferences;
private readonly ConcurrentDictionary<(Guid User, string Device), (string Language, DateTime Seen)> clientLanguages = new();
public StartupMessages(IServerConfigurationManager configuration = null, IDisplayPreferencesManager preferences = null)
{ this.configuration = configuration; this.preferences = preferences; }
private static Dictionary<string, Dictionary<string, string>> Load()
{
using var stream = typeof(StartupMessages).Assembly.GetManifestResourceStream("Jellyfin.Plugin.Enigma2.Localization.startup-messages.json");
return JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string>>>(stream);
}
private static string Supported(string locale)
{
if (string.IsNullOrWhiteSpace(locale) || locale.Length > 64) return null;
var language = locale.Trim().Replace('_', '-').Split('-')[0].ToLowerInvariant();
return Catalogue.ContainsKey(language) ? language : null;
}
public static string ResolveLanguage(string uiLanguage, string acceptLanguage, string serverLanguage)
{
var direct = Supported(uiLanguage);
if (direct != null) return direct;
// Honour quality ordering and q=0 exclusions; headers are untrusted hints,
// never file names or a reason to change metadata/audio preferences.
if (!string.IsNullOrEmpty(acceptLanguage) && acceptLanguage.Length <= 1024)
{
var candidates = acceptLanguage.Split(',').Take(32).Select((entry, order) =>
{
var parts = entry.Trim().Split(';'); var quality = 1.0;
foreach (var part in parts.Skip(1))
if (part.Trim().StartsWith("q=", StringComparison.OrdinalIgnoreCase)
&& (!double.TryParse(part.Trim().Substring(2), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out quality)
|| quality < 0 || quality > 1)) quality = 0;
return new { Language = Supported(parts[0]), Quality = quality, Order = order };
}).Where(x => x.Language != null && x.Quality > 0).OrderByDescending(x => x.Quality).ThenBy(x => x.Order);
var match = candidates.FirstOrDefault(); if (match != null) return match.Language;
}
return Supported(serverLanguage) ?? "en";
}
public string Resolve(string uiLanguage, string acceptLanguage) => ResolveLanguage(uiLanguage, acceptLanguage, configuration?.Configuration?.UICulture);
public string Resolve(Guid user, string uiLanguage, string acceptLanguage)
{
var direct = Supported(uiLanguage);
if (direct != null) return direct;
// Jellyfin Web uses usersettings/emby display preferences. Some clients
// save language there; current Web stores it locally. Read only the
// requesting user's preference, without creating or changing it.
try
{
var custom = preferences?.ListCustomItemDisplayPreferences(user, "usersettings".GetMD5(), "emby");
if (custom != null && custom.TryGetValue("language", out var value) && Supported(value) is { } saved) return saved;
}
catch { /* An optional preference lookup must not prevent playback. */ }
return Resolve(null, acceptLanguage);
}
public void RememberClientLanguage(Guid user, string device, string language)
{
if (user == Guid.Empty || string.IsNullOrEmpty(device) || Supported(language) is not { } supported) return;
foreach (var entry in clientLanguages.Where(x => DateTime.UtcNow - x.Value.Seen > TimeSpan.FromMinutes(2)).ToArray()) clientLanguages.TryRemove(entry.Key, out _);
if (clientLanguages.Count >= 512 && !clientLanguages.ContainsKey((user, device))) return;
clientLanguages[(user, device)] = (supported, DateTime.UtcNow);
}
public string ClientLanguage(Guid user, string device) => device != null && clientLanguages.TryGetValue((user, device), out var value)
&& DateTime.UtcNow - value.Seen < TimeSpan.FromMinutes(2) ? value.Language : null;
public IReadOnlyDictionary<string, string> Texts(string language) => Catalogue[Supported(language) ?? "en"];
public string Text(string language, string key) => Texts(language).TryGetValue(key, out var value) ? value : Catalogue["en"][key];
}
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Rebase cached/live MPEG-TS segments to one presentation clock.</summary>
public static class TransportStreamClock
{
private const long ClockMask = (1L << 33) - 1;
private static int Payload(byte[] data, int start)
{
var control = (data[start + 3] >> 4) & 3;
if ((control & 1) == 0) return -1;
var payload = start + 4 + ((control & 2) != 0 ? data[start + 4] + 1 : 0);
return payload < start + 188 ? payload : -1;
}
private static long ReadPts(byte[] data, int p) => ((long)(data[p] & 14) << 29) | ((long)data[p + 1] << 22)
| ((long)(data[p + 2] & 254) << 14) | ((long)data[p + 3] << 7) | ((long)data[p + 4] >> 1);
private static void WritePts(byte[] data, int p, long pts)
{
pts &= ClockMask;
data[p] = (byte)((data[p] & 240L) | ((pts >> 29) & 14) | 1);
data[p + 1] = (byte)(pts >> 22); data[p + 2] = (byte)(((pts >> 14) & 254) | 1);
data[p + 3] = (byte)(pts >> 7); data[p + 4] = (byte)((pts << 1) | 1);
}
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 (long Decode, long Earliest, long Latest)? ClockBounds(byte[] input)
{
if (input.Length % 188 != 0) return null;
long? decode = null;
var times = new List<long>();
for (var start = 0; start < input.Length; start += 188)
{
var p = Payload(input, start);
if (!Pes(input, start, p)) continue;
var pts = ReadPts(input, p + 9);
times.Add(pts);
var dts = (input[p + 7] & 192) == 192 && p + 19 <= start + 188 ? ReadPts(input, p + 14) : pts;
times.Add(dts);
if (decode == null && input[p + 3] >= 224 && input[p + 3] <= 239) decode = dts;
}
if (decode == null) return null;
long Unwrap(long value) => decode.Value + ((value - decode.Value + (1L << 32)) & ClockMask) - (1L << 32);
long min = decode.Value, max = decode.Value;
foreach (var time in times) { var value = Unwrap(time); min = Math.Min(min, value); max = Math.Max(max, value); }
return (decode.Value, min, max);
}
public static byte[] Rebase(byte[] input, long videoStart90k, bool alignAudio = true, long? offset90k = null)
{
if (input.Length % 188 != 0) throw new InvalidDataException("Unaligned MPEG-TS segment");
long? firstVideo = null, firstAudio = null;
for (var start = 0; start < input.Length; start += 188)
{
if (input[start] != 71) throw new InvalidDataException("Invalid MPEG-TS sync");
var p = Payload(input, start);
if (!Pes(input, start, p)) continue;
if (firstVideo == null && input[p + 3] >= 224 && input[p + 3] <= 239) firstVideo = ReadPts(input, p + 9);
if (firstAudio == null && input[p + 3] >= 192 && input[p + 3] <= 223) firstAudio = ReadPts(input, p + 9);
if (firstVideo != null && firstAudio != null) break;
}
if (firstVideo == null) throw new InvalidDataException("MPEG-TS segment lacks a video presentation timestamp");
var offset = offset90k ?? (videoStart90k - firstVideo.Value);
// 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 = 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)
{
var packet = input.AsSpan(start, 188).ToArray(); var pid = ((packet[1] & 31) << 8) | packet[2];
if (pid != 8191 && pids.Add(pid))
{
// Each independently muxed/cached clip may reset continuity
// counters. Signal that reset in-band, including to FFmpeg's TS
// demuxer, which does not reset them solely from HLS tags.
var reset = new byte[188]; Array.Fill(reset, (byte)255);
reset[0] = 71; reset[1] = (byte)(packet[1] & 31); reset[2] = packet[2];
var counter = packet[3] & 15;
if ((packet[3] & 16) != 0) counter = (counter + 15) & 15;
reset[3] = (byte)(32 | counter); reset[4] = 183; reset[5] = 128;
output.Write(reset);
}
var p = Payload(packet, 0);
if (Pes(packet, 0, p))
{
var shift = packet[p + 3] >= 192 && packet[p + 3] <= 223 ? audioOffset : offset;
WritePts(packet, p + 9, ReadPts(packet, p + 9) + shift);
if ((packet[p + 7] & 192) == 192 && p + 19 <= 188) WritePts(packet, p + 14, ReadPts(packet, p + 14) + shift);
}
if ((packet[3] & 32) != 0 && packet[4] >= 7 && (packet[5] & 16) != 0)
{
var clock = (((long)packet[6] << 25) | ((long)packet[7] << 17) | ((long)packet[8] << 9) | ((long)packet[9] << 1) | ((long)packet[10] >> 7));
clock = (clock + offset) & ClockMask;
packet[6] = (byte)(clock >> 25); packet[7] = (byte)(clock >> 17); packet[8] = (byte)(clock >> 9); packet[9] = (byte)(clock >> 1);
packet[10] = (byte)((packet[10] & 127L) | ((clock & 1) << 7));
}
output.Write(packet);
}
return output.ToArray();
}
}
+30
View File
@@ -176,3 +176,33 @@ Moduł webowy wtyczki czeka do 45 s na start obrazu (25 s metadane + 20 s urucho
Aktualny `deploy-plugin-only.py` wymaga `--maintenance-approved --dll PATH --expected-sha256 HASH`. Nie czyta bazy ani kluczy API. Po backupie sprawdza publiczny health, świeży log załadowania dokładnej wersji, manifest i hash DLL; w razie błędu przywraca poprzednią wtyczkę. Pierwsza próba zbyt wcześnie odrzuciła odpowiedź podczas rozruchu i została cofnięta na 7.0.7. Kontrola gotowości została poprawiona i przetestowana; ponowne wdrożenie przeszło. Backup DLL/manifestu i poprzedniego JS: `/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261013T070224088507Z`.
Końcowo potwierdzono aktywną usługę 12.2.0, manifest Active7.0.8, log załadowania nowej DLL, hash serwowanego JS, HTTP200 strony dodatku oraz 0 strumieni, 0 FFmpeg i 0 tymczasowych próbek. Hashe ustawień serwera, wrapperów FFmpeg i plików index/config Web pozostały bez zmian. Raport: `deployment-7.0.8-20261013.json`. Po wdrożeniu nie wykonano jeszcze odtwarzania na zalogowanym kliencie: przeglądarka testowa wymaga SSO, a test Android TV czeka na użytkownika. Ostatnia wcześniejsza próba Pulsu o 08:42 CEST nadal nie rozpoznała AV po 20 s; zmiana limitów nie jest dowodem naprawienia odkodowania w odbiorniku.
## Kandydat 7.0.15 — oryginalny obraz i dłuższy start (25.10.2026)
Nie wdrożono; produkcja nadal 7.0.11. Tor plansz kopiuje obraz i audio (`-c copy`),
bez wymuszania H264, 50 kl./s, Full HD, 8-bit lub stereo. Generuje wyłącznie
lokalne obrazy komunikatów. Dopasowana plansza jest dostępna dla rozpoznanego
progresywnego H264/yuv420p, AAC LC stereo 48 kHz i zgodnych granic AV. Inne formaty
(np. HEVC/HDR, przeplot, 5.1 i 23.976/59.94) zachowują natywny transport bez planszy;
nie są konwertowane do jej parametrów. Po takim fallbacku klient ma własne limity
buforowania. Zegar kopii obsługuje klatki B, powrót po planszy i zawinięcie PTS/DTS.
Napisy DVB używają rzeczywistych wymiarów kanału podczas czyszczenia planszy.
Analiza startowa: domyślnie 90 s (25–180), z ponawianiem świeżych próbek do 24 MiB
na wspólnym relay. Przerwa danych: domyślnie 30 s (8–60). Cztery równoczesne
żądania współdzielą próbę, a anulowanie ostatniego kończy ją od razu. Web dostaje
budżet analizy +45 s na uruchomienie; nie zmieniamy limitów natywnego Android TV.
205 kontroli .NET, 27 kontroli Pythona i test-web-bridge.cjs PASS; build bez
ostrzeżeń. Rzeczywisty ffprobe rozpoznał źródło z pierwszymi danymi po 35 s po
około 40 s, przy czterech żądaniach i jednym połączeniu. Próby pakietów potwierdziły
oryginalny H264 60, H264 23.976, 4K HEVC 10-bit HDR10/PQ/BT2020 59.94 z AC3 5.1
oraz H264 1080i25. Próba wielu języków i napisów sprawdzała też faktyczne piksele.
To syntetyczny harness; fizycznego Android TV na 7.0.15 jeszcze nie sprawdzono.
Stary pakiet 7.0.14 został wycofany ze względu na normalizację obrazu.
Instalator 7.0.15 ma --check-only, sumy DLL/JS i rollback własnych plików wtyczki.
Wdrożenie wymaga osobno zatwierdzonego restartu. test-web-player.cjs jest wyłączony:
stary test produkcyjny odczytywał klucz API z SQLite. Regresja offline to
`node test-web-bridge.cjs`; test klienta wymaga jawnie zalogowanej sesji.
+41
View File
@@ -94,3 +94,44 @@ Przed natywnym odświeżeniem zapisuje własny checkpoint w `data/decoder-integr
Wdrożenie17:35:43–17:36:05UTC: świeży loader7.0.11, DLL SHA256 `10107a4e9c81c972f9d98b664ec9f14208f5e1dd95df769760f3540955089ade`, ustawienia serwera i wrappery bez zmian, nagranie dekodera zachowane. NativeRefreshGuide zakończyło7min8s importu o17:43:14UTC, po czym zadanie wtyczki zakończyło się statusemCompleted. Checkpoint odzyskano i wyczyszczono po powodzeniu, zapisano fingerprint oraz czas ostatniej synchronizacji. Przeniesiono4zmienione identyfikatory. Natywna biblioteka ma214unikalnych kanałów (216pozycji bukietu), nowe referencje Puls/Puls2/WP/TVN24 są obecne. Zadanie nie ma nadpisanych wyzwalaczy, więc korzysta ze sprawdzonego domyślnego harmonogramu6h/start z wdrożonej DLL.
121 kontroli backendu PASS; build bez ostrzeżeń i błędów. Produkcyjne sprawdzenie: `channel-sync-verification-20261023.json`; odczyt `BaseItems` oraz samychID `Users` służy wyłącznie kontroli metadanych, bez pól uwierzytelniania. 26historycznych plików preferencji nie ma już właścicieli w19bieżących kontach; zachowano je, nie przepisując danych usuniętych użytkowników. Zawierają32odwołania do4starych kanałów; nie są to preferencje bieżących kont. Nie jest to pełny test wszystkich klientów ani naprawa przerywanego braku AV Polsatu Sport1. Użytkownik potwierdził odtwarzanie Polsatu SportExtra1 po ponownym uruchomieniu tego samego kanału; pierwsza próba nie rozpoznała AV po22s, kolejna otworzyła się po5,35s bez zmiany referencji i konfiguracji dekodera.
### 7.0.12 — plansze FFmpeg, podtrzymanie streamu i język użytkownika
Nowa opcja `EnableContinuitySlates` utrzymuje otwartą listę HLS podczas oczekiwania na dekoder i po utracie danych. Wtyczka ponawia odbiór co 3–10 sekund, bez kończenia odtwarzania po stronie widza. Po odzyskaniu użytecznego AV wraca do kanału. Granica segmentu 2,24 s odpowiada 112 klatkom przy 50 Hz oraz 105 ramkom AAC 48 kHz. Znaczniki PTS/DTS/PCR są przenoszone na wspólny zegar, a reset liczników TS jest sygnalizowany w pakietach. Początkowy segment enkodera jest pomijany.
Jeden producent FFmpeg odbiera dany kanał przez istniejący współdzielony relay. Widzowie mają osobne identyfikatory i języki plansz, ale korzystają z tych samych segmentów obrazu kanału. Playlisty nigdy nie zawierają ENDLIST podczas awarii. Pamięć segmentów jest ograniczona; zatrzymanie ostatniego widza lub brak żądań HTTP przez 60 s zatrzymuje producenta. Dotychczasowy relay może jeszcze zachować fizyczne połączenie przez 15 s. Limit to cztery kanały i 64 widzów jednocześnie. Nagrania w tle nie korzystają z plansz.
Wspólny katalog tekstów zawiera polski, angielski, niemiecki, francuski, hiszpański i ukraiński. Web przekazuje język aktualnego interfejsu z `<html lang>`; wtyczka zapamiętuje go krótko dla konkretnego użytkownika i urządzenia. Następne źródła to zapisana preferencja `usersettings/emby/language` bieżącego profilu (jeśli istnieje), Accept-Language klienta i `ServerConfiguration.UICulture`, a ostatecznie angielski. W zbadanej instalacji język web był zapisany lokalnie, a serwer miał UICulture=pl; nie było serwerowych preferencji language. Nie używamy języka metadanych, audio ani napisów jako języka interfejsu. Android TV bez przekazanego języka i bez preferencji profilu korzysta z języka serwera.
Moduł web pokazuje stan do pierwszej odtworzonej klatki, umożliwia anulowanie i nie zabija już działającego streamu po upływie pierwotnych 45 s podczas późniejszego buforowania. Opcjonalne `ShowNativeStartupMessages` korzysta z natywnego DisplayMessage wyłącznie na żądającym urządzeniu Android TV; jest niezależne od obrazu FFmpeg. Dla trybu plansz można je wyłączyć, aby nie dublować komunikatów.
**Koszt i zakres:** tryb jest domyślnie wyłączony w nowych konfiguracjach. Włączenie normalizuje kanał do H264 1920×1080 50 kl./s, CRF20 z maksimum 12 Mb/s, AAC 48 kHz stereo. Nie zachowuje 4K, HDR, wielokanałowego audio, dodatkowych ścieżek ani napisów. Wymaga programowego kodowania; pomiary dotyczą konkretnego testu. Wyłączenie przywraca dotychczasowy odbiór. Nie zmienia konfiguracji Jellyfina, wrapperów, profili dekodera ani OSCam.
Weryfikacja: `Tests` obejmuje izolację użytkowników/urządzeń, uprawnienia, migrację kanałów i wcześniejsze mechanizmy streamu, katalog języków, zegar TS i ograniczoną pamięć przy symulowanej sześciogodzinnej awarii. `test-web-bridge.cjs` sprawdza web bez kont produkcyjnych. `experiments/run-continuity-e2e.py` uruchamia izolowany harness z prawdziwym FFmpeg, dwoma językami i sterowanym źródłem syntetycznym; opcja `--long` utrzymuje awarię 75 s, sprawdza odzyskanie obrazu, rygorystyczny odczyt AV i wszystkie 12 plansz. Nie jest to test fizycznego Android TV ani produkcyjnej sesji Jellyfina. Należy jawnie odróżnić przygotowany kandydat od wersji aktywnej na serwerze.
### 7.0.13 — zachowanie rozdzielczości źródła
Na żądanie użytkownika usunięto sztywny limit Full HD z domyślnego trybu plansz. `PreserveSourceResolution` domyślnie wynosi true: filtr producenta nie skaluje ani nie zmienia SAR kanału. Rozdzielczość początkowej planszy nie jest już wpisywana jako rozmiar źródła do natywnej negocjacji Jellyfina. Profile urządzeń mogą nadal ograniczać wyjście zgodnie z możliwościami klienta i jego ustawieniami jakości. Nie ustawiono sztywnego limitu szerokości/wysokości producenta; maksymalny segment zwiększono do 32 MiB, a limit przepływności kodera do 48 Mb/s. Kodek i dźwięk pozostają H264 8-bit, 50 kl./s i AAC stereo — ta zmiana nie dodaje zachowania HDR, oryginalnego kodeka, ścieżek audio ani napisów.
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/`.
+38
View File
@@ -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<bool,string> 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[1].Codec=="ac3"&&layout.Output[1].Channels==6&&layout.Output[2].Codec=="aac", "every audio language retains its original codec and surround channels");
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&&section.Contains((byte)89)&&section.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");
}
}
+114
View File
@@ -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<double>();
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<double>();
for(var at=0;at+96000<=bytes.Length;at+=96000)
{
var peak=0;crossings=0;
for(var i=at;i<at+96000;i+=2){var value=BitConverter.ToInt16(bytes,i);peak=Math.Max(peak,Math.Abs((int)value));if(i>at&&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<string> 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<string> 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);}
}
+124
View File
@@ -0,0 +1,124 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
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;
/// <summary>Real FFmpeg HTTP/HLS failover; synthetic source only, no decoder or Jellyfin configuration.</summary>
public static class ContinuityE2E
{
private static string ffmpeg;
private static volatile bool enabled;
private static int connections, active, maximum;
public static async Task Run(string encoder, string output, bool longTest = false, bool preserveResolution = false)
{
ffmpeg = encoder; output = Path.GetFullPath(output);
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 streams = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), NullLogger.Instance, preserveResolution: preserveResolution);
using var relay = new LiveStreamRelay(new System.Net.Http.HttpClient(), port, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5));
builder.Services.AddSingleton(relay); builder.Services.AddSingleton(streams); builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly);
await using var app = builder.Build(); app.MapControllers(); app.MapGet("/fixture.ts", Fixture); await app.StartAsync();
Process first = null, second = null;
try
{
var source = relay.Register($"http://127.0.0.1:{port}/fixture.ts");
var tracks = LosslessSlateTests.Tracks(1280, 720, 25);
var pl = await streams.Open(source, "pl", CancellationToken.None, tracks);
var en = await streams.Open(source, "en", CancellationToken.None, tracks);
Require(JsonSerializer.Serialize(streams.Status()).Contains("\"Sources\":1"), "two languages share one source worker");
Require(!streams.Segment(pl.Id, 0).SequenceEqual(streams.Segment(en.Id, 0)), "same timeline uses different language slates");
await File.WriteAllBytesAsync(Path.Combine(output, "pl-slate.ts"), streams.Segment(pl.Id, 0));
await File.WriteAllBytesAsync(Path.Combine(output, "en-slate.ts"), streams.Segment(en.Id, 0));
var denied = new ContinuityController(streams) { ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = new DefaultHttpContext() } };
denied.HttpContext.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.1");
Require(denied.Playlist(pl.Id) is Microsoft.AspNetCore.Mvc.StatusCodeResult { StatusCode: 403 }, "private continuity endpoint denies remote access");
denied.HttpContext.Connection.RemoteIpAddress = IPAddress.Loopback; denied.Request.Headers["X-Forwarded-For"] = "192.0.2.1";
Require(denied.Playlist(pl.Id) is Microsoft.AspNetCore.Mvc.StatusCodeResult { StatusCode: 403 }, "private continuity endpoint denies forwarded requests");
first = Start(new[] { "-xerror", "-i", pl.Path, "-t", longTest ? "150" : "65", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy", Path.Combine(output, "pl.mkv") }, output, "pl-client");
second = Start(new[] { "-xerror", "-i", en.Path, "-t", longTest ? "150" : "65", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy", Path.Combine(output, "en.mkv") }, output, "en-client");
await Task.Delay(8000); enabled = true; Console.WriteLine("E2E source enabled after initial failure");
await Task.Delay(20000);
var beforeLoss = JsonSerializer.SerializeToElement(streams.Status()).GetProperty("LiveSegmentsProduced").GetInt64();
Console.WriteLine("E2E status before loss " + JsonSerializer.Serialize(streams.Status()));
Require(beforeLoss > 0, "real synthetic video reached clients before induced loss"); enabled = false; Console.WriteLine("E2E source disconnected");
await Task.Delay(longTest ? 75000 : 15000); enabled = true; Console.WriteLine("E2E source recovered");
await Task.WhenAll(first.WaitForExitAsync(), second.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(65));
Require(first.ExitCode == 0 && second.ExitCode == 0, "both HLS clients survive waiting, source loss and recovery without reopening");
foreach (var language in new[] { "pl", "en" })
{
using var decode = Start(new[] { "-xerror", "-i", Path.Combine(output, language + ".mkv"), "-map", "0:v:0", "-map", "0:a:0", "-f", "null", "-" }, output, language + "-decode");
await decode.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(40)); Require(decode.ExitCode == 0, "strict decode across all switches: " + language);
foreach (var (name, secondAt) in new[] { ("waiting", 1), ("live", longTest ? 39 : 22), ("retrying", longTest ? 70 : 39), ("recovered", longTest ? 140 : 60) })
{
using var image = Start(new[] { "-ss", secondAt.ToString(), "-i", Path.Combine(output, language + ".mkv"), "-frames:v", "1", "-update", "1", Path.Combine(output, language + "-" + name + ".png") }, output, language + "-" + name);
await image.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); Require(image.ExitCode == 0, "frame extraction: " + language + " " + name);
}
}
Console.WriteLine("E2E status after recovery "+JsonSerializer.Serialize(streams.Status()));
Require(JsonSerializer.SerializeToElement(streams.Status()).GetProperty("LiveSegmentsProduced").GetInt64() > beforeLoss, "new live segments arrive after recovery");
streams.Close(pl.Id); Require(JsonSerializer.Serialize(streams.Status()).Contains("\"Viewers\":1"), "one viewer stopping leaves the other active");
streams.Close(en.Id); await Task.Delay(2000);
Require(active == 0 && maximum <= 1 && connections >= 2, "one upstream at a time, retries and final source cleanup");
foreach (var language in new[] { "pl", "en", "de", "fr", "es", "uk" })
foreach (var phase in new[] { "waiting", "retrying" })
{
var path=Path.Combine(output,language+"-"+phase+"-slate.ts");
await File.WriteAllBytesAsync(path,await streams.SlatePreview(language,phase,CancellationToken.None));
using var image=Start(new[]{"-i",path,"-frames:v","1","-update","1",Path.Combine(output,language+"-"+phase+"-slate.png")},output,language+"-"+phase+"-slate");
await image.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));Require(image.ExitCode==0,"localized FFmpeg slate: "+language+" "+phase);
}
var report = new { Passed = true, SyntheticOnly = true, ReceiverConnectionsOpened = 0, ViewerLanguages = new[] { "pl", "en" }, SourceConnections = connections, FailureSeconds = longTest ? 75 : 15,
MaximumConcurrentSourceConnections = maximum, RemainingConnections = active, WaitingLossRecoverySamePlayer = true,
StrictVideoAudioDecode = true, PreserveSourceResolution = preserveResolution, Width = 1280, Height = 720, FrameRate = 25, OriginalAvCopied = 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 (first is { HasExited: false }) first.Kill(true); if (second is { HasExited: false }) second.Kill(true);
first?.Dispose(); second?.Dispose(); streams.Dispose(); await app.StopAsync();
}
}
private static void Require(bool condition, string message) { if (!condition) throw new Exception(message); Console.WriteLine("PASS " + message); }
private static Process Start(string[] args, string output, string name)
{
var info = new ProcessStartInfo(ffmpeg) { 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(ContinuityE2E)) 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 current = Interlocked.Increment(ref active); maximum = Math.Max(maximum, current);
var info = new ProcessStartInfo(ffmpeg) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=25", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-c:v", "libx264", "-threads", "1", "-preset", "ultrafast", "-g", "25", "-c:a", "aac", "-ac", "2", "-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[64 * 1024];
while (enabled && !context.RequestAborted.IsCancellationRequested)
{
var count = await process.StandardOutput.BaseStream.ReadAsync(buffer, context.RequestAborted); if (count == 0) break;
await context.Response.Body.WriteAsync(buffer.AsMemory(0, count), 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(); }
}
}
+69
View File
@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
public static class ContinuityTests
{
public static void Run(Action<bool, string> check)
{
check(StartupMessages.ResolveLanguage("de-DE", "pl", "fr") == "de", "explicit Jellyfin UI language wins over HTTP and server language");
check(StartupMessages.ResolveLanguage(null, "en;q=0,uk-UA;q=0.8,pl;q=0.6", "de") == "uk", "language matching honours regional locale, quality and q=0");
check(StartupMessages.ResolveLanguage(null, null, "pl-PL") == "pl", "missing client locale uses Jellyfin server UI culture");
check(StartupMessages.ResolveLanguage("../../secret", "xx;q=1,de;q=oops", "xx") == "en", "unsupported and malformed locales use safe English fallback");
var cfg = DispatchProxy.Create<IServerConfigurationManager, FeedbackConfig>();
var prefs = DispatchProxy.Create<IDisplayPreferencesManager, FeedbackPreferences>();
var proxy = (FeedbackPreferences)(object)prefs; var user = Guid.NewGuid(); proxy.User = user;
var messages = new StartupMessages(cfg, prefs);
check(messages.Resolve(user, null, null) == "pl", "empty server profile uses actual server UI setting");
proxy.Language = "fr-FR";
check(messages.Resolve(user, null, "de") == "fr" && proxy.LastUser == user, "stored interface language is read only for the authenticated profile");
check(messages.Resolve(user, "uk", "de") == "uk", "current device UI choice overrides stored profile language");
messages.RememberClientLanguage(user, "web-a", "es");
check(messages.ClientLanguage(user, "web-a") == "es" && messages.ClientLanguage(user, "tv-a") == null && messages.ClientLanguage(Guid.NewGuid(), "web-a") == null,
"UI language handoff isolates users and devices");
var keys = messages.Texts("en").Keys.Order().ToArray();
foreach (var locale in new[] { "pl", "en", "de", "fr", "es", "uk" })
check(messages.Texts(locale).Keys.Order().SequenceEqual(keys) && messages.Text(locale, "slate_retrying").Contains('\n'), "complete shared text and FFmpeg slate catalogue: " + locale);
check(Math.Abs(ContinuityTimeline.SegmentSeconds * 50 - 112) < 0.00001 && Math.Abs(ContinuityTimeline.SegmentSeconds * 48000 / 1024 - 105) < 0.00001,
"segment boundary aligns whole video frames and whole AAC frames");
var packet = new byte[188]; packet[0]=71;packet[1]=65;packet[2]=0;packet[3]=16;packet[6]=1;packet[7]=224;packet[10]=128;packet[11]=128;packet[12]=5;
packet[13]=33;packet[15]=1;packet[17]=1;
var rebased=TransportStreamClock.Rebase(packet,(1L<<33)+90000);
var p=201;long pts=((long)(rebased[p]&14)<<29)|((long)rebased[p+1]<<22)|((long)(rebased[p+2]&254)<<14)|((long)rebased[p+3]<<7)|((long)rebased[p+4]>>1);
check(pts==90000&&rebased.Length==376&&rebased[5]==128&&packet[13]==33,"TS rebasing signals continuity reset, wraps 33-bit PTS and preserves cached input");
var now = DateTime.UtcNow; var timeline = new ContinuityTimeline(now);
check(!timeline.Playlist().Contains("ENDLIST") && timeline.Find(0).Slate == "waiting", "an immediately playable slate precedes decoder readiness");
timeline.Live(new byte[] { 1 }, "first", 2, now.AddSeconds(4));
check(timeline.Find(2).Discontinuity && timeline.Find(2).Video[0] == 1, "slate to real video marks an HLS discontinuity");
timeline.Tick(now.AddSeconds(11), true);
check(timeline.Find(3).Slate == "retrying" && !timeline.Playlist().Contains("ENDLIST"), "input loss keeps the same playlist alive with a retry slate");
timeline.Live(new byte[] { 2 }, "second", 2, now.AddSeconds(15));
check(timeline.Find(4).Discontinuity && timeline.Find(4).Video[0] == 2, "recovered producer returns to live video in the same stream");
for (var i = 0; i < 10000; i++) timeline.Tick(now.AddSeconds(20 + i * ContinuityTimeline.SegmentSeconds), true);
check(timeline.Find(0) == null && !timeline.Playlist().Contains("ENDLIST") && timeline.Find(10004) != null, "six-hour failures keep a bounded monotonic live timeline");
}
}
public class FeedbackConfig : DispatchProxy
{
protected override object Invoke(MethodInfo method, object[] args) => method.Name == "get_Configuration" ? new ServerConfiguration { UICulture = "pl" } : null;
}
public class FeedbackPreferences : DispatchProxy
{
public Guid User, LastUser;
public string Language;
protected override object Invoke(MethodInfo method, object[] args)
{
if (method.Name != "ListCustomItemDisplayPreferences") throw new Exception("Unexpected preference mutation");
LastUser = (Guid)args[0]; if (LastUser != User) throw new Exception("Wrong profile");
return Language == null ? new Dictionary<string, string>() : new Dictionary<string, string> { ["language"] = Language };
}
}
+123
View File
@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
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;
/// <summary>Compare compressed video packet hashes through the real continuity transport.</summary>
public static class LosslessE2E
{
private static string encoder, output, fixture;
private static int active, maximum;
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 test 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 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("/fixture.ts",Fixture);await app.StartAsync();
var reports=new List<object>();
try
{
foreach(var profile in new[]{"h264-60","h264-23976","hevc-hdr-5994","h264-interlaced"})
{
fixture=Path.Combine(output,profile+"-input.ts");
var hdr=profile.StartsWith("hevc");var interlaced=profile.EndsWith("interlaced");
var rate=hdr?"60000/1001":profile=="h264-23976"?"24000/1001":interlaced?"25":"60";
var size=hdr?"3840x2160":interlaced?"1920x1080":"640x360";
var args=new List<string>{"-f","lavfi","-i",$"{(hdr?"color=c=0x085f21:":"testsrc2=")}size={size}:rate={rate}","-f","lavfi","-i","sine=frequency=440:sample_rate=48000","-t","8","-vf",hdr?"format=yuv420p10le,setsar=1":interlaced?"setsar=1,setfield=tff":"setsar=1","-c:v",hdr?"libx265":"libx264","-threads","1","-preset","ultrafast","-g",hdr?"60":interlaced?"25":"24","-c:a",hdr?"ac3":"aac","-ac",hdr?"6":"2"};
if(hdr)args.AddRange(new[]{"-x265-params","pools=1:frame-threads=1:log-level=error:colorprim=9:transfer=16:colormatrix=9:hdr10=1:master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1):max-cll=1000,400","-color_trc","smpte2084","-color_primaries","bt2020","-colorspace","bt2020nc"});
if(interlaced)args.AddRange(new[]{"-flags","+ilme+ildct","-x264-params","tff=1"});
args.AddRange(new[]{"-f","mpegts",fixture});
using(var make=Start(args,profile+"-fixture")){await make.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(90));Require(make.ExitCode==0,"generate "+profile);}
var metadata=LiveStreamProbe.Parse(await Probe(fixture));var tracks=new BroadcastTracks(metadata,true,false);
if(hdr)Require(metadata[0].ColorTransfer=="smpte2084" && metadata[0].ColorPrimaries=="bt2020", "fixture carries real HDR10 PQ/BT2020 signaling");
var compatible=LosslessSlateProfile.Create(tracks)!=null;
Require(compatible==(profile=="h264-60"),"safe slate compatibility: "+profile);
var sourceHashes=(await Hashes(fixture)).ToHashSet();
var media=await streams.Open(relay.Register($"http://127.0.0.1:{port}/fixture.ts?profile={profile}"),"en",CancellationToken.None,tracks);
Require(media.MediaStreams[0].Codec==metadata[0].Codec&&media.MediaStreams[0].AverageFrameRate==metadata[0].AverageFrameRate
&&media.MediaStreams[0].IsInterlaced==metadata[0].IsInterlaced&&media.MediaStreams[0].PixelFormat==metadata[0].PixelFormat
&&media.MediaStreams[0].ColorTransfer==metadata[0].ColorTransfer&&media.MediaStreams[0].Width==metadata[0].Width
&&media.MediaStreams[1].Channels==metadata[1].Channels,"native source metadata unchanged: "+profile);
if(compatible)
{
var waiting=Path.Combine(output,profile+"-waiting.ts");await File.WriteAllBytesAsync(waiting,streams.Segment(media.Id,0));
var waitingHashes=await Hashes(waiting);
Require(waitingHashes.Length>10 && waitingHashes.All(h=>!sourceHashes.Contains(h)),"real generated waiting picture precedes unchanged source: "+profile);
}
var recording=Path.Combine(output,profile+"-output.ts");
using(var client=Start(new[]{"-xerror","-i",media.Path,"-t",compatible?"18":"6","-map","0:v:0","-map","0:a","-c","copy","-f","mpegts",recording},profile+"-client"))
{
try{await client.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(65));Require(client.ExitCode==0,"client copies "+profile);}
finally{if(!client.HasExited)client.Kill(true);streams.Close(media.Id);}
}
await Task.Delay(1500);
var inputHashes=await Hashes(fixture);var actual=await Hashes(recording);
var expected=inputHashes.ToHashSet();var matching=actual.Count(expected.Contains);
Require(matching>10,"original compressed video packets reach client unchanged: "+profile);
if(compatible)Require(actual.Count(h=>!expected.Contains(h))>10,"recording contains both status picture and original video: "+profile);
if(!compatible)Require(actual.All(expected.Contains),"every video packet is an original packet in native fallback: "+profile);
using(var decode=Start(new[]{"-xerror","-i",recording,"-map","0:v:0","-map","0:a","-f","null","-"},profile+"-decode"))
{await decode.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60));Require(decode.ExitCode==0,"strict decode "+profile);}
var decodedMetadata=LiveStreamProbe.Parse(await Probe(recording));
Require(decodedMetadata[0].Width==metadata[0].Width && decodedMetadata[0].Height==metadata[0].Height
&& decodedMetadata[0].PixelFormat==metadata[0].PixelFormat && decodedMetadata[0].IsInterlaced==metadata[0].IsInterlaced
&& decodedMetadata[0].ColorTransfer==metadata[0].ColorTransfer && decodedMetadata[0].ColorPrimaries==metadata[0].ColorPrimaries,
"actual output retains dimensions, bit depth, interlace and HDR signaling: "+profile);
reports.Add(new{Profile=profile,SlatesInserted=compatible,Width=metadata[0].Width,Height=metadata[0].Height,FrameRate=metadata[0].AverageFrameRate,
Codec=metadata[0].Codec,PixelFormat=metadata[0].PixelFormat,Transfer=metadata[0].ColorTransfer,Primaries=metadata[0].ColorPrimaries,OutputTransfer=decodedMetadata[0].ColorTransfer,Interlaced=metadata[0].IsInterlaced,AudioChannels=metadata[1].Channels,
OriginalCompressedVideoPackets=matching,OutputVideoPackets=actual.Length,AllOutputVideoPacketsOriginal=!compatible&&actual.All(expected.Contains),StrictDecode=true});
}
Require(active==0&&maximum==1,"release source; at most one upstream");
var result=new{Passed=true,SyntheticOnly=true,ReceiverConnectionsOpened=0,PhysicalAndroidTvTested=false,ActualJellyfinPlayerTested=false,MaximumConcurrentSourceConnections=maximum,RemainingConnections=active,Reports=reports};
await File.WriteAllTextAsync(Path.Combine(output,"result.json"),JsonSerializer.Serialize(result,new JsonSerializerOptions{WriteIndented=true}));Console.WriteLine(JsonSerializer.Serialize(result));
}
finally{streams.Dispose();await app.StopAsync();}
}
private static async Task<string> Probe(string path)
{
var info=new ProcessStartInfo(Path.Combine(Path.GetDirectoryName(encoder),"ffprobe")){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
foreach(var a in new[]{"-v","error","-show_streams","-of","json",path})info.ArgumentList.Add(a);
using var p=Process.Start(info);var text=p.StandardOutput.ReadToEndAsync();var error=p.StandardError.ReadToEndAsync();await p.WaitForExitAsync();Require(p.ExitCode==0,"probe fixture");await error;return await text;
}
private static async Task<string[]> Hashes(string path)
{
var info=new ProcessStartInfo(Path.Combine(Path.GetDirectoryName(encoder),"ffprobe")){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
foreach(var a in new[]{"-v","error","-select_streams","v:0","-show_packets","-show_data_hash","sha256","-show_entries","packet=data_hash","-of","csv=p=0",path})info.ArgumentList.Add(a);
using var p=Process.Start(info);var text=p.StandardOutput.ReadToEndAsync();var error=p.StandardError.ReadToEndAsync();await p.WaitForExitAsync();Require(p.ExitCode==0,"hash encoded packets");await error;
return (await text).Split('\n').Select(l=>l.Split(',').FirstOrDefault(x=>x.StartsWith("SHA256:"))).Where(l=>l!=null).Select(l=>l.Trim()).ToArray();
}
private static Process Start(IEnumerable<string> args,string name)
{
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardError=true,RedirectStandardOutput=true};
foreach(var a in new[]{"-hide_banner","-loglevel","warning","-nostdin","-filter_threads","1","-threads","1"}.Concat(args))info.ArgumentList.Add(a);
var p=Process.Start(info);p.ErrorDataReceived+=(_,e)=>{if(e.Data!=null)lock(typeof(LosslessE2E))File.AppendAllText(Path.Combine(output,name+".log"),e.Data+"\n");};p.BeginErrorReadLine();return p;
}
private static async Task Fixture(HttpContext context)
{
maximum=Math.Max(maximum,Interlocked.Increment(ref active));
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
foreach(var a in new[]{"-hide_banner","-loglevel","error","-nostdin","-re","-stream_loop","-1","-i",fixture,"-map","0","-c","copy","-f","mpegts","pipe:1"})info.ArgumentList.Add(a);
using var p=Process.Start(info);p.ErrorDataReceived+=(_,_)=>{};p.BeginErrorReadLine();context.Response.ContentType="video/mp2t";
try{await p.StandardOutput.BaseStream.CopyToAsync(context.Response.Body,context.RequestAborted);}
catch(OperationCanceledException){}catch(IOException){}
finally{if(!p.HasExited)p.Kill(true);await p.WaitForExitAsync();Interlocked.Decrement(ref active);}
}
private static void Require(bool value,string message){if(!value)throw new Exception(message);Console.WriteLine("PASS "+message);}
}
+63
View File
@@ -0,0 +1,63 @@
using System;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Model.Entities;
public static class LosslessSlateTests
{
public static BroadcastTracks Tracks(int width, int height, float rate) => new(new MediaStream[] {
new BroadcastMediaStream { Type=MediaStreamType.Video, Index=0, Codec="h264", Width=width, Height=height,
AverageFrameRate=rate, RealFrameRate=rate, Profile="Constrained Baseline", PixelFormat="yuv420p", SampleAspectRatio="1:1" },
new MediaStream { Type=MediaStreamType.Audio, Index=1, Codec="aac", Profile="LC", Channels=2, SampleRate=48000 }
});
public static void Run(Action<bool,string> check)
{
foreach (var rate in new[] { 24f, 25f, 30f, 50f, 60f, 120f })
{
var tracks=Tracks(3840,2160,rate);
var profile=LosslessSlateProfile.Create(tracks);
check(profile?.Rate==rate && profile.Width==3840 && profile.Height==2160 && tracks.Output[0].AverageFrameRate==rate,
$"status picture matches source rate {rate} and 4K size without changing channel metadata");
}
var original=Tracks(3840,2160,60000f/1001);
var video=(BroadcastMediaStream)original.Input[0];video.FrameRateRational="60000/1001";
check(LosslessSlateProfile.Create(original)==null && original.Output[0].AverageFrameRate==60000f/1001, "fractional cadence is preserved by native fallback instead of rounded for a status image");
var key=new BroadcastTracks(original.Input).Key;video.AverageFrameRate=24;
check(new BroadcastTracks(original.Input).Key!=key,"source rate changes invalidate cached status images");
video.Codec="hevc";video.PixelFormat="yuv420p10le";video.ColorTransfer="smpte2084";video.ColorPrimaries="bt2020";video.Level=153;
original.Input[1].Codec="ac3";original.Input[1].Channels=6;
var hdr=new BroadcastTracks(original.Input,true,false);
check(!hdr.Normalize && hdr.Output[0].Codec=="hevc" && hdr.Output[0].PixelFormat=="yuv420p10le" && hdr.Output[0].ColorTransfer=="smpte2084"
&& hdr.Output[0].Level==153 && hdr.Output[0].Width==3840 && hdr.Output[1].Codec=="ac3" && hdr.Output[1].Channels==6,
"legacy slate/Full HD options cannot normalize 4K HDR10 or surround audio");
check(LosslessSlateProfile.Create(hdr)==null,"HDR/HEVC keeps original transport instead of converting the channel to fit a status picture");
var interlaced=Tracks(1920,1080,25);interlaced.Input[0].IsInterlaced=true;
check(LosslessSlateProfile.Create(interlaced)==null && new BroadcastTracks(interlaced.Input).Output[0].IsInterlaced,
"interlaced broadcast is preserved without plugin deinterlacing");
check(LosslessSlateProfile.Create(BroadcastTracks.Fallback())==null,"unknown cold-start metadata cannot force a guessed status format");
var unknown=Tracks(1280,720,60);((BroadcastMediaStream)unknown.Input[0]).SampleAspectRatio=null;
check(LosslessSlateProfile.Create(unknown)==null,"unknown sample aspect ratio uses unmodified broadcast fallback");
var now=DateTime.UtcNow;var timeline=new ContinuityTimeline(now,false);
var first=Packet(12000,9000);var second=Packet(195000,189000);
timeline.Live(first,"producer",2,now);timeline.Live(second,"producer",2,now.AddSeconds(2));
long Decode(long sequence,byte[] packet)
{
var segment=timeline.Find(sequence);
return TransportStreamClock.ClockBounds(TransportStreamClock.Rebase(packet,segment.Start90k,false,segment.ClockOffset90k)).Value.Decode;
}
check(Decode(1,second)-Decode(0,first)==180000,"copied B-frames retain their decode-clock interval despite different first presentation timestamps");
timeline.Tick(now.AddSeconds(9),true);var retry=timeline.Find(2);
var recovered=Packet(372000,369000);timeline.Live(recovered,"producer",2,now.AddSeconds(10));
check(Decode(3,recovered)>=retry.Start90k+(long)(retry.Duration*90000),"short source stall followed by the same producer cannot rewind behind the inserted status picture");
timeline=new ContinuityTimeline(now,false);first=Packet((1L<<33)-6000,(1L<<33)-9000);second=Packet(93000,90000);
timeline.Live(first,"wrapped",1.1,now);timeline.Live(second,"wrapped",1.1,now.AddSeconds(1.1));
check(Decode(1,second)-Decode(0,first)==99000,"copied transport keeps a monotonic decode clock across the 33-bit timestamp wrap");
}
private static byte[] Packet(long pts,long dts)
{
var packet=new byte[188];packet[0]=71;packet[1]=65;packet[2]=0;packet[3]=16;
packet[6]=1;packet[7]=224;packet[10]=128;packet[11]=192;packet[12]=10;
void Stamp(int at,long time,int prefix)
{time&=(1L<<33)-1;packet[at]=(byte)((long)prefix|((time>>29)&14)|1);packet[at+1]=(byte)(time>>22);packet[at+2]=(byte)(1|((time>>14)&254));packet[at+3]=(byte)(time>>7);packet[at+4]=(byte)(1|(time<<1));}
Stamp(13,pts,48);Stamp(18,dts,16);return packet;
}
}
+41
View File
@@ -0,0 +1,41 @@
using System;
using System.Reflection;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging.Abstractions;
public static class NativeResolutionPolicyTests
{
public static void Run(Action<bool, string> check)
{
// Exercise the installed Jellyfin 12.2 negotiation code, including
// devices whose width/height conditions are optional rather than required.
foreach (var (width, height) in new[] { (720, 576), (1280, 720), (1920, 1080), (2560, 1440), (3840, 2160) })
foreach (var required in new[] { true, false })
{
var source = new MediaSourceInfo { Id = "test", Path = "http://127.0.0.1/test.m3u8", Protocol = MediaProtocol.Http,
Container = "hls", SupportsDirectPlay = false, SupportsDirectStream = true, SupportsProbing = false,
MediaStreams = new[] { new MediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", AverageFrameRate = 50, Profile = "Constrained Baseline" },
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Channels = 2, SampleRate = 48000, IsDefault = true } } };
var profile = new DeviceProfile { Name = "Resolution contract", MaxStreamingBitrate = 100000000,
DirectPlayProfiles = new[] { new DirectPlayProfile { Container = "hls,ts", Type = DlnaProfileType.Video, VideoCodec = "h264", AudioCodec = "aac" } },
TranscodingProfiles = new[] { new TranscodingProfile { Container = "ts", Type = DlnaProfileType.Video, VideoCodec = "h264", AudioCodec = "aac", Protocol = MediaStreamProtocol.hls, Context = EncodingContext.Streaming } },
CodecProfiles = new[] { new CodecProfile { Codec = "h264", Type = CodecType.Video, Conditions = new[] {
new ProfileCondition { Property = ProfileConditionValue.Width, Condition = ProfileConditionType.LessThanEqual, Value = width.ToString(), IsRequired = required },
new ProfileCondition { Property = ProfileConditionValue.Height, Condition = ProfileConditionType.LessThanEqual, Value = height.ToString(), IsRequired = required } } } } };
var support = DispatchProxy.Create<ITranscoderSupport, ResolutionTranscoder>();
var stream = new StreamBuilder(support, NullLogger.Instance).GetOptimalVideoStream(new MediaOptions {
ItemId = Guid.NewGuid(), DeviceId = "test", Profile = profile, MediaSources = new[] { source }, EnableDirectPlay = true,
EnableDirectStream = true, AllowVideoStreamCopy = true, AllowAudioStreamCopy = true, MaxBitrate = 100000000, Context = EncodingContext.Streaming });
check(stream.MaxWidth == width && stream.MaxHeight == height && source.MediaStreams[0].Width == null,
$"native Jellyfin negotiates {width}x{height} without a plugin Full HD cap; required={required}");
}
}
}
public class ResolutionTranscoder : DispatchProxy
{
protected override object Invoke(MethodInfo method, object[] args) => method.ReturnType == typeof(bool) ? true : method.ReturnType == typeof(int) ? 2 : null;
}
+70
View File
@@ -0,0 +1,70 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
public static class PatientProbeTests
{
public static async Task Run(Action<bool,string> check)
{
using var upstream = new RelayHandler();
using var relay = new LiveStreamRelay(new HttpClient(upstream),8096,TimeSpan.FromMilliseconds(200));
var url=relay.Register("http://receiver/patient");
using var local = new HttpClient(new LocalRelayHandler(relay));
var attempts=0;var snapshots=new System.Collections.Generic.List<string>();
var limits=new LiveStreamProbe.SamplingLimits(TimeSpan.FromMilliseconds(20),TimeSpan.FromMilliseconds(80),188*8,188*128);
const string good="""{"streams":[{"index":0,"codec_type":"video","codec_name":"h264","width":1920,"height":1080}]}""";
const string bad="""{"streams":[{"index":0,"codec_type":"video","codec_name":"h264","width":0,"height":0}]}""";
var probe=new LiveStreamProbe(local,(path,sid,extended,token)=>
{snapshots.Add(path);return Task.FromResult(++attempts>=3?good:bad);},limits,TimeSpan.FromSeconds(3),true,TimeSpan.FromMilliseconds(40));
var result=await Task.WhenAll(Enumerable.Range(0,4).Select(_=>probe.Get(url,CancellationToken.None)));
check(attempts==3 && result.All(x=>x[0].Width==1920),"patient startup retries fresh bounded sample windows shared by all four viewers");
check(upstream.Calls==1,"multiple metadata windows keep exactly one physical receiver connection");
check(snapshots.All(path=>!File.Exists(path)&&!File.Exists(path.Replace(".sample.ts",""))),"patient success cleans every sample and capture window");
await Task.Delay(350);
check(System.Text.Json.JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"),"patient startup releases receiver after final probe consumer");
var started=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var stopped=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var cancellable=new LiveStreamProbe(async (source,token)=>
{started.SetResult();try{await Task.Delay(10000,token);return good;}finally{stopped.SetResult();}},TimeSpan.FromSeconds(3));
using var first=new CancellationTokenSource();using var second=new CancellationTokenSource();
var a=cancellable.Get("cancel",first.Token);var b=cancellable.Get("cancel",second.Token);await started.Task;
first.Cancel();try{await a;}catch(OperationCanceledException){}
check(!stopped.Task.IsCompleted,"one viewer cancelling does not stop another viewer's patient startup");
second.Cancel();try{await b;}catch(OperationCanceledException){}
await stopped.Task.WaitAsync(TimeSpan.FromSeconds(1));
check(stopped.Task.IsCompleted,"last viewer cancellation stops producer immediately instead of waiting 90 seconds");
var bounded=new LiveStreamProbe(local,(path,sid,extended,token)=>Task.FromResult(bad),limits,TimeSpan.FromMilliseconds(250),true,TimeSpan.FromMilliseconds(20));
try { await bounded.Get(url,CancellationToken.None); throw new Exception("Expected bounded startup failure"); }
catch(InvalidOperationException error)
{ check(error.ToString().Contains("video_parameters_unavailable"),"patient deadline preserves the last input diagnosis instead of waiting forever"); }
}
private sealed class LocalRelayHandler(LiveStreamRelay relay) : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token)
{
var sub=await relay.Subscribe(request.RequestUri.Segments.Last(),token);
return new(HttpStatusCode.OK){Content=new StreamContent(new SubscriptionStream(sub))};
}
}
private sealed class SubscriptionStream(LiveStreamRelay.Subscription subscription) : Stream
{
private byte[] chunk;private int at;private bool disposed;
public override async ValueTask<int> ReadAsync(Memory<byte> buffer,CancellationToken token=default)
{
if(chunk==null||at==chunk.Length){chunk=await subscription.Reader.ReadAsync(token);at=0;}
var n=Math.Min(buffer.Length,chunk.Length-at);chunk.AsMemory(at,n).CopyTo(buffer);at+=n;return n;
}
protected override void Dispose(bool disposing){if(disposing&&!disposed){disposed=true;subscription.DisposeAsync().AsTask().GetAwaiter().GetResult();}base.Dispose(disposing);}
public override bool CanRead=>true;public override bool CanSeek=>false;public override bool CanWrite=>false;
public override long Length=>throw new NotSupportedException();public override long Position{get=>0;set=>throw new NotSupportedException();}
public override int Read(byte[] b,int o,int n)=>ReadAsync(b.AsMemory(o,n)).AsTask().GetAwaiter().GetResult();
public override void Flush(){}public override long Seek(long o,SeekOrigin s)=>throw new NotSupportedException();
public override void SetLength(long l)=>throw new NotSupportedException();public override void Write(byte[] b,int o,int n)=>throw new NotSupportedException();
}
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
public static class PatientStartupE2E
{
private static int connections, active, maximum;
public static async Task Run(string encoder, string folder)
{
folder=Path.GetFullPath(folder);if(Directory.Exists(folder))throw new Exception("Choose a new output directory");Directory.CreateDirectory(folder);
var file=Path.Combine(folder,"fixture.ts");
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardError=true};
foreach(var a in new[]{"-v","error","-nostdin","-filter_threads","1","-f","lavfi","-i","testsrc2=size=320x180:rate=25","-f","lavfi","-i","sine=sample_rate=48000","-t","4","-c:v","libx264","-threads","1","-preset","ultrafast","-g","25","-c:a","aac","-ac","2","-f","mpegts",file})info.ArgumentList.Add(a);
using(var make=Process.Start(info)){var error=make.StandardError.ReadToEndAsync();await make.WaitForExitAsync();if(make.ExitCode!=0)throw new Exception(await error);}
var bytes=await File.ReadAllBytesAsync(file);
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 relay=new LiveStreamRelay(new System.Net.Http.HttpClient(),port,TimeSpan.FromMilliseconds(200),TimeSpan.FromSeconds(StartupBudget.Seconds),TimeSpan.FromSeconds(StartupBudget.StallSeconds));
builder.Services.AddSingleton(relay);builder.Services.AddControllers().AddApplicationPart(typeof(LiveRelayController).Assembly);
await using var app=builder.Build();app.MapControllers();
app.MapGet("/late.ts",async context=>
{
Interlocked.Increment(ref connections);maximum=Math.Max(maximum,Interlocked.Increment(ref active));
context.Response.ContentType="video/mp2t";await context.Response.StartAsync(context.RequestAborted);
try
{
await Task.Delay(TimeSpan.FromSeconds(35),context.RequestAborted);
while(!context.RequestAborted.IsCancellationRequested)
for(var at=0;at<bytes.Length;at+=188*32)
{
var n=Math.Min(188*32,bytes.Length-at);
await context.Response.Body.WriteAsync(bytes.AsMemory(at,n),context.RequestAborted);await context.Response.Body.FlushAsync(context.RequestAborted);
await Task.Delay(TimeSpan.FromSeconds(4d*n/bytes.Length),context.RequestAborted);
}
}
catch(OperationCanceledException){}catch(IOException){}
finally{Interlocked.Decrement(ref active);}
});
await app.StartAsync();
try
{
var proxy=DispatchProxy.Create<IMediaEncoder,PatientEncoderProxy>();((PatientEncoderProxy)(object)proxy).ProbePath=Path.Combine(Path.GetDirectoryName(encoder),"ffprobe");
var probe=new LiveStreamProbe(proxy);var url=relay.Register($"http://127.0.0.1:{port}/late.ts");var watch=Stopwatch.StartNew();
var result=await Task.WhenAll(Enumerable.Range(0,4).Select(_=>probe.Get(url,CancellationToken.None,1))).WaitAsync(TimeSpan.FromSeconds(95));
if(watch.Elapsed.TotalSeconds<35||watch.Elapsed.TotalSeconds>=90||result.Any(x=>x[0].Width!=320||x[0].Height!=180))throw new Exception("Delayed source failed the configured budget");
await Task.Delay(800);
if(maximum!=1||connections!=1||active!=0)throw new Exception("Receiver sharing or cleanup failed");
var report=new{Passed=true,SyntheticOnly=true,ReceiverConnectionsOpened=0,ConfiguredStartupSeconds=StartupBudget.Seconds,DelayedFirstDataSeconds=35,
ElapsedSeconds=watch.Elapsed.TotalSeconds,ConcurrentRequesters=4,PhysicalFixtureConnections=connections,MaximumConcurrentFixtureConnections=maximum,RemainingConnections=active,
ActualFfprobeUsed=true,PhysicalAndroidTvTested=false};
await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(report,new JsonSerializerOptions{WriteIndented=true}));Console.WriteLine(JsonSerializer.Serialize(report));
}
finally{relay.Dispose();await app.StopAsync();}
}
}
public class PatientEncoderProxy : DispatchProxy
{
public string ProbePath;
protected override object Invoke(MethodInfo method,object[] args)=>method.Name=="get_ProbePath"?ProbePath:null;
}
+85
View File
@@ -0,0 +1,85 @@
using Jellyfin.Data;
using System.Collections.Concurrent;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging.Abstractions;
using System;
using System.Linq;
using System.Reflection;
using System.Security.Claims;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
public static class PlaybackFeedbackTests
{
public static async Task Run(Action<bool,string> check)
{
var user=new User("feedback-user","auth","reset");user.SetPermission(PermissionKind.EnableLiveTvAccess,true);
var auth=DispatchProxy.Create<IAuthorizationContext,FeedbackAuth>();
((FeedbackAuth)(object)auth).Info=new AuthorizationInfo{User=user,DeviceId="device-a",Client="Jellyfin Web"};
var sessions=DispatchProxy.Create<ISessionManager,FeedbackSessions>();
var contexts=new HttpContextAccessor{HttpContext=new DefaultHttpContext()};
var feedback=new PlaybackFeedback(contexts,auth,sessions);
var first=await feedback.Begin("channel-a",CancellationToken.None);
first.Report("checking_stream");
string Snapshot(Guid id,string device,string reference)=>JsonSerializer.Serialize(feedback.Snapshot(id,device,reference));
check(Snapshot(user.Id,"device-a","channel-a").Contains("checking_stream"),"startup feedback exposes actual probe phase");
check(!Snapshot(user.Id,"device-b","channel-a").Contains("checking_stream")&&!Snapshot(Guid.NewGuid(),"device-a","channel-a").Contains("checking_stream"),"startup progress isolates user and device");
check(!Snapshot(user.Id,"device-a","channel-b").Contains("checking_stream"),"startup progress isolates channels");
var second=await feedback.Begin("channel-a",CancellationToken.None);first.Report("failed");second.Report("ready");
check(Snapshot(user.Id,"device-a","channel-a").Contains("ready")&&!Snapshot(user.Id,"device-a","channel-a").Contains("failed"),"late old attempt cannot replace current progress");
check(!Snapshot(user.Id,"device-a","channel-a").Contains("device-a")&&!Snapshot(user.Id,"device-a","channel-a").Contains(user.Id.ToString()),"progress response excludes user and device identifiers");
check(((FeedbackSessions)(object)sessions).Messages==0,"web requests never send unsolicited native messages");
contexts.HttpContext=null;check(await feedback.Begin("recording",CancellationToken.None)==null,"background recording has no guessed message destination");
contexts.HttpContext=new DefaultHttpContext();user.SetPermission(PermissionKind.EnableLiveTvAccess,false);
check(await feedback.Begin("channel-a",CancellationToken.None)==null,"feedback requires live TV permission");
check(PlaybackFeedback.IsAndroidTv("Jellyfin Android TV")&&!PlaybackFeedback.IsAndroidTv("Jellyfin Android")&&!PlaybackFeedback.IsAndroidTv("Jellyfin Web"),"native messages scoped to Android TV clients");
user.SetPermission(PermissionKind.EnableLiveTvAccess,true);
var library=DispatchProxy.Create<MediaBrowser.Controller.Library.ILibraryManager,PolicyLibraryProxy>();
((PolicyLibraryProxy)(object)library).Current=user;((PolicyLibraryProxy)(object)library).Item=new MediaBrowser.Controller.LiveTv.LiveTvChannel{ServiceName="Enigma2",ExternalId="channel-a"};
var users=DispatchProxy.Create<MediaBrowser.Controller.Library.IUserManager,PolicyUserProxy>();((PolicyUserProxy)(object)users).Current=user;
var context=new DefaultHttpContext();context.User=new ClaimsPrincipal(new ClaimsIdentity(new[]{new Claim("Jellyfin-UserId",user.Id.ToString())},"test"));
var controller=new PlaybackFeedbackController(feedback,library,users,auth){ControllerContext=new ControllerContext{HttpContext=context}};
check(await controller.Get(Guid.NewGuid()) is OkObjectResult,"progress endpoint uses native user-filtered channel access");
((PolicyLibraryProxy)(object)library).Item=null;check(await controller.Get(Guid.NewGuid()) is NotFoundResult,"invisible native channel cannot expose startup state");
context.User=new ClaimsPrincipal();check(await controller.Get(Guid.NewGuid()) is UnauthorizedResult,"progress endpoint rejects absent authentication");
var manager=(FeedbackSessions)(object)sessions;
var native=new SessionInfo(sessions,NullLogger.Instance){Id="tv-session",UserId=user.Id,DeviceId="device-a",Client="Jellyfin Android TV",LastActivityDate=DateTime.UtcNow,
Capabilities=new ClientCapabilities{SupportedCommands=new[]{GeneralCommandType.DisplayMessage}}};
var other=new SessionInfo(sessions,NullLogger.Instance){Id="other-session",UserId=user.Id,DeviceId="device-b",Client="Jellyfin Android TV",LastActivityDate=DateTime.UtcNow};
manager.Entries=new[]{native,other}; contexts.HttpContext.Request.Headers.AcceptLanguage="uk-UA";
check(native.IsActive,"native feedback fixture has an active session");
var nativeAttempt=await feedback.Begin("native",CancellationToken.None);nativeAttempt.Report("waiting_for_stream");nativeAttempt.Report("ready");nativeAttempt.Report("failed");
for(var i=0;i<100&&manager.Messages<3;i++)await Task.Delay(10);
check(manager.Messages==3&&manager.Sent.All(m=>m.Target==native.Id&&m.Message.Header=="Телебачення"),"native localized feedback targets only the requesting user/device and has a three-message ceiling");
manager.Entries=new[]{native,native};var ambiguous=await feedback.Begin("ambiguous",CancellationToken.None);ambiguous.Report("ready");await Task.Delay(30);
check(manager.Messages==3,"ambiguous native session never broadcasts a message");
((FeedbackAuth)(object)auth).Info.IsApiKey=true;
check(await feedback.Begin("api-key",CancellationToken.None)==null,"server API keys cannot impersonate a native viewer");
}
}
public class FeedbackAuth:DispatchProxy
{
public AuthorizationInfo Info;
protected override object Invoke(MethodInfo method,object[] args)=>Task.FromResult(Info);
}
public class FeedbackSessions:DispatchProxy
{
public int Messages;
public SessionInfo[] Entries=Array.Empty<SessionInfo>();
public ConcurrentQueue<(string Target,MessageCommand Message)> Sent=new();
protected override object Invoke(MethodInfo method,object[] args)
{
if(method.Name=="get_Sessions")return Entries;
if(method.Name=="SendMessageCommand"){Sent.Enqueue(((string)args[1],(MessageCommand)args[2]));Interlocked.Increment(ref Messages);return Task.CompletedTask;}
throw new InvalidOperationException(method.Name);
}
}
+12
View File
@@ -18,9 +18,16 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
if (args.Length == 3 && args[0] == "--patient-startup-e2e") { await PatientStartupE2E.Run(args[1], args[2]); return; }
if (args.Length == 3 && args[0] == "--lossless-e2e") { await LosslessE2E.Run(args[1], args[2]); return; }
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");
@@ -108,6 +115,7 @@ Check(true,"empty source rejected before playback");
var liveVideo=LiveStreamProbe.ParseLiveMetadata(probes[0][0]);
Check(liveVideo.Index==-1&&liveVideo.IsInterlaced&&liveVideo.AverageFrameRate==25&&probes[0][0].Index==0,"live automatic mapping preserves metadata without mutating cached probe");
await AdaptiveProbeTests.Run(Check);
await PatientProbeTests.Run(Check);
await RelayStartupTests.Run(Check);
var relayHandler=new RelayHandler();
using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100)))
@@ -126,6 +134,10 @@ using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler)
}
await SavedChannelPlaybackTests.Run(Check);
await ChannelSyncTests.Run(Check);
await PlaybackFeedbackTests.Run(Check);
ContinuityTests.Run(Check);
LosslessSlateTests.Run(Check);
NativeResolutionPolicyTests.Run(Check);
public class PathsProxy : DispatchProxy
{
public string Folder;
+108
View File
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
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;
/// <summary>Native source dimensions through slate/live switches, remux and fMP4 packaging.</summary>
public static class ResolutionE2E
{
private static string encoder, output;
private static int active, maximum;
private static readonly Dictionary<string, string> Sizes = new() {
["sd"] = "720x576", ["hd"] = "1280x720", ["fhd"] = "1920x1080", ["qhd"] = "2560x1440", ["uhd"] = "3840x2160"
};
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 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 logging = LoggerFactory.Create(b => b.AddSimpleConsole().SetMinimumLevel(LogLevel.Warning));
using var manager = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), logging.CreateLogger<ContinuityStreams>(), 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();
var reports = new List<object>();
try
{
foreach (var (name, size) in Sizes)
{
var dimensions = size.Split('x');
var tracks = LosslessSlateTests.Tracks(int.Parse(dimensions[0]), int.Parse(dimensions[1]), 25);
((BroadcastMediaStream)tracks.Input[0]).SampleAspectRatio = name == "sd" ? "16:15" : "1:1";
tracks = new BroadcastTracks(tracks.Input);
var media = await manager.Open(relay.Register($"http://127.0.0.1:{port}/fixture/{name}.ts"), "en", CancellationToken.None, tracks);
Require(media.MediaStreams[0].Width == tracks.Input[0].Width && media.MediaStreams[0].Height == tracks.Input[0].Height,
"source metadata does not falsely cap the channel at Full HD: " + name);
var target = Path.Combine(output, name); Directory.CreateDirectory(target);
var common = new[] { "-xerror", "-i", media.Path, "-t", "32", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy" };
using var recording = Start(common.Concat(new[] { Path.Combine(target, "recording.ts") }), name + "-recording");
using var packaging = Start(common.Concat(new[] { "-bsf:a", "aac_adtstoasc", "-f", "hls", "-hls_segment_type", "fmp4", "-hls_time", "2", "-hls_list_size", "0", Path.Combine(target, "index.m3u8") }), name + "-fmp4");
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
{
if (!recording.HasExited) recording.Kill(true); if (!packaging.HasExited) packaging.Kill(true); manager.Close(media.Id);
}
foreach (var file in new[] { "recording.ts", "index.m3u8" })
{
using var decode = Start(new[] { "-xerror", "-i", Path.Combine(target, file), "-map", "0:v:0", "-map", "0:a:0", "-f", "null", "-" }, name + "-decode-" + Path.GetExtension(file).TrimStart('.'));
await decode.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60)); Require(decode.ExitCode == 0, "decode mixed-resolution " + file + ": " + size);
var probePath = Path.Combine(Path.GetDirectoryName(encoder), "ffprobe");
var info = new ProcessStartInfo(probePath) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var arg in new[] { "-v", "error", "-threads", "1", "-select_streams", "v:0", "-show_entries", "frame=width,height,sample_aspect_ratio", "-of", "csv=p=0", Path.Combine(target, file) }) info.ArgumentList.Add(arg);
using var probe = Process.Start(info); var text = await probe.StandardOutput.ReadToEndAsync(); var error = await probe.StandardError.ReadToEndAsync(); await probe.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60));
Require(probe.ExitCode == 0 && string.IsNullOrWhiteSpace(error), "probe actual decoded dimensions: " + name + " " + file);
var decodedDimensions = text.Split('\n').Where(line => line.Length > 0).Select(line => line.Trim()).Distinct().ToArray();
var expected = size.Replace('x', ',') + ",";
Require(decodedDimensions.Any(line => line.StartsWith(expected, StringComparison.Ordinal)), "native dimensions reach the decoded client frames: " + size);
if (name == "sd") Require(decodedDimensions.Any(line => line.StartsWith(expected + "16:15", StringComparison.Ordinal)), "SD sample aspect ratio survives slate/live switching");
reports.Add(new { Profile = name, File = file, Dimensions = decodedDimensions });
}
await Task.Delay(1500);
}
Require(active == 0 && maximum == 1, "resolution trials keep one upstream and release the last source");
var report = new { Passed = true, SyntheticOnly = true, LowMotionFixture = true, ReceiverConnectionsOpened = 0, PhysicalAndroidTvTested = false,
ActualJellyfinPlayerTested = false, MaximumConcurrentSourceConnections = maximum, Reports = reports };
await File.WriteAllTextAsync(Path.Combine(output, "result.json"), JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); Console.WriteLine(JsonSerializer.Serialize(report));
}
finally { manager.Dispose(); await app.StopAsync(); }
}
private static Process Start(IEnumerable<string> arguments, 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(arguments)) info.ArgumentList.Add(arg);
var process = Process.Start(info); process.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (typeof(ResolutionE2E)) File.AppendAllText(Path.Combine(output, name + ".log"), e.Data + "\n"); }; process.BeginErrorReadLine(); return process;
}
private static void Require(bool condition, string text) { if (!condition) throw new Exception(text); Console.WriteLine("PASS " + text); }
private static async Task Fixture(HttpContext context)
{
var name = context.Request.RouteValues["size"]?.ToString(); if (name == null || !Sizes.TryGetValue(name, out var size)) { context.Response.StatusCode = 404; return; }
maximum = Math.Max(maximum, Interlocked.Increment(ref active));
var filter = "color=c=0x085f21:s=" + size + ":r=25" + (name == "sd" ? ",setsar=16/15" : "");
var info = new ProcessStartInfo(encoder) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true };
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", filter,
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-c:v", "libx264", "-threads", "1", "-preset", "ultrafast", "-g", "25", "-c:a", "aac", "-ac", "2", "-f", "mpegts", "pipe:1" }) info.ArgumentList.Add(arg);
using var process = Process.Start(info); process.ErrorDataReceived += (_, _) => { }; process.BeginErrorReadLine(); context.Response.ContentType = "video/mp2t";
try { await process.StandardOutput.BaseStream.CopyToAsync(context.Response.Body, context.RequestAborted); }
catch (OperationCanceledException) { } catch (IOException) { }
finally { if (!process.HasExited) process.Kill(true); await process.WaitForExitAsync(); Interlocked.Decrement(ref active); }
}
}
+81
View File
@@ -0,0 +1,81 @@
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Model.Entities;
/// <summary>Self-authored DVB bitmap fixtures; no broadcaster samples or receiver connection.</summary>
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;pos<input.Length;pos+=188)
{
output.Write(input,pos,188);var pid=((input[pos+1]&31)<<8)|input[pos+2];
if(pid!=256||(input[pos+1]&64)==0)continue;
var p=pos+4+((input[pos+3]&32)!=0?1+input[pos+4]:0);
if(p+14>pos+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<next)continue;next=pts+180000;
for(var track=0;track<3;track++)
foreach(var packet in Packets(track==2?Teletext():Bitmap(track),pts,259+track,ref counters[track]))output.Write(packet);
}
return output.ToArray();
}
public static byte[][] Packets(byte[] bitmap,long pts,int pid,ref int counter)
{
var pes=new List<byte>{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<byte[]>();
for(var i=0;i<pes.Count;)
{
var packet=Enumerable.Repeat((byte)255,188).ToArray();packet[0]=71;packet[1]=(byte)((pid>>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<byte>{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<byte>();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<byte>{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<byte>();
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<byte>{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<byte>());result.Add(255);
return result.ToArray();
}
}
+1 -1
View File
@@ -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.11.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.
+58 -4
View File
@@ -12,14 +12,17 @@ import time
import urllib.error
import urllib.request
import uuid
import xml.etree.ElementTree as ET
VERSION = '7.0.11.0'
VERSION = '7.0.15.0'
ABI = '12.2.0.0'
GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a'
DLL = 'Jellyfin.Plugin.Enigma2.dll'
PLUGINS = pathlib.Path('/var/lib/jellyfin/plugins')
BACKUPS = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups')
LOGS = pathlib.Path('/var/log/jellyfin')
WEB = pathlib.Path('/usr/share/jellyfin/web/decoder-channel-playback.js')
CONFIG = PLUGINS / 'configurations/Jellyfin.Plugin.Enigma2.xml'
PROTECTED = tuple(map(pathlib.Path, (
'/etc/default/jellyfin', '/etc/jellyfin/encoding.xml',
'/usr/local/lib/jellyfin-enigma/ffmpeg', '/usr/local/lib/jellyfin-enigma/ffprobe',
@@ -217,14 +220,40 @@ def activate(old, new, stage, backup, verify, verify_rollback, control=service):
shutil.rmtree(stage)
class PluginAssetControl:
"""Update only the plugin's assets while stopped; restore them on rollback."""
def __init__(self, old, new, assets, control=service):
self.old, self.new, self.assets, self.control = old, new, assets, control
def __call__(self, action):
if action == 'start':
candidate = self.new.exists() and not self.old.exists()
for destination, prepared, saved in self.assets:
shutil.copyfile(prepared if candidate else saved, destination)
self.control(action)
def enable_slates(content):
pattern = r'<EnableContinuitySlates>\s*(?:true|false)\s*</EnableContinuitySlates>'
tag = '<EnableContinuitySlates>true</EnableContinuitySlates>'
result = re.sub(pattern, tag, content) if re.search(pattern, content) else re.sub(r'(</[\w:]+>\s*)$', ' ' + tag + '\n' + r'\1', content, count=1)
if ET.fromstring(result).findtext('EnableContinuitySlates') != 'true':
raise ValueError('Cannot set the plugin status-picture option')
return result
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--maintenance-approved', action='store_true',
help='Required confirmation of an already agreed Jellyfin restart window')
parser.add_argument('--dll', type=pathlib.Path, required=True)
parser.add_argument('--expected-sha256', required=True)
parser.add_argument('--web', type=pathlib.Path)
parser.add_argument('--expected-web-sha256')
parser.add_argument('--enable-slates', action='store_true')
parser.add_argument('--check-only', action='store_true')
args = parser.parse_args(argv)
if not args.maintenance_approved:
if not args.maintenance_approved and not args.check_only:
parser.error('--maintenance-approved is required; this script cannot inspect playback sessions')
expected = args.expected_sha256.lower()
if not re.fullmatch(r'[0-9a-f]{64}', expected):
@@ -235,6 +264,16 @@ def main(argv=None):
new = PLUGINS / ('Enigma2_' + VERSION)
if new.exists() or new.is_symlink():
raise ValueError('Target plugin directory already exists; refusing to replace it')
if bool(args.web) != bool(args.expected_web_sha256):
raise ValueError('Web asset and its checksum must be supplied together')
if args.web and (not args.web.is_file() or sha256(args.web) != args.expected_web_sha256.lower() or not WEB.is_file()):
raise ValueError('Plugin web asset validation failed')
if args.enable_slates:
enable_slates(CONFIG.read_text(encoding='utf-8-sig'))
if args.check_only:
print(json.dumps({'preflight': True, 'activated': False, 'restart': False, 'candidate': VERSION,
'current': metadata['version'], 'web_included': bool(args.web), 'enable_slates_requested': args.enable_slates}))
return
before = hashes()
old_hash = sha256(old / DLL)
boundary = log_boundary(LOGS)
@@ -242,7 +281,16 @@ def main(argv=None):
backup = BACKUPS / ('plugin-only-' + stamp)
backup.mkdir(mode=0o700, parents=True, exist_ok=False)
stage = backup / 'candidate'
assets = []
try:
if args.web:
saved = backup / 'previous-web.js'; shutil.copy2(WEB, saved)
assets.append((WEB, args.web, saved))
if args.enable_slates:
saved = backup / 'previous-plugin-config.xml'; shutil.copy2(CONFIG, saved)
prepared = backup / 'candidate-plugin-config.xml'
prepared.write_text(enable_slates(CONFIG.read_text(encoding='utf-8-sig')), encoding='utf-8')
assets.append((CONFIG, prepared, saved))
shutil.copytree(old, stage, copy_function=shutil.copy2)
shutil.copy2(args.dll, stage / DLL)
updated = dict(metadata, version=VERSION, status='Active', targetAbi=ABI)
@@ -251,9 +299,15 @@ def main(argv=None):
shutil.chown(path, user='jellyfin', group='jellyfin')
if sha256(stage / DLL) != expected or hashes() != before:
raise RuntimeError('Pre-restart checksum verification failed')
def verify_plugin(directory, version, digest, candidate):
wait_ready(directory, version, digest, before, boundary)
for destination, prepared, saved in assets:
if sha256(destination) != sha256(prepared if candidate else saved):
raise RuntimeError('Plugin asset verification failed')
activate(old, new, stage, backup,
lambda: wait_ready(new, VERSION, expected, before, boundary),
lambda: wait_ready(old, metadata['version'], old_hash, before, boundary))
lambda: verify_plugin(new, VERSION, expected, True),
lambda: verify_plugin(old, metadata['version'], old_hash, False),
PluginAssetControl(old, new, assets))
finally:
if stage.exists():
shutil.rmtree(stage)
+72
View File
@@ -0,0 +1,72 @@
"""Run the isolated synthetic C# harness and measure its own source FFmpeg only.
No Jellyfin configuration changes, restarts, API keys or receiver connections.
"""
import argparse
import json
import os
from pathlib import Path
import signal
import subprocess
import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--harness', required=True)
parser.add_argument('--ffmpeg', default='/usr/lib/jellyfin-ffmpeg/ffmpeg')
parser.add_argument('--output', required=True)
parser.add_argument('--long', action='store_true')
parser.add_argument('--native', action='store_true')
args = parser.parse_args()
root = Path(args.output).resolve()
if root.exists():
raise ValueError('Choose a new output directory')
samples, previous = [], {}
with Path(str(root) + '.log').open('x') as log:
process = subprocess.Popen([args.harness, '--native-continuity-e2e' if args.native else '--long-continuity-e2e' if args.long else '--continuity-e2e', args.ffmpeg, str(root)], stdout=log,
stderr=subprocess.STDOUT, start_new_session=True)
deadline = time.monotonic() + 240
try:
while process.poll() is None:
if time.monotonic() > deadline:
raise TimeoutError('Synthetic harness deadline exceeded')
now = time.monotonic()
for proc in Path('/proc').glob('[0-9]*'):
try:
if not str((proc / 'cwd').resolve()).startswith(str(root / 'cache' / 'source-')):
continue
value = (proc / 'stat').read_text()
fields = value[value.rfind(')') + 2:].split()
cpu = (int(fields[11]) + int(fields[12])) / os.sysconf('SC_CLK_TCK')
rss = int(fields[21]) * os.sysconf('SC_PAGE_SIZE') / 1048576
if proc.name in previous:
before, at = previous[proc.name]
samples.append({'one_core_percent': round((cpu-before)/(now-at)*100, 1),
'rss_mib': round(rss, 1)})
previous[proc.name] = (cpu, now)
except OSError:
pass
time.sleep(1)
finally:
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
report = {'harness_exit_code': process.returncode, 'samples': samples,
'scope': 'Only synthetic plugin source encoding; excludes fixture and player decoders'}
(root / 'cpu-sample.json').write_text(json.dumps(report, indent=2))
if process.returncode == 0 and (args.long or args.native):
subprocess.run(['python3', str(Path(__file__).with_name('verify-continuity-recording.py')), '--output', str(root), '--ffmpeg', args.ffmpeg], check=True)
print(Path(str(root) + '.log').read_text()[-3500:])
print(json.dumps({'exit_code': process.returncode, 'cpu_samples': len(samples),
'maximum_one_core_percent': max((s['one_core_percent'] for s in samples), default=None),
'maximum_rss_mib': max((s['rss_mib'] for s in samples), default=None)}))
raise SystemExit(0 if process.returncode == 0 else 1)
if __name__ == '__main__':
main()
@@ -0,0 +1,58 @@
"""Verify actual rendered stages in the synthetic long-trial recordings.
Copies of H.264 packets alone are insufficient evidence that clients saw a
retry slate or a recovered channel. Compare decoded text pixels, not file names.
"""
import argparse
import json
from pathlib import Path
import subprocess
def pixels(ffmpeg, path, second=0):
command = [ffmpeg, '-hide_banner', '-loglevel', 'error', '-nostdin', '-threads', '1', '-filter_threads', '1',
'-ss', str(second), '-i', str(path), '-frames:v', '1', '-vf',
'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,crop=1920:260:0:430,scale=640:86:flags=area', '-pix_fmt', 'gray', '-f', 'rawvideo', 'pipe:1']
output = subprocess.run(command, capture_output=True, check=True, timeout=20).stdout
if len(output) != 640 * 86:
raise RuntimeError('Incomplete video frame')
return output
def difference(first, second):
return sum(abs(a-b) for a, b in zip(first, second)) / len(first)
def verify(root, ffmpeg):
result = json.loads((root / 'result.json').read_text())
if result['FailureSeconds'] != 75:
raise ValueError('Use the long trial with the documented stage times')
errors = {}
for language in ('pl', 'en'):
waiting = pixels(ffmpeg, root / (language + '-waiting-slate.ts'))
retrying = pixels(ffmpeg, root / (language + '-retrying-slate.ts'))
observed_waiting = pixels(ffmpeg, root / (language + '.mkv'), 1)
observed_retrying = pixels(ffmpeg, root / (language + '.mkv'), 70)
live = max((pixels(ffmpeg, root / (language + '.mkv'), at) for at in (18,22,26,30,34,38,42,46)), key=lambda frame: difference(retrying,frame))
recovered = pixels(ffmpeg, root / (language + '.mkv'), 140)
metrics = {'waiting_error': difference(waiting, observed_waiting),
'retrying_error': difference(retrying, observed_retrying),
'live_difference': difference(retrying, live),
'recovered_difference': difference(retrying, recovered)}
assert metrics['waiting_error'] < 0.5 and metrics['retrying_error'] < 0.5, metrics
assert metrics['live_difference'] > 15 and metrics['recovered_difference'] > 15, metrics
other = 'en' if language == 'pl' else 'pl'
assert difference(observed_retrying, pixels(ffmpeg, root / (other + '-retrying-slate.ts'))) > 2
errors[language] = {key: round(value, 4) for key, value in metrics.items()}
result.update(RenderedWaitingVerified=True, RenderedRetryingVerified=True, RenderedLiveRecoveryVerified=True,
RenderedPerViewerLanguageVerified=True, VisualErrorMetrics=errors)
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps({'rendered_stages_verified': True, 'metrics': errors}))
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--ffmpeg', default='/usr/lib/jellyfin-ffmpeg/ffmpeg')
args = parser.parse_args()
verify(args.output, args.ffmpeg)
+51 -11
View File
@@ -3,7 +3,12 @@ const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const listeners = {}, documentListeners = {}, elements = [];
let clock = 0, interval, starts = 0, stops = 0, osd = 0, returned = 0, readyState = 0, remote = false;
let clock = 0, interval, starts = 0, stops = 0, osd = 0, returned = 0, readyState = 0, remote = false, currentTime = 0, paused = false;
let startupTimeoutSeconds;
let phase = 'checking_stream', progressCalls = 0, languageQueries = [], progressDeferred;
const catalogue = JSON.parse(fs.readFileSync(__dirname + '/Jellyfin.Plugin.Enigma2/Localization/startup-messages.json', 'utf8'));
const video = { get readyState() { return readyState; }, get currentTime() { return currentTime; }, get paused() { return paused; }, currentSrc: 'stream' };
const playerListeners = {};
let policy = { IsEnigmaChannel: true, PreferH264: true }, policyCalls = 0, policyError = false;
const profile = { DirectPlayProfiles: [{ Type: 'Video', VideoCodec: 'av1,h264' }],
TranscodingProfiles: [{ Type: 'Video', Context: 'Streaming', VideoCodec: 'av1,h264', AudioCodec: 'aac', Protocol: 'hls', MaxWidth: 1920 },
@@ -12,36 +17,47 @@ const profile = { DirectPlayProfiles: [{ Type: 'Video', VideoCodec: 'av1,h264' }
const pending = [];
const location = { hash: '#/livetv', pathname: '/web/index.html', search: '', replace(value) { this.hash = value; } };
const document = {
fullscreenElement: null,
fullscreenElement: null, documentElement: { lang: "de-DE" },
body: { append(element) { elements.push(element); } },
createElement() { return { style: {}, remove() { elements.splice(elements.indexOf(this), 1); } }; },
querySelector() { return { readyState, currentTime: 0 }; },
createElement() { return { style: {}, children: [], attributes: {}, setAttribute(key, value) { this.attributes[key] = value; },
append(...children) { this.children.push(...children); }, remove() { const i = elements.indexOf(this); if (i >= 0) elements.splice(i, 1); } }; },
querySelector() { return video; },
addEventListener(name, callback) { documentListeners[name] = callback; }
};
const window = {
ApiClient: { getCurrentUserId: () => 'user', serverId: () => 'server', getItem: async (user, id) => ({ Id: id, Type: 'TvChannel' }),
getUrl: (path, params) => path + '?' + new URLSearchParams(params),
ajax: async () => { ++policyCalls; if (policyError) throw Error('offline'); return policy; } },
ajax: async options => {
if (options.url.startsWith('DecoderIntegration/StartupMessages')) {
const language = new URLSearchParams(options.url.split('?')[1]).get('language'); languageQueries.push(language);
return { Language: language.split('-')[0], Texts: catalogue[language.split('-')[0]], StartupTimeoutSeconds: startupTimeoutSeconds };
}
if (options.url.startsWith('DecoderIntegration/PlaybackProgress')) {
++progressCalls; if (progressDeferred) return progressDeferred;
return { Available: true, Phase: phase, StartedUtc: new Date(clock).toISOString() };
}
++policyCalls; if (policyError) throw Error('offline'); return policy;
} },
addEventListener(name, callback) { listeners[name] = callback; }
};
const context = {
window, document, location, console, URLSearchParams,
history: { replaceState(a, b, url) { location.hash = url.slice(url.indexOf('#')); } },
Date: { now: () => clock }, setTimeout, clearTimeout,
Date: { now: () => clock, parse: Date.parse }, setTimeout, clearTimeout,
setInterval(callback) { interval = callback; return callback; },
clearInterval(callback) { if (interval === callback) interval = null; }
};
const manager = {
getCurrentPlayer: () => ({ isLocalPlayer: !remote }),
play() { ++starts; return new Promise(resolve => pending.push(resolve)); },
play() { ++starts; playerListeners.playbackstart?.(); return new Promise(resolve => pending.push(resolve)); },
stop: async () => { ++stops; }
};
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
(async () => {
vm.runInNewContext(fs.readFileSync(__dirname + '/web/decoder-channel-playback.js', 'utf8'), context);
const Plugin = await window.DecoderChannelPlayback();
const appHost = { getDeviceProfile: async () => profile };
const dependencies = { playbackManager: manager, appHost, toast() {}, loading: { hide() {} }, events: { on() {} },
const dependencies = { playbackManager: manager, appHost, toast() {}, loading: { hide() {} }, events: { on(target, name, callback) { playerListeners[name] = callback; } },
appRouter: { showVideoOsd: async () => { ++osd; }, show: async () => { ++returned; location.hash = '#/livetv?tab=2'; } } };
new Plugin(dependencies);
const channel = { Id: 'channel', Type: 'TvChannel', ServerId: 'server' };
@@ -70,17 +86,41 @@ const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve();
const click = () => documentListeners.click({ button: 0, target: { closest: () => ({ closest: () => ({ dataset: { id: 'channel', serverid: 'server' } }) }) }, preventDefault() {}, stopImmediatePropagation() {} });
click(); click(); await flush();
assert.equal(starts, 1); assert.equal(elements[0].id, 'decoderPlaybackExit');
assert.equal(languageQueries.at(-1), 'de-DE', 'use chosen Jellyfin UI language, not browser metadata language');
assert.equal(elements[0].textContent, catalogue.de.cancel); assert.equal(elements[1].attributes.role, 'status');
interval(); await flush(); assert.equal(osd, 0, 'OSD must wait for actual video metadata');
assert.equal(progressCalls, 1); assert.equal(elements[1].children[2].textContent, catalogue.de.checking_stream);
phase = 'waiting_for_stream'; clock = 2000; interval(); await flush();
assert.equal(elements[1].children[2].textContent, catalogue.de.waiting_for_stream);
readyState=1;interval(); await flush(); assert.equal(osd, 1);
listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} });
await flush(); assert.equal(stops, 1); assert.equal(returned, 1); assert.equal(elements.length, 0);
click(); await flush(); assert.equal(starts, 2);
readyState = 0; clock = 0; click(); await flush(); assert.equal(starts, 2);
pending[0](); await flush(); assert.equal(stops, 1, 'late old completion must not stop the new channel');
clock = 35000; interval(); await flush();
assert.equal(stops, 1, 'pending startup must survive 35 seconds');
assert.equal(returned, 1); assert.equal(elements.length, 1, 'cancel control remains available during slow startup');
assert.equal(returned, 1); assert.equal(elements.length, 2, 'status and cancel remain available during slow startup');
clock = 45001; interval(); await flush();
assert.equal(stops, 2); assert.equal(returned, 2); assert.equal(elements.length, 0);
pending[1](); await flush();
readyState = 0; clock = 50000; click(); await flush();
readyState = 2; currentTime = 1; interval(); await flush();
assert.equal(elements[1].style.display, 'none', 'first decoded frame hides waiting panel');
readyState = 1; currentTime = 2; clock = 500000; interval(); await flush();
assert.equal(stops, 2, 'a stream already playing must not be killed by the startup timeout');
assert.equal(elements[1].style.display, 'flex'); assert.equal(elements[1].children[2].textContent, catalogue.de.buffering);
paused = true; interval(); assert.equal(elements[1].style.display, 'none', 'pause is not a buffering failure');
paused = false;
listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} }); await flush();
assert.equal(elements.length, 0); pending[2](); await flush();
startupTimeoutSeconds = 135; clock = 0; currentTime = 0; readyState = 0;
const stoppedBefore = stops; click(); await flush();
clock = 90000; interval(); await flush();
assert.equal(stops, stoppedBefore, 'configured metadata budget must survive the old web deadline');
clock = 135001; interval(); await flush();
assert.equal(stops, stoppedBefore + 1, 'configured web startup remains bounded');
pending[3](); await flush();
console.log('PASS provider startup budget extends web waiting without disabling timeout or cancellation');
console.log('PASS per-device UI locale, real progress, first frame, later buffering and pause');
console.log('PASS default channels, explicit programmes, one start, native OSD, Escape, late completion and startup timeout');
})().catch(error => { console.error(error); process.exitCode = 1; });
+5 -39
View File
@@ -1,39 +1,5 @@
// Isolated headless browser; temporary server account is always removed.
const {execFileSync}=require('node:child_process');
const {chromium}=require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const ssh=py=>execFileSync('ssh',['-o','BatchMode=yes','root@192.168.100.25','pct exec 100191 -- python3 -'],{input:py,encoding:'utf8'}).trim().split('\n').at(-1);
const setup=`import sqlite3,json,secrets,urllib.request
c=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()
def api(p,d):
r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"','Content-Type':'application/json'},data=json.dumps(d).encode());b=urllib.request.urlopen(r).read();return json.loads(b) if b else None
n='decoder-browser-check-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});api('/Users/'+u['Id']+'/Password',{'CurrentPw':'','NewPw':p});print(json.dumps({'id':u['Id'],'name':n,'password':p}))
`;
(async()=>{
const user=JSON.parse(ssh(setup));let browser;
try{
browser=await chromium.launch({headless:true,channel:'msedge'});const page=await browser.newPage({viewport:{width:1280,height:900}});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
const base='http://192.168.100.191:8096/web/index.html';
await page.goto(base);await page.locator('#txtManualName').fill(user.name);await page.locator('#txtManualPassword').fill(user.password);await page.getByRole('checkbox').check();await page.getByRole('button',{name:'Sign In',exact:true}).click();
await page.waitForURL(/home/,{timeout:20000});
await page.waitForFunction(()=>JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers?.some(s=>s.AccessToken&&s.UserId));
await page.goto('http://192.168.100.191:8096/DecoderIntegration/Channels');
try { await page.locator('#channelRows section').first().waitFor({timeout:30000}); }
catch(error) { console.log(JSON.stringify({url:page.url(),text:await page.locator('body').innerText(),errors})); await page.screenshot({path:process.argv[2]||'decoder-channels.png'});throw error; }
await page.goto(base+'#/livetv');await page.waitForURL(/tab=2/,{timeout:15000});
const target=process.env.DECODER_TEST_CHANNEL || 'TVN HD';
const image=page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:new RegExp('^\\d+ '+target+'$')})}).locator('a.cardImageContainer');
await image.click({position:{x:20,y:20}});
await page.locator('#decoderPlaybackExit').waitFor({timeout:15000});
let video=false;
try { await page.waitForFunction(()=>[...document.querySelectorAll('video')].some(v=>v.currentTime>0&&v.readyState>=2),{},{timeout:20000});video=true; } catch {}
await page.screenshot({path:process.argv[2]||'decoder-web-player.png'});
if(await page.locator('#decoderPlaybackExit').count())await page.locator('#decoderPlaybackExit').click();
await page.waitForURL(/livetv.*tab=2/,{timeout:15000});
await page.locator('.card[data-type="TvChannel"]').first().waitFor({timeout:15000});
console.log(JSON.stringify({default_channels:'PASS',exit_to_channels:'PASS',channel:target,video:video?'PASS':'NO_FRAME_FROM_SOURCE',browser_errors:errors}));
}finally{
if(browser)await browser.close();
ssh(`import sqlite3,urllib.request,pathlib\nc=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()\nurllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Users/${user.id}',headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'},method='DELETE')).read()\npathlib.Path('/var/lib/jellyfin/decoder-integration/users/${user.id.replace(/-/g,'')}.json').unlink(missing_ok=True)\nprint('clean')`);
}
})().catch(e=>{console.error(e.message);process.exitCode=1});
// Retired production helper: its old implementation extracted an API key from SQLite.
// Offline regression: node test-web-bridge.cjs. Real playback needs an explicitly
// authenticated client session; never manufacture one from the server database.
console.error('Legacy production browser test disabled. Use test-web-bridge.cjs for offline checks or an explicitly authenticated client session.');
process.exitCode = 2;
+73 -11
View File
@@ -34,12 +34,48 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
try { appHost.getDeviceProfile = getDeviceProfile; }
catch { /* A client exposing a read-only host keeps its native negotiation. */ }
}
let generation = 0, starting = null, cancelButton = null, watcher = null;
const clearControls = () => { clearInterval(watcher); watcher = null; cancelButton?.remove(); cancelButton = null; };
// Jellyfin's globalize module sets <html lang> to the current user's UI
// language, including an explicit choice that differs from browser locale.
const fallback = { header: 'Television', preparing: 'Preparing channel…',
checking_stream: 'Waiting for video and audio…', waiting_for_stream: 'Still waiting for a usable stream…',
ready: 'Stream detected. Starting player…', failed: 'Unable to prepare the stream.', buffering: 'Buffering video…',
help: 'The receiver may need several seconds. You can cancel or press Esc.', cancel: 'Stop and return to channels',
elapsed: 'Waiting: {seconds} s', timeout: 'The channel is not providing video. Returning to the channel list.',
play_error: 'Unable to start the channel.' };
let messages = fallback;
const text = key => messages[key] || fallback[key];
const loadMessages = async api => {
let timer;
try {
const language = document.documentElement?.lang || '';
const response = await Promise.race([
api.ajax({ type: 'GET', dataType: 'json', url: api.getUrl('DecoderIntegration/StartupMessages', { language }) }),
new Promise(resolve => { timer = setTimeout(() => resolve(null), 1000); })
]);
return response?.Texts ? { ...fallback, ...response.Texts, StartupTimeoutSeconds: response.StartupTimeoutSeconds } : fallback;
} catch { return fallback; } finally { clearTimeout(timer); }
};
let generation = 0, starting = null, cancelButton = null, watcher = null, statusPanel = null, statusText = null, elapsedText = null, playbackSequence = 0;
const clearControls = () => { clearInterval(watcher); watcher = null; cancelButton?.remove(); cancelButton = null; statusPanel?.remove(); statusPanel = statusText = elapsedText = null; };
events.on(playbackManager, 'playbackstop', () => { if (!starting) clearControls(); });
events.on(playbackManager, 'playbackstart', () => { ++playbackSequence; });
document.addEventListener('fullscreenchange', () => {
if (cancelButton) (document.fullscreenElement || document.body).append(cancelButton);
if (statusPanel) (document.fullscreenElement || document.body).append(statusPanel);
});
const showStatus = (channelName) => {
statusPanel = document.createElement('section'); statusPanel.id = 'decoderPlaybackStatus';
statusPanel.setAttribute('role', 'status'); statusPanel.setAttribute('aria-live', 'polite');
statusPanel.style.cssText = 'position:fixed;inset:0;z-index:2147483646;background:#101723;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:30px;box-sizing:border-box;color:#f4f7fc;text-align:center;font:18px system-ui';
const icon = document.createElement('div'); icon.setAttribute('aria-hidden', 'true');
icon.style.cssText = 'width:112px;height:76px;border:5px solid #58bfee;border-radius:14px;display:grid;place-items:center;color:#58bfee;font:bold 26px system-ui'; icon.textContent = 'TV';
const title = document.createElement('h2'); title.textContent = channelName || text('header'); title.style.cssText = 'margin:6px;max-width:90vw;overflow-wrap:anywhere;font-size:28px';
statusText = document.createElement('p'); statusText.textContent = text('preparing'); statusText.style.cssText = 'margin:0;font-size:22px';
const help = document.createElement('p'); help.textContent = text('help'); help.style.cssText = 'max-width:560px;color:#b1bdce;margin:0;line-height:1.5';
elapsedText = document.createElement('span'); elapsedText.setAttribute('aria-hidden', 'true'); elapsedText.style.cssText = 'color:#91a4bb;font-variant-numeric:tabular-nums';
statusPanel.append(icon, title, statusText, help, elapsedText); document.body.append(statusPanel);
};
const stageText = phase => ['preparing', 'checking_stream', 'waiting_for_stream', 'ready', 'failed'].includes(phase) ? text(phase) : null;
const leave = async () => {
++generation; starting = null; clearControls();
try { await playbackManager.stop(); } finally { loading.hide(); await appRouter.show('livetv?tab=2'); }
@@ -51,29 +87,55 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
request = (async () => {
const api = window.ApiClient;
if (!api?.getCurrentUserId() || (serverId && api.serverId() !== serverId)) return false;
const item = await api.getItem(api.getCurrentUserId(), id);
const [item, localized] = await Promise.all([api.getItem(api.getCurrentUserId(), id), loadMessages(api)]);
if (ticket !== generation) return false;
messages = localized;
if (item.Type !== 'TvChannel') throw Error('Expected a TV channel');
clearControls();
cancelButton = document.createElement('button');
cancelButton.id = 'decoderPlaybackExit'; cancelButton.textContent = 'Zatrzymaj i wróć do kanałów';
cancelButton.id = 'decoderPlaybackExit'; cancelButton.textContent = text('cancel');
cancelButton.style.cssText = 'position:fixed;right:16px;top:72px;z-index:2147483647;background:#252525;color:white;border:1px solid #aaa;border-radius:6px;padding:12px;font:16px system-ui;cursor:pointer';
cancelButton.onclick = () => void leave(); document.body.append(cancelButton);
// Allow the bounded metadata probe (25 s) plus HLS startup (20 s).
let opened = false, opening = false; const deadline = Date.now() + 45000;
showStatus(item.Name);
// Use this provider's bounded startup budget; retain compatibility with older DLLs.
let opened = false, opening = false, hasPlayed = false, polling = false, pollFailures = 0, lastPoll = -Infinity;
const configuredWait = Number(messages.StartupTimeoutSeconds);
const waitMs = Number.isFinite(configuredWait) && configuredWait >= 45 && configuredWait <= 225 ? configuredWait * 1000 : 45000;
const began = Date.now(), deadline = began + waitMs, initialVideo = document.querySelector('video'), initialSource = initialVideo?.currentSrc, initialSequence = playbackSequence;
watcher = setInterval(() => {
if (ticket !== generation) return;
const video = document.querySelector('video');
const player = playbackManager.getCurrentPlayer();
if (player?.isLocalPlayer === false) { clearControls(); return; }
if (!opened && !opening && video?.readyState >= 1 && player) {
const currentVideo = video && (video !== initialVideo || video.currentSrc !== initialSource || playbackSequence > initialSequence);
if (elapsedText) elapsedText.textContent = text('elapsed').replace('{seconds}', Math.floor((Date.now() - began) / 1000));
if (!opened && !opening && currentVideo && video.readyState >= 1 && player) {
opening = true;
Promise.resolve(appRouter.showVideoOsd()).then(() => { opened = true; })
.catch(() => { /* A cancelled/unready native route can be retried. */ })
.finally(() => { opening = false; });
}
if (video?.readyState >= 2 && video.currentTime > 0) return;
if (Date.now() >= deadline) { void leave(); toast('Kanał nie przesyła obrazu. Powrót do listy kanałów.'); }
if (currentVideo && video.readyState >= 2 && video.currentTime > 0) {
hasPlayed = true; if (statusPanel) statusPanel.style.display = 'none'; return;
}
if (hasPlayed) {
if (statusPanel) statusPanel.style.display = video?.paused ? 'none' : 'flex';
if (statusText) statusText.textContent = text('buffering');
return; // A later buffer underrun must not reuse the startup deadline.
}
if (currentVideo && video.readyState >= 1 && statusText) statusText.textContent = text('buffering');
if (!polling && pollFailures < 3 && Date.now() - lastPoll >= 1500 && api.ajax && api.getUrl) {
polling = true; lastPoll = Date.now();
Promise.resolve().then(() => api.ajax({ type: 'GET', dataType: 'json',
url: api.getUrl('DecoderIntegration/PlaybackProgress', { channelId: item.Id }) }))
.then(snapshot => {
if (ticket !== generation || hasPlayed || (currentVideo && video.readyState >= 1)) return;
const text = snapshot?.Available && Date.parse(snapshot.StartedUtc) >= began - 1000 && stageText(snapshot.Phase);
if (text && statusText) statusText.textContent = text;
}).catch(() => { ++pollFailures; })
.finally(() => { polling = false; });
}
if (Date.now() >= deadline) { void leave(); toast(text('timeout')); }
}, 250);
try { await playbackManager.play({ items: [item], fullscreen: true }); }
catch (error) { if (ticket !== generation) return false; await leave(); throw error; }
@@ -106,7 +168,7 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
query.delete('decoderPlay');
history.replaceState(null, '', location.pathname + location.search + route.split('?')[0] + (query.size ? '?' + query : ''));
try { await play(id, query.get('serverId')); }
catch (error) { console.error('Decoder channel playback failed', error); toast('Nie udało się uruchomić kanału.'); }
catch (error) { console.error('Decoder channel playback failed', error); toast(text('play_error')); }
};
void attempt();
};
@@ -123,7 +185,7 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
event.preventDefault(); event.stopImmediatePropagation();
void play(id, card.dataset.serverid).catch(error => {
console.error('Decoder channel playback failed', error);
toast('Nie udało się uruchomić kanału.');
toast(text('play_error'));
});
}, true);
}