Author SHA1 Message Date
Luke Pulverenti a08bbd0f31 3.2.1 2019-08-12 14:04:56 -04:00
Luke Pulverenti f7a7fced43 update recordings on schedule 2019-05-09 13:14:09 -04:00
Luke Pulverenti 46ea3e4c21 switch to DateTimeOffset 2018-09-07 15:22:39 -04:00
105 changed files with 960 additions and 25536 deletions
-199
View File
@@ -1,199 +0,0 @@
# With more recent updates Visual Studio 2017 supports EditorConfig files out of the box
# Visual Studio Code needs an extension: https://github.com/editorconfig/editorconfig-vscode
# For emacs, vim, np++ and other editors, see here: https://github.com/editorconfig
###############################
# Core EditorConfig Options #
###############################
root = true
# All files
[*]
indent_style = space
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf
max_line_length = 9999
# YAML indentation
[*.{yml,yaml}]
indent_size = 2
# XML indentation
[*.{csproj,xml}]
indent_size = 2
###############################
# .NET Coding Conventions #
###############################
[*.{cs,vb}]
# Organize usings
dotnet_sort_system_directives_first = true
# this. preferences
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_property = false:silent
dotnet_style_qualification_for_method = false:silent
dotnet_style_qualification_for_event = false:silent
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_predefined_type_for_member_access = true:silent
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
dotnet_style_readonly_field = true:suggestion
# Expression-level preferences
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
###############################
# Naming Conventions #
###############################
# Style Definitions (From Roslyn)
# Non-private static fields are PascalCase
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style
dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field
dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
dotnet_naming_symbols.non_private_static_fields.required_modifiers = static
dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case
# Constants are PascalCase
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style
dotnet_naming_symbols.constants.applicable_kinds = field, local
dotnet_naming_symbols.constants.required_modifiers = const
dotnet_naming_style.constant_style.capitalization = pascal_case
# Static fields are camelCase and start with s_
dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion
dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields
dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style
dotnet_naming_symbols.static_fields.applicable_kinds = field
dotnet_naming_symbols.static_fields.required_modifiers = static
dotnet_naming_style.static_field_style.capitalization = camel_case
dotnet_naming_style.static_field_style.required_prefix = _
# Instance fields are camelCase and start with _
dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion
dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields
dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style
dotnet_naming_symbols.instance_fields.applicable_kinds = field
dotnet_naming_style.instance_field_style.capitalization = camel_case
dotnet_naming_style.instance_field_style.required_prefix = _
# Locals and parameters are camelCase
dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion
dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters
dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style
dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local
dotnet_naming_style.camel_case_style.capitalization = camel_case
# Local functions are PascalCase
dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions
dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
dotnet_naming_style.local_function_style.capitalization = pascal_case
# By default, name items with PascalCase
dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members
dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style
dotnet_naming_symbols.all_members.applicable_kinds = *
dotnet_naming_style.pascal_case_style.capitalization = pascal_case
###############################
# C# Coding Conventions #
###############################
[*.cs]
# var preferences
csharp_style_var_for_built_in_types = true:silent
csharp_style_var_when_type_is_apparent = true:silent
csharp_style_var_elsewhere = true:silent
# Expression-bodied members
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_accessors = true:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
# Null-checking preferences
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences
csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion
# Expression-level preferences
csharp_prefer_braces = true:silent
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_pattern_local_over_anonymous_function = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
###############################
# C# Formatting Rules #
###############################
# New line preferences
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
csharp_indent_labels = flush_left
# Space preferences
csharp_space_after_cast = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_around_binary_operators = before_and_after
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
# Wrapping preferences
csharp_preserve_single_line_statements = true
csharp_preserve_single_line_blocks = true
-2
View File
@@ -106,5 +106,3 @@ Generated_Code #added for RIA/Silverlight projects
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
.vs
-41
View File
@@ -1,41 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj"
],
"problemMatcher": "$msCompile"
}
]
}
@@ -1,108 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Declare silent audio and empty DVB subtitle tracks on status clips with stable transport PIDs.</summary>
public static class BroadcastSlateProgram
{
public static byte[] Apply(byte[] clip, BroadcastTracks tracks, bool clearSubtitles = true)
{
if (clip.Length % 188 != 0) throw new InvalidDataException("Unaligned status clip");
var section = Section(tracks);
using var output = new MemoryStream(clip.Length + 188 * 8);
var cc = 0; var written = false; var cleared = false;
for (var start = 0; start < clip.Length; start += 188)
{
var pid = ((clip[start + 1] & 31) << 8) | clip[start + 2];
if (pid != 4096)
{
output.Write(clip, start, 188);
if (clearSubtitles && !cleared && pid == 256 && (clip[start + 1] & 64) != 0)
{
var p = start + 4 + ((clip[start + 3] & 32) != 0 ? clip[start + 4] + 1 : 0);
if (p + 14 <= start + 188 && clip[p] == 0 && clip[p + 1] == 0 && clip[p + 2] == 1 && (clip[p + 7] & 128) != 0)
{
long pts = ((long)(clip[p + 9] & 14) << 29) | ((long)clip[p + 10] << 22) | ((long)(clip[p + 11] & 254) << 14) | ((long)clip[p + 12] << 7) | ((long)clip[p + 13] >> 1);
for (var i = 0; i < tracks.Output.Length; i++)
if (tracks.Output[i].Codec == "DVBSUB") output.Write(ClearSubtitle(tracks.Input[i], 256 + i, pts,
tracks.Input[0].Width ?? ContinuityStreams.Width, tracks.Input[0].Height ?? ContinuityStreams.Height));
cleared = true;
}
}
continue;
}
if ((clip[start + 1] & 64) == 0) continue;
written = true;
var offset = 0;
while (offset < section.Length)
{
var packet = new byte[188]; Array.Fill(packet, (byte)255);
packet[0] = 71; packet[1] = (byte)(16 | (offset == 0 ? 64 : 0)); packet[2] = 0; packet[3] = (byte)(16 | (cc++ & 15));
var p = 4; if (offset == 0) packet[p++] = 0;
var count = Math.Min(188 - p, section.Length - offset); section.AsSpan(offset, count).CopyTo(packet.AsSpan(p));
offset += count; output.Write(packet);
}
}
if (!written) throw new InvalidDataException("Status clip has no expected program map");
return output.ToArray();
}
private static byte[] ClearSubtitle(MediaStream stream, int pid, long pts, int width, int height)
{
var extra = (stream as BroadcastMediaStream)?.SubtitleExtraData;
var page = extra is { Length: >= 2 } ? (extra[0] << 8) | extra[1] : 1;
var payload = new byte[] { 32, 0,
15, 20, (byte)(page >> 8), (byte)page, 0, 5, 0, (byte)((width - 1) >> 8), (byte)(width - 1), (byte)((height - 1) >> 8), (byte)(height - 1),
15, 16, (byte)(page >> 8), (byte)page, 0, 2, 1, 3,
15, 128, (byte)(page >> 8), (byte)page, 0, 0, 255 };
var pes = new List<byte> { 0, 0, 1, 189, 0, (byte)(payload.Length + 8), 128, 128, 5,
(byte)(33L | ((pts >> 29) & 14)), (byte)(pts >> 22), (byte)(1L | ((pts >> 14) & 254)), (byte)(pts >> 7), (byte)(1L | (pts << 1)) };
pes.AddRange(payload);
var packet = Enumerable.Repeat((byte)255, 188).ToArray();
packet[0] = 71; packet[1] = (byte)(64 | (pid >> 8)); packet[2] = (byte)pid; packet[3] = 48;
packet[4] = (byte)(183 - pes.Count); packet[5] = 0; pes.CopyTo(packet, 188 - pes.Count);
return packet;
}
public static byte[] Section(BroadcastTracks tracks)
{
var data = new List<byte> { 2, 176, 0, 0, 1, 193, 0, 0, 225, 0, 240, 0 };
for (var i = 0; i < tracks.Output.Length; i++)
{
var s = tracks.Output[i]; var descriptors = new List<byte>();
var language = (s.Language ?? "und").Split(',')[0];
if (language.Length != 3 || language.Any(c => c < 'a' || c > 'z')) language = "und";
var lang = Encoding.ASCII.GetBytes(language);
var type = s.Type == MediaStreamType.Video ? 27 : s.Type == MediaStreamType.Audio ? 15 : 6;
if (s.Type == MediaStreamType.Audio) { descriptors.AddRange(new byte[] { 10, 4 }); descriptors.AddRange(lang); descriptors.Add((byte)(s.IsHearingImpaired ? 2 : 0)); }
if (s.Type == MediaStreamType.Subtitle)
{
var extra = (tracks.Input[i] as BroadcastMediaStream)?.SubtitleExtraData ?? Array.Empty<byte>();
if (s.Codec == "DVBSUB")
{
descriptors.AddRange(new byte[] { 89, 8 }); descriptors.AddRange(lang); descriptors.Add((byte)(s.IsHearingImpaired ? 32 : 16));
// ffprobe DVB extradata stores composition/ancillary page IDs in big endian.
descriptors.AddRange(extra.Length >= 4 ? extra.Take(4) : new byte[] { 0, 1, 0, 1 });
}
else if (s.Codec == "DVBTXT")
{
descriptors.AddRange(new byte[] { 86, 5 }); descriptors.AddRange(lang);
descriptors.Add(extra.Length >= 2 ? extra[0] : (byte)17); descriptors.Add(extra.Length >= 2 ? extra[1] : (byte)0);
}
}
var pid = 256 + i;
data.Add((byte)type); data.Add((byte)(224 | (pid >> 8))); data.Add((byte)pid);
data.Add((byte)(240 | (descriptors.Count >> 8))); data.Add((byte)descriptors.Count); data.AddRange(descriptors);
}
var length = data.Count - 3 + 4;
if (length > 1021) throw new InvalidDataException("Broadcast program map exceeds MPEG-TS limit");
data[1] = (byte)(176 | (length >> 8)); data[2] = (byte)length;
uint crc = 0xffffffff;
foreach (var value in data) { crc ^= (uint)value << 24; for (var bit = 0; bit < 8; bit++) crc = (crc & 0x80000000) != 0 ? (crc << 1) ^ 0x04c11db7 : crc << 1; }
data.Add((byte)(crc >> 24)); data.Add((byte)(crc >> 16)); data.Add((byte)(crc >> 8)); data.Add((byte)crc);
return data.ToArray();
}
}
-103
View File
@@ -1,103 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.Enigma2;
// Probe-only transport details; the public Jellyfin contract remains MediaStream.
public sealed class BroadcastMediaStream : MediaStream
{
public string SampleAspectRatio { get; set; }
public string FrameRateRational { get; set; }
public int? TransportId { get; set; }
public bool IsVisualImpaired { get; set; }
public byte[] SubtitleExtraData { get; set; }
}
/// <summary>A stable, explicitly mapped program shared by native negotiation and FFmpeg.</summary>
public sealed class BroadcastTracks
{
public MediaStream[] Input { get; }
public MediaStream[] Output { get; }
public bool Normalize { get; }
public string Key { get; }
public int AudioCount => Input.Count(s => s.Type == MediaStreamType.Audio);
public bool HasSubtitles => Input.Any(s => s.Type == MediaStreamType.Subtitle);
public BroadcastTracks(IEnumerable<MediaStream> streams, bool normalize = false, bool preserveResolution = true)
{
Normalize = false; // Legacy argument retained for compatibility; channel AV is never normalized.
var all = streams.ToArray();
Input = all.Where(s => s.Type == MediaStreamType.Video).Take(1)
.Concat(all.Where(s => s.Type == MediaStreamType.Audio))
.Concat(all.Where(s => s.Type == MediaStreamType.Subtitle)).ToArray();
if (Input.Length == 0 || Input[0].Type != MediaStreamType.Video || Input.Length > 48)
throw new ArgumentException("Invalid broadcast program layout");
if (Input.Any(s => s.Type == MediaStreamType.Subtitle && s.Codec is not ("DVBSUB" or "DVBTXT")))
throw new NotSupportedException("Unsupported broadcast subtitle codec; tracks cannot be silently discarded");
Output = Input.Select((s, i) => {
var copy = LiveStreamProbe.ParseLiveMetadata(s); copy.Index = i;
if (s is BroadcastMediaStream { IsVisualImpaired: true } && string.IsNullOrWhiteSpace(copy.Title)) copy.Title = "Audio description";
return copy;
}).ToArray();
// Stable per-channel track cache; no URLs, credentials or user identities in its key.
Key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(Input.Select(s => new {
s.Type, s.Index, s.Codec, s.Language, s.Title, s.IsDefault, s.IsForced, s.IsHearingImpaired, s.Channels, s.Width, s.Height,
s.Profile, s.PixelFormat, s.AverageFrameRate, s.RealFrameRate, s.IsInterlaced, s.ColorRange, s.ColorSpace, s.ColorTransfer, s.ColorPrimaries, s.SampleRate, s.ChannelLayout,
Sar = (s as BroadcastMediaStream)?.SampleAspectRatio, Rate = (s as BroadcastMediaStream)?.FrameRateRational,
Pid = (s as BroadcastMediaStream)?.TransportId, Extra = (s as BroadcastMediaStream)?.SubtitleExtraData
}))))).ToLowerInvariant();
}
public IEnumerable<string> MapArguments()
{
for (var i = 0; i < Input.Length; i++)
{
yield return "-map";
yield return Input[i] is BroadcastMediaStream { TransportId: { } pid }
? "0:i:" + pid.ToString(CultureInfo.InvariantCulture) : Input[i].Index >= 0 ? "0:" + Input[i].Index.ToString(CultureInfo.InvariantCulture)
: "0:" + (Input[i].Type == MediaStreamType.Video ? "v" : Input[i].Type == MediaStreamType.Audio ? "a" : "s") + ":" + Input.Take(i).Count(s => s.Type == Input[i].Type);
yield return "-streamid"; yield return i.ToString(CultureInfo.InvariantCulture) + ":" + (256 + i).ToString(CultureInfo.InvariantCulture);
}
}
public IEnumerable<string> MetadataArguments(bool audioVideoOnly = false)
{
for (var i = 0; i < Output.Length; i++)
{
var s = Output[i];
if (audioVideoOnly && s.Type == MediaStreamType.Subtitle) continue;
if (!string.IsNullOrWhiteSpace(s.Language)) { yield return "-metadata:s:" + i; yield return "language=" + s.Language; }
if (!string.IsNullOrWhiteSpace(s.Title)) { yield return "-metadata:s:" + i; yield return "title=" + s.Title; }
yield return "-disposition:" + i;
var flags = new List<string>(); if (s.IsDefault) flags.Add("default"); if (s.IsForced) flags.Add("forced");
if (s.IsHearingImpaired) flags.Add("hearing_impaired");
yield return flags.Count == 0 ? "0" : string.Join('+', flags);
}
}
public static string JellyfinCodec(string codec) => codec switch { "dvb_subtitle" => "DVBSUB", "dvb_teletext" => "DVBTXT", "hdmv_pgs_subtitle" => "PGSSUB", "dvd_subtitle" => "DVDSUB", "subrip" => "srt", "ass" => "ssa", _ => codec };
public static int? TransportId(string value)
{
if (value != null && value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(value.AsSpan(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hex) && hex < 8191 && hex >= 0) return hex;
return int.TryParse(value, out var dec) && dec >= 0 && dec < 8191 ? dec : null;
}
public static byte[] ExtraData(string value)
{
if (value == null || value.Length > 8192) return Array.Empty<byte>();
var hex = new StringBuilder();
foreach (var line in value.Split('\n'))
{
var match = Regex.Match(line, @"^[0-9a-fA-F]{8}: ([0-9a-fA-F ]+?)(?: |$)");
if (match.Success) hex.Append(match.Groups[1].Value.Replace(" ", ""));
}
return hex.Length % 2 == 0 ? Convert.FromHexString(hex.ToString()) : Array.Empty<byte>();
}
public static BroadcastTracks Fallback(bool preserveResolution = true) => new(new[] {
new MediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264" },
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", IsDefault = true }
}, true, preserveResolution);
}
@@ -1,42 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Controller.LiveTv;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Keep saved native channels playable after a receiver bouquet migration.</summary>
public static class ChannelReferenceResolver
{
public static string Resolve(string reference, IEnumerable<ChannelInfo> channels, Func<string> previousName)
{
var current = channels.Where(c => !string.IsNullOrWhiteSpace(c.Id)).ToArray();
var exact = current.FirstOrDefault(c => string.Equals(c.Id, reference, StringComparison.OrdinalIgnoreCase));
if (exact != null) return exact.Id;
// A changed Enigma service type does not change the DVB service identity.
var identity = Identity(reference);
var matches = current.Where(c => identity != null && Identity(c.Id) == identity)
.Select(c => c.Id).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (matches.Length == 1) return matches[0];
if (matches.Length > 1) return reference;
// A moved service has a new SID/TSID. Use only a unique exact historical
// name; never guess between HD/SD, regional or alternative services.
var name = previousName()?.Trim();
if (string.IsNullOrEmpty(name)) return reference;
matches = current.Where(c => string.Equals(c.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase))
.Select(c => c.Id).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
return matches.Length == 1 ? matches[0] : reference;
}
private static string Identity(string reference)
{
var fields = reference?.TrimEnd(':').Split(':');
if (fields == null || fields.Length != 10 || fields[0] != "1" || fields[1] != "0") return null;
// No URLs, labels or non-DVB references participate in this fallback.
if (fields.Any(f => f.Length == 0 || !f.All(Uri.IsHexDigit))) return null;
fields[2] = "";
return string.Join(':', fields).ToUpperInvariant();
}
}
-179
View File
@@ -1,179 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Synchronize the receiver's bouquet through Jellyfin's native guide job.</summary>
public sealed class ChannelSyncTask : IScheduledTask
{
private readonly IEnumerable<ILiveTvService> services;
private readonly ITaskManager tasks;
private readonly ILibraryManager library;
private readonly IUserManager users;
private readonly IUserDataManager userData;
private readonly PreferencesStore preferences;
private readonly ILogger<ChannelSyncTask> logger;
private readonly string stateFile;
private readonly SemaphoreSlim gate=new(1,1);
public string Name=>"Synchronizacja kanałów Enigma2";
public string Key=>"Enigma2ChannelSync";
public string Description=>"Sprawdza listę dekodera co 6 godzin. Po zmianach odświeża natywną Telewizję i zachowuje preferencje kanałów. Harmonogram można zmienić w zadaniach zaplanowanych.";
public string Category=>"Live TV";
public DateTime? LastCheckedUtc { get; private set; }
public DateTime? LastSynchronizedUtc { get; private set; }
public string LastError { get; private set; }
public ChannelSyncTask(IEnumerable<ILiveTvService> services,ITaskManager tasks,ILibraryManager library,IUserManager users,IUserDataManager userData,PreferencesStore preferences,IApplicationPaths paths,ILogger<ChannelSyncTask> logger)
{
this.services=services;this.tasks=tasks;this.library=library;this.users=users;this.userData=userData;this.preferences=preferences;this.logger=logger;
stateFile=Path.Combine(paths.DataPath,"decoder-integration","channel-sync.json");
}
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()=>new[]
{
new TaskTriggerInfo{Type=TaskTriggerInfoType.IntervalTrigger,IntervalTicks=TimeSpan.FromHours(6).Ticks},
new TaskTriggerInfo{Type=TaskTriggerInfoType.StartupTrigger}
};
private LiveTvChannel[] NativeChannels()=>library.GetItemList(new InternalItemsQuery{IncludeItemTypes=new[]{BaseItemKind.LiveTvChannel}})
.OfType<LiveTvChannel>().Where(c=>string.Equals(c.ServiceName,"Enigma2",StringComparison.OrdinalIgnoreCase)).ToArray();
public async Task ExecuteAsync(IProgress<double> progress,CancellationToken token)
{
if(Plugin.Instance?.Configuration.EnableAutomaticChannelSync==false){progress.Report(100);return;}
if(!await gate.WaitAsync(0,token).ConfigureAwait(false))return;
try
{
LastCheckedUtc=DateTime.UtcNow;LastError=null;
var state=File.Exists(stateFile)?JsonSerializer.Deserialize<SyncState>(await File.ReadAllTextAsync(stateFile,token))??new():new SyncState();
LastSynchronizedUtc=state.LastSynchronizedUtc;
var service=services.Single(s=>s.Name=="Enigma2");
ChannelInfo[] current;
using(var deadline=CancellationTokenSource.CreateLinkedTokenSource(token))
{
deadline.CancelAfter(TimeSpan.FromSeconds(30));
current=(await service.GetChannelsAsync(deadline.Token).ConfigureAwait(false)).ToArray();
}
if(current.Length==0||current.Any(c=>string.IsNullOrWhiteSpace(c.Id)||string.IsNullOrWhiteSpace(c.Name)))
throw new InvalidOperationException("Empty or invalid receiver bouquet; native guide was not refreshed.");
var fingerprint=Fingerprint(current);
var before=NativeChannels();
if(state.Pending==null&&state.Fingerprint==fingerprint&&current.All(c=>before.Any(n=>n.ExternalId==c.Id)))
{
logger.LogInformation("[Enigma2] Channel synchronization: receiver bouquet unchanged ({ChannelCount} positions)",current.Length);
progress.Report(100);return;
}
var worker=tasks.ScheduledTasks.SingleOrDefault(t=>t.ScheduledTask.Key=="RefreshGuide")
??throw new InvalidOperationException("Native RefreshGuide task unavailable.");
if(worker.State!=TaskState.Idle)
{
LastError="NativeGuideBusy";
logger.LogInformation("[Enigma2] Native guide is already running; channel synchronization deferred to next trigger");return;
}
var owners=users.GetUsers().ToArray();
if(state.Pending==null)
{
state.Pending=new PendingState{Channels=before.Select(c=>new SavedChannel{Id=c.Id,Reference=c.ExternalId,Name=c.Name}).ToArray()};
foreach(var owner in owners)
{
var favorites=userData.GetUserDataBatch(before,owner).Where(p=>p.Value.IsFavorite).Select(p=>p.Key).ToArray();
if(favorites.Length>0)state.Pending.Favorites[owner.Id]=favorites;
}
// Keep a recovery checkpoint before the native job can remove old IDs.
await Save(state,token).ConfigureAwait(false);
}
progress.Report(10);
var refreshStarted=DateTime.UtcNow;
// A first seven-day import may take much longer than a channel check.
// Keep the native job's ordinary execution/cancellation policy.
await tasks.Execute(worker,new TaskOptions()).WaitAsync(token).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
if(worker.LastExecutionResult?.Status!=TaskCompletionStatus.Completed||worker.LastExecutionResult.StartTimeUtc<refreshStarted.AddSeconds(-1))
throw new InvalidOperationException("Native guide refresh did not complete successfully.");
var after=NativeChannels();
if(!current.All(c=>after.Any(n=>n.ExternalId==c.Id)))
throw new InvalidOperationException("Native channel list is incomplete after guide refresh.");
var mapping=BuildMapping(state.Pending.Channels,current,after);
foreach(var owner in owners)
{
token.ThrowIfCancellationRequested();
await preferences.Remap(owner.Id,mapping,token).ConfigureAwait(false);
if(!state.Pending.Favorites.TryGetValue(owner.Id,out var favorites))continue;
foreach(var oldId in favorites)
{
if(!mapping.TryGetValue(oldId.ToString("N"),out var target))continue;
var channel=after.Single(c=>c.Id==Guid.Parse(target));
var data=userData.GetUserData(owner,channel);
if(data.IsFavorite)continue;
data.IsFavorite=true;
userData.SaveUserData(owner,channel,data,UserDataSaveReason.UpdateUserData,token);
}
}
state.Pending=null;state.Fingerprint=fingerprint;state.LastSynchronizedUtc=DateTime.UtcNow;
await Save(state,token).ConfigureAwait(false);
LastSynchronizedUtc=state.LastSynchronizedUtc;
logger.LogInformation("[Enigma2] Synchronized {ChannelCount} receiver positions with native Live TV; remapped {MigratedCount} saved channel IDs",current.Length,mapping.Count);
progress.Report(100);
}
catch(Exception error)
{
LastError=error.GetType().Name;
logger.LogWarning("[Enigma2] Channel synchronization failed ({FailureType}); checkpoint retained for retry",LastError);
throw;
}
finally { gate.Release(); }
}
private async Task Save(SyncState state,CancellationToken token)
{
Directory.CreateDirectory(Path.GetDirectoryName(stateFile));
await File.WriteAllTextAsync(stateFile+".tmp",JsonSerializer.Serialize(state),token).ConfigureAwait(false);
File.Move(stateFile+".tmp",stateFile,true);
}
public static string Fingerprint(IEnumerable<ChannelInfo> channels)
{
var values=channels.Select(c=>new[]{c.Id,c.Name,c.Number,c.ImageUrl,c.ImagePath}).ToArray();
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(values))));
}
public static Dictionary<string,string> BuildMapping(IEnumerable<SavedChannel> before,ChannelInfo[] current,LiveTvChannel[] after)
{
var mapping=new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
foreach(var old in before)
{
var reference=ChannelReferenceResolver.Resolve(old.Reference,current,()=>old.Name);
var targets=after.Where(c=>string.Equals(c.ExternalId,reference,StringComparison.OrdinalIgnoreCase)).Select(c=>c.Id).Distinct().ToArray();
if(targets.Length==1&&targets[0]!=old.Id)mapping[old.Id.ToString("N")]=targets[0].ToString("N");
}
return mapping;
}
public sealed class SavedChannel
{
public Guid Id { get; set; }
public string Reference { get; set; }
public string Name { get; set; }
}
public sealed class PendingState
{
public SavedChannel[] Channels { get; set; }=Array.Empty<SavedChannel>();
public Dictionary<Guid,Guid[]> Favorites { get; set; }=new();
}
public sealed class SyncState
{
public string Fingerprint { get; set; }
public DateTime? LastSynchronizedUtc { get; set; }
public PendingState Pending { get; set; }
}
}
@@ -1,65 +0,0 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.Enigma2.Configuration
{
/// <summary>
/// Class PluginConfiguration
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
public string HostName { get; set; }
public string StreamingPort { get; set; }
public string WebInterfacePort { get; set; }
public string WebInterfaceUsername { get; set; }
public string WebInterfacePassword { get; set; }
public bool UseLoginForStreams { get; set; }
public bool UseSecureHTTPS { get; set; }
public bool UseSecureHTTPSForStreams { get; set; }
public bool OnlyOneBouquet { get; set; }
public string TVBouquet { get; set; }
public bool ZapToChannel { get; set; }
public bool FetchPiconsFromWebInterface { get; set; }
public string PiconsPath { get; set; }
public string RecordingPath { get; set; }
public bool TranscodedStream { get; set; }
public string TranscodingPort { get; set; }
public bool EnableDebugLogging { get; set; }
public bool EnableVuRecordings { get; set; } = false;
public bool PreferH264ForWebLiveTv { get; set; } = true;
public bool EnableAutomaticChannelSync { get; set; } = true;
public bool ShowNativeStartupMessages { get; set; } = true;
public bool EnableContinuitySlates { get; set; } = false;
public bool PreserveSourceResolution { get; set; } = true;
public int StreamStartupWaitSeconds { get; set; } = 90;
public int StreamStallWaitSeconds { get; set; } = 30;
public PluginConfiguration()
{
HostName = "localhost";
StreamingPort = "8001";
WebInterfacePort = "8000";
WebInterfaceUsername = "";
WebInterfacePassword = "";
UseLoginForStreams = false;
UseSecureHTTPS = false;
UseSecureHTTPSForStreams = false;
OnlyOneBouquet = true;
TVBouquet = "Favourites (TV)";
ZapToChannel = false;
FetchPiconsFromWebInterface = true;
PiconsPath = "";
RecordingPath = "";
TranscodedStream = false;
TranscodingPort = "8002";
EnableDebugLogging = false;
}
}
}
@@ -1,55 +0,0 @@
<!DOCTYPE html><html><head><title>Telewizja</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{background:#181818;color:#eee;font:16px system-ui;margin:0} .content-primary{max-width:1200px;margin:auto;padding:24px}button,input,select{font:inherit;background:#303030;color:#eee;border:1px solid #666;border-radius:6px;padding:10px;margin:4px}button{cursor:pointer}button:hover{background:#455a64}a{color:#7cd5ef}img{object-fit:contain;border-radius:5px}section>div{flex:1;min-width:160px}</style></head><body>
<div id="decoderChannels" data-role="page" class="page type-interior pluginConfigurationPage">
<div data-role="content"><div class="content-primary">
<h1>Telewizja</h1><p>Cała lista kanałów dekodera. Ustawienia dotyczą Twojego konta.</p>
<input id="channelSearch" is="emby-input" placeholder="Szukaj kanału lub audycji" aria-label="Szukaj">
<select id="channelView" is="emby-select" aria-label="Widok"><option value="all">Wszystkie kanały</option><option value="favorites">Ulubione</option><option value="guide">Program telewizyjny</option><option value="hidden">Ukryte kanały</option></select>
<button id="saveDefault" is="emby-button">Ustaw ten widok jako domyślny</button>
<a id="nativeGuide" href="/web/index.html#/livetv">Natywny przewodnik, nagrania i timery</a>
<p id="channelMessage" role="status"></p><div id="channelRows"></div>
</div></div>
<script>
(function(){
const standalone=typeof ApiClient==='undefined';
const base=standalone?location.pathname.replace(/\/DecoderIntegration\/Channels\/?$/,''):'';
if(standalone){
let servers=[];try{servers=JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers||[];}catch(e){}
const server=servers.find(s=>s.AccessToken&&s.UserId&&[s.ManualAddress,s.LocalAddress,s.RemoteAddress].some(a=>{try{return new URL(a).host===location.host;}catch(e){return false;}}));
if(!server){document.querySelector('#channelMessage').textContent='Zaloguj się w Jellyfinie z opcją „Zapamiętaj mnie”, a następnie otwórz tę stronę ponownie.';const link=document.createElement('a');link.href=base+'/web/index.html#/login';link.textContent='Przejdź do logowania Jellyfin';document.querySelector('#channelRows').append(link);return;}
window.ApiClient={getCurrentUserId:()=>server.UserId,serverId:()=>server.Id,getUrl:(path,query)=>base+'/'+path+(query?'?'+new URLSearchParams(query):''),
ajax:async options=>{const response=await fetch(options.url,{method:options.type||'GET',headers:{'Authorization':'MediaBrowser Token="'+server.AccessToken+'", Client="DecoderIntegration", Device="Browser", DeviceId="decoder-integration", Version="7"','Content-Type':'application/json'},body:options.data});if(!response.ok)throw Error('HTTP '+response.status);return options.dataType==='json'?response.json():undefined;},
getImageUrl:(id,options)=>base+'/Items/'+encodeURIComponent(id)+'/Images/'+options.type+'?'+new URLSearchParams({tag:options.tag,maxWidth:options.maxWidth})};
}
const page=document.querySelector('#decoderChannels'); let channels=[], preferences, programs={}, saving=Promise.resolve();
const q=id=>page.querySelector('#'+id), key=id=>id.replace(/-/g,'').toLowerCase();
function request(path,type='GET',data){return ApiClient.ajax({type,url:ApiClient.getUrl(path),data:data?JSON.stringify(data):undefined,contentType:'application/json',dataType:type==='GET'?'json':undefined});}
function save(){const snapshot=JSON.parse(JSON.stringify(preferences));saving=saving.catch(()=>{}).then(()=>request('DecoderIntegration/Preferences','PUT',snapshot));saving.catch(()=>q('channelMessage').textContent='Nie udało się zapisać ustawień.');}
function button(text,action){const b=document.createElement('button');b.textContent=text;b.className='raised emby-button';b.onclick=action;return b;}
function details(item){location.href=base+'/web/index.html#/details?id='+encodeURIComponent(item.Id)+'&serverId='+encodeURIComponent(ApiClient.serverId());}
function playChannel(item){location.href=base+'/web/index.html#/livetv?decoderPlay='+encodeURIComponent(item.Id)+'&serverId='+encodeURIComponent(ApiClient.serverId());}
q('nativeGuide').href=base+'/web/index.html#/livetv';
function toggle(field,id){const a=preferences[field],i=a.indexOf(id);if(i<0)a.push(id);else a.splice(i,1);if(field==='FavoriteIds')request('Users/'+ApiClient.getCurrentUserId()+'/FavoriteItems/'+id,i<0?'POST':'DELETE').catch(()=>q('channelMessage').textContent='Nie udało się zsynchronizować ulubionych z Jellyfinem.');save();render();}
function render(){
const view=q('channelView').value,search=q('channelSearch').value.toLocaleLowerCase(),hidden=new Set(preferences.HiddenIds),fav=new Set(preferences.FavoriteIds);
const order=new Map(preferences.OrderedIds.map((id,i)=>[id,i]));
const sorted=[...channels].sort((a,b)=>(order.get(key(a.Id))??99999)-(order.get(key(b.Id))??99999));
const visible=sorted.filter(c=>{const id=key(c.Id);return (view==='hidden'?hidden.has(id):!hidden.has(id))&&(view!=='favorites'||fav.has(id))&&(c.Name+' '+(c.CurrentProgram?.Name||'')).toLocaleLowerCase().includes(search);});
q('channelRows').replaceChildren();q('channelMessage').textContent=visible.length+' / '+channels.length+' kanałów';
visible.forEach(c=>{const id=key(c.Id),row=document.createElement('section');row.style.cssText='padding:1em 0;border-bottom:1px solid #666;display:flex;gap:1em;flex-wrap:wrap;align-items:center';
if(c.ImageTags?.Primary){const image=document.createElement('img');image.src=ApiClient.getImageUrl(c.Id,{type:'Primary',tag:c.ImageTags.Primary,maxWidth:100});image.alt='';image.width=80;image.loading='lazy';row.append(image);}
row.append(button((c.Number?c.Number+'. ':'')+c.Name,()=>playChannel(c)),button('Szczegóły',()=>details(c)),button(fav.has(id)?'★ Ulubiony':'☆ Ulubiony',()=>toggle('FavoriteIds',id)),button(hidden.has(id)?'Pokaż':'Ukryj',()=>toggle('HiddenIds',id)));
row.append(button('↑',()=>{const list=sorted.map(x=>key(x.Id)),i=list.indexOf(id);if(i>0){[list[i-1],list[i]]=[list[i],list[i-1]];preferences.OrderedIds=list;save();render();}}));
const events=view==='guide'?(programs[c.Id]||[]):[c.CurrentProgram].filter(Boolean);
events.forEach(p=>{const card=document.createElement('div');card.style.cssText='max-width:22em;cursor:pointer';card.onclick=()=>details(p);
if(p.ImageTags?.Primary){const image=document.createElement('img');image.src=ApiClient.getImageUrl(p.Id,{type:'Primary',tag:p.ImageTags.Primary,maxWidth:240});image.width=160;image.alt='';image.loading='lazy';card.append(image);}
const text=document.createElement('p');text.textContent=new Date(p.StartDate).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})+'–'+new Date(p.EndDate).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})+' '+p.Name;card.append(text);row.append(card);
});q('channelRows').append(row);
});
}
async function guide(){if(Object.keys(programs).length)return;const now=new Date(),until=new Date(now.getTime()+6*3600000);let offset=0;while(true){const result=await ApiClient.ajax({type:'GET',url:ApiClient.getUrl('LiveTv/Programs',{UserId:ApiClient.getCurrentUserId(),MinEndDate:now.toISOString(),MaxStartDate:until.toISOString(),StartIndex:offset,Limit:500}),dataType:'json'});for(const p of result.Items)(programs[p.ChannelId]??=[]).push(p);render();offset+=result.Items.length;if(!result.Items.length||offset>=result.TotalRecordCount)break;}render();}
q('channelSearch').oninput=()=>preferences&&render();q('channelView').onchange=()=>{render();if(q('channelView').value==='guide')guide().catch(()=>q('channelMessage').textContent='Nie udało się pobrać przewodnika.');};
q('saveDefault').onclick=()=>{if(q('channelView').value!=='hidden'){preferences.DefaultView=q('channelView').value;save();}};
async function load(){try{preferences=await request('DecoderIntegration/Preferences');channels=[];programs={};let offset=0;while(true){const result=await ApiClient.ajax({type:'GET',url:ApiClient.getUrl('LiveTv/Channels',{UserId:ApiClient.getCurrentUserId(),AddCurrentProgram:true,EnableUserData:true,DecoderIncludeHidden:true,StartIndex:offset,Limit:250}),dataType:'json'});channels.push(...result.Items);offset+=result.Items.length;if(!result.Items.length||offset>=result.TotalRecordCount)break;}preferences.FavoriteIds=[...new Set([...preferences.FavoriteIds,...channels.filter(c=>c.UserData?.IsFavorite).map(c=>key(c.Id))])];q('channelView').value=preferences.DefaultView;render();if(preferences.DefaultView==='guide')await guide();}catch(e){q('channelMessage').textContent='Nie udało się pobrać kanałów. Sprawdź dostęp do telewizji na swoim koncie.';}}
page.addEventListener('pageshow',load);if(standalone)load();
})();
</script></div></body></html>
@@ -1,278 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Enigma2</title>
</head>
<body>
<div data-role="page" id="Enigma2ConfigurationPage" class="page type-interior pluginConfigurationPage Enigma2ConfigurationPage" data-require="emby-button,emby-checkbox,emby-input">
<div data-role="content">
<div class="content-primary">
<h1>Enigma2</h1>
<form id="Enigma2ConfigurationForm" class="Enigma2ConfigurationForm">
<div class="inputContainer">
<input is="emby-input" id="txtHostName" type="text" label="Enigma2 hostname or ip address:" />
<div class="fieldDescription">
The host name (address) or ip address of your receiver
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtStreamingPort" type="text" label="Enigma2 streaming port:" />
<div class="fieldDescription">
The Streaming port of your receiver eg. 8001
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtWebInterfacePort" type="text" label="Enigma2 Web Interface port:" />
<div class="fieldDescription">
The web interface port of your receiver eg. 8000
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtWebInterfaceUsername" type="text" label="Enigma2 Web Interface username:" />
<div class="fieldDescription">
The web interface username of your receiver (optional)
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtWebInterfacePassword" type="password" label="Enigma2 Web Interface password:" />
<div class="fieldDescription">
The web interface password of your receiver (optional)
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkUseLoginForStreams" />
<span>Use login for streaming</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Use web interface login for streaming
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkUseSecureHTTPS" />
<span>Use secure HTTP (HTTPS)</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Use HTTPS to connect to your receiver
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkUseSecureHTTPSForStreams" />
<span>Use secure HTTP (HTTPS) for streams</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Use HTTPS to connect to streaming port
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkOnlyOneBouquet" />
<span>Fetch Only one TV bouquet</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Limit channels to only those contained within the specified TV Bouquet below (optional)
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtTVBouquet" type="text" label="Enigma2 TVBouquet:" />
<div class="fieldDescription">
The TV Bouquet to load channels for (optional)
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkZapToChannel" />
<span>Zap before Channel switch (single tuner)</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Set if only one tuner within receiver
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkFetchPiconsFromWebInterface" />
<span>Fetch picons from webinterface</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Set if you want to retrieve Picons from the web interface of the receiver
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtPiconsPath" type="text" label="Picons Path:" />
<div class="fieldDescription">
The local location of your Picons eg. C:\Picons\ (optional)
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtRecordingPath" type="text" label="Recording Path:" />
<div class="fieldDescription">
The location to store your recordings on your receiver eg. /hdd/movie/ (optional)
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkTranscodedStream" />
<span>Request Transcoded Stream</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Request Transcoded Stream (h264, 720p, 1Mpbs and mp4 as default)
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtTranscodingPort" type="text" label="Enigma2 transcoding port:" />
<div class="fieldDescription">
The Transcoding port of your receiver eg. 8002
</div>
</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkEnableVuRecordings" />
<span>Obsługuj nagrania z Vu+ (Enigma2 Recordings)</span>
</label>
<div class="fieldDescription">Włącza bibliotekę nagrań dekodera. Zmiana wymaga ponownego uruchomienia Jellyfina. Wyłączenie nie usuwa plików nagrań.</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkPreferH264ForWebLiveTv" />
<span>Preferuj H264 przy transkodowaniu telewizji w przeglądarce</span>
</label>
<div class="fieldDescription">Ogranicza transkodowanie kanałów Enigma2 w aplikacji webowej do H264. Ułatwia odtwarzanie na urządzeniach bez wydajnego dekodowania AV1. Zmiana dotyczy kolejnego uruchomienia kanału.</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkEnableContinuitySlates" />
<span>Podtrzymuj stream planszą FFmpeg podczas oczekiwania i przerw</span>
</label>
<div class="fieldDescription">Obraz i dźwięk kanału są kopiowane bez przekodowania: bez limitu 50 kl./s, zmiany rozdzielczości, HDR i dźwięku przestrzennego. Plansza jest dostępna dla rozpoznanego progresywnego H264 8-bit z AAC LC stereo 48 kHz; ma rozmiar i klatkaż źródła oraz wymaga zgodnych granic klatek obrazu i dźwięku. Pozostałe formaty oraz źródła bez metadanych używają zwykłego oczekiwania, bez obniżania jakości. Język planszy według interfejsu, profilu i ustawień Jellyfina. Nie dotyczy nagrań.</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtStreamStartupWaitSeconds">Czas oczekiwania na obraz (sekundy)</label>
<input is="emby-input" type="number" min="25" max="180" id="txtStreamStartupWaitSeconds" />
<div class="fieldDescription">Domyślnie 90, zakres 25–180. Ponawia analizę świeżej próbki przy jednym połączeniu dekodera. Anulowanie ostatniego widza kończy próbę. Android TV może mieć własny, krótszy limit.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtStreamStallWaitSeconds">Tolerowana cisza strumienia (sekundy)</label>
<input is="emby-input" type="number" min="8" max="60" id="txtStreamStallWaitSeconds" />
<div class="fieldDescription">Domyślnie 30, zakres 8–60. Dotyczy braku danych; nie potwierdza poprawnego obrazu w napływających pakietach.</div>
</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkShowNativeStartupMessages" />
<span>Pokazuj komunikaty uruchamiania kanału w Android TV</span>
</label>
<div class="fieldDescription">Krótkie komunikaty o oczekiwaniu, gotowości lub błędzie. Wyłącznie na urządzeniu, które uruchomiło kanał. W przeglądarce moduł wtyczki pokazuje osobny ekran oczekiwania.</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkDebugLogging" />
<span>Enable Enigma2 debug logging</span>
</label>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkEnableAutomaticChannelSync" />
<span>Automatycznie synchronizuj kanały z dekodera</span>
</label>
<div class="fieldDescription">Domyślnie przy starcie i co 6 godzin. Po zmianie listy uruchamia natywne odświeżenie Telewizji, zachowując ulubione i preferencje kanałów. Harmonogram zadania „Synchronizacja kanałów Enigma2” można zmienić w zadaniach zaplanowanych.</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var Enigma2ConfigurationPage = {
pluginUniqueId: "193f29f9-ea6c-4595-a6f6-55e79d7c590a",
loadConfiguration: function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(Enigma2ConfigurationPage.pluginUniqueId).then(function (config) {
document.getElementById('txtHostName').value = config.HostName || "";
document.getElementById('txtStreamingPort').value = config.StreamingPort || "";
document.getElementById('txtWebInterfacePort').value = config.WebInterfacePort || "";
document.getElementById('txtWebInterfaceUsername').value = config.WebInterfaceUsername || "";
document.getElementById('txtWebInterfacePassword').value = config.WebInterfacePassword || "";
document.getElementById('chkUseLoginForStreams').checked = config.UseLoginForStreams || false;
document.getElementById('chkUseSecureHTTPS').checked = config.UseSecureHTTPS || false;
document.getElementById('chkUseSecureHTTPSForStreams').checked = config.UseSecureHTTPSForStreams || false;
document.getElementById('chkOnlyOneBouquet').checked = config.OnlyOneBouquet || false;
document.getElementById('txtTVBouquet').value = config.TVBouquet || "";
document.getElementById('chkZapToChannel').checked = config.ZapToChannel || false;
document.getElementById('chkFetchPiconsFromWebInterface').checked = config.FetchPiconsFromWebInterface || false;
document.getElementById('txtPiconsPath').value = config.PiconsPath || "";
document.getElementById('txtRecordingPath').value = config.RecordingPath || "";
document.getElementById('chkTranscodedStream').checked = config.TranscodedStream || false;
document.getElementById('txtTranscodingPort').value = config.TranscodingPort || "";
document.getElementById('chkDebugLogging').checked = config.EnableDebugLogging || false;
document.getElementById('chkEnableVuRecordings').checked = config.EnableVuRecordings || false;
document.getElementById('chkPreferH264ForWebLiveTv').checked = config.PreferH264ForWebLiveTv !== false;
document.getElementById('chkEnableAutomaticChannelSync').checked = config.EnableAutomaticChannelSync !== false;
document.getElementById('chkShowNativeStartupMessages').checked = config.ShowNativeStartupMessages !== false;
document.getElementById('chkEnableContinuitySlates').checked = config.EnableContinuitySlates === true;
document.getElementById('txtStreamStartupWaitSeconds').value = config.StreamStartupWaitSeconds ?? 90;
document.getElementById('txtStreamStallWaitSeconds').value = config.StreamStallWaitSeconds ?? 30;
Dashboard.hideLoadingMsg();
});
},
saveConfiguration: function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(Enigma2ConfigurationPage.pluginUniqueId).then(function (config) {
config.HostName = document.getElementById('txtHostName').value;
config.StreamingPort = document.getElementById('txtStreamingPort').value;
config.WebInterfacePort = document.getElementById('txtWebInterfacePort').value;
config.WebInterfaceUsername = document.getElementById('txtWebInterfaceUsername').value;
config.WebInterfacePassword = document.getElementById('txtWebInterfacePassword').value;
config.UseLoginForStreams = document.getElementById('chkUseLoginForStreams').checked;
config.UseSecureHTTPS = document.getElementById('chkUseSecureHTTPS').checked;
config.UseSecureHTTPSForStreams = document.getElementById('chkUseSecureHTTPSForStreams').checked;
config.OnlyOneBouquet = document.getElementById('chkOnlyOneBouquet').checked;
config.TVBouquet = document.getElementById('txtTVBouquet').value;
config.ZapToChannel = document.getElementById('chkZapToChannel').checked;
config.FetchPiconsFromWebInterface = document.getElementById('chkFetchPiconsFromWebInterface').checked;
config.PiconsPath = document.getElementById('txtPiconsPath').value;
config.RecordingPath = document.getElementById('txtRecordingPath').value;
config.TranscodedStream = document.getElementById('chkTranscodedStream').checked;
config.TranscodingPort = document.getElementById('txtTranscodingPort').value;
config.EnableDebugLogging = document.getElementById('chkDebugLogging').checked;
config.EnableVuRecordings = document.getElementById('chkEnableVuRecordings').checked;
config.PreferH264ForWebLiveTv = document.getElementById('chkPreferH264ForWebLiveTv').checked;
config.EnableAutomaticChannelSync = document.getElementById('chkEnableAutomaticChannelSync').checked;
config.ShowNativeStartupMessages = document.getElementById('chkShowNativeStartupMessages').checked;
config.EnableContinuitySlates = document.getElementById('chkEnableContinuitySlates').checked;
config.StreamStartupWaitSeconds = Math.max(25, Math.min(180, Number(document.getElementById('txtStreamStartupWaitSeconds').value) || 90));
config.StreamStallWaitSeconds = Math.max(8, Math.min(60, Number(document.getElementById('txtStreamStallWaitSeconds').value) || 30));
ApiClient.updatePluginConfiguration(Enigma2ConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
}
}
document.getElementById('Enigma2ConfigurationPage').addEventListener('pageshow', function () {
Enigma2ConfigurationPage.loadConfiguration();
});
document.getElementById('Enigma2ConfigurationForm').addEventListener('submit', function (e) {
e.preventDefault();
Enigma2ConfigurationPage.saveConfiguration();
});
</script>
</div>
</body>
</html>
@@ -1,334 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Plugin-owned FFmpeg failover. One producer per channel; per-viewer language and lifetime.</summary>
public sealed class ContinuityStreams : IDisposable
{
private readonly string root;
private Func<string> encoderPath;
private readonly int port;
private readonly StartupMessages messages;
private readonly ILogger logger;
private readonly object gate = new();
private readonly Dictionary<string, Source> sources = new();
private readonly Dictionary<string, Viewer> viewers = new();
private readonly ConcurrentDictionary<string, Lazy<Task<Dictionary<string, byte[]>>>> slates = new();
private readonly CancellationTokenSource lifetime = new();
private readonly Task sweeper;
private readonly SemaphoreSlim slateGate = new(1, 1);
private bool disposed;
private long attempts, liveSegments;
public const int Width = 1920, Height = 1080, FrameRate = 50;
public ContinuityStreams(IMediaEncoder encoder, IApplicationPaths paths, IServerApplicationHost host, StartupMessages messages, ILogger<ContinuityStreams> logger)
: this(encoder.EncoderPath, Path.Combine(paths.CachePath, "enigma2-continuity-v1"), host.HttpPort, messages, logger, Plugin.Instance?.Configuration?.PreserveSourceResolution ?? true) { encoderPath = () => encoder.EncoderPath; }
// Explicit inputs support an isolated FFmpeg/Kestrel test without a receiver or server configuration changes.
public ContinuityStreams(string encoder, string root, int port, StartupMessages messages, ILogger logger, bool preserveResolution = true)
{
encoderPath = () => encoder; this.root = Path.GetFullPath(root); this.port = port; this.messages = messages; this.logger = logger;
Directory.CreateDirectory(this.root); sweeper = Task.Run(Sweep);
}
private sealed class Viewer
{
public string Id = Guid.NewGuid().ToString("N");
public Source Source;
public Dictionary<string, byte[]> Slates;
public DvbSubtitleVersions SubtitleVersions;
public DateTime LastAccess = DateTime.UtcNow;
}
public async Task<MediaSourceInfo> Open(string relayUrl, string language, CancellationToken token, BroadcastTracks tracks = null, bool useSlates = true)
{
language = StartupMessages.ResolveLanguage(language, null, "en");
tracks ??= BroadcastTracks.Fallback();
var profile = useSlates ? LosslessSlateProfile.Create(tracks) : null;
useSlates = profile != null;
Dictionary<string, byte[]> clips = null;
if (useSlates)
{
try { clips = await CachedSlates(language, token, tracks); }
catch (Exception error) when (error is not OperationCanceledException && !token.IsCancellationRequested)
{ useSlates = false; logger.LogWarning("[Enigma2] Status picture unavailable ({FailureType}); preserving the original broadcast", error.GetType().Name); }
}
var sourceKey = relayUrl + "|" + useSlates + "|" + tracks.Key;
token.ThrowIfCancellationRequested();
Viewer viewer;
lock (gate)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (viewers.Count >= 64) throw new InvalidOperationException("Continuity viewer limit reached");
if (!sources.TryGetValue(sourceKey, out var source))
{
if (sources.Count >= 4) throw new InvalidOperationException("Continuity channel limit reached");
source = new(this, relayUrl, sourceKey, tracks, useSlates); sources.Add(sourceKey, source);
}
viewer = new() { Source = source, Slates = clips, SubtitleVersions = new(tracks) }; viewers.Add(viewer.Id, viewer);
}
try
{
if (!useSlates) await viewer.Source.Ready.Task.WaitAsync(TimeSpan.FromSeconds(StartupBudget.Seconds), token);
return new MediaSourceInfo {
Id = viewer.Id, Path = $"http://127.0.0.1:{port}/DecoderIntegration/Continuity/{viewer.Id}/index.m3u8",
Protocol = MediaProtocol.Http, Container = "hls", IsInfiniteStream = true, SupportsProbing = false,
SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true, BufferMs = 1000,
MediaStreams = tracks.Output,
DefaultAudioStreamIndex = tracks.Output.FirstOrDefault(s => s.Type == MediaStreamType.Audio && s.IsDefault)?.Index
?? tracks.Output.FirstOrDefault(s => s.Type == MediaStreamType.Audio)?.Index,
DefaultSubtitleStreamIndex = tracks.Output.FirstOrDefault(s => s.Type == MediaStreamType.Subtitle && s.IsDefault)?.Index
};
}
catch { Close(viewer.Id); throw; }
}
private async Task<Dictionary<string, byte[]>> CachedSlates(string language, CancellationToken token, BroadcastTracks tracks = null)
{
tracks ??= PreviewTracks();
var key = language + "-" + tracks.Key;
if (slates.Count >= 64)
foreach (var stale in slates.Keys.Where(k => k != key).Take(slates.Count - 63)) slates.TryRemove(stale, out _);
var cached = slates.GetOrAdd(key, _ => new(() => GenerateSlates(language, tracks), LazyThreadSafetyMode.ExecutionAndPublication));
Dictionary<string, byte[]> clips;
try { clips = await cached.Value.WaitAsync(token); }
catch { if (cached.IsValueCreated && cached.Value.IsFaulted) slates.TryRemove(new KeyValuePair<string, Lazy<Task<Dictionary<string, byte[]>>>>(key, cached)); throw; }
return clips;
}
public async Task<byte[]> SlatePreview(string language, string phase, CancellationToken token)
{
language = StartupMessages.ResolveLanguage(language, null, "en");
if (phase != "waiting" && phase != "retrying") throw new ArgumentException("Unknown slate phase");
return TransportStreamClock.Rebase((await CachedSlates(language, token))[phase], 126000);
}
public void Close(string id)
{
lock (gate)
{
if (!viewers.Remove(id, out var viewer)) return;
if (!viewers.Values.Any(v => ReferenceEquals(v.Source, viewer.Source)))
{ sources.Remove(viewer.Source.Key); viewer.Source.Stop(); }
}
}
public string Playlist(string id)
{
lock (gate)
{
if (!viewers.TryGetValue(id, out var viewer)) return null;
viewer.LastAccess = DateTime.UtcNow; return viewer.Source.Timeline.Playlist();
}
}
public byte[] Segment(string id, long sequence)
{
lock (gate)
{
if (!viewers.TryGetValue(id, out var viewer)) return null;
viewer.LastAccess = DateTime.UtcNow;
var segment = viewer.Source.Timeline.Find(sequence);
if (segment == null) return null;
var transport = TransportStreamClock.Rebase(segment.Slate is { } slate ? viewer.Slates[slate] : segment.Video, segment.Start90k,
alignAudio: segment.Slate != null, offset90k: segment.ClockOffset90k);
viewer.SubtitleVersions.Apply(transport, sequence);
return transport;
}
}
public object Status() { lock (gate) return new { Sources = sources.Count, Viewers = viewers.Count, Retrying = sources.Values.Count(s => s.Retrying), ProducerAttempts = Interlocked.Read(ref attempts), LiveSegmentsProduced = Interlocked.Read(ref liveSegments) }; }
private async Task Sweep()
{
try
{
while (!lifetime.IsCancellationRequested)
{
await Task.Delay(100, lifetime.Token);
lock (gate)
{
foreach (var id in viewers.Values.Where(v => DateTime.UtcNow - v.LastAccess > TimeSpan.FromSeconds(60)).Select(v => v.Id).ToArray()) Close(id);
foreach (var source in sources.Values) if (source.UseSlates) source.Timeline.Tick(DateTime.UtcNow, source.Retrying);
}
}
}
catch (OperationCanceledException) { }
}
public void Dispose()
{
lock (gate)
{
if (disposed) return; disposed = true; lifetime.Cancel();
foreach (var source in sources.Values) source.Stop(); sources.Clear(); viewers.Clear();
}
}
private static BroadcastTracks PreviewTracks() => new(new MediaStream[] {
new BroadcastMediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", Width = Width, Height = Height,
Profile = "Constrained Baseline", PixelFormat = "yuv420p", AverageFrameRate = FrameRate, SampleAspectRatio = "1:1", FrameRateRational = "50/1" },
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Profile = "LC", Channels = 2, SampleRate = 48000, IsDefault = true }
});
// These arguments encode the generated status image only. Live AV always uses -c copy.
private static IEnumerable<string> SlateCodecArguments(LosslessSlateProfile profile) => new[] {
"-c:v", "libx264", "-threads", "2", "-preset", "ultrafast", "-tune", "zerolatency", "-crf", "20",
"-profile:v", profile.Profile, "-pix_fmt", "yuv420p", "-r", profile.RateArgument,
"-g", profile.Gop.ToString(CultureInfo.InvariantCulture), "-keyint_min", profile.Gop.ToString(CultureInfo.InvariantCulture), "-sc_threshold", "0",
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k" };
private Process Start(IEnumerable<string> arguments, string folder)
{
var info = new ProcessStartInfo(encoderPath()) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, WorkingDirectory = folder };
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "1", "-threads", "2" }.Concat(arguments)) info.ArgumentList.Add(arg);
var process = Process.Start(info) ?? throw new IOException("FFmpeg did not start");
// Drain without retaining lines: FFmpeg errors may contain private input URLs.
process.ErrorDataReceived += (_, _) => { }; process.OutputDataReceived += (_, _) => { };
process.BeginErrorReadLine(); process.BeginOutputReadLine(); return process;
}
private async Task<Dictionary<string, byte[]>> GenerateSlates(string language, BroadcastTracks tracks)
{
await slateGate.WaitAsync(lifetime.Token);
var folder = Path.Combine(root, "slates-" + language + "-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(folder);
var profile = LosslessSlateProfile.Create(tracks) ?? throw new InvalidOperationException("No lossless status profile");
var seconds = profile.SegmentSeconds.ToString("0.########", CultureInfo.InvariantCulture);
var font = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
if (!File.Exists(font)) throw new FileNotFoundException("Continuity slates require a Unicode DejaVu Sans font");
var clips = new Dictionary<string, byte[]>();
foreach (var phase in new[] { "waiting", "retrying" })
{
await File.WriteAllTextAsync(Path.Combine(folder, "message.txt"), messages.Text(language, "slate_" + phase), lifetime.Token);
var size = Math.Max(12, profile.Height / 28);
var filter = $"setsar={profile.Aspect},drawtext=fontfile={font}:text=TV:fontsize={size}:fontcolor=0x58bfee:x=(w-tw)/2:y=h*0.25," +
$"drawtext=fontfile={font}:textfile=message.txt:fontsize={size}:fontcolor=white:line_spacing={size / 2}:x=(w-tw)/2:y=h*0.45";
using var process = Start(new[] { "-f", "lavfi", "-i", $"color=c=0x101723:s={profile.Width}x{profile.Height}:r={profile.RateArgument}", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-vf", filter,
"-map", "0:v:0" }.Concat(Enumerable.Range(0, tracks.AudioCount).SelectMany(_ => new[] { "-map", "1:a:0" })).Concat(new[] { "-t", (profile.SegmentSeconds * 3).ToString("0.########", CultureInfo.InvariantCulture) }).Concat(SlateCodecArguments(profile)).Concat(tracks.MetadataArguments(audioVideoOnly: true)).Concat(new[] { "-f", "hls", "-hls_time", seconds, "-hls_list_size", "0", "-hls_segment_filename", "slate-%d.ts", "slate.m3u8" }), folder);
try { await process.WaitForExitAsync(lifetime.Token).WaitAsync(TimeSpan.FromSeconds(20), lifetime.Token); }
finally { if (!process.HasExited) process.Kill(entireProcessTree: true); }
if (process.ExitCode != 0) throw new IOException("FFmpeg slate generation failed");
// Use an interior HLS segment: it has neither AAC encoder priming
// nor the final padding of a newly encoded standalone file.
clips[phase] = BroadcastSlateProgram.Apply(await File.ReadAllBytesAsync(Path.Combine(folder, "slate-1.ts"), lifetime.Token), tracks);
foreach (var file in Directory.GetFiles(folder, "slate*")) File.Delete(file);
}
return clips;
}
finally { try { if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); } finally { slateGate.Release(); } }
}
private sealed class Source
{
private readonly ContinuityStreams owner;
private readonly CancellationTokenSource lifetime;
private readonly Task task;
public readonly string Url, Key;
public readonly BroadcastTracks Tracks;
public readonly bool UseSlates;
public TaskCompletionSource Ready { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public ContinuityTimeline Timeline { get; }
public volatile bool Retrying;
public Source(ContinuityStreams owner, string url, string key, BroadcastTracks tracks, bool useSlates)
{ this.owner = owner; Url = url; Key = key; Tracks = tracks; UseSlates = useSlates; Timeline = new(DateTime.UtcNow, useSlates, LosslessSlateProfile.Create(tracks)?.SegmentSeconds ?? ContinuityTimeline.SegmentSeconds); lifetime = CancellationTokenSource.CreateLinkedTokenSource(owner.lifetime.Token); task = Task.Run(Run); }
public void Stop() { try { lifetime.Cancel(); } catch (ObjectDisposedException) { } }
private async Task Run()
{
var failures = 0;
try
{
while (!lifetime.IsCancellationRequested)
{
var epoch = Guid.NewGuid().ToString("N"); var folder = Path.Combine(owner.root, "source-" + epoch);
var delivered = false;
try
{
Directory.CreateDirectory(folder);
var args = new[] { "-rw_timeout", (StartupBudget.StallSeconds * 1000000L).ToString(CultureInfo.InvariantCulture), "-analyzeduration", "5000000", "-probesize", "4000000", "-i", Url }
.Concat(Tracks.MapArguments()).Concat(new[] { "-dn" });
args = args.Concat(new[] { "-c", "copy" });
// MPEG-TS segmenter preserves DVB/teletext subtitles, unlike the HLS muxer's WebVTT-only subtitle output.
args = args.Concat(Tracks.MetadataArguments()).Concat(new[] { "-max_interleave_delta", "1000000", "-f", "segment", "-segment_format", "mpegts", "-segment_time", "2.24",
"-segment_list", "live.m3u8", "-segment_list_type", "m3u8", "-segment_list_size", "6", "-segment_list_flags", "+live", "segment-%09d.ts" });
Interlocked.Increment(ref owner.attempts);
using var process = owner.Start(args, folder);
try
{
var highest = -1; // Stream copy has no encoder-priming segment to discard.
var lastOutput = DateTime.UtcNow;
while (!process.HasExited && !lifetime.IsCancellationRequested)
{
var playlist = Path.Combine(folder, "live.m3u8");
if (File.Exists(playlist))
{
var lines = await File.ReadAllLinesAsync(playlist, lifetime.Token); double duration = 0;
foreach (var line in lines)
{
if (line.StartsWith("#EXTINF:", StringComparison.Ordinal)) double.TryParse(line.Substring(8).TrimEnd(','), NumberStyles.Float, CultureInfo.InvariantCulture, out duration);
var match = Regex.Match(line, @"^segment-(\d{9})\.ts$");
if (!match.Success || !int.TryParse(match.Groups[1].Value, out var index) || index <= highest) continue;
var path = Path.Combine(folder, line);
if (!File.Exists(path) || new FileInfo(path).Length > 32 * 1024 * 1024) continue;
Timeline.Live(await File.ReadAllBytesAsync(path, lifetime.Token), epoch, duration, DateTime.UtcNow);
highest = index;
foreach (var obsolete in Directory.EnumerateFiles(folder, "segment-*.ts"))
if (int.TryParse(Path.GetFileNameWithoutExtension(obsolete).AsSpan(8), out var number) && number < highest - 14) File.Delete(obsolete);
lastOutput = DateTime.UtcNow; delivered = true; Ready.TrySetResult(); Retrying = false; failures = 0; Interlocked.Increment(ref owner.liveSegments);
}
}
if ((DateTime.UtcNow - lastOutput).TotalSeconds > (delivered ? StartupBudget.StallSeconds + 5 : StartupBudget.Seconds)) throw new IOException("No usable encoded segments");
await Task.Delay(250, lifetime.Token);
}
}
finally
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
if (!lifetime.IsCancellationRequested && process.ExitCode != 0) owner.logger.LogWarning("[Enigma2] Continuity producer exited with code {ExitCode}; retrying while retaining the viewer stream", process.ExitCode);
}
}
catch (Exception error) when (!lifetime.IsCancellationRequested)
{ owner.logger.LogWarning("[Enigma2] Continuity producer retry ({FailureType}); keeping the viewer stream open", error.GetType().Name); }
finally { try { if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); } catch (IOException) { owner.logger.LogWarning("[Enigma2] Could not remove an inactive continuity cache directory"); } }
Retrying = true; failures++;
await Task.Delay(TimeSpan.FromSeconds(Math.Min(10, 3 * failures)), lifetime.Token);
}
}
catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { }
catch (Exception error) { owner.logger.LogError("[Enigma2] Continuity worker stopped ({FailureType})", error.GetType().Name); }
finally { lifetime.Dispose(); }
}
}
}
[ApiController]
[Route("DecoderIntegration/Continuity")]
public sealed class ContinuityController : ControllerBase
{
private readonly ContinuityStreams streams;
public ContinuityController(ContinuityStreams streams) { this.streams = streams; }
private bool Local => HttpContext.Connection.RemoteIpAddress is { } remote
&& (IPAddress.IsLoopback(remote) || remote.IsIPv4MappedToIPv6 && IPAddress.IsLoopback(remote.MapToIPv4()))
&& !Request.Headers.ContainsKey("Forwarded") && !Request.Headers.ContainsKey("X-Forwarded-For") && !Request.Headers.ContainsKey("X-Real-IP");
[HttpGet("{id}/index.m3u8")]
[AllowAnonymous]
public IActionResult Playlist(string id)
{
if (!Local) return StatusCode(403);
Response.Headers.CacheControl = "no-store";
return streams.Playlist(id) is { } text ? Content(text, "application/vnd.apple.mpegurl") : NotFound();
}
[HttpGet("{id}/{sequence:long}.ts")]
[AllowAnonymous]
public IActionResult Segment(string id, long sequence)
{
if (!Local) return StatusCode(403);
Response.Headers.CacheControl = "no-store";
return streams.Segment(id, sequence) is { } bytes ? File(bytes, "video/mp2t") : NotFound();
}
}
@@ -1,79 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Bounded, never-ending HLS timeline shared by language-specific viewers.</summary>
public sealed class ContinuityTimeline
{
// 112 frames at 50 Hz == 105 AAC frames at 48 kHz: exact AV boundary.
public const double SegmentSeconds = 2.24;
public sealed record Segment(long Sequence, long DiscontinuitiesBefore, bool Discontinuity, double Duration, byte[] Video, string Slate, long Start90k, long? ClockOffset90k = null);
private readonly double slateSeconds;
private readonly List<Segment> segments = new();
private readonly object gate = new();
private long next, discontinuities;
private long clock90k = 126000;
private string previousEpoch;
private string clockEpoch;
private long sourceOffset90k, sourceWrap90k, lastDecode90k;
private DateTime lastAppend, lastLive;
public ContinuityTimeline(DateTime now, bool withSlates = true, double slateSeconds = SegmentSeconds) { this.slateSeconds = slateSeconds; lastAppend = now; lastLive = DateTime.MinValue; if (!withSlates) return; Add(null, "waiting", "slate", slateSeconds, now); Add(null, "waiting", "slate", slateSeconds, now); }
private void Add(byte[] video, string slate, string epoch, double duration, DateTime now, long? offset = null, long? start = null, long? end = null)
{
var discontinuity = next > 0 && (slate != null || previousEpoch != epoch);
segments.Add(new(next++, discontinuities, discontinuity, duration, video, slate, start ?? clock90k, offset));
clock90k = end ?? clock90k + (long)Math.Round(duration * 90000);
if (discontinuity) discontinuities++;
previousEpoch = epoch; lastAppend = now;
while (segments.Count > 8) segments.RemoveAt(0);
}
public void Live(byte[] video, string epoch, double duration, DateTime now)
{
if (video == null || video.Length == 0 || video.Length > 32 * 1024 * 1024 || duration <= 0 || duration > 30) throw new ArgumentException("Invalid live segment");
lock (gate)
{
lastLive = now;
var bounds = TransportStreamClock.ClockBounds(video);
if (bounds == null) { Add(video, null, epoch, duration, now); return; }
var times = bounds.Value;
// Keep one offset for an entire producer epoch. Anchoring each segment
// to its first PTS corrupts DTS order when copied video has B-frames.
if (clockEpoch != epoch || previousEpoch != epoch)
{ clockEpoch = epoch; sourceWrap90k = 0; sourceOffset90k = clock90k + 9000 - times.Earliest; }
else if (times.Decode + sourceWrap90k < lastDecode90k - (1L << 32)) sourceWrap90k += 1L << 33;
lastDecode90k = times.Decode + sourceWrap90k;
var offset = sourceOffset90k + sourceWrap90k;
var end = Math.Max(times.Decode + (long)Math.Round(duration * 90000), times.Latest + 9000) + offset;
Add(video, null, epoch, duration, now, offset, times.Decode + offset, end);
}
}
public void Tick(DateTime now, bool retrying)
{
lock (gate)
if ((now - lastAppend).TotalSeconds >= 2 && (now - lastLive).TotalSeconds >= 5)
Add(null, retrying ? "retrying" : "waiting", "slate", slateSeconds, now);
}
public Segment Find(long sequence) { lock (gate) return segments.FirstOrDefault(s => s.Sequence == sequence); }
public string Playlist()
{
lock (gate)
{
var window = segments.TakeLast(4).ToArray();
var text = new StringBuilder($"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:{Math.Max(5, Math.Ceiling(window.Select(s => s.Duration).DefaultIfEmpty(5).Max()))}\n");
if (window.Length == 0) return text.ToString();
text.Append("#EXT-X-MEDIA-SEQUENCE:").Append(window[0].Sequence).Append('\n');
text.Append("#EXT-X-DISCONTINUITY-SEQUENCE:").Append(window[0].DiscontinuitiesBefore).Append('\n');
foreach (var segment in window)
{
if (segment.Discontinuity) text.Append("#EXT-X-DISCONTINUITY\n");
text.Append("#EXTINF:").Append(segment.Duration.ToString("0.000000", CultureInfo.InvariantCulture)).Append(",\n");
text.Append(segment.Sequence).Append(".ts\n");
}
return text.ToString(); // Never ENDLIST: only the viewer closes playback.
}
}
}
@@ -1,66 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Keep DVB page/region/object versions distinct across independently muxed status and live clips.</summary>
public sealed class DvbSubtitleVersions
{
private readonly HashSet<int> pids;
private readonly Dictionary<(int Pid, int Type, int Id), int> versions = new();
private readonly Dictionary<long, List<(int Position, byte Value)>> plans = new();
public DvbSubtitleVersions(BroadcastTracks tracks) => pids = tracks.Output.Select((s, i) => (s.Codec, Pid: 256 + i)).Where(s => s.Codec == "DVBSUB").Select(s => s.Pid).ToHashSet();
public void Apply(byte[] transport, long sequence)
{
if (pids.Count == 0) return;
if (plans.TryGetValue(sequence, out var known)) { foreach (var edit in known) transport[edit.Position] = edit.Value; return; }
var edits = new List<(int, byte)>();
var packets = new Dictionary<int, List<int>>();
void Flush(int pid, List<int> positions)
{
if (positions.Count < 14) return;
byte At(int index) => transport[positions[index]];
if (At(0) != 0 || At(1) != 0 || At(2) != 1 || At(3) != 189) return;
var length = (At(4) << 8) | At(5);
var end = length > 0 ? Math.Min(positions.Count, length + 6) : positions.Count;
var p = 9 + At(8);
if (p + 2 > end || At(p) != 32) return; p += 2;
while (p + 6 <= end && At(p) == 15)
{
var type = At(p + 1); var size = (At(p + 4) << 8) | At(p + 5); var data = p + 6;
if (data + size > end) break;
var field = type switch { 16 => 1, 17 => 1, 18 => 1, 19 => 2, 20 => 0, _ => -1 };
if (field >= 0 && size > field)
{
var id = type switch { 17 or 18 => At(data), 19 => (At(data) << 8) | At(data + 1), _ => 0 };
var key = (pid, (int)type, id);
if (!versions.ContainsKey(key) && versions.Count >= 4096) throw new InvalidDataException("Too many DVB subtitle objects");
versions.TryGetValue(key, out var next); versions[key] = next + 1;
var index = positions[data + field]; var value = (byte)((transport[index] & 15) | ((next & 15) << 4));
transport[index] = value; edits.Add((index, value));
}
p = data + size;
}
}
for (var start = 0; start + 188 <= transport.Length; start += 188)
{
var pid = ((transport[start + 1] & 31) << 8) | transport[start + 2]; if (!pids.Contains(pid)) continue;
if ((transport[start + 3] & 16) == 0) continue;
var payload = start + 4 + ((transport[start + 3] & 32) != 0 ? transport[start + 4] + 1 : 0);
if (payload >= start + 188) continue;
if ((transport[start + 1] & 64) != 0)
{
if (packets.TryGetValue(pid, out var previous)) Flush(pid, previous);
packets[pid] = new();
}
if (!packets.TryGetValue(pid, out var positions)) continue;
if (positions.Count + 188 > 512 * 1024) throw new InvalidDataException("Oversized DVB subtitle PES");
for (var p = payload; p < start + 188; p++) positions.Add(p);
}
foreach (var packet in packets) Flush(packet.Key, packet.Value);
plans[sequence] = edits;
foreach (var old in plans.Keys.Order().Take(Math.Max(0, plans.Count - 8)).ToArray()) plans.Remove(old);
}
}
@@ -1,71 +0,0 @@
using System;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Enigma2.Helpers
{
public static class ChannelHelper
{
public static ChannelType GetChannelType(string channelType)
{
var type = new ChannelType();
if (channelType == "0x1")
{
type = ChannelType.TV;
}
else if (channelType == "0xa")
{
type = ChannelType.Radio;
}
return type;
}
}
public static class UtilsHelper
{
public static void DebugInformation(ILogger<LiveTvService> logger, string message)
{
var config = Plugin.Instance.Configuration;
var enableDebugLogging = config.EnableDebugLogging;
if (enableDebugLogging)
{
logger.LogDebug(message);
}
}
}
public static class RecordingHelper
{
}
public static class ApiHelper
{
private static readonly DateTime UnixEpoch =
new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
public static long GetCurrentUnixTimestampMillis()
{
return (long)(DateTime.UtcNow - UnixEpoch).TotalMilliseconds;
}
public static DateTime DateTimeFromUnixTimestampMillis(long millis)
{
return UnixEpoch.AddMilliseconds(millis);
}
public static long GetCurrentUnixTimestampSeconds(DateTime date)
{
return (long)(date - UnixEpoch).TotalSeconds;
}
public static DateTime DateTimeFromUnixTimestampSeconds(long seconds)
{
return UnixEpoch.AddSeconds(seconds);
}
}
}
-96
View File
@@ -1,96 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using MediaBrowser.Controller.LiveTv;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Enrich receiver EIT without replacing its timer identifiers or schedule.</summary>
public sealed class InternetEpg
{
public static InternetEpg Instance { get; private set; }
private readonly IHttpClientFactory factory;
private readonly ILogger<InternetEpg> logger;
private readonly SemaphoreSlim gate = new(1);
private Dictionary<string, List<Entry>> entries = new();
private Dictionary<string, string[]> aliases = new();
private DateTime nextAttempt;
public DateTime? LastSuccess { get; private set; }
public int MatchedImages { get; private set; }
public string LastError { get; private set; }
public record Entry(string Title, DateTime Start, string Image);
public InternetEpg(IHttpClientFactory factory, ILogger<InternetEpg> logger)
{ this.factory = factory; this.logger = logger; Instance = this; }
public static string Normalize(string text) => Regex.Replace((text ?? "").ToLowerInvariant(), @"[^\p{L}\p{N}]", "");
public static string ChannelKey(string name) => Normalize(Regex.Replace(name ?? "", @"\s+(?:FHD|UHD|HD|SD)$", "", RegexOptions.IgnoreCase));
public static Entry Match(IEnumerable<Entry> candidates, string title, DateTime start)
{
var matches = candidates.Where(e => Normalize(e.Title) == Normalize(title) && Math.Abs((e.Start-start).TotalMinutes) <= 7).ToArray();
return matches.Length == 1 ? matches[0] : null;
}
public static bool AllowedImage(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == "https" && uri.Port == 443 && string.IsNullOrEmpty(uri.UserInfo) &&
(uri.Host == "ptv-prod.wphcdn.cloud" || uri.Host == "media.teleman.pl");
private async Task Refresh(CancellationToken token)
{
if (DateTime.UtcNow < nextAttempt) return;
await gate.WaitAsync(token).ConfigureAwait(false);
try
{
if (DateTime.UtcNow < nextAttempt) return;
nextAttempt = DateTime.UtcNow.AddMinutes(15);
using var client = factory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(90);
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 Jellyfin-Enigma2/7.0");
using var response = await client.GetAsync("https://epg.polishteam.one/guide.xml", HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength > 134217728) throw new InvalidOperationException("EPG too large");
using var stream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
using var reader = XmlReader.Create(stream, new XmlReaderSettings { Async=true, DtdProcessing=DtdProcessing.Prohibit, XmlResolver=null, MaxCharactersInDocument=134217728 });
var doc = await XDocument.LoadAsync(reader, LoadOptions.None, token).ConfigureAwait(false);
var newAliases = doc.Root.Elements("channel").SelectMany(c => c.Elements("display-name").Select(n => (Key:ChannelKey(n.Value), Id:(string)c.Attribute("id"))))
.Where(a => !string.IsNullOrEmpty(a.Id)).GroupBy(a=>a.Key).ToDictionary(g=>g.Key,g=>g.Select(a=>a.Id).Distinct().ToArray());
var newEntries = new Dictionary<string,List<Entry>>();
foreach (var programme in doc.Root.Elements("programme"))
{
var image = (string)programme.Element("icon")?.Attribute("src");
var raw = (string)programme.Attribute("start");
var id = (string)programme.Attribute("channel");
if (!AllowedImage(image) || id == null || raw == null) continue;
if (!DateTimeOffset.TryParseExact(raw, "yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start))
{
if (raw.Length != 20 || !DateTimeOffset.TryParseExact(raw.Insert(18, ":"), "yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture, DateTimeStyles.None, out start)) continue;
}
if (!newEntries.TryGetValue(id, out var list)) newEntries[id] = list = new();
list.Add(new Entry(programme.Element("title")?.Value ?? "", start.UtcDateTime, image));
}
if (newEntries.Count == 0) throw new InvalidOperationException("EPG has no supported images");
entries = newEntries; aliases = newAliases; LastSuccess = DateTime.UtcNow; LastError = null;
nextAttempt = DateTime.UtcNow.AddHours(6);
logger.LogInformation("Internet EPG refreshed: {Channels} channels with programme images", entries.Count);
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; }
catch (Exception ex) { LastError=ex.GetType().Name; logger.LogWarning("Internet EPG unavailable ({Type}); receiver EPG retained", LastError); }
finally { gate.Release(); }
}
public async Task EnrichAsync(string channelName, IEnumerable<ProgramInfo> programmes, CancellationToken token)
{
await Refresh(token).ConfigureAwait(false);
if (LastSuccess == null || DateTime.UtcNow - LastSuccess > TimeSpan.FromHours(48)) return;
if (!aliases.TryGetValue(ChannelKey(channelName), out var ids) || ids.Length != 1 || !entries.TryGetValue(ids[0], out var candidates)) return;
foreach (var programme in programmes)
{
var match = Match(candidates, programme.Name, programme.StartDate);
if (match == null) continue;
programme.ImagePath = null; programme.ImageUrl = match.Image; programme.HasImage = true;
MatchedImages++;
}
}
}
@@ -1,34 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AssemblyVersion>7.0.15.0</AssemblyVersion>
<FileVersion>7.0.15.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0|AnyCPU'">
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Common" Version="12.2.0" />
<PackageReference Include="Jellyfin.Naming" Version="12.2.0" />
<PackageReference Include="Jellyfin.Controller" Version="12.2.0" />
<PackageReference Include="Jellyfin.Model" Version="12.2.0" />
</ItemGroup>
<ItemGroup><FrameworkReference Include="Microsoft.AspNetCore.App" /><EmbeddedResource Include="Configuration/channels.html" /><EmbeddedResource Include="Localization/startup-messages.json" /></ItemGroup>
</Project>
-408
View File
@@ -1,408 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>One bounded metadata probe per source, shared by concurrent playback requests.</summary>
public sealed class LiveStreamProbe
{
private readonly Func<string, CancellationToken, Task<string>> runner;
private sealed class ProbeEntry
{
public readonly TaskCompletionSource<List<MediaStream>> Completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public readonly CancellationTokenSource Stop = new();
public DateTime Until = DateTime.MaxValue;
public int Waiters;
public bool Abandoned;
public Task<List<MediaStream>> Task => Completion.Task;
}
private readonly Dictionary<string, ProbeEntry> cache = new();
private readonly object gate = new();
private readonly Dictionary<string, (List<MediaStream> Streams, DateTime At)> lastKnown = new();
private readonly SemaphoreSlim slots = new(8);
private readonly Func<TimeSpan> timeout;
public string LastError { get; private set; }
public LiveStreamProbe(IMediaEncoder encoder) : this((url, token) => Run(encoder.ProbePath, url, token))
{ timeout = () => TimeSpan.FromSeconds(StartupBudget.Seconds); }
public LiveStreamProbe(Func<string, CancellationToken, Task<string>> 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<string, int?, bool, CancellationToken, Task<string>> fileProbe, SamplingLimits limits = null, TimeSpan? timeout = null, bool patient = false, TimeSpan? retryDelay = null)
: this((url, token) => patient ? Patient(() => CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), token, retryDelay)
: 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));
}
}
// 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)
{
if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture);
caller.ThrowIfCancellationRequested();
ProbeEntry entry;
lock (gate)
{
if (cache.TryGetValue(url, out var existing) && existing.Until > DateTime.UtcNow && !existing.Abandoned && !existing.Stop.IsCancellationRequested) entry = existing;
else
{
foreach (var expired in cache.Where(x => x.Value.Until <= DateTime.UtcNow).Select(x => x.Key).ToArray())
{ cache[expired].Stop.Dispose(); cache.Remove(expired); }
entry = new(); cache[url] = entry;
_ = Produce(url, entry);
}
entry.Waiters++;
}
return AwaitShared(entry, caller);
}
private async Task<List<MediaStream>> AwaitShared(ProbeEntry entry, CancellationToken caller)
{
try { return await entry.Task.WaitAsync(caller); }
finally
{
bool stop;
lock (gate) { entry.Waiters--; stop = entry.Waiters == 0 && !entry.Task.IsCompleted; if (stop) entry.Abandoned = true; }
if (stop) entry.Stop.Cancel(); // Last viewer left; do not occupy a tuner for the remaining budget.
}
}
public List<MediaStream> LastKnown(string url, int? programme = null)
{
if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture);
lock (gate) return lastKnown.TryGetValue(url, out var value) && DateTime.UtcNow - value.At <= TimeSpan.FromHours(6) ? value.Streams.ToList() : null;
}
private async Task Produce(string url, ProbeEntry entry)
{
var success = false;
var completion = entry.Completion;
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(entry.Stop.Token);
deadline.CancelAfter(timeout());
try
{
await slots.WaitAsync(deadline.Token);
try
{
var streams = Parse(await runner(url, deadline.Token), Programme(url));
lock (gate)
{
foreach (var stale in lastKnown.Where(x => DateTime.UtcNow - x.Value.At > TimeSpan.FromHours(6)).Select(x => x.Key).ToArray()) lastKnown.Remove(stale);
if (lastKnown.Count >= 256 && !lastKnown.ContainsKey(url)) lastKnown.Remove(lastKnown.OrderBy(x => x.Value.At).First().Key);
lastKnown[url] = (streams, DateTime.UtcNow);
}
completion.TrySetResult(streams); LastError = null; success = true;
}
finally { slots.Release(); }
}
catch (Exception) when (entry.Stop.IsCancellationRequested) { completion.TrySetCanceled(entry.Stop.Token); }
catch (Exception error) { LastError = error.Message; completion.TrySetException(new InvalidOperationException("Decoder returned no usable live stream within the probe deadline.", error)); }
finally { lock (gate) { entry.Until = DateTime.UtcNow.AddSeconds(success ? 30 : 3); if (entry.Stop.IsCancellationRequested && cache.TryGetValue(url, out var current) && ReferenceEquals(current, entry)) cache.Remove(url); } }
}
private static async Task<string> Run(string executable, string url, CancellationToken token)
{
using var client = new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3) }) { Timeout = Timeout.InfiniteTimeSpan };
Task<string> Sample() => CaptureAndProbe(client, (sample, programme, extended, cancellation) => RunFile(executable, sample, programme, extended, cancellation), SamplingLimits.Default, url, token);
// Production always uses the local shared relay. Reopening its HTTP subscription
// keeps the same receiver connection and a fresh bounded sample window.
return Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.IsLoopback && uri.AbsolutePath.StartsWith("/DecoderIntegration/Live/", StringComparison.Ordinal)
? await Patient(Sample, token) : await Sample();
}
private static async Task<string> Patient(Func<Task<string>> sample, CancellationToken token, TimeSpan? retryDelay = null)
{
Exception last = null; var windows = 0;
try
{
while (true)
{
token.ThrowIfCancellationRequested(); windows++;
try { return await sample(); }
catch (InvalidOperationException error) when (new[] { "video_parameters_unavailable", "missing_expected_programme", "missing_video", "capture_http_failure", "capture_empty", "probe_process_failed" }.Any(c => error.Message.Contains("cause=" + c, StringComparison.Ordinal)))
{ last = error; }
await Task.Delay(retryDelay ?? TimeSpan.FromSeconds(1), token);
}
}
catch (OperationCanceledException) when (last != null)
{ throw new InvalidOperationException($"Decoder startup budget exhausted after {windows} sample windows; last: {last.Message}", last); }
}
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<string> CaptureAndProbe(HttpClient client, Func<string, int?, bool, CancellationToken, Task<string>> 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 response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
response.EnsureSuccessStatusCode();
using var input = await response.Content.ReadAsStreamAsync(token);
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()
{
var buffer = new byte[188 * 256];
try
{
while (capturedBytes < limits.MaximumBytes)
{
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<bool> 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;
try
{
json = await fileProbe(samplePath, programme, false, token);
analysisReturned = true;
}
catch (ProbeProcessException) { cause = "initial_probe_process_failed"; }
if (analysisReturned)
{
try { Parse(json, programme); return json; }
catch (Exception error) when (error is InvalidOperationException || error is JsonException)
{
cause = "initial_video_unavailable";
}
}
}
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) { }
}
}
catch (Exception error)
{
cause = error is OperationCanceledException ? "deadline"
: error is IOException ? "capture_io_failure"
: error is HttpRequestException ? "capture_http_failure"
: error is ProbeProcessException ? "probe_process_failed"
: attempts > 0 && !analysisReturned ? "probe_analysis_failure"
: attempts > 0 ? ProbeCause(json, programme) : capturedBytes == 0 ? "capture_empty" : "capture_unavailable";
throw new InvalidOperationException($"Decoder probe failed: cause={cause}; expectedProgramme={programme?.ToString(CultureInfo.InvariantCulture) ?? "none"}; {ProbeSummary(json)}; sampleBytes={sampleBytes}; capturedBytes={capturedBytes}; elapsedMs={watch.ElapsedMilliseconds}; attempts={attempts}; captureStop={captureStop}.");
}
finally
{
if (File.Exists(samplePath)) File.Delete(samplePath);
if (File.Exists(capturePath)) File.Delete(capturePath);
}
}
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<string>();
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<string>();
return $"programmes=[{string.Join(',', programmes)}]; AV=[{string.Join(',', streams)}]";
}
catch (Exception) { return "programmes=[]; AV=[]"; }
}
private static async Task<string> 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", extended ? "20000000" : "3000000", "-probesize", extended ? "25165824" : "4194304", "-i", sample, "-show_streams", "-show_programs", "-show_data", "-of", "json" }) process.StartInfo.ArgumentList.Add(argument);
if (programme != null) { process.StartInfo.ArgumentList.Add("-select_streams"); process.StartInfo.ArgumentList.Add("p:" + programme.Value.ToString(CultureInfo.InvariantCulture)); }
process.Start();
var output = process.StandardOutput.ReadToEndAsync();
var errors = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(token);
await errors;
if (process.ExitCode != 0) throw new ProbeProcessException(process.ExitCode);
return await output;
}
finally
{
if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); }
await Task.WhenAll(output, errors);
}
}
public static List<MediaStream> Parse(string json, int? programme = null)
{
using var document = JsonDocument.Parse(json);
var result = new List<MediaStream>();
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;
var type = Text("codec_type");
if (type != "video" && type != "audio" && type != "subtitle") continue;
var codec = Text("codec_name"); var index = Number("index");
if (string.IsNullOrEmpty(codec) || index == null) continue;
float? Rate(string field)
{
var values = (Text(field) ?? "").Split('/');
if (!float.TryParse(values[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var numerator)) return null;
if (values.Length == 1) return numerator;
return float.TryParse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator) && denominator != 0 ? numerator / denominator : null;
}
var stream = new BroadcastMediaStream { SampleAspectRatio = Text("sample_aspect_ratio"), FrameRateRational = Text("avg_frame_rate"), TransportId = BroadcastTracks.TransportId(Text("id")), SubtitleExtraData = BroadcastTracks.ExtraData(Text("extradata")), Type = type == "video" ? MediaStreamType.Video : type == "audio" ? MediaStreamType.Audio : MediaStreamType.Subtitle, Index = index.Value, Codec = BroadcastTracks.JellyfinCodec(codec), Width = Number("width"), Height = Number("height"), Channels = Number("channels"), SampleRate = Number("sample_rate"), BitRate = Number("bit_rate"), Profile = Text("profile"), Level = Number("level"), PixelFormat = Text("pix_fmt"), ColorRange = Text("color_range"), ColorSpace = Text("color_space"), ColorTransfer = Text("color_transfer"), ColorPrimaries = Text("color_primaries"), AverageFrameRate = Rate("avg_frame_rate"), RealFrameRate = Rate("r_frame_rate"), IsInterlaced = Text("field_order") is "tt" or "bb" or "tb" or "bt", ChannelLayout = Text("channel_layout") };
if (item.TryGetProperty("tags", out var tags) && tags.TryGetProperty("language", out var language)) stream.Language = language.GetString();
if (item.TryGetProperty("tags", out var labels))
{
if (labels.TryGetProperty("title", out var title)) stream.Title = title.GetString();
else if (labels.TryGetProperty("handler_name", out var handler)) stream.Title = handler.GetString();
}
if (item.TryGetProperty("disposition", out var disposition))
{
bool Flag(string key) => disposition.TryGetProperty(key, out var flag) && flag.TryGetInt32(out var value) && value != 0;
stream.IsDefault = Flag("default"); stream.IsForced = Flag("forced");
stream.IsHearingImpaired = Flag("hearing_impaired"); stream.IsVisualImpaired = Flag("visual_impaired"); stream.IsOriginal = Flag("original");
}
result.Add(stream);
}
if (!result.Any(s => s.Type == MediaStreamType.Video && s.Width > 0 && s.Height > 0)) throw new InvalidOperationException("Decoder stream has no identified video.");
return result;
}
public static MediaStream ParseLiveMetadata(MediaStream s) => new MediaStream
{
Index = -1, Type = s.Type, Codec = s.Codec, Width = s.Width, Height = s.Height,
Channels = s.Channels, SampleRate = s.SampleRate, BitRate = s.BitRate,
Profile = s.Profile, Level = s.Level, PixelFormat = s.PixelFormat,
ColorRange = s.ColorRange, ColorSpace = s.ColorSpace, ColorTransfer = s.ColorTransfer,
ColorPrimaries = s.ColorPrimaries, AverageFrameRate = s.AverageFrameRate,
RealFrameRate = s.RealFrameRate, IsInterlaced = s.IsInterlaced,
ChannelLayout = s.ChannelLayout, Language = s.Language, Title = s.Title, IsDefault = s.IsDefault, IsForced = s.IsForced, IsHearingImpaired = s.IsHearingImpaired, IsOriginal = s.IsOriginal
};
}
-211
View File
@@ -1,211 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using MediaBrowser.Controller;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Shares one receiver HTTP connection between probing and all consumers of a channel.</summary>
public sealed class LiveStreamRelay : IDisposable
{
private readonly HttpClient client;
private readonly int port;
private readonly Dictionary<string, Source> sources = new();
private readonly Dictionary<string, Source> tokens = new();
private readonly object gate = new();
private int opened;
private readonly TimeSpan idleGrace;
private Func<TimeSpan> startupBudget, readBudget;
private TimeSpan startupTimeout => startupBudget();
private TimeSpan readTimeout => readBudget();
public LiveStreamRelay(IServerApplicationHost host) : this(new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3), AllowAutoRedirect = false }), host.HttpPort) { startupBudget = () => TimeSpan.FromSeconds(StartupBudget.Seconds); readBudget = () => TimeSpan.FromSeconds(StartupBudget.StallSeconds); }
public LiveStreamRelay(HttpClient client, int port, TimeSpan? idleGrace = null, TimeSpan? startupTimeout = null, TimeSpan? readTimeout = null)
{
startupBudget = () => startupTimeout ?? TimeSpan.FromSeconds(20);
readBudget = () => readTimeout ?? TimeSpan.FromSeconds(8);
if (this.startupTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(startupTimeout));
if (this.readTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(readTimeout));
this.client = client; client.Timeout = Timeout.InfiniteTimeSpan; this.port = port; this.idleGrace = idleGrace ?? TimeSpan.FromSeconds(15);
}
public string Register(string url)
{
lock (gate)
{
if (!sources.TryGetValue(url, out var source))
{
source = new Source(this, url);
sources.Add(url, source); tokens.Add(source.Id, source);
}
return $"http://127.0.0.1:{port}/DecoderIntegration/Live/{source.Id}";
}
}
public Task<Subscription> Subscribe(string id, CancellationToken token)
{
Source source;
lock (gate) if (!tokens.TryGetValue(id, out source)) throw new FileNotFoundException();
return source.Subscribe(token);
}
public object Status()
{
lock (gate) return new { UpstreamConnectionsOpened = opened, ActiveSources = sources.Values.Count(s => s.Active), Consumers = sources.Values.Sum(s => s.Consumers), LastErrors = sources.Values.Select(s => s.LastError).Where(s => s != null).ToArray(), Channels = sources.Values.Select(s => new { s.Id, s.ServiceReference, s.Active, s.Consumers }).ToArray() };
}
public void Dispose() { lock (gate) foreach (var s in sources.Values) s.Stop(); client.Dispose(); }
public sealed class Subscription : IAsyncDisposable
{
private readonly Source source;
internal readonly Channel<byte[]> Buffer = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(128) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true });
internal Subscription(Source source) { this.source = source; }
public ChannelReader<byte[]> Reader => Buffer.Reader;
public ValueTask DisposeAsync() { source.Remove(this); return ValueTask.CompletedTask; }
}
internal sealed class Source
{
private readonly LiveStreamRelay owner;
private readonly string url;
private readonly object gate = new();
private readonly HashSet<Subscription> subscribers = new();
private readonly Queue<byte[]> history = new();
private int historyBytes;
private CancellationTokenSource lifetime;
private CancellationTokenSource idle;
private Task pump;
private TaskCompletionSource ready;
private DateTime failedUntil;
public string Id { get; } = Guid.NewGuid().ToString("N");
public string ServiceReference => new Uri(url).AbsolutePath;
public string LastError { get; private set; }
public bool Active { get { lock (gate) return pump != null && !pump.IsCompleted; } }
public int Consumers { get { lock (gate) return subscribers.Count; } }
public Source(LiveStreamRelay owner, string url) { this.owner = owner; this.url = url; }
public async Task<Subscription> Subscribe(CancellationToken token)
{
Subscription subscription; Task initial;
lock (gate)
{
idle?.Cancel(); idle?.Dispose(); idle = null;
if (subscribers.Count >= 16) throw new IOException("Channel consumer limit reached.");
if (pump == null || pump.IsCompleted)
{
if (DateTime.UtcNow < failedUntil) throw new IOException("Decoder source is unavailable.");
lifetime?.Dispose(); lifetime = new();
history.Clear(); historyBytes = 0;
ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
pump = Task.Run(() => Pump(lifetime.Token));
}
subscription = new(this); subscribers.Add(subscription);
foreach (var chunk in history) subscription.Buffer.Writer.TryWrite(chunk);
initial = ready.Task;
}
try { await initial.WaitAsync(owner.startupTimeout + TimeSpan.FromSeconds(1), token); return subscription; }
catch { await subscription.DisposeAsync(); throw; }
}
public void Remove(Subscription subscription)
{
lock (gate)
{
if (!subscribers.Remove(subscription)) return;
subscription.Buffer.Writer.TryComplete();
if (subscribers.Count == 0 && pump != null && !pump.IsCompleted)
{
idle?.Cancel(); idle?.Dispose(); idle = new();
var timer = idle.Token;
var current = lifetime;
_ = Task.Run(async () => {
try { await Task.Delay(owner.idleGrace, timer); lock (gate) if (subscribers.Count == 0 && lifetime == current) current.Cancel(); }
catch (OperationCanceledException) { }
});
}
}
}
public void Stop() { lock (gate) { idle?.Cancel(); lifetime?.Cancel(); } }
private async Task Pump(CancellationToken token)
{
Exception failure = null;
try
{
// Headers and the first MPEG-TS chunk share one startup deadline.
// A slow tuner may accept HTTP before it starts delivering packets.
using var opening = CancellationTokenSource.CreateLinkedTokenSource(token); opening.CancelAfter(owner.startupTimeout);
Interlocked.Increment(ref owner.opened);
using var response = await owner.client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, opening.Token);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(token);
var buffer = new byte[188 * 256 + 188]; var carry = 0; var started = false;
while (true)
{
using var reading = CancellationTokenSource.CreateLinkedTokenSource(token);
if (started) reading.CancelAfter(owner.readTimeout);
var count = await stream.ReadAtLeastAsync(buffer.AsMemory(carry, buffer.Length - carry), 188 * 256 - carry, throwOnEndOfStream: false, cancellationToken: started ? reading.Token : opening.Token);
if (count == 0) throw new IOException("Decoder closed the live stream.");
count += carry; var bytes = count / 188 * 188;
if (bytes > 0)
{
if (buffer[0] != 0x47 || (bytes >= 376 && buffer[188] != 0x47)) throw new IOException("Decoder did not return MPEG-TS packets.");
started = true;
var chunk = buffer.AsSpan(0, bytes).ToArray();
lock (gate)
{
history.Enqueue(chunk); historyBytes += bytes;
while (historyBytes > 2 * 1024 * 1024) historyBytes -= history.Dequeue().Length;
foreach (var subscriber in subscribers.ToArray())
if (!subscriber.Buffer.Writer.TryWrite(chunk)) { subscriber.Buffer.Writer.TryComplete(new IOException("Slow live stream consumer.")); Remove(subscriber); }
ready.TrySetResult();
}
}
carry = count - bytes;
if (carry > 0) System.Buffer.BlockCopy(buffer, bytes, buffer, 0, carry);
}
}
catch (Exception error) { failure = error; }
finally
{
lock (gate)
{
LastError = token.IsCancellationRequested ? null : failure?.Message;
if (!token.IsCancellationRequested) failedUntil = DateTime.UtcNow.AddSeconds(3);
ready.TrySetException(new IOException("Decoder stream unavailable.", failure));
foreach (var subscriber in subscribers) subscriber.Buffer.Writer.TryComplete(failure);
subscribers.Clear(); history.Clear(); historyBytes = 0;
}
}
}
}
}
[ApiController]
[Route("DecoderIntegration/Live")]
public sealed class LiveRelayController : ControllerBase
{
private readonly LiveStreamRelay relay;
public LiveRelayController(LiveStreamRelay relay) { this.relay = relay; }
[HttpGet("{id}")]
[AllowAnonymous]
public async Task Stream(string id, CancellationToken token)
{
var remote = HttpContext.Connection.RemoteIpAddress;
if (remote == null || !(IPAddress.IsLoopback(remote) || (remote.IsIPv4MappedToIPv6 && IPAddress.IsLoopback(remote.MapToIPv4())))) { Response.StatusCode = 403; return; }
try
{
await using var subscription = await relay.Subscribe(id, token);
Response.ContentType = "video/mp2t"; Response.Headers.CacheControl = "no-store";
await foreach (var chunk in subscription.Reader.ReadAllAsync(token))
{ await Response.Body.WriteAsync(chunk, token); await Response.Body.FlushAsync(token); }
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { }
catch (Exception)
{
if (Response.HasStarted) HttpContext.Abort();
else Response.StatusCode = 502;
}
}
}
@@ -1,128 +0,0 @@
{
"en": {
"header": "Television",
"preparing": "Preparing channel…",
"checking_stream": "Waiting for video and audio…",
"waiting_for_stream": "Still waiting for a usable stream…",
"ready": "Stream detected. Starting player…",
"failed": "Unable to prepare the stream.",
"buffering": "Buffering video…",
"help": "The receiver may need several seconds. You can cancel or press Esc.",
"cancel": "Stop and return to channels",
"elapsed": "Waiting: {seconds} s",
"timeout": "The channel is not providing video. Returning to the channel list.",
"play_error": "Unable to start the channel.",
"slate_waiting": "Waiting for the stream…\nThe receiver is preparing video and audio.",
"slate_failed": "Unable to receive the channel.\nReturn to the list and try again.",
"native_preparing": "Starting channel. The receiver may need several seconds.",
"native_waiting": "Still waiting for usable video and audio. Press Back to return.",
"native_ready": "Stream detected. Starting player.",
"native_failed": "Unable to prepare the stream. Return to channels and try again.",
"slate_retrying": "The stream was interrupted.\nRetrying channel reception…"
},
"pl": {
"header": "Telewizja",
"preparing": "Przygotowywanie kanału…",
"checking_stream": "Oczekiwanie na obraz i dźwięk…",
"waiting_for_stream": "Nadal czekamy na użyteczny strumień…",
"ready": "Strumień rozpoznany. Uruchamianie odtwarzacza…",
"failed": "Nie udało się przygotować strumienia.",
"buffering": "Buforowanie obrazu…",
"help": "Dekoder może potrzebować kilkunastu sekund. Możesz anulować lub nacisnąć Esc.",
"cancel": "Zatrzymaj i wróć do kanałów",
"elapsed": "Czas oczekiwania: {seconds} s",
"timeout": "Kanał nie przesyła obrazu. Powrót do listy kanałów.",
"play_error": "Nie udało się uruchomić kanału.",
"slate_waiting": "Oczekiwanie na strumień…\nDekoder przygotowuje obraz i dźwięk.",
"slate_failed": "Nie udało się odebrać kanału.\nWróć do listy i spróbuj ponownie.",
"native_preparing": "Uruchamianie kanału. Dekoder może potrzebować kilkunastu sekund.",
"native_waiting": "Nadal czekamy na użyteczny obraz i dźwięk. Możesz wrócić przyciskiem Wstecz.",
"native_ready": "Strumień rozpoznany. Uruchamianie odtwarzacza.",
"native_failed": "Nie udało się przygotować strumienia. Wróć do kanałów i spróbuj ponownie.",
"slate_retrying": "Strumień został przerwany.\nPonawianie odbioru kanału…"
},
"de": {
"header": "Fernsehen",
"preparing": "Sender wird vorbereitet…",
"checking_stream": "Warten auf Bild und Ton…",
"waiting_for_stream": "Weiterhin warten auf einen nutzbaren Stream…",
"ready": "Stream erkannt. Player wird gestartet…",
"failed": "Der Stream konnte nicht vorbereitet werden.",
"buffering": "Video wird gepuffert…",
"help": "Der Receiver benötigt möglicherweise einige Sekunden. Abbrechen oder Esc drücken.",
"cancel": "Stoppen und zur Senderliste zurückkehren",
"elapsed": "Wartezeit: {seconds} s",
"timeout": "Der Sender liefert kein Bild. Zurück zur Senderliste.",
"play_error": "Der Sender konnte nicht gestartet werden.",
"slate_waiting": "Warten auf den Stream…\nDer Receiver bereitet Bild und Ton vor.",
"slate_failed": "Senderempfang nicht möglich.\nZur Liste zurückkehren und erneut versuchen.",
"native_preparing": "Sender wird gestartet. Der Receiver benötigt möglicherweise einige Sekunden.",
"native_waiting": "Weiterhin warten auf nutzbares Bild und Ton. Mit Zurück zur Liste wechseln.",
"native_ready": "Stream erkannt. Player wird gestartet.",
"native_failed": "Der Stream konnte nicht vorbereitet werden. Zur Senderliste zurückkehren und erneut versuchen.",
"slate_retrying": "Der Stream wurde unterbrochen.\nSenderempfang wird erneut versucht…"
},
"fr": {
"header": "Télévision",
"preparing": "Préparation de la chaîne…",
"checking_stream": "En attente de l’image et du son…",
"waiting_for_stream": "Toujours en attente d’un flux utilisable…",
"ready": "Flux détecté. Démarrage du lecteur…",
"failed": "Impossible de préparer le flux.",
"buffering": "Mise en mémoire tampon…",
"help": "Le décodeur peut avoir besoin de quelques secondes. Vous pouvez annuler ou appuyer sur Échap.",
"cancel": "Arrêter et revenir aux chaînes",
"elapsed": "Attente : {seconds} s",
"timeout": "La chaîne ne fournit pas d’image. Retour à la liste des chaînes.",
"play_error": "Impossible de démarrer la chaîne.",
"slate_waiting": "En attente du flux…\nLe décodeur prépare l’image et le son.",
"slate_failed": "Impossible de recevoir la chaîne.\nRevenez à la liste et réessayez.",
"native_preparing": "Démarrage de la chaîne. Le décodeur peut avoir besoin de quelques secondes.",
"native_waiting": "Toujours en attente de l’image et du son. Appuyez sur Retour pour revenir.",
"native_ready": "Flux détecté. Démarrage du lecteur.",
"native_failed": "Impossible de préparer le flux. Revenez aux chaînes et réessayez.",
"slate_retrying": "Le flux a été interrompu.\nNouvelle tentative de réception…"
},
"es": {
"header": "Televisión",
"preparing": "Preparando el canal…",
"checking_stream": "Esperando imagen y sonido…",
"waiting_for_stream": "Seguimos esperando un flujo válido…",
"ready": "Flujo detectado. Iniciando el reproductor…",
"failed": "No se pudo preparar el flujo.",
"buffering": "Cargando vídeo…",
"help": "El receptor puede necesitar varios segundos. Puedes cancelar o pulsar Esc.",
"cancel": "Detener y volver a los canales",
"elapsed": "Espera: {seconds} s",
"timeout": "El canal no proporciona imagen. Volviendo a la lista de canales.",
"play_error": "No se pudo iniciar el canal.",
"slate_waiting": "Esperando el flujo…\nEl receptor está preparando imagen y sonido.",
"slate_failed": "No se pudo recibir el canal.\nVuelve a la lista e inténtalo de nuevo.",
"native_preparing": "Iniciando el canal. El receptor puede necesitar varios segundos.",
"native_waiting": "Seguimos esperando imagen y sonido. Pulsa Atrás para volver.",
"native_ready": "Flujo detectado. Iniciando el reproductor.",
"native_failed": "No se pudo preparar el flujo. Vuelve a los canales e inténtalo de nuevo.",
"slate_retrying": "El flujo se ha interrumpido.\nReintentando la recepción del canal…"
},
"uk": {
"header": "Телебачення",
"preparing": "Підготовка каналу…",
"checking_stream": "Очікування зображення та звуку…",
"waiting_for_stream": "Триває очікування придатного потоку…",
"ready": "Потік виявлено. Запуск програвача…",
"failed": "Не вдалося підготувати потік.",
"buffering": "Буферизація відео…",
"help": "Приймачу може знадобитися кілька секунд. Можна скасувати або натиснути Esc.",
"cancel": "Зупинити та повернутися до каналів",
"elapsed": "Очікування: {seconds} с",
"timeout": "Канал не передає зображення. Повернення до списку каналів.",
"play_error": "Не вдалося запустити канал.",
"slate_waiting": "Очікування потоку…\nПриймач готує зображення та звук.",
"slate_failed": "Не вдалося прийняти канал.\nПоверніться до списку та спробуйте ще раз.",
"native_preparing": "Запуск каналу. Приймачу може знадобитися кілька секунд.",
"native_waiting": "Триває очікування зображення та звуку. Натисніть Назад, щоб повернутися.",
"native_ready": "Потік виявлено. Запуск програвача.",
"native_failed": "Не вдалося підготувати потік. Поверніться до каналів та спробуйте ще раз.",
"slate_retrying": "Потік перервано.\nПовторна спроба приймання каналу…"
}
}
@@ -1,48 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Only the status picture is encoded. Unsupported programs stay entirely in stream-copy mode.</summary>
public sealed class LosslessSlateProfile
{
public int Width { get; private init; }
public int Height { get; private init; }
public double Rate { get; private init; }
public string RateArgument { get; private init; }
public string Aspect { get; private init; }
public string Profile { get; private init; }
public int Gop { get; private init; }
public double SegmentSeconds => Gop / Rate;
public static LosslessSlateProfile Create(BroadcastTracks tracks)
{
var video = tracks.Input[0];
// A guessed codec/layout is unsafe during cold-start outages. Never convert the channel to fit a slate.
if (video.Codec != "h264" || video.PixelFormat != "yuv420p" || video.IsInterlaced
|| video.Width is not (> 0 and <= 16384) || video.Height is not (> 0 and <= 16384)
|| video.Width % 2 != 0 || video.Height % 2 != 0
|| video.ColorTransfer is "smpte2084" or "arib-std-b67"
|| tracks.AudioCount == 0 || tracks.AudioCount > 8
|| tracks.Input.Where(s => s.Type == MediaStreamType.Audio).Any(s => s.Codec != "aac" || s.Profile != "LC" || s.Channels != 2 || s.SampleRate != 48000)) return null;
var profile = video.Profile switch { "Constrained Baseline" or "Baseline" => "baseline", "Main" => "main", "High" => "high", _ => null };
var rate = (double)(video.AverageFrameRate ?? video.RealFrameRate ?? 0);
var aspect = (video as BroadcastMediaStream)?.SampleAspectRatio;
if (profile == null || !double.IsFinite(rate) || rate <= 0 || rate > 240
|| aspect == null || !Regex.IsMatch(aspect, @"^[1-9][0-9]{0,5}:[1-9][0-9]{0,5}$")) return null;
var rational = (video as BroadcastMediaStream)?.FrameRateRational;
if (rational == null || !Regex.IsMatch(rational, @"^[1-9][0-9]{0,8}/[1-9][0-9]{0,8}$"))
rational = rate.ToString("0.########", CultureInfo.InvariantCulture);
// Repeating a cached clip must end on complete video AND AAC frames.
// If that needs a long clip (e.g. 23.976/59.94), retain the original
// broadcast without a status clip rather than rounding its cadence.
var gop = Enumerable.Range(Math.Max(1, (int)(rate * 1.5)), Math.Max(1, (int)(rate * 1.5) + 2))
.Where(n => Math.Abs(n / rate * 48000 / 1024 - Math.Round(n / rate * 48000 / 1024)) < 0.00001)
.OrderBy(n => Math.Abs(n / rate - ContinuityTimeline.SegmentSeconds)).FirstOrDefault();
if (gop == 0) return null;
return new() { Width = video.Width.Value, Height = video.Height.Value, Rate = rate,
RateArgument = rational, Aspect = aspect.Replace(':', '/'), Profile = profile, Gop = gop };
}
}
@@ -1,67 +0,0 @@
using System;
using System.Linq;
using System.Text.Json.Nodes;
using System.IO;
using MediaBrowser.Common.Configuration;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Apply presentation preferences after the native API has enforced access rights.</summary>
public sealed class NativeChannelFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
{
app.Use(async (context, continuePipeline) =>
{
if (!HttpMethods.IsGet(context.Request.Method) ||
!context.Request.Path.Value.TrimEnd('/').Equals("/LiveTv/Channels",StringComparison.OrdinalIgnoreCase))
{ await continuePipeline(); return; }
var originalQuery=context.Request.QueryString;
var originalEncoding=context.Request.Headers.AcceptEncoding;
// Buffer JSON before applying preferences; compression must not run inside this buffer.
context.Request.Headers.Remove("Accept-Encoding");
var parsed=QueryHelpers.ParseQuery(originalQuery.Value ?? "");
int Number(string name,int fallback) => parsed.TryGetValue(name,out var value) && int.TryParse(value,out var number) && number>=0 ? number : fallback;
var start=Number("StartIndex",0);var limit=Number("Limit",int.MaxValue);
// Fetch the permission-filtered full result, then paginate after preferences.
context.Request.QueryString=QueryString.Create(parsed.Where(p=>!p.Key.Equals("StartIndex",StringComparison.OrdinalIgnoreCase)&&!p.Key.Equals("Limit",StringComparison.OrdinalIgnoreCase)));
var originalBody=context.Response.Body;
await using var buffer=new MemoryStream();context.Response.Body=buffer;
try
{
await continuePipeline();
context.Response.Body=originalBody;
if (context.Response.StatusCode==200 && context.Response.ContentType?.StartsWith("application/json",StringComparison.OrdinalIgnoreCase)==true)
{
buffer.Position=0;
var document=await JsonNode.ParseAsync(buffer,cancellationToken:context.RequestAborted);
if(document?["Items"] is JsonArray items)
{
Guid.TryParse(context.User.FindFirst("Jellyfin-UserId")?.Value,out var userId);
if(userId==Guid.Empty && parsed.TryGetValue("UserId",out var requested)&&Guid.TryParse(requested,out var requestedId)) userId=requestedId;
var preferences=userId==Guid.Empty?new ChannelPreferences():await context.RequestServices.GetRequiredService<PreferencesStore>().Load(userId,context.RequestAborted);
var hidden=preferences.HiddenIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
var includeHidden=parsed.TryGetValue("DecoderIncludeHidden",out var includeValue)&&bool.TryParse(includeValue,out var include)&&include;
var order=preferences.OrderedIds.Select((id,index)=>(id,index)).ToDictionary(x=>x.id,x=>x.index,StringComparer.OrdinalIgnoreCase);
string Id(JsonNode node) => Guid.TryParse((string)node?["Id"],out var id)?id.ToString("N"):"";
var filtered=items.Where(item=>includeHidden||!hidden.Contains(Id(item))).OrderBy(item=>order.TryGetValue(Id(item),out var position)?position:int.MaxValue).ToArray();
document["TotalRecordCount"]=filtered.Length;
document["StartIndex"]=start;
document["Items"]=new JsonArray(filtered.Skip(start).Take(limit).Select(item=>item.DeepClone()).ToArray());
context.Response.ContentLength=null;
await context.Response.WriteAsync(document.ToJsonString(),context.RequestAborted);
return;
}
}
buffer.Position=0;await buffer.CopyToAsync(originalBody,context.RequestAborted);
}
finally { context.Response.Body=originalBody;context.Request.QueryString=originalQuery;context.Request.Headers.AcceptEncoding=originalEncoding; }
});
next(app);
};
}
-170
View File
@@ -1,170 +0,0 @@
using Jellyfin.Data;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Per-user/device startup feedback; no tuner connections or stream URLs.</summary>
public sealed class PlaybackFeedback
{
private readonly IHttpContextAccessor contexts;
private readonly IAuthorizationContext authorization;
private readonly ISessionManager sessions;
private readonly StartupMessages messages;
private readonly object gate = new();
private readonly Dictionary<(Guid User, string Device, string Reference), Attempt> attempts = new();
public PlaybackFeedback(IHttpContextAccessor contexts, IAuthorizationContext authorization, ISessionManager sessions, StartupMessages messages = null)
{ this.contexts = contexts; this.authorization = authorization; this.sessions = sessions; this.messages = messages ?? new StartupMessages(); }
public async Task<Attempt> Begin(string reference, CancellationToken token)
{
// Scheduled recordings/background probes have no requesting viewer.
// Never guess a destination or broadcast to all sessions of a user.
var context = contexts.HttpContext;
if (context == null) return null;
AuthorizationInfo auth;
try { auth = await authorization.GetAuthorizationInfo(context).WaitAsync(TimeSpan.FromSeconds(1), token); }
catch (Exception error) when (error is not OperationCanceledException || !token.IsCancellationRequested) { return null; }
if (auth.UserId == Guid.Empty || string.IsNullOrEmpty(auth.DeviceId) || auth.IsApiKey
|| auth.User == null || !auth.User.HasPermission(PermissionKind.EnableLiveTvAccess)) return null;
string destination = null;
var matches = sessions.Sessions.Where(s => s.UserId == auth.UserId && s.DeviceId == auth.DeviceId && s.IsActive).Take(2).ToArray();
if ((Plugin.Instance?.Configuration?.ShowNativeStartupMessages ?? true) && matches.Length == 1 && IsAndroidTv(matches[0].Client)
&& matches[0].SupportedCommands?.Any(c => c == GeneralCommandType.DisplayMessage) == true) destination = matches[0].Id;
var key = (auth.UserId, auth.DeviceId, reference);
var language = messages.Resolve(auth.UserId, messages.ClientLanguage(auth.UserId, auth.DeviceId), context.Request.Headers.AcceptLanguage.ToString());
var attempt = new Attempt(this, key, destination, language, token);
lock (gate)
{
foreach (var expired in attempts.Where(x => DateTime.UtcNow - x.Value.UpdatedUtc > TimeSpan.FromSeconds(StartupBudget.WebSeconds + 15)).Select(x => x.Key).ToArray()) attempts.Remove(expired);
if (attempts.Count >= 512 && !attempts.ContainsKey(key)) attempts.Remove(attempts.OrderBy(x => x.Value.UpdatedUtc).First().Key);
attempts[key] = attempt;
}
attempt.Report("preparing");
return attempt;
}
public static bool IsAndroidTv(string client) => (client ?? "").Replace(" ", "").Contains("AndroidTV", StringComparison.OrdinalIgnoreCase);
public object Snapshot(Guid user, string device, string reference)
{
lock (gate)
{
if (!attempts.TryGetValue((user, device ?? "", reference), out var attempt) || DateTime.UtcNow - attempt.UpdatedUtc > TimeSpan.FromSeconds(StartupBudget.WebSeconds + 15))
return new { Available = false, Phase = "preparing" };
return new { Available = true, attempt.Phase, attempt.StartedUtc, attempt.UpdatedUtc };
}
}
public async Task<T> AwaitStream<T>(Task<T> stream, Attempt attempt, CancellationToken token)
{
if (attempt == null) return await stream;
attempt.Report("checking_stream");
if (await Task.WhenAny(stream, Task.Delay(TimeSpan.FromSeconds(6), token)) != stream)
{
token.ThrowIfCancellationRequested();
attempt.Report("waiting_for_stream");
}
return await stream;
}
private bool Current(Attempt attempt)
{ lock (gate) return attempts.TryGetValue(attempt.Key, out var current) && ReferenceEquals(current, attempt); }
public sealed class Attempt
{
private readonly PlaybackFeedback owner;
internal readonly (Guid User, string Device, string Reference) Key;
private readonly string destination;
private readonly string language;
public string Language => language;
private readonly CancellationToken token;
private Task notifications = Task.CompletedTask;
private int notificationCount;
public DateTime StartedUtc { get; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; private set; } = DateTime.UtcNow;
public string Phase { get; private set; } = "preparing";
internal Attempt(PlaybackFeedback owner, (Guid, string, string) key, string destination, string language, CancellationToken token)
{ this.owner = owner; Key = key; this.destination = destination; this.language = language; this.token = token; }
public void Report(string phase)
{
lock (owner.gate)
{
if (!owner.Current(this)) return;
Phase = phase; UpdatedUtc = DateTime.UtcNow;
var message = phase switch
{
"preparing" => owner.messages.Text(language, "native_preparing"),
"waiting_for_stream" => owner.messages.Text(language, "native_waiting"),
"ready" => owner.messages.Text(language, "native_ready"),
"failed" => owner.messages.Text(language, "native_failed"),
_ => null
};
if (destination == null || message == null || token.IsCancellationRequested || notificationCount >= 3) return;
notificationCount++;
notifications = notifications.ContinueWith(async _ =>
{
if (token.IsCancellationRequested || !owner.Current(this)) return;
// Sending status must never hold up or fail actual playback.
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(token);
deadline.CancelAfter(TimeSpan.FromMilliseconds(500));
try
{
var target = owner.sessions.Sessions.FirstOrDefault(s => s.Id == destination && s.UserId == Key.User && s.DeviceId == Key.Device && s.IsActive);
if (target != null) await owner.sessions.SendMessageCommand(destination, destination,
new MessageCommand { Header = owner.messages.Text(language, "header"), Text = message, TimeoutMs = 3000 }, deadline.Token).WaitAsync(deadline.Token);
}
catch { /* Optional feedback cannot break the channel. */ }
}, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default).Unwrap();
}
}
}
}
[ApiController]
[Route("DecoderIntegration/PlaybackProgress")]
[Authorize(Policy = "LiveTvAccess")]
public sealed class PlaybackFeedbackController : ControllerBase
{
private readonly PlaybackFeedback feedback;
private readonly ILibraryManager library;
private readonly IUserManager users;
private readonly IAuthorizationContext authorization;
private readonly StartupMessages messages;
public PlaybackFeedbackController(PlaybackFeedback feedback, ILibraryManager library, IUserManager users, IAuthorizationContext authorization, StartupMessages messages = null)
{ this.feedback = feedback; this.library = library; this.users = users; this.authorization = authorization; this.messages = messages ?? new StartupMessages(); }
[HttpGet("/DecoderIntegration/StartupMessages")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<IActionResult> Messages([FromQuery] string language = null)
{
if (!Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value, out var id) || users.GetUserById(id) is not { } user) return Unauthorized();
if (!user.HasPermission(PermissionKind.EnableLiveTvAccess)) return Forbid();
var selected = messages.Resolve(id, language, Request.Headers.AcceptLanguage.ToString());
var auth = await authorization.GetAuthorizationInfo(HttpContext);
if (auth.UserId == id && !auth.IsApiKey) messages.RememberClientLanguage(id, auth.DeviceId, language);
return Ok(new { Language = selected, Texts = messages.Texts(selected), StartupTimeoutSeconds = StartupBudget.WebSeconds });
}
[HttpGet]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<IActionResult> Get([FromQuery] Guid channelId)
{
if (!Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value, out var id)) return Unauthorized();
var user = users.GetUserById(id);
if (user == null) return Unauthorized();
if (!user.HasPermission(PermissionKind.EnableLiveTvAccess)) return Forbid();
var item = channelId == Guid.Empty ? null : library.GetItemById<BaseItem>(channelId, user);
if (item is not LiveTvChannel channel || !string.Equals(channel.ServiceName, "Enigma2", StringComparison.OrdinalIgnoreCase)) return NotFound();
var auth = await authorization.GetAuthorizationInfo(HttpContext);
if (auth.UserId != id || string.IsNullOrEmpty(auth.DeviceId)) return Unauthorized();
return Ok(feedback.Snapshot(id, auth.DeviceId, channel.ExternalId));
}
}
-58
View File
@@ -1,58 +0,0 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using Jellyfin.Plugin.Enigma2.Configuration;
namespace Jellyfin.Plugin.Enigma2
{
/// <summary>
/// Class Plugin
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
public Plugin(
IApplicationPaths applicationPaths,
IXmlSerializer xmlSerializer
) : base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <summary>
/// Gets the name of the plugin
/// </summary>
/// <value>The name.</value>
public override string Name => "Enigma2";
/// <summary>
/// Gets the description.
/// </summary>
/// <value>The description.</value>
public override string Description => "Provides live TV using Enigma2 PVR as a back-end.";
private Guid _id = new Guid("193f29f9-ea6c-4595-a6f6-55e79d7c590a");
public override Guid Id => _id;
/// <summary>
/// Gets the instance.
/// </summary>
/// <value>The instance.</value>
public static Plugin Instance { get; private set; }
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = "enigma2",
EmbeddedResourcePath = "Jellyfin.Plugin.Enigma2.Configuration.configPage.html"
},
new PluginPageInfo { Name = "decoder-channels", DisplayName = "Telewizja", EmbeddedResourcePath = "Jellyfin.Plugin.Enigma2.Configuration.channels.html" }
};
}
}
}
@@ -1,120 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.Enigma2;
public sealed class WebPlaybackPolicy
{
public bool IsEnigmaChannel { get; set; }
public bool PreferH264 { get; set; }
public static WebPlaybackPolicy For(BaseItem item, bool enabled)
{
var owned = item is LiveTvChannel && string.Equals(item.ServiceName, "Enigma2", StringComparison.OrdinalIgnoreCase);
return new() { IsEnigmaChannel = owned, PreferH264 = owned && enabled };
}
}
public sealed class ChannelPreferences
{
public string[] FavoriteIds { get; set; } = Array.Empty<string>();
public string[] HiddenIds { get; set; } = Array.Empty<string>();
public string[] OrderedIds { get; set; } = Array.Empty<string>();
public string DefaultView { get; set; } = "all";
public void Validate()
{
string[] Clean(string[] values) => (values ?? Array.Empty<string>()).Take(2000).Where(v=>Guid.TryParse(v,out _)).Select(v=>Guid.Parse(v).ToString("N")).Distinct().ToArray();
FavoriteIds=Clean(FavoriteIds); HiddenIds=Clean(HiddenIds); OrderedIds=Clean(OrderedIds);
if (DefaultView != "all" && DefaultView != "favorites" && DefaultView != "guide") DefaultView="all";
}
}
public sealed class PreferencesStore
{
private readonly string folder;
private readonly ConcurrentDictionary<Guid,SemaphoreSlim> locks = new();
public PreferencesStore(IApplicationPaths paths) { folder=Path.Combine(paths.DataPath,"decoder-integration","users"); }
public async Task<ChannelPreferences> Load(Guid user, CancellationToken token)
{
var gate=locks.GetOrAdd(user,_=>new(1)); await gate.WaitAsync(token);
try { var file=Path.Combine(folder,user.ToString("N")+".json"); if (!File.Exists(file)) return new();
var result=JsonSerializer.Deserialize<ChannelPreferences>(await File.ReadAllTextAsync(file,token)) ?? new(); result.Validate(); return result; }
finally { gate.Release(); }
}
public async Task Save(Guid user, ChannelPreferences value, CancellationToken token)
{
value.Validate(); var gate=locks.GetOrAdd(user,_=>new(1)); await gate.WaitAsync(token);
try { Directory.CreateDirectory(folder); var file=Path.Combine(folder,user.ToString("N")+".json"); var temp=file+".tmp";
await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(value),token); File.Move(temp,file,true); }
finally { gate.Release(); }
}
public async Task Remap(Guid user, System.Collections.Generic.IReadOnlyDictionary<string,string> mapping, CancellationToken token)
{
var gate=locks.GetOrAdd(user,_=>new(1)); await gate.WaitAsync(token);
try
{
var file=Path.Combine(folder,user.ToString("N")+".json");
if(!File.Exists(file))return;
var value=JsonSerializer.Deserialize<ChannelPreferences>(await File.ReadAllTextAsync(file,token))??new();value.Validate();
var before=JsonSerializer.Serialize(value);
string[] Map(string[] ids)=>ids.Select(id=>mapping.TryGetValue(id,out var target)?target:id).Distinct().ToArray();
value.FavoriteIds=Map(value.FavoriteIds);value.HiddenIds=Map(value.HiddenIds);value.OrderedIds=Map(value.OrderedIds);
var after=JsonSerializer.Serialize(value);
if(before==after)return;
await File.WriteAllTextAsync(file+".tmp",after,token);File.Move(file+".tmp",file,true);
}
finally { gate.Release(); }
}
}
[ApiController]
[Route("DecoderIntegration")]
[Authorize(Policy="LiveTvAccess")]
public sealed class PreferencesController : ControllerBase
{
private readonly PreferencesStore store;
private readonly InternetEpg epg;
private readonly LiveStreamRelay relay;
private readonly LiveStreamProbe probe;
private readonly ILibraryManager library;
private readonly IUserManager users;
private readonly ChannelSyncTask channelSync;
public PreferencesController(PreferencesStore store,InternetEpg epg,LiveStreamRelay relay,LiveStreamProbe probe,ILibraryManager library,IUserManager users,ChannelSyncTask channelSync=null) { this.store=store; this.epg=epg; this.relay=relay; this.probe=probe; this.library=library; this.users=users; this.channelSync=channelSync; }
private Guid UserId => Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value,out var id) ? id : Guid.Empty;
[HttpGet("Channels")]
[AllowAnonymous]
public IActionResult Page()
{
var stream=typeof(Plugin).Assembly.GetManifestResourceStream("Jellyfin.Plugin.Enigma2.Configuration.channels.html");
return File(stream,"text/html; charset=utf-8");
}
[HttpGet("Preferences")]
public async Task<ActionResult<ChannelPreferences>> Get(CancellationToken token) => UserId == Guid.Empty ? Unauthorized() : Ok(await store.Load(UserId,token));
[HttpPut("Preferences")]
public async Task<IActionResult> Put(ChannelPreferences value,CancellationToken token)
{ if (UserId == Guid.Empty) return Unauthorized(); await store.Save(UserId,value,token); return NoContent(); }
[HttpGet("WebPlaybackPolicy")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public ActionResult<WebPlaybackPolicy> GetWebPlaybackPolicy([FromQuery] Guid channelId)
{
// Resolve the authenticated user explicitly: the native library lookup with a
// null user would skip visibility checks. Never accept a userId from the query.
if (UserId == Guid.Empty) return Unauthorized();
var user = users.GetUserById(UserId);
if (user == null) return Unauthorized();
if (!user.HasPermission(PermissionKind.EnableLiveTvAccess)) return Forbid();
var item = channelId == Guid.Empty ? null : library.GetItemById<BaseItem>(channelId, user);
return WebPlaybackPolicy.For(item, Plugin.Instance?.Configuration?.PreferH264ForWebLiveTv ?? false);
}
[HttpGet("Status")]
[Authorize(Policy="RequiresElevation")]
public IActionResult Status() => Ok(new { epg.LastSuccess,epg.MatchedImages,epg.LastError, Source="https://epg.polishteam.one/guide.xml", Streams=relay.Status(), ProbeError=probe.LastError, ChannelSync=channelSync==null?null:new {channelSync.LastCheckedUtc,channelSync.LastSynchronizedUtc,channelSync.LastError} });
}
@@ -1,47 +0,0 @@
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller;
using Microsoft.Extensions.DependencyInjection;
using System.Net.Http;
using Jellyfin.Plugin.Enigma2.Helpers;
using MediaBrowser.Common.Configuration;
using System.IO;
using System.Linq;
using System.Xml;
using System.Xml.Linq;
namespace Jellyfin.Plugin.Enigma2
{
public class ServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<InternetEpg>();
serviceCollection.AddSingleton<LiveStreamProbe>();
serviceCollection.AddSingleton<LiveStreamRelay>();
serviceCollection.AddHttpContextAccessor();
serviceCollection.AddSingleton<StartupMessages>();
serviceCollection.AddSingleton<ContinuityStreams>();
serviceCollection.AddSingleton<PlaybackFeedback>();
serviceCollection.AddSingleton<PreferencesStore>();
serviceCollection.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, NativeChannelFilter>();
serviceCollection.AddSingleton<ILiveTvService, LiveTvService>();
serviceCollection.AddSingleton<ChannelSyncTask>();
serviceCollection.AddSingleton<MediaBrowser.Model.Tasks.IScheduledTask>(provider => provider.GetRequiredService<ChannelSyncTask>());
// Jellyfin registers services before constructing Plugin.Instance.
var paths = serviceCollection.Last(x => x.ServiceType == typeof(IApplicationPaths)).ImplementationInstance as IApplicationPaths;
if (VuRecordingsEnabled(Path.Combine(paths.PluginConfigurationsPath, "Jellyfin.Plugin.Enigma2.xml")))
serviceCollection.AddSingleton<IChannel, RecordingsChannel>();
}
public static bool VuRecordingsEnabled(string configurationPath)
{
if (!File.Exists(configurationPath)) return false;
using var reader = XmlReader.Create(configurationPath, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null });
var value = XDocument.Load(reader).Root?.Element("EnableVuRecordings")?.Value;
return bool.TryParse(value, out var enabled) && enabled;
}
}
}
-10
View File
@@ -1,10 +0,0 @@
using System;
namespace Jellyfin.Plugin.Enigma2;
public static class StartupBudget
{
public static int Seconds => Math.Clamp(Plugin.Instance?.Configuration?.StreamStartupWaitSeconds ?? 90, 25, 180);
public static int StallSeconds => Math.Clamp(Plugin.Instance?.Configuration?.StreamStallWaitSeconds ?? 30, 8, 60);
public static int WebSeconds => Seconds + 45;
}
@@ -1,81 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>One catalogue for web status, native messages and cached FFmpeg slates.</summary>
public sealed class StartupMessages
{
private static readonly Dictionary<string, Dictionary<string, string>> Catalogue = Load();
private readonly IServerConfigurationManager configuration;
private readonly IDisplayPreferencesManager preferences;
private readonly ConcurrentDictionary<(Guid User, string Device), (string Language, DateTime Seen)> clientLanguages = new();
public StartupMessages(IServerConfigurationManager configuration = null, IDisplayPreferencesManager preferences = null)
{ this.configuration = configuration; this.preferences = preferences; }
private static Dictionary<string, Dictionary<string, string>> Load()
{
using var stream = typeof(StartupMessages).Assembly.GetManifestResourceStream("Jellyfin.Plugin.Enigma2.Localization.startup-messages.json");
return JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string>>>(stream);
}
private static string Supported(string locale)
{
if (string.IsNullOrWhiteSpace(locale) || locale.Length > 64) return null;
var language = locale.Trim().Replace('_', '-').Split('-')[0].ToLowerInvariant();
return Catalogue.ContainsKey(language) ? language : null;
}
public static string ResolveLanguage(string uiLanguage, string acceptLanguage, string serverLanguage)
{
var direct = Supported(uiLanguage);
if (direct != null) return direct;
// Honour quality ordering and q=0 exclusions; headers are untrusted hints,
// never file names or a reason to change metadata/audio preferences.
if (!string.IsNullOrEmpty(acceptLanguage) && acceptLanguage.Length <= 1024)
{
var candidates = acceptLanguage.Split(',').Take(32).Select((entry, order) =>
{
var parts = entry.Trim().Split(';'); var quality = 1.0;
foreach (var part in parts.Skip(1))
if (part.Trim().StartsWith("q=", StringComparison.OrdinalIgnoreCase)
&& (!double.TryParse(part.Trim().Substring(2), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out quality)
|| quality < 0 || quality > 1)) quality = 0;
return new { Language = Supported(parts[0]), Quality = quality, Order = order };
}).Where(x => x.Language != null && x.Quality > 0).OrderByDescending(x => x.Quality).ThenBy(x => x.Order);
var match = candidates.FirstOrDefault(); if (match != null) return match.Language;
}
return Supported(serverLanguage) ?? "en";
}
public string Resolve(string uiLanguage, string acceptLanguage) => ResolveLanguage(uiLanguage, acceptLanguage, configuration?.Configuration?.UICulture);
public string Resolve(Guid user, string uiLanguage, string acceptLanguage)
{
var direct = Supported(uiLanguage);
if (direct != null) return direct;
// Jellyfin Web uses usersettings/emby display preferences. Some clients
// save language there; current Web stores it locally. Read only the
// requesting user's preference, without creating or changing it.
try
{
var custom = preferences?.ListCustomItemDisplayPreferences(user, "usersettings".GetMD5(), "emby");
if (custom != null && custom.TryGetValue("language", out var value) && Supported(value) is { } saved) return saved;
}
catch { /* An optional preference lookup must not prevent playback. */ }
return Resolve(null, acceptLanguage);
}
public void RememberClientLanguage(Guid user, string device, string language)
{
if (user == Guid.Empty || string.IsNullOrEmpty(device) || Supported(language) is not { } supported) return;
foreach (var entry in clientLanguages.Where(x => DateTime.UtcNow - x.Value.Seen > TimeSpan.FromMinutes(2)).ToArray()) clientLanguages.TryRemove(entry.Key, out _);
if (clientLanguages.Count >= 512 && !clientLanguages.ContainsKey((user, device))) return;
clientLanguages[(user, device)] = (supported, DateTime.UtcNow);
}
public string ClientLanguage(Guid user, string device) => device != null && clientLanguages.TryGetValue((user, device), out var value)
&& DateTime.UtcNow - value.Seen < TimeSpan.FromMinutes(2) ? value.Language : null;
public IReadOnlyDictionary<string, string> Texts(string language) => Catalogue[Supported(language) ?? "en"];
public string Text(string language, string key) => Texts(language).TryGetValue(key, out var value) ? value : Catalogue["en"][key];
}
@@ -1,103 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Rebase cached/live MPEG-TS segments to one presentation clock.</summary>
public static class TransportStreamClock
{
private const long ClockMask = (1L << 33) - 1;
private static int Payload(byte[] data, int start)
{
var control = (data[start + 3] >> 4) & 3;
if ((control & 1) == 0) return -1;
var payload = start + 4 + ((control & 2) != 0 ? data[start + 4] + 1 : 0);
return payload < start + 188 ? payload : -1;
}
private static long ReadPts(byte[] data, int p) => ((long)(data[p] & 14) << 29) | ((long)data[p + 1] << 22)
| ((long)(data[p + 2] & 254) << 14) | ((long)data[p + 3] << 7) | ((long)data[p + 4] >> 1);
private static void WritePts(byte[] data, int p, long pts)
{
pts &= ClockMask;
data[p] = (byte)((data[p] & 240L) | ((pts >> 29) & 14) | 1);
data[p + 1] = (byte)(pts >> 22); data[p + 2] = (byte)(((pts >> 14) & 254) | 1);
data[p + 3] = (byte)(pts >> 7); data[p + 4] = (byte)((pts << 1) | 1);
}
private static bool Pes(byte[] data, int start, int p) => p >= 0 && (data[start + 1] & 64) != 0 && p + 14 <= start + 188
&& data[p] == 0 && data[p + 1] == 0 && data[p + 2] == 1 && (data[p + 7] & 128) != 0;
public static (long Decode, long Earliest, long Latest)? ClockBounds(byte[] input)
{
if (input.Length % 188 != 0) return null;
long? decode = null;
var times = new List<long>();
for (var start = 0; start < input.Length; start += 188)
{
var p = Payload(input, start);
if (!Pes(input, start, p)) continue;
var pts = ReadPts(input, p + 9);
times.Add(pts);
var dts = (input[p + 7] & 192) == 192 && p + 19 <= start + 188 ? ReadPts(input, p + 14) : pts;
times.Add(dts);
if (decode == null && input[p + 3] >= 224 && input[p + 3] <= 239) decode = dts;
}
if (decode == null) return null;
long Unwrap(long value) => decode.Value + ((value - decode.Value + (1L << 32)) & ClockMask) - (1L << 32);
long min = decode.Value, max = decode.Value;
foreach (var time in times) { var value = Unwrap(time); min = Math.Min(min, value); max = Math.Max(max, value); }
return (decode.Value, min, max);
}
public static byte[] Rebase(byte[] input, long videoStart90k, bool alignAudio = true, long? offset90k = null)
{
if (input.Length % 188 != 0) throw new InvalidDataException("Unaligned MPEG-TS segment");
long? firstVideo = null, firstAudio = null;
for (var start = 0; start < input.Length; start += 188)
{
if (input[start] != 71) throw new InvalidDataException("Invalid MPEG-TS sync");
var p = Payload(input, start);
if (!Pes(input, start, p)) continue;
if (firstVideo == null && input[p + 3] >= 224 && input[p + 3] <= 239) firstVideo = ReadPts(input, p + 9);
if (firstAudio == null && input[p + 3] >= 192 && input[p + 3] <= 223) firstAudio = ReadPts(input, p + 9);
if (firstVideo != null && firstAudio != null) break;
}
if (firstVideo == null) throw new InvalidDataException("MPEG-TS segment lacks a video presentation timestamp");
var offset = offset90k ?? (videoStart90k - firstVideo.Value);
// Independent AAC encoders and HLS boundaries have different priming
// offsets. Anchor the first audio PES of each segment as well, so a
// switch cannot send an audio timestamp backwards by a few milliseconds.
var audioOffset = alignAudio ? videoStart90k - (firstAudio ?? firstVideo.Value) : offset;
using var output = new MemoryStream(input.Length + 188 * 6); var pids = new HashSet<int>();
for (var start = 0; start < input.Length; start += 188)
{
var packet = input.AsSpan(start, 188).ToArray(); var pid = ((packet[1] & 31) << 8) | packet[2];
if (pid != 8191 && pids.Add(pid))
{
// Each independently muxed/cached clip may reset continuity
// counters. Signal that reset in-band, including to FFmpeg's TS
// demuxer, which does not reset them solely from HLS tags.
var reset = new byte[188]; Array.Fill(reset, (byte)255);
reset[0] = 71; reset[1] = (byte)(packet[1] & 31); reset[2] = packet[2];
var counter = packet[3] & 15;
if ((packet[3] & 16) != 0) counter = (counter + 15) & 15;
reset[3] = (byte)(32 | counter); reset[4] = 183; reset[5] = 128;
output.Write(reset);
}
var p = Payload(packet, 0);
if (Pes(packet, 0, p))
{
var shift = packet[p + 3] >= 192 && packet[p + 3] <= 223 ? audioOffset : offset;
WritePts(packet, p + 9, ReadPts(packet, p + 9) + shift);
if ((packet[p + 7] & 192) == 192 && p + 19 <= 188) WritePts(packet, p + 14, ReadPts(packet, p + 14) + shift);
}
if ((packet[3] & 32) != 0 && packet[4] >= 7 && (packet[5] & 16) != 0)
{
var clock = (((long)packet[6] << 25) | ((long)packet[7] << 17) | ((long)packet[8] << 9) | ((long)packet[9] << 1) | ((long)packet[10] >> 7));
clock = (clock + offset) & ClockMask;
packet[6] = (byte)(clock >> 25); packet[7] = (byte)(clock >> 17); packet[8] = (byte)(clock >> 9); packet[9] = (byte)(clock >> 1);
packet[10] = (byte)((packet[10] & 127L) | ((clock & 1) << 7));
}
output.Write(packet);
}
return output.ToArray();
}
}
-121
View File
@@ -1,121 +0,0 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.
@@ -1,14 +1,9 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.2.32616.157
# Visual Studio 15
VisualStudioVersion = 15.0.26730.3
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Plugin.Enigma2", "Jellyfin.Plugin.Enigma2\Jellyfin.Plugin.Enigma2.csproj", "{52B1ABDF-09C1-450E-B808-BBA0A9C19848}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A968FB09-8732-407C-8748-6DB383EB5D91}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
EndProjectSection
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MediaBrowser.Plugins.VuPlus", "MediaBrowser.Plugins.VuPlus\MediaBrowser.Plugins.VuPlus.csproj", "{52B1ABDF-09C1-450E-B808-BBA0A9C19848}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -0,0 +1,48 @@
using System;
using MediaBrowser.Model.Plugins;
namespace MediaBrowser.Plugins.VuPlus.Configuration
{
/// <summary>
/// Class PluginConfiguration
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
public string HostName { get; set; }
public string StreamingPort { get; set; }
public string WebInterfacePort { get; set; }
public string WebInterfaceUsername { get; set; }
public string WebInterfacePassword { get; set; }
public Boolean UseSecureHTTPS { get; set; }
public Boolean OnlyOneBouquet { get; set; }
public string TVBouquet { get; set; }
public Boolean ZapToChannel { get; set; }
public Boolean FetchPiconsFromWebInterface { get; set; }
public string PiconsPath { get; set; }
public string RecordingPath { get; set; }
public Boolean EnableDebugLogging { get; set; }
public PluginConfiguration()
{
HostName = "http://localhost";
StreamingPort = "8001";
WebInterfacePort = "8000";
WebInterfaceUsername= "";
WebInterfacePassword = "";
UseSecureHTTPS = false;
OnlyOneBouquet = true;
TVBouquet = "Favourites (TV)";
ZapToChannel = false;
FetchPiconsFromWebInterface = true;
PiconsPath = "";
RecordingPath = "";
EnableDebugLogging = false;
}
}
}
@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html>
<head>
<title>Vu+</title>
</head>
<body>
<div data-role="page" class="page type-interior pluginConfigurationPage VuPlusConfigurationPage" data-require="emby-button,emby-checkbox,emby-input">
<div data-role="content">
<div class="content-primary">
<h1>VuPlus</h1>
<form class="VuPlusConfigurationForm">
<div class="inputContainer">
<input is="emby-input" id="txtHostName" type="text" label="Vu+ hostname or ip address:" />
<div class="fieldDescription">
The host name (address) or ip address of your receiver
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtStreamingPort" type="text" label="Vu+ streaming port:" />
<div class="fieldDescription">
The Streaming port of your receiver eg. 8001
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtWebInterfacePort" type="text" label="Vu+ Web Interface port:" />
<div class="fieldDescription">
The web Interface port of your receiver eg. 8000
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtWebInterfaceUsername" type="text" label="Vu+ Web Interface username:" />
<div class="fieldDescription">
The web Interface username of your receiver (optional)
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtWebInterfacePassword" type="text" label="Vu+ Web Interface password:" />
<div class="fieldDescription">
The web Interface password of your receiver (optional)
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkUseSecureHTTPS" />
<span>Use secure HTTP (HTTPS)</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Use HTTPS to connect to your receiver
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkOnlyOneBouquet" />
<span>Fetch Only one TV bouquet</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Limit channels to only those contained within the specified TV Bouquet below (optional)
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtTVBouquet" type="text" label="Vu+ TVBouquet:" />
<div class="fieldDescription">
The TV Bouquet to load channels for (optional)
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkZapToChannel" />
<span>Zap before Channel switch (single tuner)</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Set if only one tuner within receiver
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkFetchPiconsFromWebInterface" />
<span>Fetch picons from webinterface</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Set if you want to retrieve Picons from the web interface of the receiver
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtPiconsPath" type="text" label="Picons Path:" />
<div class="fieldDescription">
The local location of your Picons eg. C:\Picons\ (optional)
</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtRecordingPath" type="text" label="Recording Path:" />
<div class="fieldDescription">
The location to store your recordings on your receiver eg. /hdd/movie/ (optional)
</div>
</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkDebugLogging" />
<span>Enable VuPlus debug logging</span>
</label>
<div>
<button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var VuPlusConfigurationPage = {
pluginUniqueId: "9564ac10-af23-39bc-449f-624f23cfa48f"
};
$('.VuPlusConfigurationPage').on('pageshow', function (event) {
Dashboard.showLoadingMsg();
var page = this;
ApiClient.getPluginConfiguration(VuPlusConfigurationPage.pluginUniqueId).then(function (config) {
$('#txtHostName', page).val(config.HostName || "");
$('#txtStreamingPort', page).val(config.StreamingPort || "");
$('#txtWebInterfacePort', page).val(config.WebInterfacePort || "");
$('#txtWebInterfaceUsername', page).val(config.WebInterfaceUsername || "");
$('#txtWebInterfacePassword', page).val(config.WebInterfacePassword || "");
page.querySelector('#chkUseSecureHTTPS').checked = config.UseSecureHTTPS || false;
page.querySelector('#chkOnlyOneBouquet').checked = config.OnlyOneBouquet || false;
page.querySelector('#txtTVBouquet', page).val(config.TVBouquet || "");
page.querySelector('#chkZapToChannel').checked = config.ZapToChannel || false;
page.querySelector('#chkFetchPiconsFromWebInterface').checked = config.FetchPiconsFromWebInterface || false;
$('#txtPiconsPath', page).val(config.PiconsPath || "");
$('#txtRecordingPath', page).val(config.RecordingPath || "");
page.querySelector('#chkDebugLogging').checked = config.EnableDebugLogging || false;
Dashboard.hideLoadingMsg();
});
});
$('.VuPlusConfigurationForm').on('submit', function (e) {
Dashboard.showLoadingMsg();
var form = this;
ApiClient.getPluginConfiguration(VuPlusConfigurationPage.pluginUniqueId).then(function (config) {
//reset TV bouquet reference if TV Bouquet changed
//if (config.TVBouquet != $('#txtTVBouquet', form).val()) {
// config.tvBouquetSRef = '';
//}
//reset TV bouquet reference if Only 1 Bouquet not set
//if (form.querySelector('#chkOnlyOneBouquet').checked) {
//}
//else
//{
// config.tvBouquetSRef = '';
//}
config.HostName = $('#txtHostName', form).val();
config.StreamingPort = $('#txtStreamingPort', form).val();
config.WebInterfacePort = $('#txtWebInterfacePort', form).val();
config.WebInterfaceUsername = $('#txtWebInterfaceUsername', form).val();
config.WebInterfacePassword = $('#txtWebInterfacePassword', form).val();
config.UseSecureHTTPS = form.querySelector('#chkUseSecureHTTPS').checked;
config.OnlyOneBouquet = form.querySelector('#chkOnlyOneBouquet').checked;
config.TVBouquet = $('#txtTVBouquet', form).val();
config.ZapToChannel = form.querySelector('#chkZapToChannel').checked;
config.FetchPiconsFromWebInterface = form.querySelector('#chkFetchPiconsFromWebInterface').checked;
config.PiconsPath = $('#txtPiconsPath', form).val();
config.RecordingPath = $('#txtRecordingPath', form).val();
config.EnableDebugLogging = form.querySelector('#chkDebugLogging').checked;
ApiClient.updatePluginConfiguration(VuPlusConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
// Disable default form submission
return false;
});
</script>
</div>
</body>
</html>
@@ -0,0 +1,40 @@
using MediaBrowser.Model.LiveTv;
using System;
using MediaBrowser.Model.Logging;
namespace MediaBrowser.Plugins.VuPlus.Helpers
{
public static class ChannelHelper
{
public static ChannelType GetChannelType(string channelType)
{
ChannelType type = new ChannelType();
if (channelType == "0x1")
{
type = ChannelType.TV;
}
else if (channelType == "0xa")
{
type = ChannelType.Radio;
}
return type;
}
}
public static class UtilsHelper
{
public static void DebugInformation(ILogger logger, string message)
{
var config = Plugin.Instance.Configuration;
bool enableDebugLogging = config.EnableDebugLogging;
if (enableDebugLogging)
{
logger.Debug(message);
}
}
}
}

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;</TargetFrameworks>
<AssemblyVersion>3.2.1</AssemblyVersion>
<FileVersion>3.2.1</FileVersion>
</PropertyGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<None Remove="Images\thumb.png" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
<EmbeddedResource Include="Images\thumb.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="mediabrowser.server.core" Version="4.1.0.26" />
<PackageReference Include="System.Memory" Version="4.5.2" />
</ItemGroup>
</Project>
+83
View File
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Plugins.VuPlus.Configuration;
using System.IO;
using MediaBrowser.Model.Drawing;
namespace MediaBrowser.Plugins.VuPlus
{
/// <summary>
/// Class Plugin
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages, IHasThumbImage
{
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <summary>
/// Gets the name of the plugin
/// </summary>
/// <value>The name.</value>
public override string Name
{
get { return "VuPlus"; }
}
/// <summary>
/// Gets the description.
/// </summary>
/// <value>The description.</value>
public override string Description
{
get
{
return "Provides live tv using Vu+ / Enigma2 pvr as a back-end.";
}
}
private Guid _id = new Guid("9564ac10-af23-39bc-449f-624f23cfa48f");
public override Guid Id
{
get { return _id; }
}
/// <summary>
/// Gets the instance.
/// </summary>
/// <value>The instance.</value>
public static Plugin Instance { get; private set; }
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = "vuplus",
EmbeddedResourcePath = "MediaBrowser.Plugins.VuPlus.Configuration.configPage.html"
}
};
}
public Stream GetThumbImage()
{
var type = GetType();
return type.Assembly.GetManifestResourceStream(type.Namespace + ".Images.thumb.png");
}
public ImageFormat ThumbImageFormat
{
get
{
return ImageFormat.Png;
}
}
}
}
@@ -1,53 +1,110 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Channels;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using MediaBrowser.Controller.LiveTv;
using System.Linq;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Model.Dto;
using System.Globalization;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.LiveTv;
namespace Jellyfin.Plugin.Enigma2
namespace MediaBrowser.Plugins.VuPlus
{
public class RecordingsChannel : IChannel, IHasCacheKey, ISupportsDelete, ISupportsLatestMedia, ISupportsMediaProbe, IHasFolderAttributes
public class RecordingsChannel : IChannel, IHasCacheKey, ISupportsDelete, ISupportsLatestMedia, ISupportsMediaProbe, IHasFolderAttributes, IHasChangeEvent
{
private readonly ILogger<LiveTvService> _logger;
public ILiveTvManager _liveTvManager;
public RecordingsChannel(ILoggerFactory loggerFactory)
public event EventHandler ContentChanged;
private Timer _updateTimer;
public void OnContentChanged()
{
_logger = loggerFactory.CreateLogger<LiveTvService>();
_logger.LogDebug("[Enigma2] RecordingsChannel()");
if (ContentChanged != null)
{
ContentChanged(this, EventArgs.Empty);
}
}
public string Name => "Enigma2 Recordings";
private void OnUpdateTimerCallback(object state)
{
OnContentChanged();
}
public string[] Attributes => new[] { "Recordings" };
public RecordingsChannel(ILiveTvManager liveTvManager)
{
_liveTvManager = liveTvManager;
public string Description => "Enigma2 Recordings";
var interval = TimeSpan.FromMinutes(15);
_updateTimer = new Timer(OnUpdateTimerCallback, null, interval, interval);
}
public string DataVersion => "1";
public void Dispose()
{
if (_updateTimer != null)
{
_updateTimer.Dispose();
_updateTimer = null;
}
}
public string HomePageUrl => null;
public string Name
{
get
{
return "VuPlus Recordings";
}
}
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
public string[] Attributes
{
get
{
return new[] { "Recordings" };
}
}
public string Description
{
get
{
return "VuPlus Recordings";
}
}
public string DataVersion
{
get
{
return "1";
}
}
public string HomePageUrl
{
get { return null; }
}
public ChannelParentalRating ParentalRating
{
get { return ChannelParentalRating.GeneralAudience; }
}
public string GetCacheKey(string userId)
{
var now = DateTime.UtcNow;
var now = DateTimeOffset.UtcNow;
var values = new List<string>
{
now.DayOfYear.ToString(CultureInfo.InvariantCulture),
now.Hour.ToString(CultureInfo.InvariantCulture)
};
var values = new List<string>();
values.Add(now.DayOfYear.ToString(CultureInfo.InvariantCulture));
values.Add(now.Hour.ToString(CultureInfo.InvariantCulture));
double minute = now.Minute;
minute /= 5;
@@ -65,8 +122,8 @@ namespace Jellyfin.Plugin.Enigma2
{
ContentTypes = new List<ChannelMediaContentType>
{
ChannelMediaContentType.Movie,
ChannelMediaContentType.Episode,
ChannelMediaContentType.Movie,
ChannelMediaContentType.Episode,
ChannelMediaContentType.Clip
},
MediaTypes = new List<ChannelMediaType>
@@ -84,7 +141,7 @@ namespace Jellyfin.Plugin.Enigma2
{
return Task.FromResult(new DynamicImageResponse
{
Path = "https://github.com/CompoUK/MediaBrowser.Plugins.VuPlus/blob/master/src/Images/Vu+.png?raw=true",
Path = "https://github.com/MediaBrowser/MediaBrowser.Plugins.VuPlus/raw/master/MediaBrowser.Plugins.VuPlus/Images/thumb.png",
Protocol = MediaProtocol.Http,
HasImage = true
});
@@ -111,7 +168,7 @@ namespace Jellyfin.Plugin.Enigma2
private LiveTvService GetService()
{
return LiveTvService.Instance;
return _liveTvManager.Services.OfType<LiveTvService>().First();
}
public bool CanDelete(BaseItem item)
@@ -124,13 +181,6 @@ namespace Jellyfin.Plugin.Enigma2
return GetService().DeleteRecordingAsync(id, cancellationToken);
}
public async Task<IEnumerable<ChannelItemInfo>> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken)
{
var result = await GetChannelItems(new InternalChannelItemQuery(), i => true, cancellationToken).ConfigureAwait(false);
return result.Items.OrderByDescending(i => i.DateCreated ?? DateTime.MinValue);
}
public Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(query.FolderId))
@@ -182,10 +232,14 @@ namespace Jellyfin.Plugin.Enigma2
var service = GetService();
var allRecordings = await service.GetAllRecordingsAsync(cancellationToken).ConfigureAwait(false);
return new ChannelItemResult()
var result = new ChannelItemResult()
{
Items = allRecordings.Where(filter).Select(ConvertToChannelItem).ToList()
Items = new List<ChannelItemInfo>()
};
result.Items.AddRange(allRecordings.Where(filter).Select(ConvertToChannelItem));
return result;
}
private ChannelItemInfo ConvertToChannelItem(MyRecordingInfo item)
@@ -204,7 +258,7 @@ namespace Jellyfin.Plugin.Enigma2
//HomePageUrl = item.HomePageUrl
Id = item.Id,
//IndexNumber = item.IndexNumber,
MediaType = item.ChannelType == ChannelType.TV ? ChannelMediaType.Video : ChannelMediaType.Audio,
MediaType = item.ChannelType == Model.LiveTv.ChannelType.TV ? ChannelMediaType.Video : ChannelMediaType.Audio,
MediaSources = new List<MediaSourceInfo>
{
new MediaSourceInfo
@@ -222,7 +276,7 @@ namespace Jellyfin.Plugin.Enigma2
DateModified = item.DateLastUpdated,
Overview = item.Overview,
//People = item.People
IsLiveStream = item.Status == RecordingStatus.InProgress,
IsLiveStream = item.Status == Model.LiveTv.RecordingStatus.InProgress,
Etag = item.Status.ToString()
};
@@ -234,14 +288,16 @@ namespace Jellyfin.Plugin.Enigma2
var service = GetService();
var allRecordings = await service.GetAllRecordingsAsync(cancellationToken).ConfigureAwait(false);
var result = new ChannelItemResult()
{
Items = new List<ChannelItemInfo>()
};
var series = allRecordings
.Where(i => i.IsSeries)
.ToLookup(i => i.Name, StringComparer.OrdinalIgnoreCase);
var items = new List<ChannelItemInfo>();
items.AddRange(series.OrderBy(i => i.Key).Select(i => new ChannelItemInfo
result.Items.AddRange(series.OrderBy(i => i.Key).Select(i => new ChannelItemInfo
{
Name = i.Key,
FolderType = ChannelFolderType.Container,
@@ -254,7 +310,7 @@ namespace Jellyfin.Plugin.Enigma2
if (kids != null)
{
items.Add(new ChannelItemInfo
result.Items.Add(new ChannelItemInfo
{
Name = "Kids",
FolderType = ChannelFolderType.Container,
@@ -267,7 +323,7 @@ namespace Jellyfin.Plugin.Enigma2
var movies = allRecordings.FirstOrDefault(i => i.IsMovie);
if (movies != null)
{
items.Add(new ChannelItemInfo
result.Items.Add(new ChannelItemInfo
{
Name = "Movies",
FolderType = ChannelFolderType.Container,
@@ -280,7 +336,7 @@ namespace Jellyfin.Plugin.Enigma2
var news = allRecordings.FirstOrDefault(i => i.IsNews);
if (news != null)
{
items.Add(new ChannelItemInfo
result.Items.Add(new ChannelItemInfo
{
Name = "News",
FolderType = ChannelFolderType.Container,
@@ -293,7 +349,7 @@ namespace Jellyfin.Plugin.Enigma2
var sports = allRecordings.FirstOrDefault(i => i.IsSports);
if (sports != null)
{
items.Add(new ChannelItemInfo
result.Items.Add(new ChannelItemInfo
{
Name = "Sports",
FolderType = ChannelFolderType.Container,
@@ -306,7 +362,7 @@ namespace Jellyfin.Plugin.Enigma2
var other = allRecordings.FirstOrDefault(i => !i.IsSports && !i.IsNews && !i.IsMovie && !i.IsKids && !i.IsSeries);
if (other != null)
{
items.Add(new ChannelItemInfo
result.Items.Add(new ChannelItemInfo
{
Name = "Others",
FolderType = ChannelFolderType.Container,
@@ -316,10 +372,7 @@ namespace Jellyfin.Plugin.Enigma2
});
}
return new ChannelItemResult()
{
Items = items
};
return result;
}
}
public class MyRecordingInfo
@@ -378,12 +431,12 @@ namespace Jellyfin.Plugin.Enigma2
/// <summary>
/// The start date of the recording, in UTC.
/// </summary>
public DateTime StartDate { get; set; }
public DateTimeOffset StartDate { get; set; }
/// <summary>
/// The end date of the recording, in UTC.
/// </summary>
public DateTime EndDate { get; set; }
public DateTimeOffset EndDate { get; set; }
/// <summary>
/// Gets or sets the program identifier.
@@ -430,7 +483,7 @@ namespace Jellyfin.Plugin.Enigma2
/// Gets or sets the original air date.
/// </summary>
/// <value>The original air date.</value>
public DateTime? OriginalAirDate { get; set; }
public DateTimeOffset? OriginalAirDate { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this instance is movie.
@@ -513,7 +566,7 @@ namespace Jellyfin.Plugin.Enigma2
/// Gets or sets the date last updated.
/// </summary>
/// <value>The date last updated.</value>
public DateTime DateLastUpdated { get; set; }
public DateTimeOffset DateLastUpdated { get; set; }
public MyRecordingInfo()
{
-208
View File
@@ -1,208 +0,0 @@
# Enigma2 dla Jellyfin 12.2
Lokalne rozszerzenie wtyczki Enigma2 6.0.0.0, wersja 7.0.6.0. Zachowuje identyfikator wtyczki, nazwę usługi, konfigurację połączenia, natywne strumienie, nagrania i timery dekodera. Profile transkodowania pozostają na dekoderze.
## Funkcje
- Pełna lista 216 kanałów skonfigurowanego bukietu „Polskie”, z numerami, logo i wyszukiwaniem. Stronicowanie API obsługuje również większe listy.
- Ulubione, ukrywanie, kolejność i domyślny widok osobno dla każdego użytkownika. Ulubione synchronizują się z natywnym Jellyfinem. Ukrywanie i kolejność są nakładane także na natywny endpoint `/LiveTv/Channels`, po kontroli uprawnień i przed stronicowaniem; widok domyślny dotyczy strony dodatku.
- Globalny przełącznik **Obsługuj nagrania z Vu+ (Enigma2 Recordings)** w konfiguracji wtyczki Enigma2, domyślnie wyłączony. Wyłączenie nie rejestruje dostawcy biblioteki nagrań, pozostawia telewizję, EPG oraz timery i nie usuwa plików z dekodera. Zmiana wymaga restartu Jellyfina oraz odświeżenia kanałów dostawców. Nie jest to przełącznik na stronie kanałów.
- Widok audycji na najbliższe sześć godzin, przejścia do szczegółów kanału/audycji oraz natywnego przewodnika, nagrań i timerów.
- Rzeczywiste grafiki audycji z `https://epg.polishteam.one/guide.xml`, pobierane i buforowane przez Jellyfin. Brak generowania obrazków.
- Konserwatywne dopasowanie po jednoznacznym kanale, dokładnym znormalizowanym tytule oraz czasie rozpoczęcia ±7 minut. Brak zgadywania przy niejednoznacznych danych. Harmonogram i EIT do timerów pozostają z Enigmy.
- Stałe identyfikatory audycji: service reference, EIT i początek emisji, zamiast losowej liczby. Poprawiona deduplikacja i cache kanałów przy odczycie wielu bukietów.
## Otwieranie
Jellyfin Web → menu **Telewizja dekodera**. Bezpośrednio: `/DecoderIntegration/Channels` na tym samym serwerze i adresie, pod którym użytkownik loguje się do Jellyfina. Strona korzysta z zapisanej sesji Jellyfin Web; wymagane logowanie z opcją **Zapamiętaj mnie**. Nie ma oddzielnych kont ani haseł dodatku. Bez zapisanej sesji strona pokazuje odnośnik do logowania.
W Jellyfin 10.11 strony `configurationpage` są ograniczone do administratorów w routerze klienta. Dlatego dla wszystkich użytkowników użyto standardowego `menuLinks` w `/usr/share/jellyfin/web/config.json` i strony HTML serwowanej przez wtyczkę. API preferencji wymaga uprawnienia Live TV i identyfikuje użytkownika z uwierzytelnionych claims, bez przyjmowania dowolnego UserId od klienta. Administracyjny stan importu: `GET /DecoderIntegration/Status`.
Grafiki trafiają do natywnego EPG i są dostępne również w innych klientach Jellyfina. Własna strona i jej układ są funkcją przeglądarkową; nie zmieniają interfejsu aplikacji Android TV. Uprawnienia kanałów nadal egzekwuje natywne API Jellyfina.
## Źródła i ograniczenia
Sprawdzono 11.10.2026: [wydania autora](https://github.com/knackebrot/jellyfin-plugin-enigma2/releases) i [manifest](https://github.com/knackebrot/jellyfin-repo/blob/master/manifest.json) nadal wskazują **6.0.0.0, 22.06.2024**, jako najnowsze opublikowane wydanie. **7.0.0.0 jest wersją lokalną tego projektu**, nie oficjalnym wydaniem autora. Pochodzenie vendora: `UPSTREAM.txt`, licencja CC0.
Źródło grafik: PolishTeam. Dozwolone adresy obrazków to HTTPS na `ptv-prod.wphcdn.cloud` i `media.teleman.pl`; dane XML mają limit 128 MiB i zablokowane DTD/encje zewnętrzne. Brak pasującej grafiki pozostawia dotychczasowe dane Enigmy. Awaria importu nie blokuje telewizji. Feed jest odświeżany najwyżej co 6 h przy odświeżaniu przewodnika; po błędzie ponowna próba po 15 minutach, ostatni dobry cache ważny do 48 h. Cache feedu jest w pamięci, po restarcie pobiera się ponownie.
Nie wszystkie audycje mają zgodny tytuł i grafikę w źródle. Dostępność zewnętrznego feedu nie jest gwarantowana. Pełne odświeżanie EPG i cache zdjęć może potrwać; dopiero zakończone natywne Refresh Guide usuwa stare losowe identyfikatory audycji.
## Budowanie i wdrożenie
SDK .NET 10, `dotnet run --project Tests -c Release`. Projekt odwołuje się do Jellyfin 12.2. Testy konsolowe sprawdzają dopasowanie, niejednoznaczność, zakres czasu, alias HD, ograniczenia URL i walidację preferencji.
Budowano w izolowanym `mcr.microsoft.com/dotnet/sdk:10.0` na CT100195 z limitem dwóch CPU i osobnym katalogiem `/tmp/jellyfin-decoder-plugin`. Nie zmieniono usług serwera strumieniowego. Artefakt: `Jellyfin.Plugin.Enigma2/bin/Release/net10.0/Jellyfin.Plugin.Enigma2.dll`.
`deploy.py` uruchamia się w CT100191 po umieszczeniu DLL w `/tmp/Jellyfin.Plugin.Enigma2.dll`. Odmawia restartu przy aktywnym odtwarzaniu. Zachowuje stare katalogi wtyczki, konfigurację oraz web-config w `/var/lib/jellyfin/decoder-plugin-backups/<UTC>`, przenosi starą wersję poza katalog plugins i instaluje nową z zachowaniem tożsamości. Restartuje tylko Jellyfin, nie dekoder.
Powrót po aktualizacji serwera wymaga przywrócenia **pełnej kopii danych i konfiguracji sprzed migracji oraz pakietów Jellyfin 10.11.6**. Sama zamiana DLL nie cofa zmian bazy danych. W obrębie Jellyfina 12.2 można przywrócić poprzednią zgodną wersję dodatku z kopii wdrożenia. Wrapper FFmpeg i profile odbiornika są zachowane.
`verify.py` sprawdza aktywność wtyczki, kanały, timery, izolację preferencji dwóch kont i politykę administracyjną. Konta testowe usuwa w finally. `test-browser.cjs` sprawdza rzeczywisty Jellyfin Web w izolowanym Edge headless i usuwa własne konto. Nie drukuje tokenów ani haseł.
Kod jest także aktualizowany w `C:\Users\michal\git\jellyfin-plugin-enigma2` na gałęzi `codex/decoder-integration`. Dodatkowy remote `ekacprzak`: `ssh://git@git.e-kacprzak.eu:2222/michal/jellyfin-plugin-enigma2.git`. Zmiany wtyczki są wypychane wyłącznie do tego remote; GitHub origin pozostaje źródłem upstream.
Przed końcowym wyłączeniem dostawcy ukryto jego kafelek i ostatnio dodane na 19 istniejących kontach przez natywne MyMediaExcludes/LatestItemsExcludes (wyraźna zgoda użytkownika); kopia poprzednich ustawień: `/var/lib/jellyfin/decoder-plugin-backups/home-exclusions-20261011T104028Z.json`. `hide-recordings-home.py` dokumentuje ten jednorazowy krok. Nie jest mechanizmem przełącznika obsługi nagrań. Po ewentualnym ponownym włączeniu dostawcy użytkownicy mogą odznaczyć wykluczenie biblioteki w ustawieniach ekranu głównego.
## Aktualizacja serwera 11.10.2026
Jellyfin w CT100191 zaktualizowano z 10.11.6 do **12.2.0**, FFmpeg do **8.1.3**. Pełna kopia po zatrzymaniu serwera: `/var/backups/jellyfin-pre12-20261011T110237Z/data-config.tar` (9 465 702 400 bajtów). Migracje zakończyły się pomyślnie; zachowano 19 kont, 216 kanałów i 33 timery. LDAP 19/22 usunięto, SSO zachowano. Test inicjacji OIDC z Host `jellyfin.e-kacprzak.eu` zwrócił 302 do `login.e-kacprzak.eu`; adres techniczny nie jest dozwolonym callbackiem w Keycloak. Pełnego logowania użytkownika w Keycloak nie automatyzowano.
SSO autora nie ma jeszcze paczki ABI 12. Przygotowano lokalny build 4.0.0.4 z upstream `f1e157bac29e6d27a0844378f3e1d4a4fdaa577a`, .NET10 i zależnościami Jellyfin12.2. Usunięto starą referencję XML cryptography6, przypięto Pkcs10; build bez ostrzeżeń i błędów. Reprodukcja bez instalacji: `python3 compat/build-sso12.py` na Linuxie z Dockerem. Oficjalne dodatki: DLNA14, OpenSubtitles25, TVmaze14, Webhook22, SessionCleaner6. Kopia starych paczek: `/var/lib/jellyfin/decoder-plugin-backups/upgrade12-20261011T110701Z`.
Enigma7 aktywna. Obsługa nagrań Vu wyłączona w konfiguracji, lista dostawców `/Channels` pusta. Testy konsolowe obejmują także rejestrację przed utworzeniem Plugin.Instance i odpowiedzi przy Accept-Encoding gzip/br. Test API: preferencje dwóch kont, natywna lista/paginacja, uprawnienia administracyjne. Test Edge headless: 216 kanałów, ulubione, ukrywanie/przywracanie, EPG, brak błędów JS. Po aktualizacji uruchomiono pełny skan bibliotek i odświeżanie EPG.
## Odtwarzanie jednym kliknięciem
Wersja 7.0.1.0: kliknięcie nazwy kanału na stronie dodatku uruchamia natywny odtwarzacz Jellyfin Web. Osobny przycisk „Szczegóły” zachowuje dostęp do informacji i EPG. `web/decoder-channel-playback.js` korzysta ze wspieranego API dodatków window w Jellyfin Web (PluginManager przekazuje playbackManager). Kanał jest sprawdzany przez uwierzytelnione API użytkownika i musi mieć typ TvChannel. Grafiki przyszłych audycji nadal otwierają szczegóły.
Po skopiowaniu skryptu do `/tmp/decoder-channel-playback.js` uruchomić `install-web-playback.py` w CT100191; instalator zachowuje kopie index/config i wpisuje dodatek do istniejącej listy plugins. Po aktualizacji pakietu jellyfin-web trzeba ponownie uruchomić instalator. W otwartej przeglądarce wykonać Ctrl+Shift+R. Działanie dotyczy Jellyfin Web; natywne aplikacje Android TV mają własne sterowanie. Test Edge: wybór TVP INFO HD jednym kliknięciem, odpowiedź PlaybackInfo 200 i odtwarzanie video z rosnącym currentTime, bez przycisku Play.
Test `test-native-playback.cjs`: kliknięcie narożnika grafiki kanału TVP INFO HD w natywnym `/livetv?tab=2`, bez trafienia w przycisk Play, uruchamia wideo (PASS). Stanowisko: izolowany Edge headless, konto testowe usuwane w finally.
## Współdzielenie transmisji i błędy odtwarzania — 12.10.2026
Wersja 7.0.4.0 współdzieli jedno połączenie HTTP z odbiornikiem między analizą metadanych i odbiorcami danego kanału. Lokalny relay ma losowy identyfikator oraz akceptuje tylko połączenia loopback; zewnętrzne odtwarzacze korzystają z natywnego API Jellyfina. Bufor historii jest ograniczony do 2 MiB, kolejki klientów do 128 bloków, liczba klientów kanału do 16. Zakończenie jednego odbiorcy nie zatrzymuje pozostałych; po ostatnim połączeniu źródło jest zamykane po 15 sekundach.
Wtyczka wykonuje własne współdzielone badanie rzeczywistych metadanych strumienia. Limit 15 sekund kończy również uruchomiony przez wtyczkę ffprobe; wynik sukcesu jest buforowany 30 sekund, błędu 3 sekundy. MediaSourceInfo przekazuje natywnemu odtwarzaczowi zidentyfikowane indeksy i wyłącza kolejne probing. Nie zmienia się systemowych wrapperów ffmpeg/ffprobe ani konfiguracji Jellyfina.
Moduł web domyślnie otwiera Telewizję w zakładce Kanały (tab=2), zachowując jawnie wybraną zakładkę. Zapobiega podwójnemu uruchomieniu podczas oczekiwania, otwiera natywny OSD i udostępnia przycisk „Zatrzymaj i wróć do kanałów” oraz Escape. Brak obrazu przez 25 sekund kończy próbę i wraca do listy. Działanie przy błędzie nie stanowi potwierdzenia prawidłowej transmisji z odbiornika.
Do aktualizacji wyłącznie DLL używa się `deploy-plugin-only.py` w CT100191 po dostarczeniu artefaktu do `/tmp/Jellyfin.Plugin.Enigma2.dll`. Flaga `--maintenance-approved` pozwala wykonać uzgodniony restart przy aktywnym odtwarzaniu. Skrypt zachowuje poprzednią wtyczkę i sprawdza niezmienione hashe konfiguracji serwera, wrapperów oraz index/config web. Moduł JS aktualizuje się osobno w istniejącym `/usr/share/jellyfin/web/decoder-channel-playback.js`; nie uruchamiać ponownie instalatora web przy naprawie ograniczonej do wtyczki. Ostatnia kopia wdrożenia: `/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261012T183340Z`.
Testy konsolowe przeszły: współdzielenie probe, anulowanie pojedynczego odbiorcy, limit czasu, brak wideo, jeden upstream dla dwóch odbiorców relay, odmowa dostępu zewnętrznego oraz zwolnienie ostatniego połączenia. `test-web-bridge.cjs` sprawdza domyślną zakładkę, OSD, Escape, deduplikację i spóźnione zakończenie. `verify.py` potwierdził aktywną 7.0.4.0, 216 kanałów i izolację preferencji/uprawnień. `test-web-player.cjs` pozwala ustawić nazwę przez DECODER_TEST_CHANNEL i sprawdza rzeczywisty obraz oraz wyjście w Edge.
Bieżące próby TVP 1 HD i TVP 2 HD: domyślne Kanały i wyjście PASS, obraz NO_FRAME_FROM_SOURCE, bez błędów JS. Bezpośrednie żądanie TVP1 z CT100191 do portu 8001, z pominięciem wtyczki i odtwarzacza, otrzymało HTTP 200 i 0 bajtów po około 5,3 s. Dla TVP2 test web nie uzyskał obrazu; nie potwierdzono identycznej przyczyny. Bieżące service reference w natywnej bazie są zgodne z OpenWebif: TVP1 3ABD:514:13E oraz TVP2 C22:1E78:71. Dodatkową ręczną próbę 3D66:2C88:13E błędnie opisano jako TVP2; jest to TV PULS HD i nie stanowi dowodu dla TVP2. Nie potwierdzono przywrócenia obrazu. Odczyt logów dekodera wykazał dodatkowo zatrzymania GUI przez runlevel 4→3 o 20:44, 20:45, 20:47 i 20:48 CEST; Enigma obsługuje porty 80 i 8001, więc te przerwy wyjaśniają wcześniejsze Connection refused. Źródła poleceń restartów oraz przyczyny pustej transmisji nie przypisano do LNB ani OSCam bez dalszych dowodów. W ramach tej naprawy nie zmieniano ani nie restartowano odbiornika.
## E2E i poprawki odtwarzania — 12.10.2026, 7.0.6.0
Pełny test produkcyjny używa `test-e2e.cjs` oraz izolowanych kontekstów Edge i tymczasowych kont. `--ui-only` sprawdza interfejs, zapis po przeładowaniu, wyszukiwanie, ulubione, ukrywanie, kolejność w natywnym API, EPG, obrazki, domyślny widok, uprawnienia, brak dostawcy nagrań Vu i panel administracyjny. `--playback-only` sprawdza rzeczywiste klatki 4Fun.TV, TVP1, TVP2 i TVN, native OSD, dwuklik, dwa odtwarzacze i zwolnienie źródła. `--control-only` ogranicza odtwarzanie do 4Fun.TV, a `--config-only` nie otwiera transmisji TV. `--report=<ścieżka.json>` zapisuje wyniki bez tokenów i haseł. Testy są operacjami produkcyjnymi: konta są usuwane w finally; nie tworzą nagrań ani timerów i nie zapisują formularza konfiguracji wtyczki.
E2E wykrył dwa błędy po stronie wtyczki i sprawdził ich poprawki. Analiza nieskończonego TS zawierającego PID danych EPG mogła osiągać limit 15 s mimo poprawnego AV. Probe teraz pobiera przez ten sam relay próbkę do około 4 MiB / 4 sekund i analizuje skończony plik. Wybór programu po SID z service reference zapobiega uznaniu danych innego transpondera za oczekiwany kanał. Plik próbki jest usuwany także po błędzie; obserwacja po testach wykazała zero pozostałych próbek.
Drugi błąd: Jellyfin 12.2 w `EncodingHelper.FindIndex/GetMapArgs` mapuje pozycję na liście strumieni. Lista AV pozbawiona wcześniejszego PID EPG powodowała `-map 0:0 -map 0:1`, próbę zapisania EPG w MP4 i błąd HLS. Wtyczka przekazuje teraz kopię rzeczywistych metadanych AV z `Index=-1`, używając natywnego wariantu automatycznego wyboru AV, zamiast zgadywania indeksów. Oryginalny wynik probe nie jest zmieniany. Nie zmieniono kodu Jellyfina ani wrapperów. Przy takim mapowaniu wybór ścieżki audio pozostaje automatyczny; ręcznego wyboru alternatywnej ścieżki nie potwierdzono. Źródło zachowuje `SupportsProbing=false` i ustawia `AnalyzeDurationMs=1500`. Web OSD czeka na metadane wideo i obsługuje odrzuconą/anulowaną nawigację.
Wdrożona DLL: 7.0.6.0; backup `/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261012T192438Z`. Sprawdzone hashe konfiguracji/wrapperów/index/config bez zmian. Instalator dodatkowo czeka na aktywną wtyczkę w API po starcie usługi.
Wyniki w osobnych JSON: `e2e-ui-20261012.json`, `e2e-config-20261012.json`, `e2e-control-20261012.json`, `e2e-playback-20261012.json`, `e2e-guide-20261012.json`. Test interfejsu potwierdził 16 podstawowych kontroli, 216 kanałów, izolację dwóch użytkowników i 36 istniejących timerów. Zakończony świeży Refresh Guide dopasował 5302 grafiki audycji. Kontrolny playback 4Fun.TV zaliczył 9/9: rosnące zdekodowane klatki, dwa browsery / jedno fizyczne połączenie dekodera, zatrzymanie jednego bez przerwania drugiego i pełne zwolnienie źródła. Końcowy przebieg obejmował również pozytywny natywny kafelek i OSD.
Całego E2E nie uznano za zielony: TVP1, TVP2 i TVN nie uzyskały klatek; źródło dekodera było puste lub miało niewłaściwy program/transponder według równoległej diagnostyki natywnej. Przy błędach źródła Jellyfin Web nadal zgłosił nieobsłużone odrzucenia obietnic `undefined`; wyjście do kanałów przeszło. Administracyjna nawigacja konfiguracji dla tymczasowego konta jest raportowana osobno i nie wolno uznać jej za PASS na podstawie samego API HTML. Nie zmieniano konfiguracji tunerów, nie restartowano Vu ani nie omijano ochrony logowania administratora. Końcowe aktywne transmisje wstrzymano na prośbę zadania dekodera prowadzącego ponownie izolowane strojenie.
Zakres nie obejmuje Android TV, pełnego logowania SSO w Keycloak, tworzenia/wykonywania nowych nagrań ani wielogodzinnej stabilności. Wynik short E2E nie jest gwarancją tych funkcji.
## Analiza niezaliczonych punktów — 12.10.2026
Korekta raportu panelu administratora: wcześniejszy FAIL był fałszywym wynikiem scenariusza testowego. Test zmieniał uprawnienia po logowaniu oraz nawigował przed zakończeniem inicjalizacji sesji. Wydzielone konto administratora utworzone przed pierwszym logowaniem, oczekiwanie na zapisane credentials i zniknięcie formularza logowania oraz dopiero potem nawigacja pozwoliły otworzyć konfigurację. Z modułem web i z modułem wyłączonym wyłącznie w izolowanej przeglądarce formularz i API konfiguracji działały (HTTP 200, host192.168.10.20, port8001, nagraniaVu=false). Nie zmieniano konfiguracji dodatku ani serwera. Poprawiony `test-e2e.cjs --ui-only` zakończył się **17/17 PASS**, a `--config-only` **3/3 PASS**. Podane poprzednio przypisanie tego FAIL do konfiguracji wtyczki jest wycofane.
`diagnose-web.cjs` nie otwiera TV na odbiorniku: podstawia odpowiedzi PlaybackInfo i blokuje ewentualny endpoint otwarcia live. Porównuje moduł web z jego brakiem, oraz zapisuje typ/treść unhandledrejection i przebieg stop/powrotu. Wynik `web-analysis-20261012.json` potwierdza: sam błąd HTTP i anulowanie oczekującego żądania nie wywołały unhandledrejection; wyjście po błędzie, podczas zamykania komunikatu odtwarzacza, wywołało `undefined` także bez modułu wtyczki. Przy aktywnym module zarówno `playbackManager.stop()`, jak i `appRouter.show()` spełniły obietnice; samo dodanie catch do tych dwóch wywołań nie usuwa źródłowego odrzucenia.
Mechanizm pasujący do wyników: natywny [playbackmanager](https://github.com/jellyfin/jellyfin-web/blob/master/src/components/playback/playbackmanager.js) wywołuje `alert()` w showPlaybackInfoErrorMessage bez obsługi zwróconej obietnicy. [Dialog](https://github.com/jellyfin/jellyfin-web/blob/master/src/components/dialog/dialog.js) kończy się `Promise.reject()` bez argumentu, gdy zamknięto go bez wyniku przycisku. [dialogHelper](https://github.com/jellyfin/jellyfin-web/blob/master/src/components/dialogHelper/dialogHelper.js) zamyka dialogi przy zmianie historii. To tłumaczy undefined po opuszczeniu komunikatu; nie jest dowodem błędu zatrzymania strumienia ani diagnozą OSCam. W tym zadaniu analizy nie modyfikowano natywnego klienta lub produkcyjnego modułu web. Ewentualne obejście w module wtyczki powinno skoordynować domknięcie komunikatu i nawigację; nie należy globalnie tłumić unhandledrejection.
Brak obrazu na TVP1/TVP2/TVN nadal ma odrębne dowody: produkcyjne logi końcowego E2E zawierają POST PlaybackInfo z InvalidOperationException po HTTP502 z relay, a wcześniejsze odczyty bezpośrednie i diagnostyka odbiornika wykazały brak lub niewłaściwy program/transponder. Nie jest to błąd logowania do konfiguracji ani sam komunikat undefined. Odtwarzanie 4Fun, native OSD, współdzielenie i zwolnienie źródła pozostają potwierdzone wynikami E2E. Aktualne próby sterowania SCR są prowadzone wyłącznie przez zadanie odbiornika; ich wynik nie jest zakładany z góry.
## Skoordynowana próba ciągłego odbioru
`test-reception-trial.cjs` uruchamia się dopiero po wyraźnej wiadomości zadania odbiornika, że zaczyna się uzgodnione okno. Wymaga flagi `--coordinated`; bez niej kończy się przed logowaniem i otwarciem TV. Domyślnie TVP 1 HD: pierwszy klient od pierwszej rzeczywistej klatki przez 900 s, drugi dołącza po 300 s i kończy po 600 s. Parametry `--duration`, `--join`, `--leave` podaje się w sekundach i zmienia wyłącznie zgodnie z ustalonym oknem. Przykład po uzgodnieniu: `node test-reception-trial.cjs --coordinated --report=reception-trial.json`.
Co 5 s zapisuje postęp klatek i zegara, bufor, stan odtwarzacza, zdarzenia przerw i dostępny licznik zdekodowanego audio. Co około 30 s odczytuje administracyjny status wtyczki i listę istniejących klientów OpenWebif. Nie otwiera dodatkowej transmisji diagnostycznej. Wyniki JSON/JSONL rozróżniają jednoczesną liczbę fizycznych połączeń od globalnego licznika otwarć. `Consumers` nie jest utożsamiany z liczbą przeglądarek, ponieważ Jellyfin może współdzielić również własny pipeline. Brak licznika audio jest oznaczony UNVERIFIED; same metadane nie potwierdzają słyszalnego dźwięku.
Próba używa dwóch własnych kont, zamyka ich sesje i usuwa konta oraz preferencje w cleanup; po zakończeniu czeka 20 s na zwolnienie relay. Nie wymaga globalnego zera źródeł, jeżeli inni użytkownicy oglądają TV. Nie przełącza kanałów, nie zapisuje konfiguracji, nie tworzy nagrań i nie restartuje usług. Przy pierwszym nieudanym starcie kończy się z FAIL bez samodzielnego ponawiania odbioru.
Przebieg 12.10.2026, 21:31:43–21:47:13 UTC (23:31–23:47 CEST): TVP1 uruchomił się po 13,31 s. Główny klient działał przez uzgodnione 900 s, drugi w fazie 300–600 s; oba 1920×1080 i rosnące liczniki audio. **15/15 kontroli transportu, współdzielenia, wyjścia i zwolnienia źródła PASS**. W obu fazach dokładnie jeden fizyczny klient Jellyfina na odbiorniku, globalny licznik otwarć pozostał 18; relay Consumers 1→2→1→0. Pozostałe trzy źródła natywne należały do równoległego zadania dekodera i nie były zamykane przez test. Wtyczka produkcyjna 7.0.6.0, brak zmian konfiguracji lub restartów w tej próbie.
Ten przebieg **nie zaliczył płynności klienta Web**. Wyjście było AV1/AAC 1080p50; FFmpeg pracował około 0,997×, HLS bez błędów HTTP. Główny headless Edge: 894,873 s pomiaru, 713,581 s postępu filmu, 53,5% dropped, 188 oczekiwań po starcie / 181,144 s łącznie, bufor minimum 2,624 s. Drugi: 56% dropped i 65,198 s oczekiwań. Sam przyrost klatek nie jest kryterium płynności. Raport `reception-trial-20261012.json` zachowuje wyniki transportu, a `reception-summary-20261012.json` rozdziela je od jakości klienta. `analyze-reception-trial.cjs` liczy proporcję czasu, dropped oraz waiting→playing; progi dotyczą tego stanowiska testowego, nie wszystkich aplikacji.
`--bridge-file=<lokalny JS>` pozwala w świeżych izolowanych kontekstach podstawić wyłącznie skrypt wtyczki i odpowiedź polityki H264 dla dokładnego ID testowanego kanału. Nie zmienia plików ani konfiguracji serwera. Taka próba porównawcza potwierdza efekt negocjacji kodeka; rzeczywiste uprawnienia i kontrakt endpointu należy osobno sprawdzić po wdrożeniu DLL. Każde kolejne okno odbioru wymaga uzgodnienia z zadaniem dekodera.
## Przygotowana opcja H264 — 7.0.7.0
Kod zawiera opcję „Preferuj H264 przy transkodowaniu telewizji w przeglądarce”, domyślnie włączoną również po wczytaniu starszego XML. Endpoint `GET /DecoderIntegration/WebPlaybackPolicy?channelId=<guid>` wymaga dostępu LiveTV, rozpoznaje rzeczywistego użytkownika z claim oraz stosuje natywną widoczność kanału. Zwraca tylko `IsEnigmaChannel` i `PreferH264`; własność wymaga `LiveTvChannel.ServiceName == Enigma2`. Brak/obcy/niewidoczny item zwraca false/false, wyłączona opcja dla Enigma true/false. Odpowiedź nie jest buforowana. Nie przyjmuje userId jako źródła uprawnień.
Moduł window zawęża klon profilu transkodowania w `appHost.getDeviceProfile(item, options)` do H264 wyłącznie dla potwierdzonego kanału Enigma w zwykłym Web. Zachowuje audio, napisy, ograniczenia profilu i DirectPlay. Filmy, inne źródła TV, inny serwer, NativeShell i zdalny odtwarzacz pozostają bez zmiany. Brak H264 w oryginalnym profilu lub niedostępna polityka zachowuje natywną negocjację. Oczekiwanie na politykę jest ograniczone do 2 s; ten odczyt HTTP nie otwiera TV. JS i endpoint muszą być wdrożone razem; nie należy nadpisywać globalnych preferencji Jellyfina.
Wspólny build .NET10/ABI12.2 i testy backendu przeszły, w tym autoryzacja, własność i konfiguracja false. `test-web-bridge.cjs` potwierdził zakres H264, brak mutacji wspólnego profilu, zachowanie audio/warunków, fallback, retry i ochronę przed ponownym instalowaniem wrappera. **Na etapie zapisu tego punktu produkcja nadal używa 7.0.6.0; 7.0.7.0 nie wdrożono.** Próba A/B i kontrola rzeczywistego endpointu pozostają do wykonania po kolejnym sygnale zadania odbiornika. Wynik AV1 nie dowodzi problemu każdego klienta Web ani awarii anteny.
## A/B H264 i wdrożenie — 13.10.2026 CEST
Po kolejnym wyraźnym START zadania odbiornika wykonano pięć minut TVP1 z nowym JS wyłącznie w dwóch izolowanych kontekstach, przy produkcyjnej DLL nadal 7.0.6.0. Raporty `reception-h264-20261012.json` i `reception-h264-summary-20261012.json` używają daty UTC: firstframe 12.10 21:59:14.667 UTC / 23:59 CEST, cleanup 22:04:46.092 UTC / 13.10 00:04 CEST. Wyjście faktycznie H264/AAC 1080p50; polityka podmieniona tylko dla dokładnego ID TVP1. **15/15 kontroli transportu, dwóch odbiorców, audio, wyjścia i zwolnienia źródła PASS.** Pierwszy widz: 297,551 s pomiaru, postęp 295,088 s (ratio 0,992), dropped 1,4%, około 48,855 kl./s nieodrzuconych, trzy oczekiwania / 2,473 s. Drugi: ratio 1,000, dropped 0,1%, około 49,913 kl./s, zero oczekiwań po starcie. Jeden fizyczny klient Jellyfina przez całą fazę dwóch widzów; licznik otwarć 19 nie wzrósł. Końcowo brak klientów OpenWebif i relay 0/0. Oba stanowiska zaliczyły kryterium płynności klienta testowego. Nie jest to gwarancja innych urządzeń ani słyszalnego dźwięku.
Po zakończeniu uzgodnionego okna i potwierdzeniu zera odbiorców wdrożono **7.0.7.0**: DLL i skrypt wtyczki, z wcześniej zatwierdzonym restartem wyłącznie Jellyfina. Backup `/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261012T220741Z` zawiera poprzednią DLL/manifest oraz JS. Instalator potwierdził aktywną wersję i niezmienione hashe `/etc/default/jellyfin`, `encoding.xml`, wrapperów oraz `index.html/config.json` Web. Nie zmieniono odbiornika ani konfiguracji serwera. Preferencja H264 jest domyślną wartością nowego pola w konfiguracji dodatku; nie zapisywano globalnych ustawień klienta.
`verify.py` sprawdził rzeczywisty endpoint dla zwykłego użytkownika: kanał Enigma true/true, film i brak false/false, użytkownik bez uprawnień TV 403. Preferencje, izolacja, natywna paginacja i admin ACL PASS; 216 kanałów i 38 aktualnych timerów, bez tworzenia nowych timerów przez test. `test-e2e.cjs --ui-only` na produkcyjnej 7.0.7 zakończył się **17/17 PASS**, 500 audycji i 500 grafik w sprawdzanym EPG; nagrania Vu pozostają wyłączone. Po restarcie liczniki relay i dopasowań EPG zaczynają od zera; istniejące grafiki programów pozostały w natywnych danych i przeszły test ładowania. Na tym etapie nie uruchamiano dodatkowej TV po wdrożeniu: krótka kontrola odtwarzania bez podmian wymaga kolejnego uzgodnionego okna. W przeglądarkach z wcześniejszym JS należy wymusić odświeżenie (Ctrl+F5).
Parametr `--frame=<plik.png>` w próbie ciągłej może zapisać jeden kadr aktywnego wideo dla kontroli wizualnej. Jest opcjonalny; sam licznik klatek nie potwierdza obrazu na ekranie człowieka ani jakości odsłuchu.
## Końcowe odtwarzanie produkcyjne 7.0.7 — 13.10.2026 CEST
Po osobnym START zadania odbiornika wykonano uzgodnione 90 s TVP1 z produkcyjną DLL i JS **bez żadnych podmian**. `reception-production-7.0.7-20261013.json`: firstframe 22:14:02.849 UTC / 00:14 CEST, cleanup 22:16:02.627 UTC / 00:16 CEST; **15/15 PASS**. Rzeczywiste H264/AAC, 1920×1080, tor audio dekodowany i licznik rosnący. Główny widz: 82,787 s próbkowania, postęp filmu 82,791 s, około 49,887 kl./s nieodrzuconych, dropped 0,2%, zero waiting po starcie. Drugi: około 49,948 kl./s, zero dropped i waiting w sprawdzonym oknie; ze względu na startup drugi klient był mierzony przez 11,452 s w zaplanowanej fazie 30–60 s. Starty obu, domyślne Kanały i powrót po zatrzymaniu PASS. Jeden fizyczny strumień odbiornika, relay Consumers 1→2→1→0, globalny licznik otwarć pozostawał 1 po restarcie. `reception-production-summary-7.0.7-20261013.json` oddziela kontrolę płynności od transportu.
Kadr `frame-7.0.7-20261013.png` obejrzano: rzeczywista transmisja wideo, nie picon ani plansza. Nie wykonywano odsłuchu przez człowieka; potwierdzenie audio dotyczy dekodowania. Końcowy niezależny odczyt: aktywna 7.0.7, zero źródeł, zero odbiorców relay, zero tymczasowych kont i zero plików `enigma-probe-*.ts`. Zakończenie przekazano zadaniu odbiornika; nie pozostawiono własnych sesji ani procesów próbnych. Testy TV po tej kontroli są zakończone. Nie sprawdzono w tej fazie wszystkich 216 kanałów, Android TV ani wielogodzinnej stabilności.
### Uzupełnienie zadania odbiornika — 13.10.2026, 00:23 CEST
Zadanie odbiornika przekazało wynik osobnej końcowej próby zakończonej 12.10 22:23:25 UTC: dodanie cache MPEG APID TVP1 `c:01027a` (PID 634) poprawiło inicjalizację audio. W tym samym układzie czterech TCP / trzech transponderów TVN24 i TVP2 video-only oraz dwie sesje TVP1 (pierwsze audio i jawny PID634) ukończyły 30 s z exit0 w 33–35 s; TVP1 729 klatek i MP2 48 kHz stereo 192 kb/s / 30 s PCM. Przed poprawką obie próby TVP1 przekraczały 65 s i rozpoznawały pierwsze audio jako MP3 / zero kanałów. Według przekazania zachowano AC3, CA i wybór audio oraz wykonano backup przed zmianą. Szczegółowe dowody, wdrożenie i odtworzenie cache należą do raportu/handoff zadania odbiornika; tutaj nie wykonywano ani niezależnie nie powtarzano tego testu.
Ta informacja rozdziela dwa potwierdzone zagadnienia: inicjalizację źródła/audio dekodera oraz płynność AV1 w kliencie Web. Wtyczka 7.0.7 i końcowe testy H264 pozostają aktualne. Nie należy zamieniać niezaliczonej wcześniejszej długiej próby natywnej w PASS na podstawie tej krótkiej kontroli; po poprawce cache nie ponawiano wielogodzinnego odbioru.
### ARTE — do uwzględnienia przy następnej aktualizacji kanałów
Podczas późniejszego sześciogodzinnego testu zadanie odbiornika przekazało naprawę dwóch dokładnych wpisów ARTE w `userbouquet.canalPL.tv` i `userbouquet.polsatbox.tv`: stare SID133 / TSID3E1C / 11681H zastąpiono sprawdzoną referencją `1:0:1:51B:2E7C:13F:820000:0:0:0:` na 10892H. Backup na odbiorniku: `/etc/enigma2/arte-reference-repair-20261012T232930Z-bcdb983f`. Według przekazania zastosowano PAUSE, sprawdzono brak strumieni i zajętych timerów, wykonano oficjalny reload listy i weryfikację, a następnie wznowiono test; picony i pozostałe wpisy zachowano.
Nową referencję należy uwzględnić przy następnej uzgodnionej aktualizacji mapy kanałów Jellyfina. W tym zadaniu zapisano wyłącznie tę informację: nie odświeżono produkcyjnych kanałów/EPG, nie otwarto TV i nie zmieniono serwera. Odbiornik jest zarezerwowany do 13.10.2026 07:25:06 CEST (05:25:06 UTC). Przy późniejszym odświeżeniu należy sprawdzić ARTE oraz zachowanie ulubionych/kolejności dla ewentualnie zmienionego ID kanału; nie wykonywać tego podczas trwającej próby.
### Korekta PMT i korelacji OSCam podczas próby 6h
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.
## 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.
## Kandydat 7.0.15 — oryginalny obraz i dłuższy start (25.10.2026)
Nie wdrożono; produkcja nadal 7.0.11. Tor plansz kopiuje obraz i audio (`-c copy`),
bez wymuszania H264, 50 kl./s, Full HD, 8-bit lub stereo. Generuje wyłącznie
lokalne obrazy komunikatów. Dopasowana plansza jest dostępna dla rozpoznanego
progresywnego H264/yuv420p, AAC LC stereo 48 kHz i zgodnych granic AV. Inne formaty
(np. HEVC/HDR, przeplot, 5.1 i 23.976/59.94) zachowują natywny transport bez planszy;
nie są konwertowane do jej parametrów. Po takim fallbacku klient ma własne limity
buforowania. Zegar kopii obsługuje klatki B, powrót po planszy i zawinięcie PTS/DTS.
Napisy DVB używają rzeczywistych wymiarów kanału podczas czyszczenia planszy.
Analiza startowa: domyślnie 90 s (25–180), z ponawianiem świeżych próbek do 24 MiB
na wspólnym relay. Przerwa danych: domyślnie 30 s (8–60). Cztery równoczesne
żądania współdzielą próbę, a anulowanie ostatniego kończy ją od razu. Web dostaje
budżet analizy +45 s na uruchomienie; nie zmieniamy limitów natywnego Android TV.
205 kontroli .NET, 27 kontroli Pythona i test-web-bridge.cjs PASS; build bez
ostrzeżeń. Rzeczywisty ffprobe rozpoznał źródło z pierwszymi danymi po 35 s po
około 40 s, przy czterech żądaniach i jednym połączeniu. Próby pakietów potwierdziły
oryginalny H264 60, H264 23.976, 4K HEVC 10-bit HDR10/PQ/BT2020 59.94 z AC3 5.1
oraz H264 1080i25. Próba wielu języków i napisów sprawdzała też faktyczne piksele.
To syntetyczny harness; fizycznego Android TV na 7.0.15 jeszcze nie sprawdzono.
Stary pakiet 7.0.14 został wycofany ze względu na normalizację obrazu.
Instalator 7.0.15 ma --check-only, sumy DLL/JS i rollback własnych plików wtyczki.
Wdrożenie wymaga osobno zatwierdzonego restartu. test-web-player.cjs jest wyłączony:
stary test produkcyjny odczytywał klucz API z SQLite. Regresja offline to
`node test-web-bridge.cjs`; test klienta wymaga jawnie zalogowanej sesji.
-76
View File
@@ -59,79 +59,3 @@ The location to store your recordings on your receiver (must end with appropria
Enable VuPlus debug logging:
Plugin Debugging
## Local Jellyfin 10.11 integration
See [integration changes and deployment](README.integration.md). Internet EPG artwork, native channel preferences, optional Vu+ recordings provider; local version 6.1.0.0.
## Kandydat 7.0.9 — stare wpisy kanałów po migracji, 23.10.2026
Odczyt metadanych LiveTvChannel potwierdził stare i nowe wpisy Pulsu/Pulsu 2 oraz stare wpisy WP i TVN24 po zmianie bukietów dekodera. GetChannelStream odświeża listę z dekodera i rozwiązuje zapamiętaną referencję przed zapem, rejestracją relay i wyznaczeniem SID do probe. Najpierw zachowuje dokładny aktualny wpis; potem dopuszcza jednoznaczną zmianę samego typu usługi DVB; migrację SID/TSID rozpoznaje tylko po unikalnej dokładnej nazwie odczytanej z historycznego kanału Enigma2. Brak dopasowania lub niejednoznaczność zachowuje poprzednie zachowanie. Nie zgadujemy HD/SD ani wariantów regionalnych. Natywny identyfikator kanału i preferencje użytkownika pozostają zachowane. Stary i nowy wpis wskazują ten sam adres relay, więc nie otwierają dwóch źródeł. Odczyt bukietu ma wspólny limit 5 s; niedostępny lub zawieszony OpenWebif zachowuje próbę odtwarzania oryginalnej referencji. Anulowanie przez klienta pozostaje obowiązujące.
102 kontrole .NET10 przeszły, w tym rzeczywiste GetChannelStream z atrapą OpenWebif i natywnej biblioteki: nowy SID 5105, wspólne metadane i jedno połączenie relay dla starego/nowego Pulsu, anulowanie bez dodatkowego połączenia. Build Release: 0 ostrzeżeń, 0 błędów. Restore korzystał z cache pakietów bez sieci; NuGetAudit wyłączono jedynie dla tego odtworzenia offline. Nie potwierdza to odbioru na zalogowanym urządzeniu.
Status na moment zapisu: **7.0.9 przygotowana, niewdrożona**. W produkcji pozostaje 7.0.8. Nowa sesja Wydarzeń 24 rozpoczęta o 16:21:45 UTC dała poprawne serwerowe HLS (248 klatek 1080p / 9,88 s oraz 10,027 s audio). Restart wymagany do podmiany DLL czeka na zgodę dotyczącą tej aktywnej sesji. Osobny brak odkodowanego wyjścia CANAL+4K wymaga diagnostyki źródła ECM i nie jest naprawiany przez ten resolver. Nie zmieniono ustawień Jellyfina, wrapperów, plików Web ani konfiguracji dekodera.
### 7.0.9 wdrożona — 23.10.2026, 19:07 CEST
Po poleceniu użytkownika „Restart” wdrożono DLL o SHA256 `8db3b3700ef417c591f2361e013e20c601052bd35bc124f449c899183950c986`. Produkcja używa teraz **7.0.9.0**, co potwierdził świeży log załadowania i publiczny health. Konfiguracja serwera/wtyczki, wrappery, moduł Web i 26 plików preferencji pozostały identyczne. Dwa aktywne nagrania dekodera zachowano; nie restartowano odbiornika. Backup: `/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261023T170741733074Z`; raport `deployment-7.0.9-20261023.json`. Poprzedni akapit opisuje etap przed zgodą i wdrożeniem. Obsługa automatycznej synchronizacji okresowej jest osobnym zadaniem; 7.0.9 sprawdza aktualne referencje przy odtwarzaniu.
## 7.0.10 — automatyczna synchronizacja, 23.10.2026
Na żądanie użytkownika domyślny harmonogram zadania wtyczki „Synchronizacja kanałów Enigma2” to **start serwera i co 6 godzin**. Dodano w ustawieniach wtyczki przełącznik automatycznej synchronizacji, domyślnie włączony. Godziny można zmienić w natywnych zadaniach zaplanowanych dla tego zadania. Nie zmieniamy globalnego harmonogramu Refresh Guide.
Zadanie pobiera bukiety z limitem30s i wykrywa zmiany referencji, nazw, kolejności, numerów lub adresów logo. Przy zmianie albo braku kanałów w natywnej bibliotece uruchamia przez ITaskManager istniejące natywne RefreshGuide (limit5min), dzięki czemu aktualizowana jest właściwa Telewizja i EPG. Nie otwiera strumieni ani nie zmienia tunerów. Pusta lista lub błąd odbiornika nie uruchamia odświeżenia. Gdy RefreshGuide już trwa, synchronizacja jest odkładana do kolejnego wyzwolenia. Lista bez zmian nie wymaga ponownego pełnego odświeżania przewodnika.
Przed natywnym odświeżeniem zapisuje własny checkpoint w `data/decoder-integration/channel-sync.json`: stare identyfikatory/ref/nazwy oraz natywne ulubione. Po jednoznacznej migracji aktualizuje natywne ulubione oraz ulubione/ukryte/kolejność we własnych preferencjach użytkowników. Aktualizacje plików preferencji używają tych samych blokad co edytor, zachowują aktualne ustawienia i usuwają duplikaty. Błąd/anulowanie pozostawia checkpoint do ponowienia również wtedy, gdy natywne zadanie usunęło już stare kanały. Bezpośrednich zapisów do bazy Jellyfina nie ma. Stan ostatniej kontroli/synchronizacji jest dostępny w administracyjnym DecoderIntegration/Status.
120 kontroli .NET10 przeszło: dokładnie6h/start, zmiany numerów i logo, migracja natywnych i własnych preferencji, rozdzielenie użytkowników, pusta lista, zajęty przewodnik, częściowa awaria i ponowienie, współbieżność, anulowanie oraz wyłączenie funkcji. Źródła ani nagrania produkcyjne nie były używane w tych testach. Stan przed wdrożeniem: produkcja7.0.9, kandydat7.0.10.
### 7.0.11 — czas pełnego importu EPG
7.0.10 wdrożono o17:27UTC. Zadanie startowe poprawnie uruchomiło natywny RefreshGuide i wprowadziło aktualne referencje Pulsu/Pulsu2/WP/TVN24. Pełny import7dni EPG przekroczył jednak narzucone5min i został anulowany o17:32:55UTC. Własna synchronizacja prawidłowo zgłosiła błąd i zachowała checkpoint216starych wpisów; nie oznaczono importu jako udanego. W7.0.11 usunięto własny limit czasu natywnego zadania — używane są zwykłe zasady jego wykonania i anulowania. Limit30s dotyczy tylko wstępnego pobrania bukietu. Dodano regresję na brak krótkiego MaxRuntimeTicks. Checkpoint i natywne preferencje pozostają zgodne pomiędzy7.0.10 i7.0.11, więc start nowej wersji ponawia migrację z oryginalnego stanu sprzed odświeżenia.
### Potwierdzone wdrożenie i pierwszy pełny przebieg7.0.11
Wdrożenie17:35:43–17:36:05UTC: świeży loader7.0.11, DLL SHA256 `10107a4e9c81c972f9d98b664ec9f14208f5e1dd95df769760f3540955089ade`, ustawienia serwera i wrappery bez zmian, nagranie dekodera zachowane. NativeRefreshGuide zakończyło7min8s importu o17:43:14UTC, po czym zadanie wtyczki zakończyło się statusemCompleted. Checkpoint odzyskano i wyczyszczono po powodzeniu, zapisano fingerprint oraz czas ostatniej synchronizacji. Przeniesiono4zmienione identyfikatory. Natywna biblioteka ma214unikalnych kanałów (216pozycji bukietu), nowe referencje Puls/Puls2/WP/TVN24 są obecne. Zadanie nie ma nadpisanych wyzwalaczy, więc korzysta ze sprawdzonego domyślnego harmonogramu6h/start z wdrożonej DLL.
121 kontroli backendu PASS; build bez ostrzeżeń i błędów. Produkcyjne sprawdzenie: `channel-sync-verification-20261023.json`; odczyt `BaseItems` oraz samychID `Users` służy wyłącznie kontroli metadanych, bez pól uwierzytelniania. 26historycznych plików preferencji nie ma już właścicieli w19bieżących kontach; zachowano je, nie przepisując danych usuniętych użytkowników. Zawierają32odwołania do4starych kanałów; nie są to preferencje bieżących kont. Nie jest to pełny test wszystkich klientów ani naprawa przerywanego braku AV Polsatu Sport1. Użytkownik potwierdził odtwarzanie Polsatu SportExtra1 po ponownym uruchomieniu tego samego kanału; pierwsza próba nie rozpoznała AV po22s, kolejna otworzyła się po5,35s bez zmiany referencji i konfiguracji dekodera.
### 7.0.12 — plansze FFmpeg, podtrzymanie streamu i język użytkownika
Nowa opcja `EnableContinuitySlates` utrzymuje otwartą listę HLS podczas oczekiwania na dekoder i po utracie danych. Wtyczka ponawia odbiór co 3–10 sekund, bez kończenia odtwarzania po stronie widza. Po odzyskaniu użytecznego AV wraca do kanału. Granica segmentu 2,24 s odpowiada 112 klatkom przy 50 Hz oraz 105 ramkom AAC 48 kHz. Znaczniki PTS/DTS/PCR są przenoszone na wspólny zegar, a reset liczników TS jest sygnalizowany w pakietach. Początkowy segment enkodera jest pomijany.
Jeden producent FFmpeg odbiera dany kanał przez istniejący współdzielony relay. Widzowie mają osobne identyfikatory i języki plansz, ale korzystają z tych samych segmentów obrazu kanału. Playlisty nigdy nie zawierają ENDLIST podczas awarii. Pamięć segmentów jest ograniczona; zatrzymanie ostatniego widza lub brak żądań HTTP przez 60 s zatrzymuje producenta. Dotychczasowy relay może jeszcze zachować fizyczne połączenie przez 15 s. Limit to cztery kanały i 64 widzów jednocześnie. Nagrania w tle nie korzystają z plansz.
Wspólny katalog tekstów zawiera polski, angielski, niemiecki, francuski, hiszpański i ukraiński. Web przekazuje język aktualnego interfejsu z `<html lang>`; wtyczka zapamiętuje go krótko dla konkretnego użytkownika i urządzenia. Następne źródła to zapisana preferencja `usersettings/emby/language` bieżącego profilu (jeśli istnieje), Accept-Language klienta i `ServerConfiguration.UICulture`, a ostatecznie angielski. W zbadanej instalacji język web był zapisany lokalnie, a serwer miał UICulture=pl; nie było serwerowych preferencji language. Nie używamy języka metadanych, audio ani napisów jako języka interfejsu. Android TV bez przekazanego języka i bez preferencji profilu korzysta z języka serwera.
Moduł web pokazuje stan do pierwszej odtworzonej klatki, umożliwia anulowanie i nie zabija już działającego streamu po upływie pierwotnych 45 s podczas późniejszego buforowania. Opcjonalne `ShowNativeStartupMessages` korzysta z natywnego DisplayMessage wyłącznie na żądającym urządzeniu Android TV; jest niezależne od obrazu FFmpeg. Dla trybu plansz można je wyłączyć, aby nie dublować komunikatów.
**Koszt i zakres:** tryb jest domyślnie wyłączony w nowych konfiguracjach. Włączenie normalizuje kanał do H264 1920×1080 50 kl./s, CRF20 z maksimum 12 Mb/s, AAC 48 kHz stereo. Nie zachowuje 4K, HDR, wielokanałowego audio, dodatkowych ścieżek ani napisów. Wymaga programowego kodowania; pomiary dotyczą konkretnego testu. Wyłączenie przywraca dotychczasowy odbiór. Nie zmienia konfiguracji Jellyfina, wrapperów, profili dekodera ani OSCam.
Weryfikacja: `Tests` obejmuje izolację użytkowników/urządzeń, uprawnienia, migrację kanałów i wcześniejsze mechanizmy streamu, katalog języków, zegar TS i ograniczoną pamięć przy symulowanej sześciogodzinnej awarii. `test-web-bridge.cjs` sprawdza web bez kont produkcyjnych. `experiments/run-continuity-e2e.py` uruchamia izolowany harness z prawdziwym FFmpeg, dwoma językami i sterowanym źródłem syntetycznym; opcja `--long` utrzymuje awarię 75 s, sprawdza odzyskanie obrazu, rygorystyczny odczyt AV i wszystkie 12 plansz. Nie jest to test fizycznego Android TV ani produkcyjnej sesji Jellyfina. Należy jawnie odróżnić przygotowany kandydat od wersji aktywnej na serwerze.
### 7.0.13 — zachowanie rozdzielczości źródła
Na żądanie użytkownika usunięto sztywny limit Full HD z domyślnego trybu plansz. `PreserveSourceResolution` domyślnie wynosi true: filtr producenta nie skaluje ani nie zmienia SAR kanału. Rozdzielczość początkowej planszy nie jest już wpisywana jako rozmiar źródła do natywnej negocjacji Jellyfina. Profile urządzeń mogą nadal ograniczać wyjście zgodnie z możliwościami klienta i jego ustawieniami jakości. Nie ustawiono sztywnego limitu szerokości/wysokości producenta; maksymalny segment zwiększono do 32 MiB, a limit przepływności kodera do 48 Mb/s. Kodek i dźwięk pozostają H264 8-bit, 50 kl./s i AAC stereo — ta zmiana nie dodaje zachowania HDR, oryginalnego kodeka, ścieżek audio ani napisów.
Izolowana próba z rzeczywistym FFmpeg potwierdziła przejścia plansza → 720×576, 1280×720, 1920×1080, 2560×1440 i 3840×2160. Odczytano rzeczywiste wymiary zdekodowanych klatek zarówno z MPEG-TS, jak i HLS fMP4, a dla SD również SAR16:15. To test syntetyczny o małej zmienności obrazu, nie dowód wydajności każdego kanału UHD ani test fizycznej aplikacji. Dodatkowa próba z zachowaniem 720p potwierdziła 75 sekund awarii, właściwe języki plansz i odzyskanie obrazu bez ponownego otwierania klienta. Testy wykorzystują również natywny StreamBuilder Jellyfina12.2 do sprawdzenia limitów urządzeń od SD do UHD.
7.0.13 jest kandydatem do odrębnego zatwierdzenia; nie nadpisuje automatycznie wdrożonej7.0.11. Wcześniejsza zgoda dotyczyła tylko nocnego Jellytel. Nie aktywować starego kandydata7.0.12 ograniczonego do Full HD.
### 7.0.14 — wybór języka audio i nadawanych napisów
Analiza programu zachowuje wszystkie ścieżki audio oraz napisy DVB i teletekstowe, języki ISO639, tytuły oraz oznaczenia default/forced/hearing-impaired. Nazwy kodeków napisów są normalizowane do natywnych DVBSUB/DVBTXT Jellyfina, aby bitmapy nie trafiały do ścieżki ekstrakcji tekstu. Natywne menu odtwarzacza wybiera język i napisy; wtyczka nie wymusza pierwszego audio ani napisów na stałe.
Programy z wieloma ścieżkami lub napisami są przepakowywane do jednego, jawnie uporządkowanego MPEG-TS/HLS. Wejście jest mapowane po PID, a wyjście zawsze ma wideo, wszystkie audio i wszystkie napisy. To usuwa problem -1/automatycznego wyboru/-sn w natywnym mapowaniu Jellyfina i nie uzależnia wyboru od pozycji PID danych EPG. Segmenter MPEG-TS zachowuje napisy DVB/teletekst zamiast ograniczać je do WebVTT. Odbiór nadal korzysta ze współdzielonego relay; analiza i widzowie nie otwierają osobnych połączeń do dekodera.
Z włączonymi planszami każdy język ma oddzielną ścieżkę AAC stereo. Plansza ma odpowiadające im ścieżki ciszy oraz deklaracje napisów i pustą stronę DVB. Wersje stron, obiektów, regionów i definicji obrazu DVB są porządkowane oddzielnie dla widza, aby pusty napis z planszy nie zablokował późniejszego napisu o tej samej wersji. Powtórne pobranie segmentu używa tego samego planu wersji; bufory i liczba obiektów są ograniczone. Rozmiar obrazu źródła pozostaje zachowany. Po udanej analizie negocjacja dostaje rzeczywiste wymiary kanału, a nie planszy; przy nieznanych metadanych nie zgadujemy rozdzielczości. Tryb nadal koduje wideo do H264 8-bit/50Hz i każde audio do AAC stereo, bez zachowania HDR/5.1.
Przy wyłączonych planszach bogaty program jest tylko przepakowywany: oryginalne kodeki wideo/audio, liczba kanałów audio i metadane koloru pozostają zachowane. Ścieżka jednego audio bez napisów zachowuje poprzednie zachowanie. Nagrania w tle nie otrzymują plansz. Przepakowanie zmienia nagłówki kontenera i porządkuje wersje napisów; nie jest kopią binarną całego TS.
Lista ścieżek jest wykrywana przed negocjacją odtwarzania. Ostatnie poprawne metadane są trzymane w pamięci maksymalnie6h dla256 źródeł/programów i pozwalają szybko pokazać planszę podczas awarii. Po zimnym starcie bez wcześniejszych danych i przy niedostępnym źródle nie można poznać jego języków: wtedy plansza używa układu zastępczego, a pełny wybór jest dostępny przy ponownym otwarciu po udanej analizie. Nie są wymyślane języki nieobecnych ścieżek. Ten cache nie zmienia harmonogramu synchronizacji kanałów co6h.
Kandydat pozostaje oddzielony od aktywnej wersji7.0.11. Nie wdrażać go ani nie rozszerzać nocnego planu Jellytel bez wyraźnego zatwierdzenia użytkownika.
Weryfikacja7.0.14:182 kontrole .NET przeszły, build i publikacja bez ostrzeżeń. Rzeczywisty FFmpeg na CT100191 z samodzielnie wygenerowanym MPEG-TS potwierdził dwa różne języki audio (440/880Hz), dwa języki bitmap DVB i dodatkowy strumień teletekstu. Trzej odbiorcy (pełen program, drugie audio, drugie audio z napisami) przetrwali12s utraty źródła, zachowując jedno połączenie fizyczne. Sprawdzono rzeczywiste pakiety wszystkich napisów i różnicę pikseli po nałożeniu bitmapy. Wykryty w próbie konflikt wersji pustej i nadawanej strony DVB został usunięty; same pakiety lub poprawny exit FFmpeg nie były uznawane za dowód obrazu. Dodatkowo powtórzono75s awarii z PL/EN oraz SD/720/1080/1440/2160 w TS i fMP4. Testy pozostają syntetyczne, bez dekodera, kont produkcyjnych i fizycznego Android TV. Teletekst sprawdzono do poziomu wykrywania i przekazywania pakietów; renderowanie konkretnej strony na rzeczywistym kliencie wymaga próby po wdrożeniu. Raporty w projekcie Infra: `enigma-diagnostics/startup-feedback-preview/verified/`.
-222
View File
@@ -1,222 +0,0 @@
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<bool, string> check)
{
var limits = new LiveStreamProbe.SamplingLimits(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(300), PacketSize * 2, PacketSize * 24);
var snapshots = new List<string>();
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 rejectedSampleStream = new ProbePacketStream(delay: 1);
using (var handler = new ProbeHttpHandler(rejectedSampleStream))
using (var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan })
{
var probe = new LiveStreamProbe(client, (path, programme, extended, token) =>
{
snapshots.Add(path); attempts++;
if (!extended) throw new LiveStreamProbe.ProbeProcessException(1);
return Task.FromResult(Metadata(Sid, 1920));
}, limits, TimeSpan.FromSeconds(2));
check((await probe.Get("http://relay/channel", CancellationToken.None, Sid))[0].Width == 1920 && attempts == 2 && handler.Calls == 1,
"a first ffprobe sample rejection retries analysis on the same HTTP capture");
}
check(rejectedSampleStream.Disposed && Deleted(snapshots), "retried process rejection cleans up subscription and sample files");
snapshots.Clear(); attempts = 0;
var failedAnalysisStream = new ProbePacketStream(delay: 1);
using (var handler = new ProbeHttpHandler(failedAnalysisStream))
using (var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan })
{
var probe = new LiveStreamProbe(client, (path, programme, extended, token) =>
{
snapshots.Add(path); attempts++;
throw new InvalidOperationException("Analysis implementation failed.");
}, limits, TimeSpan.FromSeconds(2));
var error = await Failure(probe.Get("http://relay/channel", CancellationToken.None, Sid));
check(attempts == 1 && handler.Calls == 1 && error.Contains("cause=probe_analysis_failure"),
"an unrelated first analysis failure is not retried");
}
check(failedAnalysisStream.Disposed && Deleted(snapshots), "unrelated analysis failure cleans up subscription and sample files");
snapshots.Clear(); attempts = 0;
var initiallyQuietStream = new ProbePacketStream(delay: 150);
using (var handler = new ProbeHttpHandler(initiallyQuietStream))
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 && snapshots.Count == 1 && analysingStream.Disposed && Deleted(snapshots) && error.Contains("cause=deadline"), "deadline cancels analysis without retry, joins the reader and removes temporary files");
}
}
private static bool Deleted(IEnumerable<string> paths) => paths.All(path => !File.Exists(path) && !File.Exists(path[..^10]));
private static async Task<string> 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<HttpResponseMessage> 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<int> ReadAsync(Memory<byte> 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();
}
-38
View File
@@ -1,38 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Entities;
public static class BroadcastTrackTests
{
public static async Task Run(Action<bool,string> check)
{
const string json="""{"streams":[{"index":0,"id":"0x100","codec_type":"video","codec_name":"h264","width":1920,"height":1080},{"index":2,"id":"0x102","codec_type":"audio","codec_name":"ac3","channels":6,"tags":{"language":"pol","title":"Polski"},"disposition":{"default":1}},{"index":3,"id":"0x103","codec_type":"audio","codec_name":"aac","channels":2,"tags":{"language":"eng","title":"English"}},{"index":5,"id":"0x105","codec_type":"subtitle","codec_name":"dvb_subtitle","tags":{"language":"pol","title":"Napisy"},"disposition":{"forced":1,"hearing_impaired":1},"extradata":"\n00000000: 0001 0001 ....\n"},{"index":6,"id":"0x106","codec_type":"subtitle","codec_name":"dvb_teletext","tags":{"language":"eng"}},{"index":1,"codec_type":"data","codec_name":"bin_data"}]}""";
var parsed=LiveStreamProbe.Parse(json);
check(parsed.Count==5&&parsed.Count(s=>s.Type==MediaStreamType.Subtitle)==2,"probe retains both audio languages and broadcast subtitles while excluding data PIDs");
check(parsed[3].Codec=="DVBSUB"&&!parsed[3].IsTextSubtitleStream&&parsed[4].Codec=="DVBTXT","native Jellyfin codec names distinguish DVB bitmap and teletext subtitles");
check(parsed[3].IsForced&&parsed[3].IsHearingImpaired&&parsed[1].IsDefault&&parsed[1].Title=="Polski","language labels and default/forced/hearing-impaired dispositions survive probing");
check(parsed[3] is BroadcastMediaStream {TransportId:261,SubtitleExtraData:var extra}&&extra.SequenceEqual(new byte[]{0,1,0,1}),"probe preserves transport PIDs and DVB page IDs");
var layout=new BroadcastTracks(parsed);
check(layout.Output[0].Width==1920&&layout.Output[0].Height==1080&&BroadcastTracks.Fallback().Output[0].Width==null,"native negotiation uses probed channel dimensions, never the status picture dimensions");
check(layout.Output.Select(s=>s.Index).SequenceEqual(Enumerable.Range(0,5))&&layout.Output[2].Language=="eng","explicitly ordered program exposes distinct stable selectable indices");
check(layout.Output[1].Codec=="ac3"&&layout.Output[1].Channels==6&&layout.Output[2].Codec=="aac", "every audio language retains its original codec and surround channels");
check(EncodingHelper.FindIndex(layout.Output,layout.Output[2])==2&&EncodingHelper.GetSubtitleStreamIndexForFfmpeg(new(){MediaStreams=layout.Output},layout.Output[3])==3,"installed Jellyfin resolves alternate audio and subtitle maps without unknown-index auto-selection");
check(layout.MapArguments().Contains("0:i:261")&&!layout.MapArguments().Contains("-sn"),"producer maps broadcast PIDs instead of unstable probe positions and retains subtitles");
var untouched=new BroadcastTracks(parsed,false);
check(untouched.Output[1].Codec=="ac3"&&untouched.Output[1].Channels==6,"disabled slates preserve original audio codec and surround channels through remux");
var section=BroadcastSlateProgram.Section(layout);uint crc=0xffffffff;
foreach(var value in section){crc^=(uint)value<<24;for(var bit=0;bit<8;bit++)crc=(crc&0x80000000)!=0?(crc<<1)^0x04c11db7:crc<<1;}
check(crc==0&&section.Contains((byte)89)&&section.Contains((byte)86),"status program map has valid MPEG-TS CRC and DVB/teletext descriptors");
var subtitleClock=new DvbSubtitleVersions(SyntheticBroadcastFixture.Tracks());var cc=0;
var wire=SyntheticBroadcastFixture.Packets(SyntheticBroadcastFixture.Bitmap(0),126000,259,ref cc).SelectMany(b=>b).ToArray();
var first=wire.ToArray();subtitleClock.Apply(first,0);var next=wire.ToArray();subtitleClock.Apply(next,1);
check(!first.SequenceEqual(next),"DVB pages, objects and display definitions get fresh versions across status/live boundaries");
var retry=wire.ToArray();subtitleClock.Apply(retry,1);check(retry.SequenceEqual(next),"repeated HLS segment requests reuse the same DVB version plan");
var probe=new LiveStreamProbe((_,_)=>Task.FromResult(json));await probe.Get("channel",CancellationToken.None);
check(probe.LastKnown("channel")?.Count==5&&probe.LastKnown("other",1)==null&&probe.LastKnown("channel",2)==null,"last-known track catalogue is isolated by source and programme");
}
}
-114
View File
@@ -1,114 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
public static class BroadcastTracksE2E
{
private static string encoder,output,fixture;
private static volatile bool enabled=true;
private static int active,maximum,connections;
public static async Task Run(string executable,string folder)
{
encoder=executable;output=Path.GetFullPath(folder);if(Directory.Exists(output))throw new Exception("Choose a new output directory");Directory.CreateDirectory(output);
using(var make=Start(new[]{"-f","lavfi","-i","testsrc2=size=640x360:rate=25","-f","lavfi","-i","sine=frequency=440:sample_rate=48000","-f","lavfi","-i","sine=frequency=880:sample_rate=48000","-map","0:v","-map","1:a","-map","2:a","-t","16","-c:v","libx264","-threads","1","-preset","ultrafast","-g","25","-c:a","aac","-ac","2","-metadata:s:a:0","language=pol","-metadata:s:a:1","language=eng","-f","mpegts",Path.Combine(output,"base.ts")},"fixture"))
{await make.WaitForExitAsync();Require(make.ExitCode==0,"synthetic two-language AV fixture");}
fixture=Path.Combine(output,"broadcast.ts");await File.WriteAllBytesAsync(fixture,SyntheticBroadcastFixture.AddSubtitles(await File.ReadAllBytesAsync(Path.Combine(output,"base.ts"))));
var metadata=LiveStreamProbe.Parse(await Probe(fixture));
Require(metadata.Count(s=>s.Type==MediaBrowser.Model.Entities.MediaStreamType.Audio)==2&&metadata.Count(s=>s.Codec=="DVBSUB")==2&&metadata.Count(s=>s.Codec=="DVBTXT")==1,"actual ffprobe sees two audio, two DVB bitmap languages and a teletext subtitle track");
using var listener=new TcpListener(IPAddress.Loopback,0);listener.Start();var port=((IPEndPoint)listener.LocalEndpoint).Port;listener.Stop();
var builder=WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
using var streams=new ContinuityStreams(encoder,Path.Combine(output,"cache"),port,new StartupMessages(),NullLogger.Instance);
using var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(),port,TimeSpan.FromSeconds(1),TimeSpan.FromSeconds(10),TimeSpan.FromSeconds(5));
builder.Services.AddSingleton(streams);builder.Services.AddSingleton(relay);builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly);
await using var app=builder.Build();app.MapControllers();app.MapGet("/broadcast.ts",Fixture);await app.StartAsync();
Process recording=null,english=null,subtitled=null;
try
{
var url=relay.Register($"http://127.0.0.1:{port}/broadcast.ts");var layout=new BroadcastTracks(metadata);
var pl=await streams.Open(url,"pl",CancellationToken.None,layout);var en=await streams.Open(url,"en",CancellationToken.None,layout);
Require(pl.MediaStreams.Count==6&&pl.MediaStreams[2].Language=="eng"&&pl.MediaStreams[4].Codec=="DVBSUB","native source advertises every selectable track before live data");
await File.WriteAllBytesAsync(Path.Combine(output,"waiting.ts"),streams.Segment(pl.Id,0));
var waiting=LiveStreamProbe.Parse(await Probe(Path.Combine(output,"waiting.ts")));
Require(waiting.Count==6&&waiting[2].Language=="eng"&&waiting[4].Language=="eng","waiting clip declares both silent audio tracks and both DVB subtitle tracks");
recording=Start(new[]{"-xerror","-i",pl.Path,"-t","60","-map","0","-c","copy",Path.Combine(output,"all.ts")},"all-client");
english=Start(new[]{"-xerror","-i",en.Path,"-t","60","-map","0:v:0","-map","0:2","-c","copy",Path.Combine(output,"english.mkv")},"english-client");
subtitled=Start(new[]{"-xerror","-i",en.Path,"-filter_complex","[0:v:0][0:s:1]overlay=eof_action=pass:shortest=0[v]","-map","[v]","-map","0:2","-t","60","-c:v","libx264","-threads","1","-preset","ultrafast","-c:a","copy",Path.Combine(output,"subtitled.mkv")},"subtitled-client");
await Task.Delay(18000);Require(JsonSerializer.SerializeToElement(streams.Status()).GetProperty("LiveSegmentsProduced").GetInt64()>0,"multitrack live segments reach viewers");
enabled=false;await Task.Delay(12000);enabled=true;
await Task.WhenAll(recording.WaitForExitAsync(),english.WaitForExitAsync(),subtitled.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(65));
Require(recording.ExitCode==0&&english.ExitCode==0&&subtitled.ExitCode==0,"all-track, selected-English and selected-subtitle clients survive source loss and recovery");
streams.Close(pl.Id);streams.Close(en.Id);await Task.Delay(2000);Require(active==0&&maximum==1,"two viewer languages keep one receiver subscription and release it");
using(var decode=Start(new[]{"-xerror","-i",Path.Combine(output,"all.ts"),"-map","0:v","-map","0:a","-f","null","-"},"decode")){await decode.WaitForExitAsync();Require(decode.ExitCode==0,"strict video and both-audio decode across status/live switches");}
var actual=JsonDocument.Parse(await Probe(Path.Combine(output,"all.ts"),true));
var subtitles=actual.RootElement.GetProperty("streams").EnumerateArray().Where(s=>s.GetProperty("codec_type").GetString()=="subtitle").ToArray();
Require(subtitles.Length==3&&subtitles.All(s=>int.Parse(s.GetProperty("nb_read_packets").GetString())>0),"actual packets for both DVB languages and teletext survive source loss and recovery");
var frequencies=new List<double>();
for(var track=0;track<2;track++)
{
var path=Path.Combine(output,$"audio-{track}.pcm");using var audio=Start(new[]{"-i",Path.Combine(output,"all.ts"),"-map",$"0:a:{track}","-ac","1","-ar","48000","-f","s16le",path},"audio-"+track);
await audio.WaitForExitAsync();Require(audio.ExitCode==0,"extract independently selectable audio "+track);var bytes=await File.ReadAllBytesAsync(path);var crossings=0;
var windows=new List<double>();
for(var at=0;at+96000<=bytes.Length;at+=96000)
{
var peak=0;crossings=0;
for(var i=at;i<at+96000;i+=2){var value=BitConverter.ToInt16(bytes,i);peak=Math.Max(peak,Math.Abs((int)value));if(i>at&&BitConverter.ToInt16(bytes,i-2)<0&&value>=0)crossings++;}
if(peak>500)windows.Add(crossings);
}
Require(windows.Count>0,"audio language has non-silent live data "+track);frequencies.Add(windows.Order().ElementAt(windows.Count/2));
}
Require(Math.Abs(frequencies[0]-440)<12&&Math.Abs(frequencies[1]-880)<12,"audio choices contain distinct Polish/English fixture tones, not duplicate first audio");
// Exercise Jellyfin's bitmap-burn-in delivery mechanism, independently of a physical client.
using(var overlay=Start(new[]{"-i",Path.Combine(output,"all.ts"),"-filter_complex","[0:v:0][0:s:1]overlay=eof_action=pass:shortest=0[v]","-map","[v]","-t","60","-f","null","-"},"subtitle-overlay"))
{await overlay.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(45));Require(overlay.ExitCode==0,"selected DVB bitmap subtitles can be decoded and overlaid");}
var difference=Path.Combine(output,"subtitle-difference.gray");
using(var pixels=Start(new[]{"-i",Path.Combine(output,"all.ts"),"-filter_complex","[0:v:0]split[a][b];[b][0:s:1]overlay=eof_action=pass:shortest=0[sub];[a][sub]blend=all_mode=difference,crop=96:32:64:300,format=gray[v]","-map","[v]","-t","60","-f","rawvideo",difference},"subtitle-pixels"))
{await pixels.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(45));Require(pixels.ExitCode==0,"compare selected subtitle render against the same video frames");}
var rendered=await File.ReadAllBytesAsync(difference);Require(rendered.Count(b=>b>40)>96*32,"selected subtitle bitmap changes actual rendered pixels");
// With slates disabled the ordered transport only remuxes original codecs.
var direct=await streams.Open(url,"en",CancellationToken.None,new BroadcastTracks(metadata,false),false);
using(var client=Start(new[]{"-xerror","-i",direct.Path,"-t","5","-map","0:v","-map","0:a:1","-c","copy",Path.Combine(output,"passthrough.ts")},"passthrough"))
{try{await client.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30));Require(client.ExitCode==0,"disabled-slates multitrack remux supports alternate audio");}finally{if(!client.HasExited)client.Kill(true);streams.Close(direct.Id);}}
await Task.Delay(2000);
var report=new{Passed=true,SyntheticOnly=true,ReceiverConnectionsOpened=0,AudioLanguages=new[]{"pol","eng"},SubtitleLanguages=new[]{"pol","eng"},AudioFrequencies=frequencies,SourceFailureSeconds=12,MaximumConcurrentSourceConnections=maximum,RemainingConnections=active,SourceConnections=connections,SubtitlePacketsVerified=true,TeletextPacketsVerified=true,SubtitleOverlayDecoded=true,SubtitleRenderedPixelsVerified=true,SelectedSubtitleLiveClientVerified=true,PhysicalAndroidTvTested=false,ActualJellyfinPlayerTested=false};
await File.WriteAllTextAsync(Path.Combine(output,"result.json"),JsonSerializer.Serialize(report,new JsonSerializerOptions{WriteIndented=true}));Console.WriteLine(JsonSerializer.Serialize(report));
}
finally{if(recording is{HasExited:false})recording.Kill(true);if(english is{HasExited:false})english.Kill(true);if(subtitled is{HasExited:false})subtitled.Kill(true);recording?.Dispose();english?.Dispose();subtitled?.Dispose();streams.Dispose();await app.StopAsync();}
}
private static async Task<string> Probe(string file,bool packets=false)
{
var info=new ProcessStartInfo(Path.Combine(Path.GetDirectoryName(encoder),"ffprobe")){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
foreach(var arg in new[]{"-v","error","-analyzeduration","5000000","-probesize","8000000","-show_streams","-show_data","-of","json"})info.ArgumentList.Add(arg);if(packets)info.ArgumentList.Add("-count_packets");info.ArgumentList.Add(file);
using var process=Process.Start(info);var text=process.StandardOutput.ReadToEndAsync();var errors=process.StandardError.ReadToEndAsync();await process.WaitForExitAsync();Require(process.ExitCode==0,"real FFmpeg track probe "+Path.GetFileName(file));var error=await errors;if(error.Length>0)await File.WriteAllTextAsync(Path.Combine(output,"probe-"+Path.GetFileName(file)+".log"),error);return await text;
}
private static Process Start(IEnumerable<string> args,string name)
{
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardError=true,RedirectStandardOutput=true};
foreach(var arg in new[]{"-hide_banner","-loglevel","warning","-nostdin","-threads","1","-filter_threads","1"}.Concat(args))info.ArgumentList.Add(arg);
var process=Process.Start(info);process.ErrorDataReceived+=(_,e)=>{if(e.Data!=null)lock(typeof(BroadcastTracksE2E))File.AppendAllText(Path.Combine(output,name+".log"),e.Data+"\n");};process.BeginErrorReadLine();return process;
}
private static async Task Fixture(HttpContext context)
{
Interlocked.Increment(ref connections);if(!enabled){context.Response.StatusCode=503;return;}var count=Interlocked.Increment(ref active);maximum=Math.Max(maximum,count);
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
foreach(var arg in new[]{"-hide_banner","-loglevel","error","-nostdin","-re","-stream_loop","-1","-i",fixture,"-map","0","-c","copy","-f","mpegts","pipe:1"})info.ArgumentList.Add(arg);
using var process=Process.Start(info);process.ErrorDataReceived+=(_,_)=>{};process.BeginErrorReadLine();context.Response.ContentType="video/mp2t";
try{var buffer=new byte[65536];while(enabled&&!context.RequestAborted.IsCancellationRequested){var n=await process.StandardOutput.BaseStream.ReadAsync(buffer,context.RequestAborted);if(n==0)break;await context.Response.Body.WriteAsync(buffer.AsMemory(0,n),context.RequestAborted);await context.Response.Body.FlushAsync(context.RequestAborted);}}
catch(OperationCanceledException){}catch(IOException){}
finally{if(!process.HasExited)process.Kill(true);await process.WaitForExitAsync();Interlocked.Decrement(ref active);context.Abort();}
}
private static void Require(bool condition,string text){if(!condition)throw new Exception(text);Console.WriteLine("PASS "+text);}
}
-32
View File
@@ -1,32 +0,0 @@
using System;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Controller.LiveTv;
public static class ChannelReferenceTests
{
public static void Run(Action<bool,string> check)
{
const string oldPuls = "1:0:1:3D66:2C88:13E:820000:0:0:0:";
const string newPuls = "1:0:1:13F1:5DC:13E:820000:0:0:0:";
const string oldTvn = "1:0:1:3DD2:640:13E:820000:0:0:0:";
const string newTvn = "1:0:19:3DD2:640:13E:820000:0:0:0:";
var channels = new[] { new ChannelInfo { Id=newPuls, Name="TV PULS HD" }, new ChannelInfo { Id=newTvn, Name="TVN24 HD" } };
check(ChannelReferenceResolver.Resolve(newPuls,channels,()=>throw new Exception("Unnecessary library lookup"))==newPuls,"current channel uses its exact reference without historical lookup");
check(ChannelReferenceResolver.Resolve(oldTvn,channels,()=>throw new Exception("Unnecessary library lookup"))==newTvn,"saved TVN24 reference resolves changed service type");
check(ChannelReferenceResolver.Resolve(oldPuls,channels,()=>"TV PULS HD")==newPuls,"saved Puls reference resolves migrated SID and transponder");
check(ChannelReferenceResolver.Resolve(oldPuls,channels,()=>"tv puls hd ")==newPuls,"historical name matches case and surrounding whitespace only");
check(ChannelReferenceResolver.Resolve(oldPuls,channels,()=>"TV PULS")==oldPuls,"HD and SD names are not guessed");
check(ChannelReferenceResolver.Resolve(oldPuls,channels,()=>null)==oldPuls,"unknown historical name preserves original service");
var duplicate = new[] { channels[0], channels[0] };
check(ChannelReferenceResolver.Resolve(oldPuls,duplicate,()=>"TV PULS HD")==newPuls,"same reference in two bouquets is not ambiguous");
var ambiguous = new[] { channels[0], new ChannelInfo { Id=oldTvn, Name="TV PULS HD" } };
check(ChannelReferenceResolver.Resolve(oldPuls,ambiguous,()=>"TV PULS HD")==oldPuls,"different services with the same name are not guessed");
var types = new[] { channels[1], new ChannelInfo { Id="1:0:1F:3DD2:640:13E:820000:0:0:0:",Name="TVN24 HD" } };
check(ChannelReferenceResolver.Resolve(oldTvn,types,()=>throw new Exception("Ambiguous identity must not fall back"))==oldTvn,"ambiguous DVB identity cannot select arbitrary service type");
var presentOld = new[] { channels[0], new ChannelInfo { Id=oldPuls,Name="TV PULS HD" } };
check(ChannelReferenceResolver.Resolve(oldPuls,presentOld,()=>throw new Exception("Exact reference wins"))==oldPuls,"reference still present in bouquet wins over same-name alternative");
check(ChannelReferenceResolver.Resolve(oldTvn,Array.Empty<ChannelInfo>(),()=>"TVN24 HD")==oldTvn,"empty bouquet cannot redirect a saved channel");
const string nonDvb = "4097:0:1:3DD2:640:13E:820000:0:0:0:";
check(ChannelReferenceResolver.Resolve(nonDvb,channels,()=>null)==nonDvb,"non-DVB reference cannot use service-type fallback");
}
}
-165
View File
@@ -1,165 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
public static class ChannelSyncTests
{
public static async Task Run(Action<bool,string> check)
{
var previous=Plugin.Instance;
var root=Path.Combine(Path.GetTempPath(),"channel-sync-tests-"+Guid.NewGuid().ToString("N"));Directory.CreateDirectory(root);
try
{
var paths=Paths(root);var plugin=new Plugin(paths,DispatchProxy.Create<IXmlSerializer,SavedChannelPlaybackTests.NoXmlFiles>());
check(plugin.Configuration.EnableAutomaticChannelSync,"legacy plugin configuration enables periodic synchronization by default");
using(var c=new Context(root))
{
var triggers=c.Task.GetDefaultTriggers().ToArray();
check(triggers.Length==2&&triggers.Single(t=>t.Type==TaskTriggerInfoType.IntervalTrigger).IntervalTicks==TimeSpan.FromHours(6).Ticks&&triggers.Any(t=>t.Type==TaskTriggerInfoType.StartupTrigger),"channel synchronization defaults to startup and exactly six hours");
await c.Store.Save(c.Owner.Id,new ChannelPreferences{FavoriteIds=new[]{c.OldId.ToString(),c.NewId.ToString()},HiddenIds=new[]{c.OldId.ToString()},OrderedIds=new[]{c.OtherId.ToString(),c.OldId.ToString()},DefaultView="favorites"},CancellationToken.None);
await c.Store.Save(c.SecondOwner.Id,new ChannelPreferences{HiddenIds=new[]{c.OldId.ToString()}},CancellationToken.None);
await c.Run();
check(c.Executions==1&&c.SourceCalls==1,"changed bouquet invokes native RefreshGuide once without opening a receiver stream");
check(c.NativeRuntimePolicy,"full guide import retains native runtime policy without an artificial short timeout");
var prefs=await c.Store.Load(c.Owner.Id,CancellationToken.None);
check(prefs.FavoriteIds.SequenceEqual(new[]{c.NewId.ToString("N")})&&prefs.HiddenIds.SequenceEqual(new[]{c.NewId.ToString("N")})&&prefs.OrderedIds.SequenceEqual(new[]{c.OtherId.ToString("N"),c.NewId.ToString("N")})&&prefs.DefaultView=="favorites","native channel migration remaps and deduplicates saved favorites, hidden IDs and ordering");
check(c.Data[(c.Owner.Id,c.NewId)].IsFavorite&&c.Data[(c.Owner.Id,c.NewId)].PlayCount==7,"native favorite migration preserves existing play history");
check(!c.Data[(c.SecondOwner.Id,c.NewId)].IsFavorite&&(await c.Store.Load(c.SecondOwner.Id,CancellationToken.None)).HiddenIds.Single()==c.NewId.ToString("N"),"channel migration preserves preference and favorite isolation between users");
check(c.Task.LastSynchronizedUtc.HasValue&&c.ReadState().Pending==null,"successful native synchronization commits its checkpoint");
await c.Run();check(c.Executions==1,"unchanged bouquet skips redundant native guide refresh");
c.Current[0].Number="9";await c.Run();check(c.Executions==2,"receiver channel renumbering triggers native synchronization");
c.Current=Array.Empty<ChannelInfo>();await MustFail(c.Run());check(c.Executions==2,"empty receiver bouquet never triggers destructive native refresh");
}
using(var c=new Context(root))
{
c.Worker.CurrentState=TaskState.Running;await c.Run();
check(c.Executions==0&&c.Task.LastError=="NativeGuideBusy","running native guide is not duplicated or cancelled");
}
using(var c=new Context(root))
{
await c.Store.Save(c.Owner.Id,new ChannelPreferences{FavoriteIds=new[]{c.OldId.ToString()}},CancellationToken.None);
c.Fail=true;await MustFail(c.Run());
check(c.ReadState().Pending!=null&&c.ReadState().Fingerprint==null&&(await c.Store.Load(c.Owner.Id,CancellationToken.None)).FavoriteIds.Single()==c.OldId.ToString("N"),"failed native job retains historical IDs and pending favorite checkpoint");
c.Fail=false;await c.Run();
check(c.Executions==2&&c.Data[(c.Owner.Id,c.NewId)].IsFavorite&&(await c.Store.Load(c.Owner.Id,CancellationToken.None)).FavoriteIds.Single()==c.NewId.ToString("N"),"retry restores native favorites after the first native job removed old channel metadata");
}
using(var c=new Context(root))
{
c.Block=new(TaskCreationOptions.RunContinuationsAsynchronously);
var first=c.Run();await c.Started.Task.WaitAsync(TimeSpan.FromSeconds(3));await c.Run();
check(c.Executions==1,"overlapping plugin synchronization requests coalesce into one native job");
c.Block.SetResult();await first;
}
using(var c=new Context(root))
{
c.Block=new(TaskCreationOptions.RunContinuationsAsynchronously);using var cancel=new CancellationTokenSource();
var run=c.Task.ExecuteAsync(new Progress<double>(),cancel.Token);await c.Started.Task.WaitAsync(TimeSpan.FromSeconds(3));cancel.Cancel();
await MustFail(run);check(c.ReadState().Pending!=null&&c.Task.LastSynchronizedUtc==null,"cancelled synchronization retains recovery checkpoint without claiming completion");
c.Block.SetResult();
}
using(var c=new Context(root))
{
plugin.Configuration.EnableAutomaticChannelSync=false;await c.Run();plugin.Configuration.EnableAutomaticChannelSync=true;
check(c.SourceCalls==0&&c.Executions==0,"disabled plugin synchronization performs no receiver or native-guide work");
}
var a=new ChannelInfo{Id=SavedChannelPlaybackTests.Current,Name="TV PULS HD",Number="1",ImageUrl="http://receiver/picon.png"};
var hash=ChannelSyncTask.Fingerprint(new[]{a});a.ImageUrl="http://receiver/new-picon.png";
check(hash!=ChannelSyncTask.Fingerprint(new[]{a}),"changed receiver logo URL invalidates channel fingerprint");
var old=Guid.NewGuid();var newer=Guid.NewGuid();
var ambiguous=new[]{a,new ChannelInfo{Id="1:0:1:AAAA:BBBB:13E:820000:0:0:0:",Name=a.Name}};
var map=ChannelSyncTask.BuildMapping(new[]{new ChannelSyncTask.SavedChannel{Id=old,Reference=SavedChannelPlaybackTests.Old,Name=a.Name}},ambiguous,new[]{new LiveTvChannel{Id=newer,ExternalId=a.Id}});
check(map.Count==0,"ambiguous same-name services do not migrate user preferences arbitrarily");
}
finally {typeof(Plugin).GetProperty(nameof(Plugin.Instance)).SetValue(null,previous);Directory.Delete(root,true);}
}
private static async Task MustFail(Task task)
{try{await task;}catch(InvalidOperationException){return;}catch(OperationCanceledException){return;}throw new Exception("Expected synchronization failure");}
private static IApplicationPaths Paths(string folder)
{var paths=DispatchProxy.Create<IApplicationPaths,SavedChannelPlaybackTests.SavedPaths>();((SavedChannelPlaybackTests.SavedPaths)(object)paths).Folder=folder;return paths;}
public sealed class Context:IDisposable
{
public Guid OldId=Guid.NewGuid(),NewId=Guid.NewGuid(),OtherId=Guid.NewGuid();
public User Owner=new("sync-owner","test","test"),SecondOwner=new("sync-second","test","test");
public ChannelInfo[] Current={new(){Id=SavedChannelPlaybackTests.Current,Name="TV PULS HD",Number="1"}};
public BaseItem[] Items;public int Executions,SourceCalls;public bool Fail,NativeRuntimePolicy;
public Dictionary<(Guid,Guid),UserItemData> Data=new();
public PreferencesStore Store;public ChannelSyncTask Task;public Worker Worker=new();
public TaskCompletionSource Block;public TaskCompletionSource Started=new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly string folder;
public Context(string root)
{
folder=Path.Combine(root,Guid.NewGuid().ToString("N"));Directory.CreateDirectory(folder);var paths=Paths(folder);Store=new(paths);
Items=new BaseItem[]{new LiveTvChannel{Id=OldId,ExternalId=SavedChannelPlaybackTests.Old,Name="TV PULS HD",ServiceName="Enigma2"},new LiveTvChannel{Id=OtherId,ExternalId="other",Name="Unrelated",ServiceName="Other provider"}};
foreach(var owner in new[]{Owner,SecondOwner})
{
Data[(owner.Id,OldId)]=new(){Key=OldId.ToString("N"),IsFavorite=owner==Owner};Data[(owner.Id,NewId)]=new(){Key=NewId.ToString("N"),PlayCount=7};Data[(owner.Id,OtherId)]=new(){Key=OtherId.ToString("N"),IsFavorite=true};
}
Task=new(new[]{Proxy<ILiveTvService>()},Proxy<ITaskManager>(),Proxy<ILibraryManager>(),Proxy<IUserManager>(),Proxy<IUserDataManager>(),Store,paths,NullLogger<ChannelSyncTask>.Instance);
}
private T Proxy<T>()where T:class
{var value=DispatchProxy.Create<T,SyncProxy>();((SyncProxy)(object)value).Context=this;return value;}
public Task Run()=>Task.ExecuteAsync(new Progress<double>(),CancellationToken.None);
public ChannelSyncTask.SyncState ReadState()=>JsonSerializer.Deserialize<ChannelSyncTask.SyncState>(File.ReadAllText(Path.Combine(folder,"decoder-integration","channel-sync.json")));
public void Dispose()=>Directory.Delete(folder,true);
public async Task Execute()
{
Executions++;Worker.CurrentState=TaskState.Running;Started.TrySetResult();
Items=new BaseItem[]{new LiveTvChannel{Id=NewId,ExternalId=SavedChannelPlaybackTests.Current,Name="TV PULS HD",ServiceName="Enigma2"},Items.Single(i=>i.Id==OtherId)};
foreach(var owner in new[]{Owner,SecondOwner})Data.Remove((owner.Id,OldId));
if(Block!=null)await Block.Task;
Worker.Result=new(){StartTimeUtc=DateTime.UtcNow,EndTimeUtc=DateTime.UtcNow,Status=Fail?TaskCompletionStatus.Failed:TaskCompletionStatus.Completed};Worker.CurrentState=TaskState.Idle;
}
}
public class SyncProxy:DispatchProxy
{
public Context Context;
protected override object Invoke(MethodInfo method,object[] args)
{
var c=Context;
switch(method.Name)
{
case "get_Name":return "Enigma2";
case "GetChannelsAsync":c.SourceCalls++;return System.Threading.Tasks.Task.FromResult<IEnumerable<ChannelInfo>>(c.Current);
case "get_ScheduledTasks":return new IScheduledTaskWorker[]{c.Worker};
case "Execute":if(!ReferenceEquals(args[0],c.Worker))throw new Exception("Wrong native task");c.NativeRuntimePolicy=((TaskOptions)args[1]).MaxRuntimeTicks==null;return c.Execute();
case "GetItemList":return c.Items;
case "GetUsers":return new[]{c.Owner,c.SecondOwner};
case "GetUserDataBatch":
return ((IReadOnlyList<BaseItem>)args[0]).ToDictionary(i=>i.Id,i=>c.Data[(((User)args[1]).Id,i.Id)]);
case "GetUserData":return c.Data[(((User)args[0]).Id,((BaseItem)args[1]).Id)];
case "SaveUserData":
if(((BaseItem)args[1]).Id!=c.NewId)throw new Exception("Unrelated native user data was changed");
c.Data[(((User)args[0]).Id,((BaseItem)args[1]).Id)]=(UserItemData)args[2];return null;
default:throw new Exception("Unexpected sync dependency: "+method.Name);
}
}
}
public sealed class Worker:IScheduledTaskWorker
{
public TaskState CurrentState=TaskState.Idle;public TaskResult Result;
public IScheduledTask ScheduledTask { get; }=new NativeTask();
public TaskResult LastExecutionResult=>Result;public string Name=>"Refresh Guide";public string Description=>"Test";public string Category=>"Live TV";
public TaskState State=>CurrentState;public double? CurrentProgress=>null;public IReadOnlyList<TaskTriggerInfo> Triggers {get;set;}=Array.Empty<TaskTriggerInfo>();public string Id=>"native";public void ReloadTriggerEvents(){}public void Dispose(){}
public event EventHandler<Jellyfin.Data.Events.GenericEventArgs<double>> TaskProgress {add{}remove{}}
}
private sealed class NativeTask:IScheduledTask
{
public string Name=>"Refresh Guide";public string Key=>"RefreshGuide";public string Description=>"Test";public string Category=>"Live TV";
public Task ExecuteAsync(IProgress<double> p,CancellationToken t)=>throw new Exception("Must use native task manager");public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()=>Array.Empty<TaskTriggerInfo>();
}
}
-124
View File
@@ -1,124 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
/// <summary>Real FFmpeg HTTP/HLS failover; synthetic source only, no decoder or Jellyfin configuration.</summary>
public static class ContinuityE2E
{
private static string ffmpeg;
private static volatile bool enabled;
private static int connections, active, maximum;
public static async Task Run(string encoder, string output, bool longTest = false, bool preserveResolution = false)
{
ffmpeg = encoder; output = Path.GetFullPath(output);
if (Directory.Exists(output)) throw new Exception("Choose a new output directory"); Directory.CreateDirectory(output);
using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop();
var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
using var streams = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), NullLogger.Instance, preserveResolution: preserveResolution);
using var relay = new LiveStreamRelay(new System.Net.Http.HttpClient(), port, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5));
builder.Services.AddSingleton(relay); builder.Services.AddSingleton(streams); builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly);
await using var app = builder.Build(); app.MapControllers(); app.MapGet("/fixture.ts", Fixture); await app.StartAsync();
Process first = null, second = null;
try
{
var source = relay.Register($"http://127.0.0.1:{port}/fixture.ts");
var tracks = LosslessSlateTests.Tracks(1280, 720, 25);
var pl = await streams.Open(source, "pl", CancellationToken.None, tracks);
var en = await streams.Open(source, "en", CancellationToken.None, tracks);
Require(JsonSerializer.Serialize(streams.Status()).Contains("\"Sources\":1"), "two languages share one source worker");
Require(!streams.Segment(pl.Id, 0).SequenceEqual(streams.Segment(en.Id, 0)), "same timeline uses different language slates");
await File.WriteAllBytesAsync(Path.Combine(output, "pl-slate.ts"), streams.Segment(pl.Id, 0));
await File.WriteAllBytesAsync(Path.Combine(output, "en-slate.ts"), streams.Segment(en.Id, 0));
var denied = new ContinuityController(streams) { ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = new DefaultHttpContext() } };
denied.HttpContext.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.1");
Require(denied.Playlist(pl.Id) is Microsoft.AspNetCore.Mvc.StatusCodeResult { StatusCode: 403 }, "private continuity endpoint denies remote access");
denied.HttpContext.Connection.RemoteIpAddress = IPAddress.Loopback; denied.Request.Headers["X-Forwarded-For"] = "192.0.2.1";
Require(denied.Playlist(pl.Id) is Microsoft.AspNetCore.Mvc.StatusCodeResult { StatusCode: 403 }, "private continuity endpoint denies forwarded requests");
first = Start(new[] { "-xerror", "-i", pl.Path, "-t", longTest ? "150" : "65", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy", Path.Combine(output, "pl.mkv") }, output, "pl-client");
second = Start(new[] { "-xerror", "-i", en.Path, "-t", longTest ? "150" : "65", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy", Path.Combine(output, "en.mkv") }, output, "en-client");
await Task.Delay(8000); enabled = true; Console.WriteLine("E2E source enabled after initial failure");
await Task.Delay(20000);
var beforeLoss = JsonSerializer.SerializeToElement(streams.Status()).GetProperty("LiveSegmentsProduced").GetInt64();
Console.WriteLine("E2E status before loss " + JsonSerializer.Serialize(streams.Status()));
Require(beforeLoss > 0, "real synthetic video reached clients before induced loss"); enabled = false; Console.WriteLine("E2E source disconnected");
await Task.Delay(longTest ? 75000 : 15000); enabled = true; Console.WriteLine("E2E source recovered");
await Task.WhenAll(first.WaitForExitAsync(), second.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(65));
Require(first.ExitCode == 0 && second.ExitCode == 0, "both HLS clients survive waiting, source loss and recovery without reopening");
foreach (var language in new[] { "pl", "en" })
{
using var decode = Start(new[] { "-xerror", "-i", Path.Combine(output, language + ".mkv"), "-map", "0:v:0", "-map", "0:a:0", "-f", "null", "-" }, output, language + "-decode");
await decode.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(40)); Require(decode.ExitCode == 0, "strict decode across all switches: " + language);
foreach (var (name, secondAt) in new[] { ("waiting", 1), ("live", longTest ? 39 : 22), ("retrying", longTest ? 70 : 39), ("recovered", longTest ? 140 : 60) })
{
using var image = Start(new[] { "-ss", secondAt.ToString(), "-i", Path.Combine(output, language + ".mkv"), "-frames:v", "1", "-update", "1", Path.Combine(output, language + "-" + name + ".png") }, output, language + "-" + name);
await image.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); Require(image.ExitCode == 0, "frame extraction: " + language + " " + name);
}
}
Console.WriteLine("E2E status after recovery "+JsonSerializer.Serialize(streams.Status()));
Require(JsonSerializer.SerializeToElement(streams.Status()).GetProperty("LiveSegmentsProduced").GetInt64() > beforeLoss, "new live segments arrive after recovery");
streams.Close(pl.Id); Require(JsonSerializer.Serialize(streams.Status()).Contains("\"Viewers\":1"), "one viewer stopping leaves the other active");
streams.Close(en.Id); await Task.Delay(2000);
Require(active == 0 && maximum <= 1 && connections >= 2, "one upstream at a time, retries and final source cleanup");
foreach (var language in new[] { "pl", "en", "de", "fr", "es", "uk" })
foreach (var phase in new[] { "waiting", "retrying" })
{
var path=Path.Combine(output,language+"-"+phase+"-slate.ts");
await File.WriteAllBytesAsync(path,await streams.SlatePreview(language,phase,CancellationToken.None));
using var image=Start(new[]{"-i",path,"-frames:v","1","-update","1",Path.Combine(output,language+"-"+phase+"-slate.png")},output,language+"-"+phase+"-slate");
await image.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));Require(image.ExitCode==0,"localized FFmpeg slate: "+language+" "+phase);
}
var report = new { Passed = true, SyntheticOnly = true, ReceiverConnectionsOpened = 0, ViewerLanguages = new[] { "pl", "en" }, SourceConnections = connections, FailureSeconds = longTest ? 75 : 15,
MaximumConcurrentSourceConnections = maximum, RemainingConnections = active, WaitingLossRecoverySamePlayer = true,
StrictVideoAudioDecode = true, PreserveSourceResolution = preserveResolution, Width = 1280, Height = 720, FrameRate = 25, OriginalAvCopied = true,
PhysicalAndroidTvTested = false, ActualJellyfinPlayerTested = false };
await File.WriteAllTextAsync(Path.Combine(output, "result.json"), JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); Console.WriteLine(JsonSerializer.Serialize(report));
}
finally
{
if (first is { HasExited: false }) first.Kill(true); if (second is { HasExited: false }) second.Kill(true);
first?.Dispose(); second?.Dispose(); streams.Dispose(); await app.StopAsync();
}
}
private static void Require(bool condition, string message) { if (!condition) throw new Exception(message); Console.WriteLine("PASS " + message); }
private static Process Start(string[] args, string output, string name)
{
var info = new ProcessStartInfo(ffmpeg) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true };
foreach (var arg in new[] { "-hide_banner", "-loglevel", "warning", "-nostdin", "-threads", "1", "-filter_threads", "1" }.Concat(args)) info.ArgumentList.Add(arg);
var process = Process.Start(info); process.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (typeof(ContinuityE2E)) File.AppendAllText(Path.Combine(output, name + ".log"), e.Data + "\n"); };
process.BeginErrorReadLine(); return process;
}
private static async Task Fixture(HttpContext context)
{
Interlocked.Increment(ref connections);
if (!enabled) { context.Response.StatusCode = 503; return; }
var current = Interlocked.Increment(ref active); maximum = Math.Max(maximum, current);
var info = new ProcessStartInfo(ffmpeg) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=25", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-c:v", "libx264", "-threads", "1", "-preset", "ultrafast", "-g", "25", "-c:a", "aac", "-ac", "2", "-f", "mpegts", "pipe:1" }) info.ArgumentList.Add(arg);
using var process = Process.Start(info); process.ErrorDataReceived += (_, _) => { }; process.BeginErrorReadLine(); context.Response.ContentType = "video/mp2t";
try
{
var buffer = new byte[64 * 1024];
while (enabled && !context.RequestAborted.IsCancellationRequested)
{
var count = await process.StandardOutput.BaseStream.ReadAsync(buffer, context.RequestAborted); if (count == 0) break;
await context.Response.Body.WriteAsync(buffer.AsMemory(0, count), context.RequestAborted); await context.Response.Body.FlushAsync(context.RequestAborted);
}
}
catch (OperationCanceledException) { }
catch (IOException) { }
finally { if (!process.HasExited) process.Kill(true); await process.WaitForExitAsync(); Interlocked.Decrement(ref active); context.Abort(); }
}
}
-69
View File
@@ -1,69 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
public static class ContinuityTests
{
public static void Run(Action<bool, string> check)
{
check(StartupMessages.ResolveLanguage("de-DE", "pl", "fr") == "de", "explicit Jellyfin UI language wins over HTTP and server language");
check(StartupMessages.ResolveLanguage(null, "en;q=0,uk-UA;q=0.8,pl;q=0.6", "de") == "uk", "language matching honours regional locale, quality and q=0");
check(StartupMessages.ResolveLanguage(null, null, "pl-PL") == "pl", "missing client locale uses Jellyfin server UI culture");
check(StartupMessages.ResolveLanguage("../../secret", "xx;q=1,de;q=oops", "xx") == "en", "unsupported and malformed locales use safe English fallback");
var cfg = DispatchProxy.Create<IServerConfigurationManager, FeedbackConfig>();
var prefs = DispatchProxy.Create<IDisplayPreferencesManager, FeedbackPreferences>();
var proxy = (FeedbackPreferences)(object)prefs; var user = Guid.NewGuid(); proxy.User = user;
var messages = new StartupMessages(cfg, prefs);
check(messages.Resolve(user, null, null) == "pl", "empty server profile uses actual server UI setting");
proxy.Language = "fr-FR";
check(messages.Resolve(user, null, "de") == "fr" && proxy.LastUser == user, "stored interface language is read only for the authenticated profile");
check(messages.Resolve(user, "uk", "de") == "uk", "current device UI choice overrides stored profile language");
messages.RememberClientLanguage(user, "web-a", "es");
check(messages.ClientLanguage(user, "web-a") == "es" && messages.ClientLanguage(user, "tv-a") == null && messages.ClientLanguage(Guid.NewGuid(), "web-a") == null,
"UI language handoff isolates users and devices");
var keys = messages.Texts("en").Keys.Order().ToArray();
foreach (var locale in new[] { "pl", "en", "de", "fr", "es", "uk" })
check(messages.Texts(locale).Keys.Order().SequenceEqual(keys) && messages.Text(locale, "slate_retrying").Contains('\n'), "complete shared text and FFmpeg slate catalogue: " + locale);
check(Math.Abs(ContinuityTimeline.SegmentSeconds * 50 - 112) < 0.00001 && Math.Abs(ContinuityTimeline.SegmentSeconds * 48000 / 1024 - 105) < 0.00001,
"segment boundary aligns whole video frames and whole AAC frames");
var packet = new byte[188]; packet[0]=71;packet[1]=65;packet[2]=0;packet[3]=16;packet[6]=1;packet[7]=224;packet[10]=128;packet[11]=128;packet[12]=5;
packet[13]=33;packet[15]=1;packet[17]=1;
var rebased=TransportStreamClock.Rebase(packet,(1L<<33)+90000);
var p=201;long pts=((long)(rebased[p]&14)<<29)|((long)rebased[p+1]<<22)|((long)(rebased[p+2]&254)<<14)|((long)rebased[p+3]<<7)|((long)rebased[p+4]>>1);
check(pts==90000&&rebased.Length==376&&rebased[5]==128&&packet[13]==33,"TS rebasing signals continuity reset, wraps 33-bit PTS and preserves cached input");
var now = DateTime.UtcNow; var timeline = new ContinuityTimeline(now);
check(!timeline.Playlist().Contains("ENDLIST") && timeline.Find(0).Slate == "waiting", "an immediately playable slate precedes decoder readiness");
timeline.Live(new byte[] { 1 }, "first", 2, now.AddSeconds(4));
check(timeline.Find(2).Discontinuity && timeline.Find(2).Video[0] == 1, "slate to real video marks an HLS discontinuity");
timeline.Tick(now.AddSeconds(11), true);
check(timeline.Find(3).Slate == "retrying" && !timeline.Playlist().Contains("ENDLIST"), "input loss keeps the same playlist alive with a retry slate");
timeline.Live(new byte[] { 2 }, "second", 2, now.AddSeconds(15));
check(timeline.Find(4).Discontinuity && timeline.Find(4).Video[0] == 2, "recovered producer returns to live video in the same stream");
for (var i = 0; i < 10000; i++) timeline.Tick(now.AddSeconds(20 + i * ContinuityTimeline.SegmentSeconds), true);
check(timeline.Find(0) == null && !timeline.Playlist().Contains("ENDLIST") && timeline.Find(10004) != null, "six-hour failures keep a bounded monotonic live timeline");
}
}
public class FeedbackConfig : DispatchProxy
{
protected override object Invoke(MethodInfo method, object[] args) => method.Name == "get_Configuration" ? new ServerConfiguration { UICulture = "pl" } : null;
}
public class FeedbackPreferences : DispatchProxy
{
public Guid User, LastUser;
public string Language;
protected override object Invoke(MethodInfo method, object[] args)
{
if (method.Name != "ListCustomItemDisplayPreferences") throw new Exception("Unexpected preference mutation");
LastUser = (Guid)args[0]; if (LastUser != User) throw new Exception("Wrong profile");
return Language == null ? new Dictionary<string, string>() : new Dictionary<string, string> { ["language"] = Language };
}
}
-123
View File
@@ -1,123 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
/// <summary>Compare compressed video packet hashes through the real continuity transport.</summary>
public static class LosslessE2E
{
private static string encoder, output, fixture;
private static int active, maximum;
public static async Task Run(string executable, string folder)
{
encoder=executable; output=Path.GetFullPath(folder);
if (Directory.Exists(output)) throw new Exception("Choose a new test directory"); Directory.CreateDirectory(output);
using var listener=new TcpListener(IPAddress.Loopback,0);listener.Start();var port=((IPEndPoint)listener.LocalEndpoint).Port;listener.Stop();
var builder=WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
using var streams=new ContinuityStreams(encoder,Path.Combine(output,"cache"),port,new StartupMessages(),NullLogger.Instance);
using var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(),port,TimeSpan.FromSeconds(1),TimeSpan.FromSeconds(10),TimeSpan.FromSeconds(5));
builder.Services.AddSingleton(streams);builder.Services.AddSingleton(relay);builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly);
await using var app=builder.Build();app.MapControllers();app.MapGet("/fixture.ts",Fixture);await app.StartAsync();
var reports=new List<object>();
try
{
foreach(var profile in new[]{"h264-60","h264-23976","hevc-hdr-5994","h264-interlaced"})
{
fixture=Path.Combine(output,profile+"-input.ts");
var hdr=profile.StartsWith("hevc");var interlaced=profile.EndsWith("interlaced");
var rate=hdr?"60000/1001":profile=="h264-23976"?"24000/1001":interlaced?"25":"60";
var size=hdr?"3840x2160":interlaced?"1920x1080":"640x360";
var args=new List<string>{"-f","lavfi","-i",$"{(hdr?"color=c=0x085f21:":"testsrc2=")}size={size}:rate={rate}","-f","lavfi","-i","sine=frequency=440:sample_rate=48000","-t","8","-vf",hdr?"format=yuv420p10le,setsar=1":interlaced?"setsar=1,setfield=tff":"setsar=1","-c:v",hdr?"libx265":"libx264","-threads","1","-preset","ultrafast","-g",hdr?"60":interlaced?"25":"24","-c:a",hdr?"ac3":"aac","-ac",hdr?"6":"2"};
if(hdr)args.AddRange(new[]{"-x265-params","pools=1:frame-threads=1:log-level=error:colorprim=9:transfer=16:colormatrix=9:hdr10=1:master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1):max-cll=1000,400","-color_trc","smpte2084","-color_primaries","bt2020","-colorspace","bt2020nc"});
if(interlaced)args.AddRange(new[]{"-flags","+ilme+ildct","-x264-params","tff=1"});
args.AddRange(new[]{"-f","mpegts",fixture});
using(var make=Start(args,profile+"-fixture")){await make.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(90));Require(make.ExitCode==0,"generate "+profile);}
var metadata=LiveStreamProbe.Parse(await Probe(fixture));var tracks=new BroadcastTracks(metadata,true,false);
if(hdr)Require(metadata[0].ColorTransfer=="smpte2084" && metadata[0].ColorPrimaries=="bt2020", "fixture carries real HDR10 PQ/BT2020 signaling");
var compatible=LosslessSlateProfile.Create(tracks)!=null;
Require(compatible==(profile=="h264-60"),"safe slate compatibility: "+profile);
var sourceHashes=(await Hashes(fixture)).ToHashSet();
var media=await streams.Open(relay.Register($"http://127.0.0.1:{port}/fixture.ts?profile={profile}"),"en",CancellationToken.None,tracks);
Require(media.MediaStreams[0].Codec==metadata[0].Codec&&media.MediaStreams[0].AverageFrameRate==metadata[0].AverageFrameRate
&&media.MediaStreams[0].IsInterlaced==metadata[0].IsInterlaced&&media.MediaStreams[0].PixelFormat==metadata[0].PixelFormat
&&media.MediaStreams[0].ColorTransfer==metadata[0].ColorTransfer&&media.MediaStreams[0].Width==metadata[0].Width
&&media.MediaStreams[1].Channels==metadata[1].Channels,"native source metadata unchanged: "+profile);
if(compatible)
{
var waiting=Path.Combine(output,profile+"-waiting.ts");await File.WriteAllBytesAsync(waiting,streams.Segment(media.Id,0));
var waitingHashes=await Hashes(waiting);
Require(waitingHashes.Length>10 && waitingHashes.All(h=>!sourceHashes.Contains(h)),"real generated waiting picture precedes unchanged source: "+profile);
}
var recording=Path.Combine(output,profile+"-output.ts");
using(var client=Start(new[]{"-xerror","-i",media.Path,"-t",compatible?"18":"6","-map","0:v:0","-map","0:a","-c","copy","-f","mpegts",recording},profile+"-client"))
{
try{await client.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(65));Require(client.ExitCode==0,"client copies "+profile);}
finally{if(!client.HasExited)client.Kill(true);streams.Close(media.Id);}
}
await Task.Delay(1500);
var inputHashes=await Hashes(fixture);var actual=await Hashes(recording);
var expected=inputHashes.ToHashSet();var matching=actual.Count(expected.Contains);
Require(matching>10,"original compressed video packets reach client unchanged: "+profile);
if(compatible)Require(actual.Count(h=>!expected.Contains(h))>10,"recording contains both status picture and original video: "+profile);
if(!compatible)Require(actual.All(expected.Contains),"every video packet is an original packet in native fallback: "+profile);
using(var decode=Start(new[]{"-xerror","-i",recording,"-map","0:v:0","-map","0:a","-f","null","-"},profile+"-decode"))
{await decode.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60));Require(decode.ExitCode==0,"strict decode "+profile);}
var decodedMetadata=LiveStreamProbe.Parse(await Probe(recording));
Require(decodedMetadata[0].Width==metadata[0].Width && decodedMetadata[0].Height==metadata[0].Height
&& decodedMetadata[0].PixelFormat==metadata[0].PixelFormat && decodedMetadata[0].IsInterlaced==metadata[0].IsInterlaced
&& decodedMetadata[0].ColorTransfer==metadata[0].ColorTransfer && decodedMetadata[0].ColorPrimaries==metadata[0].ColorPrimaries,
"actual output retains dimensions, bit depth, interlace and HDR signaling: "+profile);
reports.Add(new{Profile=profile,SlatesInserted=compatible,Width=metadata[0].Width,Height=metadata[0].Height,FrameRate=metadata[0].AverageFrameRate,
Codec=metadata[0].Codec,PixelFormat=metadata[0].PixelFormat,Transfer=metadata[0].ColorTransfer,Primaries=metadata[0].ColorPrimaries,OutputTransfer=decodedMetadata[0].ColorTransfer,Interlaced=metadata[0].IsInterlaced,AudioChannels=metadata[1].Channels,
OriginalCompressedVideoPackets=matching,OutputVideoPackets=actual.Length,AllOutputVideoPacketsOriginal=!compatible&&actual.All(expected.Contains),StrictDecode=true});
}
Require(active==0&&maximum==1,"release source; at most one upstream");
var result=new{Passed=true,SyntheticOnly=true,ReceiverConnectionsOpened=0,PhysicalAndroidTvTested=false,ActualJellyfinPlayerTested=false,MaximumConcurrentSourceConnections=maximum,RemainingConnections=active,Reports=reports};
await File.WriteAllTextAsync(Path.Combine(output,"result.json"),JsonSerializer.Serialize(result,new JsonSerializerOptions{WriteIndented=true}));Console.WriteLine(JsonSerializer.Serialize(result));
}
finally{streams.Dispose();await app.StopAsync();}
}
private static async Task<string> Probe(string path)
{
var info=new ProcessStartInfo(Path.Combine(Path.GetDirectoryName(encoder),"ffprobe")){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
foreach(var a in new[]{"-v","error","-show_streams","-of","json",path})info.ArgumentList.Add(a);
using var p=Process.Start(info);var text=p.StandardOutput.ReadToEndAsync();var error=p.StandardError.ReadToEndAsync();await p.WaitForExitAsync();Require(p.ExitCode==0,"probe fixture");await error;return await text;
}
private static async Task<string[]> Hashes(string path)
{
var info=new ProcessStartInfo(Path.Combine(Path.GetDirectoryName(encoder),"ffprobe")){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
foreach(var a in new[]{"-v","error","-select_streams","v:0","-show_packets","-show_data_hash","sha256","-show_entries","packet=data_hash","-of","csv=p=0",path})info.ArgumentList.Add(a);
using var p=Process.Start(info);var text=p.StandardOutput.ReadToEndAsync();var error=p.StandardError.ReadToEndAsync();await p.WaitForExitAsync();Require(p.ExitCode==0,"hash encoded packets");await error;
return (await text).Split('\n').Select(l=>l.Split(',').FirstOrDefault(x=>x.StartsWith("SHA256:"))).Where(l=>l!=null).Select(l=>l.Trim()).ToArray();
}
private static Process Start(IEnumerable<string> args,string name)
{
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardError=true,RedirectStandardOutput=true};
foreach(var a in new[]{"-hide_banner","-loglevel","warning","-nostdin","-filter_threads","1","-threads","1"}.Concat(args))info.ArgumentList.Add(a);
var p=Process.Start(info);p.ErrorDataReceived+=(_,e)=>{if(e.Data!=null)lock(typeof(LosslessE2E))File.AppendAllText(Path.Combine(output,name+".log"),e.Data+"\n");};p.BeginErrorReadLine();return p;
}
private static async Task Fixture(HttpContext context)
{
maximum=Math.Max(maximum,Interlocked.Increment(ref active));
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
foreach(var a in new[]{"-hide_banner","-loglevel","error","-nostdin","-re","-stream_loop","-1","-i",fixture,"-map","0","-c","copy","-f","mpegts","pipe:1"})info.ArgumentList.Add(a);
using var p=Process.Start(info);p.ErrorDataReceived+=(_,_)=>{};p.BeginErrorReadLine();context.Response.ContentType="video/mp2t";
try{await p.StandardOutput.BaseStream.CopyToAsync(context.Response.Body,context.RequestAborted);}
catch(OperationCanceledException){}catch(IOException){}
finally{if(!p.HasExited)p.Kill(true);await p.WaitForExitAsync();Interlocked.Decrement(ref active);}
}
private static void Require(bool value,string message){if(!value)throw new Exception(message);Console.WriteLine("PASS "+message);}
}
-63
View File
@@ -1,63 +0,0 @@
using System;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Model.Entities;
public static class LosslessSlateTests
{
public static BroadcastTracks Tracks(int width, int height, float rate) => new(new MediaStream[] {
new BroadcastMediaStream { Type=MediaStreamType.Video, Index=0, Codec="h264", Width=width, Height=height,
AverageFrameRate=rate, RealFrameRate=rate, Profile="Constrained Baseline", PixelFormat="yuv420p", SampleAspectRatio="1:1" },
new MediaStream { Type=MediaStreamType.Audio, Index=1, Codec="aac", Profile="LC", Channels=2, SampleRate=48000 }
});
public static void Run(Action<bool,string> check)
{
foreach (var rate in new[] { 24f, 25f, 30f, 50f, 60f, 120f })
{
var tracks=Tracks(3840,2160,rate);
var profile=LosslessSlateProfile.Create(tracks);
check(profile?.Rate==rate && profile.Width==3840 && profile.Height==2160 && tracks.Output[0].AverageFrameRate==rate,
$"status picture matches source rate {rate} and 4K size without changing channel metadata");
}
var original=Tracks(3840,2160,60000f/1001);
var video=(BroadcastMediaStream)original.Input[0];video.FrameRateRational="60000/1001";
check(LosslessSlateProfile.Create(original)==null && original.Output[0].AverageFrameRate==60000f/1001, "fractional cadence is preserved by native fallback instead of rounded for a status image");
var key=new BroadcastTracks(original.Input).Key;video.AverageFrameRate=24;
check(new BroadcastTracks(original.Input).Key!=key,"source rate changes invalidate cached status images");
video.Codec="hevc";video.PixelFormat="yuv420p10le";video.ColorTransfer="smpte2084";video.ColorPrimaries="bt2020";video.Level=153;
original.Input[1].Codec="ac3";original.Input[1].Channels=6;
var hdr=new BroadcastTracks(original.Input,true,false);
check(!hdr.Normalize && hdr.Output[0].Codec=="hevc" && hdr.Output[0].PixelFormat=="yuv420p10le" && hdr.Output[0].ColorTransfer=="smpte2084"
&& hdr.Output[0].Level==153 && hdr.Output[0].Width==3840 && hdr.Output[1].Codec=="ac3" && hdr.Output[1].Channels==6,
"legacy slate/Full HD options cannot normalize 4K HDR10 or surround audio");
check(LosslessSlateProfile.Create(hdr)==null,"HDR/HEVC keeps original transport instead of converting the channel to fit a status picture");
var interlaced=Tracks(1920,1080,25);interlaced.Input[0].IsInterlaced=true;
check(LosslessSlateProfile.Create(interlaced)==null && new BroadcastTracks(interlaced.Input).Output[0].IsInterlaced,
"interlaced broadcast is preserved without plugin deinterlacing");
check(LosslessSlateProfile.Create(BroadcastTracks.Fallback())==null,"unknown cold-start metadata cannot force a guessed status format");
var unknown=Tracks(1280,720,60);((BroadcastMediaStream)unknown.Input[0]).SampleAspectRatio=null;
check(LosslessSlateProfile.Create(unknown)==null,"unknown sample aspect ratio uses unmodified broadcast fallback");
var now=DateTime.UtcNow;var timeline=new ContinuityTimeline(now,false);
var first=Packet(12000,9000);var second=Packet(195000,189000);
timeline.Live(first,"producer",2,now);timeline.Live(second,"producer",2,now.AddSeconds(2));
long Decode(long sequence,byte[] packet)
{
var segment=timeline.Find(sequence);
return TransportStreamClock.ClockBounds(TransportStreamClock.Rebase(packet,segment.Start90k,false,segment.ClockOffset90k)).Value.Decode;
}
check(Decode(1,second)-Decode(0,first)==180000,"copied B-frames retain their decode-clock interval despite different first presentation timestamps");
timeline.Tick(now.AddSeconds(9),true);var retry=timeline.Find(2);
var recovered=Packet(372000,369000);timeline.Live(recovered,"producer",2,now.AddSeconds(10));
check(Decode(3,recovered)>=retry.Start90k+(long)(retry.Duration*90000),"short source stall followed by the same producer cannot rewind behind the inserted status picture");
timeline=new ContinuityTimeline(now,false);first=Packet((1L<<33)-6000,(1L<<33)-9000);second=Packet(93000,90000);
timeline.Live(first,"wrapped",1.1,now);timeline.Live(second,"wrapped",1.1,now.AddSeconds(1.1));
check(Decode(1,second)-Decode(0,first)==99000,"copied transport keeps a monotonic decode clock across the 33-bit timestamp wrap");
}
private static byte[] Packet(long pts,long dts)
{
var packet=new byte[188];packet[0]=71;packet[1]=65;packet[2]=0;packet[3]=16;
packet[6]=1;packet[7]=224;packet[10]=128;packet[11]=192;packet[12]=10;
void Stamp(int at,long time,int prefix)
{time&=(1L<<33)-1;packet[at]=(byte)((long)prefix|((time>>29)&14)|1);packet[at+1]=(byte)(time>>22);packet[at+2]=(byte)(1|((time>>14)&254));packet[at+3]=(byte)(time>>7);packet[at+4]=(byte)(1|(time<<1));}
Stamp(13,pts,48);Stamp(18,dts,16);return packet;
}
}
-41
View File
@@ -1,41 +0,0 @@
using System;
using System.Reflection;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging.Abstractions;
public static class NativeResolutionPolicyTests
{
public static void Run(Action<bool, string> check)
{
// Exercise the installed Jellyfin 12.2 negotiation code, including
// devices whose width/height conditions are optional rather than required.
foreach (var (width, height) in new[] { (720, 576), (1280, 720), (1920, 1080), (2560, 1440), (3840, 2160) })
foreach (var required in new[] { true, false })
{
var source = new MediaSourceInfo { Id = "test", Path = "http://127.0.0.1/test.m3u8", Protocol = MediaProtocol.Http,
Container = "hls", SupportsDirectPlay = false, SupportsDirectStream = true, SupportsProbing = false,
MediaStreams = new[] { new MediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", AverageFrameRate = 50, Profile = "Constrained Baseline" },
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Channels = 2, SampleRate = 48000, IsDefault = true } } };
var profile = new DeviceProfile { Name = "Resolution contract", MaxStreamingBitrate = 100000000,
DirectPlayProfiles = new[] { new DirectPlayProfile { Container = "hls,ts", Type = DlnaProfileType.Video, VideoCodec = "h264", AudioCodec = "aac" } },
TranscodingProfiles = new[] { new TranscodingProfile { Container = "ts", Type = DlnaProfileType.Video, VideoCodec = "h264", AudioCodec = "aac", Protocol = MediaStreamProtocol.hls, Context = EncodingContext.Streaming } },
CodecProfiles = new[] { new CodecProfile { Codec = "h264", Type = CodecType.Video, Conditions = new[] {
new ProfileCondition { Property = ProfileConditionValue.Width, Condition = ProfileConditionType.LessThanEqual, Value = width.ToString(), IsRequired = required },
new ProfileCondition { Property = ProfileConditionValue.Height, Condition = ProfileConditionType.LessThanEqual, Value = height.ToString(), IsRequired = required } } } } };
var support = DispatchProxy.Create<ITranscoderSupport, ResolutionTranscoder>();
var stream = new StreamBuilder(support, NullLogger.Instance).GetOptimalVideoStream(new MediaOptions {
ItemId = Guid.NewGuid(), DeviceId = "test", Profile = profile, MediaSources = new[] { source }, EnableDirectPlay = true,
EnableDirectStream = true, AllowVideoStreamCopy = true, AllowAudioStreamCopy = true, MaxBitrate = 100000000, Context = EncodingContext.Streaming });
check(stream.MaxWidth == width && stream.MaxHeight == height && source.MediaStreams[0].Width == null,
$"native Jellyfin negotiates {width}x{height} without a plugin Full HD cap; required={required}");
}
}
}
public class ResolutionTranscoder : DispatchProxy
{
protected override object Invoke(MethodInfo method, object[] args) => method.ReturnType == typeof(bool) ? true : method.ReturnType == typeof(int) ? 2 : null;
}
-70
View File
@@ -1,70 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
public static class PatientProbeTests
{
public static async Task Run(Action<bool,string> check)
{
using var upstream = new RelayHandler();
using var relay = new LiveStreamRelay(new HttpClient(upstream),8096,TimeSpan.FromMilliseconds(200));
var url=relay.Register("http://receiver/patient");
using var local = new HttpClient(new LocalRelayHandler(relay));
var attempts=0;var snapshots=new System.Collections.Generic.List<string>();
var limits=new LiveStreamProbe.SamplingLimits(TimeSpan.FromMilliseconds(20),TimeSpan.FromMilliseconds(80),188*8,188*128);
const string good="""{"streams":[{"index":0,"codec_type":"video","codec_name":"h264","width":1920,"height":1080}]}""";
const string bad="""{"streams":[{"index":0,"codec_type":"video","codec_name":"h264","width":0,"height":0}]}""";
var probe=new LiveStreamProbe(local,(path,sid,extended,token)=>
{snapshots.Add(path);return Task.FromResult(++attempts>=3?good:bad);},limits,TimeSpan.FromSeconds(3),true,TimeSpan.FromMilliseconds(40));
var result=await Task.WhenAll(Enumerable.Range(0,4).Select(_=>probe.Get(url,CancellationToken.None)));
check(attempts==3 && result.All(x=>x[0].Width==1920),"patient startup retries fresh bounded sample windows shared by all four viewers");
check(upstream.Calls==1,"multiple metadata windows keep exactly one physical receiver connection");
check(snapshots.All(path=>!File.Exists(path)&&!File.Exists(path.Replace(".sample.ts",""))),"patient success cleans every sample and capture window");
await Task.Delay(350);
check(System.Text.Json.JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"),"patient startup releases receiver after final probe consumer");
var started=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var stopped=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var cancellable=new LiveStreamProbe(async (source,token)=>
{started.SetResult();try{await Task.Delay(10000,token);return good;}finally{stopped.SetResult();}},TimeSpan.FromSeconds(3));
using var first=new CancellationTokenSource();using var second=new CancellationTokenSource();
var a=cancellable.Get("cancel",first.Token);var b=cancellable.Get("cancel",second.Token);await started.Task;
first.Cancel();try{await a;}catch(OperationCanceledException){}
check(!stopped.Task.IsCompleted,"one viewer cancelling does not stop another viewer's patient startup");
second.Cancel();try{await b;}catch(OperationCanceledException){}
await stopped.Task.WaitAsync(TimeSpan.FromSeconds(1));
check(stopped.Task.IsCompleted,"last viewer cancellation stops producer immediately instead of waiting 90 seconds");
var bounded=new LiveStreamProbe(local,(path,sid,extended,token)=>Task.FromResult(bad),limits,TimeSpan.FromMilliseconds(250),true,TimeSpan.FromMilliseconds(20));
try { await bounded.Get(url,CancellationToken.None); throw new Exception("Expected bounded startup failure"); }
catch(InvalidOperationException error)
{ check(error.ToString().Contains("video_parameters_unavailable"),"patient deadline preserves the last input diagnosis instead of waiting forever"); }
}
private sealed class LocalRelayHandler(LiveStreamRelay relay) : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token)
{
var sub=await relay.Subscribe(request.RequestUri.Segments.Last(),token);
return new(HttpStatusCode.OK){Content=new StreamContent(new SubscriptionStream(sub))};
}
}
private sealed class SubscriptionStream(LiveStreamRelay.Subscription subscription) : Stream
{
private byte[] chunk;private int at;private bool disposed;
public override async ValueTask<int> ReadAsync(Memory<byte> buffer,CancellationToken token=default)
{
if(chunk==null||at==chunk.Length){chunk=await subscription.Reader.ReadAsync(token);at=0;}
var n=Math.Min(buffer.Length,chunk.Length-at);chunk.AsMemory(at,n).CopyTo(buffer);at+=n;return n;
}
protected override void Dispose(bool disposing){if(disposing&&!disposed){disposed=true;subscription.DisposeAsync().AsTask().GetAwaiter().GetResult();}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[] b,int o,int n)=>ReadAsync(b.AsMemory(o,n)).AsTask().GetAwaiter().GetResult();
public override void Flush(){}public override long Seek(long o,SeekOrigin s)=>throw new NotSupportedException();
public override void SetLength(long l)=>throw new NotSupportedException();public override void Write(byte[] b,int o,int n)=>throw new NotSupportedException();
}
}
-74
View File
@@ -1,74 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
public static class PatientStartupE2E
{
private static int connections, active, maximum;
public static async Task Run(string encoder, string folder)
{
folder=Path.GetFullPath(folder);if(Directory.Exists(folder))throw new Exception("Choose a new output directory");Directory.CreateDirectory(folder);
var file=Path.Combine(folder,"fixture.ts");
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardError=true};
foreach(var a in new[]{"-v","error","-nostdin","-filter_threads","1","-f","lavfi","-i","testsrc2=size=320x180:rate=25","-f","lavfi","-i","sine=sample_rate=48000","-t","4","-c:v","libx264","-threads","1","-preset","ultrafast","-g","25","-c:a","aac","-ac","2","-f","mpegts",file})info.ArgumentList.Add(a);
using(var make=Process.Start(info)){var error=make.StandardError.ReadToEndAsync();await make.WaitForExitAsync();if(make.ExitCode!=0)throw new Exception(await error);}
var bytes=await File.ReadAllBytesAsync(file);
using var listener=new TcpListener(IPAddress.Loopback,0);listener.Start();var port=((IPEndPoint)listener.LocalEndpoint).Port;listener.Stop();
var builder=WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
using var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(),port,TimeSpan.FromMilliseconds(200),TimeSpan.FromSeconds(StartupBudget.Seconds),TimeSpan.FromSeconds(StartupBudget.StallSeconds));
builder.Services.AddSingleton(relay);builder.Services.AddControllers().AddApplicationPart(typeof(LiveRelayController).Assembly);
await using var app=builder.Build();app.MapControllers();
app.MapGet("/late.ts",async context=>
{
Interlocked.Increment(ref connections);maximum=Math.Max(maximum,Interlocked.Increment(ref active));
context.Response.ContentType="video/mp2t";await context.Response.StartAsync(context.RequestAborted);
try
{
await Task.Delay(TimeSpan.FromSeconds(35),context.RequestAborted);
while(!context.RequestAborted.IsCancellationRequested)
for(var at=0;at<bytes.Length;at+=188*32)
{
var n=Math.Min(188*32,bytes.Length-at);
await context.Response.Body.WriteAsync(bytes.AsMemory(at,n),context.RequestAborted);await context.Response.Body.FlushAsync(context.RequestAborted);
await Task.Delay(TimeSpan.FromSeconds(4d*n/bytes.Length),context.RequestAborted);
}
}
catch(OperationCanceledException){}catch(IOException){}
finally{Interlocked.Decrement(ref active);}
});
await app.StartAsync();
try
{
var proxy=DispatchProxy.Create<IMediaEncoder,PatientEncoderProxy>();((PatientEncoderProxy)(object)proxy).ProbePath=Path.Combine(Path.GetDirectoryName(encoder),"ffprobe");
var probe=new LiveStreamProbe(proxy);var url=relay.Register($"http://127.0.0.1:{port}/late.ts");var watch=Stopwatch.StartNew();
var result=await Task.WhenAll(Enumerable.Range(0,4).Select(_=>probe.Get(url,CancellationToken.None,1))).WaitAsync(TimeSpan.FromSeconds(95));
if(watch.Elapsed.TotalSeconds<35||watch.Elapsed.TotalSeconds>=90||result.Any(x=>x[0].Width!=320||x[0].Height!=180))throw new Exception("Delayed source failed the configured budget");
await Task.Delay(800);
if(maximum!=1||connections!=1||active!=0)throw new Exception("Receiver sharing or cleanup failed");
var report=new{Passed=true,SyntheticOnly=true,ReceiverConnectionsOpened=0,ConfiguredStartupSeconds=StartupBudget.Seconds,DelayedFirstDataSeconds=35,
ElapsedSeconds=watch.Elapsed.TotalSeconds,ConcurrentRequesters=4,PhysicalFixtureConnections=connections,MaximumConcurrentFixtureConnections=maximum,RemainingConnections=active,
ActualFfprobeUsed=true,PhysicalAndroidTvTested=false};
await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(report,new JsonSerializerOptions{WriteIndented=true}));Console.WriteLine(JsonSerializer.Serialize(report));
}
finally{relay.Dispose();await app.StopAsync();}
}
}
public class PatientEncoderProxy : DispatchProxy
{
public string ProbePath;
protected override object Invoke(MethodInfo method,object[] args)=>method.Name=="get_ProbePath"?ProbePath:null;
}
-85
View File
@@ -1,85 +0,0 @@
using Jellyfin.Data;
using System.Collections.Concurrent;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging.Abstractions;
using System;
using System.Linq;
using System.Reflection;
using System.Security.Claims;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
public static class PlaybackFeedbackTests
{
public static async Task Run(Action<bool,string> check)
{
var user=new User("feedback-user","auth","reset");user.SetPermission(PermissionKind.EnableLiveTvAccess,true);
var auth=DispatchProxy.Create<IAuthorizationContext,FeedbackAuth>();
((FeedbackAuth)(object)auth).Info=new AuthorizationInfo{User=user,DeviceId="device-a",Client="Jellyfin Web"};
var sessions=DispatchProxy.Create<ISessionManager,FeedbackSessions>();
var contexts=new HttpContextAccessor{HttpContext=new DefaultHttpContext()};
var feedback=new PlaybackFeedback(contexts,auth,sessions);
var first=await feedback.Begin("channel-a",CancellationToken.None);
first.Report("checking_stream");
string Snapshot(Guid id,string device,string reference)=>JsonSerializer.Serialize(feedback.Snapshot(id,device,reference));
check(Snapshot(user.Id,"device-a","channel-a").Contains("checking_stream"),"startup feedback exposes actual probe phase");
check(!Snapshot(user.Id,"device-b","channel-a").Contains("checking_stream")&&!Snapshot(Guid.NewGuid(),"device-a","channel-a").Contains("checking_stream"),"startup progress isolates user and device");
check(!Snapshot(user.Id,"device-a","channel-b").Contains("checking_stream"),"startup progress isolates channels");
var second=await feedback.Begin("channel-a",CancellationToken.None);first.Report("failed");second.Report("ready");
check(Snapshot(user.Id,"device-a","channel-a").Contains("ready")&&!Snapshot(user.Id,"device-a","channel-a").Contains("failed"),"late old attempt cannot replace current progress");
check(!Snapshot(user.Id,"device-a","channel-a").Contains("device-a")&&!Snapshot(user.Id,"device-a","channel-a").Contains(user.Id.ToString()),"progress response excludes user and device identifiers");
check(((FeedbackSessions)(object)sessions).Messages==0,"web requests never send unsolicited native messages");
contexts.HttpContext=null;check(await feedback.Begin("recording",CancellationToken.None)==null,"background recording has no guessed message destination");
contexts.HttpContext=new DefaultHttpContext();user.SetPermission(PermissionKind.EnableLiveTvAccess,false);
check(await feedback.Begin("channel-a",CancellationToken.None)==null,"feedback requires live TV permission");
check(PlaybackFeedback.IsAndroidTv("Jellyfin Android TV")&&!PlaybackFeedback.IsAndroidTv("Jellyfin Android")&&!PlaybackFeedback.IsAndroidTv("Jellyfin Web"),"native messages scoped to Android TV clients");
user.SetPermission(PermissionKind.EnableLiveTvAccess,true);
var library=DispatchProxy.Create<MediaBrowser.Controller.Library.ILibraryManager,PolicyLibraryProxy>();
((PolicyLibraryProxy)(object)library).Current=user;((PolicyLibraryProxy)(object)library).Item=new MediaBrowser.Controller.LiveTv.LiveTvChannel{ServiceName="Enigma2",ExternalId="channel-a"};
var users=DispatchProxy.Create<MediaBrowser.Controller.Library.IUserManager,PolicyUserProxy>();((PolicyUserProxy)(object)users).Current=user;
var context=new DefaultHttpContext();context.User=new ClaimsPrincipal(new ClaimsIdentity(new[]{new Claim("Jellyfin-UserId",user.Id.ToString())},"test"));
var controller=new PlaybackFeedbackController(feedback,library,users,auth){ControllerContext=new ControllerContext{HttpContext=context}};
check(await controller.Get(Guid.NewGuid()) is OkObjectResult,"progress endpoint uses native user-filtered channel access");
((PolicyLibraryProxy)(object)library).Item=null;check(await controller.Get(Guid.NewGuid()) is NotFoundResult,"invisible native channel cannot expose startup state");
context.User=new ClaimsPrincipal();check(await controller.Get(Guid.NewGuid()) is UnauthorizedResult,"progress endpoint rejects absent authentication");
var manager=(FeedbackSessions)(object)sessions;
var native=new SessionInfo(sessions,NullLogger.Instance){Id="tv-session",UserId=user.Id,DeviceId="device-a",Client="Jellyfin Android TV",LastActivityDate=DateTime.UtcNow,
Capabilities=new ClientCapabilities{SupportedCommands=new[]{GeneralCommandType.DisplayMessage}}};
var other=new SessionInfo(sessions,NullLogger.Instance){Id="other-session",UserId=user.Id,DeviceId="device-b",Client="Jellyfin Android TV",LastActivityDate=DateTime.UtcNow};
manager.Entries=new[]{native,other}; contexts.HttpContext.Request.Headers.AcceptLanguage="uk-UA";
check(native.IsActive,"native feedback fixture has an active session");
var nativeAttempt=await feedback.Begin("native",CancellationToken.None);nativeAttempt.Report("waiting_for_stream");nativeAttempt.Report("ready");nativeAttempt.Report("failed");
for(var i=0;i<100&&manager.Messages<3;i++)await Task.Delay(10);
check(manager.Messages==3&&manager.Sent.All(m=>m.Target==native.Id&&m.Message.Header=="Телебачення"),"native localized feedback targets only the requesting user/device and has a three-message ceiling");
manager.Entries=new[]{native,native};var ambiguous=await feedback.Begin("ambiguous",CancellationToken.None);ambiguous.Report("ready");await Task.Delay(30);
check(manager.Messages==3,"ambiguous native session never broadcasts a message");
((FeedbackAuth)(object)auth).Info.IsApiKey=true;
check(await feedback.Begin("api-key",CancellationToken.None)==null,"server API keys cannot impersonate a native viewer");
}
}
public class FeedbackAuth:DispatchProxy
{
public AuthorizationInfo Info;
protected override object Invoke(MethodInfo method,object[] args)=>Task.FromResult(Info);
}
public class FeedbackSessions:DispatchProxy
{
public int Messages;
public SessionInfo[] Entries=Array.Empty<SessionInfo>();
public ConcurrentQueue<(string Target,MessageCommand Message)> Sent=new();
protected override object Invoke(MethodInfo method,object[] args)
{
if(method.Name=="get_Sessions")return Entries;
if(method.Name=="SendMessageCommand"){Sent.Enqueue(((string)args[1],(MessageCommand)args[2]));Interlocked.Increment(ref Messages);return Task.CompletedTask;}
throw new InvalidOperationException(method.Name);
}
}
-174
View File
@@ -1,174 +0,0 @@
using System;
using Jellyfin.Plugin.Enigma2;
using System.IO;
using System.Reflection;
using System.Security.Claims;
using System.Text.Json.Nodes;
using System.Threading;
using System.Linq;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.Enigma2.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
if (args.Length == 3 && args[0] == "--patient-startup-e2e") { await PatientStartupE2E.Run(args[1], args[2]); return; }
if (args.Length == 3 && args[0] == "--lossless-e2e") { await LosslessE2E.Run(args[1], args[2]); return; }
if (args.Length == 3 && args[0] == "--broadcast-tracks-e2e") { await BroadcastTracksE2E.Run(args[1], args[2]); return; }
if (args.Length == 3 && args[0] == "--native-continuity-e2e") { await ContinuityE2E.Run(args[1], args[2], true, true); return; }
if (args.Length == 3 && args[0] == "--resolution-e2e") { await ResolutionE2E.Run(args[1], args[2]); return; }
if (args.Length == 3 && (args[0] == "--continuity-e2e" || args[0] == "--long-continuity-e2e")) { await ContinuityE2E.Run(args[1], args[2], args[0].StartsWith("--long")); return; }
var time=DateTime.UtcNow;
void Check(bool ok,string name){if(!ok)throw new Exception(name);Console.WriteLine("PASS "+name);}
ChannelReferenceTests.Run(Check);
await BroadcastTrackTests.Run(Check);
var entry=new InternetEpg.Entry("Barwy szczęścia",time,"https://ptv-prod.wphcdn.cloud/media/a.jpg");
Check(InternetEpg.Match(new[]{entry},"Barwy szczęścia",time.AddMinutes(5))==entry,"matching channel programme time");
Check(InternetEpg.Match(new[]{entry},"Inny program",time)==null,"reject different title");
Check(InternetEpg.Match(new[]{entry},entry.Title,time.AddMinutes(8))==null,"reject distant emission");
Check(InternetEpg.Match(new[]{entry,entry},entry.Title,time)==null,"reject ambiguous emission");
Check(InternetEpg.ChannelKey("TVP 2 HD")==InternetEpg.ChannelKey("TVP 2"),"HD channel alias");
Check(!InternetEpg.AllowedImage("http://127.0.0.1/a"),"reject local image URL");
Check(!InternetEpg.AllowedImage("https://ptv-prod.wphcdn.cloud.evil.example/a"),"exact artwork provider host");
Check(InternetEpg.AllowedImage(entry.Image),"real provider image");
var prefs=new ChannelPreferences{FavoriteIds=new[]{Guid.NewGuid().ToString(),"../other-user"},DefaultView="invalid"};prefs.Validate();
Check(prefs.FavoriteIds.Length==1 && prefs.DefaultView=="all","preference input validation");
var legacyConfiguration=(PluginConfiguration)new System.Xml.Serialization.XmlSerializer(typeof(PluginConfiguration)).Deserialize(new StringReader("<PluginConfiguration><HostName>receiver</HostName></PluginConfiguration>"));
Check(legacyConfiguration.PreferH264ForWebLiveTv,"legacy configuration defaults to web H264 preference");
var ownedChannel=new LiveTvChannel{Id=Guid.NewGuid(),ServiceName="Enigma2"};
var policy=WebPlaybackPolicy.For(ownedChannel,true);
Check(policy.IsEnigmaChannel&&policy.PreferH264,"web codec policy recognizes native Enigma service");
policy=WebPlaybackPolicy.For(ownedChannel,false);
Check(policy.IsEnigmaChannel&&!policy.PreferH264,"disabled web codec policy preserves native negotiation");
Check(!WebPlaybackPolicy.For(new LiveTvChannel{ServiceName="OtherProvider"},true).PreferH264&&!WebPlaybackPolicy.For(new Video{ServiceName="Enigma2"},true).PreferH264&&!WebPlaybackPolicy.For(null,true).PreferH264,"web codec policy excludes other providers, movies and missing items");
var policyUser=new User("policy-test","test-auth","test-reset");policyUser.SetPermission(PermissionKind.EnableLiveTvAccess,true);
var userManager=DispatchProxy.Create<IUserManager,PolicyUserProxy>();((PolicyUserProxy)(object)userManager).Current=policyUser;
var libraryManager=DispatchProxy.Create<ILibraryManager,PolicyLibraryProxy>();var libraryProxy=(PolicyLibraryProxy)(object)libraryManager;libraryProxy.Current=policyUser;libraryProxy.Item=ownedChannel;
var policyContext=new DefaultHttpContext();policyContext.User=new ClaimsPrincipal(new ClaimsIdentity(new[]{new Claim("Jellyfin-UserId",policyUser.Id.ToString())},"test"));policyContext.Request.QueryString=new QueryString("?userId="+Guid.NewGuid());
var policyController=new PreferencesController(null,null,null,null,libraryManager,userManager){ControllerContext=new ControllerContext{HttpContext=policyContext}};
Check(policyController.GetWebPlaybackPolicy(ownedChannel.Id).Value.IsEnigmaChannel&&libraryProxy.Calls==1,"web policy uses native user-filtered lookup for authenticated owner");
libraryProxy.Item=null;policy=policyController.GetWebPlaybackPolicy(ownedChannel.Id).Value;Check(!policy.IsEnigmaChannel&&!policy.PreferH264,"native visibility denial leaves web profile unchanged");
policyUser.SetPermission(PermissionKind.EnableLiveTvAccess,false);Check(policyController.GetWebPlaybackPolicy(ownedChannel.Id).Result is ForbidResult&&libraryProxy.Calls==2,"web policy denies user without live TV permission before item lookup");
((PolicyUserProxy)(object)userManager).Current=null;Check(policyController.GetWebPlaybackPolicy(ownedChannel.Id).Result is UnauthorizedResult&&libraryProxy.Calls==2,"web policy rejects deleted user without unrestricted native lookup");
policyContext.User=new ClaimsPrincipal();Check(policyController.GetWebPlaybackPolicy(ownedChannel.Id).Result is UnauthorizedResult,"web policy requires authenticated user claim");
var folder=Path.Combine(Path.GetTempPath(),"decoder-tests-"+Guid.NewGuid().ToString("N"));
var paths=DispatchProxy.Create<IApplicationPaths,PathsProxy>();((PathsProxy)(object)paths).Folder=folder;
try
{
Directory.CreateDirectory(folder);var config=Path.Combine(folder,"Jellyfin.Plugin.Enigma2.xml");
var registration=new ServiceCollection().AddSingleton(paths);new ServiceRegistrator().RegisterServices(registration,null);
Check(!System.Linq.Enumerable.Any(registration,x=>x.ServiceType==typeof(MediaBrowser.Controller.Channels.IChannel)),"startup before Plugin.Instance: recordings disabled by default");
File.WriteAllText(config,"<PluginConfiguration><EnableVuRecordings>true</EnableVuRecordings></PluginConfiguration>");
registration=new ServiceCollection().AddSingleton(paths);new ServiceRegistrator().RegisterServices(registration,null);
Check(System.Linq.Enumerable.Any(registration,x=>x.ServiceType==typeof(MediaBrowser.Controller.Channels.IChannel)),"startup recordings enabled from persisted configuration");
File.WriteAllText(config,"<PluginConfiguration><EnableVuRecordings>false</EnableVuRecordings></PluginConfiguration>");
Check(!ServiceRegistrator.VuRecordingsEnabled(config),"explicit recordings disabled");
var store=new PreferencesStore(paths);var user=Guid.NewGuid();var a=Guid.NewGuid().ToString("N");var b=Guid.NewGuid().ToString("N");
await store.Save(user,new ChannelPreferences{HiddenIds=new[]{a},OrderedIds=new[]{b,a}},CancellationToken.None);
var provider=new ServiceCollection().AddLogging().AddSingleton(store).BuildServiceProvider();
async System.Threading.Tasks.Task<JsonNode> Request(Guid current,string query)
{
var app=new ApplicationBuilder(provider);
new NativeChannelFilter().Configure(next=>next.Run(async ctx=>{
Check(!ctx.Request.Headers.ContainsKey("Accept-Encoding"),"native buffered JSON is uncompressed");
Check(!ctx.Request.Query.ContainsKey("Limit"),"pagination applied after native permission query");
ctx.User=new ClaimsPrincipal(new ClaimsIdentity(new[]{new Claim("Jellyfin-UserId",current.ToString())},"test"));
ctx.Response.ContentType="application/json";
await ctx.Response.WriteAsync(new JsonObject{["Items"]=new JsonArray(new JsonObject{["Id"]=a},new JsonObject{["Id"]=b}),["TotalRecordCount"]=2}.ToJsonString());
}))(app);
var context=new DefaultHttpContext{RequestServices=provider};context.Request.Headers.AcceptEncoding="gzip, br";context.Request.Method="GET";context.Request.Path="/LiveTv/Channels";context.Request.QueryString=new QueryString(query);context.Response.Body=new MemoryStream();
await app.Build()(context);Check(context.Request.Headers.AcceptEncoding=="gzip, br","restore compression request header");context.Response.Body.Position=0;return await JsonNode.ParseAsync(context.Response.Body);
}
var result=await Request(user,"?Limit=1");Check((int)result["TotalRecordCount"]==1&&(string)result["Items"][0]["Id"]==b,"native hidden and channel order");
result=await Request(user,"?Limit=0");Check(result["Items"].AsArray().Count==0&&(int)result["TotalRecordCount"]==1,"native zero-limit count");
result=await Request(Guid.NewGuid(),"?StartIndex=1&Limit=1");Check((int)result["TotalRecordCount"]==2&&(string)result["Items"][0]["Id"]==b,"native user preference isolation and pagination");
result=await Request(user,"?UserId="+Guid.NewGuid()+"&Limit=1");Check((int)result["TotalRecordCount"]==1,"query cannot replace authenticated preference owner");
result=await Request(user,"?DecoderIncludeHidden=true&Limit=2");Check((int)result["TotalRecordCount"]==2,"preference editor can restore hidden channels");
}
finally { if(Directory.Exists(folder))Directory.Delete(folder,true); }
var probeJson="""{"streams":[{"index":0,"codec_type":"video","codec_name":"h264","width":1920,"height":1080,"field_order":"tt","avg_frame_rate":"25/1"},{"index":1,"codec_type":"audio","codec_name":"mp2","channels":2,"sample_rate":"48000"}]}""";
var calls=0;
var sharedProbe=new LiveStreamProbe(async (url,token)=>{Interlocked.Increment(ref calls);await System.Threading.Tasks.Task.Delay(100,token);return probeJson;});
var probes=await System.Threading.Tasks.Task.WhenAll(System.Linq.Enumerable.Range(0,4).Select(_=>sharedProbe.Get("channel",CancellationToken.None)));
Check(calls==1&&probes.All(x=>x.Count==2),"four simultaneous requests share one upstream probe");
Check(probes[0][0].Index==0&&probes[0][0].IsInterlaced&&probes[0][0].AverageFrameRate==25,"real stream indexes and interlacing preserved");
await sharedProbe.Get("channel",CancellationToken.None);Check(calls==1,"successful probe cached");
using(var cancelled=new CancellationTokenSource(20))
{
var abandoned=sharedProbe.Get("another",cancelled.Token);var surviving=sharedProbe.Get("another",CancellationToken.None);
try{await abandoned;throw new Exception("cancellation expected");}catch(OperationCanceledException){}
Check((await surviving).Count==2&&calls==2,"one cancelled caller does not break shared probe");
}
var failingCalls=0;
var stalled=new LiveStreamProbe(async (url,token)=>{Interlocked.Increment(ref failingCalls);await System.Threading.Tasks.Task.Delay(10000,token);return probeJson;},TimeSpan.FromMilliseconds(100));
var watch=System.Diagnostics.Stopwatch.StartNew();
for(var i=0;i<2;i++)try{await stalled.Get("stalled",CancellationToken.None);throw new Exception("failure expected");}catch(InvalidOperationException){}
Check(watch.Elapsed.TotalSeconds<2&&failingCalls==1,"stalled source bounded and failed retries coalesced");
try{LiveStreamProbe.Parse("{\"streams\":[]}");throw new Exception("invalid video expected");}catch(InvalidOperationException){}
Check(true,"empty source rejected before playback");
var liveVideo=LiveStreamProbe.ParseLiveMetadata(probes[0][0]);
Check(liveVideo.Index==-1&&liveVideo.IsInterlaced&&liveVideo.AverageFrameRate==25&&probes[0][0].Index==0,"live automatic mapping preserves metadata without mutating cached probe");
await AdaptiveProbeTests.Run(Check);
await PatientProbeTests.Run(Check);
await RelayStartupTests.Run(Check);
var relayHandler=new RelayHandler();
using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100)))
{
var address=relay.Register("http://receiver/channel");var id=address.Split('/').Last();
Check(relay.Register("http://receiver/channel")==address,"one stable relay address for a channel");
var first=await relay.Subscribe(id,CancellationToken.None);var second=await relay.Subscribe(id,CancellationToken.None);
var one=await first.Reader.ReadAsync();var two=await second.Reader.ReadAsync();
Check(relayHandler.Calls==1&&one[0]==0x47&&two[0]==0x47,"two data consumers share one receiver HTTP connection");
await first.DisposeAsync();Check((await second.Reader.ReadAsync()).Length>0&&relayHandler.Calls==1,"one consumer stopping does not interrupt the other");
var context=new DefaultHttpContext();context.Connection.RemoteIpAddress=System.Net.IPAddress.Parse("192.0.2.5");
var controller=new LiveRelayController(relay){ControllerContext=new Microsoft.AspNetCore.Mvc.ControllerContext{HttpContext=context}};
await controller.Stream(id,CancellationToken.None);Check(context.Response.StatusCode==403,"relay denied to non-loopback clients");
await second.DisposeAsync();await System.Threading.Tasks.Task.Delay(250);
Check(System.Text.Json.JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"),"receiver connection released after final consumer");
}
await SavedChannelPlaybackTests.Run(Check);
await ChannelSyncTests.Run(Check);
await PlaybackFeedbackTests.Run(Check);
ContinuityTests.Run(Check);
LosslessSlateTests.Run(Check);
NativeResolutionPolicyTests.Run(Check);
public class PathsProxy : DispatchProxy
{
public string Folder;
protected override object Invoke(MethodInfo method,object[] args)=>(method.Name=="get_DataPath" || method.Name=="get_PluginConfigurationsPath")?Folder:null;
}
public class PolicyUserProxy : DispatchProxy
{
public User Current;
protected override object Invoke(MethodInfo method,object[] args) => method.Name=="GetUserById"&&Current!=null&&(Guid)args[0]==Current.Id?Current:null;
}
public class PolicyLibraryProxy : DispatchProxy
{
public User Current;public BaseItem Item;public int Calls;
protected override object Invoke(MethodInfo method,object[] args)
{
if(method.Name!="GetItemById"||args.Length!=2||!ReferenceEquals(args[1],Current))throw new Exception("Expected native item lookup using authenticated User");
Calls++;return Item;
}
}
public class RelayHandler : System.Net.Http.HttpMessageHandler
{
public int Calls;
protected override System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage> SendAsync(System.Net.Http.HttpRequestMessage request,CancellationToken token)
{ Interlocked.Increment(ref Calls);return System.Threading.Tasks.Task.FromResult(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK){Content=new System.Net.Http.StreamContent(new PacketStream())}); }
}
public class PacketStream : Stream
{
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 async System.Threading.Tasks.ValueTask<int> ReadAsync(Memory<byte> buffer,CancellationToken token=default)
{ await System.Threading.Tasks.Task.Delay(10,token);var size=Math.Min(buffer.Length,188*256);size-=size%188;buffer.Span[..size].Clear();for(int i=0;i<size;i+=188)buffer.Span[i]=0x47;return size; }
public override int Read(byte[] b,int o,int c)=>throw new NotSupportedException();public override void Flush(){}public override long Seek(long o,SeekOrigin s)=>throw new NotSupportedException();public override void SetLength(long n)=>throw new NotSupportedException();public override void Write(byte[] b,int o,int c)=>throw new NotSupportedException();
}
-135
View File
@@ -1,135 +0,0 @@
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();
}
-108
View File
@@ -1,108 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
/// <summary>Native source dimensions through slate/live switches, remux and fMP4 packaging.</summary>
public static class ResolutionE2E
{
private static string encoder, output;
private static int active, maximum;
private static readonly Dictionary<string, string> Sizes = new() {
["sd"] = "720x576", ["hd"] = "1280x720", ["fhd"] = "1920x1080", ["qhd"] = "2560x1440", ["uhd"] = "3840x2160"
};
public static async Task Run(string executable, string folder)
{
encoder = executable; output = Path.GetFullPath(folder);
if (Directory.Exists(output)) throw new Exception("Choose a new output directory"); Directory.CreateDirectory(output);
using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop();
var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
using var logging = LoggerFactory.Create(b => b.AddSimpleConsole().SetMinimumLevel(LogLevel.Warning));
using var manager = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), logging.CreateLogger<ContinuityStreams>(), preserveResolution: true);
using var relay = new LiveStreamRelay(new System.Net.Http.HttpClient(), port, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5));
builder.Services.AddSingleton(manager); builder.Services.AddSingleton(relay); builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly);
await using var app = builder.Build(); app.MapControllers(); app.MapGet("/fixture/{size}.ts", Fixture); await app.StartAsync();
var reports = new List<object>();
try
{
foreach (var (name, size) in Sizes)
{
var dimensions = size.Split('x');
var tracks = LosslessSlateTests.Tracks(int.Parse(dimensions[0]), int.Parse(dimensions[1]), 25);
((BroadcastMediaStream)tracks.Input[0]).SampleAspectRatio = name == "sd" ? "16:15" : "1:1";
tracks = new BroadcastTracks(tracks.Input);
var media = await manager.Open(relay.Register($"http://127.0.0.1:{port}/fixture/{name}.ts"), "en", CancellationToken.None, tracks);
Require(media.MediaStreams[0].Width == tracks.Input[0].Width && media.MediaStreams[0].Height == tracks.Input[0].Height,
"source metadata does not falsely cap the channel at Full HD: " + name);
var target = Path.Combine(output, name); Directory.CreateDirectory(target);
var common = new[] { "-xerror", "-i", media.Path, "-t", "32", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy" };
using var recording = Start(common.Concat(new[] { Path.Combine(target, "recording.ts") }), name + "-recording");
using var packaging = Start(common.Concat(new[] { "-bsf:a", "aac_adtstoasc", "-f", "hls", "-hls_segment_type", "fmp4", "-hls_time", "2", "-hls_list_size", "0", Path.Combine(target, "index.m3u8") }), name + "-fmp4");
try
{
await Task.WhenAll(recording.WaitForExitAsync(), packaging.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(65));
Console.WriteLine("Source status: " + JsonSerializer.Serialize(manager.Status()));
Require(recording.ExitCode == 0 && packaging.ExitCode == 0, "strict slate/live read and fMP4 packaging: " + size);
}
finally
{
if (!recording.HasExited) recording.Kill(true); if (!packaging.HasExited) packaging.Kill(true); manager.Close(media.Id);
}
foreach (var file in new[] { "recording.ts", "index.m3u8" })
{
using var decode = Start(new[] { "-xerror", "-i", Path.Combine(target, file), "-map", "0:v:0", "-map", "0:a:0", "-f", "null", "-" }, name + "-decode-" + Path.GetExtension(file).TrimStart('.'));
await decode.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60)); Require(decode.ExitCode == 0, "decode mixed-resolution " + file + ": " + size);
var probePath = Path.Combine(Path.GetDirectoryName(encoder), "ffprobe");
var info = new ProcessStartInfo(probePath) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var arg in new[] { "-v", "error", "-threads", "1", "-select_streams", "v:0", "-show_entries", "frame=width,height,sample_aspect_ratio", "-of", "csv=p=0", Path.Combine(target, file) }) info.ArgumentList.Add(arg);
using var probe = Process.Start(info); var text = await probe.StandardOutput.ReadToEndAsync(); var error = await probe.StandardError.ReadToEndAsync(); await probe.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60));
Require(probe.ExitCode == 0 && string.IsNullOrWhiteSpace(error), "probe actual decoded dimensions: " + name + " " + file);
var decodedDimensions = text.Split('\n').Where(line => line.Length > 0).Select(line => line.Trim()).Distinct().ToArray();
var expected = size.Replace('x', ',') + ",";
Require(decodedDimensions.Any(line => line.StartsWith(expected, StringComparison.Ordinal)), "native dimensions reach the decoded client frames: " + size);
if (name == "sd") Require(decodedDimensions.Any(line => line.StartsWith(expected + "16:15", StringComparison.Ordinal)), "SD sample aspect ratio survives slate/live switching");
reports.Add(new { Profile = name, File = file, Dimensions = decodedDimensions });
}
await Task.Delay(1500);
}
Require(active == 0 && maximum == 1, "resolution trials keep one upstream and release the last source");
var report = new { Passed = true, SyntheticOnly = true, LowMotionFixture = true, ReceiverConnectionsOpened = 0, PhysicalAndroidTvTested = false,
ActualJellyfinPlayerTested = false, MaximumConcurrentSourceConnections = maximum, Reports = reports };
await File.WriteAllTextAsync(Path.Combine(output, "result.json"), JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); Console.WriteLine(JsonSerializer.Serialize(report));
}
finally { manager.Dispose(); await app.StopAsync(); }
}
private static Process Start(IEnumerable<string> arguments, string name)
{
var info = new ProcessStartInfo(encoder) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true };
foreach (var arg in new[] { "-hide_banner", "-loglevel", "warning", "-nostdin", "-threads", "1", "-filter_threads", "1" }.Concat(arguments)) info.ArgumentList.Add(arg);
var process = Process.Start(info); process.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (typeof(ResolutionE2E)) File.AppendAllText(Path.Combine(output, name + ".log"), e.Data + "\n"); }; process.BeginErrorReadLine(); return process;
}
private static void Require(bool condition, string text) { if (!condition) throw new Exception(text); Console.WriteLine("PASS " + text); }
private static async Task Fixture(HttpContext context)
{
var name = context.Request.RouteValues["size"]?.ToString(); if (name == null || !Sizes.TryGetValue(name, out var size)) { context.Response.StatusCode = 404; return; }
maximum = Math.Max(maximum, Interlocked.Increment(ref active));
var filter = "color=c=0x085f21:s=" + size + ":r=25" + (name == "sd" ? ",setsar=16/15" : "");
var info = new ProcessStartInfo(encoder) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true };
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", filter,
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-c:v", "libx264", "-threads", "1", "-preset", "ultrafast", "-g", "25", "-c:a", "aac", "-ac", "2", "-f", "mpegts", "pipe:1" }) info.ArgumentList.Add(arg);
using var process = Process.Start(info); process.ErrorDataReceived += (_, _) => { }; process.BeginErrorReadLine(); context.Response.ContentType = "video/mp2t";
try { await process.StandardOutput.BaseStream.CopyToAsync(context.Response.Body, context.RequestAborted); }
catch (OperationCanceledException) { } catch (IOException) { }
finally { if (!process.HasExited) process.Kill(true); await process.WaitForExitAsync(); Interlocked.Decrement(ref active); }
}
}
-135
View File
@@ -1,135 +0,0 @@
using System;
using System.IO;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging.Abstractions;
public static class SavedChannelPlaybackTests
{
public const string Old = "1:0:1:3D66:2C88:13E:820000:0:0:0:";
public const string Current = "1:0:1:13F1:5DC:13E:820000:0:0:0:";
public static async Task Run(Action<bool,string> check)
{
var previous = Plugin.Instance;
var folder = Path.Combine(Path.GetTempPath(),"saved-channel-"+Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
try
{
var paths=DispatchProxy.Create<IApplicationPaths,SavedPaths>();((SavedPaths)(object)paths).Folder=folder;
var plugin=new Plugin(paths,DispatchProxy.Create<IXmlSerializer,NoXmlFiles>());
plugin.Configuration.HostName="receiver";plugin.Configuration.WebInterfacePort="80";
plugin.Configuration.StreamingPort="8001";plugin.Configuration.OnlyOneBouquet=true;
plugin.Configuration.TVBouquet="Polskie";plugin.Configuration.FetchPiconsFromWebInterface=true;
var library=DispatchProxy.Create<ILibraryManager,SavedChannelLibrary>();
var handler=new ReceiverHandler();
using var relay=new LiveStreamRelay(new HttpClient(handler),8096,TimeSpan.FromMilliseconds(50));
var calls=0;
var probe=new LiveStreamProbe((url,token)=>
{
calls++;
check(url.EndsWith("?programme=5105",StringComparison.Ordinal),"saved channel probes the migrated SID rather than the historical SID");
return Task.FromResult("{\"programs\":[{\"program_id\":5105,\"streams\":[{\"index\":0,\"codec_type\":\"video\",\"codec_name\":\"h264\",\"width\":1920,\"height\":1080}]}],\"streams\":[{\"index\":0,\"codec_type\":\"video\",\"codec_name\":\"h264\",\"width\":1920,\"height\":1080}]}");
});
var service=new LiveTvService(NullLogger<LiveTvService>.Instance,new ReceiverFactory(),null,probe,relay,library);
var saved=await service.GetChannelStream(Old,null,CancellationToken.None);
var current=await service.GetChannelStream(Current,null,CancellationToken.None);
check(saved.Path==current.Path&&calls==1,"saved and current native channels share the same relay and metadata probe");
check(((SavedChannelLibrary)(object)library).Calls==1,"current reference avoids the native historical lookup");
var id=saved.Path.Split('/').Last();
await using var first=await relay.Subscribe(id,CancellationToken.None);
await using var second=await relay.Subscribe(id,CancellationToken.None);
await first.Reader.ReadAsync();await second.Reader.ReadAsync();
check(handler.Calls==1&&handler.Reference==Current,"both native IDs open only one upstream using the migrated receiver reference");
using var cancelled=new CancellationTokenSource();cancelled.Cancel();
try { await service.GetChannelStream(Old,null,cancelled.Token);throw new Exception("Expected cancellation"); }
catch(OperationCanceledException) { check(handler.Calls==1,"cancelled channel lookup opens no extra upstream"); }
var offline=new LiveTvService(NullLogger<LiveTvService>.Instance,new ReceiverFactory(new UnavailableBouquet(false)),null,probe,relay,library);
check((await offline.GetChannelStream(Current,null,CancellationToken.None)).Path==current.Path&&((SavedChannelLibrary)(object)library).Calls==1,
"unavailable web interface preserves direct playback without historical lookup");
var stalled=new LiveTvService(NullLogger<LiveTvService>.Instance,new ReceiverFactory(new UnavailableBouquet(true)),null,probe,relay,library);
var watch=Stopwatch.StartNew();
check((await stalled.GetChannelStream(Current,null,CancellationToken.None)).Path==current.Path&&watch.Elapsed<TimeSpan.FromSeconds(7),
"stalled bouquet lookup is bounded to five seconds and preserves requested playback");
}
finally
{
typeof(Plugin).GetProperty(nameof(Plugin.Instance)).SetValue(null,previous);
Directory.Delete(folder,true);
}
}
public class NoXmlFiles:DispatchProxy
{
protected override object Invoke(MethodInfo method,object[] args)
{
if(method.Name=="SerializeToFile")return null; // Keep default configuration in memory.
throw new Exception("No persisted configuration expected in isolated test");
}
}
public class SavedPaths:DispatchProxy
{
public string Folder;
protected override object Invoke(MethodInfo method,object[] args) => method.ReturnType==typeof(string)?Folder:null;
}
public class SavedChannelLibrary:DispatchProxy
{
public int Calls;
protected override object Invoke(MethodInfo method,object[] args)
{
if(method.Name!="GetItemList"||args[0] is not InternalItemsQuery q||!q.IncludeItemTypes.SequenceEqual(new[]{BaseItemKind.LiveTvChannel}))
throw new Exception("Expected metadata lookup restricted to live TV channels");
Calls++;
return new BaseItem[]{new LiveTvChannel{ExternalId=Old,Name="TV PULS HD",ServiceName="Enigma2"},
new LiveTvChannel{ExternalId=Old,Name="Different channel",ServiceName="Other provider"}};
}
}
private sealed class ReceiverFactory:IHttpClientFactory
{
private readonly HttpMessageHandler handler;
public ReceiverFactory(HttpMessageHandler handler=null){this.handler=handler;}
public HttpClient CreateClient(string name)=>new(handler??new BouquetHandler());
}
private sealed class UnavailableBouquet:HttpMessageHandler
{
private readonly bool stall;
public UnavailableBouquet(bool stall){this.stall=stall;}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token)
{
if(stall)await Task.Delay(Timeout.InfiniteTimeSpan,token);
throw new HttpRequestException("Receiver web interface unavailable");
}
}
private sealed class BouquetHandler:HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token)
{
token.ThrowIfCancellationRequested();
var body=string.IsNullOrEmpty(request.RequestUri.Query)
? "<e2servicelist><e2service><e2servicereference>1:7:1:0:0:0:0:0:0:0:FROM BOUQUET</e2servicereference><e2servicename>Polskie</e2servicename></e2service></e2servicelist>"
: "<e2servicelist><e2service><e2servicereference>"+Current+"</e2servicereference><e2servicename>TV PULS HD</e2servicename></e2service></e2servicelist>";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK){Content=new StringContent(body)});
}
}
private sealed class ReceiverHandler:HttpMessageHandler
{
public int Calls;public string Reference;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token)
{
Calls++;Reference=request.RequestUri.AbsolutePath.TrimStart('/');
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK){Content=new StreamContent(new PacketStream())});
}
}
}
-81
View File
@@ -1,81 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Model.Entities;
/// <summary>Self-authored DVB bitmap fixtures; no broadcaster samples or receiver connection.</summary>
public static class SyntheticBroadcastFixture
{
public static BroadcastTracks Tracks(bool normalize = true) => new(new MediaStream[] {
new BroadcastMediaStream { Type=MediaStreamType.Video,Index=0,TransportId=256,Codec="h264",Width=640,Height=360 },
new BroadcastMediaStream { Type=MediaStreamType.Audio,Index=1,TransportId=257,Codec="aac",Language="pol",Title="Polski",Channels=2,SampleRate=48000,IsDefault=true },
new BroadcastMediaStream { Type=MediaStreamType.Audio,Index=2,TransportId=258,Codec="aac",Language="eng",Title="English",Channels=2,SampleRate=48000 },
new BroadcastMediaStream { Type=MediaStreamType.Subtitle,Index=3,TransportId=259,Codec="DVBSUB",Language="pol",SubtitleExtraData=new byte[]{0,1,0,1},IsHearingImpaired=true },
new BroadcastMediaStream { Type=MediaStreamType.Subtitle,Index=4,TransportId=260,Codec="DVBSUB",Language="eng",SubtitleExtraData=new byte[]{0,1,0,1},IsForced=true },
new BroadcastMediaStream { Type=MediaStreamType.Subtitle,Index=5,TransportId=261,Codec="DVBTXT",Language="pol",SubtitleExtraData=new byte[]{16,136} }
},normalize);
private static long Pts(byte[] b,int p) => ((long)(b[p]&14)<<29)|((long)b[p+1]<<22)|((long)(b[p+2]&254)<<14)|((long)b[p+3]<<7)|((long)b[p+4]>>1);
private static byte[] Stamp(long t) => new[]{(byte)(33L|((t>>29)&14)),(byte)(t>>22),(byte)(1L|((t>>14)&254)),(byte)(t>>7),(byte)(1L|(t<<1))};
public static byte[] AddSubtitles(byte[] input)
{
input=BroadcastSlateProgram.Apply(input,Tracks(),false);
using var output=new MemoryStream();long next=0;var counters=new int[3];
for(var pos=0;pos<input.Length;pos+=188)
{
output.Write(input,pos,188);var pid=((input[pos+1]&31)<<8)|input[pos+2];
if(pid!=256||(input[pos+1]&64)==0)continue;
var p=pos+4+((input[pos+3]&32)!=0?1+input[pos+4]:0);
if(p+14>pos+188||input[p]!=0||input[p+1]!=0||input[p+2]!=1||(input[p+7]&128)==0)continue;
var pts=Pts(input,p+9);if(pts<next)continue;next=pts+180000;
for(var track=0;track<3;track++)
foreach(var packet in Packets(track==2?Teletext():Bitmap(track),pts,259+track,ref counters[track]))output.Write(packet);
}
return output.ToArray();
}
public static byte[][] Packets(byte[] bitmap,long pts,int pid,ref int counter)
{
var pes=new List<byte>{0,0,1,189};var length=bitmap.Length+8;
pes.Add((byte)(length>>8));pes.Add((byte)length);pes.AddRange(new byte[]{128,128,5});pes.AddRange(Stamp(pts));pes.AddRange(bitmap);
var result=new List<byte[]>();
for(var i=0;i<pes.Count;)
{
var packet=Enumerable.Repeat((byte)255,188).ToArray();packet[0]=71;packet[1]=(byte)((pid>>8)|(i==0?64:0));packet[2]=(byte)pid;
var count=Math.Min(184,pes.Count-i);var p=4;
if(count<184){packet[3]=(byte)(48|(counter++&15));packet[4]=(byte)(183-count);if(packet[4]>0)packet[5]=0;p+=184-count;}
else packet[3]=(byte)(16|(counter++&15));
pes.CopyTo(i,packet,p,count);i+=count;result.Add(packet);
}
return result.ToArray();
}
private static byte[] Teletext()
{
// A self-authored page 888, using standard Hamming-address and odd-parity text bytes.
byte Reverse(byte value) { byte result=0;for(var bit=0;bit<8;bit++)result=(byte)((result<<1)|((value>>bit)&1));return result; }
var hamming=new byte[]{21,2,73,94,100,115,56,47,208,199,140,155,161,182,253,234};
byte Character(char c) { var value=(byte)(c&127);if(System.Numerics.BitOperations.PopCount((uint)value)%2==0)value|=128;return Reverse(value); }
var result=new List<byte>{16};
void Row(int row,byte[] text){result.AddRange(new byte[]{3,44,199,228,Reverse(hamming[(row&1)<<3]),Reverse(hamming[row>>1])});result.AddRange(text);}
var header=new List<byte>();foreach(var n in new[]{8,8,0,8,0,8,0,0})header.Add(Reverse(hamming[n]));header.AddRange(Enumerable.Repeat(Character(' '),32));Row(0,header.ToArray());
Row(1,"SYNTHETIC TELETEXT SUBTITLE".PadRight(40).Select(Character).ToArray());return result.ToArray();
}
public static byte[] Bitmap(int variant)
{
var result=new List<byte>{32,0};
void Segment(byte type,byte[] data){result.AddRange(new byte[]{15,type,0,1,(byte)(data.Length>>8),(byte)data.Length});result.AddRange(data);}
Segment(20,new byte[]{0,2,127,1,103}); // display 640 x 360
Segment(16,new byte[]{2,11,0,255,0,64,1,44});
Segment(17,new byte[]{0,7,0,96,0,32,39,0,0,3,0,0,0,0,240,0});
var field=new List<byte>();
for(var y=0;y<16;y++)
{
field.Add(16);
for(var x=0;x<24;x++)field.Add((byte)((x/4+y/4+variant)%2==0?85:170));
field.Add(0);field.Add(240);
}
var data=new List<byte>{0,0,0,(byte)(field.Count>>8),(byte)field.Count,(byte)(field.Count>>8),(byte)field.Count};
data.AddRange(field);data.AddRange(field);Segment(19,data.ToArray());Segment(128,Array.Empty<byte>());result.Add(255);
return result.ToArray();
}
}
-1
View File
@@ -1 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><ProjectReference Include="../Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj" /></ItemGroup></Project>
-3
View File
@@ -1,3 +0,0 @@
knackebrot/jellyfin-plugin-enigma2 jellyfin branch
d38c1595d8fdf129dcdef1f78da4935cf7b3358c
CC0-1.0
-28
View File
@@ -1,28 +0,0 @@
// Summarize measured client smoothness separately from upstream/transport checks.
const fs = require('node:fs');
const input = process.argv[2];
if (!input) throw Error('Usage: node analyze-reception-trial.cjs report.json [summary.json]');
const report = JSON.parse(fs.readFileSync(input, 'utf8'));
const round = x => Number(x.toFixed(3));
const viewers = report.viewers.map(viewer => {
const rows = viewer.samples.filter(row => row.video);
if (rows.length < 2) return { viewer: viewer.viewer, status: 'INSUFFICIENT_DATA' };
const first = rows[0], last = rows.at(-1);
const seconds = (Date.parse(last.utc) - Date.parse(first.utc)) / 1000;
const media = last.video.time - first.video.time;
const frames = last.video.frames - first.video.frames, dropped = last.video.dropped - first.video.dropped;
let waiting, waitingSeconds = [], unmatched = 0;
for (const row of viewer.samples) for (const event of row.events || []) {
if (event.at < Date.parse(first.utc) || event.at > Date.parse(last.utc)) continue;
if (event.type === 'waiting' && waiting === undefined) waiting = event.at;
if (event.type === 'playing' && waiting !== undefined) { waitingSeconds.push((event.at - waiting) / 1000); waiting = undefined; }
}
if (waiting !== undefined) unmatched++;
waitingSeconds.sort((a,b) => a-b);
const totalWait = waitingSeconds.reduce((a,b) => a+b,0);
const realtime = media / seconds, dropFraction = frames > 0 ? dropped / frames : null;
return { viewer: viewer.viewer, sampled_seconds: round(seconds), media_progress_seconds: round(media), realtime_ratio: round(realtime), total_frame_delta: frames, dropped_frame_delta: dropped, dropped_fraction: dropFraction === null ? null : round(dropFraction), approximate_presented_fps: round((frames-dropped)/seconds), waiting_events: waitingSeconds.length, waiting_seconds: round(totalWait), waiting_median_seconds: waitingSeconds.length ? round(waitingSeconds[Math.floor(waitingSeconds.length/2)]) : null, waiting_max_seconds: waitingSeconds.length ? round(waitingSeconds.at(-1)) : null, waiting_unmatched: unmatched, minimum_buffer_seconds: round(Math.min(...rows.map(row=>row.video.buffered_ahead))), audio_decoded_byte_delta: first.video.audio_bytes === null || last.video.audio_bytes === null ? null : last.video.audio_bytes-first.video.audio_bytes, status: realtime >= 0.98 && totalWait/seconds <= 0.02 && dropFraction !== null && dropFraction <= 0.05 ? 'SMOOTH_TEST_CLIENT' : 'CLIENT_NOT_SMOOTH', scope: 'Headless test client only. Frame presentation is approximated; audible sound and other devices are not verified.' };
});
const summary = { input, plugin: report.snapshots.find(s=>s.plugin)?.plugin, isolated_bridge_override: report.isolated_bridge_override || false, channel: report.channel, first_video_utc: report.first_video_utc, finished_utc: report.finished_utc, transport_checks: { pass: report.pass, fail: report.fail, unverified: report.unverified || 0 }, viewers };
if (process.argv[3]) fs.writeFileSync(process.argv[3], JSON.stringify(summary,null,2)+'\n');
console.log(JSON.stringify(summary,null,2));
-44
View File
@@ -1,44 +0,0 @@
---
# The name of the plugin; official plugins always start "jellyfin-plugin-"
name: "jellyfin-plugin-enigma2"
# The GUID of the plugin, found in Plugin.cs
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: "7.0.14.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: "12.2.0.0" # The earliest binary-compatible version
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.
owner: "knackebrot"
# A short description of the plugin
overview: "Enigma2"
# A longer and more detailed description of the plugin; use multiple
# lines as required for readability.
description: >
Live TV plugin for watching Enigma 2 channels and recordings
# The plugin category, in a general sense. These fields are dynamic.
category: "Live TV"
# A list of built artifacts to be bundled into the ZIP for installation.
# Include the main output file, as well as any dependency `.dll`s that
# might be required for the plugin to run.
artifacts:
- "Jellyfin.Plugin.Enigma2.dll"
# Build information values for the build infrastructure; these should
# not need to be changed usually.
build_type: "dotnet"
dotnet_configuration: "Release"
dotnet_framework: "net10.0"
-45
View File
@@ -1,45 +0,0 @@
{
"verified_utc": "2026-10-23T17:47:24.731251+00:00",
"scope": "Plugin health, scheduled-task results, native channel metadata and preference references; no authenticated player startup",
"server_version": "12.2.0",
"plugin_version": "7.0.11.0",
"plugin_status": "Active",
"plugin_sha256": "10107a4e9c81c972f9d98b664ec9f14208f5e1dd95df769760f3540955089ade",
"tasks": {
"RefreshGuide": {
"Key": "RefreshGuide",
"Status": "Completed",
"StartTimeUtc": "2026-10-23T17:36:05.8679643Z",
"EndTimeUtc": "2026-10-23T17:43:14.7481938Z",
"has_custom_triggers": false
},
"Enigma2ChannelSync": {
"Key": "Enigma2ChannelSync",
"Status": "Completed",
"StartTimeUtc": "2026-10-23T17:36:05.2797565Z",
"EndTimeUtc": "2026-10-23T17:43:14.8422276Z",
"has_custom_triggers": false
}
},
"last_synchronized_utc": "2026-10-23T17:43:14.8379332Z",
"pending_recovery": false,
"fingerprint_saved": true,
"default_interval_hours": 6,
"interval_verified": true,
"native_channels": 214,
"migrated_references_present": {
"Puls": true,
"Puls2": true,
"WP": true,
"TVN24": true
},
"preference_files": 26,
"preference_channel_references": 1772,
"preference_references_missing_native_channel": 32,
"current_users": 19,
"current_user_preference_files": 0,
"orphaned_preference_files_retained": 26,
"current_user_preference_references_missing_native_channel": 0,
"active_receiver_recordings": 1,
"synchronization_verified": true
}
-50
View File
@@ -1,50 +0,0 @@
"""Rebuild pinned upstream SSO for Jellyfin 12.2 in an isolated Docker SDK.
Run on a Linux build host with Docker. Does not install or configure the plugin.
"""
import io
import pathlib
import shutil
import subprocess
import tempfile
import urllib.request
import zipfile
UPSTREAM = "f1e157bac29e6d27a0844378f3e1d4a4fdaa577a"
root = pathlib.Path(tempfile.mkdtemp(prefix="jellyfin-sso12-"))
archive = urllib.request.urlopen(
f"https://codeload.github.com/9p4/jellyfin-plugin-sso/zip/{UPSTREAM}", timeout=90
).read()
with zipfile.ZipFile(io.BytesIO(archive)) as zipped:
for item in zipped.infolist():
relative = pathlib.PurePosixPath(item.filename).parts[1:]
if not relative or item.is_dir():
continue
target = root.joinpath(*relative)
if not target.resolve().is_relative_to(root.resolve()):
raise ValueError("Unsafe archive path")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(zipped.read(item))
for project in list(root.rglob("*.csproj")) + list(root.rglob("*.fsproj")):
text = project.read_text(encoding="utf-8-sig")
text = text.replace("net9.0", "net10.0").replace("net8.0", "net10.0")
text = text.replace('Version="10.11.0"', 'Version="12.2.0"')
text = text.replace(
'<PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.1" />',
'<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.0" />',
)
project.write_text(text, encoding="utf-8")
subprocess.run(
["docker", "run", "--rm", "--cpus=2", "-v", f"{root}:/src", "-w", "/src",
"mcr.microsoft.com/dotnet/sdk:10.0", "dotnet", "build", "SSO-Auth", "-c", "Release",
"-p:CopyLocalLockFileAssemblies=true", "-o", "/src/build"], check=True,
)
package = root / "package"
package.mkdir()
# Jellyfin provides its own assemblies. Include only SSO's additional runtime dependencies.
for name in ["SSO-Auth.dll", "Lib.dll", "FSharp.Core.dll",
"Duende.IdentityModel.OidcClient.dll", "Duende.IdentityModel.dll"]:
shutil.copy2(root / "build" / name, package / name)
for license_file in root.glob("LICENSE*"):
shutil.copy2(license_file, package / license_file.name)
print(f"SSO source: {UPSTREAM}; package: {package}")
-320
View File
@@ -1,320 +0,0 @@
"""Deploy only Enigma2 after an explicitly approved Jellyfin maintenance window."""
import argparse
import datetime
import hashlib
import json
import os
import pathlib
import re
import shutil
import subprocess
import time
import urllib.error
import urllib.request
import uuid
import xml.etree.ElementTree as ET
VERSION = '7.0.15.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')
WEB = pathlib.Path('/usr/share/jellyfin/web/decoder-channel-playback.js')
CONFIG = PLUGINS / 'configurations/Jellyfin.Plugin.Enigma2.xml'
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()
def hashes():
# Include missing paths so an unexpected new file is also detected.
return {str(p): sha256(p) if p.exists() else None for p in PROTECTED}
def read_manifest(directory):
value = json.loads((directory / 'meta.json').read_text(encoding='utf-8-sig'))
if not isinstance(value, dict):
raise ValueError('Plugin manifest must be an object')
return value
def validate_manifest(metadata, version=None):
if metadata.get('name') != 'Enigma2' or str(uuid.UUID(metadata.get('guid', ''))) != GUID:
raise ValueError('Unexpected plugin identity')
if metadata.get('targetAbi') != ABI:
raise ValueError('Unexpected plugin target ABI')
if not re.fullmatch(r'\d+\.\d+\.\d+\.\d+', str(metadata.get('version', ''))):
raise ValueError('Invalid plugin version')
if metadata.get('status') != 'Active':
raise ValueError('Existing plugin is not Active')
if version is not None and metadata['version'] != version:
raise ValueError('Installed manifest version mismatch')
def existing_plugin(plugins):
matches = []
for directory in plugins.iterdir():
if not directory.is_dir():
continue
metadata = {}
if (directory / 'meta.json').is_file():
try:
metadata = read_manifest(directory)
except (ValueError, OSError):
pass
if (directory.name.startswith('Enigma2_') or (directory / DLL).exists()
or metadata.get('guid') == GUID or metadata.get('name') == 'Enigma2'):
matches.append(directory)
if len(matches) != 1:
raise ValueError('Expected exactly one existing Enigma2 installation; resolve conflicts first')
old = matches[0]
if old.is_symlink() or any(p.is_symlink() for p in old.rglob('*')):
raise ValueError('Symlinks in the plugin installation are unsupported')
metadata = read_manifest(old)
validate_manifest(metadata)
if old.name != 'Enigma2_' + metadata['version'] or not (old / DLL).is_file():
raise ValueError('Plugin directory, manifest and DLL are inconsistent')
return old, metadata
def command(arguments):
# Never print raw service logs, which can contain unrelated sensitive data.
return subprocess.run(arguments, check=True, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True).stdout
def service(action):
command(['systemctl', action, 'jellyfin'])
def log_boundary(directory):
offsets = {}
for path in directory.glob('jellyfin*.log'):
if path.is_file() and not path.is_symlink():
stat = path.stat()
offsets[(stat.st_dev, stat.st_ino)] = stat.st_size
if not offsets:
raise RuntimeError('Cannot establish a fresh Jellyfin file-log boundary')
return offsets
def fresh_plugin_load(directory, boundary, version):
for path in directory.glob('jellyfin*.log'):
if not path.is_file() or path.is_symlink():
continue
try:
with path.open('rb') as stream:
stat = os.fstat(stream.fileno())
start = boundary.get((stat.st_dev, stat.st_ino), 0)
if start > stat.st_size: # Existing log was truncated at restart.
start = 0
stream.seek(start)
remaining = stat.st_size - start
carry = ''
while remaining > 0:
chunk = stream.read(min(65536, remaining))
if not chunk:
break
remaining -= len(chunk)
text = carry + chunk.decode('utf-8', errors='replace')
if loaded_version(text, version):
return True
carry = text[-256:]
except FileNotFoundError: # Rotation between listing and opening; retry next poll.
continue
return False
def loaded_version(log, version):
# Match only the loader's exact plugin name/version; do not return log content.
return re.search(r'Loaded plugin:\s*"?Enigma2"?\s+"?' + re.escape(version)
+ r'"?(?=\s|$)', log, flags=re.MULTILINE) is not None
def wait_ready(directory, version, expected_hash, protected_before, boundary, timeout=60):
deadline = time.monotonic() + timeout
observed_version = '<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
time.sleep(1)
raise RuntimeError('Public health and fresh plugin load confirmation did not pass; '
+ 'observedVersion=' + observed_version)
def activate(old, new, stage, backup, verify, verify_rollback, control=service):
"""Keep the original plugin until the replacement passes; restore on any failure."""
saved = backup / old.name
moved_old = False
installed = False
try:
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)
class PluginAssetControl:
"""Update only the plugin's assets while stopped; restore them on rollback."""
def __init__(self, old, new, assets, control=service):
self.old, self.new, self.assets, self.control = old, new, assets, control
def __call__(self, action):
if action == 'start':
candidate = self.new.exists() and not self.old.exists()
for destination, prepared, saved in self.assets:
shutil.copyfile(prepared if candidate else saved, destination)
self.control(action)
def enable_slates(content):
pattern = r'<EnableContinuitySlates>\s*(?:true|false)\s*</EnableContinuitySlates>'
tag = '<EnableContinuitySlates>true</EnableContinuitySlates>'
result = re.sub(pattern, tag, content) if re.search(pattern, content) else re.sub(r'(</[\w:]+>\s*)$', ' ' + tag + '\n' + r'\1', content, count=1)
if ET.fromstring(result).findtext('EnableContinuitySlates') != 'true':
raise ValueError('Cannot set the plugin status-picture option')
return result
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)
parser.add_argument('--web', type=pathlib.Path)
parser.add_argument('--expected-web-sha256')
parser.add_argument('--enable-slates', action='store_true')
parser.add_argument('--check-only', action='store_true')
args = parser.parse_args(argv)
if not args.maintenance_approved and not args.check_only:
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')
if bool(args.web) != bool(args.expected_web_sha256):
raise ValueError('Web asset and its checksum must be supplied together')
if args.web and (not args.web.is_file() or sha256(args.web) != args.expected_web_sha256.lower() or not WEB.is_file()):
raise ValueError('Plugin web asset validation failed')
if args.enable_slates:
enable_slates(CONFIG.read_text(encoding='utf-8-sig'))
if args.check_only:
print(json.dumps({'preflight': True, 'activated': False, 'restart': False, 'candidate': VERSION,
'current': metadata['version'], 'web_included': bool(args.web), 'enable_slates_requested': args.enable_slates}))
return
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'
assets = []
try:
if args.web:
saved = backup / 'previous-web.js'; shutil.copy2(WEB, saved)
assets.append((WEB, args.web, saved))
if args.enable_slates:
saved = backup / 'previous-plugin-config.xml'; shutil.copy2(CONFIG, saved)
prepared = backup / 'candidate-plugin-config.xml'
prepared.write_text(enable_slates(CONFIG.read_text(encoding='utf-8-sig')), encoding='utf-8')
assets.append((CONFIG, prepared, saved))
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')
def verify_plugin(directory, version, digest, candidate):
wait_ready(directory, version, digest, before, boundary)
for destination, prepared, saved in assets:
if sha256(destination) != sha256(prepared if candidate else saved):
raise RuntimeError('Plugin asset verification failed')
activate(old, new, stage, backup,
lambda: verify_plugin(new, VERSION, expected, True),
lambda: verify_plugin(old, metadata['version'], old_hash, False),
PluginAssetControl(old, new, assets))
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()
-36
View File
@@ -1,36 +0,0 @@
"""Run in CT100191 after staging DLL at /tmp/Jellyfin.Plugin.Enigma2.dll."""
import datetime,json,pathlib,shutil,sqlite3,subprocess,urllib.request
db=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True)
token=db.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];db.close()
headers={'Authorization':'MediaBrowser Token="'+token+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'}
def get(path):
return json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096'+path,headers=headers),timeout=30))
if get('/System/Info/Public')['Version']!='12.2.0':raise SystemExit('This build requires Jellyfin 12.2.0')
if any(s.get('NowPlayingItem') for s in get('/Sessions')):raise SystemExit('Active playback: restart deferred')
plugins=pathlib.Path('/var/lib/jellyfin/plugins')
backup=pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups')/datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
backup.mkdir(parents=True,mode=0o700)
config=plugins/'configurations/Jellyfin.Plugin.Enigma2.xml'
if config.exists():shutil.copy2(config,backup/config.name)
web_config=pathlib.Path('/usr/share/jellyfin/web/config.json')
shutil.copy2(web_config,backup/'web-config.json')
old=[p for p in plugins.glob('Enigma2_*') if p.is_dir()]
new=plugins/'Enigma2_7.0.1.0'
subprocess.run(['systemctl','stop','jellyfin'],check=True)
try:
for folder in old:shutil.move(str(folder),str(backup/folder.name))
new.mkdir();shutil.copy2('/tmp/Jellyfin.Plugin.Enigma2.dll',new/'Jellyfin.Plugin.Enigma2.dll')
metadata={'category':'Live TV','description':'Enigma2 with internet EPG artwork and per-user channel preferences','guid':'193f29f9-ea6c-4595-a6f6-55e79d7c590a','name':'Enigma2','overview':'Enigma2 decoder integration','owner':'local','status':'Active','targetAbi':'12.2.0.0','version':'7.0.1.0'}
(new/'meta.json').write_text(json.dumps(metadata))
subprocess.run(['chown','-R','jellyfin:jellyfin',str(new)],check=True)
settings=json.loads(web_config.read_text());links=settings.setdefault('menuLinks',[])
if not any(x.get('name')=='Telewizja dekodera' for x in links):links.append({'name':'Telewizja dekodera','icon':'tv','url':'../DecoderIntegration/Channels'})
web_config.write_text(json.dumps(settings,indent=2)+'\n')
except Exception:
shutil.copy2(backup/'web-config.json',web_config)
if new.exists():shutil.rmtree(new)
for folder in old:
if (backup/folder.name).exists():shutil.move(str(backup/folder.name),str(folder))
raise
finally:subprocess.run(['systemctl','start','jellyfin'],check=True)
print(json.dumps({'installed':'7.0.1.0','backup':str(backup)}))
-20
View File
@@ -1,20 +0,0 @@
{
"plugin": "7.0.10.0",
"backup": "/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261023T172732990384Z",
"ready": true,
"sha256": "d9f6e3de77a2c859e3f3a80da1165a3ff9834fba25f175255ca412f0fdaf4fd2",
"fresh_plugin_load_confirmed": true,
"server_configuration_and_wrappers_unchanged": true,
"started_utc": "2026-10-23T17:27:32.680450+00:00",
"finished_utc": "2026-10-23T17:27:54.906504+00:00",
"source_commit": "2e7662f",
"receiver_recordings_before": 1,
"receiver_recordings_after": 1,
"receiver_active_recordings_preserved": true,
"receiver_restarted": false,
"user_preference_files_before": 26,
"user_preference_files_after": 26,
"user_preference_files_changed": 0,
"client_playback_verified": false,
"synchronization_verified": false
}
-18
View File
@@ -1,18 +0,0 @@
{
"plugin": "7.0.11.0",
"backup": "/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261023T173543864167Z",
"ready": true,
"sha256": "10107a4e9c81c972f9d98b664ec9f14208f5e1dd95df769760f3540955089ade",
"fresh_plugin_load_confirmed": true,
"server_configuration_and_wrappers_unchanged": true,
"started_utc": "2026-10-23T17:35:43.842449+00:00",
"finished_utc": "2026-10-23T17:36:05.515593+00:00",
"source_commit": "eb95d10",
"receiver_recordings_before": 1,
"receiver_recordings_after": 1,
"receiver_active_recordings_preserved": true,
"receiver_restarted": false,
"recovery_checkpoint_present_before_deployment": true,
"client_playback_verified": false,
"synchronization_verified": false
}
-48
View File
@@ -1,48 +0,0 @@
{
"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."
}
-23
View File
@@ -1,23 +0,0 @@
{
"plugin": "7.0.9.0",
"backup": "/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261023T170741733074Z",
"ready": true,
"sha256": "8db3b3700ef417c591f2361e013e20c601052bd35bc124f449c899183950c986",
"fresh_plugin_load_confirmed": true,
"server_configuration_and_wrappers_unchanged": true,
"started_utc": "2026-10-23T17:07:41.709901+00:00",
"finished_utc": "2026-10-23T17:07:49.246332+00:00",
"plugin_configuration_unchanged": true,
"user_preferences_unchanged": true,
"user_preference_files": 26,
"web_module_unchanged": true,
"receiver_recordings_before": 2,
"receiver_recordings_after": 2,
"receiver_active_recordings_preserved": true,
"receiver_restarted": false,
"source_commits": [
"4ff46f5",
"50b39fa"
],
"client_playback_verified": false
}
-89
View File
@@ -1,89 +0,0 @@
// No receiver streams: playback failures are injected into the browser request only.
const fs=require('node:fs');
const {execFileSync}=require('node:child_process');
const {chromium}=require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const ssh=code=>JSON.parse(execFileSync('ssh',['-o','BatchMode=yes','root@192.168.100.25','pct exec 100191 -- python3 -'],{input:code,encoding:'utf8',timeout:30000}).trim().split('\n').at(-1));
const admin=`import sqlite3,json,urllib.request,secrets,pathlib
c=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys LIMIT 1').fetchone()[0];c.close()
def api(p,d=None,method=None):
r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="Analysis", Device="Maintenance", DeviceId="decoder-analysis", Version="1"','Content-Type':'application/json'},data=json.dumps(d).encode() if d is not None else None,method=method)
b=urllib.request.urlopen(r,timeout=15).read();return json.loads(b) if b else None
`;
const origin='http://192.168.100.191:8096',web=origin+'/web/index.html';
const report={checked_utc:new Date().toISOString(),receiver_connections_opened_by_test:0,configuration:[],playback:[]};
const users=[];let browser;
const safe=s=>String(s).replace(/([?&](?:api_key|AccessToken)=)[^&\s]+/gi,'$1<redacted>').slice(0,1800);
async function login(page,u){await page.goto(web);await page.locator('#txtManualName').fill(u.name);await page.locator('#txtManualPassword').fill(u.password);await page.getByRole('checkbox').check();await page.getByRole('button',{name:'Sign In',exact:true}).click();await page.waitForURL(/home/,{timeout:20000});await page.waitForFunction(()=>JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers?.some(s=>s.AccessToken&&s.UserId));await page.waitForTimeout(1000);}
async function traceBridge(page){
const source=fs.readFileSync(__dirname+'/web/decoder-channel-playback.js','utf8')
.replace('await playbackManager.stop();',`await Promise.resolve(playbackManager.stop()).then(()=>{(window.__leaveTrace??=[]).push('stop resolved');},e=>{(window.__leaveTrace??=[]).push('stop rejected: '+String(e));throw e;});`)
.replace("await appRouter.show('livetv?tab=2');",`await Promise.resolve(appRouter.show('livetv?tab=2')).then(()=>{(window.__leaveTrace??=[]).push('route resolved');},e=>{(window.__leaveTrace??=[]).push('route rejected: '+String(e));throw e;});`);
await page.route('**/decoder-channel-playback.js',r=>r.fulfill({status:200,contentType:'application/javascript',body:source}));
}
function create(isAdmin){
const u=ssh(admin+`n='decoder-analysis-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});print(json.dumps({'id':u['Id'],'name':n,'password':p}))`);users.push(u);
ssh(admin+`api('/Users/${u.id}/Password',{'CurrentPw':'','NewPw':${JSON.stringify(u.password)}})
u=api('/Users/${u.id}');p=u['Policy'];p['IsAdministrator']=${isAdmin?'True':'False'};api('/Users/${u.id}/Policy',p)
print('{}')`);return u;
}
(async()=>{
try{
browser=await chromium.launch({headless:true,channel:'msedge'});
const elevated=create(true),ordinary=create(false);
for(const bridge of [true,false]){
const context=await browser.newContext(),page=await context.newPage();const responses=[],errors=[];
if(!bridge)await page.route('**/decoder-channel-playback.js',r=>r.fulfill({status:200,contentType:'application/javascript',body:'window.DecoderChannelPlayback = async () => class { constructor({playbackManager,appRouter}) { this.id="decoder-channel-playback";this.type="integration";window.__diagnosticPlayer=playbackManager;window.__diagnosticRouter=appRouter; } };'}));
page.on('pageerror',e=>errors.push(safe(e.stack||e.message)));
page.on('response',r=>{const path=new URL(r.url()).pathname;if(/ConfigurationPage|\/Plugins\//i.test(path))responses.push({path,status:r.status()});});
await login(page,elevated);await page.evaluate(()=>location.hash='#/configurationpage?name=enigma2');
let opened=false;try{await page.locator('#txtHostName').waitFor({timeout:12000});await page.waitForFunction(()=>document.querySelector('#txtHostName')?.value==='192.168.10.20',{},{timeout:12000});opened=true;}catch{}
const state=await page.evaluate(()=>({route:location.hash,login_fields:document.querySelectorAll('#txtManualPassword').length,configuration_fields:document.querySelectorAll('#txtHostName').length}));
let values;if(opened){values={host:await page.locator('#txtHostName').inputValue(),port:await page.locator('#txtStreamingPort').inputValue(),vu_recordings:await page.locator('#chkEnableVuRecordings').isChecked()};}
report.configuration.push({bridge,administrator_before_initial_login:true,opened,state,values,responses,errors});console.log('configuration '+bridge+': '+opened);await context.close();
}
for(const bridge of [true,false]){
const context=await browser.newContext(),page=await context.newPage();const errors=[];let injected=0;
if(bridge)await traceBridge(page);
if(!bridge)await page.route('**/decoder-channel-playback.js',r=>r.fulfill({status:200,contentType:'application/javascript',body:'window.DecoderChannelPlayback = async () => class { constructor({playbackManager,appRouter}) { this.id="decoder-channel-playback";this.type="integration";window.__diagnosticPlayer=playbackManager;window.__diagnosticRouter=appRouter; } };'}));
await page.addInitScript(()=>{window.__rejections=[];window.addEventListener('unhandledrejection',e=>window.__rejections.push({type:typeof e.reason,message:String(e.reason?.message||e.reason),stack:e.reason?.stack||null}));});
page.on('pageerror',e=>errors.push(safe(e.stack||e.message)));
await page.route('**/*PlaybackInfo*',r=>{++injected;return r.fulfill({status:500,contentType:'application/json',body:JSON.stringify({Message:'Diagnostic unavailable source',StatusCode:500})});});
// Defense in depth: an unexpected live-open request is also blocked locally.
await page.route('**/LiveTv/LiveStreams/Open*',r=>r.fulfill({status:503,body:'Diagnostic: blocked live open'}));
await login(page,ordinary);await page.goto(web+'#/livetv?tab=2');await page.locator('.card[data-type="TvChannel"]').first().waitFor({timeout:15000});
const card=page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:/^\d+ TVP 1 HD$/})});
if(bridge)await card.locator('a.cardImageContainer').click({position:{x:20,y:20}});
else {
// Start the same native player directly; the bridge is the only excluded layer.
await page.evaluate(async()=>{const Plugin=await window.DecoderChannelPlayback();window.__nativeStart=Plugin;});
const play=card.locator('[data-action="play"]');
if(await play.count())await play.first().click({force:true});else await card.locator('a.cardImageContainer').click();
if(await page.getByRole('button',{name:'Play',exact:true}).count())await page.getByRole('button',{name:'Play',exact:true}).first().click();
}
await page.waitForTimeout(5000);
if(bridge&&await page.locator('#decoderPlaybackExit').count()) {await page.locator('#decoderPlaybackExit').click();await page.waitForTimeout(1000);}
if(!bridge) {await page.evaluate(async()=>{try{await window.__diagnosticPlayer.stop();}catch{}try{await window.__diagnosticRouter.show('livetv?tab=2');}catch{}});await page.waitForTimeout(1000);}
const rejected=await page.evaluate(()=>window.__rejections);report.playback.push({bridge,injected_failures:injected,errors,rejected:rejected.map(r=>({...r,stack:r.stack?safe(r.stack):null})),leave_trace:await page.evaluate(()=>window.__leaveTrace||[])});console.log('failure '+bridge+': '+errors.length);await context.close();
}
const context=await browser.newContext(),page=await context.newPage();let injected=0;const errors=[];
await page.addInitScript(()=>{window.__rejections=[];window.addEventListener('unhandledrejection',e=>window.__rejections.push({type:typeof e.reason,message:String(e.reason?.message||e.reason),stack:e.reason?.stack||null}));});
page.on('pageerror',e=>errors.push(safe(e.stack||e.message)));
await page.route('**/*PlaybackInfo*',async r=>{++injected;await new Promise(resolve=>setTimeout(resolve,10000));try{await r.fulfill({status:500,body:'Diagnostic unavailable source'});}catch{}});
await page.route('**/LiveTv/LiveStreams/Open*',r=>r.fulfill({status:503,body:'Diagnostic: blocked live open'}));
await login(page,ordinary);await page.goto(web+'#/livetv?tab=2');await page.locator('.card[data-type="TvChannel"]').first().waitFor();
await page.locator('.card[data-type="TvChannel"]').first().locator('a.cardImageContainer').click({position:{x:20,y:20}});await page.locator('#decoderPlaybackExit').waitFor();
await page.waitForTimeout(1000);await page.locator('#decoderPlaybackExit').click();await page.waitForTimeout(12000);
report.playback.push({bridge:true,scenario:'cancel pending playback request',injected_failures:injected,errors,rejected:await page.evaluate(()=>window.__rejections),final_route:new URL(page.url()).hash});await context.close();
const emptyContext=await browser.newContext(),empty=await emptyContext.newPage();const emptyErrors=[];let emptyResponses=0;
empty.on('pageerror',e=>emptyErrors.push(safe(e.stack||e.message)));
await empty.route('**/*PlaybackInfo*',r=>{++emptyResponses;return r.fulfill({status:200,contentType:'application/json',body:JSON.stringify({MediaSources:[],PlaySessionId:'00000000000000000000000000000000',ErrorCode:'NoCompatibleStream'})});});
await empty.route('**/LiveTv/LiveStreams/Open*',r=>r.fulfill({status:503,body:'Diagnostic: blocked live open'}));
await login(empty,ordinary);await empty.goto(web+'#/livetv?tab=2');await empty.locator('.card[data-type="TvChannel"]').first().waitFor();await empty.locator('.card[data-type="TvChannel"]').first().locator('a.cardImageContainer').click({position:{x:20,y:20}});await empty.waitForTimeout(5000);
if(await empty.locator('#decoderPlaybackExit').count())await empty.locator('#decoderPlaybackExit').click();await empty.waitForTimeout(1000);
report.playback.push({bridge:true,scenario:'empty media sources and exit',injected_failures:emptyResponses,errors:emptyErrors,final_route:new URL(empty.url()).hash});await emptyContext.close();
}catch(e){report.error=safe(e.stack||e.message);}
finally{
if(browser)await browser.close();for(const u of users)ssh(admin+`api('/Users/${u.id}',method='DELETE');pathlib.Path('/var/lib/jellyfin/decoder-integration/users/${u.id.replace(/-/g,'')}.json').unlink(missing_ok=True);print('{}')`);
report.finished_utc=new Date().toISOString();fs.writeFileSync(__dirname+'/web-analysis-20261012.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));
}
})();
-36
View File
@@ -1,36 +0,0 @@
{
"checked_utc": "2026-10-12T19:54:03.596Z",
"mode": "ui",
"checks": [
{
"name": "two isolated users can sign in",
"status": "PASS",
"evidence": {
"users": 2,
"plugin": "7.0.6.0"
}
},
{
"name": "administrator plugin configuration loads current receiver settings",
"status": "PASS"
},
{
"name": "no uncaught browser errors",
"status": "PASS"
}
],
"plugin": {
"Name": "Enigma2",
"Version": "7.0.6.0",
"ConfigurationFileName": "Jellyfin.Plugin.Enigma2.xml",
"Description": "Provides live TV using Enigma2 PVR as a back-end.",
"Id": "193f29f9ea6c4595a6f655e79d7c590a",
"CanUninstall": true,
"HasImage": false,
"Status": "Active"
},
"finished_utc": "2026-10-12T19:54:27.907Z",
"pass": 3,
"fail": 0,
"blocked": 0
}
-83
View File
@@ -1,83 +0,0 @@
{
"checked_utc": "2026-10-12T19:24:55.872Z",
"mode": "playback",
"checks": [
{
"name": "two isolated users can sign in",
"status": "PASS",
"evidence": {
"users": 2,
"plugin": "7.0.6.0"
}
},
{
"name": "4Fun.TV custom one-click decoded video",
"status": "PASS",
"evidence": {
"time_progress": 3.02,
"decoded_frames": 103,
"width": 1047
}
},
{
"name": "4Fun.TV stop or recover to Channels",
"status": "PASS"
},
{
"name": "custom channel button starts decoded video",
"status": "PASS",
"evidence": {
"time_progress": 3.07,
"decoded_frames": 98,
"width": 1047
}
},
{
"name": "custom playback exit",
"status": "PASS"
},
{
"name": "double-click and two browsers share one physical upstream",
"status": "PASS",
"evidence": {
"consumers": 2,
"physical_connections": 1
}
},
{
"name": "stopping one viewer preserves other viewer",
"status": "PASS",
"evidence": {
"time_progress": 3.01,
"decoded_frames": 151,
"width": 1047
}
},
{
"name": "last viewer releases source",
"status": "PASS",
"evidence": {
"active": 0,
"consumers": 0
}
},
{
"name": "no uncaught browser errors",
"status": "PASS"
}
],
"plugin": {
"Name": "Enigma2",
"Version": "7.0.6.0",
"ConfigurationFileName": "Jellyfin.Plugin.Enigma2.xml",
"Description": "Provides live TV using Enigma2 PVR as a back-end.",
"Id": "193f29f9ea6c4595a6f655e79d7c590a",
"CanUninstall": true,
"HasImage": false,
"Status": "Active"
},
"finished_utc": "2026-10-12T19:26:40.168Z",
"pass": 9,
"fail": 0,
"blocked": 0
}
-13
View File
@@ -1,13 +0,0 @@
{
"checked_utc": "2026-10-12T19:17:45Z",
"plugin": "7.0.4.0",
"guide_refresh": {
"started": "2026-10-12T19:11:37.3638053Z",
"finished": "2026-10-12T19:17:39.3939294Z",
"status": "Completed"
},
"feed_success": "2026-10-12T19:11:43.3794835Z",
"matched_programme_images": 5302,
"source": "https://epg.polishteam.one/guide.xml",
"status": "PASS"
}
-116
View File
@@ -1,116 +0,0 @@
{
"checked_utc": "2026-10-12T19:33:31.686Z",
"mode": "playback",
"checks": [
{
"name": "two isolated users can sign in",
"status": "PASS",
"evidence": {
"users": 2,
"plugin": "7.0.6.0"
}
},
{
"name": "4Fun.TV custom one-click decoded video",
"status": "PASS",
"evidence": {
"time_progress": 3.02,
"decoded_frames": 108,
"width": 1047
}
},
{
"name": "4Fun.TV stop or recover to Channels",
"status": "PASS"
},
{
"name": "TVP 1 HD native one-click decoded video",
"status": "FAIL",
"error": "page.waitForFunction: Timeout 20000ms exceeded."
},
{
"name": "TVP 1 HD stop or recover to Channels",
"status": "PASS"
},
{
"name": "TVP 2 HD native one-click decoded video",
"status": "FAIL",
"error": "page.waitForFunction: Timeout 20000ms exceeded."
},
{
"name": "TVP 2 HD stop or recover to Channels",
"status": "PASS"
},
{
"name": "TVN HD native one-click decoded video",
"status": "FAIL",
"error": "page.waitForFunction: Timeout 20000ms exceeded."
},
{
"name": "TVN HD stop or recover to Channels",
"status": "PASS"
},
{
"name": "native channel card opens actual video and native OSD",
"status": "PASS",
"evidence": {
"time_progress": 3.02,
"decoded_frames": 83,
"width": 1047
}
},
{
"name": "custom channel button starts decoded video",
"status": "FAIL",
"error": "page.waitForFunction: Timeout 20000ms exceeded."
},
{
"name": "custom playback exit",
"status": "PASS"
},
{
"name": "double-click and two browsers share one physical upstream",
"status": "PASS",
"evidence": {
"consumers": 2,
"physical_connections": 1
}
},
{
"name": "stopping one viewer preserves other viewer",
"status": "PASS",
"evidence": {
"time_progress": 3.01,
"decoded_frames": 150,
"width": 1047
}
},
{
"name": "last viewer releases source",
"status": "PASS",
"evidence": {
"active": 0,
"consumers": 0
}
},
{
"name": "no uncaught browser errors",
"status": "FAIL",
"error": "Uncaught browser errors: undefined; undefined; undefined; undefined"
}
],
"plugin": {
"Name": "Enigma2",
"Version": "7.0.6.0",
"ConfigurationFileName": "Jellyfin.Plugin.Enigma2.xml",
"Description": "Provides live TV using Enigma2 PVR as a back-end.",
"Id": "193f29f9ea6c4595a6f655e79d7c590a",
"CanUninstall": true,
"HasImage": false,
"Status": "Active"
},
"finished_utc": "2026-10-12T19:36:48.612Z",
"pass": 11,
"fail": 5,
"blocked": 0
}
-109
View File
@@ -1,109 +0,0 @@
{
"checked_utc": "2026-10-12T19:58:18.326Z",
"mode": "ui",
"checks": [
{
"name": "two isolated users can sign in",
"status": "PASS",
"evidence": {
"users": 2,
"plugin": "7.0.6.0"
}
},
{
"name": "anonymous page requires login",
"status": "PASS"
},
{
"name": "complete channel list and logos",
"status": "PASS",
"evidence": {
"channels": 216,
"loaded_logos": 35
}
},
{
"name": "search filters and restores channels",
"status": "PASS"
},
{
"name": "favorites persist and synchronize natively",
"status": "PASS"
},
{
"name": "hide applies to native channels and is isolated per user",
"status": "PASS",
"evidence": {
"mine": 215,
"other": 216
}
},
{
"name": "restore hidden channel",
"status": "PASS"
},
{
"name": "custom order persists in native API",
"status": "PASS"
},
{
"name": "EPG renders actual programmes",
"status": "PASS",
"evidence": {
"programmes": 500
}
},
{
"name": "EPG image links exist and load",
"status": "PASS",
"evidence": {
"images": 500
}
},
{
"name": "default preference persists after reload",
"status": "PASS"
},
{
"name": "native default Channels and explicit Guide",
"status": "PASS"
},
{
"name": "ordinary user cannot read admin diagnostics",
"status": "PASS"
},
{
"name": "external relay access denied",
"status": "PASS"
},
{
"name": "Vu recording provider disabled; timers remain readable",
"status": "PASS",
"evidence": {
"timers": 36
}
},
{
"name": "administrator plugin configuration loads current receiver settings",
"status": "PASS"
},
{
"name": "no uncaught browser errors",
"status": "PASS"
}
],
"plugin": {
"Name": "Enigma2",
"Version": "7.0.6.0",
"ConfigurationFileName": "Jellyfin.Plugin.Enigma2.xml",
"Description": "Provides live TV using Enigma2 PVR as a back-end.",
"Id": "193f29f9ea6c4595a6f655e79d7c590a",
"CanUninstall": true,
"HasImage": false,
"Status": "Active"
},
"finished_utc": "2026-10-12T19:58:57.786Z",
"pass": 17,
"fail": 0,
"blocked": 0
}
-109
View File
@@ -1,109 +0,0 @@
{
"checked_utc": "2026-10-12T22:09:18.893Z",
"mode": "ui",
"checks": [
{
"name": "two isolated users can sign in",
"status": "PASS",
"evidence": {
"users": 2,
"plugin": "7.0.7.0"
}
},
{
"name": "anonymous page requires login",
"status": "PASS"
},
{
"name": "complete channel list and logos",
"status": "PASS",
"evidence": {
"channels": 216,
"loaded_logos": 36
}
},
{
"name": "search filters and restores channels",
"status": "PASS"
},
{
"name": "favorites persist and synchronize natively",
"status": "PASS"
},
{
"name": "hide applies to native channels and is isolated per user",
"status": "PASS",
"evidence": {
"mine": 215,
"other": 216
}
},
{
"name": "restore hidden channel",
"status": "PASS"
},
{
"name": "custom order persists in native API",
"status": "PASS"
},
{
"name": "EPG renders actual programmes",
"status": "PASS",
"evidence": {
"programmes": 500
}
},
{
"name": "EPG image links exist and load",
"status": "PASS",
"evidence": {
"images": 500
}
},
{
"name": "default preference persists after reload",
"status": "PASS"
},
{
"name": "native default Channels and explicit Guide",
"status": "PASS"
},
{
"name": "ordinary user cannot read admin diagnostics",
"status": "PASS"
},
{
"name": "external relay access denied",
"status": "PASS"
},
{
"name": "Vu recording provider disabled; timers remain readable",
"status": "PASS",
"evidence": {
"timers": 38
}
},
{
"name": "administrator plugin configuration loads current receiver settings",
"status": "PASS"
},
{
"name": "no uncaught browser errors",
"status": "PASS"
}
],
"plugin": {
"Name": "Enigma2",
"Version": "7.0.7.0",
"ConfigurationFileName": "Jellyfin.Plugin.Enigma2.xml",
"Description": "Provides live TV using Enigma2 PVR as a back-end.",
"Id": "193f29f9ea6c4595a6f655e79d7c590a",
"CanUninstall": true,
"HasImage": false,
"Status": "Active"
},
"finished_utc": "2026-10-12T22:09:59.477Z",
"pass": 17,
"fail": 0,
"blocked": 0
}
-72
View File
@@ -1,72 +0,0 @@
"""Run the isolated synthetic C# harness and measure its own source FFmpeg only.
No Jellyfin configuration changes, restarts, API keys or receiver connections.
"""
import argparse
import json
import os
from pathlib import Path
import signal
import subprocess
import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--harness', required=True)
parser.add_argument('--ffmpeg', default='/usr/lib/jellyfin-ffmpeg/ffmpeg')
parser.add_argument('--output', required=True)
parser.add_argument('--long', action='store_true')
parser.add_argument('--native', action='store_true')
args = parser.parse_args()
root = Path(args.output).resolve()
if root.exists():
raise ValueError('Choose a new output directory')
samples, previous = [], {}
with Path(str(root) + '.log').open('x') as log:
process = subprocess.Popen([args.harness, '--native-continuity-e2e' if args.native else '--long-continuity-e2e' if args.long else '--continuity-e2e', args.ffmpeg, str(root)], stdout=log,
stderr=subprocess.STDOUT, start_new_session=True)
deadline = time.monotonic() + 240
try:
while process.poll() is None:
if time.monotonic() > deadline:
raise TimeoutError('Synthetic harness deadline exceeded')
now = time.monotonic()
for proc in Path('/proc').glob('[0-9]*'):
try:
if not str((proc / 'cwd').resolve()).startswith(str(root / 'cache' / 'source-')):
continue
value = (proc / 'stat').read_text()
fields = value[value.rfind(')') + 2:].split()
cpu = (int(fields[11]) + int(fields[12])) / os.sysconf('SC_CLK_TCK')
rss = int(fields[21]) * os.sysconf('SC_PAGE_SIZE') / 1048576
if proc.name in previous:
before, at = previous[proc.name]
samples.append({'one_core_percent': round((cpu-before)/(now-at)*100, 1),
'rss_mib': round(rss, 1)})
previous[proc.name] = (cpu, now)
except OSError:
pass
time.sleep(1)
finally:
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
report = {'harness_exit_code': process.returncode, 'samples': samples,
'scope': 'Only synthetic plugin source encoding; excludes fixture and player decoders'}
(root / 'cpu-sample.json').write_text(json.dumps(report, indent=2))
if process.returncode == 0 and (args.long or args.native):
subprocess.run(['python3', str(Path(__file__).with_name('verify-continuity-recording.py')), '--output', str(root), '--ffmpeg', args.ffmpeg], check=True)
print(Path(str(root) + '.log').read_text()[-3500:])
print(json.dumps({'exit_code': process.returncode, 'cpu_samples': len(samples),
'maximum_one_core_percent': max((s['one_core_percent'] for s in samples), default=None),
'maximum_rss_mib': max((s['rss_mib'] for s in samples), default=None)}))
raise SystemExit(0 if process.returncode == 0 else 1)
if __name__ == '__main__':
main()
@@ -1,58 +0,0 @@
"""Verify actual rendered stages in the synthetic long-trial recordings.
Copies of H.264 packets alone are insufficient evidence that clients saw a
retry slate or a recovered channel. Compare decoded text pixels, not file names.
"""
import argparse
import json
from pathlib import Path
import subprocess
def pixels(ffmpeg, path, second=0):
command = [ffmpeg, '-hide_banner', '-loglevel', 'error', '-nostdin', '-threads', '1', '-filter_threads', '1',
'-ss', str(second), '-i', str(path), '-frames:v', '1', '-vf',
'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,crop=1920:260:0:430,scale=640:86:flags=area', '-pix_fmt', 'gray', '-f', 'rawvideo', 'pipe:1']
output = subprocess.run(command, capture_output=True, check=True, timeout=20).stdout
if len(output) != 640 * 86:
raise RuntimeError('Incomplete video frame')
return output
def difference(first, second):
return sum(abs(a-b) for a, b in zip(first, second)) / len(first)
def verify(root, ffmpeg):
result = json.loads((root / 'result.json').read_text())
if result['FailureSeconds'] != 75:
raise ValueError('Use the long trial with the documented stage times')
errors = {}
for language in ('pl', 'en'):
waiting = pixels(ffmpeg, root / (language + '-waiting-slate.ts'))
retrying = pixels(ffmpeg, root / (language + '-retrying-slate.ts'))
observed_waiting = pixels(ffmpeg, root / (language + '.mkv'), 1)
observed_retrying = pixels(ffmpeg, root / (language + '.mkv'), 70)
live = max((pixels(ffmpeg, root / (language + '.mkv'), at) for at in (18,22,26,30,34,38,42,46)), key=lambda frame: difference(retrying,frame))
recovered = pixels(ffmpeg, root / (language + '.mkv'), 140)
metrics = {'waiting_error': difference(waiting, observed_waiting),
'retrying_error': difference(retrying, observed_retrying),
'live_difference': difference(retrying, live),
'recovered_difference': difference(retrying, recovered)}
assert metrics['waiting_error'] < 0.5 and metrics['retrying_error'] < 0.5, metrics
assert metrics['live_difference'] > 15 and metrics['recovered_difference'] > 15, metrics
other = 'en' if language == 'pl' else 'pl'
assert difference(observed_retrying, pixels(ffmpeg, root / (other + '-retrying-slate.ts'))) > 2
errors[language] = {key: round(value, 4) for key, value in metrics.items()}
result.update(RenderedWaitingVerified=True, RenderedRetryingVerified=True, RenderedLiveRecoveryVerified=True,
RenderedPerViewerLanguageVerified=True, VisualErrorMetrics=errors)
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps({'rendered_stages_verified': True, 'metrics': errors}))
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--ffmpeg', default='/usr/lib/jellyfin-ffmpeg/ffmpeg')
args = parser.parse_args()
verify(args.output, args.ffmpeg)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 738 KiB

-22
View File
@@ -1,22 +0,0 @@
"""Hide the duplicate home tile without removing the provider or recording files."""
import datetime,json,os,pathlib,sqlite3,urllib.request
db=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True)
key=db.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];db.close()
def api(path,data=None):
request=urllib.request.Request('http://127.0.0.1:8096'+path,headers={'Authorization':'MediaBrowser Token="'+key+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"','Content-Type':'application/json'},data=None if data is None else json.dumps(data).encode())
body=urllib.request.urlopen(request,timeout=30).read();return json.loads(body) if body else None
channel=next(c for c in api('/Channels')['Items'] if c['Name']=='Enigma2 Recordings')
users=api('/Users');backup={u['Id']:u['Configuration'] for u in users}
folder=pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups');folder.mkdir(mode=0o700,exist_ok=True)
file=folder/('home-exclusions-'+datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')+'.json')
fd=os.open(file,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
with os.fdopen(fd,'w') as output:json.dump(backup,output)
for user in users:
config=dict(user['Configuration'])
for field in ['MyMediaExcludes','LatestItemsExcludes']:
ids=config.get(field,[])
if channel['Id'].replace('-','').lower() not in [x.replace('-','').lower() for x in ids]:config[field]=ids+[channel['Id']]
api('/Users/'+user['Id']+'/Configuration',config)
check=api('/Users/'+user['Id'])['Configuration']
assert channel['Id'] in check['MyMediaExcludes'] and channel['Id'] in check['LatestItemsExcludes']
print(json.dumps({'home_tile_hidden_for_users':len(users),'provider_retained':True,'backup':str(file)}))
-27
View File
@@ -1,27 +0,0 @@
"""Run in CT100191 after staging decoder-channel-playback.js in /tmp. No restart needed."""
import datetime
import json
import pathlib
import shutil
web = pathlib.Path('/usr/share/jellyfin/web')
backup = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups') / ('web-playback-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
backup.mkdir(mode=0o700)
for name in ['index.html', 'config.json']:
shutil.copy2(web / name, backup / name)
script = web / 'decoder-channel-playback.js'
if script.exists():
shutil.copy2(script, backup / script.name)
shutil.copy2('/tmp/decoder-channel-playback.js', script)
html = (web / 'index.html').read_text()
tag = '<script defer="defer" src="decoder-channel-playback.js"></script>'
if tag not in html:
html = html.replace('<head>', '<head>' + tag, 1)
assert tag in html
(web / 'index.html').write_text(html)
config = json.loads((web / 'config.json').read_text())
plugins = config.setdefault('plugins', [])
if 'DecoderChannelPlayback' not in plugins:
plugins.append('DecoderChannelPlayback')
(web / 'config.json').write_text(json.dumps(config, indent=2) + '\n')
print(json.dumps({'web_playback_installed': True, 'backup': str(backup)}))
-180
View File
@@ -1,180 +0,0 @@
{
"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."
}
}
-110
View File
@@ -1,110 +0,0 @@
{
"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
}
]
}
]
}
-268
View File
@@ -1,268 +0,0 @@
{
"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."
}
}
-20
View File
@@ -1,20 +0,0 @@
{
"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."
}
File diff suppressed because it is too large Load Diff
-103
View File
@@ -1,103 +0,0 @@
{"utc":"2026-10-12T21:58:50.492Z","type":"isolated_bridge_served","viewer":1}
{"utc":"2026-10-12T21:58:57.013Z","type":"isolated_bridge_served","viewer":2}
{"utc":"2026-10-12T21:59:02.196Z","type":"server","label":"before","elapsed":null,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":0,"Consumers":0,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-b0b81cc6","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}},{"user":"decoder-reception-3281562c","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":0,"rows":[{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T21:59:05.028Z","type":"isolated_policy_request","viewer":1,"matched":true}
{"utc":"2026-10-12T21:59:14.667Z","type":"check","name":"viewer 1 one-click decoded start","status":"PASS","evidence":{"seconds":12.47}}
{"utc":"2026-10-12T21:59:16.527Z","type":"server","label":"primary-started","elapsed":1.86,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":75,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-b0b81cc6","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:59:16.569Z","type":"video","viewer":1,"elapsed":1.9,"video":{"time":1.876559,"frames":40,"dropped":2,"audio_bytes":69657,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.3123999999999993},"events":[{"type":"waiting","at":1791842350487,"time":0,"error":null},{"type":"playing","at":1791842354585,"time":0,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:59:21.583Z","type":"video","viewer":1,"elapsed":6.92,"video":{"time":6.925073,"frames":292,"dropped":2,"audio_bytes":233798,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.2638860000000003},"events":[],"route":"#/video"}
{"utc":"2026-10-12T21:59:26.591Z","type":"video","viewer":1,"elapsed":11.92,"video":{"time":11.933131,"frames":541,"dropped":2,"audio_bytes":395180,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.255828000000001},"events":[],"route":"#/video"}
{"utc":"2026-10-12T21:59:31.603Z","type":"video","viewer":1,"elapsed":16.94,"video":{"time":16.944833,"frames":792,"dropped":5,"audio_bytes":557941,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.244126000000001},"events":[],"route":"#/video"}
{"utc":"2026-10-12T21:59:36.620Z","type":"video","viewer":1,"elapsed":21.95,"video":{"time":21.96176,"frames":1043,"dropped":5,"audio_bytes":720013,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.227198999999999},"events":[],"route":"#/video"}
{"utc":"2026-10-12T21:59:41.633Z","type":"video","viewer":1,"elapsed":26.97,"video":{"time":26.97434,"frames":1294,"dropped":5,"audio_bytes":881395,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.214618999999999},"events":[],"route":"#/video"}
{"utc":"2026-10-12T21:59:46.635Z","type":"video","viewer":1,"elapsed":31.97,"video":{"time":31.977022,"frames":1544,"dropped":5,"audio_bytes":1043466,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.2119369999999954},"events":[],"route":"#/video"}
{"utc":"2026-10-12T21:59:48.389Z","type":"server","label":"periodic","elapsed":33.72,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":53,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-b0b81cc6","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:59:53.392Z","type":"video","viewer":1,"elapsed":38.73,"video":{"time":38.733765,"frames":1882,"dropped":5,"audio_bytes":1262091,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":1.4551939999999988},"events":[],"route":"#/video"}
{"utc":"2026-10-12T21:59:58.403Z","type":"video","viewer":1,"elapsed":43.74,"video":{"time":43.745543,"frames":2132,"dropped":5,"audio_bytes":1424162,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.443415999999999},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:00:03.421Z","type":"video","viewer":1,"elapsed":48.75,"video":{"time":48.762651,"frames":2384,"dropped":5,"audio_bytes":1586234,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.426307999999999},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:00:08.427Z","type":"video","viewer":1,"elapsed":53.76,"video":{"time":53.766429,"frames":2634,"dropped":5,"audio_bytes":1748306,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":1.4225299999999947},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:00:13.440Z","type":"video","viewer":1,"elapsed":58.77,"video":{"time":58.782276,"frames":2885,"dropped":5,"audio_bytes":1910377,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.406682999999994},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:00:18.445Z","type":"video","viewer":1,"elapsed":63.78,"video":{"time":63.786593,"frames":3135,"dropped":5,"audio_bytes":2071417,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.4023659999999936},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:00:20.081Z","type":"server","label":"periodic","elapsed":65.41,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":52,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-b0b81cc6","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T22:00:25.100Z","type":"video","viewer":1,"elapsed":70.43,"video":{"time":70.440709,"frames":3467,"dropped":5,"audio_bytes":2286290,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.7482499999999987},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:00:30.113Z","type":"video","viewer":1,"elapsed":75.45,"video":{"time":75.455398,"frames":3718,"dropped":5,"audio_bytes":2449248,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.7335609999999946},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:00:35.116Z","type":"video","viewer":1,"elapsed":80.45,"video":{"time":80.458436,"frames":3968,"dropped":5,"audio_bytes":2610389,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":1.730522999999991},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:00:40.126Z","type":"video","viewer":1,"elapsed":85.46,"video":{"time":85.469341,"frames":4219,"dropped":5,"audio_bytes":2773150,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.719617999999997},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:00:45.142Z","type":"video","viewer":1,"elapsed":90.48,"video":{"time":90.483532,"frames":4470,"dropped":5,"audio_bytes":2934532,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.7054270000000002},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:00:50.146Z","type":"video","viewer":1,"elapsed":95.48,"video":{"time":95.487846,"frames":4720,"dropped":5,"audio_bytes":3095914,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":1.7011129999999923},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:00:51.813Z","type":"server","label":"periodic","elapsed":97.15,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":52,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-b0b81cc6","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T22:00:59.535Z","type":"isolated_policy_request","viewer":2,"matched":true}
{"utc":"2026-10-12T22:01:07.552Z","type":"check","name":"viewer 2 one-click decoded start","status":"PASS","evidence":{"seconds":10.72}}
{"utc":"2026-10-12T22:01:09.768Z","type":"server","label":"secondary-joined","elapsed":115.1,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-b0b81cc6","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":104,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T22:01:09.772Z","type":"video","viewer":1,"elapsed":115.11,"video":{"time":115.113613,"frames":5700,"dropped":11,"audio_bytes":3724201,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":0.07534599999999614},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:09.776Z","type":"video","viewer":2,"elapsed":115.11,"video":{"time":2.301304,"frames":40,"dropped":0,"audio_bytes":83450,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.303655},"events":[{"type":"waiting","at":1791842464182,"time":0,"error":null},{"type":"playing","at":1791842467406,"time":0,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T22:01:14.780Z","type":"video","viewer":1,"elapsed":120.11,"video":{"time":118.961222,"frames":5894,"dropped":12,"audio_bytes":3862134,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.2277369999999905},"events":[{"type":"waiting","at":1791842469797,"time":115.140231,"error":null},{"type":"playing","at":1791842470941,"time":115.2,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T22:01:14.783Z","type":"video","viewer":2,"elapsed":120.12,"video":{"time":7.308216,"frames":290,"dropped":0,"audio_bytes":246212,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.296742999999999},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:19.786Z","type":"video","viewer":1,"elapsed":125.12,"video":{"time":123.969167,"frames":6144,"dropped":12,"audio_bytes":4024210,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.219791999999998},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:19.789Z","type":"video","viewer":2,"elapsed":125.12,"video":{"time":12.315273,"frames":541,"dropped":0,"audio_bytes":407594,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.289686000000001},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:24.793Z","type":"video","viewer":1,"elapsed":130.13,"video":{"time":128.975089,"frames":6394,"dropped":14,"audio_bytes":4185588,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.213870000000014},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:24.796Z","type":"video","viewer":2,"elapsed":130.13,"video":{"time":17.321367,"frames":790,"dropped":1,"audio_bytes":568976,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.283592000000002},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:26.455Z","type":"server","label":"periodic","elapsed":131.79,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-b0b81cc6","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":62,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T22:01:31.465Z","type":"video","viewer":1,"elapsed":136.8,"video":{"time":135.647502,"frames":6727,"dropped":14,"audio_bytes":4401453,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.541457000000008},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:31.469Z","type":"video","viewer":2,"elapsed":136.8,"video":{"time":23.994703,"frames":1125,"dropped":1,"audio_bytes":785531,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.610256},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:36.474Z","type":"video","viewer":1,"elapsed":141.81,"video":{"time":140.656175,"frames":6978,"dropped":15,"audio_bytes":4562964,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.532784000000021},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:36.477Z","type":"video","viewer":2,"elapsed":141.81,"video":{"time":29.002894,"frames":1375,"dropped":1,"audio_bytes":947603,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.6020649999999996},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:41.488Z","type":"video","viewer":1,"elapsed":146.82,"video":{"time":145.667843,"frames":7227,"dropped":18,"audio_bytes":4725597,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.5211160000000064},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:41.492Z","type":"video","viewer":2,"elapsed":146.82,"video":{"time":34.016629,"frames":1624,"dropped":1,"audio_bytes":1108295,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.588329999999999},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:46.505Z","type":"video","viewer":1,"elapsed":151.84,"video":{"time":150.686526,"frames":7479,"dropped":18,"audio_bytes":4887668,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.5024330000000248},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:46.507Z","type":"video","viewer":2,"elapsed":151.84,"video":{"time":39.032623,"frames":1876,"dropped":1,"audio_bytes":1271747,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.572336},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:51.510Z","type":"video","viewer":1,"elapsed":156.84,"video":{"time":155.691999,"frames":7728,"dropped":18,"audio_bytes":5048361,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.496960000000001},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:51.513Z","type":"video","viewer":2,"elapsed":156.85,"video":{"time":44.039004,"frames":2126,"dropped":1,"audio_bytes":1433128,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.5659550000000024},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:56.530Z","type":"video","viewer":1,"elapsed":161.86,"video":{"time":160.712512,"frames":7980,"dropped":18,"audio_bytes":5211122,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.476447000000007},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:56.533Z","type":"video","viewer":2,"elapsed":161.87,"video":{"time":49.058493,"frames":2376,"dropped":1,"audio_bytes":1595200,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.5464660000000023},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:01:58.145Z","type":"server","label":"periodic","elapsed":163.48,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-b0b81cc6","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":54,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T22:02:03.157Z","type":"video","viewer":1,"elapsed":168.49,"video":{"time":167.338757,"frames":8311,"dropped":19,"audio_bytes":5425608,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.850202000000024},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:03.161Z","type":"video","viewer":2,"elapsed":168.49,"video":{"time":55.685453,"frames":2708,"dropped":1,"audio_bytes":1810376,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.919505999999998},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:08.164Z","type":"video","viewer":1,"elapsed":173.5,"video":{"time":172.346217,"frames":8559,"dropped":21,"audio_bytes":5585611,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.8427420000000154},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:08.167Z","type":"video","viewer":2,"elapsed":173.5,"video":{"time":60.693591,"frames":2959,"dropped":5,"audio_bytes":1971758,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.911367999999996},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:13.175Z","type":"video","viewer":1,"elapsed":178.51,"video":{"time":177.357419,"frames":8812,"dropped":21,"audio_bytes":5749885,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.831540000000018},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:13.178Z","type":"video","viewer":2,"elapsed":178.51,"video":{"time":65.703929,"frames":3209,"dropped":5,"audio_bytes":2133829,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.901029999999992},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:18.184Z","type":"video","viewer":1,"elapsed":183.52,"video":{"time":182.367203,"frames":9063,"dropped":21,"audio_bytes":5911823,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.821756000000022},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:18.188Z","type":"video","viewer":2,"elapsed":183.52,"video":{"time":70.713987,"frames":3460,"dropped":5,"audio_bytes":2295901,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.8909719999999908},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:23.190Z","type":"video","viewer":1,"elapsed":188.52,"video":{"time":187.37359,"frames":9313,"dropped":21,"audio_bytes":6073247,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.815369000000004},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:23.193Z","type":"video","viewer":2,"elapsed":188.53,"video":{"time":75.718181,"frames":3710,"dropped":5,"audio_bytes":2457283,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.8867779999999925},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:28.210Z","type":"video","viewer":1,"elapsed":193.54,"video":{"time":192.392823,"frames":9564,"dropped":21,"audio_bytes":6235982,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.7961360000000184},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:28.213Z","type":"video","viewer":2,"elapsed":193.55,"video":{"time":80.738507,"frames":3960,"dropped":5,"audio_bytes":2619355,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.866451999999995},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:30.007Z","type":"server","label":"periodic","elapsed":195.34,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-b0b81cc6","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":52,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T22:02:36.879Z","type":"check","name":"viewer 2 exit to Channels","status":"PASS","evidence":{"elapsed":202.21}}
{"utc":"2026-10-12T22:02:38.259Z","type":"server","label":"secondary-left","elapsed":203.59,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-b0b81cc6","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T22:02:38.262Z","type":"video","viewer":1,"elapsed":203.6,"video":{"time":202.445138,"frames":10067,"dropped":22,"audio_bytes":6560800,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.7438210000000254},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:43.273Z","type":"video","viewer":1,"elapsed":208.61,"video":{"time":207.455876,"frames":10319,"dropped":29,"audio_bytes":6722871,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.733083000000022},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:48.278Z","type":"video","viewer":1,"elapsed":213.61,"video":{"time":212.458439,"frames":10563,"dropped":31,"audio_bytes":6880805,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.730520000000013},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:53.284Z","type":"video","viewer":1,"elapsed":218.62,"video":{"time":217.465721,"frames":10804,"dropped":36,"audio_bytes":7036670,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.723238000000009},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:02:58.299Z","type":"video","viewer":1,"elapsed":223.63,"video":{"time":222.481591,"frames":11069,"dropped":77,"audio_bytes":7208546,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.7073680000000024},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:03:03.313Z","type":"video","viewer":1,"elapsed":228.65,"video":{"time":227.497078,"frames":11320,"dropped":77,"audio_bytes":7370468,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.6918810000000235},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:03:05.090Z","type":"server","label":"periodic","elapsed":230.42,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-b0b81cc6","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T22:03:10.130Z","type":"video","viewer":1,"elapsed":235.46,"video":{"time":234.297342,"frames":11660,"dropped":77,"audio_bytes":7589782,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.891617000000025},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:03:15.136Z","type":"video","viewer":1,"elapsed":240.47,"video":{"time":239.320467,"frames":11910,"dropped":78,"audio_bytes":7753233,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":1.8684920000000034},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:03:20.144Z","type":"video","viewer":1,"elapsed":245.48,"video":{"time":244.326955,"frames":12161,"dropped":96,"audio_bytes":7914615,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.862004000000013},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:03:25.151Z","type":"video","viewer":1,"elapsed":250.49,"video":{"time":249.334507,"frames":12412,"dropped":96,"audio_bytes":8076687,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.854452000000009},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:03:30.158Z","type":"video","viewer":1,"elapsed":255.49,"video":{"time":254.340386,"frames":12662,"dropped":96,"audio_bytes":8238759,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.848573000000016},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:03:35.163Z","type":"video","viewer":1,"elapsed":260.5,"video":{"time":259.347239,"frames":12913,"dropped":96,"audio_bytes":8400830,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.8417200000000093},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:03:36.844Z","type":"server","label":"periodic","elapsed":262.18,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-b0b81cc6","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T22:03:41.860Z","type":"video","viewer":1,"elapsed":267.19,"video":{"time":266.036838,"frames":13247,"dropped":104,"audio_bytes":8616696,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.152121000000022},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:03:46.866Z","type":"video","viewer":1,"elapsed":272.2,"video":{"time":271.049455,"frames":13498,"dropped":106,"audio_bytes":8778768,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.139503999999988},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:03:51.881Z","type":"video","viewer":1,"elapsed":277.22,"video":{"time":276.064773,"frames":13749,"dropped":106,"audio_bytes":8940839,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.124186000000009},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:03:56.888Z","type":"video","viewer":1,"elapsed":282.22,"video":{"time":281.07178,"frames":13997,"dropped":113,"audio_bytes":9100842,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.1171790000000215},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:04:01.892Z","type":"video","viewer":1,"elapsed":287.23,"video":{"time":286.076081,"frames":14249,"dropped":113,"audio_bytes":9263603,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.1128780000000233},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:04:07.001Z","type":"video","viewer":1,"elapsed":292.34,"video":{"time":291.092414,"frames":14484,"dropped":113,"audio_bytes":9416020,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.096544999999992},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:04:09.111Z","type":"server","label":"periodic","elapsed":294.45,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-b0b81cc6","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T22:04:14.120Z","type":"video","viewer":1,"elapsed":299.45,"video":{"time":296.96406,"frames":14785,"dropped":210,"audio_bytes":9625678,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.2248989999999935},"events":[{"type":"waiting","at":1791842647019,"time":291.202802,"error":null},{"type":"playing","at":1791842647575,"time":291.264,"error":null},{"type":"waiting","at":1791842651588,"time":295.213946,"error":null},{"type":"playing","at":1791842652361,"time":295.284,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T22:04:20.969Z","type":"check","name":"viewer 1 exit to Channels","status":"PASS","evidence":{"elapsed":306.3}}
{"utc":"2026-10-12T22:04:20.970Z","type":"check","name":"scheduled duration completed","status":"PASS","evidence":{"seconds":304.47,"interrupted":false}}
{"utc":"2026-10-12T22:04:20.970Z","type":"check","name":"second viewer phase completed","status":"PASS","evidence":{"joinAt":100,"leaveAt":200}}
{"utc":"2026-10-12T22:04:42.796Z","type":"server","label":"after-cleanup","elapsed":328.13,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":19,"ActiveSources":0,"Consumers":0,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-3281562c","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}},{"user":"decoder-reception-b0b81cc6","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":0,"rows":[],"channel_matches":0}}
{"utc":"2026-10-12T22:04:42.797Z","type":"check","name":"own relay consumers released after last viewer","status":"PASS","evidence":{"relay_id":"dfc8c635289f419987efa5a6e24526e4","active":false,"consumers":0,"baseline_active":false,"baseline_consumers":0}}
{"utc":"2026-10-12T22:04:46.087Z","type":"check","name":"viewer 1 continuous decoded progress","status":"PASS","evidence":{"samples":54,"progressing_intervals":53,"stalled_intervals":0,"audio_progress_intervals":53}}
{"utc":"2026-10-12T22:04:46.088Z","type":"check","name":"viewer 1 browser and HLS responses","status":"PASS","evidence":{"browser_errors":[],"hls":{"manifests":107,"segments":0,"failed":[],"request_failed":[]}}}
{"utc":"2026-10-12T22:04:46.088Z","type":"check","name":"viewer 1 audio decoding progresses","status":"PASS","evidence":{"progressing_intervals":53}}
{"utc":"2026-10-12T22:04:46.089Z","type":"check","name":"viewer 2 continuous decoded progress","status":"PASS","evidence":{"samples":16,"progressing_intervals":15,"stalled_intervals":0,"audio_progress_intervals":15}}
{"utc":"2026-10-12T22:04:46.090Z","type":"check","name":"viewer 2 browser and HLS responses","status":"PASS","evidence":{"browser_errors":[],"hls":{"manifests":33,"segments":0,"failed":[],"request_failed":[]}}}
{"utc":"2026-10-12T22:04:46.090Z","type":"check","name":"viewer 2 audio decoding progresses","status":"PASS","evidence":{"progressing_intervals":15}}
{"utc":"2026-10-12T22:04:46.091Z","type":"check","name":"sharing uses one physical receiver stream","status":"PASS","evidence":{"before_opened":19,"during":[{"elapsed":115.1,"opened":19,"physical_rows":1,"consumers":2},{"elapsed":131.79,"opened":19,"physical_rows":1,"consumers":2},{"elapsed":163.48,"opened":19,"physical_rows":1,"consumers":2},{"elapsed":195.34,"opened":19,"physical_rows":1,"consumers":2}]}}
{"utc":"2026-10-12T22:04:46.091Z","type":"check","name":"global receiver upstream counter stable during sharing","status":"PASS","evidence":{"before":19,"during":[19,19,19,19],"scope":"Global plugin counter; correlate a failure with other users and channel snapshots."}}
-53
View File
@@ -1,53 +0,0 @@
{
"input": "enigma-diagnostics/jellyfin-decoder-plugin/reception-h264-20261012.json",
"plugin": "7.0.6.0",
"isolated_bridge_override": true,
"channel": "TVP 1 HD",
"first_video_utc": "2026-10-12T21:59:14.667Z",
"finished_utc": "2026-10-12T22:04:46.092Z",
"transport_checks": {
"pass": 15,
"fail": 0,
"unverified": 0
},
"viewers": [
{
"viewer": 1,
"sampled_seconds": 297.551,
"media_progress_seconds": 295.088,
"realtime_ratio": 0.992,
"total_frame_delta": 14745,
"dropped_frame_delta": 208,
"dropped_fraction": 0.014,
"approximate_presented_fps": 48.855,
"waiting_events": 3,
"waiting_seconds": 2.473,
"waiting_median_seconds": 0.773,
"waiting_max_seconds": 1.144,
"waiting_unmatched": 0,
"minimum_buffer_seconds": 0.075,
"audio_decoded_byte_delta": 9556021,
"status": "SMOOTH_TEST_CLIENT",
"scope": "Headless test client only. Frame presentation is approximated; audible sound and other devices are not verified."
},
{
"viewer": 2,
"sampled_seconds": 78.437,
"media_progress_seconds": 78.437,
"realtime_ratio": 1,
"total_frame_delta": 3920,
"dropped_frame_delta": 5,
"dropped_fraction": 0.001,
"approximate_presented_fps": 49.913,
"waiting_events": 0,
"waiting_seconds": 0,
"waiting_median_seconds": null,
"waiting_max_seconds": null,
"waiting_unmatched": 0,
"minimum_buffer_seconds": 2.566,
"audio_decoded_byte_delta": 2535905,
"status": "SMOOTH_TEST_CLIENT",
"scope": "Headless test client only. Frame presentation is approximated; audible sound and other devices are not verified."
}
]
}
-960
View File
@@ -1,960 +0,0 @@
{
"started_utc": "2026-10-12T22:13:31.094Z",
"channel": "TVP 1 HD",
"relay_id": "bd9828873b2f4a1cbbb9853beb9630e5",
"isolated_bridge_override": false,
"schedule_seconds": {
"duration": 90,
"join": 30,
"leave": 60
},
"checks": [
{
"name": "viewer 1 one-click decoded start",
"status": "PASS",
"evidence": {
"seconds": 15.04
}
},
{
"name": "viewer 2 one-click decoded start",
"status": "PASS",
"evidence": {
"seconds": 14
}
},
{
"name": "viewer 2 exit to Channels",
"status": "PASS",
"evidence": {
"elapsed": 64.5
}
},
{
"name": "viewer 1 exit to Channels",
"status": "PASS",
"evidence": {
"elapsed": 95.31
}
},
{
"name": "scheduled duration completed",
"status": "PASS",
"evidence": {
"seconds": 92.9,
"interrupted": false
}
},
{
"name": "second viewer phase completed",
"status": "PASS",
"evidence": {
"joinAt": 30,
"leaveAt": 60
}
},
{
"name": "own relay consumers released after last viewer",
"status": "PASS",
"evidence": {
"relay_id": "bd9828873b2f4a1cbbb9853beb9630e5",
"active": false,
"consumers": 0,
"baseline_active": false,
"baseline_consumers": 0
}
},
{
"name": "viewer 1 continuous decoded progress",
"status": "PASS",
"evidence": {
"samples": 13,
"progressing_intervals": 12,
"stalled_intervals": 0,
"audio_progress_intervals": 12
}
},
{
"name": "viewer 1 browser and HLS responses",
"status": "PASS",
"evidence": {
"browser_errors": [],
"hls": {
"manifests": 36,
"segments": 0,
"failed": [],
"request_failed": []
}
}
},
{
"name": "viewer 1 audio decoding progresses",
"status": "PASS",
"evidence": {
"progressing_intervals": 12
}
},
{
"name": "viewer 2 continuous decoded progress",
"status": "PASS",
"evidence": {
"samples": 3,
"progressing_intervals": 2,
"stalled_intervals": 0,
"audio_progress_intervals": 2
}
},
{
"name": "viewer 2 browser and HLS responses",
"status": "PASS",
"evidence": {
"browser_errors": [],
"hls": {
"manifests": 11,
"segments": 0,
"failed": [],
"request_failed": []
}
}
},
{
"name": "viewer 2 audio decoding progresses",
"status": "PASS",
"evidence": {
"progressing_intervals": 2
}
},
{
"name": "sharing uses one physical receiver stream",
"status": "PASS",
"evidence": {
"before_opened": 1,
"during": [
{
"elapsed": 45.97,
"opened": 1,
"physical_rows": 1,
"consumers": 2
},
{
"elapsed": 47.41,
"opened": 1,
"physical_rows": 1,
"consumers": 2
}
]
}
},
{
"name": "global receiver upstream counter stable during sharing",
"status": "PASS",
"evidence": {
"before": 1,
"during": [
1,
1
],
"scope": "Global plugin counter; correlate a failure with other users and channel snapshots."
}
}
],
"events": [],
"snapshots": [
{
"utc": "2026-10-12T22:13:47.808Z",
"type": "server",
"label": "before",
"elapsed": null,
"plugin": "7.0.7.0",
"streams": {
"UpstreamConnectionsOpened": 0,
"ActiveSources": 0,
"Consumers": 0,
"LastErrors": [],
"Channels": []
},
"probe_error": null,
"sessions": [
{
"user": "decoder-reception-d06a6455",
"channel": null,
"transcoding": {
"VideoCodec": null,
"AudioCodec": null,
"IsVideoDirect": null,
"IsAudioDirect": null,
"Framerate": null,
"TranscodeReasons": null
}
},
{
"user": "decoder-reception-9aaca800",
"channel": null,
"transcoding": {
"VideoCodec": null,
"AudioCodec": null,
"IsVideoDirect": null,
"IsAudioDirect": null,
"Framerate": null,
"TranscodeReasons": null
}
}
],
"receiver": {
"status": 200,
"jellyfin_channel_rows": 0,
"rows": [],
"channel_matches": 0
}
},
{
"utc": "2026-10-12T22:14:04.561Z",
"type": "server",
"label": "primary-started",
"elapsed": 1.71,
"plugin": "7.0.7.0",
"streams": {
"UpstreamConnectionsOpened": 1,
"ActiveSources": 1,
"Consumers": 1,
"LastErrors": [],
"Channels": [
{
"Id": "bd9828873b2f4a1cbbb9853beb9630e5",
"ServiceReference": "/1:0:1:3ABD:514:13E:820000:0:0:0:",
"Active": true,
"Consumers": 1
}
]
},
"probe_error": null,
"sessions": [
{
"user": "decoder-reception-9aaca800",
"channel": "TVP 1 HD",
"transcoding": {
"VideoCodec": "h264",
"AudioCodec": "aac",
"IsVideoDirect": false,
"IsAudioDirect": false,
"Framerate": 65,
"TranscodeReasons": [
"ContainerNotSupported",
"AudioCodecNotSupported",
"InterlacedVideoNotSupported"
]
}
},
{
"user": "decoder-reception-d06a6455",
"channel": null,
"transcoding": {
"VideoCodec": null,
"AudioCodec": null,
"IsVideoDirect": null,
"IsAudioDirect": null,
"Framerate": null,
"TranscodeReasons": null
}
}
],
"receiver": {
"status": 200,
"jellyfin_channel_rows": 1,
"rows": [
{
"ip": "192.168.100.191",
"name": "TVP 1 HD",
"ref": "1:0:1:3ABD:514:13E:820000:0:0:0:"
}
],
"channel_matches": 1
}
},
{
"utc": "2026-10-12T22:14:48.818Z",
"type": "server",
"label": "secondary-joined",
"elapsed": 45.97,
"plugin": "7.0.7.0",
"streams": {
"UpstreamConnectionsOpened": 1,
"ActiveSources": 1,
"Consumers": 2,
"LastErrors": [],
"Channels": [
{
"Id": "bd9828873b2f4a1cbbb9853beb9630e5",
"ServiceReference": "/1:0:1:3ABD:514:13E:820000:0:0:0:",
"Active": true,
"Consumers": 2
}
]
},
"probe_error": null,
"sessions": [
{
"user": "decoder-reception-d06a6455",
"channel": "TVP 1 HD",
"transcoding": {
"VideoCodec": "h264",
"AudioCodec": "aac",
"IsVideoDirect": false,
"IsAudioDirect": false,
"Framerate": 58,
"TranscodeReasons": [
"ContainerNotSupported",
"AudioCodecNotSupported",
"InterlacedVideoNotSupported"
]
}
},
{
"user": "decoder-reception-9aaca800",
"channel": "TVP 1 HD",
"transcoding": {
"VideoCodec": "h264",
"AudioCodec": "aac",
"IsVideoDirect": false,
"IsAudioDirect": false,
"Framerate": 52,
"TranscodeReasons": [
"ContainerNotSupported",
"AudioCodecNotSupported",
"InterlacedVideoNotSupported"
]
}
}
],
"receiver": {
"status": 200,
"jellyfin_channel_rows": 1,
"rows": [
{
"ip": "192.168.100.191",
"name": "TVP 1 HD",
"ref": "1:0:1:3ABD:514:13E:820000:0:0:0:"
}
],
"channel_matches": 1
}
},
{
"utc": "2026-10-12T22:14:50.258Z",
"type": "server",
"label": "periodic",
"elapsed": 47.41,
"plugin": "7.0.7.0",
"streams": {
"UpstreamConnectionsOpened": 1,
"ActiveSources": 1,
"Consumers": 2,
"LastErrors": [],
"Channels": [
{
"Id": "bd9828873b2f4a1cbbb9853beb9630e5",
"ServiceReference": "/1:0:1:3ABD:514:13E:820000:0:0:0:",
"Active": true,
"Consumers": 2
}
]
},
"probe_error": null,
"sessions": [
{
"user": "decoder-reception-d06a6455",
"channel": "TVP 1 HD",
"transcoding": {
"VideoCodec": "h264",
"AudioCodec": "aac",
"IsVideoDirect": false,
"IsAudioDirect": false,
"Framerate": 57,
"TranscodeReasons": [
"ContainerNotSupported",
"AudioCodecNotSupported",
"InterlacedVideoNotSupported"
]
}
},
{
"user": "decoder-reception-9aaca800",
"channel": "TVP 1 HD",
"transcoding": {
"VideoCodec": "h264",
"AudioCodec": "aac",
"IsVideoDirect": false,
"IsAudioDirect": false,
"Framerate": 52,
"TranscodeReasons": [
"ContainerNotSupported",
"AudioCodecNotSupported",
"InterlacedVideoNotSupported"
]
}
}
],
"receiver": {
"status": 200,
"jellyfin_channel_rows": 1,
"rows": [
{
"ip": "192.168.100.191",
"name": "TVP 1 HD",
"ref": "1:0:1:3ABD:514:13E:820000:0:0:0:"
}
],
"channel_matches": 1
}
},
{
"utc": "2026-10-12T22:15:08.968Z",
"type": "server",
"label": "secondary-left",
"elapsed": 66.12,
"plugin": "7.0.7.0",
"streams": {
"UpstreamConnectionsOpened": 1,
"ActiveSources": 1,
"Consumers": 1,
"LastErrors": [],
"Channels": [
{
"Id": "bd9828873b2f4a1cbbb9853beb9630e5",
"ServiceReference": "/1:0:1:3ABD:514:13E:820000:0:0:0:",
"Active": true,
"Consumers": 1
}
]
},
"probe_error": null,
"sessions": [
{
"user": "decoder-reception-9aaca800",
"channel": "TVP 1 HD",
"transcoding": {
"VideoCodec": "h264",
"AudioCodec": "aac",
"IsVideoDirect": false,
"IsAudioDirect": false,
"Framerate": 51,
"TranscodeReasons": [
"ContainerNotSupported",
"AudioCodecNotSupported",
"InterlacedVideoNotSupported"
]
}
}
],
"receiver": {
"status": 200,
"jellyfin_channel_rows": 1,
"rows": [
{
"ip": "192.168.100.191",
"name": "TVP 1 HD",
"ref": "1:0:1:3ABD:514:13E:820000:0:0:0:"
}
],
"channel_matches": 1
}
},
{
"utc": "2026-10-12T22:15:25.724Z",
"type": "server",
"label": "periodic",
"elapsed": 82.88,
"plugin": "7.0.7.0",
"streams": {
"UpstreamConnectionsOpened": 1,
"ActiveSources": 1,
"Consumers": 1,
"LastErrors": [],
"Channels": [
{
"Id": "bd9828873b2f4a1cbbb9853beb9630e5",
"ServiceReference": "/1:0:1:3ABD:514:13E:820000:0:0:0:",
"Active": true,
"Consumers": 1
}
]
},
"probe_error": null,
"sessions": [
{
"user": "decoder-reception-9aaca800",
"channel": "TVP 1 HD",
"transcoding": {
"VideoCodec": "h264",
"AudioCodec": "aac",
"IsVideoDirect": false,
"IsAudioDirect": false,
"Framerate": 51,
"TranscodeReasons": [
"ContainerNotSupported",
"AudioCodecNotSupported",
"InterlacedVideoNotSupported"
]
}
}
],
"receiver": {
"status": 200,
"jellyfin_channel_rows": 1,
"rows": [
{
"ip": "192.168.100.191",
"name": "TVP 1 HD",
"ref": "1:0:1:3ABD:514:13E:820000:0:0:0:"
}
],
"channel_matches": 1
}
},
{
"utc": "2026-10-12T22:15:59.915Z",
"type": "server",
"label": "after-cleanup",
"elapsed": 117.07,
"plugin": "7.0.7.0",
"streams": {
"UpstreamConnectionsOpened": 1,
"ActiveSources": 0,
"Consumers": 0,
"LastErrors": [],
"Channels": [
{
"Id": "bd9828873b2f4a1cbbb9853beb9630e5",
"ServiceReference": "/1:0:1:3ABD:514:13E:820000:0:0:0:",
"Active": false,
"Consumers": 0
}
]
},
"probe_error": null,
"sessions": [
{
"user": "decoder-reception-9aaca800",
"channel": null,
"transcoding": {
"VideoCodec": null,
"AudioCodec": null,
"IsVideoDirect": null,
"IsAudioDirect": null,
"Framerate": null,
"TranscodeReasons": null
}
}
],
"receiver": {
"status": 200,
"jellyfin_channel_rows": 0,
"rows": [],
"channel_matches": 0
}
}
],
"audio_scope": "Decoded byte counter when available; no human audible verification.",
"first_video_utc": "2026-10-12T22:14:02.849Z",
"service_reference": "/1:0:1:3ABD:514:13E:820000:0:0:0:",
"frame_artifact": "enigma-diagnostics/jellyfin-decoder-plugin/frame-7.0.7-20261013.png",
"secondary_request_seconds": 30.16,
"secondary_video_seconds": 44.16,
"secondary_stop_seconds": 62.44,
"playback_elapsed_seconds": 92.9,
"primary_stop_seconds": 92.9,
"finished_utc": "2026-10-12T22:16:02.627Z",
"pass": 15,
"fail": 0,
"unverified": 0,
"viewers": [
{
"viewer": 1,
"startup_seconds": 15.04,
"samples": [
{
"utc": "2026-10-12T22:14:07.955Z",
"type": "video",
"viewer": 1,
"elapsed": 5.11,
"video": {
"time": 5.120881,
"frames": 206,
"dropped": 0,
"audio_bytes": 175353,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 4.980078
},
"events": [
{
"type": "waiting",
"at": 1791843236668,
"time": 0,
"error": null
},
{
"type": "playing",
"at": 1791843242753,
"time": 0,
"error": null
}
],
"route": "#/video"
},
{
"utc": "2026-10-12T22:14:12.960Z",
"type": "video",
"viewer": 1,
"elapsed": 10.11,
"video": {
"time": 10.129345,
"frames": 456,
"dropped": 0,
"audio_bytes": 337352,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 2.971613999999999
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:14:17.975Z",
"type": "video",
"viewer": 1,
"elapsed": 15.13,
"video": {
"time": 15.144421,
"frames": 706,
"dropped": 0,
"audio_bytes": 498183,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 3.956538
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:14:22.992Z",
"type": "video",
"viewer": 1,
"elapsed": 20.14,
"video": {
"time": 20.160787,
"frames": 958,
"dropped": 0,
"audio_bytes": 661575,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 4.9401720000000005
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:14:28.006Z",
"type": "video",
"viewer": 1,
"elapsed": 25.16,
"video": {
"time": 25.174385,
"frames": 1209,
"dropped": 0,
"audio_bytes": 823605,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 5.926573999999999
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:14:48.822Z",
"type": "video",
"viewer": 1,
"elapsed": 45.97,
"video": {
"time": 45.991756,
"frames": 2249,
"dropped": 7,
"audio_bytes": 1496686,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 3.109203000000001
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:14:55.264Z",
"type": "video",
"viewer": 1,
"elapsed": 52.42,
"video": {
"time": 52.432635,
"frames": 2571,
"dropped": 8,
"audio_bytes": 1703659,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 5.6683240000000055
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:15:00.275Z",
"type": "video",
"viewer": 1,
"elapsed": 57.43,
"video": {
"time": 57.445776,
"frames": 2822,
"dropped": 8,
"audio_bytes": 1865694,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 3.655183000000001
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:15:08.973Z",
"type": "video",
"viewer": 1,
"elapsed": 66.12,
"video": {
"time": 66.142985,
"frames": 3257,
"dropped": 9,
"audio_bytes": 2147099,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 3.957974000000007
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:15:13.982Z",
"type": "video",
"viewer": 1,
"elapsed": 71.13,
"video": {
"time": 71.151762,
"frames": 3508,
"dropped": 9,
"audio_bytes": 2309882,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 4.949196999999998
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:15:19.000Z",
"type": "video",
"viewer": 1,
"elapsed": 76.15,
"video": {
"time": 76.168919,
"frames": 3759,
"dropped": 9,
"audio_bytes": 2471981,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 5.932040000000001
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:15:24.008Z",
"type": "video",
"viewer": 1,
"elapsed": 81.16,
"video": {
"time": 81.17878,
"frames": 4009,
"dropped": 9,
"audio_bytes": 2633952,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 3.922179
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:15:30.742Z",
"type": "video",
"viewer": 1,
"elapsed": 87.89,
"video": {
"time": 87.911523,
"frames": 4345,
"dropped": 9,
"audio_bytes": 2851535,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 6.189436000000001
},
"events": [],
"route": "#/video"
}
],
"errors": [],
"hls": {
"manifests": 36,
"segments": 0,
"failed": [],
"request_failed": []
}
},
{
"viewer": 2,
"startup_seconds": 14,
"samples": [
{
"utc": "2026-10-12T22:14:48.826Z",
"type": "video",
"viewer": 2,
"elapsed": 45.98,
"video": {
"time": 1.945995,
"frames": 28,
"dropped": 0,
"audio_bytes": 72577,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 5.530964
},
"events": [
{
"type": "waiting",
"at": 1791843278929,
"time": 0,
"error": null
},
{
"type": "playing",
"at": 1791843286817,
"time": 0,
"error": null
}
],
"route": "#/video"
},
{
"utc": "2026-10-12T22:14:55.269Z",
"type": "video",
"viewer": 2,
"elapsed": 52.42,
"video": {
"time": 8.388411,
"frames": 350,
"dropped": 0,
"audio_bytes": 280189,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 5.088548000000001
},
"events": [],
"route": "#/video"
},
{
"utc": "2026-10-12T22:15:00.278Z",
"type": "video",
"viewer": 2,
"elapsed": 57.43,
"video": {
"time": 13.398709,
"frames": 600,
"dropped": 0,
"audio_bytes": 441582,
"width": 1920,
"height": 1080,
"ready": 4,
"paused": false,
"ended": false,
"error": null,
"buffered_ahead": 6.078250000000001
},
"events": [],
"route": "#/video"
}
],
"errors": [],
"hls": {
"manifests": 11,
"segments": 0,
"failed": [],
"request_failed": []
}
}
]
}
@@ -1,39 +0,0 @@
{"utc":"2026-10-12T22:13:47.808Z","type":"server","label":"before","elapsed":null,"plugin":"7.0.7.0","streams":{"UpstreamConnectionsOpened":0,"ActiveSources":0,"Consumers":0,"LastErrors":[],"Channels":[]},"probe_error":null,"sessions":[{"user":"decoder-reception-d06a6455","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}},{"user":"decoder-reception-9aaca800","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":0,"rows":[],"channel_matches":0}}
{"utc":"2026-10-12T22:14:02.849Z","type":"check","name":"viewer 1 one-click decoded start","status":"PASS","evidence":{"seconds":15.04}}
{"utc":"2026-10-12T22:14:04.561Z","type":"server","label":"primary-started","elapsed":1.71,"plugin":"7.0.7.0","streams":{"UpstreamConnectionsOpened":1,"ActiveSources":1,"Consumers":1,"LastErrors":[],"Channels":[{"Id":"bd9828873b2f4a1cbbb9853beb9630e5","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1}]},"probe_error":null,"sessions":[{"user":"decoder-reception-9aaca800","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":65,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-d06a6455","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T22:14:07.945Z","type":"frame_saved","path":"enigma-diagnostics/jellyfin-decoder-plugin/frame-7.0.7-20261013.png","elapsed":5.1}
{"utc":"2026-10-12T22:14:07.955Z","type":"video","viewer":1,"elapsed":5.11,"video":{"time":5.120881,"frames":206,"dropped":0,"audio_bytes":175353,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.980078},"events":[{"type":"waiting","at":1791843236668,"time":0,"error":null},{"type":"playing","at":1791843242753,"time":0,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T22:14:12.960Z","type":"video","viewer":1,"elapsed":10.11,"video":{"time":10.129345,"frames":456,"dropped":0,"audio_bytes":337352,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.971613999999999},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:14:17.975Z","type":"video","viewer":1,"elapsed":15.13,"video":{"time":15.144421,"frames":706,"dropped":0,"audio_bytes":498183,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.956538},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:14:22.992Z","type":"video","viewer":1,"elapsed":20.14,"video":{"time":20.160787,"frames":958,"dropped":0,"audio_bytes":661575,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.9401720000000005},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:14:28.006Z","type":"video","viewer":1,"elapsed":25.16,"video":{"time":25.174385,"frames":1209,"dropped":0,"audio_bytes":823605,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.926573999999999},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:14:47.007Z","type":"check","name":"viewer 2 one-click decoded start","status":"PASS","evidence":{"seconds":14}}
{"utc":"2026-10-12T22:14:48.818Z","type":"server","label":"secondary-joined","elapsed":45.97,"plugin":"7.0.7.0","streams":{"UpstreamConnectionsOpened":1,"ActiveSources":1,"Consumers":2,"LastErrors":[],"Channels":[{"Id":"bd9828873b2f4a1cbbb9853beb9630e5","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2}]},"probe_error":null,"sessions":[{"user":"decoder-reception-d06a6455","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":58,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-9aaca800","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":52,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T22:14:48.822Z","type":"video","viewer":1,"elapsed":45.97,"video":{"time":45.991756,"frames":2249,"dropped":7,"audio_bytes":1496686,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.109203000000001},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:14:48.826Z","type":"video","viewer":2,"elapsed":45.98,"video":{"time":1.945995,"frames":28,"dropped":0,"audio_bytes":72577,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.530964},"events":[{"type":"waiting","at":1791843278929,"time":0,"error":null},{"type":"playing","at":1791843286817,"time":0,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T22:14:50.258Z","type":"server","label":"periodic","elapsed":47.41,"plugin":"7.0.7.0","streams":{"UpstreamConnectionsOpened":1,"ActiveSources":1,"Consumers":2,"LastErrors":[],"Channels":[{"Id":"bd9828873b2f4a1cbbb9853beb9630e5","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2}]},"probe_error":null,"sessions":[{"user":"decoder-reception-d06a6455","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":57,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-9aaca800","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":52,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T22:14:55.264Z","type":"video","viewer":1,"elapsed":52.42,"video":{"time":52.432635,"frames":2571,"dropped":8,"audio_bytes":1703659,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.6683240000000055},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:14:55.269Z","type":"video","viewer":2,"elapsed":52.42,"video":{"time":8.388411,"frames":350,"dropped":0,"audio_bytes":280189,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.088548000000001},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:15:00.275Z","type":"video","viewer":1,"elapsed":57.43,"video":{"time":57.445776,"frames":2822,"dropped":8,"audio_bytes":1865694,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.655183000000001},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:15:00.278Z","type":"video","viewer":2,"elapsed":57.43,"video":{"time":13.398709,"frames":600,"dropped":0,"audio_bytes":441582,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.078250000000001},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:15:07.344Z","type":"check","name":"viewer 2 exit to Channels","status":"PASS","evidence":{"elapsed":64.5}}
{"utc":"2026-10-12T22:15:08.968Z","type":"server","label":"secondary-left","elapsed":66.12,"plugin":"7.0.7.0","streams":{"UpstreamConnectionsOpened":1,"ActiveSources":1,"Consumers":1,"LastErrors":[],"Channels":[{"Id":"bd9828873b2f4a1cbbb9853beb9630e5","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1}]},"probe_error":null,"sessions":[{"user":"decoder-reception-9aaca800","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T22:15:08.973Z","type":"video","viewer":1,"elapsed":66.12,"video":{"time":66.142985,"frames":3257,"dropped":9,"audio_bytes":2147099,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.957974000000007},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:15:13.982Z","type":"video","viewer":1,"elapsed":71.13,"video":{"time":71.151762,"frames":3508,"dropped":9,"audio_bytes":2309882,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.949196999999998},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:15:19.000Z","type":"video","viewer":1,"elapsed":76.15,"video":{"time":76.168919,"frames":3759,"dropped":9,"audio_bytes":2471981,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.932040000000001},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:15:24.008Z","type":"video","viewer":1,"elapsed":81.16,"video":{"time":81.17878,"frames":4009,"dropped":9,"audio_bytes":2633952,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.922179},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:15:25.724Z","type":"server","label":"periodic","elapsed":82.88,"plugin":"7.0.7.0","streams":{"UpstreamConnectionsOpened":1,"ActiveSources":1,"Consumers":1,"LastErrors":[],"Channels":[{"Id":"bd9828873b2f4a1cbbb9853beb9630e5","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1}]},"probe_error":null,"sessions":[{"user":"decoder-reception-9aaca800","channel":"TVP 1 HD","transcoding":{"VideoCodec":"h264","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T22:15:30.742Z","type":"video","viewer":1,"elapsed":87.89,"video":{"time":87.911523,"frames":4345,"dropped":9,"audio_bytes":2851535,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.189436000000001},"events":[],"route":"#/video"}
{"utc":"2026-10-12T22:15:38.154Z","type":"check","name":"viewer 1 exit to Channels","status":"PASS","evidence":{"elapsed":95.31}}
{"utc":"2026-10-12T22:15:38.155Z","type":"check","name":"scheduled duration completed","status":"PASS","evidence":{"seconds":92.9,"interrupted":false}}
{"utc":"2026-10-12T22:15:38.155Z","type":"check","name":"second viewer phase completed","status":"PASS","evidence":{"joinAt":30,"leaveAt":60}}
{"utc":"2026-10-12T22:15:59.915Z","type":"server","label":"after-cleanup","elapsed":117.07,"plugin":"7.0.7.0","streams":{"UpstreamConnectionsOpened":1,"ActiveSources":0,"Consumers":0,"LastErrors":[],"Channels":[{"Id":"bd9828873b2f4a1cbbb9853beb9630e5","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-9aaca800","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":0,"rows":[],"channel_matches":0}}
{"utc":"2026-10-12T22:15:59.916Z","type":"check","name":"own relay consumers released after last viewer","status":"PASS","evidence":{"relay_id":"bd9828873b2f4a1cbbb9853beb9630e5","active":false,"consumers":0,"baseline_active":false,"baseline_consumers":0}}
{"utc":"2026-10-12T22:16:02.622Z","type":"check","name":"viewer 1 continuous decoded progress","status":"PASS","evidence":{"samples":13,"progressing_intervals":12,"stalled_intervals":0,"audio_progress_intervals":12}}
{"utc":"2026-10-12T22:16:02.623Z","type":"check","name":"viewer 1 browser and HLS responses","status":"PASS","evidence":{"browser_errors":[],"hls":{"manifests":36,"segments":0,"failed":[],"request_failed":[]}}}
{"utc":"2026-10-12T22:16:02.624Z","type":"check","name":"viewer 1 audio decoding progresses","status":"PASS","evidence":{"progressing_intervals":12}}
{"utc":"2026-10-12T22:16:02.624Z","type":"check","name":"viewer 2 continuous decoded progress","status":"PASS","evidence":{"samples":3,"progressing_intervals":2,"stalled_intervals":0,"audio_progress_intervals":2}}
{"utc":"2026-10-12T22:16:02.625Z","type":"check","name":"viewer 2 browser and HLS responses","status":"PASS","evidence":{"browser_errors":[],"hls":{"manifests":11,"segments":0,"failed":[],"request_failed":[]}}}
{"utc":"2026-10-12T22:16:02.625Z","type":"check","name":"viewer 2 audio decoding progresses","status":"PASS","evidence":{"progressing_intervals":2}}
{"utc":"2026-10-12T22:16:02.626Z","type":"check","name":"sharing uses one physical receiver stream","status":"PASS","evidence":{"before_opened":1,"during":[{"elapsed":45.97,"opened":1,"physical_rows":1,"consumers":2},{"elapsed":47.41,"opened":1,"physical_rows":1,"consumers":2}]}}
{"utc":"2026-10-12T22:16:02.627Z","type":"check","name":"global receiver upstream counter stable during sharing","status":"PASS","evidence":{"before":1,"during":[1,1],"scope":"Global plugin counter; correlate a failure with other users and channel snapshots."}}
@@ -1,53 +0,0 @@
{
"input": "enigma-diagnostics/jellyfin-decoder-plugin/reception-production-7.0.7-20261013.json",
"plugin": "7.0.7.0",
"isolated_bridge_override": false,
"channel": "TVP 1 HD",
"first_video_utc": "2026-10-12T22:14:02.849Z",
"finished_utc": "2026-10-12T22:16:02.627Z",
"transport_checks": {
"pass": 15,
"fail": 0,
"unverified": 0
},
"viewers": [
{
"viewer": 1,
"sampled_seconds": 82.787,
"media_progress_seconds": 82.791,
"realtime_ratio": 1,
"total_frame_delta": 4139,
"dropped_frame_delta": 9,
"dropped_fraction": 0.002,
"approximate_presented_fps": 49.887,
"waiting_events": 0,
"waiting_seconds": 0,
"waiting_median_seconds": null,
"waiting_max_seconds": null,
"waiting_unmatched": 0,
"minimum_buffer_seconds": 2.972,
"audio_decoded_byte_delta": 2676182,
"status": "SMOOTH_TEST_CLIENT",
"scope": "Headless test client only. Frame presentation is approximated; audible sound and other devices are not verified."
},
{
"viewer": 2,
"sampled_seconds": 11.452,
"media_progress_seconds": 11.453,
"realtime_ratio": 1,
"total_frame_delta": 572,
"dropped_frame_delta": 0,
"dropped_fraction": 0,
"approximate_presented_fps": 49.948,
"waiting_events": 0,
"waiting_seconds": 0,
"waiting_median_seconds": null,
"waiting_max_seconds": null,
"waiting_unmatched": 0,
"minimum_buffer_seconds": 5.089,
"audio_decoded_byte_delta": 369005,
"status": "SMOOTH_TEST_CLIENT",
"scope": "Headless test client only. Frame presentation is approximated; audible sound and other devices are not verified."
}
]
}
-53
View File
@@ -1,53 +0,0 @@
{
"input": "enigma-diagnostics/jellyfin-decoder-plugin/reception-trial-20261012.json",
"plugin": "7.0.6.0",
"isolated_bridge_override": false,
"channel": "TVP 1 HD",
"first_video_utc": "2026-10-12T21:31:43.759Z",
"finished_utc": "2026-10-12T21:47:13.009Z",
"transport_checks": {
"pass": 15,
"fail": 0,
"unverified": 0
},
"viewers": [
{
"viewer": 1,
"sampled_seconds": 894.873,
"media_progress_seconds": 713.581,
"realtime_ratio": 0.797,
"total_frame_delta": 35672,
"dropped_frame_delta": 19069,
"dropped_fraction": 0.535,
"approximate_presented_fps": 18.553,
"waiting_events": 188,
"waiting_seconds": 181.144,
"waiting_median_seconds": 0.957,
"waiting_max_seconds": 1.784,
"waiting_unmatched": 0,
"minimum_buffer_seconds": 2.624,
"audio_decoded_byte_delta": 23068427,
"status": "CLIENT_NOT_SMOOTH",
"scope": "Headless test client only. Frame presentation is approximated; audible sound and other devices are not verified."
},
{
"viewer": 2,
"sampled_seconds": 281.009,
"media_progress_seconds": 215.712,
"realtime_ratio": 0.768,
"total_frame_delta": 10762,
"dropped_frame_delta": 6022,
"dropped_fraction": 0.56,
"approximate_presented_fps": 16.868,
"waiting_events": 59,
"waiting_seconds": 65.198,
"waiting_median_seconds": 1.177,
"waiting_max_seconds": 1.757,
"waiting_unmatched": 0,
"minimum_buffer_seconds": 4.71,
"audio_decoded_byte_delta": 6973189,
"status": "CLIENT_NOT_SMOOTH",
"scope": "Headless test client only. Frame presentation is approximated; audible sound and other devices are not verified."
}
]
}
File diff suppressed because it is too large Load Diff
-269
View File
@@ -1,269 +0,0 @@
{"utc":"2026-10-12T21:31:30.448Z","type":"server","label":"before","elapsed":null,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":17,"ActiveSources":0,"Consumers":0,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}},{"user":"decoder-reception-0703d0b0","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":0,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T21:31:43.758Z","type":"check","name":"viewer 1 one-click decoded start","status":"PASS","evidence":{"seconds":13.31}}
{"utc":"2026-10-12T21:31:45.664Z","type":"server","label":"primary-started","elapsed":1.91,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":66,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:31:45.670Z","type":"video","viewer":1,"elapsed":1.91,"video":{"time":1.948837,"frames":5,"dropped":0,"audio_bytes":73460,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.000122},"events":[{"type":"waiting","at":1791840698378,"time":0,"error":null},{"type":"playing","at":1791840703648,"time":0,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:31:50.675Z","type":"video","viewer":1,"elapsed":6.92,"video":{"time":5.954098,"frames":199,"dropped":105,"audio_bytes":202151,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.994861},"events":[{"type":"waiting","at":1791840709122,"time":5.4,"error":null},{"type":"playing","at":1791840710053,"time":5.4,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:31:55.692Z","type":"video","viewer":1,"elapsed":11.93,"video":{"time":10.128179,"frames":397,"dropped":213,"audio_bytes":337326,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.820779999999999},"events":[{"type":"waiting","at":1791840713637,"time":8.916161,"error":null},{"type":"playing","at":1791840714481,"time":8.980248,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:00.695Z","type":"video","viewer":1,"elapsed":16.94,"video":{"time":14.154475,"frames":609,"dropped":311,"audio_bytes":467113,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.794483999999999},"events":[{"type":"waiting","at":1791840717954,"time":12.392476,"error":null},{"type":"playing","at":1791840718935,"time":12.46,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:05.710Z","type":"video","viewer":1,"elapsed":21.95,"video":{"time":19.048178,"frames":864,"dropped":396,"audio_bytes":625825,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.9007809999999985},"events":[{"type":"waiting","at":1791840723856,"time":17.315117,"error":null},{"type":"playing","at":1791840723971,"time":17.38,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:10.714Z","type":"video","viewer":1,"elapsed":26.96,"video":{"time":23.912905,"frames":1107,"dropped":480,"audio_bytes":782936,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.036054},"events":[{"type":"waiting","at":1791840730337,"time":23.676054,"error":null},{"type":"playing","at":1791840730477,"time":23.74,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:15.722Z","type":"video","viewer":1,"elapsed":31.96,"video":{"time":28.720021,"frames":1321,"dropped":555,"audio_bytes":938165,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.228938000000003},"events":[{"type":"waiting","at":1791840735521,"time":28.720021,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:17.487Z","type":"server","label":"periodic","elapsed":33.73,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":52,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:32:22.506Z","type":"video","viewer":1,"elapsed":38.75,"video":{"time":34.244548,"frames":1611,"dropped":697,"audio_bytes":1117453,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.704411},"events":[{"type":"playing","at":1791840736100,"time":28.78,"error":null},{"type":"waiting","at":1791840740553,"time":33.151318,"error":null},{"type":"playing","at":1791840741413,"time":33.22,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:27.509Z","type":"video","viewer":1,"elapsed":43.75,"video":{"time":38.689258,"frames":1846,"dropped":780,"audio_bytes":1260866,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.259701},"events":[{"type":"waiting","at":1791840745136,"time":36.875998,"error":null},{"type":"playing","at":1791840745696,"time":36.94,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:32.519Z","type":"video","viewer":1,"elapsed":48.76,"video":{"time":43.481305,"frames":2047,"dropped":845,"audio_bytes":1416252,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.467654000000003},"events":[{"type":"waiting","at":1791840752302,"time":43.481305,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:37.523Z","type":"video","viewer":1,"elapsed":53.76,"video":{"time":46.959019,"frames":2233,"dropped":958,"audio_bytes":1527708,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.989940000000004},"events":[{"type":"playing","at":1791840753444,"time":43.56,"error":null},{"type":"waiting","at":1791840756919,"time":46.959019,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:42.531Z","type":"video","viewer":1,"elapsed":58.77,"video":{"time":50.433053,"frames":2419,"dropped":1069,"audio_bytes":1640382,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":8.515906000000001},"events":[{"type":"playing","at":1791840758164,"time":47.02,"error":null},{"type":"waiting","at":1791840761653,"time":50.433053,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:47.544Z","type":"video","viewer":1,"elapsed":63.79,"video":{"time":54.044753,"frames":2607,"dropped":1180,"audio_bytes":1757427,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.904206000000002},"events":[{"type":"playing","at":1791840762808,"time":50.5,"error":null},{"type":"waiting","at":1791840766390,"time":54,"error":null},{"type":"playing","at":1791840767423,"time":54,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:49.378Z","type":"server","label":"periodic","elapsed":65.62,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:32:54.426Z","type":"video","viewer":1,"elapsed":70.67,"video":{"time":59.904208,"frames":2875,"dropped":1317,"audio_bytes":1946422,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.044751000000005},"events":[{"type":"waiting","at":1791840771268,"time":57.767087,"error":null},{"type":"playing","at":1791840772243,"time":57.84,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:32:59.443Z","type":"video","viewer":1,"elapsed":75.68,"video":{"time":63.902455,"frames":3079,"dropped":1435,"audio_bytes":2075897,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.046503999999999},"events":[{"type":"waiting","at":1791840775719,"time":61.237264,"error":null},{"type":"playing","at":1791840776761,"time":61.3,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:33:04.447Z","type":"video","viewer":1,"elapsed":80.69,"video":{"time":68.193163,"frames":3285,"dropped":1550,"audio_bytes":2214578,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.755796000000004},"events":[{"type":"waiting","at":1791840780384,"time":64.844091,"error":null},{"type":"playing","at":1791840781086,"time":64.92,"error":null},{"type":"waiting","at":1791840784433,"time":68.193163,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:33:09.461Z","type":"video","viewer":1,"elapsed":85.7,"video":{"time":72,"frames":3487,"dropped":1669,"audio_bytes":2335352,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.948959000000002},"events":[{"type":"playing","at":1791840785204,"time":68.26,"error":null},{"type":"waiting","at":1791840789027,"time":72,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:33:14.469Z","type":"video","viewer":1,"elapsed":90.71,"video":{"time":75.588439,"frames":3685,"dropped":1787,"audio_bytes":2453364,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.360520000000008},"events":[{"type":"playing","at":1791840789855,"time":72,"error":null},{"type":"waiting","at":1791840793316,"time":75.396042,"error":null},{"type":"playing","at":1791840794276,"time":75.46,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:33:19.474Z","type":"video","viewer":1,"elapsed":95.72,"video":{"time":79.73421,"frames":3889,"dropped":1889,"audio_bytes":2587425,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.214748999999998},"events":[{"type":"waiting","at":1791840797750,"time":78.869218,"error":null},{"type":"playing","at":1791840798608,"time":78.94,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:33:21.202Z","type":"server","label":"periodic","elapsed":97.44,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:33:26.218Z","type":"video","viewer":1,"elapsed":102.46,"video":{"time":85.818468,"frames":4185,"dropped":2040,"audio_bytes":2783727,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.130491000000006},"events":[{"type":"waiting","at":1791840802585,"time":82.844938,"error":null},{"type":"playing","at":1791840803245,"time":82.92,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:33:31.223Z","type":"video","viewer":1,"elapsed":107.47,"video":{"time":89.843655,"frames":4371,"dropped":2148,"audio_bytes":2914820,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.105304000000004},"events":[{"type":"waiting","at":1791840808166,"time":87.766127,"error":null},{"type":"playing","at":1791840809155,"time":87.84,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:33:36.228Z","type":"video","viewer":1,"elapsed":112.47,"video":{"time":93.727554,"frames":4569,"dropped":2259,"audio_bytes":3040386,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.221405000000004},"events":[{"type":"waiting","at":1791840812499,"time":91.120038,"error":null},{"type":"playing","at":1791840813616,"time":91.18,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:33:41.234Z","type":"video","viewer":1,"elapsed":117.48,"video":{"time":97.81394,"frames":4759,"dropped":2366,"audio_bytes":3172012,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.135019},"events":[{"type":"waiting","at":1791840817216,"time":94.715985,"error":null},{"type":"playing","at":1791840818124,"time":94.78,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:33:46.239Z","type":"video","viewer":1,"elapsed":122.48,"video":{"time":101.56998,"frames":4951,"dropped":2483,"audio_bytes":3293420,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.378979000000001},"events":[{"type":"waiting","at":1791840821482,"time":98.062081,"error":null},{"type":"playing","at":1791840822660,"time":98.140419,"error":null},{"type":"waiting","at":1791840826149,"time":101.56998,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:33:51.256Z","type":"video","viewer":1,"elapsed":127.5,"video":{"time":105.165133,"frames":5161,"dropped":2614,"audio_bytes":3409835,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.783826000000005},"events":[{"type":"playing","at":1791840827073,"time":101.64,"error":null},{"type":"waiting","at":1791840830664,"time":105.165133,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:33:52.923Z","type":"server","label":"periodic","elapsed":129.17,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:33:57.946Z","type":"video","viewer":1,"elapsed":134.19,"video":{"time":111.105629,"frames":5445,"dropped":2749,"audio_bytes":3601558,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.843330000000009},"events":[{"type":"playing","at":1791840831343,"time":105.24,"error":null},{"type":"waiting","at":1791840835664,"time":109.485019,"error":null},{"type":"playing","at":1791840836331,"time":109.56,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:02.964Z","type":"video","viewer":1,"elapsed":139.21,"video":{"time":115.143543,"frames":5647,"dropped":2865,"audio_bytes":3732573,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.805416000000008},"events":[{"type":"waiting","at":1791840839798,"time":112.958272,"error":null},{"type":"playing","at":1791840840765,"time":113.02,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:07.967Z","type":"video","viewer":1,"elapsed":144.21,"video":{"time":119.387752,"frames":5850,"dropped":2979,"audio_bytes":3869879,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.561206999999996},"events":[{"type":"waiting","at":1791840844264,"time":116.445081,"error":null},{"type":"playing","at":1791840845035,"time":116.52,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:12.982Z","type":"video","viewer":1,"elapsed":149.22,"video":{"time":123.047209,"frames":6049,"dropped":3106,"audio_bytes":3987801,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.901750000000007},"events":[{"type":"waiting","at":1791840848382,"time":119.802075,"error":null},{"type":"playing","at":1791840849122,"time":119.88,"error":null},{"type":"waiting","at":1791840852367,"time":123.047209,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:17.988Z","type":"video","viewer":1,"elapsed":154.23,"video":{"time":127.227558,"frames":6265,"dropped":3217,"audio_bytes":4123082,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.721401},"events":[{"type":"playing","at":1791840853205,"time":123.12,"error":null},{"type":"waiting","at":1791840857164,"time":127.004027,"error":null},{"type":"playing","at":1791840857777,"time":127.08,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:22.993Z","type":"video","viewer":1,"elapsed":159.24,"video":{"time":131.454148,"frames":6465,"dropped":3325,"audio_bytes":4259499,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.494810999999999},"events":[{"type":"waiting","at":1791840861246,"time":130.486014,"error":null},{"type":"playing","at":1791840862022,"time":130.56,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:24.655Z","type":"server","label":"periodic","elapsed":160.9,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:34:29.670Z","type":"video","viewer":1,"elapsed":165.91,"video":{"time":137.010389,"frames":6721,"dropped":3467,"audio_bytes":4439000,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.938569999999999},"events":[{"type":"waiting","at":1791840865479,"time":133.940242,"error":null},{"type":"playing","at":1791840866590,"time":134,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:34.678Z","type":"video","viewer":1,"elapsed":170.92,"video":{"time":140.796995,"frames":6909,"dropped":3579,"audio_bytes":4561609,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.151963999999992},"events":[{"type":"waiting","at":1791840870096,"time":137.437229,"error":null},{"type":"playing","at":1791840871238,"time":137.5,"error":null},{"type":"waiting","at":1791840874597,"time":140.796995,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:39.685Z","type":"video","viewer":1,"elapsed":175.93,"video":{"time":144.286273,"frames":7105,"dropped":3698,"audio_bytes":4674891,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.662686000000008},"events":[{"type":"playing","at":1791840875643,"time":140.86,"error":null},{"type":"waiting","at":1791840879146,"time":144.286273,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:44.694Z","type":"video","viewer":1,"elapsed":180.94,"video":{"time":147.995561,"frames":7303,"dropped":3815,"audio_bytes":4794014,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.953397999999993},"events":[{"type":"playing","at":1791840880053,"time":144.36,"error":null},{"type":"waiting","at":1791840883513,"time":147.754021,"error":null},{"type":"playing","at":1791840884452,"time":147.82,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:49.701Z","type":"video","viewer":1,"elapsed":185.94,"video":{"time":152.080896,"frames":7501,"dropped":3919,"audio_bytes":4926436,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.8680630000000065},"events":[{"type":"waiting","at":1791840888179,"time":151.48107,"error":null},{"type":"playing","at":1791840889107,"time":151.56,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:54.710Z","type":"video","viewer":1,"elapsed":190.95,"video":{"time":156.070451,"frames":7693,"dropped":4032,"audio_bytes":5055433,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.878508000000011},"events":[{"type":"waiting","at":1791840892462,"time":154.843028,"error":null},{"type":"playing","at":1791840893492,"time":154.92,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:34:56.392Z","type":"server","label":"periodic","elapsed":192.64,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:35:01.396Z","type":"video","viewer":1,"elapsed":197.64,"video":{"time":161.735151,"frames":7979,"dropped":4182,"audio_bytes":5238289,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.213808},"events":[{"type":"waiting","at":1791840896845,"time":158.205111,"error":null},{"type":"playing","at":1791840897873,"time":158.28,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:35:06.411Z","type":"video","viewer":1,"elapsed":202.65,"video":{"time":166.490844,"frames":8236,"dropped":4238,"audio_bytes":5391925,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.458114999999992},"events":[{"type":"waiting","at":1791840901693,"time":161.98,"error":null},{"type":"playing","at":1791840901838,"time":161.98,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:35:11.419Z","type":"video","viewer":1,"elapsed":207.66,"video":{"time":171.500123,"frames":8481,"dropped":4238,"audio_bytes":5554136,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.448836},"events":[],"route":"#/video"}
{"utc":"2026-10-12T21:35:16.426Z","type":"video","viewer":1,"elapsed":212.67,"video":{"time":176.508075,"frames":8721,"dropped":4267,"audio_bytes":5716067,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.440884000000011},"events":[],"route":"#/video"}
{"utc":"2026-10-12T21:35:21.439Z","type":"video","viewer":1,"elapsed":217.68,"video":{"time":181.119771,"frames":8955,"dropped":4345,"audio_bytes":5864999,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.829188000000016},"events":[{"type":"waiting","at":1791840918528,"time":178.609578,"error":null},{"type":"playing","at":1791840919049,"time":178.731161,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:35:26.442Z","type":"video","viewer":1,"elapsed":222.69,"video":{"time":185.344288,"frames":9163,"dropped":4456,"audio_bytes":6001654,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.604670999999996},"events":[{"type":"waiting","at":1791840924193,"time":183.875246,"error":null},{"type":"playing","at":1791840924972,"time":183.94,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:35:28.261Z","type":"server","label":"periodic","elapsed":224.5,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:35:33.270Z","type":"video","viewer":1,"elapsed":229.51,"video":{"time":191.445165,"frames":9465,"dropped":4621,"audio_bytes":6198854,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.503793999999999},"events":[{"type":"waiting","at":1791840928578,"time":187.478955,"error":null},{"type":"playing","at":1791840929064,"time":187.54,"error":null},{"type":"waiting","at":1791840933043,"time":191.445165,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:35:38.277Z","type":"video","viewer":1,"elapsed":234.52,"video":{"time":196.179026,"frames":9703,"dropped":4706,"audio_bytes":6351892,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.769933000000009},"events":[{"type":"playing","at":1791840933544,"time":191.52,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:35:43.284Z","type":"video","viewer":1,"elapsed":239.53,"video":{"time":199.925734,"frames":9891,"dropped":4815,"audio_bytes":6473393,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.0232249999999965},"events":[{"type":"waiting","at":1791840940611,"time":198.511276,"error":null},{"type":"playing","at":1791840941863,"time":198.58,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:35:48.288Z","type":"video","viewer":1,"elapsed":244.53,"video":{"time":203.970055,"frames":10083,"dropped":4925,"audio_bytes":6603746,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.978904},"events":[{"type":"waiting","at":1791840945476,"time":202.117009,"error":null},{"type":"playing","at":1791840946428,"time":202.18,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:35:53.300Z","type":"video","viewer":1,"elapsed":249.54,"video":{"time":208.042845,"frames":10279,"dropped":5035,"audio_bytes":6735545,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.906114000000002},"events":[{"type":"waiting","at":1791840949911,"time":205.592012,"error":null},{"type":"playing","at":1791840950839,"time":205.66,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:35:58.306Z","type":"video","viewer":1,"elapsed":254.55,"video":{"time":211.828764,"frames":10477,"dropped":5152,"audio_bytes":6858266,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.120194999999995},"events":[{"type":"waiting","at":1791840954343,"time":209.084115,"error":null},{"type":"playing","at":1791840955662,"time":209.183042,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:36:00.030Z","type":"server","label":"periodic","elapsed":256.27,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:36:05.038Z","type":"video","viewer":1,"elapsed":261.28,"video":{"time":217.380404,"frames":10767,"dropped":5303,"audio_bytes":7037471,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":3.5685550000000035},"events":[{"type":"waiting","at":1791840958925,"time":212.446342,"error":null},{"type":"playing","at":1791840959511,"time":212.52,"error":null},{"type":"waiting","at":1791840963460,"time":216.402034,"error":null},{"type":"playing","at":1791840964073,"time":216.48,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:36:10.041Z","type":"video","viewer":1,"elapsed":266.29,"video":{"time":221.784277,"frames":10983,"dropped":5419,"audio_bytes":7180261,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.164681999999999},"events":[{"type":"waiting","at":1791840967658,"time":220.000157,"error":null},{"type":"playing","at":1791840968364,"time":220.105617,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:36:15.061Z","type":"video","viewer":1,"elapsed":271.31,"video":{"time":226.121461,"frames":11193,"dropped":5531,"audio_bytes":7320261,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.827497999999991},"events":[{"type":"waiting","at":1791840972209,"time":223.949367,"error":null},{"type":"playing","at":1791840972885,"time":224.02,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:36:20.066Z","type":"video","viewer":1,"elapsed":276.31,"video":{"time":230.366666,"frames":11407,"dropped":5641,"audio_bytes":7457530,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.582292999999993},"events":[{"type":"waiting","at":1791840976375,"time":227.436026,"error":null},{"type":"playing","at":1791840977119,"time":227.5,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:36:25.076Z","type":"video","viewer":1,"elapsed":281.32,"video":{"time":234.777645,"frames":11631,"dropped":5725,"audio_bytes":7599689,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.171313999999995},"events":[{"type":"waiting","at":1791840980858,"time":231.158236,"error":null},{"type":"playing","at":1791840981450,"time":231.22,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:36:30.089Z","type":"video","viewer":1,"elapsed":286.33,"video":{"time":239.031434,"frames":11833,"dropped":5836,"audio_bytes":7737473,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.917525000000012},"events":[{"type":"waiting","at":1791840986508,"time":236.208326,"error":null},{"type":"playing","at":1791840987274,"time":236.28,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:36:31.526Z","type":"server","label":"periodic","elapsed":287.77,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:36:36.538Z","type":"video","viewer":1,"elapsed":292.78,"video":{"time":244.140465,"frames":12109,"dropped":5984,"audio_bytes":7902389,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.808493999999996},"events":[{"type":"waiting","at":1791840990624,"time":239.566577,"error":null},{"type":"playing","at":1791840991281,"time":239.64,"error":null},{"type":"waiting","at":1791840994641,"time":242.923045,"error":null},{"type":"playing","at":1791840995322,"time":243,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:36:41.542Z","type":"video","viewer":1,"elapsed":297.79,"video":{"time":248.325224,"frames":12311,"dropped":6097,"audio_bytes":8038273,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":2.6237350000000106},"events":[{"type":"waiting","at":1791840999390,"time":246.992337,"error":null},{"type":"playing","at":1791841000202,"time":247.06,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:36:58.188Z","type":"check","name":"viewer 2 one-click decoded start","status":"PASS","evidence":{"seconds":11.64}}
{"utc":"2026-10-12T21:36:59.829Z","type":"server","label":"secondary-joined","elapsed":316.07,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-f2fe3bd2","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":64,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:36:59.834Z","type":"video","viewer":1,"elapsed":316.08,"video":{"time":262.296382,"frames":12997,"dropped":6504,"audio_bytes":8489364,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.652577000000008},"events":[{"type":"waiting","at":1791841003707,"time":250.489027,"error":null},{"type":"playing","at":1791841004643,"time":250.566835,"error":null},{"type":"waiting","at":1791841007823,"time":253.706082,"error":null},{"type":"playing","at":1791841009020,"time":253.78,"error":null},{"type":"waiting","at":1791841012389,"time":257.069997,"error":null},{"type":"playing","at":1791841013431,"time":257.14,"error":null},{"type":"waiting","at":1791841017039,"time":260.682274,"error":null},{"type":"playing","at":1791841018239,"time":260.760867,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:36:59.842Z","type":"video","viewer":2,"elapsed":316.09,"video":{"time":1.61659,"frames":9,"dropped":0,"audio_bytes":62251,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.9163689999999995},"events":[{"type":"waiting","at":1791841012790,"time":0,"error":null},{"type":"playing","at":1791841018154,"time":0,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:04.848Z","type":"video","viewer":1,"elapsed":321.09,"video":{"time":265.789598,"frames":13173,"dropped":6611,"audio_bytes":8602213,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.15936099999999},"events":[{"type":"waiting","at":1791841021456,"time":263.919035,"error":null},{"type":"playing","at":1791841022957,"time":263.98,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:04.851Z","type":"video","viewer":2,"elapsed":321.1,"video":{"time":5.025659,"frames":177,"dropped":108,"audio_bytes":172574,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.5073},"events":[{"type":"waiting","at":1791841023208,"time":4.96,"error":null},{"type":"playing","at":1791841024710,"time":4.96,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:06.443Z","type":"server","label":"periodic","elapsed":322.69,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":56,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:37:11.455Z","type":"video","viewer":1,"elapsed":327.7,"video":{"time":270.639061,"frames":13407,"dropped":6753,"audio_bytes":8758859,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.309897999999976},"events":[{"type":"waiting","at":1791841026339,"time":267.281371,"error":null},{"type":"playing","at":1791841027599,"time":267.36,"error":null},{"type":"waiting","at":1791841030957,"time":270.639061,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:11.461Z","type":"video","viewer":2,"elapsed":327.71,"video":{"time":10.334364,"frames":415,"dropped":243,"audio_bytes":343456,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.198595000000001},"events":[{"type":"waiting","at":1791841028097,"time":8.273044,"error":null},{"type":"playing","at":1791841029393,"time":8.34,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:16.464Z","type":"video","viewer":1,"elapsed":332.71,"video":{"time":274.003069,"frames":13593,"dropped":6870,"audio_bytes":8867954,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.94589000000002},"events":[{"type":"playing","at":1791841032339,"time":270.7,"error":null},{"type":"waiting","at":1791841035722,"time":274.003069,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:16.468Z","type":"video","viewer":2,"elapsed":332.71,"video":{"time":14.002676,"frames":597,"dropped":355,"audio_bytes":462854,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.5302830000000025},"events":[{"type":"waiting","at":1791841032766,"time":11.640313,"error":null},{"type":"playing","at":1791841034112,"time":11.72,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:21.476Z","type":"video","viewer":1,"elapsed":337.72,"video":{"time":277.497558,"frames":13779,"dropped":6987,"audio_bytes":8980367,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.451400999999976},"events":[{"type":"playing","at":1791841036827,"time":274.08,"error":null},{"type":"waiting","at":1791841040189,"time":277.371134,"error":null},{"type":"playing","at":1791841041353,"time":277.44,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:21.479Z","type":"video","viewer":2,"elapsed":337.72,"video":{"time":17.634284,"frames":777,"dropped":462,"audio_bytes":580180,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.898675000000001},"events":[{"type":"waiting","at":1791841037457,"time":14.992062,"error":null},{"type":"playing","at":1791841038830,"time":15.06,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:26.486Z","type":"video","viewer":1,"elapsed":342.73,"video":{"time":281.487332,"frames":13971,"dropped":7098,"audio_bytes":9110404,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.461627000000021},"events":[{"type":"waiting","at":1791841044821,"time":280.842894,"error":null},{"type":"playing","at":1791841045842,"time":280.92,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:26.490Z","type":"video","viewer":2,"elapsed":342.73,"video":{"time":21.543456,"frames":961,"dropped":568,"audio_bytes":706403,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.989503000000003},"events":[{"type":"waiting","at":1791841042188,"time":18.34209,"error":null},{"type":"playing","at":1791841043298,"time":18.42,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:31.499Z","type":"video","viewer":1,"elapsed":347.74,"video":{"time":285.462528,"frames":14167,"dropped":7216,"audio_bytes":9238942,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.486430999999982},"events":[{"type":"waiting","at":1791841049205,"time":284.20719,"error":null},{"type":"playing","at":1791841050248,"time":284.28,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:31.503Z","type":"video","viewer":2,"elapsed":347.75,"video":{"time":25.057039,"frames":1143,"dropped":684,"audio_bytes":819519,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.475920000000002},"events":[{"type":"waiting","at":1791841046771,"time":21.82656,"error":null},{"type":"playing","at":1791841047988,"time":21.9,"error":null},{"type":"waiting","at":1791841051221,"time":25.057039,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:36.512Z","type":"video","viewer":1,"elapsed":352.76,"video":{"time":289.392882,"frames":14359,"dropped":7329,"audio_bytes":9364984,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.556077000000016},"events":[{"type":"waiting","at":1791841053720,"time":287.68309,"error":null},{"type":"playing","at":1791841054808,"time":287.76,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:36.515Z","type":"video","viewer":2,"elapsed":352.76,"video":{"time":28.422046,"frames":1325,"dropped":801,"audio_bytes":929161,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.1109129999999965},"events":[{"type":"playing","at":1791841052505,"time":25.12,"error":null},{"type":"waiting","at":1791841055887,"time":28.422046,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:38.147Z","type":"server","label":"periodic","elapsed":354.39,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":52,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:37:43.163Z","type":"video","viewer":1,"elapsed":359.41,"video":{"time":294.651048,"frames":14603,"dropped":7469,"audio_bytes":9535332,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.297910999999999},"events":[{"type":"waiting","at":1791841058271,"time":291.153412,"error":null},{"type":"playing","at":1791841059481,"time":291.22,"error":null},{"type":"waiting","at":1791841062988,"time":294.651048,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:43.167Z","type":"video","viewer":2,"elapsed":359.41,"video":{"time":33.202975,"frames":1567,"dropped":953,"audio_bytes":1083467,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.329983999999996},"events":[{"type":"playing","at":1791841057213,"time":28.5,"error":null},{"type":"waiting","at":1791841060455,"time":31.671036,"error":null},{"type":"playing","at":1791841061632,"time":31.74,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:48.175Z","type":"video","viewer":1,"elapsed":364.42,"video":{"time":298.027745,"frames":14803,"dropped":7599,"audio_bytes":9643293,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.92121400000002},"events":[{"type":"playing","at":1791841064112,"time":294.72,"error":null},{"type":"waiting","at":1791841067486,"time":298.027745,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:48.179Z","type":"video","viewer":2,"elapsed":364.42,"video":{"time":37.295168,"frames":1777,"dropped":1071,"audio_bytes":1215549,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.237791000000001},"events":[{"type":"waiting","at":1791841064872,"time":34.907004,"error":null},{"type":"playing","at":1791841065914,"time":35.02974,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:53.182Z","type":"video","viewer":1,"elapsed":369.43,"video":{"time":301.805681,"frames":14995,"dropped":7706,"audio_bytes":9766673,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.143278000000009},"events":[{"type":"playing","at":1791841068285,"time":298.06,"error":null},{"type":"waiting","at":1791841071772,"time":301.474059,"error":null},{"type":"playing","at":1791841072845,"time":301.54,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:53.198Z","type":"video","viewer":2,"elapsed":369.44,"video":{"time":41.653114,"frames":1995,"dropped":1188,"audio_bytes":1356077,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.879844999999996},"events":[{"type":"waiting","at":1791841069270,"time":38.384496,"error":null},{"type":"playing","at":1791841069927,"time":38.46,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:58.206Z","type":"video","viewer":1,"elapsed":374.45,"video":{"time":305.82949,"frames":15185,"dropped":7814,"audio_bytes":9896717,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.119468999999981},"events":[{"type":"waiting","at":1791841076319,"time":304.941296,"error":null},{"type":"playing","at":1791841077334,"time":305.02,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:37:58.209Z","type":"video","viewer":2,"elapsed":374.45,"video":{"time":46.188974,"frames":2219,"dropped":1306,"audio_bytes":1502891,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.343984999999996},"events":[{"type":"waiting","at":1791841073771,"time":42.225992,"error":null},{"type":"playing","at":1791841074127,"time":42.3,"error":null},{"type":"waiting","at":1791841078094,"time":46.188974,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:03.223Z","type":"video","viewer":1,"elapsed":379.47,"video":{"time":309.6065,"frames":15373,"dropped":7927,"audio_bytes":10018801,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.342459000000019},"events":[{"type":"waiting","at":1791841080702,"time":308.325208,"error":null},{"type":"playing","at":1791841081956,"time":308.4,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:03.302Z","type":"video","viewer":2,"elapsed":379.55,"video":{"time":50.276181,"frames":2437,"dropped":1420,"audio_bytes":1635339,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.256777999999997},"events":[{"type":"playing","at":1791841078673,"time":46.26,"error":null},{"type":"waiting","at":1791841082154,"time":49.670023,"error":null},{"type":"playing","at":1791841082695,"time":49.74,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:08.305Z","type":"video","viewer":1,"elapsed":384.55,"video":{"time":314.688824,"frames":15595,"dropped":7995,"audio_bytes":10183214,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.260134999999991},"events":[],"route":"#/video"}
{"utc":"2026-10-12T21:38:08.308Z","type":"video","viewer":2,"elapsed":384.55,"video":{"time":54.544134,"frames":2649,"dropped":1522,"audio_bytes":1773343,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.9888249999999985},"events":[{"type":"waiting","at":1791841086403,"time":53.377183,"error":null},{"type":"playing","at":1791841087143,"time":53.44,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:10.033Z","type":"server","label":"periodic","elapsed":386.28,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-f2fe3bd2","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:38:15.047Z","type":"video","viewer":1,"elapsed":391.29,"video":{"time":318.53098,"frames":15829,"dropped":8153,"audio_bytes":10307221,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.4179790000000025},"events":[{"type":"waiting","at":1791841088402,"time":314.785191,"error":null},{"type":"playing","at":1791841089831,"time":314.86,"error":null},{"type":"waiting","at":1791841093349,"time":318.311519,"error":null},{"type":"playing","at":1791841094815,"time":318.36,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:15.051Z","type":"video","viewer":2,"elapsed":391.3,"video":{"time":60.726496,"frames":2943,"dropped":1676,"audio_bytes":1973308,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.806463000000008},"events":[{"type":"waiting","at":1791841091354,"time":57.589026,"error":null},{"type":"playing","at":1791841091910,"time":57.66,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:20.057Z","type":"video","viewer":1,"elapsed":396.3,"video":{"time":322.200293,"frames":16007,"dropped":8263,"audio_bytes":10426004,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.748666000000014},"events":[{"type":"waiting","at":1791841098150,"time":321.634384,"error":null},{"type":"playing","at":1791841099492,"time":321.7,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:20.061Z","type":"video","viewer":2,"elapsed":396.31,"video":{"time":64.876706,"frames":3169,"dropped":1806,"audio_bytes":2104931,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.656253000000007},"events":[{"type":"waiting","at":1791841095636,"time":61.312144,"error":null},{"type":"playing","at":1791841096167,"time":61.38,"error":null},{"type":"waiting","at":1791841099656,"time":64.791171,"error":null},{"type":"playing","at":1791841100035,"time":64.86,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:25.074Z","type":"video","viewer":1,"elapsed":401.32,"video":{"time":325.717561,"frames":16181,"dropped":8371,"audio_bytes":10539687,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.231398000000013},"events":[{"type":"waiting","at":1791841102734,"time":324.877031,"error":null},{"type":"playing","at":1791841104233,"time":324.94,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:25.078Z","type":"video","viewer":2,"elapsed":401.32,"video":{"time":69.234001,"frames":3385,"dropped":1907,"audio_bytes":2248105,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.298957999999999},"events":[{"type":"waiting","at":1791841104365,"time":69.06,"error":null},{"type":"playing","at":1791841104841,"time":69.06,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:30.081Z","type":"video","viewer":1,"elapsed":406.33,"video":{"time":329.323043,"frames":16357,"dropped":8480,"audio_bytes":10656237,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.625916000000018},"events":[{"type":"waiting","at":1791841107469,"time":328.111427,"error":null},{"type":"playing","at":1791841108862,"time":328.18,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:30.085Z","type":"video","viewer":2,"elapsed":406.33,"video":{"time":73.400632,"frames":3595,"dropped":2005,"audio_bytes":2382799,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.132327000000004},"events":[{"type":"waiting","at":1791841108119,"time":72.273853,"error":null},{"type":"playing","at":1791841108953,"time":72.34,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:35.088Z","type":"video","viewer":1,"elapsed":411.33,"video":{"time":332.871413,"frames":16527,"dropped":8588,"audio_bytes":10770658,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.077545999999984},"events":[{"type":"waiting","at":1791841112234,"time":331.477099,"error":null},{"type":"playing","at":1791841113687,"time":331.54,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:35.092Z","type":"video","viewer":2,"elapsed":411.34,"video":{"time":77.017235,"frames":3765,"dropped":2099,"audio_bytes":2499575,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":8.515724000000006},"events":[{"type":"waiting","at":1791841113700,"time":77.017235,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:40.094Z","type":"video","viewer":1,"elapsed":416.34,"video":{"time":336.277435,"frames":16695,"dropped":8695,"audio_bytes":10881157,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.671523999999977},"events":[{"type":"waiting","at":1791841116917,"time":334.700304,"error":null},{"type":"playing","at":1791841118525,"time":334.78,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:40.099Z","type":"video","viewer":2,"elapsed":416.34,"video":{"time":80.373554,"frames":3939,"dropped":2211,"audio_bytes":2607803,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":8.159405000000007},"events":[{"type":"playing","at":1791841115457,"time":77.08,"error":null},{"type":"waiting","at":1791841118817,"time":80.373554,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:41.763Z","type":"server","label":"periodic","elapsed":418.01,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-f2fe3bd2","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:38:46.770Z","type":"video","viewer":1,"elapsed":423.02,"video":{"time":341.195036,"frames":16925,"dropped":8836,"audio_bytes":11039547,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.753922999999986},"events":[{"type":"waiting","at":1791841121769,"time":337.951864,"error":null},{"type":"playing","at":1791841123315,"time":338.02,"error":null},{"type":"waiting","at":1791841126552,"time":341.195036,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:46.777Z","type":"video","viewer":2,"elapsed":423.02,"video":{"time":85.349283,"frames":4171,"dropped":2351,"audio_bytes":2769161,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.1836760000000055},"events":[{"type":"playing","at":1791841120292,"time":80.44,"error":null},{"type":"waiting","at":1791841123667,"time":83.744082,"error":null},{"type":"playing","at":1791841125181,"time":83.82,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:51.780Z","type":"video","viewer":1,"elapsed":428.02,"video":{"time":344.420229,"frames":17100,"dropped":8950,"audio_bytes":11144364,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.528729999999996},"events":[{"type":"playing","at":1791841128085,"time":341.26,"error":null},{"type":"waiting","at":1791841131317,"time":344.420229,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:51.783Z","type":"video","viewer":2,"elapsed":428.03,"video":{"time":88.878398,"frames":4347,"dropped":2460,"audio_bytes":2882985,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.654561000000001},"events":[{"type":"waiting","at":1791841128400,"time":86.976143,"error":null},{"type":"playing","at":1791841129866,"time":87.04,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:56.787Z","type":"video","viewer":1,"elapsed":433.03,"video":{"time":347.806041,"frames":17283,"dropped":9063,"audio_bytes":11253981,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":8.142918000000009},"events":[{"type":"playing","at":1791841132618,"time":344.5,"error":null},{"type":"waiting","at":1791841136002,"time":347.806041,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:38:56.790Z","type":"video","viewer":2,"elapsed":433.04,"video":{"time":92.624671,"frames":4525,"dropped":2566,"audio_bytes":3004356,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.908287999999999},"events":[{"type":"waiting","at":1791841133250,"time":90.347183,"error":null},{"type":"playing","at":1791841134522,"time":90.42,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:01.804Z","type":"video","viewer":1,"elapsed":438.05,"video":{"time":351.805948,"frames":17493,"dropped":9177,"audio_bytes":11383276,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.143011000000001},"events":[{"type":"playing","at":1791841137130,"time":347.88,"error":null},{"type":"waiting","at":1791841140601,"time":351.284181,"error":null},{"type":"playing","at":1791841141277,"time":351.36,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:01.810Z","type":"video","viewer":2,"elapsed":438.05,"video":{"time":96.383804,"frames":4715,"dropped":2677,"audio_bytes":3125570,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.1491550000000075},"events":[{"type":"waiting","at":1791841137867,"time":93.703307,"error":null},{"type":"playing","at":1791841139144,"time":93.780028,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:06.815Z","type":"video","viewer":1,"elapsed":443.06,"video":{"time":356.217973,"frames":17711,"dropped":9282,"audio_bytes":11525180,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.73098600000003},"events":[{"type":"waiting","at":1791841145003,"time":355.006064,"error":null},{"type":"playing","at":1791841145601,"time":355.08,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:06.820Z","type":"video","viewer":2,"elapsed":443.06,"video":{"time":100.434917,"frames":4909,"dropped":2786,"audio_bytes":3256728,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.098042000000007},"events":[{"type":"waiting","at":1791841142600,"time":97.175023,"error":null},{"type":"playing","at":1791841143545,"time":97.24,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:11.823Z","type":"video","viewer":1,"elapsed":448.07,"video":{"time":360.666603,"frames":17929,"dropped":9389,"audio_bytes":11669960,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.282355999999993},"events":[{"type":"waiting","at":1791841149683,"time":359.086054,"error":null},{"type":"playing","at":1791841150245,"time":359.16,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:11.827Z","type":"video","viewer":2,"elapsed":448.07,"video":{"time":104.421876,"frames":5103,"dropped":2869,"audio_bytes":3385574,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.111083000000008},"events":[{"type":"waiting","at":1791841146933,"time":100.549795,"error":null},{"type":"playing","at":1791841147962,"time":100.62,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:13.587Z","type":"server","label":"periodic","elapsed":449.83,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":51,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:39:18.593Z","type":"video","viewer":1,"elapsed":454.84,"video":{"time":366.643026,"frames":18211,"dropped":9541,"audio_bytes":11863026,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.305932999999982},"events":[{"type":"waiting","at":1791841154199,"time":363.043103,"error":null},{"type":"playing","at":1791841154899,"time":363.12,"error":null},{"type":"waiting","at":1791841158500,"time":366.643026,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:18.596Z","type":"video","viewer":2,"elapsed":454.84,"video":{"time":108.530923,"frames":5353,"dropped":3038,"audio_bytes":3518310,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.002036000000004},"events":[{"type":"waiting","at":1791841152516,"time":105.111199,"error":null},{"type":"playing","at":1791841154128,"time":105.18,"error":null},{"type":"waiting","at":1791841157484,"time":108.461032,"error":null},{"type":"playing","at":1791841158525,"time":108.52,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:23.603Z","type":"video","viewer":1,"elapsed":459.85,"video":{"time":370.54563,"frames":18433,"dropped":9664,"audio_bytes":11989299,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.403328999999985},"events":[{"type":"playing","at":1791841159214,"time":366.72,"error":null},{"type":"waiting","at":1791841162799,"time":370.242988,"error":null},{"type":"playing","at":1791841163297,"time":370.32,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:23.607Z","type":"video","viewer":2,"elapsed":459.85,"video":{"time":112.461566,"frames":5550,"dropped":3140,"audio_bytes":3645894,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.0713930000000005},"events":[{"type":"waiting","at":1791841162264,"time":112.180068,"error":null},{"type":"playing","at":1791841163305,"time":112.24,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:28.610Z","type":"video","viewer":1,"elapsed":464.86,"video":{"time":374.872635,"frames":18653,"dropped":9762,"audio_bytes":12128679,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.076324},"events":[{"type":"waiting","at":1791841167000,"time":373.944075,"error":null},{"type":"playing","at":1791841167676,"time":374.02,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:28.614Z","type":"video","viewer":2,"elapsed":464.86,"video":{"time":116.209041,"frames":5731,"dropped":3248,"audio_bytes":3766448,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":8.323918000000006},"events":[{"type":"waiting","at":1791841166815,"time":115.670491,"error":null},{"type":"playing","at":1791841168070,"time":115.74,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:33.634Z","type":"video","viewer":1,"elapsed":469.88,"video":{"time":379.036595,"frames":18863,"dropped":9865,"audio_bytes":12263037,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.912364000000025},"events":[{"type":"waiting","at":1791841172265,"time":378.529197,"error":null},{"type":"playing","at":1791841173127,"time":378.6,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:33.637Z","type":"video","viewer":2,"elapsed":469.88,"video":{"time":119.972627,"frames":5919,"dropped":3355,"audio_bytes":3888533,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.5603320000000025},"events":[{"type":"waiting","at":1791841171431,"time":119.028802,"error":null},{"type":"playing","at":1791841172698,"time":119.1,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:38.639Z","type":"video","viewer":1,"elapsed":474.89,"video":{"time":383.602058,"frames":19075,"dropped":9951,"audio_bytes":12411625,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.346901000000003},"events":[{"type":"waiting","at":1791841178198,"time":383.602058,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:38.643Z","type":"video","viewer":2,"elapsed":474.89,"video":{"time":124.039331,"frames":6111,"dropped":3462,"audio_bytes":4019566,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.493628000000001},"events":[{"type":"waiting","at":1791841176415,"time":122.75057,"error":null},{"type":"playing","at":1791841177347,"time":122.82,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:43.648Z","type":"video","viewer":1,"elapsed":479.89,"video":{"time":387.390209,"frames":19273,"dropped":10060,"audio_bytes":12533521,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.558749999999975},"events":[{"type":"playing","at":1791841178923,"time":383.68,"error":null},{"type":"waiting","at":1791841182232,"time":386.915082,"error":null},{"type":"playing","at":1791841183166,"time":386.98,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:43.652Z","type":"video","viewer":2,"elapsed":479.9,"video":{"time":127.787644,"frames":6293,"dropped":3574,"audio_bytes":4140899,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.745315000000005},"events":[{"type":"waiting","at":1791841180814,"time":126.210058,"error":null},{"type":"playing","at":1791841182078,"time":126.28,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:45.586Z","type":"server","label":"periodic","elapsed":481.83,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:39:50.591Z","type":"video","viewer":1,"elapsed":486.84,"video":{"time":392.553685,"frames":19515,"dropped":10194,"audio_bytes":12700280,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.395274000000029},"events":[{"type":"waiting","at":1791841187747,"time":391.491269,"error":null},{"type":"playing","at":1791841189519,"time":391.56,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:50.594Z","type":"video","viewer":2,"elapsed":486.84,"video":{"time":132.700063,"frames":6535,"dropped":3726,"audio_bytes":4299674,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.832896000000005},"events":[{"type":"waiting","at":1791841185330,"time":129.467017,"error":null},{"type":"playing","at":1791841186776,"time":129.54,"error":null},{"type":"waiting","at":1791841190004,"time":132.700063,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:55.598Z","type":"video","viewer":1,"elapsed":491.84,"video":{"time":396.061193,"frames":19687,"dropped":10310,"audio_bytes":12814114,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.887765999999999},"events":[{"type":"waiting","at":1791841192881,"time":394.844035,"error":null},{"type":"playing","at":1791841194394,"time":394.92,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:39:55.601Z","type":"video","viewer":2,"elapsed":491.85,"video":{"time":136.315126,"frames":6705,"dropped":3822,"audio_bytes":4416712,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.217833000000013},"events":[{"type":"playing","at":1791841191567,"time":132.76,"error":null},{"type":"waiting","at":1791841195198,"time":136.315126,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:00.607Z","type":"video","viewer":1,"elapsed":496.85,"video":{"time":399.291009,"frames":19845,"dropped":10415,"audio_bytes":12918421,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.657950000000028},"events":[{"type":"waiting","at":1791841197482,"time":397.94697,"error":null},{"type":"playing","at":1791841199266,"time":398.02,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:00.610Z","type":"video","viewer":2,"elapsed":496.86,"video":{"time":139.537088,"frames":6883,"dropped":3942,"audio_bytes":4520885,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.995870999999994},"events":[{"type":"playing","at":1791841196558,"time":136.38,"error":null},{"type":"waiting","at":1791841199781,"time":139.537088,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:05.615Z","type":"video","viewer":1,"elapsed":501.86,"video":{"time":402.919428,"frames":20027,"dropped":10529,"audio_bytes":13035663,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.02953100000002},"events":[{"type":"waiting","at":1791841202431,"time":401.115158,"error":null},{"type":"playing","at":1791841203807,"time":401.18,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:05.618Z","type":"video","viewer":2,"elapsed":501.86,"video":{"time":144.073848,"frames":7099,"dropped":3992,"audio_bytes":4667138,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.459111000000007},"events":[{"type":"playing","at":1791841201074,"time":139.6,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:10.624Z","type":"video","viewer":1,"elapsed":506.87,"video":{"time":406.608058,"frames":20209,"dropped":10638,"audio_bytes":13154842,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.340900999999974},"events":[{"type":"waiting","at":1791841207013,"time":404.317086,"error":null},{"type":"playing","at":1791841208318,"time":404.38,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:10.628Z","type":"video","viewer":2,"elapsed":506.87,"video":{"time":147.783575,"frames":7320,"dropped":4073,"audio_bytes":4787816,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.749383999999992},"events":[{"type":"waiting","at":1791841206899,"time":145.355163,"error":null},{"type":"playing","at":1791841208184,"time":145.42,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:15.633Z","type":"video","viewer":1,"elapsed":511.88,"video":{"time":410.217696,"frames":20385,"dropped":10744,"audio_bytes":13271256,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.731263000000013},"events":[{"type":"waiting","at":1791841211696,"time":407.679138,"error":null},{"type":"playing","at":1791841213084,"time":407.74,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:15.636Z","type":"video","viewer":2,"elapsed":511.88,"video":{"time":152.793348,"frames":7539,"dropped":4109,"audio_bytes":4949389,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.7396109999999965},"events":[],"route":"#/video"}
{"utc":"2026-10-12T21:40:17.375Z","type":"server","label":"periodic","elapsed":513.62,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:40:22.389Z","type":"video","viewer":1,"elapsed":518.63,"video":{"time":414.872635,"frames":20651,"dropped":10901,"audio_bytes":13421702,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.076324},"events":[{"type":"waiting","at":1791841216464,"time":411.047289,"error":null},{"type":"playing","at":1791841217484,"time":411.12,"error":null},{"type":"waiting","at":1791841220951,"time":414.515117,"error":null},{"type":"playing","at":1791841222019,"time":414.58,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:22.392Z","type":"video","viewer":2,"elapsed":518.64,"video":{"time":157.528489,"frames":7803,"dropped":4269,"audio_bytes":5102501,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.004469999999998},"events":[{"type":"waiting","at":1791841216664,"time":153.820089,"error":null},{"type":"playing","at":1791841217686,"time":153.88,"error":null},{"type":"waiting","at":1791841221412,"time":157.528489,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:27.398Z","type":"video","viewer":1,"elapsed":523.64,"video":{"time":419.445228,"frames":20837,"dropped":10966,"audio_bytes":13569882,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.503731000000016},"events":[{"type":"waiting","at":1791841226962,"time":419.445228,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:27.402Z","type":"video","viewer":2,"elapsed":523.65,"video":{"time":161.718039,"frames":8005,"dropped":4384,"audio_bytes":5237545,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.814920000000001},"events":[{"type":"playing","at":1791841222411,"time":157.6,"error":null},{"type":"waiting","at":1791841225913,"time":161.029078,"error":null},{"type":"playing","at":1791841226708,"time":161.1,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:32.418Z","type":"video","viewer":1,"elapsed":528.66,"video":{"time":422.930269,"frames":21031,"dropped":11088,"audio_bytes":13682495,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":8.018689999999992},"events":[{"type":"playing","at":1791841228564,"time":419.52,"error":null},{"type":"waiting","at":1791841232045,"time":422.930269,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:32.421Z","type":"video","viewer":2,"elapsed":528.67,"video":{"time":165.897436,"frames":8205,"dropped":4497,"audio_bytes":5372601,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.635523000000006},"events":[{"type":"waiting","at":1791841229945,"time":164.261108,"error":null},{"type":"playing","at":1791841230800,"time":164.34,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:37.424Z","type":"video","viewer":1,"elapsed":533.67,"video":{"time":426.466426,"frames":21227,"dropped":11212,"audio_bytes":13794853,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.482532999999989},"events":[{"type":"playing","at":1791841232966,"time":423,"error":null},{"type":"waiting","at":1791841236428,"time":426.39212,"error":null},{"type":"playing","at":1791841237410,"time":426.46,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:37.427Z","type":"video","viewer":2,"elapsed":533.67,"video":{"time":169.843495,"frames":8395,"dropped":4611,"audio_bytes":5500906,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.689464000000015},"events":[{"type":"waiting","at":1791841234154,"time":167.630421,"error":null},{"type":"playing","at":1791841235207,"time":167.7,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:42.432Z","type":"video","viewer":1,"elapsed":538.68,"video":{"time":430.37637,"frames":21419,"dropped":11317,"audio_bytes":13923116,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.572588999999994},"events":[{"type":"waiting","at":1791841240895,"time":429.879602,"error":null},{"type":"playing","at":1791841241933,"time":429.94,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:42.519Z","type":"video","viewer":2,"elapsed":538.77,"video":{"time":173.775357,"frames":8585,"dropped":4717,"audio_bytes":5627831,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.7576019999999914},"events":[{"type":"waiting","at":1791841238563,"time":170.979999,"error":null},{"type":"playing","at":1791841239713,"time":171.04,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:47.526Z","type":"video","viewer":1,"elapsed":543.77,"video":{"time":434.189768,"frames":21607,"dropped":11420,"audio_bytes":14046898,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.759190999999987},"events":[{"type":"waiting","at":1791841245295,"time":433.239143,"error":null},{"type":"playing","at":1791841246559,"time":433.3,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:47.530Z","type":"video","viewer":2,"elapsed":543.78,"video":{"time":177.506531,"frames":8765,"dropped":4822,"audio_bytes":5748628,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.02642800000001},"events":[{"type":"waiting","at":1791841243328,"time":174.583776,"error":null},{"type":"playing","at":1791841244610,"time":174.66,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:49.034Z","type":"server","label":"periodic","elapsed":545.28,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-f2fe3bd2","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:40:54.044Z","type":"video","viewer":1,"elapsed":550.29,"video":{"time":439.828272,"frames":21871,"dropped":11561,"audio_bytes":14228751,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.120686999999975},"events":[{"type":"waiting","at":1791841250279,"time":436.94312,"error":null},{"type":"playing","at":1791841251157,"time":437.02,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:54.047Z","type":"video","viewer":2,"elapsed":550.29,"video":{"time":181.644824,"frames":9007,"dropped":4978,"audio_bytes":5882238,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.8881350000000054},"events":[{"type":"waiting","at":1791841247978,"time":177.956057,"error":null},{"type":"playing","at":1791841249211,"time":178.02,"error":null},{"type":"waiting","at":1791841252678,"time":181.415104,"error":null},{"type":"playing","at":1791841253820,"time":181.48,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:59.051Z","type":"video","viewer":1,"elapsed":555.3,"video":{"time":443.775448,"frames":22061,"dropped":11665,"audio_bytes":14356245,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.173511000000019},"events":[{"type":"waiting","at":1791841254778,"time":440.56238,"error":null},{"type":"playing","at":1791841255839,"time":440.64,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:40:59.055Z","type":"video","viewer":2,"elapsed":555.3,"video":{"time":185.53217,"frames":9193,"dropped":5088,"audio_bytes":6007834,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":8.000788999999997},"events":[{"type":"waiting","at":1791841257194,"time":184.791265,"error":null},{"type":"playing","at":1791841258406,"time":184.883717,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:04.070Z","type":"video","viewer":1,"elapsed":560.32,"video":{"time":447.634095,"frames":22255,"dropped":11781,"audio_bytes":14481049,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.314864},"events":[{"type":"waiting","at":1791841259449,"time":444.174079,"error":null},{"type":"playing","at":1791841260354,"time":444.24,"error":null},{"type":"waiting","at":1791841263811,"time":447.634095,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:04.073Z","type":"video","viewer":2,"elapsed":560.32,"video":{"time":189.350017,"frames":9379,"dropped":5199,"audio_bytes":6131271,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.182941999999997},"events":[{"type":"waiting","at":1791841261794,"time":188.272097,"error":null},{"type":"playing","at":1791841262987,"time":188.34,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:09.088Z","type":"video","viewer":1,"elapsed":565.34,"video":{"time":450.994908,"frames":22437,"dropped":11899,"audio_bytes":14589370,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.954050999999993},"events":[{"type":"playing","at":1791841264954,"time":447.7,"error":null},{"type":"waiting","at":1791841268311,"time":450.994908,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:09.092Z","type":"video","viewer":2,"elapsed":565.34,"video":{"time":193.20887,"frames":9571,"dropped":5313,"audio_bytes":6256134,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.324089000000015},"events":[{"type":"waiting","at":1791841266344,"time":191.622029,"error":null},{"type":"playing","at":1791841267514,"time":191.7,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:14.095Z","type":"video","viewer":1,"elapsed":570.34,"video":{"time":454.96057,"frames":22657,"dropped":12011,"audio_bytes":14717759,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.988388999999984},"events":[{"type":"playing","at":1791841269581,"time":451.06,"error":null},{"type":"waiting","at":1791841272960,"time":454.363962,"error":null},{"type":"playing","at":1791841273500,"time":454.44,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:14.098Z","type":"video","viewer":2,"elapsed":570.34,"video":{"time":196.95555,"frames":9751,"dropped":5420,"audio_bytes":6376737,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.577409000000017},"events":[{"type":"waiting","at":1791841270993,"time":195.110026,"error":null},{"type":"playing","at":1791841272262,"time":195.18,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:19.102Z","type":"video","viewer":1,"elapsed":575.35,"video":{"time":459.873016,"frames":22857,"dropped":12071,"audio_bytes":14876921,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.075942999999995},"events":[{"type":"waiting","at":1791841279009,"time":459.873016,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:19.106Z","type":"video","viewer":2,"elapsed":575.35,"video":{"time":200.822886,"frames":9933,"dropped":5526,"audio_bytes":6502395,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.710072999999994},"events":[{"type":"waiting","at":1791841275609,"time":198.466134,"error":null},{"type":"playing","at":1791841276751,"time":198.54,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:20.797Z","type":"server","label":"periodic","elapsed":577.04,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":2,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":2},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-f2fe3bd2","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:41:25.814Z","type":"video","viewer":1,"elapsed":582.06,"video":{"time":465.559202,"frames":23175,"dropped":12130,"audio_bytes":15060342,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.389756999999975},"events":[{"type":"playing","at":1791841280131,"time":459.94,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:25.819Z","type":"video","viewer":2,"elapsed":582.07,"video":{"time":205.394654,"frames":10195,"dropped":5696,"audio_bytes":6648766,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.138305000000003},"events":[{"type":"waiting","at":1791841280229,"time":201.945983,"error":null},{"type":"playing","at":1791841281427,"time":202.02,"error":null},{"type":"waiting","at":1791841284793,"time":205.310241,"error":null},{"type":"playing","at":1791841285774,"time":205.38,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:30.828Z","type":"video","viewer":1,"elapsed":587.07,"video":{"time":469.471548,"frames":23371,"dropped":12236,"audio_bytes":15187357,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.477411000000018},"events":[{"type":"waiting","at":1791841288408,"time":468.153014,"error":null},{"type":"playing","at":1791841289512,"time":468.22,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:30.832Z","type":"video","viewer":2,"elapsed":587.08,"video":{"time":209.688724,"frames":10405,"dropped":5805,"audio_bytes":6788582,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.844234999999998},"events":[{"type":"waiting","at":1791841289125,"time":208.663011,"error":null},{"type":"playing","at":1791841289818,"time":208.74,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:35.840Z","type":"video","viewer":1,"elapsed":592.09,"video":{"time":473.644455,"frames":23567,"dropped":12343,"audio_bytes":15321746,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.3045040000000085},"events":[{"type":"waiting","at":1791841293242,"time":471.886338,"error":null},{"type":"playing","at":1791841294087,"time":471.96,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:35.843Z","type":"video","viewer":2,"elapsed":592.09,"video":{"time":213.680293,"frames":10591,"dropped":5910,"audio_bytes":6918336,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.852665999999999},"events":[{"type":"waiting","at":1791841293409,"time":212.267988,"error":null},{"type":"playing","at":1791841294432,"time":212.34,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:40.848Z","type":"video","viewer":1,"elapsed":597.09,"video":{"time":477.912293,"frames":23769,"dropped":12459,"audio_bytes":15459570,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.036666000000025},"events":[{"type":"waiting","at":1791841297558,"time":475.362949,"error":null},{"type":"playing","at":1791841298305,"time":475.44,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:40.851Z","type":"video","viewer":2,"elapsed":597.1,"video":{"time":217.328472,"frames":10771,"dropped":6022,"audio_bytes":7035440,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.204487},"events":[{"type":"waiting","at":1791841297908,"time":215.745059,"error":null},{"type":"playing","at":1791841299278,"time":215.82,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:47.793Z","type":"check","name":"viewer 2 exit to Channels","status":"PASS","evidence":{"elapsed":604.04}}
{"utc":"2026-10-12T21:41:49.569Z","type":"server","label":"secondary-left","elapsed":605.82,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:41:49.573Z","type":"video","viewer":1,"elapsed":605.82,"video":{"time":484.357993,"frames":24099,"dropped":12657,"audio_bytes":15667969,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.59096599999998},"events":[{"type":"waiting","at":1791841301659,"time":478.723028,"error":null},{"type":"playing","at":1791841302769,"time":478.8,"error":null},{"type":"waiting","at":1791841306242,"time":482.207985,"error":null},{"type":"playing","at":1791841307429,"time":482.28,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:54.576Z","type":"video","viewer":1,"elapsed":610.82,"video":{"time":488.260086,"frames":24295,"dropped":12771,"audio_bytes":15794189,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.688873000000001},"events":[{"type":"waiting","at":1791841310892,"time":485.676053,"error":null},{"type":"playing","at":1791841311990,"time":485.74,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:41:55.955Z","type":"server","label":"periodic","elapsed":612.2,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":3}}
{"utc":"2026-10-12T21:42:00.970Z","type":"video","viewer":1,"elapsed":617.22,"video":{"time":492.763072,"frames":24539,"dropped":12924,"audio_bytes":15940411,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.18588699999998},"events":[{"type":"waiting","at":1791841315590,"time":489.276332,"error":null},{"type":"playing","at":1791841316663,"time":489.34,"error":null},{"type":"waiting","at":1791841320159,"time":492.763072,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:05.986Z","type":"video","viewer":1,"elapsed":622.23,"video":{"time":496.3,"frames":24719,"dropped":13035,"audio_bytes":16052074,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.648958999999991},"events":[{"type":"playing","at":1791841321145,"time":492.84,"error":null},{"type":"waiting","at":1791841324608,"time":496.233024,"error":null},{"type":"playing","at":1791841325981,"time":496.3,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:10.991Z","type":"video","viewer":1,"elapsed":627.24,"video":{"time":500.195411,"frames":24909,"dropped":13141,"audio_bytes":16180434,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.753548000000023},"events":[{"type":"waiting","at":1791841329474,"time":499.718086,"error":null},{"type":"playing","at":1791841330495,"time":499.78,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:16.006Z","type":"video","viewer":1,"elapsed":632.25,"video":{"time":504.170586,"frames":25099,"dropped":13251,"audio_bytes":16308740,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.778372999999988},"events":[{"type":"waiting","at":1791841333741,"time":502.946955,"error":null},{"type":"playing","at":1791841334788,"time":503.02,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:21.021Z","type":"video","viewer":1,"elapsed":637.27,"video":{"time":508.185656,"frames":25297,"dropped":13368,"audio_bytes":16439128,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.763302999999951},"events":[{"type":"waiting","at":1791841338155,"time":506.32107,"error":null},{"type":"playing","at":1791841339174,"time":506.4,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:26.030Z","type":"video","viewer":1,"elapsed":642.28,"video":{"time":512.03615,"frames":25479,"dropped":13473,"audio_bytes":16563278,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.912808999999925},"events":[{"type":"waiting","at":1791841342525,"time":509.690246,"error":null},{"type":"playing","at":1791841343678,"time":509.76,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:27.624Z","type":"server","label":"periodic","elapsed":643.87,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:42:32.640Z","type":"video","viewer":1,"elapsed":648.89,"video":{"time":516.518074,"frames":25729,"dropped":13636,"audio_bytes":16708187,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.430884999999989},"events":[{"type":"waiting","at":1791841347040,"time":513.046068,"error":null},{"type":"playing","at":1791841348100,"time":513.12,"error":null},{"type":"waiting","at":1791841351572,"time":516.518074,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:37.643Z","type":"video","viewer":1,"elapsed":653.89,"video":{"time":520.448473,"frames":25927,"dropped":13744,"audio_bytes":16835035,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.50048599999991},"events":[{"type":"playing","at":1791841352721,"time":516.58,"error":null},{"type":"waiting","at":1791841356222,"time":520.008141,"error":null},{"type":"playing","at":1791841357213,"time":520.08,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:42.647Z","type":"video","viewer":1,"elapsed":658.89,"video":{"time":524.992915,"frames":26147,"dropped":13861,"audio_bytes":16981740,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.956043999999906},"events":[{"type":"waiting","at":1791841360922,"time":523.727108,"error":null},{"type":"playing","at":1791841361389,"time":523.8,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:47.651Z","type":"video","viewer":1,"elapsed":663.9,"video":{"time":528.87549,"frames":26327,"dropped":13967,"audio_bytes":17107277,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.073468999999932},"events":[{"type":"waiting","at":1791841364855,"time":527.200488,"error":null},{"type":"playing","at":1791841365986,"time":527.28,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:52.656Z","type":"video","viewer":1,"elapsed":668.9,"video":{"time":532.60246,"frames":26509,"dropped":14075,"audio_bytes":17228252,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.346498999999994},"events":[{"type":"waiting","at":1791841369340,"time":530.56503,"error":null},{"type":"playing","at":1791841370632,"time":530.64,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:57.660Z","type":"video","viewer":1,"elapsed":673.91,"video":{"time":536.266775,"frames":26695,"dropped":14187,"audio_bytes":17346747,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.682183999999893},"events":[{"type":"waiting","at":1791841374088,"time":534.033136,"error":null},{"type":"playing","at":1791841375418,"time":534.1,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:42:59.483Z","type":"server","label":"periodic","elapsed":675.73,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:43:04.495Z","type":"video","viewer":1,"elapsed":680.74,"video":{"time":540.885038,"frames":26947,"dropped":14349,"audio_bytes":17495618,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":7.063920999999937},"events":[{"type":"waiting","at":1791841378876,"time":537.46,"error":null},{"type":"playing","at":1791841379842,"time":537.46,"error":null},{"type":"waiting","at":1791841383340,"time":540.885038,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:43:09.503Z","type":"video","viewer":1,"elapsed":685.75,"video":{"time":544.627503,"frames":27135,"dropped":14454,"audio_bytes":17617228,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.321455999999898},"events":[{"type":"playing","at":1791841384590,"time":540.96,"error":null},{"type":"waiting","at":1791841388054,"time":544.36106,"error":null},{"type":"playing","at":1791841389231,"time":544.42,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:43:14.506Z","type":"video","viewer":1,"elapsed":690.75,"video":{"time":548.611166,"frames":27325,"dropped":14562,"audio_bytes":17745464,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.33779299999992},"events":[{"type":"waiting","at":1791841392588,"time":547.714094,"error":null},{"type":"playing","at":1791841393605,"time":547.78,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:43:19.523Z","type":"video","viewer":1,"elapsed":695.77,"video":{"time":552.688662,"frames":27525,"dropped":14677,"audio_bytes":17877181,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.260296999999923},"events":[{"type":"waiting","at":1791841397105,"time":551.210963,"error":null},{"type":"playing","at":1791841398042,"time":551.28,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:43:24.531Z","type":"video","viewer":1,"elapsed":700.78,"video":{"time":556.797302,"frames":27727,"dropped":14789,"audio_bytes":18009956,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.151657},"events":[{"type":"waiting","at":1791841401387,"time":554.553438,"error":null},{"type":"playing","at":1791841402283,"time":554.62,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:43:29.534Z","type":"video","viewer":1,"elapsed":705.78,"video":{"time":560.961055,"frames":27927,"dropped":14897,"audio_bytes":18145264,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.987903999999958},"events":[{"type":"waiting","at":1791841405772,"time":558.03895,"error":null},{"type":"playing","at":1791841406596,"time":558.1,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:43:31.109Z","type":"server","label":"periodic","elapsed":707.36,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:43:36.117Z","type":"video","viewer":1,"elapsed":712.37,"video":{"time":565.622609,"frames":28181,"dropped":15054,"audio_bytes":18295549,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.326349999999934},"events":[{"type":"waiting","at":1791841410220,"time":561.645025,"error":null},{"type":"playing","at":1791841411044,"time":561.72,"error":null},{"type":"waiting","at":1791841414404,"time":565.010027,"error":null},{"type":"playing","at":1791841415506,"time":565.08,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:43:41.129Z","type":"video","viewer":1,"elapsed":717.38,"video":{"time":569.294514,"frames":28357,"dropped":15162,"audio_bytes":18414145,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.654444999999896},"events":[{"type":"waiting","at":1791841418968,"time":568.475324,"error":null},{"type":"playing","at":1791841420313,"time":568.540624,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:43:46.133Z","type":"video","viewer":1,"elapsed":722.38,"video":{"time":573.099871,"frames":28543,"dropped":15274,"audio_bytes":18536961,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.849087999999938},"events":[{"type":"waiting","at":1791841423547,"time":571.713056,"error":null},{"type":"playing","at":1791841424735,"time":571.78,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:43:51.148Z","type":"video","viewer":1,"elapsed":727.4,"video":{"time":576.854734,"frames":28729,"dropped":15383,"audio_bytes":18658393,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.094224999999938},"events":[{"type":"waiting","at":1791841428102,"time":575.06813,"error":null},{"type":"playing","at":1791841429359,"time":575.14,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:43:56.161Z","type":"video","viewer":1,"elapsed":732.41,"video":{"time":580.868427,"frames":28931,"dropped":15493,"audio_bytes":18788005,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.080531999999948},"events":[{"type":"waiting","at":1791841432853,"time":578.55822,"error":null},{"type":"playing","at":1791841433843,"time":578.62,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:44:01.165Z","type":"video","viewer":1,"elapsed":737.41,"video":{"time":585.11228,"frames":29131,"dropped":15601,"audio_bytes":18925144,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.83667899999989},"events":[{"type":"waiting","at":1791841437452,"time":582.158892,"error":null},{"type":"playing","at":1791841438210,"time":582.22,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:44:02.996Z","type":"server","label":"periodic","elapsed":739.25,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:44:08.007Z","type":"video","viewer":1,"elapsed":744.26,"video":{"time":590.212757,"frames":29409,"dropped":15758,"audio_bytes":19090119,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.736201999999935},"events":[{"type":"waiting","at":1791841441702,"time":585.648036,"error":null},{"type":"playing","at":1791841442628,"time":585.72,"error":null},{"type":"waiting","at":1791841446335,"time":589.361082,"error":null},{"type":"playing","at":1791841447161,"time":589.44,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:44:13.025Z","type":"video","viewer":1,"elapsed":749.27,"video":{"time":594.352261,"frames":29611,"dropped":15869,"audio_bytes":19224007,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.596697999999947},"events":[{"type":"waiting","at":1791841450734,"time":592.940141,"error":null},{"type":"playing","at":1791841451615,"time":593.02,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:44:18.032Z","type":"video","viewer":1,"elapsed":754.28,"video":{"time":598.658589,"frames":29823,"dropped":15981,"audio_bytes":19363264,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.290369999999939},"events":[{"type":"waiting","at":1791841455118,"time":596.444206,"error":null},{"type":"playing","at":1791841455880,"time":596.526265,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:44:23.036Z","type":"video","viewer":1,"elapsed":759.28,"video":{"time":602.902815,"frames":30027,"dropped":16081,"audio_bytes":19501141,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.046143999999913},"events":[{"type":"waiting","at":1791841459417,"time":600.044057,"error":null},{"type":"playing","at":1791841460176,"time":600.12,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:44:28.051Z","type":"video","viewer":1,"elapsed":764.3,"video":{"time":607.238232,"frames":30251,"dropped":16185,"audio_bytes":19640482,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.710726999999906},"events":[{"type":"waiting","at":1791841464019,"time":603.886091,"error":null},{"type":"playing","at":1791841464701,"time":603.96,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:44:33.055Z","type":"video","viewer":1,"elapsed":769.3,"video":{"time":611.437131,"frames":30463,"dropped":16305,"audio_bytes":19776372,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.511827999999923},"events":[{"type":"waiting","at":1791841468899,"time":608.085956,"error":null},{"type":"playing","at":1791841469600,"time":608.16,"error":null},{"type":"waiting","at":1791841472951,"time":611.437131,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:44:35.240Z","type":"server","label":"periodic","elapsed":771.49,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:44:40.244Z","type":"video","viewer":1,"elapsed":776.49,"video":{"time":617.309705,"frames":30751,"dropped":16459,"audio_bytes":19966740,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.639253999999937},"events":[{"type":"playing","at":1791841473440,"time":611.5,"error":null},{"type":"waiting","at":1791841477185,"time":615.170083,"error":null},{"type":"playing","at":1791841478103,"time":615.24,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:44:45.250Z","type":"video","viewer":1,"elapsed":781.5,"video":{"time":621.555732,"frames":30955,"dropped":16566,"audio_bytes":20103478,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":4.393226999999911},"events":[{"type":"waiting","at":1791841481334,"time":618.401121,"error":null},{"type":"playing","at":1791841482077,"time":618.46,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:44:50.267Z","type":"video","viewer":1,"elapsed":786.52,"video":{"time":625.47378,"frames":31171,"dropped":16689,"audio_bytes":20230203,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.475178999999912},"events":[{"type":"waiting","at":1791841485682,"time":621.988271,"error":null},{"type":"playing","at":1791841486467,"time":622.06,"error":null},{"type":"waiting","at":1791841489948,"time":625.47378,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:44:55.275Z","type":"video","viewer":1,"elapsed":791.52,"video":{"time":629.461725,"frames":31375,"dropped":16798,"audio_bytes":20359936,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":8.487233999999944},"events":[{"type":"playing","at":1791841490487,"time":625.54,"error":null},{"type":"waiting","at":1791841493966,"time":628.953012,"error":null},{"type":"playing","at":1791841494771,"time":629.02,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:00.286Z","type":"video","viewer":1,"elapsed":796.54,"video":{"time":633.552826,"frames":31573,"dropped":16908,"audio_bytes":20491667,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.396132999999963},"events":[{"type":"waiting","at":1791841498132,"time":632.317417,"error":null},{"type":"playing","at":1791841499042,"time":632.38,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:05.299Z","type":"video","viewer":1,"elapsed":801.55,"video":{"time":637.625671,"frames":31767,"dropped":17015,"audio_bytes":20623331,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.323287999999934},"events":[{"type":"waiting","at":1791841502533,"time":635.800068,"error":null},{"type":"playing","at":1791841503468,"time":635.86,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:06.996Z","type":"server","label":"periodic","elapsed":803.25,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:45:12.008Z","type":"video","viewer":1,"elapsed":808.26,"video":{"time":643.361029,"frames":32053,"dropped":17173,"audio_bytes":20808845,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":6.587929999999915},"events":[{"type":"waiting","at":1791841507067,"time":639.39303,"error":null},{"type":"playing","at":1791841507939,"time":639.46,"error":null},{"type":"waiting","at":1791841511915,"time":643.361029,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:17.018Z","type":"video","viewer":1,"elapsed":813.27,"video":{"time":647.844899,"frames":32287,"dropped":17268,"audio_bytes":20953537,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.10405999999989},"events":[{"type":"playing","at":1791841512552,"time":643.44,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:22.022Z","type":"video","viewer":1,"elapsed":818.27,"video":{"time":651.788695,"frames":32487,"dropped":17385,"audio_bytes":21081183,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.160263999999984},"events":[{"type":"waiting","at":1791841519381,"time":650.209014,"error":null},{"type":"playing","at":1791841520450,"time":650.28,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:27.026Z","type":"video","viewer":1,"elapsed":823.27,"video":{"time":656.192507,"frames":32697,"dropped":17494,"audio_bytes":21223174,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.7564519999999675},"events":[{"type":"waiting","at":1791841523797,"time":653.563203,"error":null},{"type":"playing","at":1791841524404,"time":653.64,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:32.043Z","type":"video","viewer":1,"elapsed":828.29,"video":{"time":660.528392,"frames":32919,"dropped":17563,"audio_bytes":21363216,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.420566999999892},"events":[{"type":"waiting","at":1791841527952,"time":657.1,"error":null},{"type":"playing","at":1791841528536,"time":657.1,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:37.054Z","type":"video","viewer":1,"elapsed":833.3,"video":{"time":664.880523,"frames":33129,"dropped":17675,"audio_bytes":21503870,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.068435999999906},"events":[{"type":"waiting","at":1791841533947,"time":662.434991,"error":null},{"type":"playing","at":1791841534594,"time":662.5,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:38.756Z","type":"server","label":"periodic","elapsed":835.01,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.160.155","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVN 24 HD","ref":"1:0:1:3DD2:640:13E:820000:0:0:0:"},{"ip":"192.168.160.155","name":"TVP 2 HD","ref":"1:0:1:C22:1E78:71:820000:0:0:0:"},{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":2}}
{"utc":"2026-10-12T21:45:43.776Z","type":"video","viewer":1,"elapsed":840.02,"video":{"time":670.861778,"frames":33433,"dropped":17793,"audio_bytes":21697976,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.087180999999987},"events":[{"type":"waiting","at":1791841538082,"time":665.908189,"error":null},{"type":"playing","at":1791841538831,"time":665.98,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:48.783Z","type":"video","viewer":1,"elapsed":845.03,"video":{"time":674.789059,"frames":33625,"dropped":17904,"audio_bytes":21824550,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.159899999999993},"events":[{"type":"waiting","at":1791841545664,"time":672.751264,"error":null},{"type":"playing","at":1791841546742,"time":672.82,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:53.789Z","type":"video","viewer":1,"elapsed":850.04,"video":{"time":678.855878,"frames":33825,"dropped":18016,"audio_bytes":21955861,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.093080999999984},"events":[{"type":"waiting","at":1791841550229,"time":676.235935,"error":null},{"type":"playing","at":1791841551168,"time":676.3,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:45:58.801Z","type":"video","viewer":1,"elapsed":855.05,"video":{"time":682.907447,"frames":34021,"dropped":18128,"audio_bytes":22087461,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.041511999999898},"events":[{"type":"waiting","at":1791841554647,"time":679.714094,"error":null},{"type":"playing","at":1791841555609,"time":679.78,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:46:03.816Z","type":"video","viewer":1,"elapsed":860.06,"video":{"time":686.561972,"frames":34215,"dropped":18246,"audio_bytes":22205409,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":8.386986999999976},"events":[{"type":"waiting","at":1791841558979,"time":683.085032,"error":null},{"type":"playing","at":1791841559921,"time":683.187586,"error":null},{"type":"waiting","at":1791841563296,"time":686.561972,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:46:08.824Z","type":"video","viewer":1,"elapsed":865.07,"video":{"time":690.130441,"frames":34411,"dropped":18365,"audio_bytes":22320407,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.818517999999926},"events":[{"type":"playing","at":1791841564325,"time":686.64,"error":null},{"type":"waiting","at":1791841567795,"time":690.042977,"error":null},{"type":"playing","at":1791841568752,"time":690.12,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:46:10.496Z","type":"server","label":"periodic","elapsed":866.75,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T21:46:15.501Z","type":"video","viewer":1,"elapsed":871.75,"video":{"time":695.84838,"frames":34675,"dropped":18501,"audio_bytes":22505335,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":5.100578999999925},"events":[{"type":"waiting","at":1791841572213,"time":693.520071,"error":null},{"type":"playing","at":1791841573160,"time":693.58,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:46:20.513Z","type":"video","viewer":1,"elapsed":876.76,"video":{"time":700.000421,"frames":34879,"dropped":18613,"audio_bytes":22639147,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.948537999999985},"events":[{"type":"waiting","at":1791841576778,"time":697.124958,"error":null},{"type":"playing","at":1791841577646,"time":697.2,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:46:25.517Z","type":"video","viewer":1,"elapsed":881.77,"video":{"time":704.085308,"frames":35083,"dropped":18728,"audio_bytes":22771525,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":5.863650999999891},"events":[{"type":"waiting","at":1791841581112,"time":700.599052,"error":null},{"type":"playing","at":1791841581876,"time":700.66,"error":null},{"type":"waiting","at":1791841585378,"time":704.085308,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:46:30.521Z","type":"video","viewer":1,"elapsed":886.77,"video":{"time":707.686635,"frames":35281,"dropped":18847,"audio_bytes":22888056,"width":1920,"height":1080,"ready":2,"paused":false,"ended":false,"error":null,"buffered_ahead":8.262323999999921},"events":[{"type":"playing","at":1791841586281,"time":704.16,"error":null},{"type":"waiting","at":1791841589878,"time":707.686635,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:46:35.535Z","type":"video","viewer":1,"elapsed":891.78,"video":{"time":711.721714,"frames":35487,"dropped":18958,"audio_bytes":23018640,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":7.227244999999925},"events":[{"type":"playing","at":1791841590732,"time":707.76,"error":null},{"type":"waiting","at":1791841594210,"time":711.158051,"error":null},{"type":"playing","at":1791841594965,"time":711.22,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:46:40.543Z","type":"video","viewer":1,"elapsed":896.79,"video":{"time":715.529804,"frames":35677,"dropped":19069,"audio_bytes":23141887,"width":1920,"height":1080,"ready":4,"paused":false,"ended":false,"error":null,"buffered_ahead":6.419154999999932},"events":[{"type":"waiting","at":1791841598579,"time":714.765096,"error":null},{"type":"playing","at":1791841599781,"time":714.84,"error":null}],"route":"#/video"}
{"utc":"2026-10-12T21:46:42.152Z","type":"server","label":"periodic","elapsed":898.4,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":1,"Consumers":1,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":true,"Consumers":1},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":"TVP 1 HD","transcoding":{"VideoCodec":"av1","AudioCodec":"aac","IsVideoDirect":false,"IsAudioDirect":false,"Framerate":50,"TranscodeReasons":["ContainerNotSupported","AudioCodecNotSupported","InterlacedVideoNotSupported"]}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":1,"rows":[{"ip":"192.168.100.191","name":"TVP 1 HD","ref":"1:0:1:3ABD:514:13E:820000:0:0:0:"}],"channel_matches":1}}
{"utc":"2026-10-12T21:46:48.892Z","type":"check","name":"viewer 1 exit to Channels","status":"PASS","evidence":{"elapsed":905.14}}
{"utc":"2026-10-12T21:46:48.893Z","type":"check","name":"scheduled duration completed","status":"PASS","evidence":{"seconds":903.41,"interrupted":false}}
{"utc":"2026-10-12T21:46:48.894Z","type":"check","name":"second viewer phase completed","status":"PASS","evidence":{"joinAt":300,"leaveAt":600}}
{"utc":"2026-10-12T21:47:10.224Z","type":"server","label":"after-cleanup","elapsed":926.47,"plugin":"7.0.6.0","streams":{"UpstreamConnectionsOpened":18,"ActiveSources":0,"Consumers":0,"LastErrors":["Decoder closed the live stream.","Decoder closed the live stream.","The operation was canceled."],"Channels":[{"Id":"91bf9c0a4fd04dbd9923edcabe61f66a","ServiceReference":"/1:0:1:428D:2BC0:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"dfc8c635289f419987efa5a6e24526e4","ServiceReference":"/1:0:1:3ABD:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e5b639b9e6e64157acf5213d49cb6a42","ServiceReference":"/1:0:1:C22:1E78:71:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"ce7d07ce0afb421ba784df8342cfde51","ServiceReference":"/1:0:1:3DCD:640:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"e98c970f44a34da5a989debd43b6954f","ServiceReference":"/1:0:1:3A99:514:13E:820000:0:0:0:","Active":false,"Consumers":0},{"Id":"f02288443b7140dcbf34d74d660eb6ae","ServiceReference":"/1:0:1:C23:1E78:71:820000:0:0:0:","Active":false,"Consumers":0}]},"probe_error":null,"sessions":[{"user":"decoder-reception-0703d0b0","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}},{"user":"decoder-reception-f2fe3bd2","channel":null,"transcoding":{"VideoCodec":null,"AudioCodec":null,"IsVideoDirect":null,"IsAudioDirect":null,"Framerate":null,"TranscodeReasons":null}}],"receiver":{"status":200,"jellyfin_channel_rows":0,"rows":[],"channel_matches":0}}
{"utc":"2026-10-12T21:47:10.225Z","type":"check","name":"own relay consumers released after last viewer","status":"PASS","evidence":{"relay_id":"dfc8c635289f419987efa5a6e24526e4","active":false,"consumers":0,"baseline_active":false,"baseline_consumers":0}}
{"utc":"2026-10-12T21:47:13.005Z","type":"check","name":"viewer 1 continuous decoded progress","status":"PASS","evidence":{"samples":167,"progressing_intervals":166,"stalled_intervals":0,"audio_progress_intervals":166}}
{"utc":"2026-10-12T21:47:13.005Z","type":"check","name":"viewer 1 browser and HLS responses","status":"PASS","evidence":{"browser_errors":[],"hls":{"manifests":306,"segments":0,"failed":[],"request_failed":[]}}}
{"utc":"2026-10-12T21:47:13.006Z","type":"check","name":"viewer 1 audio decoding progresses","status":"PASS","evidence":{"progressing_intervals":166}}
{"utc":"2026-10-12T21:47:13.006Z","type":"check","name":"viewer 2 continuous decoded progress","status":"PASS","evidence":{"samples":54,"progressing_intervals":53,"stalled_intervals":0,"audio_progress_intervals":53}}
{"utc":"2026-10-12T21:47:13.007Z","type":"check","name":"viewer 2 browser and HLS responses","status":"PASS","evidence":{"browser_errors":[],"hls":{"manifests":100,"segments":0,"failed":[],"request_failed":[]}}}
{"utc":"2026-10-12T21:47:13.007Z","type":"check","name":"viewer 2 audio decoding progresses","status":"PASS","evidence":{"progressing_intervals":53}}
{"utc":"2026-10-12T21:47:13.008Z","type":"check","name":"sharing uses one physical receiver stream","status":"PASS","evidence":{"before_opened":18,"during":[{"elapsed":316.07,"opened":18,"physical_rows":1,"consumers":2},{"elapsed":322.69,"opened":18,"physical_rows":1,"consumers":2},{"elapsed":354.39,"opened":18,"physical_rows":1,"consumers":2},{"elapsed":386.28,"opened":18,"physical_rows":1,"consumers":2},{"elapsed":418.01,"opened":18,"physical_rows":1,"consumers":2},{"elapsed":449.83,"opened":18,"physical_rows":1,"consumers":2},{"elapsed":481.83,"opened":18,"physical_rows":1,"consumers":2},{"elapsed":513.62,"opened":18,"physical_rows":1,"consumers":2},{"elapsed":545.28,"opened":18,"physical_rows":1,"consumers":2},{"elapsed":577.04,"opened":18,"physical_rows":1,"consumers":2}]}}
{"utc":"2026-10-12T21:47:13.009Z","type":"check","name":"global receiver upstream counter stable during sharing","status":"PASS","evidence":{"before":18,"during":[18,18,18,18,18,18,18,18,18,18],"scope":"Global plugin counter; correlate a failure with other users and channel snapshots."}}
-44
View File
@@ -1,44 +0,0 @@
// Isolated headless browser; temporary server account is always removed.
const {execFileSync}=require('node:child_process');
const {chromium}=require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const ssh=py=>execFileSync('ssh',['-o','BatchMode=yes','root@192.168.100.25','pct exec 100191 -- python3 -'],{input:py,encoding:'utf8'}).trim().split('\n').at(-1);
const setup=`import sqlite3,json,secrets,urllib.request
c=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()
def api(p,d):
r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"','Content-Type':'application/json'},data=json.dumps(d).encode());b=urllib.request.urlopen(r).read();return json.loads(b) if b else None
n='decoder-browser-check-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});api('/Users/'+u['Id']+'/Password',{'CurrentPw':'','NewPw':p});print(json.dumps({'id':u['Id'],'name':n,'password':p}))
`;
(async()=>{
const user=JSON.parse(ssh(setup));let browser;
try{
browser=await chromium.launch({headless:true,channel:'msedge'});const page=await browser.newPage({viewport:{width:1280,height:900}});
const errors=[];page.on('console',m=>{if(m.type()==='error'&&m.text().includes('Decoder'))console.log(m.text());});page.on('pageerror',e=>errors.push(e.message));
const base='http://192.168.100.191:8096/web/index.html';
await page.goto(base);await page.locator('#txtManualName').fill(user.name);await page.locator('#txtManualPassword').fill(user.password);await page.getByRole('checkbox').check();await page.getByRole('button',{name:'Sign In',exact:true}).click();
await page.waitForURL(/home/,{timeout:20000});
await page.waitForFunction(()=>JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers?.some(s=>s.AccessToken&&s.UserId));
await page.goto('http://192.168.100.191:8096/DecoderIntegration/Channels');
try { await page.locator('#channelRows section').first().waitFor({timeout:30000}); }
catch(error) { console.log(JSON.stringify({url:page.url(),text:await page.locator('body').innerText(),errors})); await page.screenshot({path:process.argv[2]||'decoder-channels.png'});throw error; }
const total=await page.locator('#channelRows section').count();if(total!==216)throw Error('Expected all 216 channels, got '+total);
await page.getByRole('button',{name:'☆ Ulubiony',exact:true}).first().click();
await page.locator('#channelView').selectOption('favorites');if(await page.locator('#channelRows section').count()!==1)throw Error('Favorites filter');
await page.getByRole('button',{name:'Ukryj',exact:true}).click();if(await page.locator('#channelRows section').count()!==0)throw Error('Hidden filter');
await page.locator('#channelView').selectOption('hidden');await page.getByRole('button',{name:'Pokaż',exact:true}).click();
await page.locator('#channelView').selectOption('all');await page.getByRole('button',{name:'Ustaw ten widok jako domyślny'}).click();
await page.locator('#channelView').selectOption('guide');await page.locator('#channelRows section div p').first().waitFor({timeout:60000});
await page.screenshot({path:process.argv[2]||'decoder-channels.png'});
await page.locator('#channelView').selectOption('all');
const row=page.locator('#channelRows section').filter({has:page.getByRole('button',{name:/^\d+\. TVP INFO HD$/})});
const playback=page.waitForResponse(r=>r.url().includes('/PlaybackInfo')&&r.request().method()==='POST',{timeout:60000});
await row.locator('button').first().click();
const response=await playback;if(!response.ok())throw Error('Native playback request failed: '+response.status());
await page.waitForFunction(()=>[...document.querySelectorAll('video')].some(v=>v.currentTime>0&&v.readyState>=2),{},{timeout:60000});
await page.screenshot({path:(process.argv[2]||'decoder-channels.png').replace('.png','-playing.png')});
if(errors.length)throw Error(errors.join('; '));
console.log(JSON.stringify({channel_rows:total,favorites:'PASS',hidden:'PASS',user_page:'PASS',one_click_live_playback:'PASS',browser_errors:errors}));
}finally{
if(browser)await browser.close();
ssh(`import sqlite3,urllib.request,pathlib\nc=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()\nurllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Users/${user.id}',headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'},method='DELETE')).read()\npathlib.Path('/var/lib/jellyfin/decoder-integration/users/${user.id.replace(/-/g,'')}.json').unlink(missing_ok=True)\nprint('clean')`);
}
})().catch(e=>{console.error(e.message);process.exitCode=1});
-181
View File
@@ -1,181 +0,0 @@
// Real Jellyfin Web E2E. All credentials remain in memory; only isolated test users are changed.
const fs = require('node:fs');
const { execFileSync } = require('node:child_process');
const { chromium } = require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const origin = 'http://192.168.100.191:8096', web = origin + '/web/index.html';
const ssh = code => JSON.parse(execFileSync('ssh', ['-o', 'BatchMode=yes', 'root@192.168.100.25', 'pct exec 100191 -- python3 -'], { input: code, encoding: 'utf8', timeout: 30000 }).trim().split('\n').at(-1));
const admin = `import json,sqlite3,urllib.request,pathlib,secrets
c=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys LIMIT 1').fetchone()[0];c.close()
def api(p,d=None,method=None):
r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="E2E", Device="Maintenance", DeviceId="decoder-e2e", Version="1"','Content-Type':'application/json'},data=json.dumps(d).encode() if d is not None else None,method=method)
b=urllib.request.urlopen(r,timeout=20).read();return json.loads(b) if b else None
`;
const controlOnly = process.argv.includes('--control-only');
const configOnly=process.argv.includes('--config-only');
const report = { checked_utc: new Date().toISOString(), mode: process.argv.includes('--ui-only') || configOnly ? 'ui' : process.argv.includes('--playback-only') || controlOnly ? 'playback' : 'full', checks: [] };
const assert = (condition, message) => { if (!condition) throw Error(message); };
async function check(name, action) {
try { const evidence = await action(); report.checks.push({ name, status: 'PASS', evidence }); }
catch (e) { report.checks.push({ name, status: 'FAIL', error: e.message.replace(/Token="[^"]+"/g, 'Token="<redacted>"').slice(0, 350) }); }
console.log(name + ': ' + report.checks.at(-1).status);
return report.checks.at(-1).status==='PASS';
}
const accounts = []; let browser; const pages = [], browserErrors = [];
async function api(page, path, method='GET', data) {
return page.evaluate(async ({path,method,data}) => {
const s = JSON.parse(localStorage.getItem('jellyfin_credentials')).Servers.find(s=>s.AccessToken&&s.UserId);
const r = await fetch(path,{method,headers:{Authorization:'MediaBrowser Token="'+s.AccessToken+'", Client="DecoderE2E", Device="Browser", DeviceId="decoder-e2e", Version="1"','Content-Type':'application/json'},body:data===undefined?undefined:JSON.stringify(data)});
const text = await r.text(); return { status:r.status, body:text?JSON.parse(text):null };
}, {path,method,data});
}
async function custom(page) { await page.goto(origin+'/DecoderIntegration/Channels'); await page.locator('#channelRows section').first().waitFor({timeout:30000}); }
async function native(page) { await page.goto(web+'#/livetv'); await page.waitForURL(/tab=2/,{timeout:15000}); await page.locator('.card[data-type="TvChannel"]').first().waitFor({timeout:15000}); }
async function exit(page, escape=false) {
if(await page.locator('#decoderPlaybackExit').count()) {
if(escape)await page.keyboard.press('Escape'); else await page.locator('#decoderPlaybackExit').click();
}
await page.waitForURL(/livetv.*tab=2/,{timeout:30000});
await page.locator('.card[data-type="TvChannel"]').first().waitFor({timeout:15000});
assert(await page.locator('#decoderPlaybackExit').count()===0,'Playback control remains after stopping');
}
async function start(page, name, double=false) {
await native(page);
if(name==='4Fun.TV') {
await custom(page);await page.getByRole('button',{name:/^\d+\. 4Fun\.TV$/}).click({clickCount:double?2:1});
await page.locator('#decoderPlaybackExit').waitFor({timeout:15000});return;
}
const image=page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:new RegExp('^\\d+ '+name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'$')})}).locator('a.cardImageContainer');
await image.click({position:{x:20,y:20},clickCount:double?2:1});
await page.locator('#decoderPlaybackExit').waitFor({timeout:10000});
}
async function frames(page) {
await page.waitForFunction(()=>[...document.querySelectorAll('video')].some(v=>v.currentTime>0&&v.readyState>=2&&v.videoWidth>0),{}, {timeout:20000});
const first=await page.locator('video').first().evaluate(v=>({time:v.currentTime,frames:v.getVideoPlaybackQuality?.().totalVideoFrames||v.webkitDecodedFrameCount||0,width:v.videoWidth}));
await page.waitForTimeout(3000);
const last=await page.locator('video').first().evaluate(v=>({time:v.currentTime,frames:v.getVideoPlaybackQuality?.().totalVideoFrames||v.webkitDecodedFrameCount||0,width:v.videoWidth}));
assert(last.time>first.time && last.frames>first.frames,'Video clock or decoded frame counter did not advance');
return { time_progress:Number((last.time-first.time).toFixed(2)), decoded_frames:last.frames-first.frames,width:last.width };
}
(async()=>{
try {
browser=await chromium.launch({headless:true,channel:'msedge'});
for(let i=0;i<2;i++) {
const user=ssh(admin+`n='decoder-e2e-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});print(json.dumps({'id':u['Id'],'name':n,'password':p}))`);
accounts.push(user); ssh(admin+`api('/Users/${user.id}/Password',{'CurrentPw':'','NewPw':${JSON.stringify(user.password)}});print('{}')`);
const context=await browser.newContext({viewport:{width:1280,height:900}}),page=await context.newPage();pages.push(page);
page.on('pageerror',e=>browserErrors.push(e.message));
await page.goto(web);await page.locator('#txtManualName').fill(user.name);await page.locator('#txtManualPassword').fill(user.password);await page.getByRole('checkbox').check();await page.getByRole('button',{name:'Sign In',exact:true}).click();await page.waitForURL(/home/,{timeout:20000});
await page.waitForFunction(()=>JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers?.some(s=>s.AccessToken&&s.UserId));
}
const [page,other]=pages;
report.plugin=ssh(admin+`print(json.dumps(next(x for x in api('/Plugins') if x['Name']=='Enigma2')))`);
await check('two isolated users can sign in',async()=>({users:pages.length,plugin:report.plugin.Version}));
if(report.mode!=='playback') {
if(!configOnly) {
await check('anonymous page requires login',async()=>{
const p=await (await browser.newContext()).newPage();await p.goto(origin+'/DecoderIntegration/Channels');assert(await p.locator('#channelRows section').count()===0,'Anonymous channels exposed');assert(await p.getByRole('link',{name:'Przejdź do logowania Jellyfin'}).count()===1,'No login link');await p.context().close();
});
let all;
await check('complete channel list and logos',async()=>{
await custom(page);all=(await api(page,'/LiveTv/Channels?Limit=1000')).body.Items;
assert(await page.locator('#channelRows section').count()===all.length,'Incomplete custom channel list');assert(all.length===216,'Unexpected channel count');
await page.waitForFunction(()=>[...document.querySelectorAll('#channelRows img')].some(i=>i.complete&&i.naturalWidth>0));return {channels:all.length,loaded_logos:await page.locator('#channelRows img').evaluateAll(xs=>xs.filter(i=>i.naturalWidth>0).length)};
});
await check('search filters and restores channels',async()=>{
await page.locator('#channelSearch').fill('TVP 1 HD');assert(await page.locator('#channelRows section').count()===1,'Search not exact for TVP1');await page.locator('#channelSearch').fill('');assert(await page.locator('#channelRows section').count()===all.length,'Clear search failed');
});
await check('favorites persist and synchronize natively',async()=>{
const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'☆ Ulubiony',exact:true}).first().click();assert((await response).ok(),'Preference save failed');
await page.waitForTimeout(500);await custom(page);await page.locator('#channelView').selectOption('favorites');assert(await page.locator('#channelRows section').count()===1,'Favorite not persisted');
const native=(await api(page,'/LiveTv/Channels?Limit=1000&EnableUserData=true&UserId='+accounts[0].id)).body.Items;assert(native.some(c=>c.UserData?.IsFavorite),'Native favorite missing');
});
await check('hide applies to native channels and is isolated per user',async()=>{
const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'Ukryj',exact:true}).click();await response;
assert(await page.locator('#channelRows section').count()===0,'Favorite not hidden');
const mine=(await api(page,'/LiveTv/Channels?Limit=0&UserId='+accounts[0].id)).body, theirs=(await api(other,'/LiveTv/Channels?Limit=0&UserId='+accounts[1].id)).body;
assert(mine.TotalRecordCount===215 && theirs.TotalRecordCount===216,'Hide leaked across users');return {mine:mine.TotalRecordCount,other:theirs.TotalRecordCount};
});
await check('restore hidden channel',async()=>{
await page.locator('#channelView').selectOption('hidden');const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'Pokaż',exact:true}).click();await response;await page.locator('#channelView').selectOption('all');assert(await page.locator('#channelRows section').count()===216,'Channel not restored');
});
await check('custom order persists in native API',async()=>{
const second=all[1].Id.replace(/-/g,'');const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.locator('#channelRows section').nth(1).getByRole('button',{name:'↑',exact:true}).click();await response;
assert((await api(page,'/LiveTv/Channels?Limit=1&UserId='+accounts[0].id)).body.Items[0].Id.replace(/-/g,'')===second,'Native order not synchronized');
});
await check('EPG renders actual programmes',async()=>{
await page.locator('#channelView').selectOption('guide');await page.locator('#channelRows section div p').first().waitFor({timeout:45000});const n=await page.locator('#channelRows section div p').count();assert(n>0,'Empty EPG');return {programmes:n};
});
await check('EPG image links exist and load',async()=>{
const images=page.locator('#channelRows section div img');const n=await images.count();assert(n>0,'No programme artwork in current EPG');await images.first().scrollIntoViewIfNeeded();await images.first().evaluate(i=>i.decode());return {images:n};
});
await check('default preference persists after reload',async()=>{
const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'Ustaw ten widok jako domyślny'}).click();await response;await custom(page);assert(await page.locator('#channelView').inputValue()==='guide','Default guide preference not persisted');
});
await check('native default Channels and explicit Guide',async()=>{
await native(page);await page.goto(web+'#/livetv?tab=1');await page.waitForTimeout(1000);assert(page.url().endsWith('tab=1'),'Explicit guide tab overwritten');
});
await check('ordinary user cannot read admin diagnostics',async()=>assert((await api(page,'/DecoderIntegration/Status')).status===403,'Admin endpoint exposed'));
await check('external relay access denied',async()=>assert((await page.request.get(origin+'/DecoderIntegration/Live/00000000000000000000000000000000')).status()===403,'Relay exposed outside loopback'));
await check('Vu recording provider disabled; timers remain readable',async()=>{
const channels=(await api(page,'/Channels')).body.Items,timers=(await api(page,'/LiveTv/Timers?Limit=0')).body;assert(channels.length===0,'Recording provider still registered');return {timers:timers.TotalRecordCount};
});
}
await check('administrator plugin configuration loads current receiver settings',async()=>{
const user=ssh(admin+`n='decoder-e2e-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});print(json.dumps({'id':u['Id'],'name':n,'password':p}))`);accounts.push(user);
ssh(admin+`api('/Users/${user.id}/Password',{'CurrentPw':'','NewPw':${JSON.stringify(user.password)}});u=api('/Users/${user.id}');p=u['Policy'];p['IsAdministrator']=True;api('/Users/${user.id}/Policy',p);print('{}')`);
// Set administrator permissions before this account's first authentication.
const config=await (await browser.newContext()).newPage();await config.goto(web);await config.locator('#txtManualName').fill(user.name);await config.locator('#txtManualPassword').fill(user.password);await config.getByRole('checkbox').check();await config.getByRole('button',{name:'Sign In',exact:true}).click();await config.waitForURL(/home/,{timeout:20000});
await config.waitForFunction(()=>JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers?.some(s=>s.AccessToken&&s.UserId));
await config.locator('#txtManualPassword:visible').waitFor({state:'hidden',timeout:10000});
await config.waitForTimeout(1000);
await config.evaluate(()=>window.location.hash='#/configurationpage?name=enigma2');
try {await config.locator('#txtHostName').waitFor({timeout:20000});}
catch {throw Error(JSON.stringify(await config.evaluate(async()=>({route:location.hash,admin:(await window.ApiClient.getCurrentUser()).Policy.IsAdministrator,headings:[...document.querySelectorAll('h1,h2')].map(e=>e.textContent).slice(0,5),input_ids:[...document.querySelectorAll('input')].map(i=>i.id).slice(0,20)}))));}
await config.waitForFunction(()=>document.querySelector('#txtHostName')?.value==='192.168.10.20');assert(await config.locator('#txtStreamingPort').inputValue()==='8001','Unexpected configured source port');
assert(!await config.locator('#chkEnableVuRecordings').isChecked(),'Vu recordings unexpectedly enabled');await config.context().close();
// This check does not submit the form or alter the plugin configuration.
});
}
if(report.mode!=='ui') {
let controlVideo=false;
for(const name of controlOnly?['4Fun.TV']:['4Fun.TV','TVP 1 HD','TVP 2 HD','TVN HD']) {
let started=false;
const ok=await check(name+' '+(name==='4Fun.TV'?'custom':'native')+' one-click decoded video',async()=>{await start(page,name);started=true;return await frames(page);});
if(name==='4Fun.TV')controlVideo=ok;
await check(name+' stop or recover to Channels',async()=>{if(started)await exit(page,name==='TVP 2 HD');else await native(page);});
}
if(controlVideo) {
await check('native channel card opens actual video and native OSD',async()=>{
const channel=(await api(page,'/LiveTv/Channels?Limit=1000')).body.Items.find(c=>c.Name==='4Fun.TV');
const prefs=(await api(page,'/DecoderIntegration/Preferences')).body;prefs.OrderedIds=[channel.Id];assert((await api(page,'/DecoderIntegration/Preferences','PUT',prefs)).status===204,'Test account order save failed');
await native(page);await page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:/^\d+ 4Fun\.TV$/})}).locator('a.cardImageContainer').click({position:{x:20,y:20}});
const r=await frames(page);assert(page.url().includes('/video'),'Native video OSD did not open');await exit(page,true);return r;
});
}
await check('custom channel button starts decoded video',async()=>{await custom(page);await page.getByRole('button',{name:controlOnly?/^\d+\. 4Fun\.TV$/:/^\d+\. TVN HD$/}).click();await page.locator('#decoderPlaybackExit').waitFor({timeout:15000});return await frames(page);});
await check('custom playback exit',async()=>exit(page,true));
let shared=false;
if(controlVideo) shared=await check('double-click and two browsers share one physical upstream',async()=>{
await start(page,'4Fun.TV',true);await frames(page);await start(other,'4Fun.TV');await frames(other);
const s=ssh(admin+`print(json.dumps(api('/DecoderIntegration/Status')['Streams']))`);
const channel=s.Channels.find(c=>c.ServiceReference.includes(':428D:2BC0:13E:'));assert(channel?.Active&&channel.Consumers>=2,'Two browser consumers not present');
const owif=await page.request.get('http://192.168.10.20/api/about');const body=await owif.json();const streams=body.info?.streams||body.streams||[];assert(streams.filter(s=>/4fun.tv/i.test(JSON.stringify(s))).length===1,'Decoder does not report exactly one 4Fun.TV stream');return {consumers:channel.Consumers,physical_connections:1};
});
else report.checks.push({name:'double-click and two browsers share one physical upstream',status:'BLOCKED',error:'No working control video'});
if(shared)await check('stopping one viewer preserves other viewer',async()=>{await exit(page);const r=await frames(other);await exit(other,true);return r;});
else report.checks.push({name:'stopping one viewer preserves other viewer',status:'BLOCKED',error:'Two active viewers were not established'});
for(const p of pages)if(await p.locator('#decoderPlaybackExit').count())await exit(p);
await check('last viewer releases source',async()=>{
await page.waitForTimeout(17000);const s=ssh(admin+`print(json.dumps(api('/DecoderIntegration/Status')['Streams']))`);assert(s.Consumers===0&&s.ActiveSources===0,'Diagnostic sources remain active');return {active:s.ActiveSources,consumers:s.Consumers};
});
}
await check('no uncaught browser errors',async()=>assert(browserErrors.length===0,'Uncaught browser errors: '+browserErrors.join('; ')));
} catch(e) { report.checks.push({name:'suite prerequisite',status:'FAIL',error:e.message.slice(0,350)}); }
finally {
if(browser)await browser.close();
for(const u of accounts)try { ssh(admin+`api('/Users/${u.id}',method='DELETE');pathlib.Path('/var/lib/jellyfin/decoder-integration/users/${u.id.replace(/-/g,'')}.json').unlink(missing_ok=True);print('{}')`); } catch(e){report.checks.push({name:'test account cleanup',status:'FAIL',error:'Failed to remove '+u.name});}
report.finished_utc=new Date().toISOString();report.pass=report.checks.filter(c=>c.status==='PASS').length;report.fail=report.checks.filter(c=>c.status==='FAIL').length;report.blocked=report.checks.filter(c=>c.status==='BLOCKED').length;
const path=process.argv.find(a=>a.startsWith('--report='))?.slice(9);if(path)fs.writeFileSync(path,JSON.stringify(report,null,2)+'\n');
console.log(JSON.stringify(report));process.exitCode=report.fail?1:0;
}
})();
-33
View File
@@ -1,33 +0,0 @@
// Isolated headless browser; temporary server account is always removed.
const {execFileSync}=require('node:child_process');
const {chromium}=require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const ssh=py=>execFileSync('ssh',['-o','BatchMode=yes','root@192.168.100.25','pct exec 100191 -- python3 -'],{input:py,encoding:'utf8'}).trim().split('\n').at(-1);
const setup=`import sqlite3,json,secrets,urllib.request
c=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()
def api(p,d):
r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"','Content-Type':'application/json'},data=json.dumps(d).encode());b=urllib.request.urlopen(r).read();return json.loads(b) if b else None
n='decoder-browser-check-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});api('/Users/'+u['Id']+'/Password',{'CurrentPw':'','NewPw':p});print(json.dumps({'id':u['Id'],'name':n,'password':p}))
`;
(async()=>{
const user=JSON.parse(ssh(setup));let browser;
try{
browser=await chromium.launch({headless:true,channel:'msedge'});const page=await browser.newPage({viewport:{width:1280,height:900}});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
const base='http://192.168.100.191:8096/web/index.html';
await page.goto(base);await page.locator('#txtManualName').fill(user.name);await page.locator('#txtManualPassword').fill(user.password);await page.getByRole('checkbox').check();await page.getByRole('button',{name:'Sign In',exact:true}).click();
await page.waitForURL(/home/,{timeout:20000});
await page.waitForFunction(()=>JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers?.some(s=>s.AccessToken&&s.UserId));
await page.goto('http://192.168.100.191:8096/DecoderIntegration/Channels');
try { await page.locator('#channelRows section').first().waitFor({timeout:30000}); }
catch(error) { console.log(JSON.stringify({url:page.url(),text:await page.locator('body').innerText(),errors})); await page.screenshot({path:process.argv[2]||'decoder-channels.png'});throw error; }
await page.goto(base+'#/livetv?tab=2');
const image=page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:'TVP INFO HD'})}).locator('a.cardImageContainer');
const playback=page.waitForResponse(r=>r.url().includes('/PlaybackInfo')&&r.request().method()==='POST',{timeout:60000});playback.catch(()=>{});
await image.click({position:{x:20,y:20}}).catch(async e=>{console.log(JSON.stringify({image_count:await image.count(),error:e.message.slice(0,600)}));throw e;});const r=await playback;if(!r.ok())throw Error('Native channel playback failed');
await page.waitForFunction(()=>[...document.querySelectorAll('video')].some(v=>v.currentTime>0&&v.readyState>=2),{},{timeout:60000});
console.log(JSON.stringify({native_channel_image_one_click_playback:'PASS'}));
}finally{
if(browser)await browser.close();
ssh(`import sqlite3,urllib.request,pathlib\nc=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()\nurllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Users/${user.id}',headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'},method='DELETE')).read()\npathlib.Path('/var/lib/jellyfin/decoder-integration/users/${user.id.replace(/-/g,'')}.json').unlink(missing_ok=True)\nprint('clean')`);
}
})().catch(e=>{console.error(e.message);process.exitCode=1});
-205
View File
@@ -1,205 +0,0 @@
// Coordinated production trial. No tuning, restart, recording or configuration writes.
// Invoke only AFTER the receiver task explicitly announces its test window.
const fs = require('node:fs');
const { execFileSync } = require('node:child_process');
const { performance } = require('node:perf_hooks');
const { chromium } = require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const option = (name, fallback) => process.argv.find(a => a.startsWith('--' + name + '='))?.split('=').slice(1).join('=') ?? fallback;
const duration = Number(option('duration', '900')), joinAt = Number(option('join', '300')), leaveAt = Number(option('leave', '600'));
const channel = option('channel', 'TVP 1 HD');
const reportPath = option('report', 'reception-trial.json');
const bridgeFile = option('bridge-file', null);
const frameFile = option('frame', null);
if (!process.argv.includes('--coordinated') || !Number.isFinite(duration) || !Number.isFinite(joinAt) || !Number.isFinite(leaveAt) || !(0 < joinAt && joinAt < leaveAt && leaveAt < duration)) {
console.error('Requires --coordinated after explicit start signal, and 0 < join < leave < duration (seconds).');
process.exit(2);
}
const origin = 'http://192.168.100.191:8096', web = origin + '/web/index.html';
const safe = value => String(value).replace(/(api_key|access_token|Token|password)([=:\s]+)[^\s,"&]+/gi, '$1$2<redacted>').replace(/(https?:\/\/[^\s?"']+)\?[^\s"']*/g, '$1?<redacted>').slice(0, 500);
const report = { started_utc: new Date().toISOString(), channel, relay_id: null, isolated_bridge_override: Boolean(bridgeFile), schedule_seconds: { duration, join: joinAt, leave: leaveAt }, checks: [], events: [], snapshots: [], audio_scope: 'Decoded byte counter when available; no human audible verification.' };
fs.writeFileSync(reportPath + '.jsonl', '');
const record = (type, data) => { const row = { utc: new Date().toISOString(), type, ...data }; fs.appendFileSync(reportPath + '.jsonl', JSON.stringify(row) + '\n'); return row; };
const check = (name, ok, evidence) => { const row = { name, status: ok ? 'PASS' : 'FAIL', evidence }; report.checks.push(row); console.log(name + ': ' + row.status); record('check', row); };
const admin = `import json,sqlite3,urllib.request,pathlib,secrets\nc=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys LIMIT 1').fetchone()[0];c.close()\ndef api(p,d=None,method=None):\n r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="ReceptionTrial", Device="Maintenance", DeviceId="decoder-reception-trial", Version="1"','Content-Type':'application/json'},data=json.dumps(d).encode() if d is not None else None,method=method)\n b=urllib.request.urlopen(r,timeout=10).read();return json.loads(b) if b else None\n`;
const ssh = code => JSON.parse(execFileSync('ssh', ['-o', 'BatchMode=yes', 'root@192.168.100.25', 'pct exec 100191 -- python3 -'], { input: admin + code, encoding: 'utf8', timeout: 20000 }).trim().split('\n').at(-1));
let browser, started, serviceReference, baseline, testedChannelId, stopRequested = false;
process.on('SIGINT', () => { stopRequested = true; });
process.on('SIGTERM', () => { stopRequested = true; });
const accounts = [], viewers = [];
function elapsed() { return started === undefined ? null : Number(((performance.now() - started) / 1000).toFixed(2)); }
async function makeViewer(index) {
const account = ssh(`n='decoder-reception-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});print(json.dumps({'id':u['Id'],'name':n,'password':p}))`);
accounts.push(account);
ssh(`api('/Users/${account.id}/Password',{'CurrentPw':'','NewPw':${JSON.stringify(account.password)}});print('{}')`);
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }), page = await context.newPage();
if (bridgeFile) {
// A/B evaluation only: replace the plugin JS in this isolated browser, never on the server.
await context.route('**/decoder-channel-playback.js*', route => {
record('isolated_bridge_served', { viewer: index });
return route.fulfill({ contentType: 'application/javascript', body: fs.readFileSync(bridgeFile, 'utf8') });
});
await context.route('**/DecoderIntegration/WebPlaybackPolicy?*', route => {
const requested = new URL(route.request().url()).searchParams.get('channelId')?.replace(/-/g, '').toLowerCase();
const matched = requested === testedChannelId.replace(/-/g, '').toLowerCase();
record('isolated_policy_request', { viewer: index, matched });
return route.fulfill({ json: { IsEnigmaChannel: matched, PreferH264: true } });
});
}
const viewer = { index, context, page, active: false, closing: false, samples: [], errors: [], hls: { manifests: 0, segments: 0, failed: [], request_failed: [] } }; viewers.push(viewer);
await page.addInitScript(() => {
window.__trialEvents = [];
for (const type of ['waiting','stalled','error','ended','playing','pause']) document.addEventListener(type, event => {
if (event.target instanceof HTMLVideoElement) window.__trialEvents.push({ type, at: Date.now(), time: event.target.currentTime, error: event.target.error?.code || null });
}, true);
});
page.on('pageerror', error => { const value = { elapsed: elapsed(), message: safe(error.message) }; viewer.errors.push(value); record('browser_error', { viewer: index, ...value }); });
page.on('response', response => {
const path = new URL(response.url()).pathname;
if (!/\.(m3u8|ts|m4s)$/.test(path)) return;
if (path.endsWith('.m3u8')) viewer.hls.manifests++; else viewer.hls.segments++;
if (response.status() >= 400) { const value = { elapsed: elapsed(), path, status: response.status() }; viewer.hls.failed.push(value); record('hls_error', { viewer: index, ...value }); }
});
page.on('requestfailed', request => {
const path = new URL(request.url()).pathname;
if (/\.(m3u8|ts|m4s)$/.test(path)) {
const value = { elapsed: elapsed(), path, message: safe(request.failure()?.errorText), during_close: viewer.closing };
if (!viewer.closing) viewer.hls.request_failed.push(value);
record('hls_request_failed', { viewer: index, ...value });
}
});
await page.goto(web);
await page.locator('#txtManualName').fill(account.name); await page.locator('#txtManualPassword').fill(account.password);
await page.getByRole('checkbox').check(); await page.getByRole('button', { name: 'Sign In', exact: true }).click();
await page.waitForURL(/home/, { timeout: 20000 });
await page.waitForFunction(() => JSON.parse(localStorage.getItem('jellyfin_credentials') || '{}').Servers?.some(s => s.AccessToken && s.UserId));
await page.locator('#txtManualPassword:visible').waitFor({ state: 'hidden', timeout: 10000 });
await page.waitForTimeout(1000);
return viewer;
}
async function open(viewer) {
const began = performance.now();
await viewer.page.goto(web + '#/livetv');
await viewer.page.waitForURL(/tab=2/, { timeout: 15000 });
const escaped = channel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
await viewer.page.locator('.card[data-type="TvChannel"]').filter({ has: viewer.page.locator('a.textActionButton', { hasText: new RegExp('^\\d+ ' + escaped + '$') }) }).locator('a.cardImageContainer').click({ position: { x: 20, y: 20 } });
viewer.active = true;
await viewer.page.waitForFunction(() => [...document.querySelectorAll('video')].some(v => v.currentTime > 0 && v.readyState >= 2 && v.videoWidth > 0 && !v.paused), {}, { timeout: 30000 });
viewer.startup_seconds = Number(((performance.now() - began) / 1000).toFixed(2));
check('viewer ' + viewer.index + ' one-click decoded start', true, { seconds: viewer.startup_seconds });
}
async function sample(viewer) {
const data = await viewer.page.evaluate(() => {
const videos = [...document.querySelectorAll('video')];
const video = videos.find(v => v.currentTime > 0 && v.videoWidth > 0 && !v.paused) || videos.find(v => v.currentTime > 0 && v.videoWidth > 0);
const events = window.__trialEvents.splice(0);
if (!video) return { video: null, events, route: location.hash };
const quality = video.getVideoPlaybackQuality?.();
const range = [...Array(video.buffered.length).keys()].find(i => video.buffered.start(i) <= video.currentTime && video.buffered.end(i) >= video.currentTime);
return { video: { time: video.currentTime, frames: quality?.totalVideoFrames ?? video.webkitDecodedFrameCount ?? null, dropped: quality?.droppedVideoFrames ?? video.webkitDroppedFrameCount ?? null, audio_bytes: video.webkitAudioDecodedByteCount ?? null, width: video.videoWidth, height: video.videoHeight, ready: video.readyState, paused: video.paused, ended: video.ended, error: video.error?.code || null, buffered_ahead: range === undefined ? 0 : video.buffered.end(range) - video.currentTime }, events, route: location.hash };
});
const row = record('video', { viewer: viewer.index, elapsed: elapsed(), ...data }); viewer.samples.push(row); return row;
}
async function closeViewer(viewer) {
if (!viewer.active) return;
viewer.closing = true;
let normal = false;
try {
if (await viewer.page.locator('#decoderPlaybackExit').count()) await viewer.page.locator('#decoderPlaybackExit').click();
await viewer.page.waitForURL(/livetv.*tab=2/, { timeout: 15000 });
normal = true;
} catch (error) { record('exit_error', { viewer: viewer.index, message: safe(error.message) }); }
try { await viewer.context.close(); } finally { viewer.active = false; }
check('viewer ' + viewer.index + ' exit to Channels', normal, { elapsed: elapsed() });
}
async function serverSnapshot(label) {
const server = ssh(`s=api('/DecoderIntegration/Status');t=api('/Sessions');print(json.dumps({'plugin':next(x['Version'] for x in api('/Plugins') if x['Name']=='Enigma2'),'streams':s.get('Streams',{}),'probe_error':s.get('ProbeError'),'sessions':[{'user':x.get('UserName'),'channel':(x.get('NowPlayingItem') or {}).get('Name'),'transcoding':{k:(x.get('TranscodingInfo') or {}).get(k) for k in ['VideoCodec','AudioCodec','IsVideoDirect','IsAudioDirect','Framerate','TranscodeReasons']}} for x in t if x.get('UserName','').startswith('decoder-reception-')]}))`);
server.streams.LastErrors = (server.streams.LastErrors || []).map(safe);
if (server.probe_error) server.probe_error = safe(server.probe_error);
// The receiver endpoint only reports existing clients; it does not open a TV source.
let receiver;
try {
const response = await fetch('http://192.168.10.20/api/about', { signal: AbortSignal.timeout(8000) });
const body = await response.json();
const rows = body.info?.streams || body.streams || [];
receiver = { status: response.status, jellyfin_channel_rows: rows.filter(row => row.ip === '192.168.100.191' && row.name === channel).length, rows: rows.map(row => ({ ip: row.ip, name: row.name, ref: row.ref })) };
// OpenWebif versions differ in field names. Preserve only non-sensitive stream fields.
receiver.channel_matches = rows.filter(row => JSON.stringify(row).replace(/ /g, '').toLowerCase().includes(channel.replace(/ /g, '').toLowerCase())).length;
} catch (error) { receiver = { error: safe(error.message) }; }
const row = record('server', { label, elapsed: elapsed(), ...server, receiver }); report.snapshots.push(row); return row;
}
function identifyRelay(snapshot) {
const reference = snapshot.receiver.rows?.find(row => row.ip === '192.168.100.191' && row.name === channel)?.ref;
const normalize = text => decodeURIComponent(text || '').replace(/^\//, '').toUpperCase();
const exact = reference && snapshot.streams.Channels?.find(c => normalize(c.ServiceReference) === normalize(reference));
const added = snapshot.streams.Channels?.filter(c => !baseline?.streams.Channels?.some(b => b.Id === c.Id)) || [];
const candidate = exact || (added.length === 1 ? added[0] : null);
if (candidate) { report.relay_id = candidate.Id; serviceReference = candidate.ServiceReference; report.service_reference = serviceReference; }
}
(async () => {
try {
browser = await chromium.launch({ headless: true, channel: 'msedge', args: ['--autoplay-policy=no-user-gesture-required','--disable-background-timer-throttling','--disable-backgrounding-occluded-windows','--disable-renderer-backgrounding'] });
testedChannelId = ssh(`c=next(x for x in api('/LiveTv/Channels?Limit=1000')['Items'] if x['Name']==${JSON.stringify(channel)});print(json.dumps(c['Id']))`);
const primary = await makeViewer(1), secondary = await makeViewer(2);
baseline = await serverSnapshot('before');
await open(primary); started = performance.now(); report.first_video_utc = new Date().toISOString();
const first = await serverSnapshot('primary-started');
identifyRelay(first);
if (frameFile) {
await primary.page.waitForTimeout(3000);
const handle = await primary.page.evaluateHandle(() => [...document.querySelectorAll('video')].find(v => v.currentTime > 0 && v.videoWidth > 0 && !v.paused));
const video = handle.asElement();
if (!video) throw Error('No active video for visual verification');
await video.screenshot({ path: frameFile }); await handle.dispose();
report.frame_artifact = frameFile; record('frame_saved', { path: frameFile, elapsed: elapsed() });
}
let joined = false, left = false, nextServer = 30, nextProgress = 60;
while (!stopRequested && elapsed() < duration) {
if (!joined && elapsed() >= joinAt) { joined = true; report.secondary_request_seconds = elapsed(); await open(secondary); report.secondary_video_seconds = elapsed(); await serverSnapshot('secondary-joined'); }
if (joined && !left && elapsed() >= leaveAt) { left = true; report.secondary_stop_seconds = elapsed(); await closeViewer(secondary); await serverSnapshot('secondary-left'); }
for (const viewer of viewers.filter(v => v.active)) await sample(viewer);
if (elapsed() >= nextServer) { await serverSnapshot('periodic'); nextServer = elapsed() + 30; }
if (elapsed() >= nextProgress) { console.log(JSON.stringify({ elapsed: elapsed(), viewers: viewers.filter(v => v.active).map(v => ({ viewer: v.index, ...v.samples.at(-1)?.video })) })); nextProgress = elapsed() + 60; }
await new Promise(resolve => setTimeout(resolve, 5000));
}
report.playback_elapsed_seconds = elapsed();
report.primary_stop_seconds = elapsed();
await closeViewer(primary);
check('scheduled duration completed', !stopRequested && report.playback_elapsed_seconds >= duration, { seconds: report.playback_elapsed_seconds, interrupted: stopRequested });
check('second viewer phase completed', joined && left, { joinAt, leaveAt });
} catch (error) { check('trial execution', false, { elapsed: elapsed(), error: safe(error.message) }); }
finally {
for (const viewer of viewers) try { await closeViewer(viewer); } catch (error) { check('viewer cleanup', false, { viewer: viewer.index, error: safe(error.message) }); }
// Obtain final metrics before closing the browser API context; never close other users' sessions.
if (browser && viewers.length) {
await new Promise(resolve => setTimeout(resolve, 20000));
try {
const after = await serverSnapshot('after-cleanup'); if (!report.relay_id) identifyRelay(after);
const own = after.streams.Channels?.find(c => c.Id === report.relay_id), before = baseline?.streams.Channels?.find(c => c.Id === report.relay_id);
const released = before?.Active ? own?.Consumers <= before.Consumers : !own || !own.Active && own.Consumers === 0;
check('own relay consumers released after last viewer', Boolean(report.relay_id) && released, { relay_id: report.relay_id, active: own?.Active ?? false, consumers: own?.Consumers ?? 0, baseline_active: before?.Active ?? false, baseline_consumers: before?.Consumers ?? 0 });
} catch (error) { check('relay cleanup verification', false, { error: safe(error.message) }); }
}
if (browser) try { await browser.close(); } catch (error) { check('browser cleanup', false, { error: safe(error.message) }); }
for (const account of accounts) try { ssh(`api('/Users/${account.id}',method='DELETE');pathlib.Path('/var/lib/jellyfin/decoder-integration/users/${account.id.replace(/-/g,'')}.json').unlink(missing_ok=True);print('{}')`); } catch (error) { check('test account cleanup', false, { error: safe(error.message) }); }
for (const viewer of viewers) {
let stalls = 0, audioIntervals = 0, frameIntervals = 0, previous;
for (const row of viewer.samples) {
if (!row.video) { stalls++; previous = null; continue; }
if (previous) { if (row.video.time <= previous.time || row.video.frames !== null && previous.frames !== null && row.video.frames <= previous.frames) stalls++; else frameIntervals++; if (row.video.audio_bytes !== null && previous.audio_bytes !== null && row.video.audio_bytes > previous.audio_bytes) audioIntervals++; }
previous = row.video;
}
check('viewer ' + viewer.index + ' continuous decoded progress', stalls === 0 && frameIntervals > 0, { samples: viewer.samples.length, progressing_intervals: frameIntervals, stalled_intervals: stalls, audio_progress_intervals: audioIntervals });
check('viewer ' + viewer.index + ' browser and HLS responses', viewer.errors.length === 0 && viewer.hls.failed.length === 0 && viewer.hls.request_failed.length === 0, { browser_errors: viewer.errors, hls: viewer.hls });
const available = viewer.samples.some(row => row.video?.audio_bytes !== null && row.video?.audio_bytes !== undefined);
if (available) check('viewer ' + viewer.index + ' audio decoding progresses', audioIntervals > 0, { progressing_intervals: audioIntervals });
else { const row = { name: 'viewer ' + viewer.index + ' audio decoding progresses', status: 'UNVERIFIED', evidence: 'Browser does not expose decoded audio byte counter.' }; report.checks.push(row); record('check', row); }
}
const during = report.snapshots.filter(s => s.label === 'secondary-joined' || s.label === 'periodic' && s.elapsed >= joinAt && s.elapsed <= leaveAt);
const beforeJoin = report.snapshots.filter(s => s.elapsed !== null && s.elapsed < joinAt).at(-1);
check('sharing uses one physical receiver stream', during.length > 0 && beforeJoin && beforeJoin.receiver.jellyfin_channel_rows === 1 && during.every(s => s.receiver.jellyfin_channel_rows === 1 && s.streams.Channels?.some(c => c.Id === report.relay_id && c.Active)), { before_opened: beforeJoin?.streams.UpstreamConnectionsOpened, during: during.map(s => ({ elapsed: s.elapsed, opened: s.streams.UpstreamConnectionsOpened, physical_rows: s.receiver.jellyfin_channel_rows, consumers: s.streams.Channels?.find(c => c.Id === report.relay_id)?.Consumers })) });
check('global receiver upstream counter stable during sharing', during.length > 0 && beforeJoin && during.every(s => s.streams.UpstreamConnectionsOpened === beforeJoin.streams.UpstreamConnectionsOpened), { before: beforeJoin?.streams.UpstreamConnectionsOpened, during: during.map(s => s.streams.UpstreamConnectionsOpened), scope: 'Global plugin counter; correlate a failure with other users and channel snapshots.' });
report.finished_utc = new Date().toISOString(); report.pass = report.checks.filter(c => c.status === 'PASS').length; report.fail = report.checks.filter(c => c.status === 'FAIL').length; report.unverified = report.checks.filter(c => c.status === 'UNVERIFIED').length;
report.viewers = viewers.map(v => ({ viewer: v.index, startup_seconds: v.startup_seconds, samples: v.samples, errors: v.errors, hls: v.hls }));
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); console.log(JSON.stringify({ report: reportPath, pass: report.pass, fail: report.fail, finished_utc: report.finished_utc })); process.exitCode = report.fail ? 1 : 0;
}
})();
-126
View File
@@ -1,126 +0,0 @@
// Regression checks for channel navigation, single start, controls and cancellation.
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const listeners = {}, documentListeners = {}, elements = [];
let clock = 0, interval, starts = 0, stops = 0, osd = 0, returned = 0, readyState = 0, remote = false, currentTime = 0, paused = false;
let startupTimeoutSeconds;
let phase = 'checking_stream', progressCalls = 0, languageQueries = [], progressDeferred;
const catalogue = JSON.parse(fs.readFileSync(__dirname + '/Jellyfin.Plugin.Enigma2/Localization/startup-messages.json', 'utf8'));
const video = { get readyState() { return readyState; }, get currentTime() { return currentTime; }, get paused() { return paused; }, currentSrc: 'stream' };
const playerListeners = {};
let policy = { IsEnigmaChannel: true, PreferH264: true }, policyCalls = 0, policyError = false;
const profile = { DirectPlayProfiles: [{ Type: 'Video', VideoCodec: 'av1,h264' }],
TranscodingProfiles: [{ Type: 'Video', Context: 'Streaming', VideoCodec: 'av1,h264', AudioCodec: 'aac', Protocol: 'hls', MaxWidth: 1920 },
{ Type: 'Video', Context: 'Streaming', VideoCodec: 'av1', AudioCodec: 'aac', Protocol: 'hls' },
{ Type: 'Audio', AudioCodec: 'aac', Protocol: 'http' }], CodecProfiles: [{ Type: 'Video', Codec: 'h264', Conditions: [{ Property: 'Width', Value: '1920' }] }] };
const pending = [];
const location = { hash: '#/livetv', pathname: '/web/index.html', search: '', replace(value) { this.hash = value; } };
const document = {
fullscreenElement: null, documentElement: { lang: "de-DE" },
body: { append(element) { elements.push(element); } },
createElement() { return { style: {}, children: [], attributes: {}, setAttribute(key, value) { this.attributes[key] = value; },
append(...children) { this.children.push(...children); }, remove() { const i = elements.indexOf(this); if (i >= 0) elements.splice(i, 1); } }; },
querySelector() { return video; },
addEventListener(name, callback) { documentListeners[name] = callback; }
};
const window = {
ApiClient: { getCurrentUserId: () => 'user', serverId: () => 'server', getItem: async (user, id) => ({ Id: id, Type: 'TvChannel' }),
getUrl: (path, params) => path + '?' + new URLSearchParams(params),
ajax: async options => {
if (options.url.startsWith('DecoderIntegration/StartupMessages')) {
const language = new URLSearchParams(options.url.split('?')[1]).get('language'); languageQueries.push(language);
return { Language: language.split('-')[0], Texts: catalogue[language.split('-')[0]], StartupTimeoutSeconds: startupTimeoutSeconds };
}
if (options.url.startsWith('DecoderIntegration/PlaybackProgress')) {
++progressCalls; if (progressDeferred) return progressDeferred;
return { Available: true, Phase: phase, StartedUtc: new Date(clock).toISOString() };
}
++policyCalls; if (policyError) throw Error('offline'); return policy;
} },
addEventListener(name, callback) { listeners[name] = callback; }
};
const context = {
window, document, location, console, URLSearchParams,
history: { replaceState(a, b, url) { location.hash = url.slice(url.indexOf('#')); } },
Date: { now: () => clock, parse: Date.parse }, setTimeout, clearTimeout,
setInterval(callback) { interval = callback; return callback; },
clearInterval(callback) { if (interval === callback) interval = null; }
};
const manager = {
getCurrentPlayer: () => ({ isLocalPlayer: !remote }),
play() { ++starts; playerListeners.playbackstart?.(); return new Promise(resolve => pending.push(resolve)); },
stop: async () => { ++stops; }
};
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
(async () => {
vm.runInNewContext(fs.readFileSync(__dirname + '/web/decoder-channel-playback.js', 'utf8'), context);
const Plugin = await window.DecoderChannelPlayback();
const appHost = { getDeviceProfile: async () => profile };
const dependencies = { playbackManager: manager, appHost, toast() {}, loading: { hide() {} }, events: { on(target, name, callback) { playerListeners[name] = callback; } },
appRouter: { showVideoOsd: async () => { ++osd; }, show: async () => { ++returned; location.hash = '#/livetv?tab=2'; } } };
new Plugin(dependencies);
const channel = { Id: 'channel', Type: 'TvChannel', ServerId: 'server' };
const before = JSON.stringify(profile);
const changed = await appHost.getDeviceProfile(channel, {});
assert.notEqual(changed, profile); assert.equal(JSON.stringify(profile), before, 'shared profile must not mutate');
assert.equal(changed.TranscodingProfiles.length, 2); assert.equal(changed.TranscodingProfiles[0].VideoCodec, 'h264');
assert.equal(changed.TranscodingProfiles[0].MaxWidth, 1920); assert.equal(changed.TranscodingProfiles[0].AudioCodec, 'aac');
assert.equal(JSON.stringify(changed.DirectPlayProfiles), JSON.stringify(profile.DirectPlayProfiles));
assert.equal(JSON.stringify(changed.CodecProfiles), JSON.stringify(profile.CodecProfiles));
let count = policyCalls;
assert.equal(await appHost.getDeviceProfile({ Type: 'Movie' }, {}), profile); assert.equal(policyCalls, count);
assert.equal(await appHost.getDeviceProfile({ ...channel, ServerId: 'other' }, {}), profile); assert.equal(policyCalls, count);
remote = true; assert.equal(await appHost.getDeviceProfile(channel, {}), profile); assert.equal(policyCalls, count); remote = false;
window.NativeShell = {}; assert.equal(await appHost.getDeviceProfile(channel, {}), profile); assert.equal(policyCalls, count); delete window.NativeShell;
policy = { IsEnigmaChannel: false, PreferH264: true }; assert.equal(await appHost.getDeviceProfile(channel, {}), profile);
policy = { IsEnigmaChannel: true, PreferH264: false }; assert.equal(await appHost.getDeviceProfile(channel, {}), profile);
policyError = true; assert.equal(await appHost.getDeviceProfile(channel, {}), profile); policyError = false;
policy = { IsEnigmaChannel: true, PreferH264: true };
count = policyCalls; await appHost.getDeviceProfile(channel, {}); assert.equal(policyCalls, count + 1, 'retry refreshes current policy');
const wrapped = appHost.getDeviceProfile; new Plugin(dependencies); assert.equal(appHost.getDeviceProfile, wrapped, 'one profile wrapper per appHost');
console.log('PASS scoped H264 policy, clone, audio/conditions/direct play preserved, movies/other sources/native/remote bypass, disabled/failing policy, retry and dedup');
assert.equal(location.hash, '#/livetv?tab=2');
location.hash = '#/livetv?tab=0'; listeners.hashchange();
assert.equal(location.hash, '#/livetv?tab=0');
const click = () => documentListeners.click({ button: 0, target: { closest: () => ({ closest: () => ({ dataset: { id: 'channel', serverid: 'server' } }) }) }, preventDefault() {}, stopImmediatePropagation() {} });
click(); click(); await flush();
assert.equal(starts, 1); assert.equal(elements[0].id, 'decoderPlaybackExit');
assert.equal(languageQueries.at(-1), 'de-DE', 'use chosen Jellyfin UI language, not browser metadata language');
assert.equal(elements[0].textContent, catalogue.de.cancel); assert.equal(elements[1].attributes.role, 'status');
interval(); await flush(); assert.equal(osd, 0, 'OSD must wait for actual video metadata');
assert.equal(progressCalls, 1); assert.equal(elements[1].children[2].textContent, catalogue.de.checking_stream);
phase = 'waiting_for_stream'; clock = 2000; interval(); await flush();
assert.equal(elements[1].children[2].textContent, catalogue.de.waiting_for_stream);
readyState=1;interval(); await flush(); assert.equal(osd, 1);
listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} });
await flush(); assert.equal(stops, 1); assert.equal(returned, 1); assert.equal(elements.length, 0);
readyState = 0; clock = 0; click(); await flush(); assert.equal(starts, 2);
pending[0](); await flush(); assert.equal(stops, 1, 'late old completion must not stop the new channel');
clock = 35000; interval(); await flush();
assert.equal(stops, 1, 'pending startup must survive 35 seconds');
assert.equal(returned, 1); assert.equal(elements.length, 2, 'status and cancel remain available during slow startup');
clock = 45001; interval(); await flush();
assert.equal(stops, 2); assert.equal(returned, 2); assert.equal(elements.length, 0);
pending[1](); await flush();
readyState = 0; clock = 50000; click(); await flush();
readyState = 2; currentTime = 1; interval(); await flush();
assert.equal(elements[1].style.display, 'none', 'first decoded frame hides waiting panel');
readyState = 1; currentTime = 2; clock = 500000; interval(); await flush();
assert.equal(stops, 2, 'a stream already playing must not be killed by the startup timeout');
assert.equal(elements[1].style.display, 'flex'); assert.equal(elements[1].children[2].textContent, catalogue.de.buffering);
paused = true; interval(); assert.equal(elements[1].style.display, 'none', 'pause is not a buffering failure');
paused = false;
listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} }); await flush();
assert.equal(elements.length, 0); pending[2](); await flush();
startupTimeoutSeconds = 135; clock = 0; currentTime = 0; readyState = 0;
const stoppedBefore = stops; click(); await flush();
clock = 90000; interval(); await flush();
assert.equal(stops, stoppedBefore, 'configured metadata budget must survive the old web deadline');
clock = 135001; interval(); await flush();
assert.equal(stops, stoppedBefore + 1, 'configured web startup remains bounded');
pending[3](); await flush();
console.log('PASS provider startup budget extends web waiting without disabling timeout or cancellation');
console.log('PASS per-device UI locale, real progress, first frame, later buffering and pause');
console.log('PASS default channels, explicit programmes, one start, native OSD, Escape, late completion and startup timeout');
})().catch(error => { console.error(error); process.exitCode = 1; });

Some files were not shown because too many files have changed in this diff Show More