Align decoder startup deadlines and deploy resilient 7.0.8
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
+250
-46
@@ -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)
|
|
||||||
try:
|
|
||||||
for p in old:
|
def validate_manifest(metadata, version=None):
|
||||||
shutil.move(str(p), str(backup / p.name))
|
if metadata.get('name') != 'Enigma2' or str(uuid.UUID(metadata.get('guid', ''))) != GUID:
|
||||||
new.mkdir()
|
raise ValueError('Unexpected plugin identity')
|
||||||
shutil.copy2('/tmp/Jellyfin.Plugin.Enigma2.dll', new / 'Jellyfin.Plugin.Enigma2.dll')
|
if metadata.get('targetAbi') != ABI:
|
||||||
(new / 'meta.json').write_text(json.dumps(metadata))
|
raise ValueError('Unexpected plugin target ABI')
|
||||||
subprocess.run(['chown', '-R', 'jellyfin:jellyfin', str(new)], check=True)
|
if not re.fullmatch(r'\d+\.\d+\.\d+\.\d+', str(metadata.get('version', ''))):
|
||||||
except Exception:
|
raise ValueError('Invalid plugin version')
|
||||||
if new.exists():
|
if metadata.get('status') != 'Active':
|
||||||
shutil.rmtree(new)
|
raise ValueError('Existing plugin is not Active')
|
||||||
for p in old:
|
if version is not None and metadata['version'] != version:
|
||||||
if (backup / p.name).exists():
|
raise ValueError('Installed manifest version mismatch')
|
||||||
shutil.move(str(backup / p.name), str(p))
|
|
||||||
raise
|
|
||||||
finally:
|
def existing_plugin(plugins):
|
||||||
subprocess.run(['systemctl', 'start', 'jellyfin'], check=True)
|
matches = []
|
||||||
assert hashes() == before, 'Unexpected change outside plugin installation'
|
for directory in plugins.iterdir():
|
||||||
deadline = time.monotonic() + 45
|
if not directory.is_dir():
|
||||||
while True:
|
continue
|
||||||
|
metadata = {}
|
||||||
|
if (directory / 'meta.json').is_file():
|
||||||
try:
|
try:
|
||||||
installed = json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Plugins', headers=headers), timeout=3))
|
metadata = read_manifest(directory)
|
||||||
if any(p['Name'] == 'Enigma2' and p['Version'] == '7.0.8.0' and p['Status'] == 'Active' for p in installed):
|
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
|
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()
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user