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 PreferH264ForWebLiveTv { get; set; } = true;
public bool EnableAutomaticChannelSync { get; set; } = true;
public bool ShowNativeStartupMessages { get; set; } = true;
public bool EnableContinuitySlates { get; set; } = false;
public PluginConfiguration()
@@ -153,6 +153,16 @@
<span>Preferuj H264 przy transkodowaniu telewizji w przeglądarce</span>
</label>
<div class="fieldDescription">Ogranicza transkodowanie kanałów Enigma2 w aplikacji webowej do H264. Ułatwia odtwarzanie na urządzeniach bez wydajnego dekodowania AV1. Zmiana dotyczy kolejnego uruchomienia kanału.</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkEnableContinuitySlates" />
<span>Podtrzymuj stream planszą FFmpeg podczas oczekiwania i przerw</span>
</label>
<div class="fieldDescription">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">
<input is="emby-checkbox" type="checkbox" id="chkDebugLogging" />
<span>Enable Enigma2 debug logging</span>
@@ -201,6 +211,8 @@
document.getElementById('chkEnableVuRecordings').checked = config.EnableVuRecordings || false;
document.getElementById('chkPreferH264ForWebLiveTv').checked = config.PreferH264ForWebLiveTv !== false;
document.getElementById('chkEnableAutomaticChannelSync').checked = config.EnableAutomaticChannelSync !== false;
document.getElementById('chkShowNativeStartupMessages').checked = config.ShowNativeStartupMessages !== false;
document.getElementById('chkEnableContinuitySlates').checked = config.EnableContinuitySlates === true;
Dashboard.hideLoadingMsg();
});
@@ -230,6 +242,8 @@
config.EnableVuRecordings = document.getElementById('chkEnableVuRecordings').checked;
config.PreferH264ForWebLiveTv = document.getElementById('chkPreferH264ForWebLiveTv').checked;
config.EnableAutomaticChannelSync = document.getElementById('chkEnableAutomaticChannelSync').checked;
config.ShowNativeStartupMessages = document.getElementById('chkShowNativeStartupMessages').checked;
config.EnableContinuitySlates = document.getElementById('chkEnableContinuitySlates').checked;
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>
<TargetFrameworks>net10.0</TargetFrameworks>
<AssemblyVersion>7.0.11.0</AssemblyVersion>
<FileVersion>7.0.11.0</FileVersion>
<AssemblyVersion>7.0.12.0</AssemblyVersion>
<FileVersion>7.0.12.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
@@ -30,5 +30,5 @@
<PackageReference Include="Jellyfin.Model" Version="12.2.0" />
</ItemGroup>
<ItemGroup><FrameworkReference Include="Microsoft.AspNetCore.App" /><EmbeddedResource Include="Configuration/channels.html" /></ItemGroup>
<ItemGroup><FrameworkReference Include="Microsoft.AspNetCore.App" /><EmbeddedResource Include="Configuration/channels.html" /><EmbeddedResource Include="Localization/startup-messages.json" /></ItemGroup>
</Project>
+30 -4
View File
@@ -36,6 +36,8 @@ namespace Jellyfin.Plugin.Enigma2
private readonly LiveStreamProbe _streamProbe;
private readonly LiveStreamRelay _streamRelay;
private readonly ILibraryManager _library;
private readonly PlaybackFeedback _feedback;
private readonly ContinuityStreams _continuity;
private string tvBouquetSRef;
private List<ChannelInfo> tvChannelInfos = new List<ChannelInfo>();
@@ -46,13 +48,15 @@ namespace Jellyfin.Plugin.Enigma2
public static LiveTvService Instance { get; private set; }
public LiveTvService(ILogger<LiveTvService> logger, IHttpClientFactory httpClientFactory, InternetEpg internetEpg, LiveStreamProbe streamProbe, LiveStreamRelay streamRelay, ILibraryManager library)
public LiveTvService(ILogger<LiveTvService> logger, IHttpClientFactory httpClientFactory, InternetEpg internetEpg, LiveStreamProbe streamProbe, LiveStreamRelay streamRelay, ILibraryManager library, PlaybackFeedback feedback = null, ContinuityStreams continuity = null)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_streamProbe = streamProbe;
_streamRelay = streamRelay;
_library = library;
_feedback = feedback;
_continuity = continuity;
Instance = this;
}
@@ -1143,7 +1147,15 @@ namespace Jellyfin.Plugin.Enigma2
public async Task<MediaSourceInfo> GetChannelStream(string channelOid, string mediaSourceId, CancellationToken cancellationToken)
{
_logger.LogInformation("[Enigma2] Start GetChannelStream");
PlaybackFeedback.Attempt feedback = null;
if (_feedback != null)
{
try { feedback = await _feedback.Begin(channelOid, cancellationToken).ConfigureAwait(false); }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { /* Feedback is optional and must not affect playback. */ }
}
try
{
var requestedReference = channelOid;
IEnumerable<ChannelInfo> currentChannels = Array.Empty<ChannelInfo>();
using (var lookupDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
@@ -1214,14 +1226,23 @@ namespace Jellyfin.Plugin.Enigma2
var streamUrl = string.Format("{0}/{1}{2}", baseUrl, channelOid, trancodingUrl);
streamUrl = _streamRelay.Register(streamUrl);
// Background recordings keep the original source: never record an
// endless status slate or guess a viewer language for a timer.
if (Plugin.Instance.Configuration.EnableContinuitySlates && _continuity != null && feedback != null)
{
feedback.Report("waiting_for_stream");
return await _continuity.Open(streamUrl, feedback.Language, cancellationToken).ConfigureAwait(false);
}
var fields = channelOid.Split(':');
var programme = !Plugin.Instance.Configuration.TranscodedStream && fields.Length > 3 && int.TryParse(fields[3], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var sid) ? sid : (int?)null;
var 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.
// Jellyfin maps positions in MediaStreams rather than MPEG-TS PID values.
// Its supported unknown-index path selects AV automatically and omits data.
streams = streams.Select(s => LiveStreamProbe.ParseLiveMetadata(s)).ToList();
feedback?.Report("ready");
return new MediaSourceInfo
{
@@ -1235,7 +1256,11 @@ namespace Jellyfin.Plugin.Enigma2
SupportsDirectPlay = false,
MediaStreams = streams
};
throw new ResourceNotFoundException(string.Format("Could not stream channel {0}", channelOid));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ feedback?.Report("cancelled"); throw; }
catch
{ feedback?.Report("failed"); throw; }
}
@@ -1628,6 +1653,7 @@ namespace Jellyfin.Plugin.Enigma2
public async Task CloseLiveStream(string id, CancellationToken cancellationToken)
{
_continuity?.Close(id);
await Task.Factory.StartNew<string>(() =>
{
_logger.LogDebug("[Enigma2] LiveTvService.CloseLiveStream: closed stream for subscriptionId: {id}", id);
@@ -0,0 +1,128 @@
{
"en": {
"header": "Television",
"preparing": "Preparing channel…",
"checking_stream": "Waiting for video and audio…",
"waiting_for_stream": "Still waiting for a usable stream…",
"ready": "Stream detected. Starting player…",
"failed": "Unable to prepare the stream.",
"buffering": "Buffering video…",
"help": "The receiver may need several seconds. You can cancel or press Esc.",
"cancel": "Stop and return to channels",
"elapsed": "Waiting: {seconds} s",
"timeout": "The channel is not providing video. Returning to the channel list.",
"play_error": "Unable to start the channel.",
"slate_waiting": "Waiting for the stream…\nThe receiver is preparing video and audio.",
"slate_failed": "Unable to receive the channel.\nReturn to the list and try again.",
"native_preparing": "Starting channel. The receiver may need several seconds.",
"native_waiting": "Still waiting for usable video and audio. Press Back to return.",
"native_ready": "Stream detected. Starting player.",
"native_failed": "Unable to prepare the stream. Return to channels and try again.",
"slate_retrying": "The stream was interrupted.\nRetrying channel reception…"
},
"pl": {
"header": "Telewizja",
"preparing": "Przygotowywanie kanału…",
"checking_stream": "Oczekiwanie na obraz i dźwięk…",
"waiting_for_stream": "Nadal czekamy na użyteczny strumień…",
"ready": "Strumień rozpoznany. Uruchamianie odtwarzacza…",
"failed": "Nie udało się przygotować strumienia.",
"buffering": "Buforowanie obrazu…",
"help": "Dekoder może potrzebować kilkunastu sekund. Możesz anulować lub nacisnąć Esc.",
"cancel": "Zatrzymaj i wróć do kanałów",
"elapsed": "Czas oczekiwania: {seconds} s",
"timeout": "Kanał nie przesyła obrazu. Powrót do listy kanałów.",
"play_error": "Nie udało się uruchomić kanału.",
"slate_waiting": "Oczekiwanie na strumień…\nDekoder przygotowuje obraz i dźwięk.",
"slate_failed": "Nie udało się odebrać kanału.\nWróć do listy i spróbuj ponownie.",
"native_preparing": "Uruchamianie kanału. Dekoder może potrzebować kilkunastu sekund.",
"native_waiting": "Nadal czekamy na użyteczny obraz i dźwięk. Możesz wrócić przyciskiem Wstecz.",
"native_ready": "Strumień rozpoznany. Uruchamianie odtwarzacza.",
"native_failed": "Nie udało się przygotować strumienia. Wróć do kanałów i spróbuj ponownie.",
"slate_retrying": "Strumień został przerwany.\nPonawianie odbioru kanału…"
},
"de": {
"header": "Fernsehen",
"preparing": "Sender wird vorbereitet…",
"checking_stream": "Warten auf Bild und Ton…",
"waiting_for_stream": "Weiterhin warten auf einen nutzbaren Stream…",
"ready": "Stream erkannt. Player wird gestartet…",
"failed": "Der Stream konnte nicht vorbereitet werden.",
"buffering": "Video wird gepuffert…",
"help": "Der Receiver benötigt möglicherweise einige Sekunden. Abbrechen oder Esc drücken.",
"cancel": "Stoppen und zur Senderliste zurückkehren",
"elapsed": "Wartezeit: {seconds} s",
"timeout": "Der Sender liefert kein Bild. Zurück zur Senderliste.",
"play_error": "Der Sender konnte nicht gestartet werden.",
"slate_waiting": "Warten auf den Stream…\nDer Receiver bereitet Bild und Ton vor.",
"slate_failed": "Senderempfang nicht möglich.\nZur Liste zurückkehren und erneut versuchen.",
"native_preparing": "Sender wird gestartet. Der Receiver benötigt möglicherweise einige Sekunden.",
"native_waiting": "Weiterhin warten auf nutzbares Bild und Ton. Mit Zurück zur Liste wechseln.",
"native_ready": "Stream erkannt. Player wird gestartet.",
"native_failed": "Der Stream konnte nicht vorbereitet werden. Zur Senderliste zurückkehren und erneut versuchen.",
"slate_retrying": "Der Stream wurde unterbrochen.\nSenderempfang wird erneut versucht…"
},
"fr": {
"header": "Télévision",
"preparing": "Préparation de la chaîne…",
"checking_stream": "En attente de l’image et du son…",
"waiting_for_stream": "Toujours en attente d’un flux utilisable…",
"ready": "Flux détecté. Démarrage du lecteur…",
"failed": "Impossible de préparer le flux.",
"buffering": "Mise en mémoire tampon…",
"help": "Le décodeur peut avoir besoin de quelques secondes. Vous pouvez annuler ou appuyer sur Échap.",
"cancel": "Arrêter et revenir aux chaînes",
"elapsed": "Attente : {seconds} s",
"timeout": "La chaîne ne fournit pas d’image. Retour à la liste des chaînes.",
"play_error": "Impossible de démarrer la chaîne.",
"slate_waiting": "En attente du flux…\nLe décodeur prépare l’image et le son.",
"slate_failed": "Impossible de recevoir la chaîne.\nRevenez à la liste et réessayez.",
"native_preparing": "Démarrage de la chaîne. Le décodeur peut avoir besoin de quelques secondes.",
"native_waiting": "Toujours en attente de l’image et du son. Appuyez sur Retour pour revenir.",
"native_ready": "Flux détecté. Démarrage du lecteur.",
"native_failed": "Impossible de préparer le flux. Revenez aux chaînes et réessayez.",
"slate_retrying": "Le flux a été interrompu.\nNouvelle tentative de réception…"
},
"es": {
"header": "Televisión",
"preparing": "Preparando el canal…",
"checking_stream": "Esperando imagen y sonido…",
"waiting_for_stream": "Seguimos esperando un flujo válido…",
"ready": "Flujo detectado. Iniciando el reproductor…",
"failed": "No se pudo preparar el flujo.",
"buffering": "Cargando vídeo…",
"help": "El receptor puede necesitar varios segundos. Puedes cancelar o pulsar Esc.",
"cancel": "Detener y volver a los canales",
"elapsed": "Espera: {seconds} s",
"timeout": "El canal no proporciona imagen. Volviendo a la lista de canales.",
"play_error": "No se pudo iniciar el canal.",
"slate_waiting": "Esperando el flujo…\nEl receptor está preparando imagen y sonido.",
"slate_failed": "No se pudo recibir el canal.\nVuelve a la lista e inténtalo de nuevo.",
"native_preparing": "Iniciando el canal. El receptor puede necesitar varios segundos.",
"native_waiting": "Seguimos esperando imagen y sonido. Pulsa Atrás para volver.",
"native_ready": "Flujo detectado. Iniciando el reproductor.",
"native_failed": "No se pudo preparar el flujo. Vuelve a los canales e inténtalo de nuevo.",
"slate_retrying": "El flujo se ha interrumpido.\nReintentando la recepción del canal…"
},
"uk": {
"header": "Телебачення",
"preparing": "Підготовка каналу…",
"checking_stream": "Очікування зображення та звуку…",
"waiting_for_stream": "Триває очікування придатного потоку…",
"ready": "Потік виявлено. Запуск програвача…",
"failed": "Не вдалося підготувати потік.",
"buffering": "Буферизація відео…",
"help": "Приймачу може знадобитися кілька секунд. Можна скасувати або натиснути Esc.",
"cancel": "Зупинити та повернутися до каналів",
"elapsed": "Очікування: {seconds} с",
"timeout": "Канал не передає зображення. Повернення до списку каналів.",
"play_error": "Не вдалося запустити канал.",
"slate_waiting": "Очікування потоку…\nПриймач готує зображення та звук.",
"slate_failed": "Не вдалося прийняти канал.\nПоверніться до списку та спробуйте ще раз.",
"native_preparing": "Запуск каналу. Приймачу може знадобитися кілька секунд.",
"native_waiting": "Триває очікування зображення та звуку. Натисніть Назад, щоб повернутися.",
"native_ready": "Потік виявлено. Запуск програвача.",
"native_failed": "Не вдалося підготувати потік. Поверніться до каналів та спробуйте ще раз.",
"slate_retrying": "Потік перервано.\nПовторна спроба приймання каналу…"
}
}
+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<LiveStreamProbe>();
serviceCollection.AddSingleton<LiveStreamRelay>();
serviceCollection.AddHttpContextAccessor();
serviceCollection.AddSingleton<StartupMessages>();
serviceCollection.AddSingleton<ContinuityStreams>();
serviceCollection.AddSingleton<PlaybackFeedback>();
serviceCollection.AddSingleton<PreferencesStore>();
serviceCollection.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, NativeChannelFilter>();
serviceCollection.AddSingleton<ILiveTvService, LiveTvService>();
@@ -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();
}
}