diff --git a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj
index 348f79c..68cd0a6 100644
--- a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj
+++ b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj
@@ -2,8 +2,8 @@
net10.0
- 7.0.7.0
- 7.0.7.0
+ 7.0.8.0
+ 7.0.8.0
diff --git a/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs b/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs
index 7c056b3..b37ad65 100644
--- a/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs
+++ b/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs
@@ -23,7 +23,21 @@ public sealed class LiveStreamProbe
private readonly TimeSpan timeout;
public string LastError { get; private set; }
public LiveStreamProbe(IMediaEncoder encoder) : this((url, token) => Run(encoder.ProbePath, url, token)) { }
- public LiveStreamProbe(Func> runner, TimeSpan? timeout = null) { this.runner = runner; this.timeout = timeout ?? TimeSpan.FromSeconds(15); }
+ public LiveStreamProbe(Func> runner, TimeSpan? timeout = null) { this.runner = runner; this.timeout = timeout ?? TimeSpan.FromSeconds(25); }
+ // The HTTP and file-analysis seams let tests exercise the actual capture, retry and cleanup.
+ public LiveStreamProbe(HttpClient client, Func> fileProbe, SamplingLimits limits = null, TimeSpan? timeout = null)
+ : this((url, token) => CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), timeout) { }
+
+ public sealed record SamplingLimits(TimeSpan InitialDuration, TimeSpan MaximumDuration, int InitialBytes, int MaximumBytes)
+ {
+ public static SamplingLimits Default { get; } = new(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(20), 4 * 1024 * 1024, 24 * 1024 * 1024);
+ internal void Validate()
+ {
+ if (InitialDuration <= TimeSpan.Zero || MaximumDuration < InitialDuration || MaximumDuration > TimeSpan.FromSeconds(20)
+ || InitialBytes < 1 || MaximumBytes < InitialBytes || MaximumBytes > 24 * 1024 * 1024)
+ throw new ArgumentOutOfRangeException(nameof(SamplingLimits));
+ }
+ }
public Task> Get(string url, CancellationToken caller, int? programme = null)
{
@@ -51,7 +65,7 @@ public sealed class LiveStreamProbe
try
{
await slots.WaitAsync(deadline.Token);
- try { completion.TrySetResult(Parse(await runner(url, deadline.Token))); LastError = null; success = true; }
+ try { completion.TrySetResult(Parse(await runner(url, deadline.Token), Programme(url))); LastError = null; success = true; }
finally { slots.Release(); }
}
catch (Exception error) { LastError = error.Message; completion.TrySetException(new InvalidOperationException("Decoder returned no usable live stream within the probe deadline.", error)); }
@@ -60,43 +74,185 @@ public sealed class LiveStreamProbe
private static async Task Run(string executable, string url, CancellationToken token)
{
- // ffprobe may wait for an EPG/data PID forever on live MPEG-TS. Give it a finite
- // sample from the shared relay, preserving the same PID/index ordering.
- var sample = Path.Combine(Path.GetTempPath(), "enigma-probe-" + Guid.NewGuid().ToString("N") + ".ts");
+ using var client = new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3) }) { Timeout = Timeout.InfiniteTimeSpan };
+ return await CaptureAndProbe(client, (sample, programme, extended, cancellation) => RunFile(executable, sample, programme, extended, cancellation), SamplingLimits.Default, url, token);
+ }
+
+ private static int? Programme(string url)
+ {
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return null;
+ return uri.Query.StartsWith("?programme=", StringComparison.Ordinal) && int.TryParse(uri.Query.Substring(11), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null;
+ }
+
+ private static async Task CaptureAndProbe(HttpClient client, Func> fileProbe, SamplingLimits limits, string url, CancellationToken token)
+ {
+ limits.Validate();
+ var capturePath = Path.Combine(Path.GetTempPath(), "enigma-probe-" + Guid.NewGuid().ToString("N") + ".capture.ts");
+ var samplePath = capturePath + ".sample.ts";
+ var programme = Programme(url);
+ var watch = Stopwatch.StartNew();
+ var sampleBytes = 0L;
+ var attempts = 0;
+ var analysisReturned = false;
+ var json = "{}";
+ var cause = "capture_unavailable";
+ var captureStop = "running";
+ var closing = false;
+ var capturedBytes = 0L;
+ Task reader = null;
+ using var capture = CancellationTokenSource.CreateLinkedTokenSource(token);
+ using var fileGate = new SemaphoreSlim(1);
try
{
- using var client = new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3) }) { Timeout = Timeout.InfiniteTimeSpan };
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
response.EnsureSuccessStatusCode();
using var input = await response.Content.ReadAsStreamAsync(token);
- await using (var file = new FileStream(sample, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65536, useAsync: true))
+ await using var file = new FileStream(capturePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, 65536, useAsync: true);
+ var initialBytes = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ capture.CancelAfter(limits.MaximumDuration);
+
+ async Task Read()
{
- using var capture = CancellationTokenSource.CreateLinkedTokenSource(token);
- capture.CancelAfter(TimeSpan.FromSeconds(4));
var buffer = new byte[188 * 256];
try
{
- while (file.Length < 4 * 1024 * 1024)
+ while (capturedBytes < limits.MaximumBytes)
{
- var count = await input.ReadAsync(buffer, capture.Token);
- if (count == 0) break;
- await file.WriteAsync(buffer.AsMemory(0, count), token);
+ var count = await input.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, limits.MaximumBytes - (int)capturedBytes)), capture.Token);
+ if (count == 0) { captureStop = "end_of_stream"; return; }
+ await fileGate.WaitAsync(token);
+ try { await file.WriteAsync(buffer.AsMemory(0, count), token); capturedBytes += count; }
+ finally { fileGate.Release(); }
+ if (capturedBytes >= limits.InitialBytes) initialBytes.TrySetResult();
+ }
+ captureStop = "byte_limit";
+ }
+ catch (OperationCanceledException) when (!token.IsCancellationRequested) { captureStop = closing ? "probe_finished" : "capture_time_limit"; }
+ finally { await input.DisposeAsync(); }
+ }
+
+ async Task Snapshot(long limit)
+ {
+ await fileGate.WaitAsync(token);
+ try
+ {
+ await file.FlushAsync(token);
+ sampleBytes = Math.Min(file.Length, limit);
+ if (sampleBytes == 0) return false;
+ await using var source = new FileStream(capturePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 65536, useAsync: true);
+ await using var destination = new FileStream(samplePath, FileMode.Create, FileAccess.Write, FileShare.None, 65536, useAsync: true);
+ var remaining = sampleBytes;
+ var buffer = new byte[65536];
+ while (remaining > 0)
+ {
+ var count = await source.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), token);
+ if (count == 0) throw new IOException("Probe snapshot was truncated.");
+ await destination.WriteAsync(buffer.AsMemory(0, count), token);
+ remaining -= count;
+ }
+ return true;
+ }
+ finally { fileGate.Release(); }
+ }
+
+ // Keep draining the same HTTP subscription while ffprobe reads a stable snapshot.
+ // Otherwise its bounded relay queue could overflow during the first analysis.
+ reader = Read();
+ try
+ {
+ await Task.WhenAny(initialBytes.Task, Task.Delay(limits.InitialDuration, token), reader);
+ token.ThrowIfCancellationRequested();
+ if (reader.IsFaulted) await reader;
+ if (await Snapshot(limits.InitialBytes))
+ {
+ attempts = 1;
+ json = await fileProbe(samplePath, programme, false, token);
+ analysisReturned = true;
+ try { Parse(json, programme); return json; }
+ catch (Exception error) when (error is InvalidOperationException || error is JsonException)
+ {
+ cause = "initial_video_unavailable";
}
}
- catch (OperationCanceledException) when (!token.IsCancellationRequested && file.Length > 0) { }
- if (file.Length == 0) throw new InvalidOperationException("Decoder returned an empty probe sample.");
+
+ await reader;
+ if (!await Snapshot(limits.MaximumBytes)) throw new InvalidOperationException("Empty probe sample.");
+ attempts++;
+ analysisReturned = false;
+ json = await fileProbe(samplePath, programme, true, token);
+ analysisReturned = true;
+ Parse(json, programme);
+ return json;
+ }
+ finally
+ {
+ closing = true;
+ capture.Cancel();
+ // Do not close the file/HTTP stream until the sole writer has stopped.
+ try { await reader; }
+ catch (OperationCanceledException) { }
}
- var query = new Uri(url).Query;
- var programme = query.StartsWith("?programme=", StringComparison.Ordinal) && int.TryParse(query.Substring(11), out var value) ? value : (int?)null;
- return await RunFile(executable, sample, programme, token);
}
- finally { if (File.Exists(sample)) File.Delete(sample); }
+ catch (Exception error)
+ {
+ cause = error is OperationCanceledException ? "deadline"
+ : error is IOException ? "capture_io_failure"
+ : error is HttpRequestException ? "capture_http_failure"
+ : error.Message.StartsWith("Decoder stream probe process failed", StringComparison.Ordinal) ? "probe_process_failed"
+ : attempts > 0 && !analysisReturned ? "probe_analysis_failure"
+ : attempts > 0 ? ProbeCause(json, programme) : capturedBytes == 0 ? "capture_empty" : "capture_unavailable";
+ throw new InvalidOperationException($"Decoder probe failed: cause={cause}; expectedProgramme={programme?.ToString(CultureInfo.InvariantCulture) ?? "none"}; {ProbeSummary(json)}; sampleBytes={sampleBytes}; capturedBytes={capturedBytes}; elapsedMs={watch.ElapsedMilliseconds}; attempts={attempts}; captureStop={captureStop}.");
+ }
+ finally
+ {
+ if (File.Exists(samplePath)) File.Delete(samplePath);
+ if (File.Exists(capturePath)) File.Delete(capturePath);
+ }
}
- private static async Task RunFile(string executable, string sample, int? programme, CancellationToken token)
+ private static string ProbeCause(string json, int? programme)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ var streams = document.RootElement.GetProperty("streams");
+ if (programme != null)
+ {
+ if (!document.RootElement.TryGetProperty("programs", out var programmes)) return "missing_expected_programme";
+ var selected = programmes.EnumerateArray().FirstOrDefault(p => p.TryGetProperty("program_id", out var id) && id.TryGetInt32(out var value) && value == programme);
+ if (selected.ValueKind == JsonValueKind.Undefined) return "missing_expected_programme";
+ if (!selected.TryGetProperty("streams", out streams)) return "missing_video";
+ }
+ if (!streams.EnumerateArray().Any(s => s.TryGetProperty("codec_type", out var t) && t.GetString() == "video")) return "missing_video";
+ return "video_parameters_unavailable";
+ }
+ catch (Exception) { return "invalid_probe_output"; }
+ }
+
+ private static string ProbeSummary(string json)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ var programmes = document.RootElement.TryGetProperty("programs", out var p) && p.ValueKind == JsonValueKind.Array
+ ? p.EnumerateArray().Where(v => v.TryGetProperty("program_id", out var id) && id.TryGetInt32(out _)).Select(v => v.GetProperty("program_id").GetInt32().ToString(CultureInfo.InvariantCulture)).Take(16) : Array.Empty();
+ var streams = document.RootElement.TryGetProperty("streams", out var s) && s.ValueKind == JsonValueKind.Array
+ ? s.EnumerateArray().Where(v => v.TryGetProperty("codec_type", out var t) && (t.GetString() == "video" || t.GetString() == "audio")).Take(8).Select(v =>
+ {
+ int Number(string key) => v.TryGetProperty(key, out var n) && n.TryGetInt32(out var value) ? value : 0;
+ var codec = v.TryGetProperty("codec_name", out var c) ? c.GetString() : null;
+ if (codec == null || !System.Text.RegularExpressions.Regex.IsMatch(codec, "^[a-z0-9_]{1,32}$")) codec = "unknown";
+ return $"{v.GetProperty("codec_type").GetString()}:{codec}:{Number("width")}x{Number("height")}";
+ }) : Array.Empty();
+ return $"programmes=[{string.Join(',', programmes)}]; AV=[{string.Join(',', streams)}]";
+ }
+ catch (Exception) { return "programmes=[]; AV=[]"; }
+ }
+
+ private static async Task RunFile(string executable, string sample, int? programme, bool extended, CancellationToken token)
{
using var process = new Process { StartInfo = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } };
- foreach (var argument in new[] { "-v", "error", "-f", "mpegts", "-analyzeduration", "3000000", "-probesize", "4194304", "-i", sample, "-show_streams", "-of", "json" }) process.StartInfo.ArgumentList.Add(argument);
+ foreach (var argument in new[] { "-v", "error", "-f", "mpegts", "-analyzeduration", extended ? "20000000" : "3000000", "-probesize", extended ? "25165824" : "4194304", "-i", sample, "-show_streams", "-show_programs", "-of", "json" }) process.StartInfo.ArgumentList.Add(argument);
if (programme != null) { process.StartInfo.ArgumentList.Add("-select_streams"); process.StartInfo.ArgumentList.Add("p:" + programme.Value.ToString(CultureInfo.InvariantCulture)); }
process.Start();
var output = process.StandardOutput.ReadToEndAsync();
@@ -104,8 +260,8 @@ public sealed class LiveStreamProbe
try
{
await process.WaitForExitAsync(token);
- var diagnostics = await errors;
- if (process.ExitCode != 0) throw new InvalidOperationException("Decoder stream probe failed: " + System.Text.RegularExpressions.Regex.Replace(diagnostics, @"https?://[^\s]+", ""));
+ await errors;
+ if (process.ExitCode != 0) throw new InvalidOperationException("Decoder stream probe process failed (exit code " + process.ExitCode.ToString(CultureInfo.InvariantCulture) + ").");
return await output;
}
finally
@@ -115,11 +271,20 @@ public sealed class LiveStreamProbe
}
}
- public static List Parse(string json)
+ public static List Parse(string json, int? programme = null)
{
using var document = JsonDocument.Parse(json);
var result = new List();
- foreach (var item in document.RootElement.GetProperty("streams").EnumerateArray())
+ var items = document.RootElement.GetProperty("streams");
+ if (programme != null)
+ {
+ if (!document.RootElement.TryGetProperty("programs", out var programmes) || programmes.ValueKind != JsonValueKind.Array)
+ throw new InvalidOperationException("Decoder stream has no identified programme.");
+ var selected = programmes.EnumerateArray().FirstOrDefault(p => p.TryGetProperty("program_id", out var id) && id.TryGetInt32(out var value) && value == programme);
+ if (selected.ValueKind == JsonValueKind.Undefined || !selected.TryGetProperty("streams", out items))
+ throw new InvalidOperationException("Decoder stream does not contain the requested programme.");
+ }
+ foreach (var item in items.EnumerateArray())
{
string Text(string field) => item.TryGetProperty(field, out var value) ? value.ToString() : null;
int? Number(string field) => int.TryParse(Text(field), out var value) ? value : null;
diff --git a/README.integration.md b/README.integration.md
index c28a413..474edab 100644
--- a/README.integration.md
+++ b/README.integration.md
@@ -155,3 +155,13 @@ Nową referencję należy uwzględnić przy następnej uzgodnionej aktualizacji
Zadanie odbiornika później skorygowało indeks cache PMT w TVN24, TVP1, TVP2 i Polsat Sport1: zapis w lamedb używa dziesiętnego indeksu w formacie `%02d%x`, więc cPMTPID16 oznacza prefiks `c:16…`, a nie wcześniejszy `c:10…`. Backup `/etc/enigma2/pmt-cache-correction-20261012T235336Z-e6ccd7aa`; według przekazania sprawdzono zero strumieni/nagrań, oficjalny reload i weryfikację, po czym wznowiono test. Nie kopiować błędnych tokenów ze starszych opisów. W tym zadaniu nie wykonywano zmian cache ani aktualizacji mapy Jellyfina.
Odczyt OSCam dla trzech incydentów CC/decode nie wykazał timeoutów ECM ani błędów czytników/sieci. Po uwzględnieniu przekazanej różnicy zegarów (serwer195 przed odbiornikiem około1,94 s, granice1,28–2,59 s) zatrzymanie dekodowania BBC wystąpiło po wcześniejszych CC/decode, a STOP TVP Sport jest spójny z zamknięciem workera po błędzie. Zdarzenia STOP nie dowodzą przyczyny CA. Dowody i korekta: `../oscam-soak-correlation-20261013.json`; szczegółowy packet timeline i wynik6h należą do zadania odbiornika. Nie zmieniano OSCam ani nie otwierano transmisji do tej analizy.
+
+## Przygotowana poprawka późnego startu — 7.0.8.0
+
+Pierwsza analiza nadal korzysta z maksymalnie 4 s / 4 MiB. Gdy nie rozpozna obrazu właściwego programu, jeden reader kontynuuje pobieranie z tej samej subskrypcji relay, także podczas pracy ffprobe. Druga i ostatnia analiza używa większego, stabilnego snapshotu. Limity: 20 s pobierania, 24 MiB materiału i 25 s na całą operację, wraz z otwieraniem i analizą. Wymiary obrazu oraz przynależność do oczekiwanego SID pozostają obowiązkowe. Błąd zawiera wyłącznie bezpieczne numery programów, parametry AV, rozmiar, czas i kategorię przyczyny; bez treści danych, tags i URL.
+
+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`.
+
+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.
diff --git a/Tests/AdaptiveProbeTests.cs b/Tests/AdaptiveProbeTests.cs
new file mode 100644
index 0000000..deed9d4
--- /dev/null
+++ b/Tests/AdaptiveProbeTests.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+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 AdaptiveProbeTests
+{
+ private const int Sid = 15718;
+ private const int PacketSize = 188;
+ private static string Metadata(int sid, int width)
+ {
+ var video = new { index = 0, codec_type = "video", codec_name = "h264", width, height = width == 0 ? 0 : 1080 };
+ return JsonSerializer.Serialize(new { streams = new[] { video }, programs = new[] { new { program_id = sid, streams = new[] { video } } }, tags = new { secret = "DO_NOT_EXPORT_SOURCE_TAGS" } });
+ }
+
+ public static async Task Run(Action check)
+ {
+ var limits = new LiveStreamProbe.SamplingLimits(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(300), PacketSize * 2, PacketSize * 24);
+ var snapshots = new List();
+ var attempts = 0;
+ var lateStream = new ProbePacketStream(gateAfter: 2, delay: 3);
+ using (var handler = new ProbeHttpHandler(lateStream))
+ using (var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan })
+ {
+ var probe = new LiveStreamProbe(client, async (path, programme, extended, token) =>
+ {
+ snapshots.Add(path); attempts++;
+ check(programme == Sid, "adaptive capture preserves expected programme");
+ if (!extended)
+ {
+ check(new FileInfo(path).Length == limits.InitialBytes, "first snapshot keeps the fast byte limit");
+ lateStream.Continue();
+ await lateStream.ReachedLateData.Task.WaitAsync(token);
+ check(lateStream.Packets >= 8, "reader continues during first metadata analysis");
+ return Metadata(Sid, 0);
+ }
+ var data = await File.ReadAllBytesAsync(path, token);
+ check(data.Length <= limits.MaximumBytes && data.Contains((byte)0x55), "second snapshot contains late video parameters within byte limit");
+ return Metadata(Sid, 1920);
+ }, limits, TimeSpan.FromSeconds(2));
+ var results = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ => probe.Get("http://relay/channel", CancellationToken.None, Sid)));
+ check(attempts == 2 && handler.Calls == 1 && results.All(x => x[0].Width == 1920), "late video succeeds with two analyses and one shared HTTP connection");
+ check(lateStream.Disposed, "successful adaptive probe closes its HTTP subscription");
+ }
+ check(Deleted(snapshots), "successful adaptive probe deletes capture and snapshots");
+
+ snapshots.Clear(); attempts = 0;
+ var initiallyQuietStream = new ProbePacketStream(delay: 150);
+ using (var handler = new ProbeHttpHandler(initiallyQuietStream))
+ using (var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan })
+ {
+ var probe = new LiveStreamProbe(client, (path, programme, extended, token) =>
+ {
+ snapshots.Add(path); attempts++;
+ check(extended && new FileInfo(path).Length > 0, "empty initial window waits for bounded extended capture");
+ return Task.FromResult(Metadata(Sid, 1920));
+ }, limits, TimeSpan.FromSeconds(2));
+ check((await probe.Get("http://relay/channel", CancellationToken.None, Sid))[0].Width == 1920 && attempts == 1 && handler.Calls == 1, "video arriving after an empty first window still succeeds");
+ }
+ check(initiallyQuietStream.Disposed && Deleted(snapshots), "initially quiet capture deletes all sample files");
+
+ snapshots.Clear(); attempts = 0;
+ var wrongStream = new ProbePacketStream(delay: 1);
+ using (var handler = new ProbeHttpHandler(wrongStream))
+ using (var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan })
+ {
+ var probe = new LiveStreamProbe(client, (path, programme, extended, token) =>
+ {
+ snapshots.Add(path); attempts++;
+ return Task.FromResult(Metadata(Sid + 1, 1920));
+ }, limits, TimeSpan.FromSeconds(2));
+ var error = await Failure(probe.Get("http://relay/channel", CancellationToken.None, Sid));
+ check(handler.Calls == 1 && attempts == 2 && error.Contains("cause=missing_expected_programme") && error.Contains("programmes=[15719]") && !error.Contains("DO_NOT_EXPORT_SOURCE_TAGS") && !error.Contains("http://"), "wrong programme remains rejected with safe diagnostic after retry");
+ }
+ check(wrongStream.Disposed && Deleted(snapshots), "wrong programme failure cleans up subscription and sample files");
+
+ snapshots.Clear(); attempts = 0;
+ var limitedStream = new ProbePacketStream(bulk: true);
+ var byteLimits = limits with { MaximumBytes = PacketSize * 8 };
+ using (var handler = new ProbeHttpHandler(limitedStream))
+ using (var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan })
+ {
+ var probe = new LiveStreamProbe(client, (path, programme, extended, token) =>
+ {
+ snapshots.Add(path); attempts++;
+ check(new FileInfo(path).Length <= (extended ? byteLimits.MaximumBytes : byteLimits.InitialBytes), "each adaptive snapshot has an exact byte ceiling");
+ return Task.FromResult(Metadata(Sid, 0));
+ }, byteLimits, TimeSpan.FromSeconds(2));
+ var error = await Failure(probe.Get("http://relay/channel", CancellationToken.None, Sid));
+ check(handler.Calls == 1 && attempts == 2 && limitedStream.BytesRead == byteLimits.MaximumBytes && error.Contains("capturedBytes=1504") && error.Contains("captureStop=byte_limit"), "continuous input stops exactly at maximum size and performs at most two analyses");
+ }
+ check(limitedStream.Disposed && Deleted(snapshots), "bounded size failure releases all capture resources");
+
+ snapshots.Clear(); attempts = 0;
+ var stalledStream = new ProbePacketStream(stalled: true);
+ var beforeSilentCapture = Directory.GetFiles(Path.GetTempPath(), "enigma-probe-*.capture.ts*").ToHashSet();
+ using (var handler = new ProbeHttpHandler(stalledStream))
+ using (var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan })
+ {
+ var probe = new LiveStreamProbe(client, (path, programme, extended, token) =>
+ {
+ attempts++; return Task.FromResult(Metadata(Sid, 1920));
+ }, limits, TimeSpan.FromMilliseconds(180));
+ var error = await Failure(probe.Get("http://relay/channel", CancellationToken.None, Sid));
+ check(handler.Calls == 1 && attempts == 0 && stalledStream.Disposed && error.Contains("cause=deadline")
+ && !Directory.GetFiles(Path.GetTempPath(), "enigma-probe-*.capture.ts*").Except(beforeSilentCapture).Any(), "silent source obeys total deadline, closes its sole HTTP connection and deletes empty capture");
+ }
+
+ snapshots.Clear();
+ var cancelledAnalysis = false;
+ var analysingStream = new ProbePacketStream(gateAfter: 2);
+ using (var handler = new ProbeHttpHandler(analysingStream))
+ using (var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan })
+ {
+ var probe = new LiveStreamProbe(client, async (path, programme, extended, token) =>
+ {
+ snapshots.Add(path);
+ try { await Task.Delay(Timeout.Infinite, token); return Metadata(Sid, 1920); }
+ finally { cancelledAnalysis = true; }
+ }, limits, TimeSpan.FromMilliseconds(180));
+ var error = await Failure(probe.Get("http://relay/channel", CancellationToken.None, Sid));
+ check(cancelledAnalysis && handler.Calls == 1 && analysingStream.Disposed && Deleted(snapshots) && error.Contains("cause=deadline"), "deadline cancels analysis, joins the reader and removes temporary files");
+ }
+ }
+
+ private static bool Deleted(IEnumerable paths) => paths.All(path => !File.Exists(path) && !File.Exists(path[..^10]));
+ private static async Task Failure(Task task)
+ {
+ try { await task; throw new Exception("Expected probe failure."); }
+ catch (InvalidOperationException error) { return error.InnerException?.Message ?? error.Message; }
+ }
+}
+
+public sealed class ProbeHttpHandler : HttpMessageHandler
+{
+ private readonly Stream stream;
+ public int Calls;
+ public ProbeHttpHandler(Stream stream) { this.stream = stream; }
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken token)
+ {
+ Interlocked.Increment(ref Calls);
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });
+ }
+}
+
+public sealed class ProbePacketStream : Stream
+{
+ private readonly int gateAfter;
+ private readonly int delay;
+ private readonly bool stalled;
+ private readonly bool bulk;
+ private readonly TaskCompletionSource continued = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource ReachedLateData { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public int Packets { get; private set; }
+ public long BytesRead { get; private set; }
+ public bool Disposed { get; private set; }
+ public ProbePacketStream(int gateAfter = 0, int delay = 0, bool stalled = false, bool bulk = false)
+ { this.gateAfter = gateAfter; this.delay = delay; this.stalled = stalled; this.bulk = bulk; }
+ public void Continue() => continued.TrySetResult();
+ public override async ValueTask ReadAsync(Memory buffer, CancellationToken token = default)
+ {
+ if (stalled) await Task.Delay(Timeout.Infinite, token);
+ if (gateAfter > 0 && Packets >= gateAfter) await continued.Task.WaitAsync(token);
+ if (delay > 0) await Task.Delay(delay, token);
+ token.ThrowIfCancellationRequested();
+ var count = Math.Min(buffer.Length, bulk ? 188 * 256 : 188);
+ buffer.Span[..count].Clear();
+ for (var i = 0; i < count; i += 188) { buffer.Span[i] = 0x47; if (i + 3 < count && Packets >= 7) buffer.Span[i + 3] = 0x55; Packets++; }
+ BytesRead += count;
+ if (Packets >= 8) ReachedLateData.TrySetResult();
+ return count;
+ }
+ protected override void Dispose(bool disposing) { Disposed = true; base.Dispose(disposing); }
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position { get => 0; set => throw new NotSupportedException(); }
+ public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ public override void Flush() { }
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+}
diff --git a/Tests/Program.cs b/Tests/Program.cs
index fd42008..cfc7622 100644
--- a/Tests/Program.cs
+++ b/Tests/Program.cs
@@ -106,6 +106,7 @@ try{LiveStreamProbe.Parse("{\"streams\":[]}");throw new Exception("invalid video
Check(true,"empty source rejected before playback");
var liveVideo=LiveStreamProbe.ParseLiveMetadata(probes[0][0]);
Check(liveVideo.Index==-1&&liveVideo.IsInterlaced&&liveVideo.AverageFrameRate==25&&probes[0][0].Index==0,"live automatic mapping preserves metadata without mutating cached probe");
+await AdaptiveProbeTests.Run(Check);
var relayHandler=new RelayHandler();
using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100)))
{
diff --git a/build.yaml b/build.yaml
index a86a473..1b2145c 100644
--- a/build.yaml
+++ b/build.yaml
@@ -8,13 +8,13 @@ guid: "193f29f9-ea6c-4595-a6f6-55e79d7c590a"
# The version of the plugin, starting at 1. Note that the AssemblyVersion
# and FileVersion flags in the `.csproj` file must have two additional
# fiels, e.g. 1.0.0 to be built correctly.
-version: "5.0.1.0" # Please increment with each pull request
+version: "7.0.8.0" # Please increment with each pull request
# The supported Jellyfin version, usually the earliest binary-compatible
# version. Based on the Jellyfin components from NuGet.
-targetAbi: "10.8.0.0" # The earliest binary-compatible version
+targetAbi: "12.2.0.0" # The earliest binary-compatible version
-framework: "net6.0"
+framework: "net10.0"
# The owner name of the plugin, "jellyfin" for official plugins. Change
# to your own name if you wish to remain a 3rd-party plugin.
@@ -41,4 +41,4 @@ artifacts:
# not need to be changed usually.
build_type: "dotnet"
dotnet_configuration: "Release"
-dotnet_framework: "net6.0"
+dotnet_framework: "net10.0"
diff --git a/deploy-plugin-only.py b/deploy-plugin-only.py
index 0e6a0ff..b1ae65f 100644
--- a/deploy-plugin-only.py
+++ b/deploy-plugin-only.py
@@ -27,9 +27,9 @@ plugins = pathlib.Path('/var/lib/jellyfin/plugins')
old = [p for p in plugins.glob('Enigma2_*') if p.is_dir()]
backup = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups') / ('plugin-only-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
backup.mkdir(mode=0o700)
-new = plugins / 'Enigma2_7.0.7.0'
+new = plugins / 'Enigma2_7.0.8.0'
metadata = json.loads((old[0] / 'meta.json').read_text())
-metadata.update(version='7.0.7.0', status='Active', targetAbi='12.2.0.0')
+metadata.update(version='7.0.8.0', status='Active', targetAbi='12.2.0.0')
subprocess.run(['systemctl', 'stop', 'jellyfin'], check=True)
try:
for p in old:
@@ -52,11 +52,11 @@ deadline = time.monotonic() + 45
while True:
try:
installed = json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Plugins', headers=headers), timeout=3))
- if any(p['Name'] == 'Enigma2' and p['Version'] == '7.0.7.0' and p['Status'] == 'Active' for p in installed):
+ if any(p['Name'] == 'Enigma2' and p['Version'] == '7.0.8.0' and p['Status'] == 'Active' for p in installed):
break
except (urllib.error.URLError, TimeoutError):
pass
if time.monotonic() >= deadline:
raise RuntimeError('Jellyfin did not load the deployed plugin within 45 seconds; backup: ' + str(backup))
time.sleep(1)
-print(json.dumps({'plugin': '7.0.7.0', 'backup': str(backup), 'ready': True, 'server_configuration_and_wrappers_unchanged': True}))
+print(json.dumps({'plugin': '7.0.8.0', 'backup': str(backup), 'ready': True, 'server_configuration_and_wrappers_unchanged': True}))
diff --git a/puls-identification-20261013.json b/puls-identification-20261013.json
new file mode 100644
index 0000000..789b420
--- /dev/null
+++ b/puls-identification-20261013.json
@@ -0,0 +1,180 @@
+{
+ "checked_utc": "2026-10-13T06:22:28Z",
+ "expected_sid": 15718,
+ "active_recordings_preflight": 1,
+ "scope": "One anonymous loopback plugin relay source; no credentials, zap, restart or configuration change. Temporary sample deleted.",
+ "snapshots": [
+ {
+ "target_seconds": 4,
+ "elapsed_seconds": 4.1,
+ "bytes": 1497796,
+ "probe_exit": 0,
+ "expected_programme_present": true,
+ "selected_streams": [
+ {
+ "id": "0xb1",
+ "codec_type": "video",
+ "codec_name": "h264",
+ "width": 0,
+ "height": 0,
+ "sample_rate": null,
+ "channels": null
+ },
+ {
+ "id": "0x96",
+ "codec_type": "audio",
+ "codec_name": "mp3",
+ "width": null,
+ "height": null,
+ "sample_rate": "0",
+ "channels": 0
+ },
+ {
+ "id": "0x97",
+ "codec_type": "audio",
+ "codec_name": "mp3",
+ "width": null,
+ "height": null,
+ "sample_rate": "0",
+ "channels": 0
+ },
+ {
+ "id": "0x206",
+ "codec_type": "subtitle",
+ "codec_name": "dvb_teletext",
+ "width": 492,
+ "height": 250,
+ "sample_rate": null,
+ "channels": null
+ }
+ ],
+ "identified_video": false,
+ "pid_counts_so_far": {
+ "177": {
+ "total": 6627,
+ "scrambled": 6625,
+ "tei": 0,
+ "clear_first_seconds": 2.443
+ },
+ "150": {
+ "total": 292,
+ "scrambled": 292,
+ "tei": 0,
+ "clear_first_seconds": null
+ },
+ "151": {
+ "total": 293,
+ "scrambled": 293,
+ "tei": 0,
+ "clear_first_seconds": null
+ }
+ }
+ },
+ {
+ "target_seconds": 20,
+ "elapsed_seconds": 20.062,
+ "bytes": 12320392,
+ "probe_exit": 0,
+ "expected_programme_present": true,
+ "selected_streams": [
+ {
+ "id": "0xb1",
+ "codec_type": "video",
+ "codec_name": "h264",
+ "width": 0,
+ "height": 0,
+ "sample_rate": null,
+ "channels": null
+ },
+ {
+ "id": "0x96",
+ "codec_type": "audio",
+ "codec_name": "mp3",
+ "width": null,
+ "height": null,
+ "sample_rate": "0",
+ "channels": 0
+ },
+ {
+ "id": "0x97",
+ "codec_type": "audio",
+ "codec_name": "mp3",
+ "width": null,
+ "height": null,
+ "sample_rate": "0",
+ "channels": 0
+ },
+ {
+ "id": "0x206",
+ "codec_type": "subtitle",
+ "codec_name": "dvb_teletext",
+ "width": 492,
+ "height": 250,
+ "sample_rate": null,
+ "channels": null
+ }
+ ],
+ "identified_video": false,
+ "pid_counts_so_far": {
+ "177": {
+ "total": 54526,
+ "scrambled": 54485,
+ "tei": 0,
+ "clear_first_seconds": 2.443
+ },
+ "150": {
+ "total": 2403,
+ "scrambled": 2403,
+ "tei": 0,
+ "clear_first_seconds": null
+ },
+ "151": {
+ "total": 2405,
+ "scrambled": 2405,
+ "tei": 0,
+ "clear_first_seconds": null
+ }
+ }
+ }
+ ],
+ "reader": {
+ "bytes": 12320392,
+ "first_data_seconds": 1.874,
+ "transport_error": null
+ },
+ "reader_alive": false,
+ "pid_counts": {
+ "177": {
+ "total": 54526,
+ "scrambled": 54485,
+ "tei": 0,
+ "clear_first_seconds": 2.443
+ },
+ "150": {
+ "total": 2403,
+ "scrambled": 2403,
+ "tei": 0,
+ "clear_first_seconds": null
+ },
+ "151": {
+ "total": 2405,
+ "scrambled": 2405,
+ "tei": 0,
+ "clear_first_seconds": null
+ }
+ },
+ "temporary_samples_removed": true,
+ "cleanup": {
+ "receiver_stream_count": 0,
+ "active_recordings": 1,
+ "relay_counters": "UNVERIFIED: credentials not read"
+ },
+ "finished_utc": "2026-10-13T06:23:06Z",
+ "interpretation": {
+ "video_pid_scrambling_percent": 99.925,
+ "audio_pids_scrambling_percent": 100,
+ "scope": "Scrambling flags in TS headers and unrecognized selected AV after 20 seconds. Actual cause and relationship to Enigma SIG11 are not established.",
+ "deployment": "Candidate plugin 7.0.8.0 is built and unit-tested, not deployed; production remains7.0.7.0.",
+ "coordination": "Receiver work belongs to decoder.ek.lan; no further sources until an agreed window."
+ }
+}
diff --git a/puls-oscam-correlation-20261013.json b/puls-oscam-correlation-20261013.json
new file mode 100644
index 0000000..6ee7b5a
--- /dev/null
+++ b/puls-oscam-correlation-20261013.json
@@ -0,0 +1,110 @@
+{
+ "checked_utc": "2026-10-13T06:10:58Z",
+ "scope": "Existing OSCam log metadata/classification only; no payload, account, reader name, endpoint or credentials exported. Missing matches are not proof of cause.",
+ "logs": [
+ {
+ "path": "/tmp/oscam-polsat-local.log",
+ "first_utc": "2026-10-13T05:00:57Z",
+ "last_utc": "2026-10-13T06:10:49Z",
+ "windows": [
+ {
+ "centre_utc": "2026-10-13T06:08:07Z",
+ "retention_covers_window": true,
+ "window_timestamp_lines": 84,
+ "sid3d66_lines": 0,
+ "sid3d66_categories": {
+ "found": 0,
+ "cache": 0,
+ "timeout": 0,
+ "not_found": 0,
+ "demux_start": 0,
+ "demux_stop": 0
+ },
+ "unknown_socket_command_lines_all_sids": 0
+ },
+ {
+ "centre_utc": "2026-10-13T05:53:53Z",
+ "retention_covers_window": true,
+ "window_timestamp_lines": 80,
+ "sid3d66_lines": 0,
+ "sid3d66_categories": {
+ "found": 0,
+ "cache": 0,
+ "timeout": 0,
+ "not_found": 0,
+ "demux_start": 0,
+ "demux_stop": 0
+ },
+ "unknown_socket_command_lines_all_sids": 0
+ },
+ {
+ "centre_utc": "2026-10-13T05:54:36Z",
+ "retention_covers_window": true,
+ "window_timestamp_lines": 48,
+ "sid3d66_lines": 0,
+ "sid3d66_categories": {
+ "found": 0,
+ "cache": 0,
+ "timeout": 0,
+ "not_found": 0,
+ "demux_start": 0,
+ "demux_stop": 0
+ },
+ "unknown_socket_command_lines_all_sids": 0
+ }
+ ]
+ },
+ {
+ "path": "/tmp/oscam-polsat-local.log-prev",
+ "first_utc": "2026-10-13T01:23:09Z",
+ "last_utc": "2026-10-13T05:00:45Z",
+ "windows": [
+ {
+ "centre_utc": "2026-10-13T06:08:07Z",
+ "retention_covers_window": false,
+ "window_timestamp_lines": 0,
+ "sid3d66_lines": 0,
+ "sid3d66_categories": {
+ "found": 0,
+ "cache": 0,
+ "timeout": 0,
+ "not_found": 0,
+ "demux_start": 0,
+ "demux_stop": 0
+ },
+ "unknown_socket_command_lines_all_sids": 0
+ },
+ {
+ "centre_utc": "2026-10-13T05:53:53Z",
+ "retention_covers_window": false,
+ "window_timestamp_lines": 0,
+ "sid3d66_lines": 0,
+ "sid3d66_categories": {
+ "found": 0,
+ "cache": 0,
+ "timeout": 0,
+ "not_found": 0,
+ "demux_start": 0,
+ "demux_stop": 0
+ },
+ "unknown_socket_command_lines_all_sids": 0
+ },
+ {
+ "centre_utc": "2026-10-13T05:54:36Z",
+ "retention_covers_window": false,
+ "window_timestamp_lines": 0,
+ "sid3d66_lines": 0,
+ "sid3d66_categories": {
+ "found": 0,
+ "cache": 0,
+ "timeout": 0,
+ "not_found": 0,
+ "demux_start": 0,
+ "demux_stop": 0
+ },
+ "unknown_socket_command_lines_all_sids": 0
+ }
+ ]
+ }
+ ]
+}
diff --git a/puls-startup-20261013.json b/puls-startup-20261013.json
new file mode 100644
index 0000000..1809ac7
--- /dev/null
+++ b/puls-startup-20261013.json
@@ -0,0 +1,268 @@
+{
+ "checked_utc": "2026-10-13T06:08:07Z",
+ "expected_sid": 15718,
+ "active_recordings_preflight": 1,
+ "source": "One existing plugin relay endpoint; one HTTP consumer; no zap, configuration write or restart.",
+ "snapshots": [
+ {
+ "requested_seconds": 4,
+ "observed_seconds": 4.062,
+ "bytes": 1884136,
+ "probe_exit": 0,
+ "program_ids": [
+ 15701,
+ 15702,
+ 15703,
+ 15704,
+ 15706,
+ 15710,
+ 15711,
+ 15718,
+ 15719,
+ 15721,
+ 15722,
+ 15723,
+ 15724,
+ 15733,
+ 15734,
+ 15726,
+ 15735,
+ 15736,
+ 15751,
+ 15753,
+ 15754,
+ 15790,
+ 15797
+ ],
+ "selected_streams": [
+ {
+ "index": 1,
+ "id": "0xb1",
+ "codec_type": "video",
+ "codec_name": "h264",
+ "width": 0,
+ "height": 0,
+ "sample_rate": null,
+ "channels": null
+ },
+ {
+ "index": 2,
+ "id": "0x96",
+ "codec_type": "audio",
+ "codec_name": "mp3",
+ "width": null,
+ "height": null,
+ "sample_rate": "0",
+ "channels": 0
+ },
+ {
+ "index": 3,
+ "id": "0x97",
+ "codec_type": "audio",
+ "codec_name": "mp3",
+ "width": null,
+ "height": null,
+ "sample_rate": "0",
+ "channels": 0
+ },
+ {
+ "index": 4,
+ "id": "0x206",
+ "codec_type": "subtitle",
+ "codec_name": "dvb_teletext",
+ "width": 492,
+ "height": 250,
+ "sample_rate": null,
+ "channels": null
+ }
+ ],
+ "usable_video": false,
+ "packet_counts": {
+ "count": 10022,
+ "tei": 0,
+ "scrambled": 9069
+ },
+ "stderr_bytes_only": 0
+ },
+ {
+ "requested_seconds": 10,
+ "observed_seconds": 10.103,
+ "bytes": 5749228,
+ "probe_exit": 0,
+ "program_ids": [
+ 15701,
+ 15702,
+ 15703,
+ 15704,
+ 15706,
+ 15710,
+ 15711,
+ 15718,
+ 15719,
+ 15721,
+ 15722,
+ 15723,
+ 15724,
+ 15733,
+ 15734,
+ 15726,
+ 15735,
+ 15736,
+ 15751,
+ 15753,
+ 15754,
+ 15790,
+ 15797
+ ],
+ "selected_streams": [
+ {
+ "index": 1,
+ "id": "0xb1",
+ "codec_type": "video",
+ "codec_name": "h264",
+ "width": 0,
+ "height": 0,
+ "sample_rate": null,
+ "channels": null
+ },
+ {
+ "index": 2,
+ "id": "0x96",
+ "codec_type": "audio",
+ "codec_name": "mp3",
+ "width": null,
+ "height": null,
+ "sample_rate": "0",
+ "channels": 0
+ },
+ {
+ "index": 3,
+ "id": "0x97",
+ "codec_type": "audio",
+ "codec_name": "mp3",
+ "width": null,
+ "height": null,
+ "sample_rate": "0",
+ "channels": 0
+ },
+ {
+ "index": 4,
+ "id": "0x206",
+ "codec_type": "subtitle",
+ "codec_name": "dvb_teletext",
+ "width": 492,
+ "height": 250,
+ "sample_rate": null,
+ "channels": null
+ }
+ ],
+ "usable_video": false,
+ "packet_counts": {
+ "count": 30581,
+ "tei": 0,
+ "scrambled": 27670
+ },
+ "stderr_bytes_only": 0
+ },
+ {
+ "requested_seconds": 20,
+ "observed_seconds": 20.069,
+ "bytes": 12658416,
+ "probe_exit": 0,
+ "program_ids": [
+ 15701,
+ 15702,
+ 15703,
+ 15704,
+ 15706,
+ 15710,
+ 15711,
+ 15718,
+ 15719,
+ 15721,
+ 15722,
+ 15723,
+ 15724,
+ 15733,
+ 15734,
+ 15726,
+ 15735,
+ 15736,
+ 15751,
+ 15753,
+ 15754,
+ 15790,
+ 15797
+ ],
+ "selected_streams": [
+ {
+ "index": 1,
+ "id": "0xb1",
+ "codec_type": "video",
+ "codec_name": "h264",
+ "width": 0,
+ "height": 0,
+ "sample_rate": null,
+ "channels": null
+ },
+ {
+ "index": 2,
+ "id": "0x96",
+ "codec_type": "audio",
+ "codec_name": "mp3",
+ "width": null,
+ "height": null,
+ "sample_rate": "0",
+ "channels": 0
+ },
+ {
+ "index": 3,
+ "id": "0x97",
+ "codec_type": "audio",
+ "codec_name": "mp3",
+ "width": null,
+ "height": null,
+ "sample_rate": "0",
+ "channels": 0
+ },
+ {
+ "index": 4,
+ "id": "0x206",
+ "codec_type": "subtitle",
+ "codec_name": "dvb_teletext",
+ "width": 492,
+ "height": 250,
+ "sample_rate": null,
+ "channels": null
+ }
+ ],
+ "usable_video": false,
+ "packet_counts": {
+ "count": 67332,
+ "tei": 0,
+ "scrambled": 60926
+ },
+ "stderr_bytes_only": 0
+ }
+ ],
+ "reader": {
+ "bytes": 12658416,
+ "first_data_seconds": 1.633,
+ "error_type": "AttributeError"
+ },
+ "own_reader_alive": false,
+ "temporary_samples_removed": true,
+ "cleanup": {
+ "active_sources": 0,
+ "consumers": 0,
+ "upstream_open_delta": 1,
+ "receiver_stream_count": 0
+ },
+ "finished_utc": "2026-10-13T06:08:46Z",
+ "interpretation": {
+ "identified_video_at_4_10_20_seconds": false,
+ "global_scrambling_limit": "These scrambling counts cover all sampled PIDs, not only the target programme. Use the later per-PID report for target attribution.",
+ "reader_error_limit": "The AttributeError was recorded during the concurrent response-close cleanup in this diagnostic reader. It is not evidence of a decoder transport failure.",
+ "analysis_limit": "Extended analysis in this first trial was limited to 12 seconds; the later identification trial uses 20 seconds."
+ }
+}
diff --git a/puls-ts-metadata-20261013.json b/puls-ts-metadata-20261013.json
new file mode 100644
index 0000000..d669b3a
--- /dev/null
+++ b/puls-ts-metadata-20261013.json
@@ -0,0 +1,20 @@
+{
+ "interval_utc": ["2026-10-13T06:18:08.674Z", "2026-10-13T06:18:10.893Z"],
+ "scope": "One anonymous plugin-relay subscription, no credentials, zap, restart or configuration write. Numeric TS metadata only; no retained media sample.",
+ "duration_seconds": 2.219,
+ "limitation": "The diagnostic reader's 0.5-second read timeout ended this sample early. This is not a completed ten-second trial or a decoded AV result.",
+ "transport_packets": 1798,
+ "sampled_bytes": 338024,
+ "invalid_crc": 0,
+ "sync_skips": 0,
+ "programme": {"sid": 15718, "tsid": 11400, "pmt_pid": 1018, "pcr_pid": 177, "video_pid": 177, "video_type": "H264", "audio_pids": [150, 151], "audio_type": "MPEG", "sdt_actual_onid": null},
+ "programme_caids_hex": ["0100", "0500", "06ED", "0B01", "1803", "1813", "1861", "186C", "1870", "1884"],
+ "pid_counts": {
+ "177": {"packets": 1496, "scrambled": 234, "tei": 0},
+ "150": {"packets": 66, "scrambled": 11, "tei": 0},
+ "151": {"packets": 65, "scrambled": 10, "tei": 0}
+ },
+ "cleanup": {"native_streams_before": 0, "native_streams_after": 0, "recordings_before": 1, "recordings_after": 1, "relay_counters": "UNVERIFIED: credentials not read"},
+ "interpretation": "Header scrambling counts from this short separate sample do not prove decoded video/audio and must not replace the subsequent full twenty-second identification result. ONID13E versus71 is not resolved because SDT actual was not observed.",
+ "approval_review": "An earlier helper command was rejected before execution because it read an API token from SQLite. The executed alternative eliminated credentials entirely and used only anonymous receiver metadata and the loopback relay."
+}
diff --git a/verify.py b/verify.py
index 34b2175..a4d8e32 100644
--- a/verify.py
+++ b/verify.py
@@ -9,7 +9,7 @@ def api(path,data=None,method=None,token=admin):
body=response.read();return json.loads(body) if body else None
report={'checked_utc':datetime.datetime.now(datetime.timezone.utc).isoformat()}
report['plugin']=next(x for x in api('/Plugins') if x['Name']=='Enigma2')
-assert report['plugin']['Version']=='7.0.7.0' and report['plugin']['Status']=='Active'
+assert report['plugin']['Version']=='7.0.8.0' and report['plugin']['Status']=='Active'
report['epg']=api('/DecoderIntegration/Status')
report['channel_count']=api('/LiveTv/Channels?Limit=0')['TotalRecordCount']
report['timer_count']=api('/LiveTv/Timers?Limit=0')['TotalRecordCount']