Share decoder live sources and bound web playback startup
This commit is contained in:
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
<AssemblyVersion>7.0.1.0</AssemblyVersion>
|
<AssemblyVersion>7.0.4.0</AssemblyVersion>
|
||||||
<FileVersion>7.0.1.0</FileVersion>
|
<FileVersion>7.0.4.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Enigma2;
|
||||||
|
|
||||||
|
/// <summary>One bounded metadata probe per source, shared by concurrent playback requests.</summary>
|
||||||
|
public sealed class LiveStreamProbe
|
||||||
|
{
|
||||||
|
private readonly Func<string, CancellationToken, Task<string>> runner;
|
||||||
|
private readonly Dictionary<string, (Task<List<MediaStream>> Task, DateTime Until)> cache = new();
|
||||||
|
private readonly object gate = new();
|
||||||
|
private readonly SemaphoreSlim slots = new(8);
|
||||||
|
private readonly TimeSpan timeout;
|
||||||
|
public string LastError { get; private set; }
|
||||||
|
public LiveStreamProbe(IMediaEncoder encoder) : this((url, token) => Run(encoder.ProbePath, url, token)) { }
|
||||||
|
public LiveStreamProbe(Func<string, CancellationToken, Task<string>> runner, TimeSpan? timeout = null) { this.runner = runner; this.timeout = timeout ?? TimeSpan.FromSeconds(15); }
|
||||||
|
|
||||||
|
public Task<List<MediaStream>> Get(string url, CancellationToken caller)
|
||||||
|
{
|
||||||
|
Task<List<MediaStream>> task;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (cache.TryGetValue(url, out var existing) && existing.Until > DateTime.UtcNow) task = existing.Task;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var expired in cache.Where(x => x.Value.Until <= DateTime.UtcNow).Select(x => x.Key).ToArray()) cache.Remove(expired);
|
||||||
|
var completion = new TaskCompletionSource<List<MediaStream>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
task = completion.Task;
|
||||||
|
cache[url] = (task, DateTime.MaxValue);
|
||||||
|
_ = Produce(url, completion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return task.WaitAsync(caller);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Produce(string url, TaskCompletionSource<List<MediaStream>> completion)
|
||||||
|
{
|
||||||
|
var success = false;
|
||||||
|
using var deadline = new CancellationTokenSource(timeout);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await slots.WaitAsync(deadline.Token);
|
||||||
|
try { completion.TrySetResult(Parse(await runner(url, deadline.Token))); success = true; }
|
||||||
|
finally { slots.Release(); }
|
||||||
|
}
|
||||||
|
catch (Exception error) { LastError = error.Message; completion.TrySetException(new InvalidOperationException("Decoder returned no usable live stream within the probe deadline.", error)); }
|
||||||
|
finally { lock (gate) cache[url] = (completion.Task, DateTime.UtcNow.AddSeconds(success ? 30 : 3)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> Run(string executable, string url, CancellationToken token)
|
||||||
|
{
|
||||||
|
using var process = new Process { StartInfo = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } };
|
||||||
|
foreach (var argument in new[] { "-v", "error", "-rw_timeout", "8000000", "-seekable", "0", "-skip_estimate_duration_from_pts", "1", "-f", "mpegts", "-analyzeduration", "1500000", "-probesize", "1048576", "-fpsprobesize", "10", "-i", url, "-show_streams", "-of", "json" }) process.StartInfo.ArgumentList.Add(argument);
|
||||||
|
process.Start();
|
||||||
|
var output = process.StandardOutput.ReadToEndAsync();
|
||||||
|
var errors = process.StandardError.ReadToEndAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(token);
|
||||||
|
var diagnostics = await errors;
|
||||||
|
if (process.ExitCode != 0) throw new InvalidOperationException("Decoder stream probe failed: " + System.Text.RegularExpressions.Regex.Replace(diagnostics, @"https?://[^\s]+", "<decoder-stream>"));
|
||||||
|
return await output;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); }
|
||||||
|
await Task.WhenAll(output, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<MediaStream> Parse(string json)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
var result = new List<MediaStream>();
|
||||||
|
foreach (var item in document.RootElement.GetProperty("streams").EnumerateArray())
|
||||||
|
{
|
||||||
|
string Text(string field) => item.TryGetProperty(field, out var value) ? value.ToString() : null;
|
||||||
|
int? Number(string field) => int.TryParse(Text(field), out var value) ? value : null;
|
||||||
|
var type = Text("codec_type");
|
||||||
|
if (type != "video" && type != "audio") continue;
|
||||||
|
var codec = Text("codec_name"); var index = Number("index");
|
||||||
|
if (string.IsNullOrEmpty(codec) || index == null) continue;
|
||||||
|
float? Rate(string field)
|
||||||
|
{
|
||||||
|
var values = (Text(field) ?? "").Split('/');
|
||||||
|
if (!float.TryParse(values[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var numerator)) return null;
|
||||||
|
if (values.Length == 1) return numerator;
|
||||||
|
return float.TryParse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator) && denominator != 0 ? numerator / denominator : null;
|
||||||
|
}
|
||||||
|
var stream = new MediaStream { Type = type == "video" ? MediaStreamType.Video : MediaStreamType.Audio, Index = index.Value, Codec = codec, Width = Number("width"), Height = Number("height"), Channels = Number("channels"), SampleRate = Number("sample_rate"), BitRate = Number("bit_rate"), Profile = Text("profile"), Level = Number("level"), PixelFormat = Text("pix_fmt"), ColorRange = Text("color_range"), ColorSpace = Text("color_space"), ColorTransfer = Text("color_transfer"), ColorPrimaries = Text("color_primaries"), AverageFrameRate = Rate("avg_frame_rate"), RealFrameRate = Rate("r_frame_rate"), IsInterlaced = Text("field_order") is "tt" or "bb" or "tb" or "bt", ChannelLayout = Text("channel_layout") };
|
||||||
|
if (item.TryGetProperty("tags", out var tags) && tags.TryGetProperty("language", out var language)) stream.Language = language.GetString();
|
||||||
|
result.Add(stream);
|
||||||
|
}
|
||||||
|
if (!result.Any(s => s.Type == MediaStreamType.Video && s.Width > 0 && s.Height > 0)) throw new InvalidOperationException("Decoder stream has no identified video.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Enigma2;
|
||||||
|
|
||||||
|
/// <summary>Shares one receiver HTTP connection between probing and all consumers of a channel.</summary>
|
||||||
|
public sealed class LiveStreamRelay : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient client;
|
||||||
|
private readonly int port;
|
||||||
|
private readonly Dictionary<string, Source> sources = new();
|
||||||
|
private readonly Dictionary<string, Source> tokens = new();
|
||||||
|
private readonly object gate = new();
|
||||||
|
private int opened;
|
||||||
|
private readonly TimeSpan idleGrace;
|
||||||
|
public LiveStreamRelay(IServerApplicationHost host) : this(new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3), AllowAutoRedirect = false }), host.HttpPort) { }
|
||||||
|
public LiveStreamRelay(HttpClient client, int port, TimeSpan? idleGrace = null) { this.client = client; client.Timeout = Timeout.InfiniteTimeSpan; this.port = port; this.idleGrace = idleGrace ?? TimeSpan.FromSeconds(15); }
|
||||||
|
public string Register(string url)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (!sources.TryGetValue(url, out var source))
|
||||||
|
{
|
||||||
|
source = new Source(this, url);
|
||||||
|
sources.Add(url, source); tokens.Add(source.Id, source);
|
||||||
|
}
|
||||||
|
return $"http://127.0.0.1:{port}/DecoderIntegration/Live/{source.Id}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public Task<Subscription> Subscribe(string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
Source source;
|
||||||
|
lock (gate) if (!tokens.TryGetValue(id, out source)) throw new FileNotFoundException();
|
||||||
|
return source.Subscribe(token);
|
||||||
|
}
|
||||||
|
public object Status()
|
||||||
|
{
|
||||||
|
lock (gate) return new { UpstreamConnectionsOpened = opened, ActiveSources = sources.Values.Count(s => s.Active), Consumers = sources.Values.Sum(s => s.Consumers), LastErrors = sources.Values.Select(s => s.LastError).Where(s => s != null).ToArray(), Channels = sources.Values.Select(s => new { s.Id, s.ServiceReference, s.Active, s.Consumers }).ToArray() };
|
||||||
|
}
|
||||||
|
public void Dispose() { lock (gate) foreach (var s in sources.Values) s.Stop(); client.Dispose(); }
|
||||||
|
|
||||||
|
public sealed class Subscription : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly Source source;
|
||||||
|
internal readonly Channel<byte[]> Buffer = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(128) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true });
|
||||||
|
internal Subscription(Source source) { this.source = source; }
|
||||||
|
public ChannelReader<byte[]> Reader => Buffer.Reader;
|
||||||
|
public ValueTask DisposeAsync() { source.Remove(this); return ValueTask.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Source
|
||||||
|
{
|
||||||
|
private readonly LiveStreamRelay owner;
|
||||||
|
private readonly string url;
|
||||||
|
private readonly object gate = new();
|
||||||
|
private readonly HashSet<Subscription> subscribers = new();
|
||||||
|
private readonly Queue<byte[]> history = new();
|
||||||
|
private int historyBytes;
|
||||||
|
private CancellationTokenSource lifetime;
|
||||||
|
private CancellationTokenSource idle;
|
||||||
|
private Task pump;
|
||||||
|
private TaskCompletionSource ready;
|
||||||
|
private DateTime failedUntil;
|
||||||
|
public string Id { get; } = Guid.NewGuid().ToString("N");
|
||||||
|
public string ServiceReference => new Uri(url).AbsolutePath;
|
||||||
|
public string LastError { get; private set; }
|
||||||
|
public bool Active { get { lock (gate) return pump != null && !pump.IsCompleted; } }
|
||||||
|
public int Consumers { get { lock (gate) return subscribers.Count; } }
|
||||||
|
public Source(LiveStreamRelay owner, string url) { this.owner = owner; this.url = url; }
|
||||||
|
public async Task<Subscription> Subscribe(CancellationToken token)
|
||||||
|
{
|
||||||
|
Subscription subscription; Task initial;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
idle?.Cancel(); idle?.Dispose(); idle = null;
|
||||||
|
if (subscribers.Count >= 16) throw new IOException("Channel consumer limit reached.");
|
||||||
|
if (pump == null || pump.IsCompleted)
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow < failedUntil) throw new IOException("Decoder source is unavailable.");
|
||||||
|
lifetime?.Dispose(); lifetime = new();
|
||||||
|
history.Clear(); historyBytes = 0;
|
||||||
|
ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
pump = Task.Run(() => Pump(lifetime.Token));
|
||||||
|
}
|
||||||
|
subscription = new(this); subscribers.Add(subscription);
|
||||||
|
foreach (var chunk in history) subscription.Buffer.Writer.TryWrite(chunk);
|
||||||
|
initial = ready.Task;
|
||||||
|
}
|
||||||
|
try { await initial.WaitAsync(TimeSpan.FromSeconds(12), token); return subscription; }
|
||||||
|
catch { await subscription.DisposeAsync(); throw; }
|
||||||
|
}
|
||||||
|
public void Remove(Subscription subscription)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (!subscribers.Remove(subscription)) return;
|
||||||
|
subscription.Buffer.Writer.TryComplete();
|
||||||
|
if (subscribers.Count == 0 && pump != null && !pump.IsCompleted)
|
||||||
|
{
|
||||||
|
idle?.Cancel(); idle?.Dispose(); idle = new();
|
||||||
|
var timer = idle.Token;
|
||||||
|
var current = lifetime;
|
||||||
|
_ = Task.Run(async () => {
|
||||||
|
try { await Task.Delay(owner.idleGrace, timer); lock (gate) if (subscribers.Count == 0 && lifetime == current) current.Cancel(); }
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public void Stop() { lock (gate) { idle?.Cancel(); lifetime?.Cancel(); } }
|
||||||
|
private async Task Pump(CancellationToken token)
|
||||||
|
{
|
||||||
|
Exception failure = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var opening = CancellationTokenSource.CreateLinkedTokenSource(token); opening.CancelAfter(TimeSpan.FromSeconds(5));
|
||||||
|
Interlocked.Increment(ref owner.opened);
|
||||||
|
using var response = await owner.client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, opening.Token);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using var stream = await response.Content.ReadAsStreamAsync(token);
|
||||||
|
var buffer = new byte[188 * 256 + 188]; var carry = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
using var reading = CancellationTokenSource.CreateLinkedTokenSource(token); reading.CancelAfter(TimeSpan.FromSeconds(8));
|
||||||
|
var count = await stream.ReadAtLeastAsync(buffer.AsMemory(carry, buffer.Length - carry), 188 * 256 - carry, throwOnEndOfStream: false, cancellationToken: reading.Token);
|
||||||
|
if (count == 0) throw new IOException("Decoder closed the live stream.");
|
||||||
|
count += carry; var bytes = count / 188 * 188;
|
||||||
|
if (bytes > 0)
|
||||||
|
{
|
||||||
|
if (buffer[0] != 0x47 || (bytes >= 376 && buffer[188] != 0x47)) throw new IOException("Decoder did not return MPEG-TS packets.");
|
||||||
|
var chunk = buffer.AsSpan(0, bytes).ToArray();
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
history.Enqueue(chunk); historyBytes += bytes;
|
||||||
|
while (historyBytes > 2 * 1024 * 1024) historyBytes -= history.Dequeue().Length;
|
||||||
|
foreach (var subscriber in subscribers.ToArray())
|
||||||
|
if (!subscriber.Buffer.Writer.TryWrite(chunk)) { subscriber.Buffer.Writer.TryComplete(new IOException("Slow live stream consumer.")); Remove(subscriber); }
|
||||||
|
ready.TrySetResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
carry = count - bytes;
|
||||||
|
if (carry > 0) System.Buffer.BlockCopy(buffer, bytes, buffer, 0, carry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception error) { failure = error; }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
LastError = token.IsCancellationRequested ? null : failure?.Message;
|
||||||
|
if (!token.IsCancellationRequested) failedUntil = DateTime.UtcNow.AddSeconds(3);
|
||||||
|
ready.TrySetException(new IOException("Decoder stream unavailable.", failure));
|
||||||
|
foreach (var subscriber in subscribers) subscriber.Buffer.Writer.TryComplete(failure);
|
||||||
|
subscribers.Clear(); history.Clear(); historyBytes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("DecoderIntegration/Live")]
|
||||||
|
public sealed class LiveRelayController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly LiveStreamRelay relay;
|
||||||
|
public LiveRelayController(LiveStreamRelay relay) { this.relay = relay; }
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task Stream(string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
var remote = HttpContext.Connection.RemoteIpAddress;
|
||||||
|
if (remote == null || !(IPAddress.IsLoopback(remote) || (remote.IsIPv4MappedToIPv6 && IPAddress.IsLoopback(remote.MapToIPv4())))) { Response.StatusCode = 403; return; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var subscription = await relay.Subscribe(id, token);
|
||||||
|
Response.ContentType = "video/mp2t"; Response.Headers.CacheControl = "no-store";
|
||||||
|
await foreach (var chunk in subscription.Reader.ReadAllAsync(token))
|
||||||
|
{ await Response.Body.WriteAsync(chunk, token); await Response.Body.FlushAsync(token); }
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (token.IsCancellationRequested) { }
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
if (Response.HasStarted) HttpContext.Abort();
|
||||||
|
else Response.StatusCode = 502;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ namespace Jellyfin.Plugin.Enigma2
|
|||||||
{
|
{
|
||||||
private readonly ILogger<LiveTvService> _logger;
|
private readonly ILogger<LiveTvService> _logger;
|
||||||
private int _liveStreams;
|
private int _liveStreams;
|
||||||
|
private readonly LiveStreamProbe _streamProbe;
|
||||||
|
private readonly LiveStreamRelay _streamRelay;
|
||||||
|
|
||||||
private string tvBouquetSRef;
|
private string tvBouquetSRef;
|
||||||
private List<ChannelInfo> tvChannelInfos = new List<ChannelInfo>();
|
private List<ChannelInfo> tvChannelInfos = new List<ChannelInfo>();
|
||||||
@@ -40,10 +42,12 @@ 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)
|
public LiveTvService(ILogger<LiveTvService> logger, IHttpClientFactory httpClientFactory, InternetEpg internetEpg, LiveStreamProbe streamProbe, LiveStreamRelay streamRelay)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_streamProbe = streamProbe;
|
||||||
|
_streamRelay = streamRelay;
|
||||||
Instance = this;
|
Instance = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,34 +1171,20 @@ namespace Jellyfin.Plugin.Enigma2
|
|||||||
trancodingUrl = "?bitrate=1000000?width=1280?height=720?vcodec=h264?aspectratio=2?interlaced=0.mp4";
|
trancodingUrl = "?bitrate=1000000?width=1280?height=720?vcodec=h264?aspectratio=2?interlaced=0.mp4";
|
||||||
}
|
}
|
||||||
|
|
||||||
_liveStreams++;
|
|
||||||
var streamUrl = string.Format("{0}/{1}{2}", baseUrl, channelOid, trancodingUrl);
|
var streamUrl = string.Format("{0}/{1}{2}", baseUrl, channelOid, trancodingUrl);
|
||||||
UtilsHelper.DebugInformation(_logger, string.Format("[Enigma2] GetChannelStream url: {0}", streamUrl));
|
streamUrl = _streamRelay.Register(streamUrl);
|
||||||
|
var streams = await _streamProbe.Get(streamUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
return new MediaSourceInfo
|
return new MediaSourceInfo
|
||||||
{
|
{
|
||||||
Id = _liveStreams.ToString(CultureInfo.InvariantCulture),
|
Id = Interlocked.Increment(ref _liveStreams).ToString(CultureInfo.InvariantCulture),
|
||||||
Path = streamUrl,
|
Path = streamUrl,
|
||||||
Protocol = MediaProtocol.Http,
|
Protocol = MediaProtocol.Http,
|
||||||
MediaStreams = new List<MediaStream>
|
Container = Plugin.Instance.Configuration.TranscodedStream ? "mpegts" : "ts",
|
||||||
{
|
IsInfiniteStream = true,
|
||||||
new MediaStream
|
SupportsProbing = false,
|
||||||
{
|
SupportsDirectPlay = false,
|
||||||
Type = MediaStreamType.Video,
|
MediaStreams = streams
|
||||||
// Set the index to -1 because we don't know the exact index of the video stream within the container
|
|
||||||
Index = -1,
|
|
||||||
|
|
||||||
// Set to true if unknown to enable deinterlacing
|
|
||||||
IsInterlaced = true
|
|
||||||
|
|
||||||
},
|
|
||||||
new MediaStream
|
|
||||||
{
|
|
||||||
Type = MediaStreamType.Audio,
|
|
||||||
// Set the index to -1 because we don't know the exact index of the audio stream within the container
|
|
||||||
Index = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
throw new ResourceNotFoundException(string.Format("Could not stream channel {0}", channelOid));
|
throw new ResourceNotFoundException(string.Format("Could not stream channel {0}", channelOid));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ public sealed class PreferencesController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly PreferencesStore store;
|
private readonly PreferencesStore store;
|
||||||
private readonly InternetEpg epg;
|
private readonly InternetEpg epg;
|
||||||
public PreferencesController(PreferencesStore store,InternetEpg epg) { this.store=store; this.epg=epg; }
|
private readonly LiveStreamRelay relay;
|
||||||
|
private readonly LiveStreamProbe probe;
|
||||||
|
public PreferencesController(PreferencesStore store,InternetEpg epg,LiveStreamRelay relay,LiveStreamProbe probe) { this.store=store; this.epg=epg; this.relay=relay; this.probe=probe; }
|
||||||
private Guid UserId => Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value,out var id) ? id : Guid.Empty;
|
private Guid UserId => Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value,out var id) ? id : Guid.Empty;
|
||||||
[HttpGet("Channels")]
|
[HttpGet("Channels")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -66,5 +68,5 @@ public sealed class PreferencesController : ControllerBase
|
|||||||
{ if (UserId == Guid.Empty) return Unauthorized(); await store.Save(UserId,value,token); return NoContent(); }
|
{ if (UserId == Guid.Empty) return Unauthorized(); await store.Save(UserId,value,token); return NoContent(); }
|
||||||
[HttpGet("Status")]
|
[HttpGet("Status")]
|
||||||
[Authorize(Policy="RequiresElevation")]
|
[Authorize(Policy="RequiresElevation")]
|
||||||
public IActionResult Status() => Ok(new { epg.LastSuccess,epg.MatchedImages,epg.LastError, Source="https://epg.polishteam.one/guide.xml" });
|
public IActionResult Status() => Ok(new { epg.LastSuccess,epg.MatchedImages,epg.LastError, Source="https://epg.polishteam.one/guide.xml", Streams=relay.Status(), ProbeError=probe.LastError });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ namespace Jellyfin.Plugin.Enigma2
|
|||||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||||
{
|
{
|
||||||
serviceCollection.AddSingleton<InternetEpg>();
|
serviceCollection.AddSingleton<InternetEpg>();
|
||||||
|
serviceCollection.AddSingleton<LiveStreamProbe>();
|
||||||
|
serviceCollection.AddSingleton<LiveStreamRelay>();
|
||||||
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>();
|
||||||
|
|||||||
+15
-1
@@ -1,6 +1,6 @@
|
|||||||
# Enigma2 dla Jellyfin 12.2
|
# Enigma2 dla Jellyfin 12.2
|
||||||
|
|
||||||
Lokalne rozszerzenie wtyczki Enigma2 6.0.0.0, wersja 7.0.1.0. Zachowuje identyfikator wtyczki, nazwę usługi, konfigurację połączenia, natywne strumienie, nagrania i timery dekodera. Profile transkodowania pozostają na dekoderze.
|
Lokalne rozszerzenie wtyczki Enigma2 6.0.0.0, wersja 7.0.4.0. Zachowuje identyfikator wtyczki, nazwę usługi, konfigurację połączenia, natywne strumienie, nagrania i timery dekodera. Profile transkodowania pozostają na dekoderze.
|
||||||
|
|
||||||
## Funkcje
|
## Funkcje
|
||||||
|
|
||||||
@@ -61,3 +61,17 @@ Wersja 7.0.1.0: kliknięcie nazwy kanału na stronie dodatku uruchamia natywny o
|
|||||||
Po skopiowaniu skryptu do `/tmp/decoder-channel-playback.js` uruchomić `install-web-playback.py` w CT100191; instalator zachowuje kopie index/config i wpisuje dodatek do istniejącej listy plugins. Po aktualizacji pakietu jellyfin-web trzeba ponownie uruchomić instalator. W otwartej przeglądarce wykonać Ctrl+Shift+R. Działanie dotyczy Jellyfin Web; natywne aplikacje Android TV mają własne sterowanie. Test Edge: wybór TVP INFO HD jednym kliknięciem, odpowiedź PlaybackInfo 200 i odtwarzanie video z rosnącym currentTime, bez przycisku Play.
|
Po skopiowaniu skryptu do `/tmp/decoder-channel-playback.js` uruchomić `install-web-playback.py` w CT100191; instalator zachowuje kopie index/config i wpisuje dodatek do istniejącej listy plugins. Po aktualizacji pakietu jellyfin-web trzeba ponownie uruchomić instalator. W otwartej przeglądarce wykonać Ctrl+Shift+R. Działanie dotyczy Jellyfin Web; natywne aplikacje Android TV mają własne sterowanie. Test Edge: wybór TVP INFO HD jednym kliknięciem, odpowiedź PlaybackInfo 200 i odtwarzanie video z rosnącym currentTime, bez przycisku Play.
|
||||||
|
|
||||||
Test `test-native-playback.cjs`: kliknięcie narożnika grafiki kanału TVP INFO HD w natywnym `/livetv?tab=2`, bez trafienia w przycisk Play, uruchamia wideo (PASS). Stanowisko: izolowany Edge headless, konto testowe usuwane w finally.
|
Test `test-native-playback.cjs`: kliknięcie narożnika grafiki kanału TVP INFO HD w natywnym `/livetv?tab=2`, bez trafienia w przycisk Play, uruchamia wideo (PASS). Stanowisko: izolowany Edge headless, konto testowe usuwane w finally.
|
||||||
|
|
||||||
|
## Współdzielenie transmisji i błędy odtwarzania — 12.10.2026
|
||||||
|
|
||||||
|
Wersja 7.0.4.0 współdzieli jedno połączenie HTTP z odbiornikiem między analizą metadanych i odbiorcami danego kanału. Lokalny relay ma losowy identyfikator oraz akceptuje tylko połączenia loopback; zewnętrzne odtwarzacze korzystają z natywnego API Jellyfina. Bufor historii jest ograniczony do 2 MiB, kolejki klientów do 128 bloków, liczba klientów kanału do 16. Zakończenie jednego odbiorcy nie zatrzymuje pozostałych; po ostatnim połączeniu źródło jest zamykane po 15 sekundach.
|
||||||
|
|
||||||
|
Wtyczka wykonuje własne współdzielone badanie rzeczywistych metadanych strumienia. Limit 15 sekund kończy również uruchomiony przez wtyczkę ffprobe; wynik sukcesu jest buforowany 30 sekund, błędu 3 sekundy. MediaSourceInfo przekazuje natywnemu odtwarzaczowi zidentyfikowane indeksy i wyłącza kolejne probing. Nie zmienia się systemowych wrapperów ffmpeg/ffprobe ani konfiguracji Jellyfina.
|
||||||
|
|
||||||
|
Moduł web domyślnie otwiera Telewizję w zakładce Kanały (tab=2), zachowując jawnie wybraną zakładkę. Zapobiega podwójnemu uruchomieniu podczas oczekiwania, otwiera natywny OSD i udostępnia przycisk „Zatrzymaj i wróć do kanałów” oraz Escape. Brak obrazu przez 25 sekund kończy próbę i wraca do listy. Działanie przy błędzie nie stanowi potwierdzenia prawidłowej transmisji z odbiornika.
|
||||||
|
|
||||||
|
Do aktualizacji wyłącznie DLL używa się `deploy-plugin-only.py` w CT100191 po dostarczeniu artefaktu do `/tmp/Jellyfin.Plugin.Enigma2.dll`. Flaga `--maintenance-approved` pozwala wykonać uzgodniony restart przy aktywnym odtwarzaniu. Skrypt zachowuje poprzednią wtyczkę i sprawdza niezmienione hashe konfiguracji serwera, wrapperów oraz index/config web. Moduł JS aktualizuje się osobno w istniejącym `/usr/share/jellyfin/web/decoder-channel-playback.js`; nie uruchamiać ponownie instalatora web przy naprawie ograniczonej do wtyczki. Ostatnia kopia wdrożenia: `/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261012T183340Z`.
|
||||||
|
|
||||||
|
Testy konsolowe przeszły: współdzielenie probe, anulowanie pojedynczego odbiorcy, limit czasu, brak wideo, jeden upstream dla dwóch odbiorców relay, odmowa dostępu zewnętrznego oraz zwolnienie ostatniego połączenia. `test-web-bridge.cjs` sprawdza domyślną zakładkę, OSD, Escape, deduplikację i spóźnione zakończenie. `verify.py` potwierdził aktywną 7.0.4.0, 216 kanałów i izolację preferencji/uprawnień. `test-web-player.cjs` pozwala ustawić nazwę przez DECODER_TEST_CHANNEL i sprawdza rzeczywisty obraz oraz wyjście w Edge.
|
||||||
|
|
||||||
|
Bieżące próby TVP 1 HD i TVP 2 HD: domyślne Kanały i wyjście PASS, obraz NO_FRAME_FROM_SOURCE, bez błędów JS. Bezpośrednie żądanie TVP1 z CT100191 do portu 8001, z pominięciem wtyczki i odtwarzacza, otrzymało HTTP 200 i 0 bajtów po około 5,3 s. Dla TVP2 test web nie uzyskał obrazu; nie potwierdzono identycznej przyczyny. Bieżące service reference w natywnej bazie są zgodne z OpenWebif: TVP1 3ABD:514:13E oraz TVP2 C22:1E78:71. Dodatkową ręczną próbę 3D66:2C88:13E błędnie opisano jako TVP2; jest to TV PULS HD i nie stanowi dowodu dla TVP2. Nie potwierdzono przywrócenia obrazu. Odczyt logów dekodera wykazał dodatkowo zatrzymania GUI przez runlevel 4→3 o 20:44, 20:45, 20:47 i 20:48 CEST; Enigma obsługuje porty 80 i 8001, więc te przerwy wyjaśniają wcześniejsze Connection refused. Źródła poleceń restartów oraz przyczyny pustej transmisji nie przypisano do LNB ani OSCam bez dalszych dowodów. W ramach tej naprawy nie zmieniano ani nie restartowano odbiornika.
|
||||||
@@ -5,6 +5,7 @@ using System.Reflection;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Linq;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -57,8 +58,57 @@ try
|
|||||||
result=await Request(user,"?DecoderIncludeHidden=true&Limit=2");Check((int)result["TotalRecordCount"]==2,"preference editor can restore hidden channels");
|
result=await Request(user,"?DecoderIncludeHidden=true&Limit=2");Check((int)result["TotalRecordCount"]==2,"preference editor can restore hidden channels");
|
||||||
}
|
}
|
||||||
finally { if(Directory.Exists(folder))Directory.Delete(folder,true); }
|
finally { if(Directory.Exists(folder))Directory.Delete(folder,true); }
|
||||||
|
var probeJson="""{"streams":[{"index":0,"codec_type":"video","codec_name":"h264","width":1920,"height":1080,"field_order":"tt","avg_frame_rate":"25/1"},{"index":1,"codec_type":"audio","codec_name":"mp2","channels":2,"sample_rate":"48000"}]}""";
|
||||||
|
var calls=0;
|
||||||
|
var sharedProbe=new LiveStreamProbe(async (url,token)=>{Interlocked.Increment(ref calls);await System.Threading.Tasks.Task.Delay(100,token);return probeJson;});
|
||||||
|
var probes=await System.Threading.Tasks.Task.WhenAll(System.Linq.Enumerable.Range(0,4).Select(_=>sharedProbe.Get("channel",CancellationToken.None)));
|
||||||
|
Check(calls==1&&probes.All(x=>x.Count==2),"four simultaneous requests share one upstream probe");
|
||||||
|
Check(probes[0][0].Index==0&&probes[0][0].IsInterlaced&&probes[0][0].AverageFrameRate==25,"real stream indexes and interlacing preserved");
|
||||||
|
await sharedProbe.Get("channel",CancellationToken.None);Check(calls==1,"successful probe cached");
|
||||||
|
using(var cancelled=new CancellationTokenSource(20))
|
||||||
|
{
|
||||||
|
var abandoned=sharedProbe.Get("another",cancelled.Token);var surviving=sharedProbe.Get("another",CancellationToken.None);
|
||||||
|
try{await abandoned;throw new Exception("cancellation expected");}catch(OperationCanceledException){}
|
||||||
|
Check((await surviving).Count==2&&calls==2,"one cancelled caller does not break shared probe");
|
||||||
|
}
|
||||||
|
var failingCalls=0;
|
||||||
|
var stalled=new LiveStreamProbe(async (url,token)=>{Interlocked.Increment(ref failingCalls);await System.Threading.Tasks.Task.Delay(10000,token);return probeJson;},TimeSpan.FromMilliseconds(100));
|
||||||
|
var watch=System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
for(var i=0;i<2;i++)try{await stalled.Get("stalled",CancellationToken.None);throw new Exception("failure expected");}catch(InvalidOperationException){}
|
||||||
|
Check(watch.Elapsed.TotalSeconds<2&&failingCalls==1,"stalled source bounded and failed retries coalesced");
|
||||||
|
try{LiveStreamProbe.Parse("{\"streams\":[]}");throw new Exception("invalid video expected");}catch(InvalidOperationException){}
|
||||||
|
Check(true,"empty source rejected before playback");
|
||||||
|
var relayHandler=new RelayHandler();
|
||||||
|
using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100)))
|
||||||
|
{
|
||||||
|
var address=relay.Register("http://receiver/channel");var id=address.Split('/').Last();
|
||||||
|
Check(relay.Register("http://receiver/channel")==address,"one stable relay address for a channel");
|
||||||
|
var first=await relay.Subscribe(id,CancellationToken.None);var second=await relay.Subscribe(id,CancellationToken.None);
|
||||||
|
var one=await first.Reader.ReadAsync();var two=await second.Reader.ReadAsync();
|
||||||
|
Check(relayHandler.Calls==1&&one[0]==0x47&&two[0]==0x47,"two data consumers share one receiver HTTP connection");
|
||||||
|
await first.DisposeAsync();Check((await second.Reader.ReadAsync()).Length>0&&relayHandler.Calls==1,"one consumer stopping does not interrupt the other");
|
||||||
|
var context=new DefaultHttpContext();context.Connection.RemoteIpAddress=System.Net.IPAddress.Parse("192.0.2.5");
|
||||||
|
var controller=new LiveRelayController(relay){ControllerContext=new Microsoft.AspNetCore.Mvc.ControllerContext{HttpContext=context}};
|
||||||
|
await controller.Stream(id,CancellationToken.None);Check(context.Response.StatusCode==403,"relay denied to non-loopback clients");
|
||||||
|
await second.DisposeAsync();await System.Threading.Tasks.Task.Delay(250);
|
||||||
|
Check(System.Text.Json.JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"),"receiver connection released after final consumer");
|
||||||
|
}
|
||||||
public class PathsProxy : DispatchProxy
|
public class PathsProxy : DispatchProxy
|
||||||
{
|
{
|
||||||
public string Folder;
|
public string Folder;
|
||||||
protected override object Invoke(MethodInfo method,object[] args)=>(method.Name=="get_DataPath" || method.Name=="get_PluginConfigurationsPath")?Folder:null;
|
protected override object Invoke(MethodInfo method,object[] args)=>(method.Name=="get_DataPath" || method.Name=="get_PluginConfigurationsPath")?Folder:null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class RelayHandler : System.Net.Http.HttpMessageHandler
|
||||||
|
{
|
||||||
|
public int Calls;
|
||||||
|
protected override System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage> SendAsync(System.Net.Http.HttpRequestMessage request,CancellationToken token)
|
||||||
|
{ Interlocked.Increment(ref Calls);return System.Threading.Tasks.Task.FromResult(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK){Content=new System.Net.Http.StreamContent(new PacketStream())}); }
|
||||||
|
}
|
||||||
|
public class PacketStream : Stream
|
||||||
|
{
|
||||||
|
public override bool CanRead=>true;public override bool CanSeek=>false;public override bool CanWrite=>false;public override long Length=>throw new NotSupportedException();public override long Position{get=>0;set=>throw new NotSupportedException();}
|
||||||
|
public override async System.Threading.Tasks.ValueTask<int> ReadAsync(Memory<byte> buffer,CancellationToken token=default)
|
||||||
|
{ await System.Threading.Tasks.Task.Delay(10,token);var size=Math.Min(buffer.Length,188*256);size-=size%188;buffer.Span[..size].Clear();for(int i=0;i<size;i+=188)buffer.Span[i]=0x47;return size; }
|
||||||
|
public override int Read(byte[] b,int o,int c)=>throw new NotSupportedException();public override void Flush(){}public override long Seek(long o,SeekOrigin s)=>throw new NotSupportedException();public override void SetLength(long n)=>throw new NotSupportedException();public override void Write(byte[] b,int o,int c)=>throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Install only the plugin DLL/manifest; --maintenance-approved permits the agreed restart."""
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--maintenance-approved', action='store_true')
|
||||||
|
args = parser.parse_args()
|
||||||
|
key = sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro', uri=True).execute('SELECT AccessToken FROM ApiKeys LIMIT 1').fetchone()[0]
|
||||||
|
headers = {'Authorization': 'MediaBrowser Token="' + key + '", Client="PluginDeployment", Device="Server", DeviceId="plugin-deployment", Version="7"'}
|
||||||
|
sessions = json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Sessions', headers=headers)))
|
||||||
|
if any(s.get('NowPlayingItem') for s in sessions) and not args.maintenance_approved:
|
||||||
|
raise SystemExit('Active playback: maintenance approval required')
|
||||||
|
protected = ['/etc/default/jellyfin', '/etc/jellyfin/encoding.xml', '/usr/local/lib/jellyfin-enigma/ffmpeg', '/usr/local/lib/jellyfin-enigma/ffprobe', '/usr/share/jellyfin/web/config.json', '/usr/share/jellyfin/web/index.html']
|
||||||
|
def hashes():
|
||||||
|
return {p: hashlib.sha256(pathlib.Path(p).read_bytes()).hexdigest() for p in protected if pathlib.Path(p).exists()}
|
||||||
|
before = hashes()
|
||||||
|
plugins = pathlib.Path('/var/lib/jellyfin/plugins')
|
||||||
|
old = [p for p in plugins.glob('Enigma2_*') if p.is_dir()]
|
||||||
|
backup = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups') / ('plugin-only-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
|
||||||
|
backup.mkdir(mode=0o700)
|
||||||
|
new = plugins / 'Enigma2_7.0.4.0'
|
||||||
|
metadata = json.loads((old[0] / 'meta.json').read_text())
|
||||||
|
metadata.update(version='7.0.4.0', status='Active', targetAbi='12.2.0.0')
|
||||||
|
subprocess.run(['systemctl', 'stop', 'jellyfin'], check=True)
|
||||||
|
try:
|
||||||
|
for p in old:
|
||||||
|
shutil.move(str(p), str(backup / p.name))
|
||||||
|
new.mkdir()
|
||||||
|
shutil.copy2('/tmp/Jellyfin.Plugin.Enigma2.dll', new / 'Jellyfin.Plugin.Enigma2.dll')
|
||||||
|
(new / 'meta.json').write_text(json.dumps(metadata))
|
||||||
|
subprocess.run(['chown', '-R', 'jellyfin:jellyfin', str(new)], check=True)
|
||||||
|
except Exception:
|
||||||
|
if new.exists():
|
||||||
|
shutil.rmtree(new)
|
||||||
|
for p in old:
|
||||||
|
if (backup / p.name).exists():
|
||||||
|
shutil.move(str(backup / p.name), str(p))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
subprocess.run(['systemctl', 'start', 'jellyfin'], check=True)
|
||||||
|
assert hashes() == before, 'Unexpected change outside plugin installation'
|
||||||
|
print(json.dumps({'plugin': '7.0.4.0', 'backup': str(backup), 'server_configuration_and_wrappers_unchanged': True}))
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Regression checks for channel navigation, single start, controls and cancellation.
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const listeners = {}, documentListeners = {}, elements = [];
|
||||||
|
let clock = 0, interval, starts = 0, stops = 0, osd = 0, returned = 0;
|
||||||
|
const pending = [];
|
||||||
|
const location = { hash: '#/livetv', pathname: '/web/index.html', search: '', replace(value) { this.hash = value; } };
|
||||||
|
const document = {
|
||||||
|
fullscreenElement: null,
|
||||||
|
body: { append(element) { elements.push(element); } },
|
||||||
|
createElement() { return { style: {}, remove() { elements.splice(elements.indexOf(this), 1); } }; },
|
||||||
|
querySelector() { return { readyState: 0, currentTime: 0 }; },
|
||||||
|
addEventListener(name, callback) { documentListeners[name] = callback; }
|
||||||
|
};
|
||||||
|
const window = {
|
||||||
|
ApiClient: { getCurrentUserId: () => 'user', serverId: () => 'server', getItem: async (user, id) => ({ Id: id, Type: 'TvChannel' }) },
|
||||||
|
addEventListener(name, callback) { listeners[name] = callback; }
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
window, document, location, console, URLSearchParams,
|
||||||
|
history: { replaceState(a, b, url) { location.hash = url.slice(url.indexOf('#')); } },
|
||||||
|
Date: { now: () => clock }, setTimeout, clearTimeout,
|
||||||
|
setInterval(callback) { interval = callback; return callback; },
|
||||||
|
clearInterval(callback) { if (interval === callback) interval = null; }
|
||||||
|
};
|
||||||
|
const manager = {
|
||||||
|
getCurrentPlayer: () => ({ isLocalPlayer: true }),
|
||||||
|
play() { ++starts; return new Promise(resolve => pending.push(resolve)); },
|
||||||
|
stop: async () => { ++stops; }
|
||||||
|
};
|
||||||
|
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
|
||||||
|
(async () => {
|
||||||
|
vm.runInNewContext(fs.readFileSync(__dirname + '/web/decoder-channel-playback.js', 'utf8'), context);
|
||||||
|
const Plugin = await window.DecoderChannelPlayback();
|
||||||
|
new Plugin({ playbackManager: manager, toast() {}, loading: { hide() {} }, events: { on() {} },
|
||||||
|
appRouter: { showVideoOsd: async () => { ++osd; }, show: async () => { ++returned; location.hash = '#/livetv?tab=2'; } } });
|
||||||
|
assert.equal(location.hash, '#/livetv?tab=2');
|
||||||
|
location.hash = '#/livetv?tab=0'; listeners.hashchange();
|
||||||
|
assert.equal(location.hash, '#/livetv?tab=0');
|
||||||
|
const click = () => documentListeners.click({ button: 0, target: { closest: () => ({ closest: () => ({ dataset: { id: 'channel', serverid: 'server' } }) }) }, preventDefault() {}, stopImmediatePropagation() {} });
|
||||||
|
click(); click(); await flush();
|
||||||
|
assert.equal(starts, 1); assert.equal(elements[0].id, 'decoderPlaybackExit');
|
||||||
|
interval(); await flush(); assert.equal(osd, 1);
|
||||||
|
listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} });
|
||||||
|
await flush(); assert.equal(stops, 1); assert.equal(returned, 1); assert.equal(elements.length, 0);
|
||||||
|
click(); await flush(); assert.equal(starts, 2);
|
||||||
|
pending[0](); await flush(); assert.equal(stops, 1, 'late old completion must not stop the new channel');
|
||||||
|
clock = 25001; interval(); await flush();
|
||||||
|
assert.equal(stops, 2); assert.equal(returned, 2); assert.equal(elements.length, 0);
|
||||||
|
pending[1](); await flush();
|
||||||
|
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; });
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Isolated headless browser; temporary server account is always removed.
|
||||||
|
const {execFileSync}=require('node:child_process');
|
||||||
|
const {chromium}=require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||||
|
const ssh=py=>execFileSync('ssh',['-o','BatchMode=yes','root@192.168.100.25','pct exec 100191 -- python3 -'],{input:py,encoding:'utf8'}).trim().split('\n').at(-1);
|
||||||
|
const setup=`import sqlite3,json,secrets,urllib.request
|
||||||
|
c=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()
|
||||||
|
def api(p,d):
|
||||||
|
r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"','Content-Type':'application/json'},data=json.dumps(d).encode());b=urllib.request.urlopen(r).read();return json.loads(b) if b else None
|
||||||
|
n='decoder-browser-check-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});api('/Users/'+u['Id']+'/Password',{'CurrentPw':'','NewPw':p});print(json.dumps({'id':u['Id'],'name':n,'password':p}))
|
||||||
|
`;
|
||||||
|
(async()=>{
|
||||||
|
const user=JSON.parse(ssh(setup));let browser;
|
||||||
|
try{
|
||||||
|
browser=await chromium.launch({headless:true,channel:'msedge'});const page=await browser.newPage({viewport:{width:1280,height:900}});
|
||||||
|
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||||
|
const base='http://192.168.100.191:8096/web/index.html';
|
||||||
|
await page.goto(base);await page.locator('#txtManualName').fill(user.name);await page.locator('#txtManualPassword').fill(user.password);await page.getByRole('checkbox').check();await page.getByRole('button',{name:'Sign In',exact:true}).click();
|
||||||
|
await page.waitForURL(/home/,{timeout:20000});
|
||||||
|
await page.waitForFunction(()=>JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers?.some(s=>s.AccessToken&&s.UserId));
|
||||||
|
await page.goto('http://192.168.100.191:8096/DecoderIntegration/Channels');
|
||||||
|
try { await page.locator('#channelRows section').first().waitFor({timeout:30000}); }
|
||||||
|
catch(error) { console.log(JSON.stringify({url:page.url(),text:await page.locator('body').innerText(),errors})); await page.screenshot({path:process.argv[2]||'decoder-channels.png'});throw error; }
|
||||||
|
await page.goto(base+'#/livetv');await page.waitForURL(/tab=2/,{timeout:15000});
|
||||||
|
const target=process.env.DECODER_TEST_CHANNEL || 'TVN HD';
|
||||||
|
const image=page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:new RegExp('^\\d+ '+target+'$')})}).locator('a.cardImageContainer');
|
||||||
|
await image.click({position:{x:20,y:20}});
|
||||||
|
await page.locator('#decoderPlaybackExit').waitFor({timeout:15000});
|
||||||
|
let video=false;
|
||||||
|
try { await page.waitForFunction(()=>[...document.querySelectorAll('video')].some(v=>v.currentTime>0&&v.readyState>=2),{},{timeout:20000});video=true; } catch {}
|
||||||
|
await page.screenshot({path:process.argv[2]||'decoder-web-player.png'});
|
||||||
|
if(await page.locator('#decoderPlaybackExit').count())await page.locator('#decoderPlaybackExit').click();
|
||||||
|
await page.waitForURL(/livetv.*tab=2/,{timeout:15000});
|
||||||
|
await page.locator('.card[data-type="TvChannel"]').first().waitFor({timeout:15000});
|
||||||
|
console.log(JSON.stringify({default_channels:'PASS',exit_to_channels:'PASS',channel:target,video:video?'PASS':'NO_FRAME_FROM_SOURCE',browser_errors:errors}));
|
||||||
|
}finally{
|
||||||
|
if(browser)await browser.close();
|
||||||
|
ssh(`import sqlite3,urllib.request,pathlib\nc=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()\nurllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Users/${user.id}',headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'},method='DELETE')).read()\npathlib.Path('/var/lib/jellyfin/decoder-integration/users/${user.id.replace(/-/g,'')}.json').unlink(missing_ok=True)\nprint('clean')`);
|
||||||
|
}
|
||||||
|
})().catch(e=>{console.error(e.message);process.exitCode=1});
|
||||||
@@ -9,7 +9,7 @@ def api(path,data=None,method=None,token=admin):
|
|||||||
body=response.read();return json.loads(body) if body else None
|
body=response.read();return json.loads(body) if body else None
|
||||||
report={'checked_utc':datetime.datetime.now(datetime.timezone.utc).isoformat()}
|
report={'checked_utc':datetime.datetime.now(datetime.timezone.utc).isoformat()}
|
||||||
report['plugin']=next(x for x in api('/Plugins') if x['Name']=='Enigma2')
|
report['plugin']=next(x for x in api('/Plugins') if x['Name']=='Enigma2')
|
||||||
assert report['plugin']['Version']=='7.0.1.0' and report['plugin']['Status']=='Active'
|
assert report['plugin']['Version']=='7.0.4.0' and report['plugin']['Status']=='Active'
|
||||||
report['epg']=api('/DecoderIntegration/Status')
|
report['epg']=api('/DecoderIntegration/Status')
|
||||||
report['channel_count']=api('/LiveTv/Channels?Limit=0')['TotalRecordCount']
|
report['channel_count']=api('/LiveTv/Channels?Limit=0')['TotalRecordCount']
|
||||||
report['timer_count']=api('/LiveTv/Timers?Limit=0')['TotalRecordCount']
|
report['timer_count']=api('/LiveTv/Timers?Limit=0')['TotalRecordCount']
|
||||||
|
|||||||
@@ -1,22 +1,63 @@
|
|||||||
// Jellyfin Web's supported window plugin API supplies the native playback manager.
|
// Jellyfin Web's supported window plugin API supplies the native playback manager.
|
||||||
window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
|
window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
|
||||||
constructor({ playbackManager, toast }) {
|
constructor({ playbackManager, toast, appRouter, loading, events }) {
|
||||||
this.id = 'decoder-channel-playback';
|
this.id = 'decoder-channel-playback';
|
||||||
this.name = 'Decoder channel playback';
|
this.name = 'Decoder channel playback';
|
||||||
this.type = 'integration';
|
this.type = 'integration';
|
||||||
const play = async (id, serverId) => {
|
let generation = 0, starting = null, cancelButton = null, watcher = null;
|
||||||
|
const clearControls = () => { clearInterval(watcher); watcher = null; cancelButton?.remove(); cancelButton = null; };
|
||||||
|
events.on(playbackManager, 'playbackstop', () => { if (!starting) clearControls(); });
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
if (cancelButton) (document.fullscreenElement || document.body).append(cancelButton);
|
||||||
|
});
|
||||||
|
const leave = async () => {
|
||||||
|
++generation; starting = null; clearControls();
|
||||||
|
try { await playbackManager.stop(); } finally { loading.hide(); await appRouter.show('livetv?tab=2'); }
|
||||||
|
};
|
||||||
|
const play = (id, serverId) => {
|
||||||
|
if (starting) return starting;
|
||||||
|
const ticket = ++generation;
|
||||||
|
let request;
|
||||||
|
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 = await api.getItem(api.getCurrentUserId(), id);
|
||||||
|
if (ticket !== generation) return false;
|
||||||
if (item.Type !== 'TvChannel') throw Error('Expected a TV channel');
|
if (item.Type !== 'TvChannel') throw Error('Expected a TV channel');
|
||||||
await playbackManager.play({ items: [item] });
|
clearControls();
|
||||||
|
cancelButton = document.createElement('button');
|
||||||
|
cancelButton.id = 'decoderPlaybackExit'; cancelButton.textContent = 'Zatrzymaj i wróć do kanałów';
|
||||||
|
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);
|
||||||
|
let opened = false; const deadline = Date.now() + 25000;
|
||||||
|
watcher = setInterval(() => {
|
||||||
|
if (ticket !== generation) return;
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
const player = playbackManager.getCurrentPlayer();
|
||||||
|
if (player?.isLocalPlayer === false) { clearControls(); return; }
|
||||||
|
if (!opened && video && player) { opened = true; void appRouter.showVideoOsd(); }
|
||||||
|
if (video?.readyState >= 2 && video.currentTime > 0) return;
|
||||||
|
if (Date.now() >= deadline) { void leave(); toast('Kanał nie przesyła obrazu. Powrót do listy kanałów.'); }
|
||||||
|
}, 250);
|
||||||
|
try { await playbackManager.play({ items: [item], fullscreen: true }); }
|
||||||
|
catch (error) { if (ticket !== generation) return false; await leave(); throw error; }
|
||||||
return true;
|
return true;
|
||||||
|
})().finally(() => { if (starting === request) starting = null; });
|
||||||
|
starting = request; return request;
|
||||||
};
|
};
|
||||||
|
window.addEventListener('keydown', event => {
|
||||||
|
if (event.key === 'Escape' && cancelButton) { event.preventDefault(); event.stopImmediatePropagation(); void leave(); }
|
||||||
|
}, true);
|
||||||
let pending;
|
let pending;
|
||||||
const handleRoute = () => {
|
const handleRoute = () => {
|
||||||
clearTimeout(pending);
|
clearTimeout(pending);
|
||||||
const route = location.hash;
|
const route = location.hash;
|
||||||
const query = new URLSearchParams(route.split('?')[1] || '');
|
const query = new URLSearchParams(route.split('?')[1] || '');
|
||||||
|
if (route.split('?')[0].toLowerCase() === '#/livetv' && !query.has('tab')) {
|
||||||
|
query.set('tab', '2');
|
||||||
|
location.replace('#/livetv?' + query);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = query.get('decoderPlay');
|
const id = query.get('decoderPlay');
|
||||||
if (!id || !/^[a-f\d]{32}$|^[a-f\d-]{36}$/i.test(id)) return;
|
if (!id || !/^[a-f\d]{32}$|^[a-f\d-]{36}$/i.test(id)) return;
|
||||||
const deadline = Date.now() + 30000;
|
const deadline = Date.now() + 30000;
|
||||||
|
|||||||
Reference in New Issue
Block a user