Add opt-in FFmpeg continuity slates with per-viewer localization

This commit is contained in:
2026-10-23 22:35:40 +02:00
parent d097aad032
commit 5f6b0b5ff6
22 changed files with 1407 additions and 30 deletions
@@ -31,6 +31,8 @@ namespace Jellyfin.Plugin.Enigma2.Configuration
public bool EnableVuRecordings { get; set; } = false; public bool EnableVuRecordings { get; set; } = false;
public bool PreferH264ForWebLiveTv { get; set; } = true; public bool PreferH264ForWebLiveTv { get; set; } = true;
public bool EnableAutomaticChannelSync { get; set; } = true; public bool EnableAutomaticChannelSync { get; set; } = true;
public bool ShowNativeStartupMessages { get; set; } = true;
public bool EnableContinuitySlates { get; set; } = false;
public PluginConfiguration() public PluginConfiguration()
@@ -153,6 +153,16 @@
<span>Preferuj H264 przy transkodowaniu telewizji w przeglądarce</span> <span>Preferuj H264 przy transkodowaniu telewizji w przeglądarce</span>
</label> </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> <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">Ponawia odbiór aż do zatrzymania przez widza. Jeden proces odbioru na kanał; język planszy według interfejsu użytkownika, następnie profilu i ustawień Jellyfina. Normalizuje obraz do H264 Full HD 50 kl./s i dźwięk do AAC stereo; wymaga dodatkowego CPU, nie zachowuje 4K, HDR, dźwięku przestrzennego ani dodatkowych ścieżek. Nie dotyczy nagrań.</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"> <label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkDebugLogging" /> <input is="emby-checkbox" type="checkbox" id="chkDebugLogging" />
<span>Enable Enigma2 debug logging</span> <span>Enable Enigma2 debug logging</span>
@@ -201,6 +211,8 @@
document.getElementById('chkEnableVuRecordings').checked = config.EnableVuRecordings || false; document.getElementById('chkEnableVuRecordings').checked = config.EnableVuRecordings || false;
document.getElementById('chkPreferH264ForWebLiveTv').checked = config.PreferH264ForWebLiveTv !== false; document.getElementById('chkPreferH264ForWebLiveTv').checked = config.PreferH264ForWebLiveTv !== false;
document.getElementById('chkEnableAutomaticChannelSync').checked = config.EnableAutomaticChannelSync !== false; document.getElementById('chkEnableAutomaticChannelSync').checked = config.EnableAutomaticChannelSync !== false;
document.getElementById('chkShowNativeStartupMessages').checked = config.ShowNativeStartupMessages !== false;
document.getElementById('chkEnableContinuitySlates').checked = config.EnableContinuitySlates === true;
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
}); });
@@ -230,6 +242,8 @@
config.EnableVuRecordings = document.getElementById('chkEnableVuRecordings').checked; config.EnableVuRecordings = document.getElementById('chkEnableVuRecordings').checked;
config.PreferH264ForWebLiveTv = document.getElementById('chkPreferH264ForWebLiveTv').checked; config.PreferH264ForWebLiveTv = document.getElementById('chkPreferH264ForWebLiveTv').checked;
config.EnableAutomaticChannelSync = document.getElementById('chkEnableAutomaticChannelSync').checked; config.EnableAutomaticChannelSync = document.getElementById('chkEnableAutomaticChannelSync').checked;
config.ShowNativeStartupMessages = document.getElementById('chkShowNativeStartupMessages').checked;
config.EnableContinuitySlates = document.getElementById('chkEnableContinuitySlates').checked;
ApiClient.updatePluginConfiguration(Enigma2ConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult); ApiClient.updatePluginConfiguration(Enigma2ConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult);
}); });
@@ -0,0 +1,295 @@
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) { 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)
{
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 DateTime LastAccess = DateTime.UtcNow;
}
public async Task<MediaSourceInfo> Open(string relayUrl, string language, CancellationToken token)
{
language = StartupMessages.ResolveLanguage(language, null, "en");
var clips = await CachedSlates(language, token);
token.ThrowIfCancellationRequested();
Viewer viewer;
lock (gate)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (viewers.Count >= 64) throw new InvalidOperationException("Continuity viewer limit reached");
if (!sources.TryGetValue(relayUrl, out var source))
{
if (sources.Count >= 4) throw new InvalidOperationException("Continuity channel limit reached");
source = new(this, relayUrl); sources.Add(relayUrl, source);
}
viewer = new() { Source = source, Slates = clips }; viewers.Add(viewer.Id, viewer);
}
return new MediaSourceInfo {
Id = viewer.Id, Path = $"http://127.0.0.1:{port}/DecoderIntegration/Continuity/{viewer.Id}/index.m3u8",
Protocol = MediaProtocol.Http, Container = "hls", IsInfiniteStream = true, SupportsProbing = false,
SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true, BufferMs = 1000,
MediaStreams = new[] {
new MediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", Profile = "Constrained Baseline", Level = 42,
Width = Width, Height = Height, AverageFrameRate = FrameRate, RealFrameRate = FrameRate, IsInterlaced = false, BitRate = 12000000, PixelFormat = "yuv420p" },
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Channels = 2, SampleRate = 48000, BitRate = 128000, IsDefault = true }
}
};
}
private async Task<Dictionary<string, byte[]>> CachedSlates(string language, CancellationToken token)
{
var cached = slates.GetOrAdd(language, lang => new(() => GenerateSlates(lang), LazyThreadSafetyMode.ExecutionAndPublication));
Dictionary<string, byte[]> clips;
try { clips = await cached.Value.WaitAsync(token); }
catch { if (cached.IsValueCreated && cached.Value.IsFaulted) slates.TryRemove(new KeyValuePair<string, Lazy<Task<Dictionary<string, byte[]>>>>(language, cached)); throw; }
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.Url); 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;
return TransportStreamClock.Rebase(segment.Slate is { } slate ? viewer.Slates[slate] : segment.Video, segment.Start90k);
}
}
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) 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 IEnumerable<string> CodecArguments() => new[] {
"-c:v", "libx264", "-threads", "2", "-preset", "ultrafast", "-tune", "zerolatency", "-crf", "20",
"-maxrate", "12M", "-bufsize", "24M", "-profile:v", "baseline", "-level:v", "4.2", "-pix_fmt", "yuv420p",
"-r", FrameRate.ToString(CultureInfo.InvariantCulture), "-g", "112", "-keyint_min", "112", "-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)
{
await slateGate.WaitAsync(lifetime.Token);
var folder = Path.Combine(root, "slates-" + language + "-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(folder);
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 filter = $"drawbox=x=(iw-160)/2:y=260:w=160:h=100:color=0x58bfee:t=6,drawtext=fontfile={font}:text=TV:fontsize=42:fontcolor=0x58bfee:x=(w-tw)/2:y=285," +
$"drawtext=fontfile={font}:textfile=message.txt:fontsize=42:fontcolor=white:line_spacing=22:x=(w-tw)/2:y=490";
using var process = Start(new[] { "-f", "lavfi", "-i", $"color=c=0x101723:s={Width}x{Height}:r={FrameRate}", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-vf", filter,
"-map", "0:v:0", "-map", "1:a:0", "-t", "6.72" }.Concat(CodecArguments()).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-hls_list_size", "0", "-hls_segment_filename", "slate-%d.ts", "slate.m3u8" }), folder);
try { await process.WaitForExitAsync(lifetime.Token).WaitAsync(TimeSpan.FromSeconds(20), lifetime.Token); }
finally { if (!process.HasExited) process.Kill(entireProcessTree: true); }
if (process.ExitCode != 0) throw new IOException("FFmpeg slate generation failed");
// Use an interior HLS segment: it has neither AAC encoder priming
// nor the final padding of a newly encoded standalone file.
clips[phase] = await File.ReadAllBytesAsync(Path.Combine(folder, "slate-1.ts"), lifetime.Token);
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;
public ContinuityTimeline Timeline { get; } = new(DateTime.UtcNow);
public volatile bool Retrying;
public Source(ContinuityStreams owner, string url)
{ this.owner = owner; Url = url; lifetime = CancellationTokenSource.CreateLinkedTokenSource(owner.lifetime.Token); task = Task.Run(Run); }
public 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", "10000000", "-analyzeduration", "5000000", "-probesize", "4000000", "-i", Url,
"-map", "0:v:0", "-map", "0:a:0", "-sn", "-dn", "-vf",
$"bwdif=mode=send_field:parity=auto:deint=interlaced,scale=w={Width}:h={Height}:force_original_aspect_ratio=decrease,pad={Width}:{Height}:(ow-iw)/2:(oh-ih)/2,setsar=1" }
.Concat(CodecArguments()).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-hls_list_size", "6", "-hls_delete_threshold", "8", "-hls_flags", "delete_segments+temp_file+omit_endlist", "-hls_segment_filename", "segment-%09d.ts", "live.m3u8" });
Interlocked.Increment(ref owner.attempts);
using var process = owner.Start(args, folder);
try
{
var highest = 0; // Discard the encoder-priming segment.
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 > 8 * 1024 * 1024) continue;
Timeline.Live(await File.ReadAllBytesAsync(path, lifetime.Token), epoch, duration, DateTime.UtcNow);
highest = index; lastOutput = DateTime.UtcNow; delivered = true; Retrying = false; failures = 0; Interlocked.Increment(ref owner.liveSegments);
}
}
if ((DateTime.UtcNow - lastOutput).TotalSeconds > (delivered ? 12 : 35)) 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,60 @@
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);
private readonly List<Segment> segments = new();
private readonly object gate = new();
private long next, discontinuities;
private long clock90k = 126000;
private string previousEpoch;
private DateTime lastAppend, lastLive;
public ContinuityTimeline(DateTime now) { lastAppend = now; lastLive = DateTime.MinValue; Add(null, "waiting", "slate", SegmentSeconds, now); Add(null, "waiting", "slate", SegmentSeconds, now); }
private void Add(byte[] video, string slate, string epoch, double duration, DateTime now)
{
var discontinuity = next > 0 && (slate != null || previousEpoch != epoch);
segments.Add(new(next++, discontinuities, discontinuity, duration, video, slate, clock90k));
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 > 8 * 1024 * 1024 || duration <= 0 || duration > 5) throw new ArgumentException("Invalid live segment");
lock (gate) { lastLive = now; Add(video, null, epoch, duration, now); }
}
public void Tick(DateTime now, bool retrying)
{
lock (gate)
if ((now - lastAppend).TotalSeconds >= 2 && (now - lastLive).TotalSeconds >= 5)
Add(null, retrying ? "retrying" : "waiting", "slate", SegmentSeconds, 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:5\n");
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.
}
}
}
@@ -2,8 +2,8 @@
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks> <TargetFrameworks>net10.0</TargetFrameworks>
<AssemblyVersion>7.0.11.0</AssemblyVersion> <AssemblyVersion>7.0.12.0</AssemblyVersion>
<FileVersion>7.0.11.0</FileVersion> <FileVersion>7.0.12.0</FileVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
@@ -30,5 +30,5 @@
<PackageReference Include="Jellyfin.Model" Version="12.2.0" /> <PackageReference Include="Jellyfin.Model" Version="12.2.0" />
</ItemGroup> </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> </Project>
+30 -4
View File
@@ -36,6 +36,8 @@ namespace Jellyfin.Plugin.Enigma2
private readonly LiveStreamProbe _streamProbe; private readonly LiveStreamProbe _streamProbe;
private readonly LiveStreamRelay _streamRelay; private readonly LiveStreamRelay _streamRelay;
private readonly ILibraryManager _library; private readonly ILibraryManager _library;
private readonly PlaybackFeedback _feedback;
private readonly ContinuityStreams _continuity;
private string tvBouquetSRef; private string tvBouquetSRef;
private List<ChannelInfo> tvChannelInfos = new List<ChannelInfo>(); private List<ChannelInfo> tvChannelInfos = new List<ChannelInfo>();
@@ -46,13 +48,15 @@ namespace Jellyfin.Plugin.Enigma2
public static LiveTvService Instance { get; private set; } 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; _logger = logger;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_streamProbe = streamProbe; _streamProbe = streamProbe;
_streamRelay = streamRelay; _streamRelay = streamRelay;
_library = library; _library = library;
_feedback = feedback;
_continuity = continuity;
Instance = this; Instance = this;
} }
@@ -1143,7 +1147,15 @@ namespace Jellyfin.Plugin.Enigma2
public async Task<MediaSourceInfo> GetChannelStream(string channelOid, string mediaSourceId, CancellationToken cancellationToken) public async Task<MediaSourceInfo> GetChannelStream(string channelOid, string mediaSourceId, CancellationToken cancellationToken)
{ {
_logger.LogInformation("[Enigma2] Start GetChannelStream"); _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; var requestedReference = channelOid;
IEnumerable<ChannelInfo> currentChannels = Array.Empty<ChannelInfo>(); IEnumerable<ChannelInfo> currentChannels = Array.Empty<ChannelInfo>();
using (var lookupDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) using (var lookupDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
@@ -1214,14 +1226,23 @@ namespace Jellyfin.Plugin.Enigma2
var streamUrl = string.Format("{0}/{1}{2}", baseUrl, channelOid, trancodingUrl); var streamUrl = string.Format("{0}/{1}{2}", baseUrl, channelOid, trancodingUrl);
streamUrl = _streamRelay.Register(streamUrl); streamUrl = _streamRelay.Register(streamUrl);
// Background recordings keep the original source: never record an
// endless status slate or guess a viewer language for a timer.
if (Plugin.Instance.Configuration.EnableContinuitySlates && _continuity != null && feedback != null)
{
feedback.Report("waiting_for_stream");
return await _continuity.Open(streamUrl, feedback.Language, cancellationToken).ConfigureAwait(false);
}
var fields = channelOid.Split(':'); var 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 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 probe = _streamProbe.Get(streamUrl, cancellationToken, programme);
var streams = await (_feedback == null ? probe : _feedback.AwaitStream(probe, feedback, cancellationToken)).ConfigureAwait(false);
// A live TS may enumerate its EPG/data PID before AV on the next open. // 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. // Jellyfin maps positions in MediaStreams rather than MPEG-TS PID values.
// Its supported unknown-index path selects AV automatically and omits data. // Its supported unknown-index path selects AV automatically and omits data.
streams = streams.Select(s => LiveStreamProbe.ParseLiveMetadata(s)).ToList(); streams = streams.Select(s => LiveStreamProbe.ParseLiveMetadata(s)).ToList();
feedback?.Report("ready");
return new MediaSourceInfo return new MediaSourceInfo
{ {
@@ -1235,7 +1256,11 @@ namespace Jellyfin.Plugin.Enigma2
SupportsDirectPlay = false, SupportsDirectPlay = false,
MediaStreams = streams 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 +1653,7 @@ namespace Jellyfin.Plugin.Enigma2
public async Task CloseLiveStream(string id, CancellationToken cancellationToken) public async Task CloseLiveStream(string id, CancellationToken cancellationToken)
{ {
_continuity?.Close(id);
await Task.Factory.StartNew<string>(() => await Task.Factory.StartNew<string>(() =>
{ {
_logger.LogDebug("[Enigma2] LiveTvService.CloseLiveStream: closed stream for subscriptionId: {id}", id); _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Повторна спроба приймання каналу…"
}
}
+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.FromMinutes(2)).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.FromMinutes(2))
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) });
}
[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<InternetEpg>();
serviceCollection.AddSingleton<LiveStreamProbe>(); serviceCollection.AddSingleton<LiveStreamProbe>();
serviceCollection.AddSingleton<LiveStreamRelay>(); serviceCollection.AddSingleton<LiveStreamRelay>();
serviceCollection.AddHttpContextAccessor();
serviceCollection.AddSingleton<StartupMessages>();
serviceCollection.AddSingleton<ContinuityStreams>();
serviceCollection.AddSingleton<PlaybackFeedback>();
serviceCollection.AddSingleton<PreferencesStore>(); serviceCollection.AddSingleton<PreferencesStore>();
serviceCollection.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, NativeChannelFilter>(); serviceCollection.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, NativeChannelFilter>();
serviceCollection.AddSingleton<ILiveTvService, LiveTvService>(); serviceCollection.AddSingleton<ILiveTvService, LiveTvService>();
@@ -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,82 @@
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 byte[] Rebase(byte[] input, long videoStart90k)
{
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 = 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 = videoStart90k - (firstAudio ?? firstVideo.Value);
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();
}
}
+15
View File
@@ -94,3 +94,18 @@ 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. 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. 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.
+123
View File
@@ -0,0 +1,123 @@
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)
{
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);
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 pl = await streams.Open(source, "pl", CancellationToken.None);
var en = await streams.Open(source, "en", CancellationToken.None);
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, Width = ContinuityStreams.Width, Height = ContinuityStreams.Height, FrameRate = ContinuityStreams.FrameRate,
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", "-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 };
}
}
+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);
}
}
+3
View File
@@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
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; var time=DateTime.UtcNow;
void Check(bool ok,string name){if(!ok)throw new Exception(name);Console.WriteLine("PASS "+name);} void Check(bool ok,string name){if(!ok)throw new Exception(name);Console.WriteLine("PASS "+name);}
ChannelReferenceTests.Run(Check); ChannelReferenceTests.Run(Check);
@@ -126,6 +127,8 @@ using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler)
} }
await SavedChannelPlaybackTests.Run(Check); await SavedChannelPlaybackTests.Run(Check);
await ChannelSyncTests.Run(Check); await ChannelSyncTests.Run(Check);
await PlaybackFeedbackTests.Run(Check);
ContinuityTests.Run(Check);
public class PathsProxy : DispatchProxy public class PathsProxy : DispatchProxy
{ {
public string Folder; public string Folder;
+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 # The version of the plugin, starting at 1. Note that the AssemblyVersion
# and FileVersion flags in the `.csproj` file must have two additional # and FileVersion flags in the `.csproj` file must have two additional
# fiels, e.g. 1.0.0 to be built correctly. # fiels, e.g. 1.0.0 to be built correctly.
version: "7.0.11.0" # Please increment with each pull request version: "7.0.12.0" # Please increment with each pull request
# The supported Jellyfin version, usually the earliest binary-compatible # The supported Jellyfin version, usually the earliest binary-compatible
# version. Based on the Jellyfin components from NuGet. # version. Based on the Jellyfin components from NuGet.
+1 -1
View File
@@ -13,7 +13,7 @@ import urllib.error
import urllib.request import urllib.request
import uuid import uuid
VERSION = '7.0.11.0' VERSION = '7.0.12.0'
ABI = '12.2.0.0' ABI = '12.2.0.0'
GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a' GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a'
DLL = 'Jellyfin.Plugin.Enigma2.dll' DLL = 'Jellyfin.Plugin.Enigma2.dll'
+71
View File
@@ -0,0 +1,71 @@
"""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')
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, '--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:
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',
'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 = pixels(ffmpeg, root / (language + '.mkv'), 39)
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)
+42 -11
View File
@@ -3,7 +3,11 @@ const fs = require('node:fs');
const vm = require('node:vm'); const vm = require('node:vm');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const listeners = {}, documentListeners = {}, elements = []; 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 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; let policy = { IsEnigmaChannel: true, PreferH264: true }, policyCalls = 0, policyError = false;
const profile = { DirectPlayProfiles: [{ Type: 'Video', VideoCodec: 'av1,h264' }], const profile = { DirectPlayProfiles: [{ Type: 'Video', VideoCodec: 'av1,h264' }],
TranscodingProfiles: [{ Type: 'Video', Context: 'Streaming', VideoCodec: 'av1,h264', AudioCodec: 'aac', Protocol: 'hls', MaxWidth: 1920 }, TranscodingProfiles: [{ Type: 'Video', Context: 'Streaming', VideoCodec: 'av1,h264', AudioCodec: 'aac', Protocol: 'hls', MaxWidth: 1920 },
@@ -12,36 +16,47 @@ const profile = { DirectPlayProfiles: [{ Type: 'Video', VideoCodec: 'av1,h264' }
const pending = []; const pending = [];
const location = { hash: '#/livetv', pathname: '/web/index.html', search: '', replace(value) { this.hash = value; } }; const location = { hash: '#/livetv', pathname: '/web/index.html', search: '', replace(value) { this.hash = value; } };
const document = { const document = {
fullscreenElement: null, fullscreenElement: null, documentElement: { lang: "de-DE" },
body: { append(element) { elements.push(element); } }, body: { append(element) { elements.push(element); } },
createElement() { return { style: {}, remove() { elements.splice(elements.indexOf(this), 1); } }; }, createElement() { return { style: {}, children: [], attributes: {}, setAttribute(key, value) { this.attributes[key] = value; },
querySelector() { return { readyState, currentTime: 0 }; }, 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; } addEventListener(name, callback) { documentListeners[name] = callback; }
}; };
const window = { const window = {
ApiClient: { getCurrentUserId: () => 'user', serverId: () => 'server', getItem: async (user, id) => ({ Id: id, Type: 'TvChannel' }), ApiClient: { getCurrentUserId: () => 'user', serverId: () => 'server', getItem: async (user, id) => ({ Id: id, Type: 'TvChannel' }),
getUrl: (path, params) => path + '?' + new URLSearchParams(params), 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]] };
}
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; } addEventListener(name, callback) { listeners[name] = callback; }
}; };
const context = { const context = {
window, document, location, console, URLSearchParams, window, document, location, console, URLSearchParams,
history: { replaceState(a, b, url) { location.hash = url.slice(url.indexOf('#')); } }, 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; }, setInterval(callback) { interval = callback; return callback; },
clearInterval(callback) { if (interval === callback) interval = null; } clearInterval(callback) { if (interval === callback) interval = null; }
}; };
const manager = { const manager = {
getCurrentPlayer: () => ({ isLocalPlayer: !remote }), 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; } 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 () => { (async () => {
vm.runInNewContext(fs.readFileSync(__dirname + '/web/decoder-channel-playback.js', 'utf8'), context); vm.runInNewContext(fs.readFileSync(__dirname + '/web/decoder-channel-playback.js', 'utf8'), context);
const Plugin = await window.DecoderChannelPlayback(); const Plugin = await window.DecoderChannelPlayback();
const appHost = { getDeviceProfile: async () => profile }; 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'; } } }; appRouter: { showVideoOsd: async () => { ++osd; }, show: async () => { ++returned; location.hash = '#/livetv?tab=2'; } } };
new Plugin(dependencies); new Plugin(dependencies);
const channel = { Id: 'channel', Type: 'TvChannel', ServerId: 'server' }; const channel = { Id: 'channel', Type: 'TvChannel', ServerId: 'server' };
@@ -70,17 +85,33 @@ 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() {} }); const click = () => documentListeners.click({ button: 0, target: { closest: () => ({ closest: () => ({ dataset: { id: 'channel', serverid: 'server' } }) }) }, preventDefault() {}, stopImmediatePropagation() {} });
click(); click(); await flush(); click(); click(); await flush();
assert.equal(starts, 1); assert.equal(elements[0].id, 'decoderPlaybackExit'); 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'); 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); readyState=1;interval(); await flush(); assert.equal(osd, 1);
listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} }); listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} });
await flush(); assert.equal(stops, 1); assert.equal(returned, 1); assert.equal(elements.length, 0); 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'); pending[0](); await flush(); assert.equal(stops, 1, 'late old completion must not stop the new channel');
clock = 35000; interval(); await flush(); clock = 35000; interval(); await flush();
assert.equal(stops, 1, 'pending startup must survive 35 seconds'); 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(); clock = 45001; interval(); await flush();
assert.equal(stops, 2); assert.equal(returned, 2); assert.equal(elements.length, 0); assert.equal(stops, 2); assert.equal(returned, 2); assert.equal(elements.length, 0);
pending[1](); await flush(); 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();
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'); 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; }); })().catch(error => { console.error(error); process.exitCode = 1; });
+70 -10
View File
@@ -34,12 +34,48 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
try { appHost.getDeviceProfile = getDeviceProfile; } try { appHost.getDeviceProfile = getDeviceProfile; }
catch { /* A client exposing a read-only host keeps its native negotiation. */ } catch { /* A client exposing a read-only host keeps its native negotiation. */ }
} }
let generation = 0, starting = null, cancelButton = null, watcher = null; // Jellyfin's globalize module sets <html lang> to the current user's UI
const clearControls = () => { clearInterval(watcher); watcher = null; cancelButton?.remove(); cancelButton = null; }; // 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 } : 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, 'playbackstop', () => { if (!starting) clearControls(); });
events.on(playbackManager, 'playbackstart', () => { ++playbackSequence; });
document.addEventListener('fullscreenchange', () => { document.addEventListener('fullscreenchange', () => {
if (cancelButton) (document.fullscreenElement || document.body).append(cancelButton); 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 () => { const leave = async () => {
++generation; starting = null; clearControls(); ++generation; starting = null; clearControls();
try { await playbackManager.stop(); } finally { loading.hide(); await appRouter.show('livetv?tab=2'); } try { await playbackManager.stop(); } finally { loading.hide(); await appRouter.show('livetv?tab=2'); }
@@ -51,29 +87,53 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
request = (async () => { request = (async () => {
const api = window.ApiClient; const api = window.ApiClient;
if (!api?.getCurrentUserId() || (serverId && api.serverId() !== serverId)) return false; 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; if (ticket !== generation) return false;
messages = localized;
if (item.Type !== 'TvChannel') throw Error('Expected a TV channel'); if (item.Type !== 'TvChannel') throw Error('Expected a TV channel');
clearControls(); clearControls();
cancelButton = document.createElement('button'); 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.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); cancelButton.onclick = () => void leave(); document.body.append(cancelButton);
showStatus(item.Name);
// Allow the bounded metadata probe (25 s) plus HLS startup (20 s). // Allow the bounded metadata probe (25 s) plus HLS startup (20 s).
let opened = false, opening = false; const deadline = Date.now() + 45000; let opened = false, opening = false, hasPlayed = false, polling = false, pollFailures = 0, lastPoll = -Infinity;
const began = Date.now(), deadline = began + 45000, initialVideo = document.querySelector('video'), initialSource = initialVideo?.currentSrc, initialSequence = playbackSequence;
watcher = setInterval(() => { watcher = setInterval(() => {
if (ticket !== generation) return; if (ticket !== generation) return;
const video = document.querySelector('video'); const video = document.querySelector('video');
const player = playbackManager.getCurrentPlayer(); const player = playbackManager.getCurrentPlayer();
if (player?.isLocalPlayer === false) { clearControls(); return; } 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; opening = true;
Promise.resolve(appRouter.showVideoOsd()).then(() => { opened = true; }) Promise.resolve(appRouter.showVideoOsd()).then(() => { opened = true; })
.catch(() => { /* A cancelled/unready native route can be retried. */ }) .catch(() => { /* A cancelled/unready native route can be retried. */ })
.finally(() => { opening = false; }); .finally(() => { opening = false; });
} }
if (video?.readyState >= 2 && video.currentTime > 0) return; if (currentVideo && video.readyState >= 2 && video.currentTime > 0) {
if (Date.now() >= deadline) { void leave(); toast('Kanał nie przesyła obrazu. Powrót do listy kanałów.'); } 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); }, 250);
try { await playbackManager.play({ items: [item], fullscreen: true }); } try { await playbackManager.play({ items: [item], fullscreen: true }); }
catch (error) { if (ticket !== generation) return false; await leave(); throw error; } catch (error) { if (ticket !== generation) return false; await leave(); throw error; }
@@ -106,7 +166,7 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
query.delete('decoderPlay'); query.delete('decoderPlay');
history.replaceState(null, '', location.pathname + location.search + route.split('?')[0] + (query.size ? '?' + query : '')); history.replaceState(null, '', location.pathname + location.search + route.split('?')[0] + (query.size ? '?' + query : ''));
try { await play(id, query.get('serverId')); } 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(); void attempt();
}; };
@@ -123,7 +183,7 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
event.preventDefault(); event.stopImmediatePropagation(); event.preventDefault(); event.stopImmediatePropagation();
void play(id, card.dataset.serverid).catch(error => { void play(id, card.dataset.serverid).catch(error => {
console.error('Decoder channel playback failed', error); console.error('Decoder channel playback failed', error);
toast('Nie udało się uruchomić kanału.'); toast(text('play_error'));
}); });
}, true); }, true);
} }