Preserve native broadcast video and add patient shared stream startup
This commit is contained in:
@@ -27,9 +27,10 @@ public static class BroadcastSlateProgram
|
||||
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);
|
||||
long pts = ((long)(clip[p + 9] & 14) << 29) | ((long)clip[p + 10] << 22) | ((long)(clip[p + 11] & 254) << 14) | ((long)clip[p + 12] << 7) | ((long)clip[p + 13] >> 1);
|
||||
for (var i = 0; i < tracks.Output.Length; i++)
|
||||
if (tracks.Output[i].Codec == "DVBSUB") output.Write(ClearSubtitle(tracks.Input[i], 256 + i, pts));
|
||||
if (tracks.Output[i].Codec == "DVBSUB") output.Write(ClearSubtitle(tracks.Input[i], 256 + i, pts,
|
||||
tracks.Input[0].Width ?? ContinuityStreams.Width, tracks.Input[0].Height ?? ContinuityStreams.Height));
|
||||
cleared = true;
|
||||
}
|
||||
}
|
||||
@@ -50,12 +51,12 @@ public static class BroadcastSlateProgram
|
||||
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)
|
||||
private static byte[] ClearSubtitle(MediaStream stream, int pid, long pts, int width, int height)
|
||||
{
|
||||
var extra = (stream as BroadcastMediaStream)?.SubtitleExtraData;
|
||||
var page = extra is { Length: >= 2 } ? (extra[0] << 8) | extra[1] : 1;
|
||||
var payload = new byte[] { 32, 0,
|
||||
15, 20, (byte)(page >> 8), (byte)page, 0, 5, 0, 7, 127, 4, 55,
|
||||
15, 20, (byte)(page >> 8), (byte)page, 0, 5, 0, (byte)((width - 1) >> 8), (byte)(width - 1), (byte)((height - 1) >> 8), (byte)(height - 1),
|
||||
15, 16, (byte)(page >> 8), (byte)page, 0, 2, 1, 3,
|
||||
15, 128, (byte)(page >> 8), (byte)page, 0, 0, 255 };
|
||||
var pes = new List<byte> { 0, 0, 1, 189, 0, (byte)(payload.Length + 8), 128, 128, 5,
|
||||
|
||||
@@ -13,6 +13,8 @@ namespace Jellyfin.Plugin.Enigma2;
|
||||
// Probe-only transport details; the public Jellyfin contract remains MediaStream.
|
||||
public sealed class BroadcastMediaStream : MediaStream
|
||||
{
|
||||
public string SampleAspectRatio { get; set; }
|
||||
public string FrameRateRational { get; set; }
|
||||
public int? TransportId { get; set; }
|
||||
public bool IsVisualImpaired { get; set; }
|
||||
public byte[] SubtitleExtraData { get; set; }
|
||||
@@ -27,9 +29,9 @@ public sealed class BroadcastTracks
|
||||
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)
|
||||
public BroadcastTracks(IEnumerable<MediaStream> streams, bool normalize = false, bool preserveResolution = true)
|
||||
{
|
||||
Normalize = normalize;
|
||||
Normalize = false; // Legacy argument retained for compatibility; channel AV is never normalized.
|
||||
var all = streams.ToArray();
|
||||
Input = all.Where(s => s.Type == MediaStreamType.Video).Take(1)
|
||||
.Concat(all.Where(s => s.Type == MediaStreamType.Audio))
|
||||
@@ -41,21 +43,13 @@ public sealed class BroadcastTracks
|
||||
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,
|
||||
s.Profile, s.PixelFormat, s.AverageFrameRate, s.RealFrameRate, s.IsInterlaced, s.ColorRange, s.ColorSpace, s.ColorTransfer, s.ColorPrimaries, s.SampleRate, s.ChannelLayout,
|
||||
Sar = (s as BroadcastMediaStream)?.SampleAspectRatio, Rate = (s as BroadcastMediaStream)?.FrameRateRational,
|
||||
Pid = (s as BroadcastMediaStream)?.TransportId, Extra = (s as BroadcastMediaStream)?.SubtitleExtraData
|
||||
}))))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ namespace Jellyfin.Plugin.Enigma2.Configuration
|
||||
public bool ShowNativeStartupMessages { get; set; } = true;
|
||||
public bool EnableContinuitySlates { get; set; } = false;
|
||||
public bool PreserveSourceResolution { get; set; } = true;
|
||||
public int StreamStartupWaitSeconds { get; set; } = 90;
|
||||
public int StreamStallWaitSeconds { get; set; } = 30;
|
||||
|
||||
|
||||
public PluginConfiguration()
|
||||
|
||||
@@ -157,7 +157,17 @@
|
||||
<input is="emby-checkbox" type="checkbox" id="chkEnableContinuitySlates" />
|
||||
<span>Podtrzymuj stream planszą FFmpeg podczas oczekiwania i przerw</span>
|
||||
</label>
|
||||
<div class="fieldDescription">Ponawia odbiór aż do zatrzymania przez widza. Jeden proces odbioru na kanał; język planszy według interfejsu użytkownika, następnie profilu i ustawień Jellyfina. Zachowuje rozdzielczość obrazu źródła, również 4K, przy kodowaniu H264 50 kl./s i AAC stereo. Wymaga CPU; nie zachowuje HDR, dźwięku przestrzennego ani dodatkowych ścieżek. Nie dotyczy nagrań.</div>
|
||||
<div class="fieldDescription">Obraz i dźwięk kanału są kopiowane bez przekodowania: bez limitu 50 kl./s, zmiany rozdzielczości, HDR i dźwięku przestrzennego. Plansza jest dostępna dla rozpoznanego progresywnego H264 8-bit z AAC LC stereo 48 kHz; ma rozmiar i klatkaż źródła oraz wymaga zgodnych granic klatek obrazu i dźwięku. Pozostałe formaty oraz źródła bez metadanych używają zwykłego oczekiwania, bez obniżania jakości. Język planszy według interfejsu, profilu i ustawień Jellyfina. Nie dotyczy nagrań.</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="txtStreamStartupWaitSeconds">Czas oczekiwania na obraz (sekundy)</label>
|
||||
<input is="emby-input" type="number" min="25" max="180" id="txtStreamStartupWaitSeconds" />
|
||||
<div class="fieldDescription">Domyślnie 90, zakres 25–180. Ponawia analizę świeżej próbki przy jednym połączeniu dekodera. Anulowanie ostatniego widza kończy próbę. Android TV może mieć własny, krótszy limit.</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="txtStreamStallWaitSeconds">Tolerowana cisza strumienia (sekundy)</label>
|
||||
<input is="emby-input" type="number" min="8" max="60" id="txtStreamStallWaitSeconds" />
|
||||
<div class="fieldDescription">Domyślnie 30, zakres 8–60. Dotyczy braku danych; nie potwierdza poprawnego obrazu w napływających pakietach.</div>
|
||||
</div>
|
||||
<label class="checkboxContainer">
|
||||
<input is="emby-checkbox" type="checkbox" id="chkShowNativeStartupMessages" />
|
||||
<span>Pokazuj komunikaty uruchamiania kanału w Android TV</span>
|
||||
@@ -213,6 +223,8 @@
|
||||
document.getElementById('chkEnableAutomaticChannelSync').checked = config.EnableAutomaticChannelSync !== false;
|
||||
document.getElementById('chkShowNativeStartupMessages').checked = config.ShowNativeStartupMessages !== false;
|
||||
document.getElementById('chkEnableContinuitySlates').checked = config.EnableContinuitySlates === true;
|
||||
document.getElementById('txtStreamStartupWaitSeconds').value = config.StreamStartupWaitSeconds ?? 90;
|
||||
document.getElementById('txtStreamStallWaitSeconds').value = config.StreamStallWaitSeconds ?? 30;
|
||||
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
@@ -244,6 +256,8 @@
|
||||
config.EnableAutomaticChannelSync = document.getElementById('chkEnableAutomaticChannelSync').checked;
|
||||
config.ShowNativeStartupMessages = document.getElementById('chkShowNativeStartupMessages').checked;
|
||||
config.EnableContinuitySlates = document.getElementById('chkEnableContinuitySlates').checked;
|
||||
config.StreamStartupWaitSeconds = Math.max(25, Math.min(180, Number(document.getElementById('txtStreamStartupWaitSeconds').value) || 90));
|
||||
config.StreamStallWaitSeconds = Math.max(8, Math.min(60, Number(document.getElementById('txtStreamStallWaitSeconds').value) || 30));
|
||||
|
||||
ApiClient.updatePluginConfiguration(Enigma2ConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult);
|
||||
});
|
||||
|
||||
@@ -27,7 +27,6 @@ public sealed class ContinuityStreams : IDisposable
|
||||
private readonly string root;
|
||||
private Func<string> encoderPath;
|
||||
private readonly int port;
|
||||
private readonly bool preserveResolution;
|
||||
private readonly StartupMessages messages;
|
||||
private readonly ILogger logger;
|
||||
private readonly object gate = new();
|
||||
@@ -45,7 +44,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
// Explicit inputs support an isolated FFmpeg/Kestrel test without a receiver or server configuration changes.
|
||||
public ContinuityStreams(string encoder, string root, int port, StartupMessages messages, ILogger logger, bool preserveResolution = true)
|
||||
{
|
||||
this.preserveResolution = preserveResolution; encoderPath = () => encoder; this.root = Path.GetFullPath(root); this.port = port; this.messages = messages; this.logger = logger;
|
||||
encoderPath = () => encoder; this.root = Path.GetFullPath(root); this.port = port; this.messages = messages; this.logger = logger;
|
||||
Directory.CreateDirectory(this.root); sweeper = Task.Run(Sweep);
|
||||
}
|
||||
private sealed class Viewer
|
||||
@@ -59,9 +58,16 @@ public sealed class ContinuityStreams : IDisposable
|
||||
public async Task<MediaSourceInfo> Open(string relayUrl, string language, CancellationToken token, BroadcastTracks tracks = null, bool useSlates = true)
|
||||
{
|
||||
language = StartupMessages.ResolveLanguage(language, null, "en");
|
||||
tracks ??= BroadcastTracks.Fallback(preserveResolution);
|
||||
if (tracks.Normalize != useSlates) throw new ArgumentException("Track format does not match continuity mode");
|
||||
var clips = useSlates ? await CachedSlates(language, token, tracks) : null;
|
||||
tracks ??= BroadcastTracks.Fallback();
|
||||
var profile = useSlates ? LosslessSlateProfile.Create(tracks) : null;
|
||||
useSlates = profile != null;
|
||||
Dictionary<string, byte[]> clips = null;
|
||||
if (useSlates)
|
||||
{
|
||||
try { clips = await CachedSlates(language, token, tracks); }
|
||||
catch (Exception error) when (error is not OperationCanceledException && !token.IsCancellationRequested)
|
||||
{ useSlates = false; logger.LogWarning("[Enigma2] Status picture unavailable ({FailureType}); preserving the original broadcast", error.GetType().Name); }
|
||||
}
|
||||
var sourceKey = relayUrl + "|" + useSlates + "|" + tracks.Key;
|
||||
token.ThrowIfCancellationRequested();
|
||||
Viewer viewer;
|
||||
@@ -78,7 +84,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!useSlates) await viewer.Source.Ready.Task.WaitAsync(TimeSpan.FromSeconds(35), token);
|
||||
if (!useSlates) await viewer.Source.Ready.Task.WaitAsync(TimeSpan.FromSeconds(StartupBudget.Seconds), token);
|
||||
return new MediaSourceInfo {
|
||||
Id = viewer.Id, Path = $"http://127.0.0.1:{port}/DecoderIntegration/Continuity/{viewer.Id}/index.m3u8",
|
||||
Protocol = MediaProtocol.Http, Container = "hls", IsInfiniteStream = true, SupportsProbing = false,
|
||||
@@ -93,7 +99,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
}
|
||||
private async Task<Dictionary<string, byte[]>> CachedSlates(string language, CancellationToken token, BroadcastTracks tracks = null)
|
||||
{
|
||||
tracks ??= BroadcastTracks.Fallback(preserveResolution);
|
||||
tracks ??= PreviewTracks();
|
||||
var key = language + "-" + tracks.Key;
|
||||
if (slates.Count >= 64)
|
||||
foreach (var stale in slates.Keys.Where(k => k != key).Take(slates.Count - 63)) slates.TryRemove(stale, out _);
|
||||
@@ -134,7 +140,8 @@ public sealed class ContinuityStreams : IDisposable
|
||||
viewer.LastAccess = DateTime.UtcNow;
|
||||
var segment = viewer.Source.Timeline.Find(sequence);
|
||||
if (segment == null) return null;
|
||||
var transport = TransportStreamClock.Rebase(segment.Slate is { } slate ? viewer.Slates[slate] : segment.Video, segment.Start90k, alignAudio: viewer.Source.UseSlates);
|
||||
var transport = TransportStreamClock.Rebase(segment.Slate is { } slate ? viewer.Slates[slate] : segment.Video, segment.Start90k,
|
||||
alignAudio: segment.Slate != null, offset90k: segment.ClockOffset90k);
|
||||
viewer.SubtitleVersions.Apply(transport, sequence);
|
||||
return transport;
|
||||
}
|
||||
@@ -164,10 +171,16 @@ public sealed class ContinuityStreams : IDisposable
|
||||
foreach (var source in sources.Values) source.Stop(); sources.Clear(); viewers.Clear();
|
||||
}
|
||||
}
|
||||
private IEnumerable<string> CodecArguments(bool slate = false) => new[] {
|
||||
private static BroadcastTracks PreviewTracks() => new(new MediaStream[] {
|
||||
new BroadcastMediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", Width = Width, Height = Height,
|
||||
Profile = "Constrained Baseline", PixelFormat = "yuv420p", AverageFrameRate = FrameRate, SampleAspectRatio = "1:1", FrameRateRational = "50/1" },
|
||||
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Profile = "LC", Channels = 2, SampleRate = 48000, IsDefault = true }
|
||||
});
|
||||
// These arguments encode the generated status image only. Live AV always uses -c copy.
|
||||
private static IEnumerable<string> SlateCodecArguments(LosslessSlateProfile profile) => new[] {
|
||||
"-c:v", "libx264", "-threads", "2", "-preset", "ultrafast", "-tune", "zerolatency", "-crf", "20",
|
||||
"-maxrate", preserveResolution && !slate ? "48M" : "12M", "-bufsize", preserveResolution && !slate ? "96M" : "24M", "-profile:v", "baseline", "-pix_fmt", "yuv420p",
|
||||
"-r", FrameRate.ToString(CultureInfo.InvariantCulture), "-g", "112", "-keyint_min", "112", "-sc_threshold", "0",
|
||||
"-profile:v", profile.Profile, "-pix_fmt", "yuv420p", "-r", profile.RateArgument,
|
||||
"-g", profile.Gop.ToString(CultureInfo.InvariantCulture), "-keyint_min", profile.Gop.ToString(CultureInfo.InvariantCulture), "-sc_threshold", "0",
|
||||
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k" };
|
||||
private Process Start(IEnumerable<string> arguments, string folder)
|
||||
{
|
||||
@@ -185,16 +198,19 @@ public sealed class ContinuityStreams : IDisposable
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
var profile = LosslessSlateProfile.Create(tracks) ?? throw new InvalidOperationException("No lossless status profile");
|
||||
var seconds = profile.SegmentSeconds.ToString("0.########", CultureInfo.InvariantCulture);
|
||||
var font = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
|
||||
if (!File.Exists(font)) throw new FileNotFoundException("Continuity slates require a Unicode DejaVu Sans font");
|
||||
var clips = new Dictionary<string, byte[]>();
|
||||
foreach (var phase in new[] { "waiting", "retrying" })
|
||||
{
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, "message.txt"), messages.Text(language, "slate_" + phase), lifetime.Token);
|
||||
var 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" }.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);
|
||||
var size = Math.Max(12, profile.Height / 28);
|
||||
var filter = $"setsar={profile.Aspect},drawtext=fontfile={font}:text=TV:fontsize={size}:fontcolor=0x58bfee:x=(w-tw)/2:y=h*0.25," +
|
||||
$"drawtext=fontfile={font}:textfile=message.txt:fontsize={size}:fontcolor=white:line_spacing={size / 2}:x=(w-tw)/2:y=h*0.45";
|
||||
using var process = Start(new[] { "-f", "lavfi", "-i", $"color=c=0x101723:s={profile.Width}x{profile.Height}:r={profile.RateArgument}", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-vf", filter,
|
||||
"-map", "0:v:0" }.Concat(Enumerable.Range(0, tracks.AudioCount).SelectMany(_ => new[] { "-map", "1:a:0" })).Concat(new[] { "-t", (profile.SegmentSeconds * 3).ToString("0.########", CultureInfo.InvariantCulture) }).Concat(SlateCodecArguments(profile)).Concat(tracks.MetadataArguments(audioVideoOnly: true)).Concat(new[] { "-f", "hls", "-hls_time", seconds, "-hls_list_size", "0", "-hls_segment_filename", "slate-%d.ts", "slate.m3u8" }), folder);
|
||||
try { await process.WaitForExitAsync(lifetime.Token).WaitAsync(TimeSpan.FromSeconds(20), lifetime.Token); }
|
||||
finally { if (!process.HasExited) process.Kill(entireProcessTree: true); }
|
||||
if (process.ExitCode != 0) throw new IOException("FFmpeg slate generation failed");
|
||||
@@ -219,7 +235,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
public ContinuityTimeline Timeline { get; }
|
||||
public volatile bool Retrying;
|
||||
public Source(ContinuityStreams owner, string url, string key, BroadcastTracks tracks, bool useSlates)
|
||||
{ this.owner = owner; Url = url; Key = key; Tracks = tracks; UseSlates = useSlates; Timeline = new(DateTime.UtcNow, useSlates); lifetime = CancellationTokenSource.CreateLinkedTokenSource(owner.lifetime.Token); task = Task.Run(Run); }
|
||||
{ this.owner = owner; Url = url; Key = key; Tracks = tracks; UseSlates = useSlates; Timeline = new(DateTime.UtcNow, useSlates, LosslessSlateProfile.Create(tracks)?.SegmentSeconds ?? ContinuityTimeline.SegmentSeconds); lifetime = CancellationTokenSource.CreateLinkedTokenSource(owner.lifetime.Token); task = Task.Run(Run); }
|
||||
public void Stop() { try { lifetime.Cancel(); } catch (ObjectDisposedException) { } }
|
||||
private async Task Run()
|
||||
{
|
||||
@@ -233,12 +249,9 @@ public sealed class ContinuityStreams : IDisposable
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
var args = new[] { "-rw_timeout", "10000000", "-analyzeduration", "5000000", "-probesize", "4000000", "-i", Url }
|
||||
var args = new[] { "-rw_timeout", (StartupBudget.StallSeconds * 1000000L).ToString(CultureInfo.InvariantCulture), "-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" });
|
||||
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" });
|
||||
@@ -246,7 +259,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
using var process = owner.Start(args, folder);
|
||||
try
|
||||
{
|
||||
var highest = 0; // Discard the encoder-priming segment.
|
||||
var highest = -1; // Stream copy has no encoder-priming segment to discard.
|
||||
var lastOutput = DateTime.UtcNow;
|
||||
while (!process.HasExited && !lifetime.IsCancellationRequested)
|
||||
{
|
||||
@@ -268,7 +281,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
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");
|
||||
if ((DateTime.UtcNow - lastOutput).TotalSeconds > (delivered ? StartupBudget.StallSeconds + 5 : StartupBudget.Seconds)) throw new IOException("No usable encoded segments");
|
||||
await Task.Delay(250, lifetime.Token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,19 +11,22 @@ public sealed class ContinuityTimeline
|
||||
{
|
||||
// 112 frames at 50 Hz == 105 AAC frames at 48 kHz: exact AV boundary.
|
||||
public const double SegmentSeconds = 2.24;
|
||||
public sealed record Segment(long Sequence, long DiscontinuitiesBefore, bool Discontinuity, double Duration, byte[] Video, string Slate, long Start90k);
|
||||
public sealed record Segment(long Sequence, long DiscontinuitiesBefore, bool Discontinuity, double Duration, byte[] Video, string Slate, long Start90k, long? ClockOffset90k = null);
|
||||
private readonly double slateSeconds;
|
||||
private readonly List<Segment> segments = new();
|
||||
private readonly object gate = new();
|
||||
private long next, discontinuities;
|
||||
private long clock90k = 126000;
|
||||
private string previousEpoch;
|
||||
private string clockEpoch;
|
||||
private long sourceOffset90k, sourceWrap90k, lastDecode90k;
|
||||
private DateTime lastAppend, lastLive;
|
||||
public ContinuityTimeline(DateTime now, bool withSlates = true) { 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)
|
||||
public ContinuityTimeline(DateTime now, bool withSlates = true, double slateSeconds = SegmentSeconds) { this.slateSeconds = slateSeconds; lastAppend = now; lastLive = DateTime.MinValue; if (!withSlates) return; Add(null, "waiting", "slate", slateSeconds, now); Add(null, "waiting", "slate", slateSeconds, now); }
|
||||
private void Add(byte[] video, string slate, string epoch, double duration, DateTime now, long? offset = null, long? start = null, long? end = null)
|
||||
{
|
||||
var discontinuity = next > 0 && (slate != null || previousEpoch != epoch);
|
||||
segments.Add(new(next++, discontinuities, discontinuity, duration, video, slate, clock90k));
|
||||
clock90k += (long)Math.Round(duration * 90000);
|
||||
segments.Add(new(next++, discontinuities, discontinuity, duration, video, slate, start ?? clock90k, offset));
|
||||
clock90k = end ?? clock90k + (long)Math.Round(duration * 90000);
|
||||
if (discontinuity) discontinuities++;
|
||||
previousEpoch = epoch; lastAppend = now;
|
||||
while (segments.Count > 8) segments.RemoveAt(0);
|
||||
@@ -31,13 +34,28 @@ 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 > 30) throw new ArgumentException("Invalid live segment");
|
||||
lock (gate) { lastLive = now; Add(video, null, epoch, duration, now); }
|
||||
lock (gate)
|
||||
{
|
||||
lastLive = now;
|
||||
var bounds = TransportStreamClock.ClockBounds(video);
|
||||
if (bounds == null) { Add(video, null, epoch, duration, now); return; }
|
||||
var times = bounds.Value;
|
||||
// Keep one offset for an entire producer epoch. Anchoring each segment
|
||||
// to its first PTS corrupts DTS order when copied video has B-frames.
|
||||
if (clockEpoch != epoch || previousEpoch != epoch)
|
||||
{ clockEpoch = epoch; sourceWrap90k = 0; sourceOffset90k = clock90k + 9000 - times.Earliest; }
|
||||
else if (times.Decode + sourceWrap90k < lastDecode90k - (1L << 32)) sourceWrap90k += 1L << 33;
|
||||
lastDecode90k = times.Decode + sourceWrap90k;
|
||||
var offset = sourceOffset90k + sourceWrap90k;
|
||||
var end = Math.Max(times.Decode + (long)Math.Round(duration * 90000), times.Latest + 9000) + offset;
|
||||
Add(video, null, epoch, duration, now, offset, times.Decode + offset, end);
|
||||
}
|
||||
}
|
||||
public void Tick(DateTime now, bool retrying)
|
||||
{
|
||||
lock (gate)
|
||||
if ((now - lastAppend).TotalSeconds >= 2 && (now - lastLive).TotalSeconds >= 5)
|
||||
Add(null, retrying ? "retrying" : "waiting", "slate", SegmentSeconds, now);
|
||||
Add(null, retrying ? "retrying" : "waiting", "slate", slateSeconds, now);
|
||||
}
|
||||
public Segment Find(long sequence) { lock (gate) return segments.FirstOrDefault(s => s.Sequence == sequence); }
|
||||
public string Playlist()
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<AssemblyVersion>7.0.14.0</AssemblyVersion>
|
||||
<FileVersion>7.0.14.0</FileVersion>
|
||||
<AssemblyVersion>7.0.15.0</AssemblyVersion>
|
||||
<FileVersion>7.0.15.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
|
||||
|
||||
@@ -17,17 +17,28 @@ namespace Jellyfin.Plugin.Enigma2;
|
||||
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 sealed class ProbeEntry
|
||||
{
|
||||
public readonly TaskCompletionSource<List<MediaStream>> Completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public readonly CancellationTokenSource Stop = new();
|
||||
public DateTime Until = DateTime.MaxValue;
|
||||
public int Waiters;
|
||||
public bool Abandoned;
|
||||
public Task<List<MediaStream>> Task => Completion.Task;
|
||||
}
|
||||
private readonly Dictionary<string, ProbeEntry> cache = new();
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<string, (List<MediaStream> Streams, DateTime At)> lastKnown = new();
|
||||
private readonly SemaphoreSlim slots = new(8);
|
||||
private readonly TimeSpan timeout;
|
||||
private readonly Func<TimeSpan> timeout;
|
||||
public string LastError { get; private set; }
|
||||
public LiveStreamProbe(IMediaEncoder encoder) : this((url, token) => Run(encoder.ProbePath, url, token)) { }
|
||||
public LiveStreamProbe(Func<string, CancellationToken, Task<string>> runner, TimeSpan? timeout = null) { this.runner = runner; this.timeout = timeout ?? TimeSpan.FromSeconds(25); }
|
||||
public LiveStreamProbe(IMediaEncoder encoder) : this((url, token) => Run(encoder.ProbePath, url, token))
|
||||
{ timeout = () => TimeSpan.FromSeconds(StartupBudget.Seconds); }
|
||||
public LiveStreamProbe(Func<string, CancellationToken, Task<string>> runner, TimeSpan? timeout = null) { this.runner = runner; this.timeout = () => timeout ?? TimeSpan.FromSeconds(25); }
|
||||
// The HTTP and file-analysis seams let tests exercise the actual capture, retry and cleanup.
|
||||
public LiveStreamProbe(HttpClient client, Func<string, int?, bool, CancellationToken, Task<string>> fileProbe, SamplingLimits limits = null, TimeSpan? timeout = null)
|
||||
: this((url, token) => CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), timeout) { }
|
||||
public LiveStreamProbe(HttpClient client, Func<string, int?, bool, CancellationToken, Task<string>> fileProbe, SamplingLimits limits = null, TimeSpan? timeout = null, bool patient = false, TimeSpan? retryDelay = null)
|
||||
: this((url, token) => patient ? Patient(() => CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), token, retryDelay)
|
||||
: CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), timeout) { }
|
||||
|
||||
public sealed record SamplingLimits(TimeSpan InitialDuration, TimeSpan MaximumDuration, int InitialBytes, int MaximumBytes)
|
||||
{
|
||||
@@ -49,20 +60,31 @@ public sealed class LiveStreamProbe
|
||||
public Task<List<MediaStream>> Get(string url, CancellationToken caller, int? programme = null)
|
||||
{
|
||||
if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture);
|
||||
Task<List<MediaStream>> task;
|
||||
caller.ThrowIfCancellationRequested();
|
||||
ProbeEntry entry;
|
||||
lock (gate)
|
||||
{
|
||||
if (cache.TryGetValue(url, out var existing) && existing.Until > DateTime.UtcNow) task = existing.Task;
|
||||
if (cache.TryGetValue(url, out var existing) && existing.Until > DateTime.UtcNow && !existing.Abandoned && !existing.Stop.IsCancellationRequested) entry = existing;
|
||||
else
|
||||
{
|
||||
foreach (var expired in cache.Where(x => x.Value.Until <= DateTime.UtcNow).Select(x => x.Key).ToArray()) cache.Remove(expired);
|
||||
var completion = new TaskCompletionSource<List<MediaStream>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
task = completion.Task;
|
||||
cache[url] = (task, DateTime.MaxValue);
|
||||
_ = Produce(url, completion);
|
||||
foreach (var expired in cache.Where(x => x.Value.Until <= DateTime.UtcNow).Select(x => x.Key).ToArray())
|
||||
{ cache[expired].Stop.Dispose(); cache.Remove(expired); }
|
||||
entry = new(); cache[url] = entry;
|
||||
_ = Produce(url, entry);
|
||||
}
|
||||
entry.Waiters++;
|
||||
}
|
||||
return AwaitShared(entry, caller);
|
||||
}
|
||||
private async Task<List<MediaStream>> AwaitShared(ProbeEntry entry, CancellationToken caller)
|
||||
{
|
||||
try { return await entry.Task.WaitAsync(caller); }
|
||||
finally
|
||||
{
|
||||
bool stop;
|
||||
lock (gate) { entry.Waiters--; stop = entry.Waiters == 0 && !entry.Task.IsCompleted; if (stop) entry.Abandoned = true; }
|
||||
if (stop) entry.Stop.Cancel(); // Last viewer left; do not occupy a tuner for the remaining budget.
|
||||
}
|
||||
return task.WaitAsync(caller);
|
||||
}
|
||||
|
||||
public List<MediaStream> LastKnown(string url, int? programme = null)
|
||||
@@ -71,10 +93,12 @@ public sealed class LiveStreamProbe
|
||||
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)
|
||||
private async Task Produce(string url, ProbeEntry entry)
|
||||
{
|
||||
var success = false;
|
||||
using var deadline = new CancellationTokenSource(timeout);
|
||||
var completion = entry.Completion;
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(entry.Stop.Token);
|
||||
deadline.CancelAfter(timeout());
|
||||
try
|
||||
{
|
||||
await slots.WaitAsync(deadline.Token);
|
||||
@@ -91,14 +115,37 @@ public sealed class LiveStreamProbe
|
||||
}
|
||||
finally { slots.Release(); }
|
||||
}
|
||||
catch (Exception) when (entry.Stop.IsCancellationRequested) { completion.TrySetCanceled(entry.Stop.Token); }
|
||||
catch (Exception error) { LastError = error.Message; completion.TrySetException(new InvalidOperationException("Decoder returned no usable live stream within the probe deadline.", error)); }
|
||||
finally { lock (gate) cache[url] = (completion.Task, DateTime.UtcNow.AddSeconds(success ? 30 : 3)); }
|
||||
finally { lock (gate) { entry.Until = DateTime.UtcNow.AddSeconds(success ? 30 : 3); if (entry.Stop.IsCancellationRequested && cache.TryGetValue(url, out var current) && ReferenceEquals(current, entry)) cache.Remove(url); } }
|
||||
}
|
||||
|
||||
private static async Task<string> Run(string executable, string url, CancellationToken token)
|
||||
{
|
||||
using var client = new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3) }) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
return await CaptureAndProbe(client, (sample, programme, extended, cancellation) => RunFile(executable, sample, programme, extended, cancellation), SamplingLimits.Default, url, token);
|
||||
Task<string> Sample() => CaptureAndProbe(client, (sample, programme, extended, cancellation) => RunFile(executable, sample, programme, extended, cancellation), SamplingLimits.Default, url, token);
|
||||
// Production always uses the local shared relay. Reopening its HTTP subscription
|
||||
// keeps the same receiver connection and a fresh bounded sample window.
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.IsLoopback && uri.AbsolutePath.StartsWith("/DecoderIntegration/Live/", StringComparison.Ordinal)
|
||||
? await Patient(Sample, token) : await Sample();
|
||||
}
|
||||
|
||||
private static async Task<string> Patient(Func<Task<string>> sample, CancellationToken token, TimeSpan? retryDelay = null)
|
||||
{
|
||||
Exception last = null; var windows = 0;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested(); windows++;
|
||||
try { return await sample(); }
|
||||
catch (InvalidOperationException error) when (new[] { "video_parameters_unavailable", "missing_expected_programme", "missing_video", "capture_http_failure", "capture_empty", "probe_process_failed" }.Any(c => error.Message.Contains("cause=" + c, StringComparison.Ordinal)))
|
||||
{ last = error; }
|
||||
await Task.Delay(retryDelay ?? TimeSpan.FromSeconds(1), token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (last != null)
|
||||
{ throw new InvalidOperationException($"Decoder startup budget exhausted after {windows} sample windows; last: {last.Message}", last); }
|
||||
}
|
||||
|
||||
private static int? Programme(string url)
|
||||
@@ -329,7 +376,7 @@ 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 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") };
|
||||
var stream = new BroadcastMediaStream { SampleAspectRatio = Text("sample_aspect_ratio"), FrameRateRational = Text("avg_frame_rate"), TransportId = BroadcastTracks.TransportId(Text("id")), SubtitleExtraData = BroadcastTracks.ExtraData(Text("extradata")), Type = type == "video" ? MediaStreamType.Video : type == "audio" ? MediaStreamType.Audio : MediaStreamType.Subtitle, Index = index.Value, Codec = BroadcastTracks.JellyfinCodec(codec), Width = Number("width"), Height = Number("height"), Channels = Number("channels"), SampleRate = Number("sample_rate"), BitRate = Number("bit_rate"), Profile = Text("profile"), Level = Number("level"), PixelFormat = Text("pix_fmt"), ColorRange = Text("color_range"), ColorSpace = Text("color_space"), ColorTransfer = Text("color_transfer"), ColorPrimaries = Text("color_primaries"), AverageFrameRate = Rate("avg_frame_rate"), RealFrameRate = Rate("r_frame_rate"), IsInterlaced = Text("field_order") is "tt" or "bb" or "tb" or "bt", ChannelLayout = Text("channel_layout") };
|
||||
if (item.TryGetProperty("tags", out var tags) && tags.TryGetProperty("language", out var language)) stream.Language = language.GetString();
|
||||
if (item.TryGetProperty("tags", out var labels))
|
||||
{
|
||||
|
||||
@@ -23,13 +23,14 @@ public sealed class LiveStreamRelay : IDisposable
|
||||
private readonly object gate = new();
|
||||
private int opened;
|
||||
private readonly TimeSpan idleGrace;
|
||||
private readonly TimeSpan startupTimeout;
|
||||
private readonly TimeSpan readTimeout;
|
||||
public LiveStreamRelay(IServerApplicationHost host) : this(new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3), AllowAutoRedirect = false }), host.HttpPort) { }
|
||||
private Func<TimeSpan> startupBudget, readBudget;
|
||||
private TimeSpan startupTimeout => startupBudget();
|
||||
private TimeSpan readTimeout => readBudget();
|
||||
public LiveStreamRelay(IServerApplicationHost host) : this(new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3), AllowAutoRedirect = false }), host.HttpPort) { startupBudget = () => TimeSpan.FromSeconds(StartupBudget.Seconds); readBudget = () => TimeSpan.FromSeconds(StartupBudget.StallSeconds); }
|
||||
public LiveStreamRelay(HttpClient client, int port, TimeSpan? idleGrace = null, TimeSpan? startupTimeout = null, TimeSpan? readTimeout = null)
|
||||
{
|
||||
this.startupTimeout = startupTimeout ?? TimeSpan.FromSeconds(20);
|
||||
this.readTimeout = readTimeout ?? TimeSpan.FromSeconds(8);
|
||||
startupBudget = () => startupTimeout ?? TimeSpan.FromSeconds(20);
|
||||
readBudget = () => readTimeout ?? TimeSpan.FromSeconds(8);
|
||||
if (this.startupTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(startupTimeout));
|
||||
if (this.readTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(readTimeout));
|
||||
this.client = client; client.Timeout = Timeout.InfiniteTimeSpan; this.port = port; this.idleGrace = idleGrace ?? TimeSpan.FromSeconds(15);
|
||||
|
||||
@@ -1246,14 +1246,14 @@ namespace Jellyfin.Plugin.Enigma2
|
||||
streams = _streamProbe.LastKnown(streamUrl, programme);
|
||||
_logger.LogWarning("[Enigma2] Broadcast track analysis unavailable; keeping continuity with {MetadataState} metadata", streams == null ? "unknown" : "last-known");
|
||||
}
|
||||
if (streams == null) throw new InvalidOperationException("Broadcast metadata unavailable; refusing a guessed status format");
|
||||
// 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);
|
||||
var tracks = new BroadcastTracks(streams);
|
||||
return await _continuity.Open(streamUrl, feedback?.Language ?? "en", cancellationToken, tracks, useSlates).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace Jellyfin.Plugin.Enigma2;
|
||||
|
||||
/// <summary>Only the status picture is encoded. Unsupported programs stay entirely in stream-copy mode.</summary>
|
||||
public sealed class LosslessSlateProfile
|
||||
{
|
||||
public int Width { get; private init; }
|
||||
public int Height { get; private init; }
|
||||
public double Rate { get; private init; }
|
||||
public string RateArgument { get; private init; }
|
||||
public string Aspect { get; private init; }
|
||||
public string Profile { get; private init; }
|
||||
public int Gop { get; private init; }
|
||||
public double SegmentSeconds => Gop / Rate;
|
||||
public static LosslessSlateProfile Create(BroadcastTracks tracks)
|
||||
{
|
||||
var video = tracks.Input[0];
|
||||
// A guessed codec/layout is unsafe during cold-start outages. Never convert the channel to fit a slate.
|
||||
if (video.Codec != "h264" || video.PixelFormat != "yuv420p" || video.IsInterlaced
|
||||
|| video.Width is not (> 0 and <= 16384) || video.Height is not (> 0 and <= 16384)
|
||||
|| video.Width % 2 != 0 || video.Height % 2 != 0
|
||||
|| video.ColorTransfer is "smpte2084" or "arib-std-b67"
|
||||
|| tracks.AudioCount == 0 || tracks.AudioCount > 8
|
||||
|| tracks.Input.Where(s => s.Type == MediaStreamType.Audio).Any(s => s.Codec != "aac" || s.Profile != "LC" || s.Channels != 2 || s.SampleRate != 48000)) return null;
|
||||
var profile = video.Profile switch { "Constrained Baseline" or "Baseline" => "baseline", "Main" => "main", "High" => "high", _ => null };
|
||||
var rate = (double)(video.AverageFrameRate ?? video.RealFrameRate ?? 0);
|
||||
var aspect = (video as BroadcastMediaStream)?.SampleAspectRatio;
|
||||
if (profile == null || !double.IsFinite(rate) || rate <= 0 || rate > 240
|
||||
|| aspect == null || !Regex.IsMatch(aspect, @"^[1-9][0-9]{0,5}:[1-9][0-9]{0,5}$")) return null;
|
||||
var rational = (video as BroadcastMediaStream)?.FrameRateRational;
|
||||
if (rational == null || !Regex.IsMatch(rational, @"^[1-9][0-9]{0,8}/[1-9][0-9]{0,8}$"))
|
||||
rational = rate.ToString("0.########", CultureInfo.InvariantCulture);
|
||||
// Repeating a cached clip must end on complete video AND AAC frames.
|
||||
// If that needs a long clip (e.g. 23.976/59.94), retain the original
|
||||
// broadcast without a status clip rather than rounding its cadence.
|
||||
var gop = Enumerable.Range(Math.Max(1, (int)(rate * 1.5)), Math.Max(1, (int)(rate * 1.5) + 2))
|
||||
.Where(n => Math.Abs(n / rate * 48000 / 1024 - Math.Round(n / rate * 48000 / 1024)) < 0.00001)
|
||||
.OrderBy(n => Math.Abs(n / rate - ContinuityTimeline.SegmentSeconds)).FirstOrDefault();
|
||||
if (gop == 0) return null;
|
||||
return new() { Width = video.Width.Value, Height = video.Height.Value, Rate = rate,
|
||||
RateArgument = rational, Aspect = aspect.Replace(':', '/'), Profile = profile, Gop = gop };
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ public sealed class PlaybackFeedback
|
||||
var attempt = new Attempt(this, key, destination, language, token);
|
||||
lock (gate)
|
||||
{
|
||||
foreach (var expired in attempts.Where(x => DateTime.UtcNow - x.Value.UpdatedUtc > TimeSpan.FromMinutes(2)).Select(x => x.Key).ToArray()) attempts.Remove(expired);
|
||||
foreach (var expired in attempts.Where(x => DateTime.UtcNow - x.Value.UpdatedUtc > TimeSpan.FromSeconds(StartupBudget.WebSeconds + 15)).Select(x => x.Key).ToArray()) attempts.Remove(expired);
|
||||
if (attempts.Count >= 512 && !attempts.ContainsKey(key)) attempts.Remove(attempts.OrderBy(x => x.Value.UpdatedUtc).First().Key);
|
||||
attempts[key] = attempt;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ public sealed class PlaybackFeedback
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!attempts.TryGetValue((user, device ?? "", reference), out var attempt) || DateTime.UtcNow - attempt.UpdatedUtc > TimeSpan.FromMinutes(2))
|
||||
if (!attempts.TryGetValue((user, device ?? "", reference), out var attempt) || DateTime.UtcNow - attempt.UpdatedUtc > TimeSpan.FromSeconds(StartupBudget.WebSeconds + 15))
|
||||
return new { Available = false, Phase = "preparing" };
|
||||
return new { Available = true, attempt.Phase, attempt.StartedUtc, attempt.UpdatedUtc };
|
||||
}
|
||||
@@ -151,7 +151,7 @@ public sealed class PlaybackFeedbackController : ControllerBase
|
||||
var selected = messages.Resolve(id, language, Request.Headers.AcceptLanguage.ToString());
|
||||
var auth = await authorization.GetAuthorizationInfo(HttpContext);
|
||||
if (auth.UserId == id && !auth.IsApiKey) messages.RememberClientLanguage(id, auth.DeviceId, language);
|
||||
return Ok(new { Language = selected, Texts = messages.Texts(selected) });
|
||||
return Ok(new { Language = selected, Texts = messages.Texts(selected), StartupTimeoutSeconds = StartupBudget.WebSeconds });
|
||||
}
|
||||
[HttpGet]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.Enigma2;
|
||||
|
||||
public static class StartupBudget
|
||||
{
|
||||
public static int Seconds => Math.Clamp(Plugin.Instance?.Configuration?.StreamStartupWaitSeconds ?? 90, 25, 180);
|
||||
public static int StallSeconds => Math.Clamp(Plugin.Instance?.Configuration?.StreamStallWaitSeconds ?? 30, 8, 60);
|
||||
public static int WebSeconds => Seconds + 45;
|
||||
}
|
||||
@@ -26,7 +26,28 @@ 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, bool alignAudio = true)
|
||||
public static (long Decode, long Earliest, long Latest)? ClockBounds(byte[] input)
|
||||
{
|
||||
if (input.Length % 188 != 0) return null;
|
||||
long? decode = null;
|
||||
var times = new List<long>();
|
||||
for (var start = 0; start < input.Length; start += 188)
|
||||
{
|
||||
var p = Payload(input, start);
|
||||
if (!Pes(input, start, p)) continue;
|
||||
var pts = ReadPts(input, p + 9);
|
||||
times.Add(pts);
|
||||
var dts = (input[p + 7] & 192) == 192 && p + 19 <= start + 188 ? ReadPts(input, p + 14) : pts;
|
||||
times.Add(dts);
|
||||
if (decode == null && input[p + 3] >= 224 && input[p + 3] <= 239) decode = dts;
|
||||
}
|
||||
if (decode == null) return null;
|
||||
long Unwrap(long value) => decode.Value + ((value - decode.Value + (1L << 32)) & ClockMask) - (1L << 32);
|
||||
long min = decode.Value, max = decode.Value;
|
||||
foreach (var time in times) { var value = Unwrap(time); min = Math.Min(min, value); max = Math.Max(max, value); }
|
||||
return (decode.Value, min, max);
|
||||
}
|
||||
public static byte[] Rebase(byte[] input, long videoStart90k, bool alignAudio = true, long? offset90k = null)
|
||||
{
|
||||
if (input.Length % 188 != 0) throw new InvalidDataException("Unaligned MPEG-TS segment");
|
||||
long? firstVideo = null, firstAudio = null;
|
||||
@@ -40,7 +61,7 @@ public static class TransportStreamClock
|
||||
if (firstVideo != null && firstAudio != null) break;
|
||||
}
|
||||
if (firstVideo == null) throw new InvalidDataException("MPEG-TS segment lacks a video presentation timestamp");
|
||||
var offset = videoStart90k - firstVideo.Value;
|
||||
var offset = offset90k ?? (videoStart90k - firstVideo.Value);
|
||||
// Independent AAC encoders and HLS boundaries have different priming
|
||||
// offsets. Anchor the first audio PES of each segment as well, so a
|
||||
// switch cannot send an audio timestamp backwards by a few milliseconds.
|
||||
|
||||
Reference in New Issue
Block a user