diff --git a/Jellyfin.Plugin.Enigma2/BroadcastSlateProgram.cs b/Jellyfin.Plugin.Enigma2/BroadcastSlateProgram.cs
new file mode 100644
index 0000000..4a67819
--- /dev/null
+++ b/Jellyfin.Plugin.Enigma2/BroadcastSlateProgram.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using MediaBrowser.Model.Entities;
+
+namespace Jellyfin.Plugin.Enigma2;
+
+/// Declare silent audio and empty DVB subtitle tracks on status clips with stable transport PIDs.
+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) | (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));
+ 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)
+ {
+ 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, 7, 127, 4, 55,
+ 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 { 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 { 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();
+ 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();
+ 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();
+ }
+}
diff --git a/Jellyfin.Plugin.Enigma2/BroadcastTracks.cs b/Jellyfin.Plugin.Enigma2/BroadcastTracks.cs
new file mode 100644
index 0000000..4549dc2
--- /dev/null
+++ b/Jellyfin.Plugin.Enigma2/BroadcastTracks.cs
@@ -0,0 +1,109 @@
+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 int? TransportId { get; set; }
+ public bool IsVisualImpaired { get; set; }
+ public byte[] SubtitleExtraData { get; set; }
+}
+
+/// A stable, explicitly mapped program shared by native negotiation and FFmpeg.
+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 streams, bool normalize = true, bool preserveResolution = true)
+ {
+ Normalize = normalize;
+ 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";
+ if (normalize && copy.Type == MediaStreamType.Video)
+ {
+ copy.Codec = "h264"; copy.Width = preserveResolution ? s.Width : ContinuityStreams.Width;
+ copy.Height = preserveResolution ? s.Height : ContinuityStreams.Height; copy.Level = null;
+ copy.Profile = "Constrained Baseline"; copy.PixelFormat = "yuv420p"; copy.IsInterlaced = false;
+ copy.AverageFrameRate = copy.RealFrameRate = ContinuityStreams.FrameRate;
+ copy.ColorRange = copy.ColorSpace = copy.ColorTransfer = copy.ColorPrimaries = null; copy.BitRate = null;
+ }
+ if (normalize && copy.Type == MediaStreamType.Audio)
+ { copy.Codec = "aac"; copy.Channels = 2; copy.SampleRate = 48000; copy.BitRate = 128000; copy.Profile = "LC"; copy.ChannelLayout = "stereo"; }
+ 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,
+ Pid = (s as BroadcastMediaStream)?.TransportId, Extra = (s as BroadcastMediaStream)?.SubtitleExtraData
+ }))))).ToLowerInvariant();
+ }
+ public IEnumerable 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 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(); 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();
+ 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();
+ }
+ 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);
+}
diff --git a/Jellyfin.Plugin.Enigma2/ContinuityStreams.cs b/Jellyfin.Plugin.Enigma2/ContinuityStreams.cs
index 3243f72..937b085 100644
--- a/Jellyfin.Plugin.Enigma2/ContinuityStreams.cs
+++ b/Jellyfin.Plugin.Enigma2/ContinuityStreams.cs
@@ -53,42 +53,54 @@ public sealed class ContinuityStreams : IDisposable
public string Id = Guid.NewGuid().ToString("N");
public Source Source;
public Dictionary Slates;
+ public DvbSubtitleVersions SubtitleVersions;
public DateTime LastAccess = DateTime.UtcNow;
}
- public async Task Open(string relayUrl, string language, CancellationToken token)
+ public async Task Open(string relayUrl, string language, CancellationToken token, BroadcastTracks tracks = null, bool useSlates = true)
{
language = StartupMessages.ResolveLanguage(language, null, "en");
- var clips = await CachedSlates(language, token);
+ tracks ??= BroadcastTracks.Fallback(preserveResolution);
+ if (tracks.Normalize != useSlates) throw new ArgumentException("Track format does not match continuity mode");
+ var clips = useSlates ? await CachedSlates(language, token, tracks) : null;
+ 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(relayUrl, out var source))
+ if (!sources.TryGetValue(sourceKey, out var source))
{
if (sources.Count >= 4) throw new InvalidOperationException("Continuity channel limit reached");
- source = new(this, relayUrl); sources.Add(relayUrl, source);
+ source = new(this, relayUrl, sourceKey, tracks, useSlates); sources.Add(sourceKey, source);
}
- viewer = new() { Source = source, Slates = clips }; viewers.Add(viewer.Id, viewer);
+ viewer = new() { Source = source, Slates = clips, SubtitleVersions = new(tracks) }; viewers.Add(viewer.Id, viewer);
}
- 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 = new[] {
- new MediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", Profile = "Constrained Baseline", Level = preserveResolution ? null : 42,
- Width = preserveResolution ? null : Width, Height = preserveResolution ? null : Height, AverageFrameRate = FrameRate, RealFrameRate = FrameRate, IsInterlaced = false, BitRate = preserveResolution ? null : 12000000, PixelFormat = "yuv420p" },
- new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Channels = 2, SampleRate = 48000, BitRate = 128000, IsDefault = true }
- }
- };
+ try
+ {
+ if (!useSlates) await viewer.Source.Ready.Task.WaitAsync(TimeSpan.FromSeconds(35), 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> CachedSlates(string language, CancellationToken token)
+ private async Task> CachedSlates(string language, CancellationToken token, BroadcastTracks tracks = null)
{
- var cached = slates.GetOrAdd(language, lang => new(() => GenerateSlates(lang), LazyThreadSafetyMode.ExecutionAndPublication));
+ tracks ??= BroadcastTracks.Fallback(preserveResolution);
+ 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 clips;
try { clips = await cached.Value.WaitAsync(token); }
- catch { if (cached.IsValueCreated && cached.Value.IsFaulted) slates.TryRemove(new KeyValuePair>>>(language, cached)); throw; }
+ catch { if (cached.IsValueCreated && cached.Value.IsFaulted) slates.TryRemove(new KeyValuePair>>>(key, cached)); throw; }
return clips;
}
public async Task SlatePreview(string language, string phase, CancellationToken token)
@@ -103,7 +115,7 @@ public sealed class ContinuityStreams : IDisposable
{
if (!viewers.Remove(id, out var viewer)) return;
if (!viewers.Values.Any(v => ReferenceEquals(v.Source, viewer.Source)))
- { sources.Remove(viewer.Source.Url); viewer.Source.Stop(); }
+ { sources.Remove(viewer.Source.Key); viewer.Source.Stop(); }
}
}
public string Playlist(string id)
@@ -122,7 +134,9 @@ public sealed class ContinuityStreams : IDisposable
viewer.LastAccess = DateTime.UtcNow;
var segment = viewer.Source.Timeline.Find(sequence);
if (segment == null) return null;
- return TransportStreamClock.Rebase(segment.Slate is { } slate ? viewer.Slates[slate] : segment.Video, segment.Start90k);
+ var transport = TransportStreamClock.Rebase(segment.Slate is { } slate ? viewer.Slates[slate] : segment.Video, segment.Start90k, alignAudio: viewer.Source.UseSlates);
+ 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) }; }
@@ -136,7 +150,7 @@ public sealed class ContinuityStreams : IDisposable
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) source.Timeline.Tick(DateTime.UtcNow, source.Retrying);
+ foreach (var source in sources.Values) if (source.UseSlates) source.Timeline.Tick(DateTime.UtcNow, source.Retrying);
}
}
}
@@ -164,7 +178,7 @@ public sealed class ContinuityStreams : IDisposable
process.ErrorDataReceived += (_, _) => { }; process.OutputDataReceived += (_, _) => { };
process.BeginErrorReadLine(); process.BeginOutputReadLine(); return process;
}
- private async Task> GenerateSlates(string language)
+ private async Task> GenerateSlates(string language, BroadcastTracks tracks)
{
await slateGate.WaitAsync(lifetime.Token);
var folder = Path.Combine(root, "slates-" + language + "-" + Guid.NewGuid().ToString("N"));
@@ -180,13 +194,13 @@ public sealed class ContinuityStreams : IDisposable
var filter = $"drawbox=x=(iw-160)/2:y=260:w=160:h=100:color=0x58bfee:t=6,drawtext=fontfile={font}:text=TV:fontsize=42:fontcolor=0x58bfee:x=(w-tw)/2:y=285," +
$"drawtext=fontfile={font}:textfile=message.txt:fontsize=42:fontcolor=white:line_spacing=22:x=(w-tw)/2:y=490";
using var process = Start(new[] { "-f", "lavfi", "-i", $"color=c=0x101723:s={Width}x{Height}:r={FrameRate}", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-vf", filter,
- "-map", "0:v:0", "-map", "1:a:0", "-t", "6.72" }.Concat(CodecArguments(slate: true)).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-hls_list_size", "0", "-hls_segment_filename", "slate-%d.ts", "slate.m3u8" }), folder);
+ "-map", "0:v:0" }.Concat(Enumerable.Range(0, tracks.AudioCount).SelectMany(_ => new[] { "-map", "1:a:0" })).Concat(new[] { "-t", "6.72" }).Concat(CodecArguments(slate: true)).Concat(tracks.MetadataArguments(audioVideoOnly: true)).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-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] = await File.ReadAllBytesAsync(Path.Combine(folder, "slate-1.ts"), lifetime.Token);
+ 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;
@@ -198,11 +212,14 @@ public sealed class ContinuityStreams : IDisposable
private readonly ContinuityStreams owner;
private readonly CancellationTokenSource lifetime;
private readonly Task task;
- public readonly string Url;
- public ContinuityTimeline Timeline { get; } = new(DateTime.UtcNow);
+ 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)
- { this.owner = owner; Url = url; lifetime = CancellationTokenSource.CreateLinkedTokenSource(owner.lifetime.Token); task = Task.Run(Run); }
+ 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); lifetime = CancellationTokenSource.CreateLinkedTokenSource(owner.lifetime.Token); task = Task.Run(Run); }
public void Stop() { try { lifetime.Cancel(); } catch (ObjectDisposedException) { } }
private async Task Run()
{
@@ -216,10 +233,15 @@ public sealed class ContinuityStreams : IDisposable
try
{
Directory.CreateDirectory(folder);
- var args = new[] { "-rw_timeout", "10000000", "-analyzeduration", "5000000", "-probesize", "4000000", "-i", Url,
- "-map", "0:v:0", "-map", "0:a:0", "-sn", "-dn", "-vf",
- owner.preserveResolution ? "bwdif=mode=send_field:parity=auto:deint=interlaced" : $"bwdif=mode=send_field:parity=auto:deint=interlaced,scale=w={Width}:h={Height}:force_original_aspect_ratio=decrease,pad={Width}:{Height}:(ow-iw)/2:(oh-ih)/2,setsar=1" }
- .Concat(owner.CodecArguments()).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-hls_list_size", "6", "-hls_delete_threshold", "8", "-hls_flags", "delete_segments+temp_file+omit_endlist", "-hls_segment_filename", "segment-%09d.ts", "live.m3u8" });
+ var args = new[] { "-rw_timeout", "10000000", "-analyzeduration", "5000000", "-probesize", "4000000", "-i", Url }
+ .Concat(Tracks.MapArguments()).Concat(new[] { "-dn" });
+ if (UseSlates)
+ args = args.Concat(new[] { "-vf", owner.preserveResolution ? "bwdif=mode=send_field:parity=auto:deint=interlaced" : $"bwdif=mode=send_field:parity=auto:deint=interlaced,scale=w={Width}:h={Height}:force_original_aspect_ratio=decrease,pad={Width}:{Height}:(ow-iw)/2:(oh-ih)/2,setsar=1" })
+ .Concat(owner.CodecArguments()).Concat(new[] { "-c:s", "copy" });
+ else 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
@@ -240,7 +262,10 @@ public sealed class ContinuityStreams : IDisposable
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; lastOutput = DateTime.UtcNow; delivered = true; Retrying = false; failures = 0; Interlocked.Increment(ref owner.liveSegments);
+ 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 ? 12 : 35)) throw new IOException("No usable encoded segments");
diff --git a/Jellyfin.Plugin.Enigma2/ContinuityTimeline.cs b/Jellyfin.Plugin.Enigma2/ContinuityTimeline.cs
index ac7376b..57ab54e 100644
--- a/Jellyfin.Plugin.Enigma2/ContinuityTimeline.cs
+++ b/Jellyfin.Plugin.Enigma2/ContinuityTimeline.cs
@@ -18,7 +18,7 @@ public sealed class ContinuityTimeline
private long clock90k = 126000;
private string previousEpoch;
private DateTime lastAppend, lastLive;
- public ContinuityTimeline(DateTime now) { lastAppend = now; lastLive = DateTime.MinValue; Add(null, "waiting", "slate", SegmentSeconds, now); Add(null, "waiting", "slate", SegmentSeconds, now); }
+ public ContinuityTimeline(DateTime now, bool withSlates = true) { lastAppend = now; lastLive = DateTime.MinValue; if (!withSlates) return; Add(null, "waiting", "slate", SegmentSeconds, now); Add(null, "waiting", "slate", SegmentSeconds, now); }
private void Add(byte[] video, string slate, string epoch, double duration, DateTime now)
{
var discontinuity = next > 0 && (slate != null || previousEpoch != epoch);
@@ -30,7 +30,7 @@ public sealed class ContinuityTimeline
}
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 > 5) throw new ArgumentException("Invalid live segment");
+ 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; Add(video, null, epoch, duration, now); }
}
public void Tick(DateTime now, bool retrying)
@@ -45,7 +45,8 @@ public sealed class ContinuityTimeline
lock (gate)
{
var window = segments.TakeLast(4).ToArray();
- var text = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:5\n");
+ 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)
diff --git a/Jellyfin.Plugin.Enigma2/DvbSubtitleVersions.cs b/Jellyfin.Plugin.Enigma2/DvbSubtitleVersions.cs
new file mode 100644
index 0000000..5a57110
--- /dev/null
+++ b/Jellyfin.Plugin.Enigma2/DvbSubtitleVersions.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace Jellyfin.Plugin.Enigma2;
+
+/// Keep DVB page/region/object versions distinct across independently muxed status and live clips.
+public sealed class DvbSubtitleVersions
+{
+ private readonly HashSet pids;
+ private readonly Dictionary<(int Pid, int Type, int Id), int> versions = new();
+ private readonly Dictionary> 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>();
+ void Flush(int pid, List 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);
+ }
+}
diff --git a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj
index 870a332..3979056 100644
--- a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj
+++ b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj
@@ -2,8 +2,8 @@
net10.0
- 7.0.13.0
- 7.0.13.0
+ 7.0.14.0
+ 7.0.14.0
diff --git a/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs b/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs
index 2c70541..0ce1c78 100644
--- a/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs
+++ b/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs
@@ -19,6 +19,7 @@ public sealed class LiveStreamProbe
private readonly Func> runner;
private readonly Dictionary> Task, DateTime Until)> cache = new();
private readonly object gate = new();
+ private readonly Dictionary Streams, DateTime At)> lastKnown = new();
private readonly SemaphoreSlim slots = new(8);
private readonly TimeSpan timeout;
public string LastError { get; private set; }
@@ -64,6 +65,12 @@ public sealed class LiveStreamProbe
return task.WaitAsync(caller);
}
+ public List 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, TaskCompletionSource> completion)
{
var success = false;
@@ -71,7 +78,17 @@ public sealed class LiveStreamProbe
try
{
await slots.WaitAsync(deadline.Token);
- try { completion.TrySetResult(Parse(await runner(url, deadline.Token), Programme(url))); LastError = null; success = true; }
+ 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 error) { LastError = error.Message; completion.TrySetException(new InvalidOperationException("Decoder returned no usable live stream within the probe deadline.", error)); }
@@ -265,7 +282,7 @@ public sealed class LiveStreamProbe
private static async Task RunFile(string executable, string sample, int? programme, bool extended, CancellationToken token)
{
using var process = new Process { StartInfo = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } };
- foreach (var argument in new[] { "-v", "error", "-f", "mpegts", "-analyzeduration", extended ? "20000000" : "3000000", "-probesize", extended ? "25165824" : "4194304", "-i", sample, "-show_streams", "-show_programs", "-of", "json" }) process.StartInfo.ArgumentList.Add(argument);
+ foreach (var argument in new[] { "-v", "error", "-f", "mpegts", "-analyzeduration", extended ? "20000000" : "3000000", "-probesize", extended ? "25165824" : "4194304", "-i", sample, "-show_streams", "-show_programs", "-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();
@@ -302,7 +319,7 @@ public sealed class LiveStreamProbe
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") continue;
+ 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)
@@ -312,8 +329,19 @@ public sealed class LiveStreamProbe
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 MediaStream { Type = type == "video" ? MediaStreamType.Video : MediaStreamType.Audio, Index = index.Value, Codec = 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") };
+ var stream = new BroadcastMediaStream { 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.");
@@ -328,6 +356,6 @@ public sealed class LiveStreamProbe
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
+ ChannelLayout = s.ChannelLayout, Language = s.Language, Title = s.Title, IsDefault = s.IsDefault, IsForced = s.IsForced, IsHearingImpaired = s.IsHearingImpaired, IsOriginal = s.IsOriginal
};
}
diff --git a/Jellyfin.Plugin.Enigma2/LiveTvService.cs b/Jellyfin.Plugin.Enigma2/LiveTvService.cs
index dc1f812..dff31e5 100644
--- a/Jellyfin.Plugin.Enigma2/LiveTvService.cs
+++ b/Jellyfin.Plugin.Enigma2/LiveTvService.cs
@@ -1226,17 +1226,36 @@ namespace Jellyfin.Plugin.Enigma2
var streamUrl = string.Format("{0}/{1}{2}", baseUrl, channelOid, trancodingUrl);
streamUrl = _streamRelay.Register(streamUrl);
- // Background recordings keep the original source: never record an
- // endless status slate or guess a viewer language for a timer.
- if (Plugin.Instance.Configuration.EnableContinuitySlates && _continuity != null && feedback != null)
- {
- feedback.Report("waiting_for_stream");
- return await _continuity.Open(streamUrl, feedback.Language, cancellationToken).ConfigureAwait(false);
- }
var fields = channelOid.Split(':');
var programme = !Plugin.Instance.Configuration.TranscodedStream && fields.Length > 3 && int.TryParse(fields[3], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var sid) ? sid : (int?)null;
- var probe = _streamProbe.Get(streamUrl, cancellationToken, programme);
- var streams = await (_feedback == null ? probe : _feedback.AwaitStream(probe, feedback, cancellationToken)).ConfigureAwait(false);
+ var useSlates = Plugin.Instance.Configuration.EnableContinuitySlates && _continuity != null && feedback != null;
+ List streams;
+ try
+ {
+ var probe = _streamProbe.Get(streamUrl, cancellationToken, programme);
+ var known = useSlates ? _streamProbe.LastKnown(streamUrl, programme) : null;
+ if (known != null && !probe.IsCompleted)
+ {
+ streams = known; // Do not delay an outage slate by another full metadata timeout.
+ _ = probe.ContinueWith(t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
+ }
+ else streams = await (_feedback == null ? probe : _feedback.AwaitStream(probe, feedback, cancellationToken)).ConfigureAwait(false);
+ }
+ catch (Exception) when (useSlates && !cancellationToken.IsCancellationRequested)
+ {
+ streams = _streamProbe.LastKnown(streamUrl, programme);
+ _logger.LogWarning("[Enigma2] Broadcast track analysis unavailable; keeping continuity with {MetadataState} metadata", streams == null ? "unknown" : "last-known");
+ }
+ // Rich programs use one explicitly ordered local transport, so native
+ // Jellyfin maps the chosen language/subtitle instead of auto-selecting audio and adding -sn.
+ var richProgram = streams != null && (streams.Count(s => s.Type == MediaStreamType.Audio) > 1 || streams.Any(s => s.Type == MediaStreamType.Subtitle));
+ if (_continuity != null && (useSlates || richProgram))
+ {
+ feedback?.Report("waiting_for_stream");
+ var tracks = streams == null ? BroadcastTracks.Fallback(Plugin.Instance.Configuration.PreserveSourceResolution)
+ : new BroadcastTracks(streams, useSlates, Plugin.Instance.Configuration.PreserveSourceResolution);
+ return await _continuity.Open(streamUrl, feedback?.Language ?? "en", cancellationToken, tracks, useSlates).ConfigureAwait(false);
+ }
// A live TS may enumerate its EPG/data PID before AV on the next open.
// Jellyfin maps positions in MediaStreams rather than MPEG-TS PID values.
diff --git a/Jellyfin.Plugin.Enigma2/TransportStreamClock.cs b/Jellyfin.Plugin.Enigma2/TransportStreamClock.cs
index e5cc65f..8030422 100644
--- a/Jellyfin.Plugin.Enigma2/TransportStreamClock.cs
+++ b/Jellyfin.Plugin.Enigma2/TransportStreamClock.cs
@@ -26,7 +26,7 @@ public static class TransportStreamClock
}
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 byte[] Rebase(byte[] input, long videoStart90k)
+ public static byte[] Rebase(byte[] input, long videoStart90k, bool alignAudio = true)
{
if (input.Length % 188 != 0) throw new InvalidDataException("Unaligned MPEG-TS segment");
long? firstVideo = null, firstAudio = null;
@@ -44,7 +44,7 @@ public static class TransportStreamClock
// 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 = videoStart90k - (firstAudio ?? firstVideo.Value);
+ var audioOffset = alignAudio ? videoStart90k - (firstAudio ?? firstVideo.Value) : offset;
using var output = new MemoryStream(input.Length + 188 * 6); var pids = new HashSet();
for (var start = 0; start < input.Length; start += 188)
{
diff --git a/README.md b/README.md
index d28c145..7d9ae70 100644
--- a/README.md
+++ b/README.md
@@ -118,3 +118,20 @@ Na żądanie użytkownika usunięto sztywny limit Full HD z domyślnego trybu pl
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/`.
diff --git a/Tests/BroadcastTrackTests.cs b/Tests/BroadcastTrackTests.cs
new file mode 100644
index 0000000..f28c392
--- /dev/null
+++ b/Tests/BroadcastTrackTests.cs
@@ -0,0 +1,38 @@
+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 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.Where(s=>s.Type==MediaStreamType.Audio).All(s=>s.Codec=="aac"&&s.Channels==2),"each normalized audio language has its own AAC track");
+ 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&§ion.Contains((byte)89)&§ion.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");
+ }
+}
diff --git a/Tests/BroadcastTracksE2E.cs b/Tests/BroadcastTracksE2E.cs
new file mode 100644
index 0000000..780bfec
--- /dev/null
+++ b/Tests/BroadcastTracksE2E.cs
@@ -0,0 +1,114 @@
+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();
+ 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();
+ for(var at=0;at+96000<=bytes.Length;at+=96000)
+ {
+ var peak=0;crossings=0;
+ for(var i=at;iat&&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 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 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);}
+}
diff --git a/Tests/Program.cs b/Tests/Program.cs
index a66755b..a3dc9e2 100644
--- a/Tests/Program.cs
+++ b/Tests/Program.cs
@@ -18,12 +18,14 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
+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");
diff --git a/Tests/ResolutionE2E.cs b/Tests/ResolutionE2E.cs
index 0dc4f73..310ed66 100644
--- a/Tests/ResolutionE2E.cs
+++ b/Tests/ResolutionE2E.cs
@@ -30,7 +30,8 @@ public static class ResolutionE2E
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 manager = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), NullLogger.Instance, preserveResolution: true);
+ 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(), 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();
@@ -49,6 +50,7 @@ public static class ResolutionE2E
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
diff --git a/Tests/SyntheticBroadcastFixture.cs b/Tests/SyntheticBroadcastFixture.cs
new file mode 100644
index 0000000..14a54a9
--- /dev/null
+++ b/Tests/SyntheticBroadcastFixture.cs
@@ -0,0 +1,81 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Collections.Generic;
+using Jellyfin.Plugin.Enigma2;
+using MediaBrowser.Model.Entities;
+
+/// Self-authored DVB bitmap fixtures; no broadcaster samples or receiver connection.
+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;pospos+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{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();
+ for(var i=0;i>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{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();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{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();
+ 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{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());result.Add(255);
+ return result.ToArray();
+ }
+}
diff --git a/build.yaml b/build.yaml
index 5f5cdd1..566de16 100644
--- a/build.yaml
+++ b/build.yaml
@@ -8,7 +8,7 @@ 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.13.0" # Please increment with each pull request
+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.
diff --git a/deploy-plugin-only.py b/deploy-plugin-only.py
index b873cbe..113959e 100644
--- a/deploy-plugin-only.py
+++ b/deploy-plugin-only.py
@@ -13,7 +13,7 @@ import urllib.error
import urllib.request
import uuid
-VERSION = '7.0.13.0'
+VERSION = '7.0.14.0'
ABI = '12.2.0.0'
GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a'
DLL = 'Jellyfin.Plugin.Enigma2.dll'