Verify decoder integration E2E and fix live metadata probing and mapping

This commit is contained in:
2026-10-12 21:41:26 +02:00
parent cecd5b5625
commit f5cdfa285e
15 changed files with 657 additions and 17 deletions
@@ -2,8 +2,8 @@
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks> <TargetFrameworks>net10.0</TargetFrameworks>
<AssemblyVersion>7.0.4.0</AssemblyVersion> <AssemblyVersion>7.0.6.0</AssemblyVersion>
<FileVersion>7.0.4.0</FileVersion> <FileVersion>7.0.6.0</FileVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
+53 -3
View File
@@ -2,7 +2,9 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -23,8 +25,9 @@ public sealed class LiveStreamProbe
public LiveStreamProbe(IMediaEncoder encoder) : this((url, token) => Run(encoder.ProbePath, url, token)) { } 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 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) public Task<List<MediaStream>> Get(string url, CancellationToken caller, int? programme = null)
{ {
if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture);
Task<List<MediaStream>> task; Task<List<MediaStream>> task;
lock (gate) lock (gate)
{ {
@@ -48,7 +51,7 @@ public sealed class LiveStreamProbe
try try
{ {
await slots.WaitAsync(deadline.Token); await slots.WaitAsync(deadline.Token);
try { completion.TrySetResult(Parse(await runner(url, deadline.Token))); success = true; } try { completion.TrySetResult(Parse(await runner(url, deadline.Token))); LastError = null; success = true; }
finally { slots.Release(); } finally { slots.Release(); }
} }
catch (Exception error) { LastError = error.Message; completion.TrySetException(new InvalidOperationException("Decoder returned no usable live stream within the probe deadline.", error)); } catch (Exception error) { LastError = error.Message; completion.TrySetException(new InvalidOperationException("Decoder returned no usable live stream within the probe deadline.", error)); }
@@ -56,9 +59,45 @@ public sealed class LiveStreamProbe
} }
private static async Task<string> Run(string executable, string url, CancellationToken token) private static async Task<string> Run(string executable, string url, CancellationToken token)
{
// ffprobe may wait for an EPG/data PID forever on live MPEG-TS. Give it a finite
// sample from the shared relay, preserving the same PID/index ordering.
var sample = Path.Combine(Path.GetTempPath(), "enigma-probe-" + Guid.NewGuid().ToString("N") + ".ts");
try
{
using var client = new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3) }) { Timeout = Timeout.InfiniteTimeSpan };
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
response.EnsureSuccessStatusCode();
using var input = await response.Content.ReadAsStreamAsync(token);
await using (var file = new FileStream(sample, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65536, useAsync: true))
{
using var capture = CancellationTokenSource.CreateLinkedTokenSource(token);
capture.CancelAfter(TimeSpan.FromSeconds(4));
var buffer = new byte[188 * 256];
try
{
while (file.Length < 4 * 1024 * 1024)
{
var count = await input.ReadAsync(buffer, capture.Token);
if (count == 0) break;
await file.WriteAsync(buffer.AsMemory(0, count), token);
}
}
catch (OperationCanceledException) when (!token.IsCancellationRequested && file.Length > 0) { }
if (file.Length == 0) throw new InvalidOperationException("Decoder returned an empty probe sample.");
}
var query = new Uri(url).Query;
var programme = query.StartsWith("?programme=", StringComparison.Ordinal) && int.TryParse(query.Substring(11), out var value) ? value : (int?)null;
return await RunFile(executable, sample, programme, token);
}
finally { if (File.Exists(sample)) File.Delete(sample); }
}
private static async Task<string> RunFile(string executable, string sample, int? programme, CancellationToken token)
{ {
using var process = new Process { StartInfo = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } }; 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); foreach (var argument in new[] { "-v", "error", "-f", "mpegts", "-analyzeduration", "3000000", "-probesize", "4194304", "-i", sample, "-show_streams", "-of", "json" }) process.StartInfo.ArgumentList.Add(argument);
if (programme != null) { process.StartInfo.ArgumentList.Add("-select_streams"); process.StartInfo.ArgumentList.Add("p:" + programme.Value.ToString(CultureInfo.InvariantCulture)); }
process.Start(); process.Start();
var output = process.StandardOutput.ReadToEndAsync(); var output = process.StandardOutput.ReadToEndAsync();
var errors = process.StandardError.ReadToEndAsync(); var errors = process.StandardError.ReadToEndAsync();
@@ -102,4 +141,15 @@ public sealed class LiveStreamProbe
if (!result.Any(s => s.Type == MediaStreamType.Video && s.Width > 0 && s.Height > 0)) throw new InvalidOperationException("Decoder stream has no identified video."); 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; return result;
} }
public static MediaStream ParseLiveMetadata(MediaStream s) => new MediaStream
{
Index = -1, Type = s.Type, Codec = s.Codec, Width = s.Width, Height = s.Height,
Channels = s.Channels, SampleRate = s.SampleRate, BitRate = s.BitRate,
Profile = s.Profile, Level = s.Level, PixelFormat = s.PixelFormat,
ColorRange = s.ColorRange, ColorSpace = s.ColorSpace, ColorTransfer = s.ColorTransfer,
ColorPrimaries = s.ColorPrimaries, AverageFrameRate = s.AverageFrameRate,
RealFrameRate = s.RealFrameRate, IsInterlaced = s.IsInterlaced,
ChannelLayout = s.ChannelLayout, Language = s.Language
};
} }
+9 -1
View File
@@ -1173,7 +1173,14 @@ namespace Jellyfin.Plugin.Enigma2
var streamUrl = string.Format("{0}/{1}{2}", baseUrl, channelOid, trancodingUrl); var streamUrl = string.Format("{0}/{1}{2}", baseUrl, channelOid, trancodingUrl);
streamUrl = _streamRelay.Register(streamUrl); streamUrl = _streamRelay.Register(streamUrl);
var streams = await _streamProbe.Get(streamUrl, 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);
// 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();
return new MediaSourceInfo return new MediaSourceInfo
{ {
@@ -1183,6 +1190,7 @@ namespace Jellyfin.Plugin.Enigma2
Container = Plugin.Instance.Configuration.TranscodedStream ? "mpegts" : "ts", Container = Plugin.Instance.Configuration.TranscodedStream ? "mpegts" : "ts",
IsInfiniteStream = true, IsInfiniteStream = true,
SupportsProbing = false, SupportsProbing = false,
AnalyzeDurationMs = 1500,
SupportsDirectPlay = false, SupportsDirectPlay = false,
MediaStreams = streams MediaStreams = streams
}; };
+17 -2
View File
@@ -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.4.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.6.0. Zachowuje identyfikator wtyczki, nazwę usługi, konfigurację połączenia, natywne strumienie, nagrania i timery dekodera. Profile transkodowania pozostają na dekoderze.
## Funkcje ## Funkcje
@@ -74,4 +74,19 @@ Do aktualizacji wyłącznie DLL używa się `deploy-plugin-only.py` w CT100191 p
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. 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. 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.
## E2E i poprawki odtwarzania — 12.10.2026, 7.0.6.0
Pełny test produkcyjny używa `test-e2e.cjs` oraz izolowanych kontekstów Edge i tymczasowych kont. `--ui-only` sprawdza interfejs, zapis po przeładowaniu, wyszukiwanie, ulubione, ukrywanie, kolejność w natywnym API, EPG, obrazki, domyślny widok, uprawnienia, brak dostawcy nagrań Vu i panel administracyjny. `--playback-only` sprawdza rzeczywiste klatki 4Fun.TV, TVP1, TVP2 i TVN, native OSD, dwuklik, dwa odtwarzacze i zwolnienie źródła. `--control-only` ogranicza odtwarzanie do 4Fun.TV, a `--config-only` nie otwiera transmisji TV. `--report=<ścieżka.json>` zapisuje wyniki bez tokenów i haseł. Testy są operacjami produkcyjnymi: konta są usuwane w finally; nie tworzą nagrań ani timerów i nie zapisują formularza konfiguracji wtyczki.
E2E wykrył dwa błędy po stronie wtyczki i sprawdził ich poprawki. Analiza nieskończonego TS zawierającego PID danych EPG mogła osiągać limit 15 s mimo poprawnego AV. Probe teraz pobiera przez ten sam relay próbkę do około 4 MiB / 4 sekund i analizuje skończony plik. Wybór programu po SID z service reference zapobiega uznaniu danych innego transpondera za oczekiwany kanał. Plik próbki jest usuwany także po błędzie; obserwacja po testach wykazała zero pozostałych próbek.
Drugi błąd: Jellyfin 12.2 w `EncodingHelper.FindIndex/GetMapArgs` mapuje pozycję na liście strumieni. Lista AV pozbawiona wcześniejszego PID EPG powodowała `-map 0:0 -map 0:1`, próbę zapisania EPG w MP4 i błąd HLS. Wtyczka przekazuje teraz kopię rzeczywistych metadanych AV z `Index=-1`, używając natywnego wariantu automatycznego wyboru AV, zamiast zgadywania indeksów. Oryginalny wynik probe nie jest zmieniany. Nie zmieniono kodu Jellyfina ani wrapperów. Przy takim mapowaniu wybór ścieżki audio pozostaje automatyczny; ręcznego wyboru alternatywnej ścieżki nie potwierdzono. Źródło zachowuje `SupportsProbing=false` i ustawia `AnalyzeDurationMs=1500`. Web OSD czeka na metadane wideo i obsługuje odrzuconą/anulowaną nawigację.
Wdrożona DLL: 7.0.6.0; backup `/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261012T192438Z`. Sprawdzone hashe konfiguracji/wrapperów/index/config bez zmian. Instalator dodatkowo czeka na aktywną wtyczkę w API po starcie usługi.
Wyniki w osobnych JSON: `e2e-ui-20261012.json`, `e2e-config-20261012.json`, `e2e-control-20261012.json`, `e2e-playback-20261012.json`, `e2e-guide-20261012.json`. Test interfejsu potwierdził 16 podstawowych kontroli, 216 kanałów, izolację dwóch użytkowników i 36 istniejących timerów. Zakończony świeży Refresh Guide dopasował 5302 grafiki audycji. Kontrolny playback 4Fun.TV zaliczył 9/9: rosnące zdekodowane klatki, dwa browsery / jedno fizyczne połączenie dekodera, zatrzymanie jednego bez przerwania drugiego i pełne zwolnienie źródła. Końcowy przebieg obejmował również pozytywny natywny kafelek i OSD.
Całego E2E nie uznano za zielony: TVP1, TVP2 i TVN nie uzyskały klatek; źródło dekodera było puste lub miało niewłaściwy program/transponder według równoległej diagnostyki natywnej. Przy błędach źródła Jellyfin Web nadal zgłosił nieobsłużone odrzucenia obietnic `undefined`; wyjście do kanałów przeszło. Administracyjna nawigacja konfiguracji dla tymczasowego konta jest raportowana osobno i nie wolno uznać jej za PASS na podstawie samego API HTML. Nie zmieniano konfiguracji tunerów, nie restartowano Vu ani nie omijano ochrony logowania administratora. Końcowe aktywne transmisje wstrzymano na prośbę zadania dekodera prowadzącego ponownie izolowane strojenie.
Zakres nie obejmuje Android TV, pełnego logowania SSO w Keycloak, tworzenia/wykonywania nowych nagrań ani wielogodzinnej stabilności. Wynik short E2E nie jest gwarancją tych funkcji.
+2
View File
@@ -78,6 +78,8 @@ for(var i=0;i<2;i++)try{await stalled.Get("stalled",CancellationToken.None);thro
Check(watch.Elapsed.TotalSeconds<2&&failingCalls==1,"stalled source bounded and failed retries coalesced"); 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){} try{LiveStreamProbe.Parse("{\"streams\":[]}");throw new Exception("invalid video expected");}catch(InvalidOperationException){}
Check(true,"empty source rejected before playback"); Check(true,"empty source rejected before playback");
var liveVideo=LiveStreamProbe.ParseLiveMetadata(probes[0][0]);
Check(liveVideo.Index==-1&&liveVideo.IsInterlaced&&liveVideo.AverageFrameRate==25&&probes[0][0].Index==0,"live automatic mapping preserves metadata without mutating cached probe");
var relayHandler=new RelayHandler(); var relayHandler=new RelayHandler();
using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100))) using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100)))
{ {
+16 -3
View File
@@ -7,6 +7,8 @@ import pathlib
import shutil import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
import time
import urllib.error
import urllib.request import urllib.request
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@@ -25,9 +27,9 @@ plugins = pathlib.Path('/var/lib/jellyfin/plugins')
old = [p for p in plugins.glob('Enigma2_*') if p.is_dir()] 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 = 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) backup.mkdir(mode=0o700)
new = plugins / 'Enigma2_7.0.4.0' new = plugins / 'Enigma2_7.0.6.0'
metadata = json.loads((old[0] / 'meta.json').read_text()) metadata = json.loads((old[0] / 'meta.json').read_text())
metadata.update(version='7.0.4.0', status='Active', targetAbi='12.2.0.0') metadata.update(version='7.0.6.0', status='Active', targetAbi='12.2.0.0')
subprocess.run(['systemctl', 'stop', 'jellyfin'], check=True) subprocess.run(['systemctl', 'stop', 'jellyfin'], check=True)
try: try:
for p in old: for p in old:
@@ -46,4 +48,15 @@ except Exception:
finally: finally:
subprocess.run(['systemctl', 'start', 'jellyfin'], check=True) subprocess.run(['systemctl', 'start', 'jellyfin'], check=True)
assert hashes() == before, 'Unexpected change outside plugin installation' 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})) deadline = time.monotonic() + 45
while True:
try:
installed = json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Plugins', headers=headers), timeout=3))
if any(p['Name'] == 'Enigma2' and p['Version'] == '7.0.6.0' and p['Status'] == 'Active' for p in installed):
break
except (urllib.error.URLError, TimeoutError):
pass
if time.monotonic() >= deadline:
raise RuntimeError('Jellyfin did not load the deployed plugin within 45 seconds; backup: ' + str(backup))
time.sleep(1)
print(json.dumps({'plugin': '7.0.6.0', 'backup': str(backup), 'ready': True, 'server_configuration_and_wrappers_unchanged': True}))
+37
View File
@@ -0,0 +1,37 @@
{
"checked_utc": "2026-10-12T19:38:18.545Z",
"mode": "ui",
"checks": [
{
"name": "two isolated users can sign in",
"status": "PASS",
"evidence": {
"users": 2,
"plugin": "7.0.6.0"
}
},
{
"name": "administrator plugin configuration loads current receiver settings",
"status": "FAIL",
"error": "{\"route\":\"#/home\",\"admin\":true,\"headings\":[\"Please sign in\",\"Please sign in\",\"My Media\",\"Continue Watching\",\"Continue Listening\"],\"input_ids\":[\"txtManualName\",\"txtManualPassword\",\"\"]}"
},
{
"name": "no uncaught browser errors",
"status": "PASS"
}
],
"plugin": {
"Name": "Enigma2",
"Version": "7.0.6.0",
"ConfigurationFileName": "Jellyfin.Plugin.Enigma2.xml",
"Description": "Provides live TV using Enigma2 PVR as a back-end.",
"Id": "193f29f9ea6c4595a6f655e79d7c590a",
"CanUninstall": true,
"HasImage": false,
"Status": "Active"
},
"finished_utc": "2026-10-12T19:39:29.601Z",
"pass": 2,
"fail": 1,
"blocked": 0
}
+83
View File
@@ -0,0 +1,83 @@
{
"checked_utc": "2026-10-12T19:24:55.872Z",
"mode": "playback",
"checks": [
{
"name": "two isolated users can sign in",
"status": "PASS",
"evidence": {
"users": 2,
"plugin": "7.0.6.0"
}
},
{
"name": "4Fun.TV custom one-click decoded video",
"status": "PASS",
"evidence": {
"time_progress": 3.02,
"decoded_frames": 103,
"width": 1047
}
},
{
"name": "4Fun.TV stop or recover to Channels",
"status": "PASS"
},
{
"name": "custom channel button starts decoded video",
"status": "PASS",
"evidence": {
"time_progress": 3.07,
"decoded_frames": 98,
"width": 1047
}
},
{
"name": "custom playback exit",
"status": "PASS"
},
{
"name": "double-click and two browsers share one physical upstream",
"status": "PASS",
"evidence": {
"consumers": 2,
"physical_connections": 1
}
},
{
"name": "stopping one viewer preserves other viewer",
"status": "PASS",
"evidence": {
"time_progress": 3.01,
"decoded_frames": 151,
"width": 1047
}
},
{
"name": "last viewer releases source",
"status": "PASS",
"evidence": {
"active": 0,
"consumers": 0
}
},
{
"name": "no uncaught browser errors",
"status": "PASS"
}
],
"plugin": {
"Name": "Enigma2",
"Version": "7.0.6.0",
"ConfigurationFileName": "Jellyfin.Plugin.Enigma2.xml",
"Description": "Provides live TV using Enigma2 PVR as a back-end.",
"Id": "193f29f9ea6c4595a6f655e79d7c590a",
"CanUninstall": true,
"HasImage": false,
"Status": "Active"
},
"finished_utc": "2026-10-12T19:26:40.168Z",
"pass": 9,
"fail": 0,
"blocked": 0
}
+13
View File
@@ -0,0 +1,13 @@
{
"checked_utc": "2026-10-12T19:17:45Z",
"plugin": "7.0.4.0",
"guide_refresh": {
"started": "2026-10-12T19:11:37.3638053Z",
"finished": "2026-10-12T19:17:39.3939294Z",
"status": "Completed"
},
"feed_success": "2026-10-12T19:11:43.3794835Z",
"matched_programme_images": 5302,
"source": "https://epg.polishteam.one/guide.xml",
"status": "PASS"
}
+116
View File
@@ -0,0 +1,116 @@
{
"checked_utc": "2026-10-12T19:33:31.686Z",
"mode": "playback",
"checks": [
{
"name": "two isolated users can sign in",
"status": "PASS",
"evidence": {
"users": 2,
"plugin": "7.0.6.0"
}
},
{
"name": "4Fun.TV custom one-click decoded video",
"status": "PASS",
"evidence": {
"time_progress": 3.02,
"decoded_frames": 108,
"width": 1047
}
},
{
"name": "4Fun.TV stop or recover to Channels",
"status": "PASS"
},
{
"name": "TVP 1 HD native one-click decoded video",
"status": "FAIL",
"error": "page.waitForFunction: Timeout 20000ms exceeded."
},
{
"name": "TVP 1 HD stop or recover to Channels",
"status": "PASS"
},
{
"name": "TVP 2 HD native one-click decoded video",
"status": "FAIL",
"error": "page.waitForFunction: Timeout 20000ms exceeded."
},
{
"name": "TVP 2 HD stop or recover to Channels",
"status": "PASS"
},
{
"name": "TVN HD native one-click decoded video",
"status": "FAIL",
"error": "page.waitForFunction: Timeout 20000ms exceeded."
},
{
"name": "TVN HD stop or recover to Channels",
"status": "PASS"
},
{
"name": "native channel card opens actual video and native OSD",
"status": "PASS",
"evidence": {
"time_progress": 3.02,
"decoded_frames": 83,
"width": 1047
}
},
{
"name": "custom channel button starts decoded video",
"status": "FAIL",
"error": "page.waitForFunction: Timeout 20000ms exceeded."
},
{
"name": "custom playback exit",
"status": "PASS"
},
{
"name": "double-click and two browsers share one physical upstream",
"status": "PASS",
"evidence": {
"consumers": 2,
"physical_connections": 1
}
},
{
"name": "stopping one viewer preserves other viewer",
"status": "PASS",
"evidence": {
"time_progress": 3.01,
"decoded_frames": 150,
"width": 1047
}
},
{
"name": "last viewer releases source",
"status": "PASS",
"evidence": {
"active": 0,
"consumers": 0
}
},
{
"name": "no uncaught browser errors",
"status": "FAIL",
"error": "Uncaught browser errors: undefined; undefined; undefined; undefined"
}
],
"plugin": {
"Name": "Enigma2",
"Version": "7.0.6.0",
"ConfigurationFileName": "Jellyfin.Plugin.Enigma2.xml",
"Description": "Provides live TV using Enigma2 PVR as a back-end.",
"Id": "193f29f9ea6c4595a6f655e79d7c590a",
"CanUninstall": true,
"HasImage": false,
"Status": "Active"
},
"finished_utc": "2026-10-12T19:36:48.612Z",
"pass": 11,
"fail": 5,
"blocked": 0
}
+110
View File
@@ -0,0 +1,110 @@
{
"checked_utc": "2026-10-12T19:31:08.236Z",
"mode": "ui",
"checks": [
{
"name": "two isolated users can sign in",
"status": "PASS",
"evidence": {
"users": 2,
"plugin": "7.0.6.0"
}
},
{
"name": "anonymous page requires login",
"status": "PASS"
},
{
"name": "complete channel list and logos",
"status": "PASS",
"evidence": {
"channels": 216,
"loaded_logos": 34
}
},
{
"name": "search filters and restores channels",
"status": "PASS"
},
{
"name": "favorites persist and synchronize natively",
"status": "PASS"
},
{
"name": "hide applies to native channels and is isolated per user",
"status": "PASS",
"evidence": {
"mine": 215,
"other": 216
}
},
{
"name": "restore hidden channel",
"status": "PASS"
},
{
"name": "custom order persists in native API",
"status": "PASS"
},
{
"name": "EPG renders actual programmes",
"status": "PASS",
"evidence": {
"programmes": 500
}
},
{
"name": "EPG image links exist and load",
"status": "PASS",
"evidence": {
"images": 500
}
},
{
"name": "default preference persists after reload",
"status": "PASS"
},
{
"name": "native default Channels and explicit Guide",
"status": "PASS"
},
{
"name": "ordinary user cannot read admin diagnostics",
"status": "PASS"
},
{
"name": "external relay access denied",
"status": "PASS"
},
{
"name": "Vu recording provider disabled; timers remain readable",
"status": "PASS",
"evidence": {
"timers": 36
}
},
{
"name": "administrator plugin configuration loads current receiver settings",
"status": "FAIL",
"error": "locator.waitFor: Timeout 20000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('#txtHostName') to be visible\u001b[22m\n"
},
{
"name": "no uncaught browser errors",
"status": "PASS"
}
],
"plugin": {
"Name": "Enigma2",
"Version": "7.0.6.0",
"ConfigurationFileName": "Jellyfin.Plugin.Enigma2.xml",
"Description": "Provides live TV using Enigma2 PVR as a back-end.",
"Id": "193f29f9ea6c4595a6f655e79d7c590a",
"CanUninstall": true,
"HasImage": false,
"Status": "Active"
},
"finished_utc": "2026-10-12T19:32:03.314Z",
"pass": 16,
"fail": 1,
"blocked": 0
}
+187
View File
@@ -0,0 +1,187 @@
// Real Jellyfin Web E2E. All credentials remain in memory; only isolated test users are changed.
const fs = require('node:fs');
const { execFileSync } = require('node:child_process');
const { chromium } = require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const origin = 'http://192.168.100.191:8096', web = origin + '/web/index.html';
const ssh = code => JSON.parse(execFileSync('ssh', ['-o', 'BatchMode=yes', 'root@192.168.100.25', 'pct exec 100191 -- python3 -'], { input: code, encoding: 'utf8', timeout: 30000 }).trim().split('\n').at(-1));
const admin = `import json,sqlite3,urllib.request,pathlib,secrets
c=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys LIMIT 1').fetchone()[0];c.close()
def api(p,d=None,method=None):
r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="E2E", Device="Maintenance", DeviceId="decoder-e2e", Version="1"','Content-Type':'application/json'},data=json.dumps(d).encode() if d is not None else None,method=method)
b=urllib.request.urlopen(r,timeout=20).read();return json.loads(b) if b else None
`;
const controlOnly = process.argv.includes('--control-only');
const configOnly=process.argv.includes('--config-only');
const report = { checked_utc: new Date().toISOString(), mode: process.argv.includes('--ui-only') || configOnly ? 'ui' : process.argv.includes('--playback-only') || controlOnly ? 'playback' : 'full', checks: [] };
const assert = (condition, message) => { if (!condition) throw Error(message); };
async function check(name, action) {
try { const evidence = await action(); report.checks.push({ name, status: 'PASS', evidence }); }
catch (e) { report.checks.push({ name, status: 'FAIL', error: e.message.replace(/Token="[^"]+"/g, 'Token="<redacted>"').slice(0, 350) }); }
console.log(name + ': ' + report.checks.at(-1).status);
return report.checks.at(-1).status==='PASS';
}
const accounts = []; let browser; const pages = [], browserErrors = [];
async function api(page, path, method='GET', data) {
return page.evaluate(async ({path,method,data}) => {
const s = JSON.parse(localStorage.getItem('jellyfin_credentials')).Servers.find(s=>s.AccessToken&&s.UserId);
const r = await fetch(path,{method,headers:{Authorization:'MediaBrowser Token="'+s.AccessToken+'", Client="DecoderE2E", Device="Browser", DeviceId="decoder-e2e", Version="1"','Content-Type':'application/json'},body:data===undefined?undefined:JSON.stringify(data)});
const text = await r.text(); return { status:r.status, body:text?JSON.parse(text):null };
}, {path,method,data});
}
async function custom(page) { await page.goto(origin+'/DecoderIntegration/Channels'); await page.locator('#channelRows section').first().waitFor({timeout:30000}); }
async function native(page) { await page.goto(web+'#/livetv'); await page.waitForURL(/tab=2/,{timeout:15000}); await page.locator('.card[data-type="TvChannel"]').first().waitFor({timeout:15000}); }
async function exit(page, escape=false) {
if(await page.locator('#decoderPlaybackExit').count()) {
if(escape)await page.keyboard.press('Escape'); else await page.locator('#decoderPlaybackExit').click();
}
await page.waitForURL(/livetv.*tab=2/,{timeout:30000});
await page.locator('.card[data-type="TvChannel"]').first().waitFor({timeout:15000});
assert(await page.locator('#decoderPlaybackExit').count()===0,'Playback control remains after stopping');
}
async function start(page, name, double=false) {
await native(page);
if(name==='4Fun.TV') {
await custom(page);await page.getByRole('button',{name:/^\d+\. 4Fun\.TV$/}).click({clickCount:double?2:1});
await page.locator('#decoderPlaybackExit').waitFor({timeout:15000});return;
}
const image=page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:new RegExp('^\\d+ '+name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'$')})}).locator('a.cardImageContainer');
await image.click({position:{x:20,y:20},clickCount:double?2:1});
await page.locator('#decoderPlaybackExit').waitFor({timeout:10000});
}
async function frames(page) {
await page.waitForFunction(()=>[...document.querySelectorAll('video')].some(v=>v.currentTime>0&&v.readyState>=2&&v.videoWidth>0),{}, {timeout:20000});
const first=await page.locator('video').first().evaluate(v=>({time:v.currentTime,frames:v.getVideoPlaybackQuality?.().totalVideoFrames||v.webkitDecodedFrameCount||0,width:v.videoWidth}));
await page.waitForTimeout(3000);
const last=await page.locator('video').first().evaluate(v=>({time:v.currentTime,frames:v.getVideoPlaybackQuality?.().totalVideoFrames||v.webkitDecodedFrameCount||0,width:v.videoWidth}));
assert(last.time>first.time && last.frames>first.frames,'Video clock or decoded frame counter did not advance');
return { time_progress:Number((last.time-first.time).toFixed(2)), decoded_frames:last.frames-first.frames,width:last.width };
}
(async()=>{
try {
browser=await chromium.launch({headless:true,channel:'msedge'});
for(let i=0;i<2;i++) {
const user=ssh(admin+`n='decoder-e2e-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});print(json.dumps({'id':u['Id'],'name':n,'password':p}))`);
accounts.push(user); ssh(admin+`api('/Users/${user.id}/Password',{'CurrentPw':'','NewPw':${JSON.stringify(user.password)}});print('{}')`);
const context=await browser.newContext({viewport:{width:1280,height:900}}),page=await context.newPage();pages.push(page);
page.on('pageerror',e=>browserErrors.push(e.message));
await page.goto(web);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));
}
const [page,other]=pages;
report.plugin=ssh(admin+`print(json.dumps(next(x for x in api('/Plugins') if x['Name']=='Enigma2')))`);
await check('two isolated users can sign in',async()=>({users:pages.length,plugin:report.plugin.Version}));
if(report.mode!=='playback') {
if(!configOnly) {
await check('anonymous page requires login',async()=>{
const p=await (await browser.newContext()).newPage();await p.goto(origin+'/DecoderIntegration/Channels');assert(await p.locator('#channelRows section').count()===0,'Anonymous channels exposed');assert(await p.getByRole('link',{name:'Przejdź do logowania Jellyfin'}).count()===1,'No login link');await p.context().close();
});
let all;
await check('complete channel list and logos',async()=>{
await custom(page);all=(await api(page,'/LiveTv/Channels?Limit=1000')).body.Items;
assert(await page.locator('#channelRows section').count()===all.length,'Incomplete custom channel list');assert(all.length===216,'Unexpected channel count');
await page.waitForFunction(()=>[...document.querySelectorAll('#channelRows img')].some(i=>i.complete&&i.naturalWidth>0));return {channels:all.length,loaded_logos:await page.locator('#channelRows img').evaluateAll(xs=>xs.filter(i=>i.naturalWidth>0).length)};
});
await check('search filters and restores channels',async()=>{
await page.locator('#channelSearch').fill('TVP 1 HD');assert(await page.locator('#channelRows section').count()===1,'Search not exact for TVP1');await page.locator('#channelSearch').fill('');assert(await page.locator('#channelRows section').count()===all.length,'Clear search failed');
});
await check('favorites persist and synchronize natively',async()=>{
const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'☆ Ulubiony',exact:true}).first().click();assert((await response).ok(),'Preference save failed');
await page.waitForTimeout(500);await custom(page);await page.locator('#channelView').selectOption('favorites');assert(await page.locator('#channelRows section').count()===1,'Favorite not persisted');
const native=(await api(page,'/LiveTv/Channels?Limit=1000&EnableUserData=true&UserId='+accounts[0].id)).body.Items;assert(native.some(c=>c.UserData?.IsFavorite),'Native favorite missing');
});
await check('hide applies to native channels and is isolated per user',async()=>{
const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'Ukryj',exact:true}).click();await response;
assert(await page.locator('#channelRows section').count()===0,'Favorite not hidden');
const mine=(await api(page,'/LiveTv/Channels?Limit=0&UserId='+accounts[0].id)).body, theirs=(await api(other,'/LiveTv/Channels?Limit=0&UserId='+accounts[1].id)).body;
assert(mine.TotalRecordCount===215 && theirs.TotalRecordCount===216,'Hide leaked across users');return {mine:mine.TotalRecordCount,other:theirs.TotalRecordCount};
});
await check('restore hidden channel',async()=>{
await page.locator('#channelView').selectOption('hidden');const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'Pokaż',exact:true}).click();await response;await page.locator('#channelView').selectOption('all');assert(await page.locator('#channelRows section').count()===216,'Channel not restored');
});
await check('custom order persists in native API',async()=>{
const second=all[1].Id.replace(/-/g,'');const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.locator('#channelRows section').nth(1).getByRole('button',{name:'↑',exact:true}).click();await response;
assert((await api(page,'/LiveTv/Channels?Limit=1&UserId='+accounts[0].id)).body.Items[0].Id.replace(/-/g,'')===second,'Native order not synchronized');
});
await check('EPG renders actual programmes',async()=>{
await page.locator('#channelView').selectOption('guide');await page.locator('#channelRows section div p').first().waitFor({timeout:45000});const n=await page.locator('#channelRows section div p').count();assert(n>0,'Empty EPG');return {programmes:n};
});
await check('EPG image links exist and load',async()=>{
const images=page.locator('#channelRows section div img');const n=await images.count();assert(n>0,'No programme artwork in current EPG');await images.first().scrollIntoViewIfNeeded();await images.first().evaluate(i=>i.decode());return {images:n};
});
await check('default preference persists after reload',async()=>{
const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'Ustaw ten widok jako domyślny'}).click();await response;await custom(page);assert(await page.locator('#channelView').inputValue()==='guide','Default guide preference not persisted');
});
await check('native default Channels and explicit Guide',async()=>{
await native(page);await page.goto(web+'#/livetv?tab=1');await page.waitForTimeout(1000);assert(page.url().endsWith('tab=1'),'Explicit guide tab overwritten');
});
await check('ordinary user cannot read admin diagnostics',async()=>assert((await api(page,'/DecoderIntegration/Status')).status===403,'Admin endpoint exposed'));
await check('external relay access denied',async()=>assert((await page.request.get(origin+'/DecoderIntegration/Live/00000000000000000000000000000000')).status()===403,'Relay exposed outside loopback'));
await check('Vu recording provider disabled; timers remain readable',async()=>{
const channels=(await api(page,'/Channels')).body.Items,timers=(await api(page,'/LiveTv/Timers?Limit=0')).body;assert(channels.length===0,'Recording provider still registered');return {timers:timers.TotalRecordCount};
});
}
await check('administrator plugin configuration loads current receiver settings',async()=>{
const policy=ssh(admin+`u=api('/Users/${accounts[1].id}');p=u['Policy'];p['IsAdministrator']=True;api('/Users/${accounts[1].id}/Policy',p);print(json.dumps({'administrator':api('/Users/${accounts[1].id}')['Policy']['IsAdministrator']}))`);
assert(policy.administrator,'Temporary user did not gain admin rights');
// The old client session caches its original non-admin policy; sign in afresh.
const config=await (await browser.newContext()).newPage();await config.goto(web);await config.locator('#txtManualName').fill(accounts[1].name);await config.locator('#txtManualPassword').fill(accounts[1].password);await config.getByRole('checkbox').check();await config.getByRole('button',{name:'Sign In',exact:true}).click();await config.waitForURL(/home/,{timeout:20000});
await config.waitForFunction(()=>window.ApiClient?.getCurrentUserId());
await config.evaluate(()=>window.location.hash='#/configurationpage?name=enigma2');
// Jellyfin 12 can request a separate elevated administrator sign-in.
const elevated=config.locator('#txtManualPassword:visible');
try {
await elevated.waitFor({timeout:5000});
const name=config.locator('#txtManualName:visible');if(await name.count()&&await name.isEditable())await name.fill(accounts[1].name);
await elevated.fill(accounts[1].password);await config.getByRole('button',{name:'Sign In',exact:true}).last().click();
await config.evaluate(()=>window.location.hash='#/configurationpage?name=enigma2');
} catch {}
try {await config.locator('#txtHostName').waitFor({timeout:20000});}
catch {throw Error(JSON.stringify(await config.evaluate(async()=>({route:location.hash,admin:(await window.ApiClient.getCurrentUser()).Policy.IsAdministrator,headings:[...document.querySelectorAll('h1,h2')].map(e=>e.textContent).slice(0,5),input_ids:[...document.querySelectorAll('input')].map(i=>i.id).slice(0,20)}))));}
await config.waitForFunction(()=>document.querySelector('#txtHostName')?.value==='192.168.10.20');assert(await config.locator('#txtStreamingPort').inputValue()==='8001','Unexpected configured source port');
assert(!await config.locator('#chkEnableVuRecordings').isChecked(),'Vu recordings unexpectedly enabled');await config.context().close();
// This check does not submit the form or alter the plugin configuration.
});
}
if(report.mode!=='ui') {
let controlVideo=false;
for(const name of controlOnly?['4Fun.TV']:['4Fun.TV','TVP 1 HD','TVP 2 HD','TVN HD']) {
let started=false;
const ok=await check(name+' '+(name==='4Fun.TV'?'custom':'native')+' one-click decoded video',async()=>{await start(page,name);started=true;return await frames(page);});
if(name==='4Fun.TV')controlVideo=ok;
await check(name+' stop or recover to Channels',async()=>{if(started)await exit(page,name==='TVP 2 HD');else await native(page);});
}
if(controlVideo) {
await check('native channel card opens actual video and native OSD',async()=>{
const channel=(await api(page,'/LiveTv/Channels?Limit=1000')).body.Items.find(c=>c.Name==='4Fun.TV');
const prefs=(await api(page,'/DecoderIntegration/Preferences')).body;prefs.OrderedIds=[channel.Id];assert((await api(page,'/DecoderIntegration/Preferences','PUT',prefs)).status===204,'Test account order save failed');
await native(page);await page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:/^\d+ 4Fun\.TV$/})}).locator('a.cardImageContainer').click({position:{x:20,y:20}});
const r=await frames(page);assert(page.url().includes('/video'),'Native video OSD did not open');await exit(page,true);return r;
});
}
await check('custom channel button starts decoded video',async()=>{await custom(page);await page.getByRole('button',{name:controlOnly?/^\d+\. 4Fun\.TV$/:/^\d+\. TVN HD$/}).click();await page.locator('#decoderPlaybackExit').waitFor({timeout:15000});return await frames(page);});
await check('custom playback exit',async()=>exit(page,true));
let shared=false;
if(controlVideo) shared=await check('double-click and two browsers share one physical upstream',async()=>{
await start(page,'4Fun.TV',true);await frames(page);await start(other,'4Fun.TV');await frames(other);
const s=ssh(admin+`print(json.dumps(api('/DecoderIntegration/Status')['Streams']))`);
const channel=s.Channels.find(c=>c.ServiceReference.includes(':428D:2BC0:13E:'));assert(channel?.Active&&channel.Consumers>=2,'Two browser consumers not present');
const owif=await page.request.get('http://192.168.10.20/api/about');const body=await owif.json();const streams=body.info?.streams||body.streams||[];assert(streams.filter(s=>/4fun.tv/i.test(JSON.stringify(s))).length===1,'Decoder does not report exactly one 4Fun.TV stream');return {consumers:channel.Consumers,physical_connections:1};
});
else report.checks.push({name:'double-click and two browsers share one physical upstream',status:'BLOCKED',error:'No working control video'});
if(shared)await check('stopping one viewer preserves other viewer',async()=>{await exit(page);const r=await frames(other);await exit(other,true);return r;});
else report.checks.push({name:'stopping one viewer preserves other viewer',status:'BLOCKED',error:'Two active viewers were not established'});
for(const p of pages)if(await p.locator('#decoderPlaybackExit').count())await exit(p);
await check('last viewer releases source',async()=>{
await page.waitForTimeout(17000);const s=ssh(admin+`print(json.dumps(api('/DecoderIntegration/Status')['Streams']))`);assert(s.Consumers===0&&s.ActiveSources===0,'Diagnostic sources remain active');return {active:s.ActiveSources,consumers:s.Consumers};
});
}
await check('no uncaught browser errors',async()=>assert(browserErrors.length===0,'Uncaught browser errors: '+browserErrors.join('; ')));
} catch(e) { report.checks.push({name:'suite prerequisite',status:'FAIL',error:e.message.slice(0,350)}); }
finally {
if(browser)await browser.close();
for(const u of accounts)try { ssh(admin+`api('/Users/${u.id}',method='DELETE');pathlib.Path('/var/lib/jellyfin/decoder-integration/users/${u.id.replace(/-/g,'')}.json').unlink(missing_ok=True);print('{}')`); } catch(e){report.checks.push({name:'test account cleanup',status:'FAIL',error:'Failed to remove '+u.name});}
report.finished_utc=new Date().toISOString();report.pass=report.checks.filter(c=>c.status==='PASS').length;report.fail=report.checks.filter(c=>c.status==='FAIL').length;report.blocked=report.checks.filter(c=>c.status==='BLOCKED').length;
const path=process.argv.find(a=>a.startsWith('--report='))?.slice(9);if(path)fs.writeFileSync(path,JSON.stringify(report,null,2)+'\n');
console.log(JSON.stringify(report));process.exitCode=report.fail?1:0;
}
})();
+4 -3
View File
@@ -3,14 +3,14 @@ const fs = require('node:fs');
const vm = require('node:vm'); const vm = require('node:vm');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const listeners = {}, documentListeners = {}, elements = []; const listeners = {}, documentListeners = {}, elements = [];
let clock = 0, interval, starts = 0, stops = 0, osd = 0, returned = 0; let clock = 0, interval, starts = 0, stops = 0, osd = 0, returned = 0, readyState = 0;
const pending = []; const pending = [];
const location = { hash: '#/livetv', pathname: '/web/index.html', search: '', replace(value) { this.hash = value; } }; const location = { hash: '#/livetv', pathname: '/web/index.html', search: '', replace(value) { this.hash = value; } };
const document = { const document = {
fullscreenElement: null, fullscreenElement: null,
body: { append(element) { elements.push(element); } }, body: { append(element) { elements.push(element); } },
createElement() { return { style: {}, remove() { elements.splice(elements.indexOf(this), 1); } }; }, createElement() { return { style: {}, remove() { elements.splice(elements.indexOf(this), 1); } }; },
querySelector() { return { readyState: 0, currentTime: 0 }; }, querySelector() { return { readyState, currentTime: 0 }; },
addEventListener(name, callback) { documentListeners[name] = callback; } addEventListener(name, callback) { documentListeners[name] = callback; }
}; };
const window = { const window = {
@@ -41,7 +41,8 @@ const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve();
const click = () => documentListeners.click({ button: 0, target: { closest: () => ({ closest: () => ({ dataset: { id: 'channel', serverid: 'server' } }) }) }, preventDefault() {}, stopImmediatePropagation() {} }); const click = () => documentListeners.click({ button: 0, target: { closest: () => ({ closest: () => ({ dataset: { id: 'channel', serverid: 'server' } }) }) }, preventDefault() {}, stopImmediatePropagation() {} });
click(); click(); await flush(); click(); click(); await flush();
assert.equal(starts, 1); assert.equal(elements[0].id, 'decoderPlaybackExit'); assert.equal(starts, 1); assert.equal(elements[0].id, 'decoderPlaybackExit');
interval(); await flush(); assert.equal(osd, 1); interval(); await flush(); assert.equal(osd, 0, 'OSD must wait for actual video metadata');
readyState=1;interval(); await flush(); assert.equal(osd, 1);
listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} }); listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} });
await flush(); assert.equal(stops, 1); assert.equal(returned, 1); assert.equal(elements.length, 0); await flush(); assert.equal(stops, 1); assert.equal(returned, 1); assert.equal(elements.length, 0);
click(); await flush(); assert.equal(starts, 2); click(); await flush(); assert.equal(starts, 2);
+1 -1
View File
@@ -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.4.0' and report['plugin']['Status']=='Active' assert report['plugin']['Version']=='7.0.6.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']
+7 -2
View File
@@ -29,13 +29,18 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
cancelButton.id = 'decoderPlaybackExit'; cancelButton.textContent = 'Zatrzymaj i wróć do kanałów'; 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.style.cssText = 'position:fixed;right:16px;top:72px;z-index:2147483647;background:#252525;color:white;border:1px solid #aaa;border-radius:6px;padding:12px;font:16px system-ui;cursor:pointer';
cancelButton.onclick = () => void leave(); document.body.append(cancelButton); cancelButton.onclick = () => void leave(); document.body.append(cancelButton);
let opened = false; const deadline = Date.now() + 25000; let opened = false, opening = false; const deadline = Date.now() + 25000;
watcher = setInterval(() => { watcher = setInterval(() => {
if (ticket !== generation) return; if (ticket !== generation) return;
const video = document.querySelector('video'); const video = document.querySelector('video');
const player = playbackManager.getCurrentPlayer(); const player = playbackManager.getCurrentPlayer();
if (player?.isLocalPlayer === false) { clearControls(); return; } if (player?.isLocalPlayer === false) { clearControls(); return; }
if (!opened && video && player) { opened = true; void appRouter.showVideoOsd(); } if (!opened && !opening && video?.readyState >= 1 && player) {
opening = true;
Promise.resolve(appRouter.showVideoOsd()).then(() => { opened = true; })
.catch(() => { /* A cancelled/unready native route can be retried. */ })
.finally(() => { opening = false; });
}
if (video?.readyState >= 2 && video.currentTime > 0) return; if (video?.readyState >= 2 && video.currentTime > 0) return;
if (Date.now() >= deadline) { void leave(); toast('Kanał nie przesyła obrazu. Powrót do listy kanałów.'); } if (Date.now() >= deadline) { void leave(); toast('Kanał nie przesyła obrazu. Powrót do listy kanałów.'); }
}, 250); }, 250);