From f5cdfa285e218fe9fe4cec30193393bb55ed357a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Mon, 12 Oct 2026 21:41:26 +0200 Subject: [PATCH] Verify decoder integration E2E and fix live metadata probing and mapping --- .../Jellyfin.Plugin.Enigma2.csproj | 4 +- Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs | 56 +++++- Jellyfin.Plugin.Enigma2/LiveTvService.cs | 10 +- README.integration.md | 19 +- Tests/Program.cs | 2 + deploy-plugin-only.py | 19 +- e2e-config-20261012.json | 37 ++++ e2e-control-20261012.json | 83 ++++++++ e2e-guide-20261012.json | 13 ++ e2e-playback-20261012.json | 116 +++++++++++ e2e-ui-20261012.json | 110 +++++++++++ test-e2e.cjs | 187 ++++++++++++++++++ test-web-bridge.cjs | 7 +- verify.py | 2 +- web/decoder-channel-playback.js | 9 +- 15 files changed, 657 insertions(+), 17 deletions(-) create mode 100644 e2e-config-20261012.json create mode 100644 e2e-control-20261012.json create mode 100644 e2e-guide-20261012.json create mode 100644 e2e-playback-20261012.json create mode 100644 e2e-ui-20261012.json create mode 100644 test-e2e.cjs diff --git a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj index fccc068..b409a42 100644 --- a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj +++ b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj @@ -2,8 +2,8 @@ net10.0 - 7.0.4.0 - 7.0.4.0 + 7.0.6.0 + 7.0.6.0 diff --git a/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs b/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs index 25ffbdc..7c056b3 100644 --- a/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs +++ b/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs @@ -2,7 +2,9 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Linq; +using System.Net.Http; using System.Text.Json; using System.Threading; 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(Func> runner, TimeSpan? timeout = null) { this.runner = runner; this.timeout = timeout ?? TimeSpan.FromSeconds(15); } - public Task> Get(string url, CancellationToken caller) + public Task> Get(string url, CancellationToken caller, int? programme = null) { + if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture); Task> task; lock (gate) { @@ -48,7 +51,7 @@ public sealed class LiveStreamProbe try { 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(); } } 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 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 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 } }; - 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(); var output = process.StandardOutput.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."); 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 + }; } diff --git a/Jellyfin.Plugin.Enigma2/LiveTvService.cs b/Jellyfin.Plugin.Enigma2/LiveTvService.cs index 9506d25..ce82be9 100644 --- a/Jellyfin.Plugin.Enigma2/LiveTvService.cs +++ b/Jellyfin.Plugin.Enigma2/LiveTvService.cs @@ -1173,7 +1173,14 @@ namespace Jellyfin.Plugin.Enigma2 var streamUrl = string.Format("{0}/{1}{2}", baseUrl, channelOid, trancodingUrl); 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 { @@ -1183,6 +1190,7 @@ namespace Jellyfin.Plugin.Enigma2 Container = Plugin.Instance.Configuration.TranscodedStream ? "mpegts" : "ts", IsInfiniteStream = true, SupportsProbing = false, + AnalyzeDurationMs = 1500, SupportsDirectPlay = false, MediaStreams = streams }; diff --git a/README.integration.md b/README.integration.md index 0e4882f..b93e402 100644 --- a/README.integration.md +++ b/README.integration.md @@ -1,6 +1,6 @@ # 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 @@ -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. -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. \ No newline at end of file +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. \ No newline at end of file diff --git a/Tests/Program.cs b/Tests/Program.cs index 4f5ae9a..9f47c10 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -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"); try{LiveStreamProbe.Parse("{\"streams\":[]}");throw new Exception("invalid video expected");}catch(InvalidOperationException){} 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(); using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100))) { diff --git a/deploy-plugin-only.py b/deploy-plugin-only.py index 6cad6ee..a5723fc 100644 --- a/deploy-plugin-only.py +++ b/deploy-plugin-only.py @@ -7,6 +7,8 @@ import pathlib import shutil import sqlite3 import subprocess +import time +import urllib.error import urllib.request 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()] 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' +new = plugins / 'Enigma2_7.0.6.0' 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) try: for p in old: @@ -46,4 +48,15 @@ except Exception: 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})) +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})) diff --git a/e2e-config-20261012.json b/e2e-config-20261012.json new file mode 100644 index 0000000..2c6954d --- /dev/null +++ b/e2e-config-20261012.json @@ -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 +} diff --git a/e2e-control-20261012.json b/e2e-control-20261012.json new file mode 100644 index 0000000..77d0dce --- /dev/null +++ b/e2e-control-20261012.json @@ -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 +} diff --git a/e2e-guide-20261012.json b/e2e-guide-20261012.json new file mode 100644 index 0000000..4f9ddb7 --- /dev/null +++ b/e2e-guide-20261012.json @@ -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" +} diff --git a/e2e-playback-20261012.json b/e2e-playback-20261012.json new file mode 100644 index 0000000..4d4e21a --- /dev/null +++ b/e2e-playback-20261012.json @@ -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 +} diff --git a/e2e-ui-20261012.json b/e2e-ui-20261012.json new file mode 100644 index 0000000..5839f32 --- /dev/null +++ b/e2e-ui-20261012.json @@ -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 +} diff --git a/test-e2e.cjs b/test-e2e.cjs new file mode 100644 index 0000000..5fbf7a9 --- /dev/null +++ b/test-e2e.cjs @@ -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=""').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; + } +})(); diff --git a/test-web-bridge.cjs b/test-web-bridge.cjs index 2689b49..2d50a82 100644 --- a/test-web-bridge.cjs +++ b/test-web-bridge.cjs @@ -3,14 +3,14 @@ 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; +let clock = 0, interval, starts = 0, stops = 0, osd = 0, returned = 0, readyState = 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 }; }, + querySelector() { return { readyState, currentTime: 0 }; }, addEventListener(name, callback) { documentListeners[name] = callback; } }; 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() {} }); click(); click(); await flush(); 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() {} }); await flush(); assert.equal(stops, 1); assert.equal(returned, 1); assert.equal(elements.length, 0); click(); await flush(); assert.equal(starts, 2); diff --git a/verify.py b/verify.py index 7872c9c..3edaf8c 100644 --- a/verify.py +++ b/verify.py @@ -9,7 +9,7 @@ def api(path,data=None,method=None,token=admin): body=response.read();return json.loads(body) if body else None report={'checked_utc':datetime.datetime.now(datetime.timezone.utc).isoformat()} 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['channel_count']=api('/LiveTv/Channels?Limit=0')['TotalRecordCount'] report['timer_count']=api('/LiveTv/Timers?Limit=0')['TotalRecordCount'] diff --git a/web/decoder-channel-playback.js b/web/decoder-channel-playback.js index 68da5cc..afca34a 100644 --- a/web/decoder-channel-playback.js +++ b/web/decoder-channel-playback.js @@ -29,13 +29,18 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback { 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; + let opened = false, opening = 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 (!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 (Date.now() >= deadline) { void leave(); toast('Kanał nie przesyła obrazu. Powrót do listy kanałów.'); } }, 250);