Retain selectable audio languages and DVB subtitles through live continuity

This commit is contained in:
2026-10-23 23:50:46 +02:00
parent 9a056b3070
commit bd7395f045
17 changed files with 666 additions and 57 deletions
@@ -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;
/// <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) | (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<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();
}
}
+109
View File
@@ -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; }
}
/// <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 = 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<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);
}
+53 -28
View File
@@ -53,42 +53,54 @@ public sealed class ContinuityStreams : IDisposable
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)
public async Task<MediaSourceInfo> 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);
}
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 = 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 }
}
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
};
}
private async Task<Dictionary<string, byte[]>> CachedSlates(string language, CancellationToken token)
catch { Close(viewer.Id); throw; }
}
private async Task<Dictionary<string, byte[]>> 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<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[]>>>>(language, cached)); throw; }
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)
@@ -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<Dictionary<string, byte[]>> GenerateSlates(string language)
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"));
@@ -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");
@@ -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)
@@ -0,0 +1,66 @@
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);
}
}
@@ -2,8 +2,8 @@
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AssemblyVersion>7.0.13.0</AssemblyVersion>
<FileVersion>7.0.13.0</FileVersion>
<AssemblyVersion>7.0.14.0</AssemblyVersion>
<FileVersion>7.0.14.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
+33 -5
View File
@@ -19,6 +19,7 @@ public sealed class LiveStreamProbe
private readonly Func<string, CancellationToken, Task<string>> runner;
private readonly Dictionary<string, (Task<List<MediaStream>> Task, DateTime Until)> 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 TimeSpan timeout;
public string LastError { get; private set; }
@@ -64,6 +65,12 @@ public sealed class LiveStreamProbe
return task.WaitAsync(caller);
}
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, TaskCompletionSource<List<MediaStream>> 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<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", "-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
};
}
+27 -8
View File
@@ -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 useSlates = Plugin.Instance.Configuration.EnableContinuitySlates && _continuity != null && feedback != null;
List<MediaStream> streams;
try
{
var probe = _streamProbe.Get(streamUrl, cancellationToken, programme);
var streams = await (_feedback == null ? probe : _feedback.AwaitStream(probe, feedback, cancellationToken)).ConfigureAwait(false);
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.
@@ -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<int>();
for (var start = 0; start < input.Length; start += 188)
{
+17
View File
@@ -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/`.
+38
View File
@@ -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<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.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&&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
@@ -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<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);}
}
+2
View File
@@ -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");
+3 -1
View File
@@ -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<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();
@@ -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
+81
View File
@@ -0,0 +1,81 @@
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 -1
View File
@@ -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.
+1 -1
View File
@@ -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'