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);
|
||||
}
|
||||
return new MediaSourceInfo {
|
||||
Id = viewer.Id, Path = $"http://127.0.0.1:{port}/DecoderIntegration/Continuity/{viewer.Id}/index.m3u8",
|
||||
Protocol = MediaProtocol.Http, Container = "hls", IsInfiniteStream = true, SupportsProbing = false,
|
||||
SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true, BufferMs = 1000,
|
||||
MediaStreams = new[] {
|
||||
new MediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", Profile = "Constrained Baseline", Level = preserveResolution ? null : 42,
|
||||
Width = preserveResolution ? null : Width, Height = preserveResolution ? null : Height, AverageFrameRate = FrameRate, RealFrameRate = FrameRate, IsInterlaced = false, BitRate = preserveResolution ? null : 12000000, PixelFormat = "yuv420p" },
|
||||
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Channels = 2, SampleRate = 48000, BitRate = 128000, IsDefault = true }
|
||||
}
|
||||
};
|
||||
try
|
||||
{
|
||||
if (!useSlates) await viewer.Source.Ready.Task.WaitAsync(TimeSpan.FromSeconds(35), token);
|
||||
return new MediaSourceInfo {
|
||||
Id = viewer.Id, Path = $"http://127.0.0.1:{port}/DecoderIntegration/Continuity/{viewer.Id}/index.m3u8",
|
||||
Protocol = MediaProtocol.Http, Container = "hls", IsInfiniteStream = true, SupportsProbing = false,
|
||||
SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true, BufferMs = 1000,
|
||||
MediaStreams = tracks.Output,
|
||||
DefaultAudioStreamIndex = tracks.Output.FirstOrDefault(s => s.Type == MediaStreamType.Audio && s.IsDefault)?.Index
|
||||
?? tracks.Output.FirstOrDefault(s => s.Type == MediaStreamType.Audio)?.Index,
|
||||
DefaultSubtitleStreamIndex = tracks.Output.FirstOrDefault(s => s.Type == MediaStreamType.Subtitle && s.IsDefault)?.Index
|
||||
};
|
||||
}
|
||||
catch { Close(viewer.Id); throw; }
|
||||
}
|
||||
private async Task<Dictionary<string, byte[]>> CachedSlates(string language, CancellationToken token)
|
||||
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 probe = _streamProbe.Get(streamUrl, cancellationToken, programme);
|
||||
var streams = await (_feedback == null ? probe : _feedback.AwaitStream(probe, feedback, cancellationToken)).ConfigureAwait(false);
|
||||
var useSlates = Plugin.Instance.Configuration.EnableContinuitySlates && _continuity != null && feedback != null;
|
||||
List<MediaStream> streams;
|
||||
try
|
||||
{
|
||||
var probe = _streamProbe.Get(streamUrl, cancellationToken, programme);
|
||||
var known = useSlates ? _streamProbe.LastKnown(streamUrl, programme) : null;
|
||||
if (known != null && !probe.IsCompleted)
|
||||
{
|
||||
streams = known; // Do not delay an outage slate by another full metadata timeout.
|
||||
_ = probe.ContinueWith(t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
else streams = await (_feedback == null ? probe : _feedback.AwaitStream(probe, feedback, cancellationToken)).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) when (useSlates && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
streams = _streamProbe.LastKnown(streamUrl, programme);
|
||||
_logger.LogWarning("[Enigma2] Broadcast track analysis unavailable; keeping continuity with {MetadataState} metadata", streams == null ? "unknown" : "last-known");
|
||||
}
|
||||
// Rich programs use one explicitly ordered local transport, so native
|
||||
// Jellyfin maps the chosen language/subtitle instead of auto-selecting audio and adding -sn.
|
||||
var richProgram = streams != null && (streams.Count(s => s.Type == MediaStreamType.Audio) > 1 || streams.Any(s => s.Type == MediaStreamType.Subtitle));
|
||||
if (_continuity != null && (useSlates || richProgram))
|
||||
{
|
||||
feedback?.Report("waiting_for_stream");
|
||||
var tracks = streams == null ? BroadcastTracks.Fallback(Plugin.Instance.Configuration.PreserveSourceResolution)
|
||||
: new BroadcastTracks(streams, useSlates, Plugin.Instance.Configuration.PreserveSourceResolution);
|
||||
return await _continuity.Open(streamUrl, feedback?.Language ?? "en", cancellationToken, tracks, useSlates).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// A live TS may enumerate its EPG/data PID before AV on the next open.
|
||||
// Jellyfin maps positions in MediaStreams rather than MPEG-TS PID values.
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user