Retain selectable audio languages and DVB subtitles through live continuity
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,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'">
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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&§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");
|
||||
}
|
||||
}
|
||||
@@ -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);}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user