Align decoder startup deadlines and deploy resilient 7.0.8

This commit is contained in:
2026-10-13 09:07:29 +02:00
parent 0a834d863b
commit 0ab790d1e4
10 changed files with 527 additions and 66 deletions
+15 -2
View File
@@ -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<List<MediaStream>> Get(string url, CancellationToken caller, int? programme = null) public Task<List<MediaStream>> Get(string url, CancellationToken caller, int? programme = null)
{ {
if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture); if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture);
@@ -166,14 +172,21 @@ public sealed class LiveStreamProbe
if (await Snapshot(limits.InitialBytes)) if (await Snapshot(limits.InitialBytes))
{ {
attempts = 1; attempts = 1;
try
{
json = await fileProbe(samplePath, programme, false, token); json = await fileProbe(samplePath, programme, false, token);
analysisReturned = true; analysisReturned = true;
}
catch (ProbeProcessException) { cause = "initial_probe_process_failed"; }
if (analysisReturned)
{
try { Parse(json, programme); return json; } try { Parse(json, programme); return json; }
catch (Exception error) when (error is InvalidOperationException || error is JsonException) catch (Exception error) when (error is InvalidOperationException || error is JsonException)
{ {
cause = "initial_video_unavailable"; cause = "initial_video_unavailable";
} }
} }
}
await reader; await reader;
if (!await Snapshot(limits.MaximumBytes)) throw new InvalidOperationException("Empty probe sample."); if (!await Snapshot(limits.MaximumBytes)) throw new InvalidOperationException("Empty probe sample.");
@@ -198,7 +211,7 @@ public sealed class LiveStreamProbe
cause = error is OperationCanceledException ? "deadline" cause = error is OperationCanceledException ? "deadline"
: error is IOException ? "capture_io_failure" : error is IOException ? "capture_io_failure"
: error is HttpRequestException ? "capture_http_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 && !analysisReturned ? "probe_analysis_failure"
: attempts > 0 ? ProbeCause(json, programme) : capturedBytes == 0 ? "capture_empty" : "capture_unavailable"; : 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}."); 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 process.WaitForExitAsync(token);
await errors; 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; return await output;
} }
finally finally
+19 -6
View File
@@ -23,8 +23,17 @@ public sealed class LiveStreamRelay : IDisposable
private readonly object gate = new(); private readonly object gate = new();
private int opened; private int opened;
private readonly TimeSpan idleGrace; 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(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) public string Register(string url)
{ {
lock (gate) lock (gate)
@@ -96,7 +105,7 @@ public sealed class LiveStreamRelay : IDisposable
foreach (var chunk in history) subscription.Buffer.Writer.TryWrite(chunk); foreach (var chunk in history) subscription.Buffer.Writer.TryWrite(chunk);
initial = ready.Task; 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; } catch { await subscription.DisposeAsync(); throw; }
} }
public void Remove(Subscription subscription) public void Remove(Subscription subscription)
@@ -123,21 +132,25 @@ public sealed class LiveStreamRelay : IDisposable
Exception failure = null; Exception failure = null;
try 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); Interlocked.Increment(ref owner.opened);
using var response = await owner.client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, opening.Token); using var response = await owner.client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, opening.Token);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(token); 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) while (true)
{ {
using var reading = CancellationTokenSource.CreateLinkedTokenSource(token); reading.CancelAfter(TimeSpan.FromSeconds(8)); using var reading = CancellationTokenSource.CreateLinkedTokenSource(token);
var count = await stream.ReadAtLeastAsync(buffer.AsMemory(carry, buffer.Length - carry), 188 * 256 - carry, throwOnEndOfStream: false, cancellationToken: reading.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."); if (count == 0) throw new IOException("Decoder closed the live stream.");
count += carry; var bytes = count / 188 * 188; count += carry; var bytes = count / 188 * 188;
if (bytes > 0) if (bytes > 0)
{ {
if (buffer[0] != 0x47 || (bytes >= 376 && buffer[188] != 0x47)) throw new IOException("Decoder did not return MPEG-TS packets."); 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(); var chunk = buffer.AsSpan(0, bytes).ToArray();
lock (gate) lock (gate)
{ {
+12 -1
View File
@@ -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`. 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. 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.
+33 -1
View File
@@ -50,6 +50,38 @@ public static class AdaptiveProbeTests
} }
check(Deleted(snapshots), "successful adaptive probe deletes capture and snapshots"); 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; snapshots.Clear(); attempts = 0;
var initiallyQuietStream = new ProbePacketStream(delay: 150); var initiallyQuietStream = new ProbePacketStream(delay: 150);
using (var handler = new ProbeHttpHandler(initiallyQuietStream)) using (var handler = new ProbeHttpHandler(initiallyQuietStream))
@@ -125,7 +157,7 @@ public static class AdaptiveProbeTests
finally { cancelledAnalysis = true; } finally { cancelledAnalysis = true; }
}, limits, TimeSpan.FromMilliseconds(180)); }, limits, TimeSpan.FromMilliseconds(180));
var error = await Failure(probe.Get("http://relay/channel", CancellationToken.None, Sid)); 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");
} }
} }
+1
View File
@@ -107,6 +107,7 @@ Check(true,"empty source rejected before playback");
var liveVideo=LiveStreamProbe.ParseLiveMetadata(probes[0][0]); 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"); 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 AdaptiveProbeTests.Run(Check);
await RelayStartupTests.Run(Check);
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)))
{ {
+135
View File
@@ -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<bool, string> 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<HttpResponseMessage> 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<int> ReadAsync(Memory<byte> 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();
}
+249 -45
View File
@@ -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 argparse
import datetime import datetime
import hashlib import hashlib
import json import json
import os
import pathlib import pathlib
import re
import shutil import shutil
import sqlite3
import subprocess import subprocess
import time import time
import urllib.error import urllib.error
import urllib.request 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(): def hashes():
return {p: hashlib.sha256(pathlib.Path(p).read_bytes()).hexdigest() for p in protected if pathlib.Path(p).exists()} # Include missing paths so an unexpected new file is also detected.
before = hashes() return {str(p): sha256(p) if p.exists() else None for p in PROTECTED}
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')) def read_manifest(directory):
backup.mkdir(mode=0o700) value = json.loads((directory / 'meta.json').read_text(encoding='utf-8-sig'))
new = plugins / 'Enigma2_7.0.8.0' if not isinstance(value, dict):
metadata = json.loads((old[0] / 'meta.json').read_text()) raise ValueError('Plugin manifest must be an object')
metadata.update(version='7.0.8.0', status='Active', targetAbi='12.2.0.0') return value
subprocess.run(['systemctl', 'stop', 'jellyfin'], check=True)
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: try:
for p in old: metadata = read_manifest(directory)
shutil.move(str(p), str(backup / p.name)) except (ValueError, OSError):
new.mkdir() pass
shutil.copy2('/tmp/Jellyfin.Plugin.Enigma2.dll', new / 'Jellyfin.Plugin.Enigma2.dll') if (directory.name.startswith('Enigma2_') or (directory / DLL).exists()
(new / 'meta.json').write_text(json.dumps(metadata)) or metadata.get('guid') == GUID or metadata.get('name') == 'Enigma2'):
subprocess.run(['chown', '-R', 'jellyfin:jellyfin', str(new)], check=True) matches.append(directory)
except Exception: if len(matches) != 1:
if new.exists(): raise ValueError('Expected exactly one existing Enigma2 installation; resolve conflicts first')
shutil.rmtree(new) old = matches[0]
for p in old: if old.is_symlink() or any(p.is_symlink() for p in old.rglob('*')):
if (backup / p.name).exists(): raise ValueError('Symlinks in the plugin installation are unsupported')
shutil.move(str(backup / p.name), str(p)) metadata = read_manifest(old)
raise validate_manifest(metadata)
finally: if old.name != 'Enigma2_' + metadata['version'] or not (old / DLL).is_file():
subprocess.run(['systemctl', 'start', 'jellyfin'], check=True) raise ValueError('Plugin directory, manifest and DLL are inconsistent')
assert hashes() == before, 'Unexpected change outside plugin installation' return old, metadata
deadline = time.monotonic() + 45
while True:
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: try:
installed = json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Plugins', headers=headers), timeout=3)) with path.open('rb') as stream:
if any(p['Name'] == 'Enigma2' and p['Version'] == '7.0.8.0' and p['Status'] == 'Active' for p in installed): 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 break
except (urllib.error.URLError, TimeoutError): 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 = '<unavailable>'
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 = '<missing>'
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 = '<invalid>'
if actual_version == '12.2.0' and fresh_plugin_load(LOGS, boundary, version):
return
except (urllib.error.URLError, TimeoutError, ConnectionError, json.JSONDecodeError):
pass pass
if time.monotonic() >= deadline:
raise RuntimeError('Jellyfin did not load the deployed plugin within 45 seconds; backup: ' + str(backup))
time.sleep(1) time.sleep(1)
print(json.dumps({'plugin': '7.0.8.0', 'backup': str(backup), 'ready': True, 'server_configuration_and_wrappers_unchanged': True})) 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:
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()
+48
View File
@@ -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."
}
+4 -1
View File
@@ -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); 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);
pending[0](); await flush(); assert.equal(stops, 1, 'late old completion must not stop the new channel'); 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); assert.equal(stops, 2); assert.equal(returned, 2); assert.equal(elements.length, 0);
pending[1](); await flush(); pending[1](); await flush();
console.log('PASS default channels, explicit programmes, one start, native OSD, Escape, late completion and startup timeout'); console.log('PASS default channels, explicit programmes, one start, native OSD, Escape, late completion and startup timeout');
+2 -1
View File
@@ -59,7 +59,8 @@ 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, 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(() => { watcher = setInterval(() => {
if (ticket !== generation) return; if (ticket !== generation) return;
const video = document.querySelector('video'); const video = document.querySelector('video');