From 0ab790d1e4fc1383aac4b8255bc7a9483968b7f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Tue, 13 Oct 2026 09:07:29 +0200 Subject: [PATCH] Align decoder startup deadlines and deploy resilient 7.0.8 --- Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs | 27 +- Jellyfin.Plugin.Enigma2/LiveStreamRelay.cs | 25 +- README.integration.md | 13 +- Tests/AdaptiveProbeTests.cs | 34 ++- Tests/Program.cs | 1 + Tests/RelayStartupTests.cs | 135 +++++++++ deploy-plugin-only.py | 302 +++++++++++++++++---- deployment-7.0.8-20261013.json | 48 ++++ test-web-bridge.cjs | 5 +- web/decoder-channel-playback.js | 3 +- 10 files changed, 527 insertions(+), 66 deletions(-) create mode 100644 Tests/RelayStartupTests.cs create mode 100644 deployment-7.0.8-20261013.json diff --git a/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs b/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs index b37ad65..2c70541 100644 --- a/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs +++ b/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs @@ -39,6 +39,12 @@ public sealed class LiveStreamProbe } } + // Distinguish a rejected finite sample from process startup/I/O/cancellation failures. + public sealed class ProbeProcessException : InvalidOperationException + { + public ProbeProcessException(int exitCode) : base("Decoder stream probe process failed (exit code " + exitCode.ToString(CultureInfo.InvariantCulture) + ").") { } + } + public Task> Get(string url, CancellationToken caller, int? programme = null) { if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture); @@ -166,12 +172,19 @@ public sealed class LiveStreamProbe if (await Snapshot(limits.InitialBytes)) { attempts = 1; - json = await fileProbe(samplePath, programme, false, token); - analysisReturned = true; - try { Parse(json, programme); return json; } - catch (Exception error) when (error is InvalidOperationException || error is JsonException) + try { - cause = "initial_video_unavailable"; + json = await fileProbe(samplePath, programme, false, token); + analysisReturned = true; + } + catch (ProbeProcessException) { cause = "initial_probe_process_failed"; } + if (analysisReturned) + { + try { Parse(json, programme); return json; } + catch (Exception error) when (error is InvalidOperationException || error is JsonException) + { + cause = "initial_video_unavailable"; + } } } @@ -198,7 +211,7 @@ public sealed class LiveStreamProbe cause = error is OperationCanceledException ? "deadline" : error is IOException ? "capture_io_failure" : error is HttpRequestException ? "capture_http_failure" - : error.Message.StartsWith("Decoder stream probe process failed", StringComparison.Ordinal) ? "probe_process_failed" + : error is ProbeProcessException ? "probe_process_failed" : attempts > 0 && !analysisReturned ? "probe_analysis_failure" : attempts > 0 ? ProbeCause(json, programme) : capturedBytes == 0 ? "capture_empty" : "capture_unavailable"; throw new InvalidOperationException($"Decoder probe failed: cause={cause}; expectedProgramme={programme?.ToString(CultureInfo.InvariantCulture) ?? "none"}; {ProbeSummary(json)}; sampleBytes={sampleBytes}; capturedBytes={capturedBytes}; elapsedMs={watch.ElapsedMilliseconds}; attempts={attempts}; captureStop={captureStop}."); @@ -261,7 +274,7 @@ public sealed class LiveStreamProbe { await process.WaitForExitAsync(token); await errors; - if (process.ExitCode != 0) throw new InvalidOperationException("Decoder stream probe process failed (exit code " + process.ExitCode.ToString(CultureInfo.InvariantCulture) + ")."); + if (process.ExitCode != 0) throw new ProbeProcessException(process.ExitCode); return await output; } finally diff --git a/Jellyfin.Plugin.Enigma2/LiveStreamRelay.cs b/Jellyfin.Plugin.Enigma2/LiveStreamRelay.cs index 73baffc..e8ed0ea 100644 --- a/Jellyfin.Plugin.Enigma2/LiveStreamRelay.cs +++ b/Jellyfin.Plugin.Enigma2/LiveStreamRelay.cs @@ -23,8 +23,17 @@ public sealed class LiveStreamRelay : IDisposable private readonly object gate = new(); private int opened; private readonly TimeSpan idleGrace; + private readonly TimeSpan startupTimeout; + private readonly TimeSpan readTimeout; public LiveStreamRelay(IServerApplicationHost host) : this(new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3), AllowAutoRedirect = false }), host.HttpPort) { } - public LiveStreamRelay(HttpClient client, int port, TimeSpan? idleGrace = null) { this.client = client; client.Timeout = Timeout.InfiniteTimeSpan; this.port = port; this.idleGrace = idleGrace ?? TimeSpan.FromSeconds(15); } + public LiveStreamRelay(HttpClient client, int port, TimeSpan? idleGrace = null, TimeSpan? startupTimeout = null, TimeSpan? readTimeout = null) + { + this.startupTimeout = startupTimeout ?? TimeSpan.FromSeconds(20); + this.readTimeout = readTimeout ?? TimeSpan.FromSeconds(8); + if (this.startupTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(startupTimeout)); + if (this.readTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(readTimeout)); + this.client = client; client.Timeout = Timeout.InfiniteTimeSpan; this.port = port; this.idleGrace = idleGrace ?? TimeSpan.FromSeconds(15); + } public string Register(string url) { lock (gate) @@ -96,7 +105,7 @@ public sealed class LiveStreamRelay : IDisposable foreach (var chunk in history) subscription.Buffer.Writer.TryWrite(chunk); initial = ready.Task; } - try { await initial.WaitAsync(TimeSpan.FromSeconds(12), token); return subscription; } + try { await initial.WaitAsync(owner.startupTimeout + TimeSpan.FromSeconds(1), token); return subscription; } catch { await subscription.DisposeAsync(); throw; } } public void Remove(Subscription subscription) @@ -123,21 +132,25 @@ public sealed class LiveStreamRelay : IDisposable Exception failure = null; try { - using var opening = CancellationTokenSource.CreateLinkedTokenSource(token); opening.CancelAfter(TimeSpan.FromSeconds(5)); + // Headers and the first MPEG-TS chunk share one startup deadline. + // A slow tuner may accept HTTP before it starts delivering packets. + using var opening = CancellationTokenSource.CreateLinkedTokenSource(token); opening.CancelAfter(owner.startupTimeout); Interlocked.Increment(ref owner.opened); using var response = await owner.client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, opening.Token); response.EnsureSuccessStatusCode(); using var stream = await response.Content.ReadAsStreamAsync(token); - var buffer = new byte[188 * 256 + 188]; var carry = 0; + var buffer = new byte[188 * 256 + 188]; var carry = 0; var started = false; while (true) { - using var reading = CancellationTokenSource.CreateLinkedTokenSource(token); reading.CancelAfter(TimeSpan.FromSeconds(8)); - var count = await stream.ReadAtLeastAsync(buffer.AsMemory(carry, buffer.Length - carry), 188 * 256 - carry, throwOnEndOfStream: false, cancellationToken: reading.Token); + using var reading = CancellationTokenSource.CreateLinkedTokenSource(token); + if (started) reading.CancelAfter(owner.readTimeout); + var count = await stream.ReadAtLeastAsync(buffer.AsMemory(carry, buffer.Length - carry), 188 * 256 - carry, throwOnEndOfStream: false, cancellationToken: started ? reading.Token : opening.Token); if (count == 0) throw new IOException("Decoder closed the live stream."); count += carry; var bytes = count / 188 * 188; if (bytes > 0) { if (buffer[0] != 0x47 || (bytes >= 376 && buffer[188] != 0x47)) throw new IOException("Decoder did not return MPEG-TS packets."); + started = true; var chunk = buffer.AsSpan(0, bytes).ToArray(); lock (gate) { diff --git a/README.integration.md b/README.integration.md index 474edab..d78aa4d 100644 --- a/README.integration.md +++ b/README.integration.md @@ -162,6 +162,17 @@ Pierwsza analiza nadal korzysta z maksymalnie 4 s / 4 MiB. Gdy nie rozpozna obra Testy .NET 10 w Dockerze na CT100195 przeszły: późne video, puste początkowe okno, czytanie podczas pierwszej analizy, cztery żądania korzystające z jednego HTTP, odrzucenie niewłaściwego SID, limit rozmiaru, timeout i usunięcie próbek. Build Release 7.0.8: 0 błędów i 0 ostrzeżeń. Poprawka jest przygotowana; produkcyjna wtyczka pozostaje 7.0.7.0. Wdrożenie i test rzeczywistego klienta wymagają okna uzgodnionego z zadaniem `decoder.ek.lan`. -Nie jest to potwierdzona naprawa TV PULS HD na Android TV 0.19.10. Bieżące PlaybackInfo odrzucało próbkę przed uruchomieniem odtwarzacza. Kontrolowana próba 20 s potwierdziła SID15718, lecz video PID177 nadal miało 0×0, a oba audio brak rozpoznanych kanałów. Końcowy pomiar po reboot odbiornika: 54485/54526 pakietów PID177 z oznaczeniem scrambling oraz wszystkie 2403/2403 i 2405/2405 pakiety audio PID150/151; TEI0. Te pola nagłówków i brak rozpoznanego AV wymagają diagnozy odbiornika/CA, nie dowodzą samej przyczyny. Próbki były tymczasowe i zostały usunięte, własne źródła zamknięte, istniejące nagranie pozostało aktywne. Raporty Infra: `puls-startup-20261013.json`, `puls-identification-20261013.json`, `puls-oscam-correlation-20261013.json`. +Nie jest to potwierdzona naprawa TV PULS HD na Android TV 0.19.10. Bieżące PlaybackInfo odrzucało próbkę przed uruchomieniem odtwarzacza. Kontrolowana próba 20 s potwierdziła SID15718, lecz video PID177 nadal miało 0×0, a oba audio brak rozpoznanych kanałów. Końcowy pomiar po reboot odbiornika: 54485/54526 pakietów PID177 z oznaczeniem scrambling oraz wszystkie 2403/2403 i 2405/2405 pakiety audio PID150/151; TEI0. Te pola nagłówków i brak rozpoznanego AV wymagają diagnozy odbiornika/CA, nie dowodzą samej przyczyny. Próbki były tymczasowe i zostały usunięte, własne źródła zamknięte, istniejące nagranie pozostało aktywne. Raporty Infra: `../puls-startup-20261013.json`, `../puls-identification-20261013.json`, `../puls-oscam-correlation-20261013.json`. Zadanie `decoder.ek.lan` przejęło diagnostykę native/Unicable/OSCam i otrzymało wyniki. Kolejne własne odbiory są wstrzymane do uzgodnienia; nie zmieniano konfiguracji tunerów, profili, OSCam, wrapperów ani ustawień Jellyfina. + + +## Wdrożenie 7.0.8 i spójne limity startu — 13.10.2026, 09:05 CEST + +Produkcja CT100191 używa teraz **7.0.8.0**. Wcześniejsza informacja o kandydacie niewdrożonym powyżej opisuje poprzedni etap. Backend zaliczył 83 kontrole, build bez błędów i ostrzeżeń. Relay daje łącznie 20 s na nagłówki i pierwszy fragment TS (gotowość 21 s), a po rozpoczęciu nadal wykrywa 8 s ciszy. Cała próba rozpoznania pozostaje ograniczona do 25 s, więc późny start skraca pozostały czas analizy. Pierwsze odrzucenie próbki przez ffprobe uruchamia najwyżej jedną większą analizę na tym samym połączeniu; anulowania i innych błędów procesu nie ponawiamy. + +Moduł webowy wtyczki czeka do 45 s na start obrazu (25 s metadane + 20 s uruchomienie odtwarzacza). Przycisk powrotu i Escape pozostają dostępne. Test potwierdził oczekiwanie przy 35 s i powrót przy 45,001 s. Zaktualizowano wyłącznie istniejący `decoder-channel-playback.js`; w otwartych przeglądarkach wymagane Ctrl+F5. Natywny Android TV ma własny interfejs i własne limity klienta. + +Aktualny `deploy-plugin-only.py` wymaga `--maintenance-approved --dll PATH --expected-sha256 HASH`. Nie czyta bazy ani kluczy API. Po backupie sprawdza publiczny health, świeży log załadowania dokładnej wersji, manifest i hash DLL; w razie błędu przywraca poprzednią wtyczkę. Pierwsza próba zbyt wcześnie odrzuciła odpowiedź podczas rozruchu i została cofnięta na 7.0.7. Kontrola gotowości została poprawiona i przetestowana; ponowne wdrożenie przeszło. Backup DLL/manifestu i poprzedniego JS: `/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261013T070224088507Z`. + +Końcowo potwierdzono aktywną usługę 12.2.0, manifest Active7.0.8, log załadowania nowej DLL, hash serwowanego JS, HTTP200 strony dodatku oraz 0 strumieni, 0 FFmpeg i 0 tymczasowych próbek. Hashe ustawień serwera, wrapperów FFmpeg i plików index/config Web pozostały bez zmian. Raport: `deployment-7.0.8-20261013.json`. Po wdrożeniu nie wykonano jeszcze odtwarzania na zalogowanym kliencie: przeglądarka testowa wymaga SSO, a test Android TV czeka na użytkownika. Ostatnia wcześniejsza próba Pulsu o 08:42 CEST nadal nie rozpoznała AV po 20 s; zmiana limitów nie jest dowodem naprawienia odkodowania w odbiorniku. diff --git a/Tests/AdaptiveProbeTests.cs b/Tests/AdaptiveProbeTests.cs index deed9d4..c85f409 100644 --- a/Tests/AdaptiveProbeTests.cs +++ b/Tests/AdaptiveProbeTests.cs @@ -50,6 +50,38 @@ public static class AdaptiveProbeTests } check(Deleted(snapshots), "successful adaptive probe deletes capture and snapshots"); + snapshots.Clear(); attempts = 0; + var rejectedSampleStream = new ProbePacketStream(delay: 1); + using (var handler = new ProbeHttpHandler(rejectedSampleStream)) + using (var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }) + { + var probe = new LiveStreamProbe(client, (path, programme, extended, token) => + { + snapshots.Add(path); attempts++; + if (!extended) throw new LiveStreamProbe.ProbeProcessException(1); + return Task.FromResult(Metadata(Sid, 1920)); + }, limits, TimeSpan.FromSeconds(2)); + check((await probe.Get("http://relay/channel", CancellationToken.None, Sid))[0].Width == 1920 && attempts == 2 && handler.Calls == 1, + "a first ffprobe sample rejection retries analysis on the same HTTP capture"); + } + check(rejectedSampleStream.Disposed && Deleted(snapshots), "retried process rejection cleans up subscription and sample files"); + + snapshots.Clear(); attempts = 0; + var failedAnalysisStream = new ProbePacketStream(delay: 1); + using (var handler = new ProbeHttpHandler(failedAnalysisStream)) + using (var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }) + { + var probe = new LiveStreamProbe(client, (path, programme, extended, token) => + { + snapshots.Add(path); attempts++; + throw new InvalidOperationException("Analysis implementation failed."); + }, limits, TimeSpan.FromSeconds(2)); + var error = await Failure(probe.Get("http://relay/channel", CancellationToken.None, Sid)); + check(attempts == 1 && handler.Calls == 1 && error.Contains("cause=probe_analysis_failure"), + "an unrelated first analysis failure is not retried"); + } + check(failedAnalysisStream.Disposed && Deleted(snapshots), "unrelated analysis failure cleans up subscription and sample files"); + snapshots.Clear(); attempts = 0; var initiallyQuietStream = new ProbePacketStream(delay: 150); using (var handler = new ProbeHttpHandler(initiallyQuietStream)) @@ -125,7 +157,7 @@ public static class AdaptiveProbeTests finally { cancelledAnalysis = true; } }, limits, TimeSpan.FromMilliseconds(180)); var error = await Failure(probe.Get("http://relay/channel", CancellationToken.None, Sid)); - check(cancelledAnalysis && handler.Calls == 1 && analysingStream.Disposed && Deleted(snapshots) && error.Contains("cause=deadline"), "deadline cancels analysis, joins the reader and removes temporary files"); + check(cancelledAnalysis && handler.Calls == 1 && snapshots.Count == 1 && analysingStream.Disposed && Deleted(snapshots) && error.Contains("cause=deadline"), "deadline cancels analysis without retry, joins the reader and removes temporary files"); } } diff --git a/Tests/Program.cs b/Tests/Program.cs index cfc7622..058d894 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -107,6 +107,7 @@ 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"); await AdaptiveProbeTests.Run(Check); +await RelayStartupTests.Run(Check); var relayHandler=new RelayHandler(); using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100))) { diff --git a/Tests/RelayStartupTests.cs b/Tests/RelayStartupTests.cs new file mode 100644 index 0000000..0e18ec9 --- /dev/null +++ b/Tests/RelayStartupTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Enigma2; + +public static class RelayStartupTests +{ + public static async Task Run(Action check) + { + var delayed = new StartupPacketStream(TimeSpan.FromMilliseconds(120)); + using (var handler = new StartupHttpHandler(delayed, TimeSpan.FromMilliseconds(70))) + using (var relay = new LiveStreamRelay(new HttpClient(handler), 8096, TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(300), TimeSpan.FromMilliseconds(50))) + { + var id = relay.Register("http://receiver/delayed").Split('/')[^1]; + var subscriptions = await Task.WhenAll(relay.Subscribe(id, CancellationToken.None), relay.Subscribe(id, CancellationToken.None)); + await using var first = subscriptions[0]; + await using var second = subscriptions[1]; + var one = await first.Reader.ReadAsync(); + var two = await second.Reader.ReadAsync(); + check(handler.Calls == 1 && one[0] == 0x47 && two[0] == 0x47 && delayed.Delivered > 0, + "two subscribers share delayed startup beyond the steady-state read timeout"); + } + + var slowTotal = new StartupPacketStream(TimeSpan.FromMilliseconds(180)); + using (var handler = new StartupHttpHandler(slowTotal, TimeSpan.FromMilliseconds(180))) + using (var relay = new LiveStreamRelay(new HttpClient(handler), 8096, TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(300), TimeSpan.FromMilliseconds(50))) + { + var id = relay.Register("http://receiver/combined-delay").Split('/')[^1]; + var watch = Stopwatch.StartNew(); + await ExpectFailure(relay.Subscribe(id, CancellationToken.None)); + check(handler.Calls == 1 && handler.HeadersReturned && slowTotal.ReadCancelled && slowTotal.Delivered == 0 && slowTotal.Disposed && watch.Elapsed < TimeSpan.FromSeconds(2), + "HTTP headers and first packet use one cumulative startup deadline"); + for (var i = 0; i < 20 && !JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"); i++) await Task.Delay(5); + check(JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"), "startup timeout closes its source"); + } + + var noHeaders = new StartupPacketStream(TimeSpan.Zero); + using (var handler = new StartupHttpHandler(noHeaders, TimeSpan.FromMilliseconds(180))) + using (var relay = new LiveStreamRelay(new HttpClient(handler), 8096, TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(60), TimeSpan.FromMilliseconds(30))) + { + var id = relay.Register("http://receiver/silent-headers").Split('/')[^1]; + await ExpectFailure(relay.Subscribe(id, CancellationToken.None)); + check(handler.Cancelled && !handler.HeadersReturned && noHeaders.Delivered == 0, + "silent HTTP headers obey the same bounded startup deadline"); + } + + var stalled = new StartupPacketStream(TimeSpan.Zero, stallAfterFirst: true); + using (var handler = new StartupHttpHandler(stalled, TimeSpan.Zero)) + using (var relay = new LiveStreamRelay(new HttpClient(handler), 8096, TimeSpan.FromMilliseconds(10), TimeSpan.FromSeconds(2), TimeSpan.FromMilliseconds(50))) + { + var id = relay.Register("http://receiver/steady-stall").Split('/')[^1]; + await using var subscription = await relay.Subscribe(id, CancellationToken.None); + check((await subscription.Reader.ReadAsync()).Length > 0, "initial packet makes relay ready"); + var watch = Stopwatch.StartNew(); + await ExpectFailure(subscription.Reader.ReadAsync().AsTask()); + check(stalled.ReadCancelled && stalled.Disposed && stalled.Delivered == 1 && watch.Elapsed < TimeSpan.FromSeconds(1), + "a stall after startup retains the shorter steady-state timeout"); + } + + using (var client = new HttpClient(new StartupHttpHandler(new StartupPacketStream(TimeSpan.Zero), TimeSpan.Zero))) + { + var startupRejected = false; + var readRejected = false; + try { using var invalid = new LiveStreamRelay(client, 8096, startupTimeout: TimeSpan.Zero); } + catch (ArgumentOutOfRangeException) { startupRejected = true; } + try { using var invalid = new LiveStreamRelay(client, 8096, readTimeout: TimeSpan.FromMilliseconds(-1)); } + catch (ArgumentOutOfRangeException) { readRejected = true; } + check(startupRejected && readRejected, "relay rejects non-positive startup and read budgets"); + } + } + + private static async Task ExpectFailure(Task task) + { + try { await task.WaitAsync(TimeSpan.FromSeconds(2)); } + catch (IOException) { return; } + catch (OperationCanceledException) { return; } + throw new Exception("Expected bounded relay failure."); + } +} + +public sealed class StartupHttpHandler : HttpMessageHandler +{ + private readonly Stream stream; + private readonly TimeSpan headerDelay; + public int Calls; + public bool HeadersReturned; + public bool Cancelled; + public StartupHttpHandler(Stream stream, TimeSpan headerDelay) { this.stream = stream; this.headerDelay = headerDelay; } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken token) + { + Interlocked.Increment(ref Calls); + try { await Task.Delay(headerDelay, token); } + catch (OperationCanceledException) { Cancelled = true; throw; } + HeadersReturned = true; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) }; + } + protected override void Dispose(bool disposing) { if (disposing) stream.Dispose(); base.Dispose(disposing); } +} + +public sealed class StartupPacketStream : Stream +{ + private readonly TimeSpan firstDelay; + private readonly bool stallAfterFirst; + public int Delivered; + public bool ReadCancelled; + public bool Disposed; + public StartupPacketStream(TimeSpan firstDelay, bool stallAfterFirst = false) { this.firstDelay = firstDelay; this.stallAfterFirst = stallAfterFirst; } + public override async ValueTask ReadAsync(Memory buffer, CancellationToken token = default) + { + try { await Task.Delay(Delivered == 0 ? firstDelay : stallAfterFirst ? Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(10), token); } + catch (OperationCanceledException) { ReadCancelled = true; throw; } + var count = Math.Min(buffer.Length, 188 * 256); + count -= count % 188; + buffer.Span[..count].Clear(); + for (var i = 0; i < count; i += 188) buffer.Span[i] = 0x47; + Delivered++; + return count; + } + protected override void Dispose(bool disposing) { Disposed = true; base.Dispose(disposing); } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => 0; set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); +} diff --git a/deploy-plugin-only.py b/deploy-plugin-only.py index b1ae65f..6a4d646 100644 --- a/deploy-plugin-only.py +++ b/deploy-plugin-only.py @@ -1,62 +1,266 @@ -"""Install only the plugin DLL/manifest; --maintenance-approved permits the agreed restart.""" +"""Deploy only Enigma2 after an explicitly approved Jellyfin maintenance window.""" import argparse import datetime import hashlib import json +import os import pathlib +import re import shutil -import sqlite3 import subprocess import time import urllib.error import urllib.request +import uuid + +VERSION = '7.0.8.0' +ABI = '12.2.0.0' +GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a' +DLL = 'Jellyfin.Plugin.Enigma2.dll' +PLUGINS = pathlib.Path('/var/lib/jellyfin/plugins') +BACKUPS = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups') +LOGS = pathlib.Path('/var/log/jellyfin') +PROTECTED = tuple(map(pathlib.Path, ( + '/etc/default/jellyfin', '/etc/jellyfin/encoding.xml', + '/usr/local/lib/jellyfin-enigma/ffmpeg', '/usr/local/lib/jellyfin-enigma/ffprobe', + '/usr/share/jellyfin/web/config.json', '/usr/share/jellyfin/web/index.html', +))) + + +def sha256(path): + digest = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + -parser = argparse.ArgumentParser() -parser.add_argument('--maintenance-approved', action='store_true') -args = parser.parse_args() -key = sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro', uri=True).execute('SELECT AccessToken FROM ApiKeys LIMIT 1').fetchone()[0] -headers = {'Authorization': 'MediaBrowser Token="' + key + '", Client="PluginDeployment", Device="Server", DeviceId="plugin-deployment", Version="7"'} -sessions = json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Sessions', headers=headers))) -if any(s.get('NowPlayingItem') for s in sessions) and not args.maintenance_approved: - raise SystemExit('Active playback: maintenance approval required') -protected = ['/etc/default/jellyfin', '/etc/jellyfin/encoding.xml', '/usr/local/lib/jellyfin-enigma/ffmpeg', '/usr/local/lib/jellyfin-enigma/ffprobe', '/usr/share/jellyfin/web/config.json', '/usr/share/jellyfin/web/index.html'] def hashes(): - return {p: hashlib.sha256(pathlib.Path(p).read_bytes()).hexdigest() for p in protected if pathlib.Path(p).exists()} -before = hashes() -plugins = pathlib.Path('/var/lib/jellyfin/plugins') -old = [p for p in plugins.glob('Enigma2_*') if p.is_dir()] -backup = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups') / ('plugin-only-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')) -backup.mkdir(mode=0o700) -new = plugins / 'Enigma2_7.0.8.0' -metadata = json.loads((old[0] / 'meta.json').read_text()) -metadata.update(version='7.0.8.0', status='Active', targetAbi='12.2.0.0') -subprocess.run(['systemctl', 'stop', 'jellyfin'], check=True) -try: - for p in old: - shutil.move(str(p), str(backup / p.name)) - new.mkdir() - shutil.copy2('/tmp/Jellyfin.Plugin.Enigma2.dll', new / 'Jellyfin.Plugin.Enigma2.dll') - (new / 'meta.json').write_text(json.dumps(metadata)) - subprocess.run(['chown', '-R', 'jellyfin:jellyfin', str(new)], check=True) -except Exception: - if new.exists(): - shutil.rmtree(new) - for p in old: - if (backup / p.name).exists(): - shutil.move(str(backup / p.name), str(p)) - raise -finally: - subprocess.run(['systemctl', 'start', 'jellyfin'], check=True) -assert hashes() == before, 'Unexpected change outside plugin installation' -deadline = time.monotonic() + 45 -while True: + # Include missing paths so an unexpected new file is also detected. + return {str(p): sha256(p) if p.exists() else None for p in PROTECTED} + + +def read_manifest(directory): + value = json.loads((directory / 'meta.json').read_text(encoding='utf-8-sig')) + if not isinstance(value, dict): + raise ValueError('Plugin manifest must be an object') + return value + + +def validate_manifest(metadata, version=None): + if metadata.get('name') != 'Enigma2' or str(uuid.UUID(metadata.get('guid', ''))) != GUID: + raise ValueError('Unexpected plugin identity') + if metadata.get('targetAbi') != ABI: + raise ValueError('Unexpected plugin target ABI') + if not re.fullmatch(r'\d+\.\d+\.\d+\.\d+', str(metadata.get('version', ''))): + raise ValueError('Invalid plugin version') + if metadata.get('status') != 'Active': + raise ValueError('Existing plugin is not Active') + if version is not None and metadata['version'] != version: + raise ValueError('Installed manifest version mismatch') + + +def existing_plugin(plugins): + matches = [] + for directory in plugins.iterdir(): + if not directory.is_dir(): + continue + metadata = {} + if (directory / 'meta.json').is_file(): + try: + metadata = read_manifest(directory) + except (ValueError, OSError): + pass + if (directory.name.startswith('Enigma2_') or (directory / DLL).exists() + or metadata.get('guid') == GUID or metadata.get('name') == 'Enigma2'): + matches.append(directory) + if len(matches) != 1: + raise ValueError('Expected exactly one existing Enigma2 installation; resolve conflicts first') + old = matches[0] + if old.is_symlink() or any(p.is_symlink() for p in old.rglob('*')): + raise ValueError('Symlinks in the plugin installation are unsupported') + metadata = read_manifest(old) + validate_manifest(metadata) + if old.name != 'Enigma2_' + metadata['version'] or not (old / DLL).is_file(): + raise ValueError('Plugin directory, manifest and DLL are inconsistent') + return old, metadata + + +def command(arguments): + # Never print raw service logs, which can contain unrelated sensitive data. + return subprocess.run(arguments, check=True, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True).stdout + + +def service(action): + command(['systemctl', action, 'jellyfin']) + + +def log_boundary(directory): + offsets = {} + for path in directory.glob('jellyfin*.log'): + if path.is_file() and not path.is_symlink(): + stat = path.stat() + offsets[(stat.st_dev, stat.st_ino)] = stat.st_size + if not offsets: + raise RuntimeError('Cannot establish a fresh Jellyfin file-log boundary') + return offsets + + +def fresh_plugin_load(directory, boundary, version): + for path in directory.glob('jellyfin*.log'): + if not path.is_file() or path.is_symlink(): + continue + try: + with path.open('rb') as stream: + stat = os.fstat(stream.fileno()) + start = boundary.get((stat.st_dev, stat.st_ino), 0) + if start > stat.st_size: # Existing log was truncated at restart. + start = 0 + stream.seek(start) + remaining = stat.st_size - start + carry = '' + while remaining > 0: + chunk = stream.read(min(65536, remaining)) + if not chunk: + break + remaining -= len(chunk) + text = carry + chunk.decode('utf-8', errors='replace') + if loaded_version(text, version): + return True + carry = text[-256:] + except FileNotFoundError: # Rotation between listing and opening; retry next poll. + continue + return False + + +def loaded_version(log, version): + # Match only the loader's exact plugin name/version; do not return log content. + return re.search(r'Loaded plugin:\s*"?Enigma2"?\s+"?' + re.escape(version) + + r'"?(?=\s|$)', log, flags=re.MULTILINE) is not None + + +def wait_ready(directory, version, expected_hash, protected_before, boundary, timeout=60): + deadline = time.monotonic() + timeout + observed_version = '' + while time.monotonic() < deadline: + if hashes() != protected_before: + raise RuntimeError('Protected Jellyfin configuration, wrapper or web file changed') + validate_manifest(read_manifest(directory), version) + if sha256(directory / DLL) != expected_hash: + raise RuntimeError('Installed DLL checksum mismatch') + try: + with urllib.request.urlopen('http://127.0.0.1:8096/System/Info/Public', timeout=3) as response: + info = json.load(response) + actual_version = info.get('Version') if isinstance(info, dict) else None + # The public endpoint may return incomplete startup data before it is ready. + # Report only a short numeric version, never arbitrary response content. + if actual_version is None: + observed_version = '' + elif isinstance(actual_version, str) and re.fullmatch(r'[0-9]{1,5}(?:\.[0-9]{1,5}){1,3}', actual_version): + observed_version = actual_version + else: + observed_version = '' + if actual_version == '12.2.0' and fresh_plugin_load(LOGS, boundary, version): + return + except (urllib.error.URLError, TimeoutError, ConnectionError, json.JSONDecodeError): + pass + time.sleep(1) + raise RuntimeError('Public health and fresh plugin load confirmation did not pass; ' + + 'observedVersion=' + observed_version) + + +def activate(old, new, stage, backup, verify, verify_rollback, control=service): + """Keep the original plugin until the replacement passes; restore on any failure.""" + saved = backup / old.name + moved_old = False + installed = False 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.8.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.8.0', 'backup': str(backup), 'ready': True, 'server_configuration_and_wrappers_unchanged': True})) + control('stop') + old.rename(saved) + moved_old = True + stage.rename(new) + installed = True + control('start') + verify() + except BaseException as error: + rollback_errors = [] + # Even a failed start may leave a running process; stop before restoring files. + try: + control('stop') + except Exception: + rollback_errors.append('stop') + # Avoid modifying files while Jellyfin may still be running. + if not rollback_errors: + try: + if installed: + shutil.rmtree(new) + if moved_old: + saved.rename(old) + # Rename preserves original ownership; retain a content backup as well. + shutil.copytree(old, saved, copy_function=shutil.copy2) + except Exception: + rollback_errors.append('restore') + try: + control('start') + if not rollback_errors: + verify_rollback() + except Exception: + rollback_errors.append('start_or_verification') + if rollback_errors: + raise RuntimeError('Deployment failed; rollback requires attention: ' + + ', '.join(rollback_errors) + '; backup: ' + str(backup)) from error + raise RuntimeError('Deployment failed; previous plugin restored and started; backup: ' + + str(backup)) from error + finally: + if stage.exists(): + shutil.rmtree(stage) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--maintenance-approved', action='store_true', + help='Required confirmation of an already agreed Jellyfin restart window') + parser.add_argument('--dll', type=pathlib.Path, required=True) + parser.add_argument('--expected-sha256', required=True) + args = parser.parse_args(argv) + if not args.maintenance_approved: + parser.error('--maintenance-approved is required; this script cannot inspect playback sessions') + expected = args.expected_sha256.lower() + if not re.fullmatch(r'[0-9a-f]{64}', expected): + parser.error('--expected-sha256 must contain exactly 64 hexadecimal digits') + if not args.dll.is_file() or sha256(args.dll) != expected: + raise ValueError('Input DLL is missing or does not match the supplied checksum') + old, metadata = existing_plugin(PLUGINS) + new = PLUGINS / ('Enigma2_' + VERSION) + if new.exists() or new.is_symlink(): + raise ValueError('Target plugin directory already exists; refusing to replace it') + before = hashes() + old_hash = sha256(old / DLL) + boundary = log_boundary(LOGS) + stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') + backup = BACKUPS / ('plugin-only-' + stamp) + backup.mkdir(mode=0o700, parents=True, exist_ok=False) + stage = backup / 'candidate' + try: + shutil.copytree(old, stage, copy_function=shutil.copy2) + shutil.copy2(args.dll, stage / DLL) + updated = dict(metadata, version=VERSION, status='Active', targetAbi=ABI) + (stage / 'meta.json').write_text(json.dumps(updated, indent=2) + '\n', encoding='utf-8') + for path in [stage, *stage.rglob('*')]: + shutil.chown(path, user='jellyfin', group='jellyfin') + if sha256(stage / DLL) != expected or hashes() != before: + raise RuntimeError('Pre-restart checksum verification failed') + activate(old, new, stage, backup, + lambda: wait_ready(new, VERSION, expected, before, boundary), + lambda: wait_ready(old, metadata['version'], old_hash, before, boundary)) + finally: + if stage.exists(): + shutil.rmtree(stage) + print(json.dumps({'plugin': VERSION, 'backup': str(backup), 'ready': True, + 'sha256': expected, 'fresh_plugin_load_confirmed': True, + 'server_configuration_and_wrappers_unchanged': True})) + + +if __name__ == '__main__': + main() diff --git a/deployment-7.0.8-20261013.json b/deployment-7.0.8-20261013.json new file mode 100644 index 0000000..8dce74c --- /dev/null +++ b/deployment-7.0.8-20261013.json @@ -0,0 +1,48 @@ +{ + "checked_utc": "2026-10-13T07:05:33.860919+00:00", + "plugin_version": "7.0.8.0", + "status": "deployed", + "jellyfin_version": "12.2.0", + "host": "CT100191", + "backup": "/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261013T070224088507Z", + "dll_sha256": "86726e45fd13827c9b394e58c55a8e2a1bfda625bc08ca319a73fae81d5408d2", + "web_module_sha256": "12196ac4e3feeba490f695e69a0db99c11070091fae2986f870d2297b3cfe5ad", + "changes": { + "relay_startup_seconds": 20, + "relay_readiness_seconds": 21, + "relay_steady_read_seconds": 8, + "probe_total_deadline_seconds": 25, + "web_first_frame_deadline_seconds": 45, + "initial_ffprobe_process_rejection": "one extended retry on the same HTTP capture; cancellation and other errors are not retried" + }, + "validation": { + "dotnet_backend_checks_passed": 83, + "build_errors": 0, + "build_warnings": 0, + "web_regression": "PASS: pending at35s, recovery at45.001s; Escape/cancel retained", + "deployment_tests": "PASS: identity/hash validation, success, rollback, fresh logs, transient/missing version retry and persistent wrong version rejection", + "production_plugin_loaded": true, + "public_health": "12.2.0", + "plugin_page_http": 200, + "served_web_module_hash_verified": true, + "protected_server_configuration_and_wrappers_unchanged": true + }, + "cleanup": { + "temporary_probe_files": 0, + "ffmpeg_processes": 0, + "receiver_streams": 0 + }, + "deployment_incident": { + "initial_attempt_utc": "2026-10-13T06:57:17Z", + "initial_failure": "Readiness guard rejected a startup response immediately; exact transient response version was not recorded.", + "rollback": "7.0.7 confirmed Active and loaded at06:57:37.301UTC", + "correction": "Wait for exact12.2.0 plus fresh target-plugin log within60s; never accept another version", + "final_attempt": "successful; backup listed above" + }, + "limitations": [ + "No new receiver stream was opened in this deployment turn.", + "No authenticated browser session or Android TV playback was available for a post-deployment player test.", + "Latest prior Puls trial at06:42UTC failed selected AV identification after20s with scrambled packet flags. This deployment does not establish that receiver descrambling is fixed." + ], + "coordination": "decoder.ek.lan completed API work and released its window; informed that no own receiver sources started." +} diff --git a/test-web-bridge.cjs b/test-web-bridge.cjs index 05e08b0..5224dc5 100644 --- a/test-web-bridge.cjs +++ b/test-web-bridge.cjs @@ -76,7 +76,10 @@ const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); await flush(); assert.equal(stops, 1); assert.equal(returned, 1); assert.equal(elements.length, 0); click(); await flush(); assert.equal(starts, 2); pending[0](); await flush(); assert.equal(stops, 1, 'late old completion must not stop the new channel'); - clock = 25001; interval(); await flush(); + clock = 35000; interval(); await flush(); + assert.equal(stops, 1, 'pending startup must survive 35 seconds'); + assert.equal(returned, 1); assert.equal(elements.length, 1, 'cancel control remains available during slow startup'); + clock = 45001; interval(); await flush(); assert.equal(stops, 2); assert.equal(returned, 2); assert.equal(elements.length, 0); pending[1](); await flush(); console.log('PASS default channels, explicit programmes, one start, native OSD, Escape, late completion and startup timeout'); diff --git a/web/decoder-channel-playback.js b/web/decoder-channel-playback.js index 70b9937..c28bfae 100644 --- a/web/decoder-channel-playback.js +++ b/web/decoder-channel-playback.js @@ -59,7 +59,8 @@ 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, opening = false; const deadline = Date.now() + 25000; + // Allow the bounded metadata probe (25 s) plus HLS startup (20 s). + let opened = false, opening = false; const deadline = Date.now() + 45000; watcher = setInterval(() => { if (ticket !== generation) return; const video = document.querySelector('video');