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);
|
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)
|
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++)
|
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;
|
cleared = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,12 +51,12 @@ public static class BroadcastSlateProgram
|
|||||||
if (!written) throw new InvalidDataException("Status clip has no expected program map");
|
if (!written) throw new InvalidDataException("Status clip has no expected program map");
|
||||||
return output.ToArray();
|
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 extra = (stream as BroadcastMediaStream)?.SubtitleExtraData;
|
||||||
var page = extra is { Length: >= 2 } ? (extra[0] << 8) | extra[1] : 1;
|
var page = extra is { Length: >= 2 } ? (extra[0] << 8) | extra[1] : 1;
|
||||||
var payload = new byte[] { 32, 0,
|
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, 16, (byte)(page >> 8), (byte)page, 0, 2, 1, 3,
|
||||||
15, 128, (byte)(page >> 8), (byte)page, 0, 0, 255 };
|
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,
|
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.
|
// Probe-only transport details; the public Jellyfin contract remains MediaStream.
|
||||||
public sealed class BroadcastMediaStream : MediaStream
|
public sealed class BroadcastMediaStream : MediaStream
|
||||||
{
|
{
|
||||||
|
public string SampleAspectRatio { get; set; }
|
||||||
|
public string FrameRateRational { get; set; }
|
||||||
public int? TransportId { get; set; }
|
public int? TransportId { get; set; }
|
||||||
public bool IsVisualImpaired { get; set; }
|
public bool IsVisualImpaired { get; set; }
|
||||||
public byte[] SubtitleExtraData { get; set; }
|
public byte[] SubtitleExtraData { get; set; }
|
||||||
@@ -27,9 +29,9 @@ public sealed class BroadcastTracks
|
|||||||
public string Key { get; }
|
public string Key { get; }
|
||||||
public int AudioCount => Input.Count(s => s.Type == MediaStreamType.Audio);
|
public int AudioCount => Input.Count(s => s.Type == MediaStreamType.Audio);
|
||||||
public bool HasSubtitles => Input.Any(s => s.Type == MediaStreamType.Subtitle);
|
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();
|
var all = streams.ToArray();
|
||||||
Input = all.Where(s => s.Type == MediaStreamType.Video).Take(1)
|
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.Audio))
|
||||||
@@ -41,21 +43,13 @@ public sealed class BroadcastTracks
|
|||||||
Output = Input.Select((s, i) => {
|
Output = Input.Select((s, i) => {
|
||||||
var copy = LiveStreamProbe.ParseLiveMetadata(s); copy.Index = i;
|
var copy = LiveStreamProbe.ParseLiveMetadata(s); copy.Index = i;
|
||||||
if (s is BroadcastMediaStream { IsVisualImpaired: true } && string.IsNullOrWhiteSpace(copy.Title)) copy.Title = "Audio description";
|
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;
|
return copy;
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
// Stable per-channel track cache; no URLs, credentials or user identities in its key.
|
// 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 {
|
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.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
|
Pid = (s as BroadcastMediaStream)?.TransportId, Extra = (s as BroadcastMediaStream)?.SubtitleExtraData
|
||||||
}))))).ToLowerInvariant();
|
}))))).ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ namespace Jellyfin.Plugin.Enigma2.Configuration
|
|||||||
public bool ShowNativeStartupMessages { get; set; } = true;
|
public bool ShowNativeStartupMessages { get; set; } = true;
|
||||||
public bool EnableContinuitySlates { get; set; } = false;
|
public bool EnableContinuitySlates { get; set; } = false;
|
||||||
public bool PreserveSourceResolution { get; set; } = true;
|
public bool PreserveSourceResolution { get; set; } = true;
|
||||||
|
public int StreamStartupWaitSeconds { get; set; } = 90;
|
||||||
|
public int StreamStallWaitSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
|
||||||
public PluginConfiguration()
|
public PluginConfiguration()
|
||||||
|
|||||||
@@ -157,7 +157,17 @@
|
|||||||
<input is="emby-checkbox" type="checkbox" id="chkEnableContinuitySlates" />
|
<input is="emby-checkbox" type="checkbox" id="chkEnableContinuitySlates" />
|
||||||
<span>Podtrzymuj stream planszą FFmpeg podczas oczekiwania i przerw</span>
|
<span>Podtrzymuj stream planszą FFmpeg podczas oczekiwania i przerw</span>
|
||||||
</label>
|
</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">
|
<label class="checkboxContainer">
|
||||||
<input is="emby-checkbox" type="checkbox" id="chkShowNativeStartupMessages" />
|
<input is="emby-checkbox" type="checkbox" id="chkShowNativeStartupMessages" />
|
||||||
<span>Pokazuj komunikaty uruchamiania kanału w Android TV</span>
|
<span>Pokazuj komunikaty uruchamiania kanału w Android TV</span>
|
||||||
@@ -213,6 +223,8 @@
|
|||||||
document.getElementById('chkEnableAutomaticChannelSync').checked = config.EnableAutomaticChannelSync !== false;
|
document.getElementById('chkEnableAutomaticChannelSync').checked = config.EnableAutomaticChannelSync !== false;
|
||||||
document.getElementById('chkShowNativeStartupMessages').checked = config.ShowNativeStartupMessages !== false;
|
document.getElementById('chkShowNativeStartupMessages').checked = config.ShowNativeStartupMessages !== false;
|
||||||
document.getElementById('chkEnableContinuitySlates').checked = config.EnableContinuitySlates === true;
|
document.getElementById('chkEnableContinuitySlates').checked = config.EnableContinuitySlates === true;
|
||||||
|
document.getElementById('txtStreamStartupWaitSeconds').value = config.StreamStartupWaitSeconds ?? 90;
|
||||||
|
document.getElementById('txtStreamStallWaitSeconds').value = config.StreamStallWaitSeconds ?? 30;
|
||||||
|
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
});
|
});
|
||||||
@@ -244,6 +256,8 @@
|
|||||||
config.EnableAutomaticChannelSync = document.getElementById('chkEnableAutomaticChannelSync').checked;
|
config.EnableAutomaticChannelSync = document.getElementById('chkEnableAutomaticChannelSync').checked;
|
||||||
config.ShowNativeStartupMessages = document.getElementById('chkShowNativeStartupMessages').checked;
|
config.ShowNativeStartupMessages = document.getElementById('chkShowNativeStartupMessages').checked;
|
||||||
config.EnableContinuitySlates = document.getElementById('chkEnableContinuitySlates').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);
|
ApiClient.updatePluginConfiguration(Enigma2ConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ public sealed class ContinuityStreams : IDisposable
|
|||||||
private readonly string root;
|
private readonly string root;
|
||||||
private Func<string> encoderPath;
|
private Func<string> encoderPath;
|
||||||
private readonly int port;
|
private readonly int port;
|
||||||
private readonly bool preserveResolution;
|
|
||||||
private readonly StartupMessages messages;
|
private readonly StartupMessages messages;
|
||||||
private readonly ILogger logger;
|
private readonly ILogger logger;
|
||||||
private readonly object gate = new();
|
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.
|
// 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)
|
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);
|
Directory.CreateDirectory(this.root); sweeper = Task.Run(Sweep);
|
||||||
}
|
}
|
||||||
private sealed class Viewer
|
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)
|
public async Task<MediaSourceInfo> Open(string relayUrl, string language, CancellationToken token, BroadcastTracks tracks = null, bool useSlates = true)
|
||||||
{
|
{
|
||||||
language = StartupMessages.ResolveLanguage(language, null, "en");
|
language = StartupMessages.ResolveLanguage(language, null, "en");
|
||||||
tracks ??= BroadcastTracks.Fallback(preserveResolution);
|
tracks ??= BroadcastTracks.Fallback();
|
||||||
if (tracks.Normalize != useSlates) throw new ArgumentException("Track format does not match continuity mode");
|
var profile = useSlates ? LosslessSlateProfile.Create(tracks) : null;
|
||||||
var clips = useSlates ? await CachedSlates(language, token, 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;
|
var sourceKey = relayUrl + "|" + useSlates + "|" + tracks.Key;
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
Viewer viewer;
|
Viewer viewer;
|
||||||
@@ -78,7 +84,7 @@ public sealed class ContinuityStreams : IDisposable
|
|||||||
}
|
}
|
||||||
try
|
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 {
|
return new MediaSourceInfo {
|
||||||
Id = viewer.Id, Path = $"http://127.0.0.1:{port}/DecoderIntegration/Continuity/{viewer.Id}/index.m3u8",
|
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,
|
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)
|
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;
|
var key = language + "-" + tracks.Key;
|
||||||
if (slates.Count >= 64)
|
if (slates.Count >= 64)
|
||||||
foreach (var stale in slates.Keys.Where(k => k != key).Take(slates.Count - 63)) slates.TryRemove(stale, out _);
|
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;
|
viewer.LastAccess = DateTime.UtcNow;
|
||||||
var segment = viewer.Source.Timeline.Find(sequence);
|
var segment = viewer.Source.Timeline.Find(sequence);
|
||||||
if (segment == null) return null;
|
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);
|
viewer.SubtitleVersions.Apply(transport, sequence);
|
||||||
return transport;
|
return transport;
|
||||||
}
|
}
|
||||||
@@ -164,10 +171,16 @@ public sealed class ContinuityStreams : IDisposable
|
|||||||
foreach (var source in sources.Values) source.Stop(); sources.Clear(); viewers.Clear();
|
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",
|
"-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",
|
"-profile:v", profile.Profile, "-pix_fmt", "yuv420p", "-r", profile.RateArgument,
|
||||||
"-r", FrameRate.ToString(CultureInfo.InvariantCulture), "-g", "112", "-keyint_min", "112", "-sc_threshold", "0",
|
"-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" };
|
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k" };
|
||||||
private Process Start(IEnumerable<string> arguments, string folder)
|
private Process Start(IEnumerable<string> arguments, string folder)
|
||||||
{
|
{
|
||||||
@@ -185,16 +198,19 @@ public sealed class ContinuityStreams : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(folder);
|
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";
|
var font = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
|
||||||
if (!File.Exists(font)) throw new FileNotFoundException("Continuity slates require a Unicode DejaVu Sans font");
|
if (!File.Exists(font)) throw new FileNotFoundException("Continuity slates require a Unicode DejaVu Sans font");
|
||||||
var clips = new Dictionary<string, byte[]>();
|
var clips = new Dictionary<string, byte[]>();
|
||||||
foreach (var phase in new[] { "waiting", "retrying" })
|
foreach (var phase in new[] { "waiting", "retrying" })
|
||||||
{
|
{
|
||||||
await File.WriteAllTextAsync(Path.Combine(folder, "message.txt"), messages.Text(language, "slate_" + phase), lifetime.Token);
|
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," +
|
var size = Math.Max(12, profile.Height / 28);
|
||||||
$"drawtext=fontfile={font}:textfile=message.txt:fontsize=42:fontcolor=white:line_spacing=22:x=(w-tw)/2:y=490";
|
var filter = $"setsar={profile.Aspect},drawtext=fontfile={font}:text=TV:fontsize={size}:fontcolor=0x58bfee:x=(w-tw)/2:y=h*0.25," +
|
||||||
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,
|
$"drawtext=fontfile={font}:textfile=message.txt:fontsize={size}:fontcolor=white:line_spacing={size / 2}:x=(w-tw)/2:y=h*0.45";
|
||||||
"-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);
|
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); }
|
try { await process.WaitForExitAsync(lifetime.Token).WaitAsync(TimeSpan.FromSeconds(20), lifetime.Token); }
|
||||||
finally { if (!process.HasExited) process.Kill(entireProcessTree: true); }
|
finally { if (!process.HasExited) process.Kill(entireProcessTree: true); }
|
||||||
if (process.ExitCode != 0) throw new IOException("FFmpeg slate generation failed");
|
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 ContinuityTimeline Timeline { get; }
|
||||||
public volatile bool Retrying;
|
public volatile bool Retrying;
|
||||||
public Source(ContinuityStreams owner, string url, string key, BroadcastTracks tracks, bool useSlates)
|
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) { } }
|
public void Stop() { try { lifetime.Cancel(); } catch (ObjectDisposedException) { } }
|
||||||
private async Task Run()
|
private async Task Run()
|
||||||
{
|
{
|
||||||
@@ -233,12 +249,9 @@ public sealed class ContinuityStreams : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(folder);
|
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" });
|
.Concat(Tracks.MapArguments()).Concat(new[] { "-dn" });
|
||||||
if (UseSlates)
|
args = args.Concat(new[] { "-c", "copy" });
|
||||||
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.
|
// 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",
|
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" });
|
"-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);
|
using var process = owner.Start(args, folder);
|
||||||
try
|
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;
|
var lastOutput = DateTime.UtcNow;
|
||||||
while (!process.HasExited && !lifetime.IsCancellationRequested)
|
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);
|
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);
|
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.
|
// 112 frames at 50 Hz == 105 AAC frames at 48 kHz: exact AV boundary.
|
||||||
public const double SegmentSeconds = 2.24;
|
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 List<Segment> segments = new();
|
||||||
private readonly object gate = new();
|
private readonly object gate = new();
|
||||||
private long next, discontinuities;
|
private long next, discontinuities;
|
||||||
private long clock90k = 126000;
|
private long clock90k = 126000;
|
||||||
private string previousEpoch;
|
private string previousEpoch;
|
||||||
|
private string clockEpoch;
|
||||||
|
private long sourceOffset90k, sourceWrap90k, lastDecode90k;
|
||||||
private DateTime lastAppend, lastLive;
|
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); }
|
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)
|
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);
|
var discontinuity = next > 0 && (slate != null || previousEpoch != epoch);
|
||||||
segments.Add(new(next++, discontinuities, discontinuity, duration, video, slate, clock90k));
|
segments.Add(new(next++, discontinuities, discontinuity, duration, video, slate, start ?? clock90k, offset));
|
||||||
clock90k += (long)Math.Round(duration * 90000);
|
clock90k = end ?? clock90k + (long)Math.Round(duration * 90000);
|
||||||
if (discontinuity) discontinuities++;
|
if (discontinuity) discontinuities++;
|
||||||
previousEpoch = epoch; lastAppend = now;
|
previousEpoch = epoch; lastAppend = now;
|
||||||
while (segments.Count > 8) segments.RemoveAt(0);
|
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)
|
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");
|
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)
|
public void Tick(DateTime now, bool retrying)
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
if ((now - lastAppend).TotalSeconds >= 2 && (now - lastLive).TotalSeconds >= 5)
|
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 Segment Find(long sequence) { lock (gate) return segments.FirstOrDefault(s => s.Sequence == sequence); }
|
||||||
public string Playlist()
|
public string Playlist()
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
<AssemblyVersion>7.0.14.0</AssemblyVersion>
|
<AssemblyVersion>7.0.15.0</AssemblyVersion>
|
||||||
<FileVersion>7.0.14.0</FileVersion>
|
<FileVersion>7.0.15.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
|
||||||
|
|||||||
@@ -17,17 +17,28 @@ namespace Jellyfin.Plugin.Enigma2;
|
|||||||
public sealed class LiveStreamProbe
|
public sealed class LiveStreamProbe
|
||||||
{
|
{
|
||||||
private readonly Func<string, CancellationToken, Task<string>> runner;
|
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 object gate = new();
|
||||||
private readonly Dictionary<string, (List<MediaStream> Streams, DateTime At)> lastKnown = new();
|
private readonly Dictionary<string, (List<MediaStream> Streams, DateTime At)> lastKnown = new();
|
||||||
private readonly SemaphoreSlim slots = new(8);
|
private readonly SemaphoreSlim slots = new(8);
|
||||||
private readonly TimeSpan timeout;
|
private readonly Func<TimeSpan> timeout;
|
||||||
public string LastError { get; private set; }
|
public string LastError { get; private set; }
|
||||||
public LiveStreamProbe(IMediaEncoder encoder) : this((url, token) => Run(encoder.ProbePath, url, token)) { }
|
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); }
|
{ 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.
|
// 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)
|
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) => CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), timeout) { }
|
: 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)
|
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)
|
public Task<List<MediaStream>> Get(string url, CancellationToken caller, int? programme = null)
|
||||||
{
|
{
|
||||||
if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture);
|
if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture);
|
||||||
Task<List<MediaStream>> task;
|
caller.ThrowIfCancellationRequested();
|
||||||
|
ProbeEntry entry;
|
||||||
lock (gate)
|
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
|
else
|
||||||
{
|
{
|
||||||
foreach (var expired in cache.Where(x => x.Value.Until <= DateTime.UtcNow).Select(x => x.Key).ToArray()) cache.Remove(expired);
|
foreach (var expired in cache.Where(x => x.Value.Until <= DateTime.UtcNow).Select(x => x.Key).ToArray())
|
||||||
var completion = new TaskCompletionSource<List<MediaStream>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
{ cache[expired].Stop.Dispose(); cache.Remove(expired); }
|
||||||
task = completion.Task;
|
entry = new(); cache[url] = entry;
|
||||||
cache[url] = (task, DateTime.MaxValue);
|
_ = Produce(url, entry);
|
||||||
_ = Produce(url, completion);
|
|
||||||
}
|
}
|
||||||
|
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)
|
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;
|
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;
|
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
|
try
|
||||||
{
|
{
|
||||||
await slots.WaitAsync(deadline.Token);
|
await slots.WaitAsync(deadline.Token);
|
||||||
@@ -91,14 +115,37 @@ public sealed class LiveStreamProbe
|
|||||||
}
|
}
|
||||||
finally { slots.Release(); }
|
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)); }
|
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)
|
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 };
|
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)
|
private static int? Programme(string url)
|
||||||
@@ -329,7 +376,7 @@ public sealed class LiveStreamProbe
|
|||||||
if (values.Length == 1) return numerator;
|
if (values.Length == 1) return numerator;
|
||||||
return float.TryParse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator) && denominator != 0 ? numerator / denominator : null;
|
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 tags) && tags.TryGetProperty("language", out var language)) stream.Language = language.GetString();
|
||||||
if (item.TryGetProperty("tags", out var labels))
|
if (item.TryGetProperty("tags", out var labels))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,13 +23,14 @@ public sealed class LiveStreamRelay : IDisposable
|
|||||||
private readonly object gate = new();
|
private readonly object gate = new();
|
||||||
private int opened;
|
private int opened;
|
||||||
private readonly TimeSpan idleGrace;
|
private readonly TimeSpan idleGrace;
|
||||||
private readonly TimeSpan startupTimeout;
|
private Func<TimeSpan> startupBudget, readBudget;
|
||||||
private readonly TimeSpan readTimeout;
|
private TimeSpan startupTimeout => startupBudget();
|
||||||
public LiveStreamRelay(IServerApplicationHost host) : this(new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3), AllowAutoRedirect = false }), host.HttpPort) { }
|
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)
|
public LiveStreamRelay(HttpClient client, int port, TimeSpan? idleGrace = null, TimeSpan? startupTimeout = null, TimeSpan? readTimeout = null)
|
||||||
{
|
{
|
||||||
this.startupTimeout = startupTimeout ?? TimeSpan.FromSeconds(20);
|
startupBudget = () => startupTimeout ?? TimeSpan.FromSeconds(20);
|
||||||
this.readTimeout = readTimeout ?? TimeSpan.FromSeconds(8);
|
readBudget = () => readTimeout ?? TimeSpan.FromSeconds(8);
|
||||||
if (this.startupTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(startupTimeout));
|
if (this.startupTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(startupTimeout));
|
||||||
if (this.readTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(readTimeout));
|
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);
|
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);
|
streams = _streamProbe.LastKnown(streamUrl, programme);
|
||||||
_logger.LogWarning("[Enigma2] Broadcast track analysis unavailable; keeping continuity with {MetadataState} metadata", streams == null ? "unknown" : "last-known");
|
_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
|
// Rich programs use one explicitly ordered local transport, so native
|
||||||
// Jellyfin maps the chosen language/subtitle instead of auto-selecting audio and adding -sn.
|
// 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));
|
var richProgram = streams != null && (streams.Count(s => s.Type == MediaStreamType.Audio) > 1 || streams.Any(s => s.Type == MediaStreamType.Subtitle));
|
||||||
if (_continuity != null && (useSlates || richProgram))
|
if (_continuity != null && (useSlates || richProgram))
|
||||||
{
|
{
|
||||||
feedback?.Report("waiting_for_stream");
|
feedback?.Report("waiting_for_stream");
|
||||||
var tracks = streams == null ? BroadcastTracks.Fallback(Plugin.Instance.Configuration.PreserveSourceResolution)
|
var tracks = new BroadcastTracks(streams);
|
||||||
: new BroadcastTracks(streams, useSlates, Plugin.Instance.Configuration.PreserveSourceResolution);
|
|
||||||
return await _continuity.Open(streamUrl, feedback?.Language ?? "en", cancellationToken, tracks, useSlates).ConfigureAwait(false);
|
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);
|
var attempt = new Attempt(this, key, destination, language, token);
|
||||||
lock (gate)
|
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);
|
if (attempts.Count >= 512 && !attempts.ContainsKey(key)) attempts.Remove(attempts.OrderBy(x => x.Value.UpdatedUtc).First().Key);
|
||||||
attempts[key] = attempt;
|
attempts[key] = attempt;
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ public sealed class PlaybackFeedback
|
|||||||
{
|
{
|
||||||
lock (gate)
|
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 = false, Phase = "preparing" };
|
||||||
return new { Available = true, attempt.Phase, attempt.StartedUtc, attempt.UpdatedUtc };
|
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 selected = messages.Resolve(id, language, Request.Headers.AcceptLanguage.ToString());
|
||||||
var auth = await authorization.GetAuthorizationInfo(HttpContext);
|
var auth = await authorization.GetAuthorizationInfo(HttpContext);
|
||||||
if (auth.UserId == id && !auth.IsApiKey) messages.RememberClientLanguage(id, auth.DeviceId, language);
|
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]
|
[HttpGet]
|
||||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
[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
|
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;
|
&& 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");
|
if (input.Length % 188 != 0) throw new InvalidDataException("Unaligned MPEG-TS segment");
|
||||||
long? firstVideo = null, firstAudio = null;
|
long? firstVideo = null, firstAudio = null;
|
||||||
@@ -40,7 +61,7 @@ public static class TransportStreamClock
|
|||||||
if (firstVideo != null && firstAudio != null) break;
|
if (firstVideo != null && firstAudio != null) break;
|
||||||
}
|
}
|
||||||
if (firstVideo == null) throw new InvalidDataException("MPEG-TS segment lacks a video presentation timestamp");
|
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
|
// Independent AAC encoders and HLS boundaries have different priming
|
||||||
// offsets. Anchor the first audio PES of each segment as well, so a
|
// offsets. Anchor the first audio PES of each segment as well, so a
|
||||||
// switch cannot send an audio timestamp backwards by a few milliseconds.
|
// switch cannot send an audio timestamp backwards by a few milliseconds.
|
||||||
|
|||||||
@@ -176,3 +176,33 @@ Moduł webowy wtyczki czeka do 45 s na start obrazu (25 s metadane + 20 s urucho
|
|||||||
Aktualny `deploy-plugin-only.py` wymaga `--maintenance-approved --dll PATH --expected-sha256 HASH`. Nie czyta bazy ani kluczy API. Po backupie sprawdza publiczny health, świeży log załadowania dokładnej wersji, manifest i hash DLL; w razie błędu przywraca poprzednią wtyczkę. Pierwsza próba zbyt wcześnie odrzuciła odpowiedź podczas rozruchu i została cofnięta na 7.0.7. Kontrola gotowości została poprawiona i przetestowana; ponowne wdrożenie przeszło. Backup DLL/manifestu i poprzedniego JS: `/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261013T070224088507Z`.
|
Aktualny `deploy-plugin-only.py` wymaga `--maintenance-approved --dll PATH --expected-sha256 HASH`. Nie czyta bazy ani kluczy API. Po backupie sprawdza publiczny health, świeży log załadowania dokładnej wersji, manifest i hash DLL; w razie błędu przywraca poprzednią wtyczkę. Pierwsza próba zbyt wcześnie odrzuciła odpowiedź podczas rozruchu i została cofnięta na 7.0.7. Kontrola gotowości została poprawiona i przetestowana; ponowne wdrożenie przeszło. Backup DLL/manifestu i poprzedniego JS: `/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261013T070224088507Z`.
|
||||||
|
|
||||||
Końcowo potwierdzono aktywną usługę 12.2.0, manifest Active7.0.8, log załadowania nowej DLL, hash serwowanego JS, HTTP200 strony dodatku oraz 0 strumieni, 0 FFmpeg i 0 tymczasowych próbek. Hashe ustawień serwera, wrapperów FFmpeg i plików index/config Web pozostały bez zmian. Raport: `deployment-7.0.8-20261013.json`. Po wdrożeniu nie wykonano jeszcze odtwarzania na zalogowanym kliencie: przeglądarka testowa wymaga SSO, a test Android TV czeka na użytkownika. Ostatnia wcześniejsza próba Pulsu o 08:42 CEST nadal nie rozpoznała AV po 20 s; zmiana limitów nie jest dowodem naprawienia odkodowania w odbiorniku.
|
Końcowo potwierdzono aktywną usługę 12.2.0, manifest Active7.0.8, log załadowania nowej DLL, hash serwowanego JS, HTTP200 strony dodatku oraz 0 strumieni, 0 FFmpeg i 0 tymczasowych próbek. Hashe ustawień serwera, wrapperów FFmpeg i plików index/config Web pozostały bez zmian. Raport: `deployment-7.0.8-20261013.json`. Po wdrożeniu nie wykonano jeszcze odtwarzania na zalogowanym kliencie: przeglądarka testowa wymaga SSO, a test Android TV czeka na użytkownika. Ostatnia wcześniejsza próba Pulsu o 08:42 CEST nadal nie rozpoznała AV po 20 s; zmiana limitów nie jest dowodem naprawienia odkodowania w odbiorniku.
|
||||||
|
|
||||||
|
|
||||||
|
## Kandydat 7.0.15 — oryginalny obraz i dłuższy start (25.10.2026)
|
||||||
|
|
||||||
|
Nie wdrożono; produkcja nadal 7.0.11. Tor plansz kopiuje obraz i audio (`-c copy`),
|
||||||
|
bez wymuszania H264, 50 kl./s, Full HD, 8-bit lub stereo. Generuje wyłącznie
|
||||||
|
lokalne obrazy komunikatów. Dopasowana plansza jest dostępna dla rozpoznanego
|
||||||
|
progresywnego H264/yuv420p, AAC LC stereo 48 kHz i zgodnych granic AV. Inne formaty
|
||||||
|
(np. HEVC/HDR, przeplot, 5.1 i 23.976/59.94) zachowują natywny transport bez planszy;
|
||||||
|
nie są konwertowane do jej parametrów. Po takim fallbacku klient ma własne limity
|
||||||
|
buforowania. Zegar kopii obsługuje klatki B, powrót po planszy i zawinięcie PTS/DTS.
|
||||||
|
Napisy DVB używają rzeczywistych wymiarów kanału podczas czyszczenia planszy.
|
||||||
|
|
||||||
|
Analiza startowa: domyślnie 90 s (25–180), z ponawianiem świeżych próbek do 24 MiB
|
||||||
|
na wspólnym relay. Przerwa danych: domyślnie 30 s (8–60). Cztery równoczesne
|
||||||
|
żądania współdzielą próbę, a anulowanie ostatniego kończy ją od razu. Web dostaje
|
||||||
|
budżet analizy +45 s na uruchomienie; nie zmieniamy limitów natywnego Android TV.
|
||||||
|
|
||||||
|
205 kontroli .NET, 27 kontroli Pythona i test-web-bridge.cjs PASS; build bez
|
||||||
|
ostrzeżeń. Rzeczywisty ffprobe rozpoznał źródło z pierwszymi danymi po 35 s po
|
||||||
|
około 40 s, przy czterech żądaniach i jednym połączeniu. Próby pakietów potwierdziły
|
||||||
|
oryginalny H264 60, H264 23.976, 4K HEVC 10-bit HDR10/PQ/BT2020 59.94 z AC3 5.1
|
||||||
|
oraz H264 1080i25. Próba wielu języków i napisów sprawdzała też faktyczne piksele.
|
||||||
|
To syntetyczny harness; fizycznego Android TV na 7.0.15 jeszcze nie sprawdzono.
|
||||||
|
|
||||||
|
Stary pakiet 7.0.14 został wycofany ze względu na normalizację obrazu.
|
||||||
|
Instalator 7.0.15 ma --check-only, sumy DLL/JS i rollback własnych plików wtyczki.
|
||||||
|
Wdrożenie wymaga osobno zatwierdzonego restartu. test-web-player.cjs jest wyłączony:
|
||||||
|
stary test produkcyjny odczytywał klucz API z SQLite. Regresja offline to
|
||||||
|
`node test-web-bridge.cjs`; test klienta wymaga jawnie zalogowanej sesji.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public static class BroadcastTrackTests
|
|||||||
var layout=new BroadcastTracks(parsed);
|
var layout=new BroadcastTracks(parsed);
|
||||||
check(layout.Output[0].Width==1920&&layout.Output[0].Height==1080&&BroadcastTracks.Fallback().Output[0].Width==null,"native negotiation uses probed channel dimensions, never the status picture dimensions");
|
check(layout.Output[0].Width==1920&&layout.Output[0].Height==1080&&BroadcastTracks.Fallback().Output[0].Width==null,"native negotiation uses probed channel dimensions, never the status picture dimensions");
|
||||||
check(layout.Output.Select(s=>s.Index).SequenceEqual(Enumerable.Range(0,5))&&layout.Output[2].Language=="eng","explicitly ordered program exposes distinct stable selectable indices");
|
check(layout.Output.Select(s=>s.Index).SequenceEqual(Enumerable.Range(0,5))&&layout.Output[2].Language=="eng","explicitly ordered program exposes distinct stable selectable indices");
|
||||||
check(layout.Output.Where(s=>s.Type==MediaStreamType.Audio).All(s=>s.Codec=="aac"&&s.Channels==2),"each normalized audio language has its own AAC track");
|
check(layout.Output[1].Codec=="ac3"&&layout.Output[1].Channels==6&&layout.Output[2].Codec=="aac", "every audio language retains its original codec and surround channels");
|
||||||
check(EncodingHelper.FindIndex(layout.Output,layout.Output[2])==2&&EncodingHelper.GetSubtitleStreamIndexForFfmpeg(new(){MediaStreams=layout.Output},layout.Output[3])==3,"installed Jellyfin resolves alternate audio and subtitle maps without unknown-index auto-selection");
|
check(EncodingHelper.FindIndex(layout.Output,layout.Output[2])==2&&EncodingHelper.GetSubtitleStreamIndexForFfmpeg(new(){MediaStreams=layout.Output},layout.Output[3])==3,"installed Jellyfin resolves alternate audio and subtitle maps without unknown-index auto-selection");
|
||||||
check(layout.MapArguments().Contains("0:i:261")&&!layout.MapArguments().Contains("-sn"),"producer maps broadcast PIDs instead of unstable probe positions and retains subtitles");
|
check(layout.MapArguments().Contains("0:i:261")&&!layout.MapArguments().Contains("-sn"),"producer maps broadcast PIDs instead of unstable probe positions and retains subtitles");
|
||||||
var untouched=new BroadcastTracks(parsed,false);
|
var untouched=new BroadcastTracks(parsed,false);
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ public static class ContinuityE2E
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var source = relay.Register($"http://127.0.0.1:{port}/fixture.ts");
|
var source = relay.Register($"http://127.0.0.1:{port}/fixture.ts");
|
||||||
var pl = await streams.Open(source, "pl", CancellationToken.None);
|
var tracks = LosslessSlateTests.Tracks(1280, 720, 25);
|
||||||
var en = await streams.Open(source, "en", CancellationToken.None);
|
var pl = await streams.Open(source, "pl", CancellationToken.None, tracks);
|
||||||
|
var en = await streams.Open(source, "en", CancellationToken.None, tracks);
|
||||||
Require(JsonSerializer.Serialize(streams.Status()).Contains("\"Sources\":1"), "two languages share one source worker");
|
Require(JsonSerializer.Serialize(streams.Status()).Contains("\"Sources\":1"), "two languages share one source worker");
|
||||||
Require(!streams.Segment(pl.Id, 0).SequenceEqual(streams.Segment(en.Id, 0)), "same timeline uses different language slates");
|
Require(!streams.Segment(pl.Id, 0).SequenceEqual(streams.Segment(en.Id, 0)), "same timeline uses different language slates");
|
||||||
await File.WriteAllBytesAsync(Path.Combine(output, "pl-slate.ts"), streams.Segment(pl.Id, 0));
|
await File.WriteAllBytesAsync(Path.Combine(output, "pl-slate.ts"), streams.Segment(pl.Id, 0));
|
||||||
@@ -81,7 +82,7 @@ public static class ContinuityE2E
|
|||||||
}
|
}
|
||||||
var report = new { Passed = true, SyntheticOnly = true, ReceiverConnectionsOpened = 0, ViewerLanguages = new[] { "pl", "en" }, SourceConnections = connections, FailureSeconds = longTest ? 75 : 15,
|
var report = new { Passed = true, SyntheticOnly = true, ReceiverConnectionsOpened = 0, ViewerLanguages = new[] { "pl", "en" }, SourceConnections = connections, FailureSeconds = longTest ? 75 : 15,
|
||||||
MaximumConcurrentSourceConnections = maximum, RemainingConnections = active, WaitingLossRecoverySamePlayer = true,
|
MaximumConcurrentSourceConnections = maximum, RemainingConnections = active, WaitingLossRecoverySamePlayer = true,
|
||||||
StrictVideoAudioDecode = true, PreserveSourceResolution = preserveResolution, Width = preserveResolution ? 1280 : ContinuityStreams.Width, Height = preserveResolution ? 720 : ContinuityStreams.Height, FrameRate = ContinuityStreams.FrameRate,
|
StrictVideoAudioDecode = true, PreserveSourceResolution = preserveResolution, Width = 1280, Height = 720, FrameRate = 25, OriginalAvCopied = true,
|
||||||
PhysicalAndroidTvTested = false, ActualJellyfinPlayerTested = false };
|
PhysicalAndroidTvTested = false, ActualJellyfinPlayerTested = false };
|
||||||
await File.WriteAllTextAsync(Path.Combine(output, "result.json"), JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); Console.WriteLine(JsonSerializer.Serialize(report));
|
await File.WriteAllTextAsync(Path.Combine(output, "result.json"), JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); Console.WriteLine(JsonSerializer.Serialize(report));
|
||||||
}
|
}
|
||||||
@@ -105,7 +106,7 @@ public static class ContinuityE2E
|
|||||||
if (!enabled) { context.Response.StatusCode = 503; return; }
|
if (!enabled) { context.Response.StatusCode = 503; return; }
|
||||||
var current = Interlocked.Increment(ref active); maximum = Math.Max(maximum, current);
|
var current = Interlocked.Increment(ref active); maximum = Math.Max(maximum, current);
|
||||||
var info = new ProcessStartInfo(ffmpeg) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true };
|
var info = new ProcessStartInfo(ffmpeg) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true };
|
||||||
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=25", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-c:v", "libx264", "-threads", "1", "-preset", "ultrafast", "-g", "25", "-c:a", "aac", "-f", "mpegts", "pipe:1" }) info.ArgumentList.Add(arg);
|
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=25", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-c:v", "libx264", "-threads", "1", "-preset", "ultrafast", "-g", "25", "-c:a", "aac", "-ac", "2", "-f", "mpegts", "pipe:1" }) info.ArgumentList.Add(arg);
|
||||||
using var process = Process.Start(info); process.ErrorDataReceived += (_, _) => { }; process.BeginErrorReadLine(); context.Response.ContentType = "video/mp2t";
|
using var process = Process.Start(info); process.ErrorDataReceived += (_, _) => { }; process.BeginErrorReadLine(); context.Response.ContentType = "video/mp2t";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Enigma2;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>Compare compressed video packet hashes through the real continuity transport.</summary>
|
||||||
|
public static class LosslessE2E
|
||||||
|
{
|
||||||
|
private static string encoder, output, fixture;
|
||||||
|
private static int active, maximum;
|
||||||
|
public static async Task Run(string executable, string folder)
|
||||||
|
{
|
||||||
|
encoder=executable; output=Path.GetFullPath(folder);
|
||||||
|
if (Directory.Exists(output)) throw new Exception("Choose a new test directory"); Directory.CreateDirectory(output);
|
||||||
|
using var listener=new TcpListener(IPAddress.Loopback,0);listener.Start();var port=((IPEndPoint)listener.LocalEndpoint).Port;listener.Stop();
|
||||||
|
var builder=WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
|
||||||
|
using var streams=new ContinuityStreams(encoder,Path.Combine(output,"cache"),port,new StartupMessages(),NullLogger.Instance);
|
||||||
|
using var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(),port,TimeSpan.FromSeconds(1),TimeSpan.FromSeconds(10),TimeSpan.FromSeconds(5));
|
||||||
|
builder.Services.AddSingleton(streams);builder.Services.AddSingleton(relay);builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly);
|
||||||
|
await using var app=builder.Build();app.MapControllers();app.MapGet("/fixture.ts",Fixture);await app.StartAsync();
|
||||||
|
var reports=new List<object>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach(var profile in new[]{"h264-60","h264-23976","hevc-hdr-5994","h264-interlaced"})
|
||||||
|
{
|
||||||
|
fixture=Path.Combine(output,profile+"-input.ts");
|
||||||
|
var hdr=profile.StartsWith("hevc");var interlaced=profile.EndsWith("interlaced");
|
||||||
|
var rate=hdr?"60000/1001":profile=="h264-23976"?"24000/1001":interlaced?"25":"60";
|
||||||
|
var size=hdr?"3840x2160":interlaced?"1920x1080":"640x360";
|
||||||
|
var args=new List<string>{"-f","lavfi","-i",$"{(hdr?"color=c=0x085f21:":"testsrc2=")}size={size}:rate={rate}","-f","lavfi","-i","sine=frequency=440:sample_rate=48000","-t","8","-vf",hdr?"format=yuv420p10le,setsar=1":interlaced?"setsar=1,setfield=tff":"setsar=1","-c:v",hdr?"libx265":"libx264","-threads","1","-preset","ultrafast","-g",hdr?"60":interlaced?"25":"24","-c:a",hdr?"ac3":"aac","-ac",hdr?"6":"2"};
|
||||||
|
if(hdr)args.AddRange(new[]{"-x265-params","pools=1:frame-threads=1:log-level=error:colorprim=9:transfer=16:colormatrix=9:hdr10=1:master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1):max-cll=1000,400","-color_trc","smpte2084","-color_primaries","bt2020","-colorspace","bt2020nc"});
|
||||||
|
if(interlaced)args.AddRange(new[]{"-flags","+ilme+ildct","-x264-params","tff=1"});
|
||||||
|
args.AddRange(new[]{"-f","mpegts",fixture});
|
||||||
|
using(var make=Start(args,profile+"-fixture")){await make.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(90));Require(make.ExitCode==0,"generate "+profile);}
|
||||||
|
var metadata=LiveStreamProbe.Parse(await Probe(fixture));var tracks=new BroadcastTracks(metadata,true,false);
|
||||||
|
if(hdr)Require(metadata[0].ColorTransfer=="smpte2084" && metadata[0].ColorPrimaries=="bt2020", "fixture carries real HDR10 PQ/BT2020 signaling");
|
||||||
|
var compatible=LosslessSlateProfile.Create(tracks)!=null;
|
||||||
|
Require(compatible==(profile=="h264-60"),"safe slate compatibility: "+profile);
|
||||||
|
var sourceHashes=(await Hashes(fixture)).ToHashSet();
|
||||||
|
var media=await streams.Open(relay.Register($"http://127.0.0.1:{port}/fixture.ts?profile={profile}"),"en",CancellationToken.None,tracks);
|
||||||
|
Require(media.MediaStreams[0].Codec==metadata[0].Codec&&media.MediaStreams[0].AverageFrameRate==metadata[0].AverageFrameRate
|
||||||
|
&&media.MediaStreams[0].IsInterlaced==metadata[0].IsInterlaced&&media.MediaStreams[0].PixelFormat==metadata[0].PixelFormat
|
||||||
|
&&media.MediaStreams[0].ColorTransfer==metadata[0].ColorTransfer&&media.MediaStreams[0].Width==metadata[0].Width
|
||||||
|
&&media.MediaStreams[1].Channels==metadata[1].Channels,"native source metadata unchanged: "+profile);
|
||||||
|
if(compatible)
|
||||||
|
{
|
||||||
|
var waiting=Path.Combine(output,profile+"-waiting.ts");await File.WriteAllBytesAsync(waiting,streams.Segment(media.Id,0));
|
||||||
|
var waitingHashes=await Hashes(waiting);
|
||||||
|
Require(waitingHashes.Length>10 && waitingHashes.All(h=>!sourceHashes.Contains(h)),"real generated waiting picture precedes unchanged source: "+profile);
|
||||||
|
}
|
||||||
|
var recording=Path.Combine(output,profile+"-output.ts");
|
||||||
|
using(var client=Start(new[]{"-xerror","-i",media.Path,"-t",compatible?"18":"6","-map","0:v:0","-map","0:a","-c","copy","-f","mpegts",recording},profile+"-client"))
|
||||||
|
{
|
||||||
|
try{await client.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(65));Require(client.ExitCode==0,"client copies "+profile);}
|
||||||
|
finally{if(!client.HasExited)client.Kill(true);streams.Close(media.Id);}
|
||||||
|
}
|
||||||
|
await Task.Delay(1500);
|
||||||
|
var inputHashes=await Hashes(fixture);var actual=await Hashes(recording);
|
||||||
|
var expected=inputHashes.ToHashSet();var matching=actual.Count(expected.Contains);
|
||||||
|
Require(matching>10,"original compressed video packets reach client unchanged: "+profile);
|
||||||
|
if(compatible)Require(actual.Count(h=>!expected.Contains(h))>10,"recording contains both status picture and original video: "+profile);
|
||||||
|
if(!compatible)Require(actual.All(expected.Contains),"every video packet is an original packet in native fallback: "+profile);
|
||||||
|
using(var decode=Start(new[]{"-xerror","-i",recording,"-map","0:v:0","-map","0:a","-f","null","-"},profile+"-decode"))
|
||||||
|
{await decode.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60));Require(decode.ExitCode==0,"strict decode "+profile);}
|
||||||
|
var decodedMetadata=LiveStreamProbe.Parse(await Probe(recording));
|
||||||
|
Require(decodedMetadata[0].Width==metadata[0].Width && decodedMetadata[0].Height==metadata[0].Height
|
||||||
|
&& decodedMetadata[0].PixelFormat==metadata[0].PixelFormat && decodedMetadata[0].IsInterlaced==metadata[0].IsInterlaced
|
||||||
|
&& decodedMetadata[0].ColorTransfer==metadata[0].ColorTransfer && decodedMetadata[0].ColorPrimaries==metadata[0].ColorPrimaries,
|
||||||
|
"actual output retains dimensions, bit depth, interlace and HDR signaling: "+profile);
|
||||||
|
reports.Add(new{Profile=profile,SlatesInserted=compatible,Width=metadata[0].Width,Height=metadata[0].Height,FrameRate=metadata[0].AverageFrameRate,
|
||||||
|
Codec=metadata[0].Codec,PixelFormat=metadata[0].PixelFormat,Transfer=metadata[0].ColorTransfer,Primaries=metadata[0].ColorPrimaries,OutputTransfer=decodedMetadata[0].ColorTransfer,Interlaced=metadata[0].IsInterlaced,AudioChannels=metadata[1].Channels,
|
||||||
|
OriginalCompressedVideoPackets=matching,OutputVideoPackets=actual.Length,AllOutputVideoPacketsOriginal=!compatible&&actual.All(expected.Contains),StrictDecode=true});
|
||||||
|
}
|
||||||
|
Require(active==0&&maximum==1,"release source; at most one upstream");
|
||||||
|
var result=new{Passed=true,SyntheticOnly=true,ReceiverConnectionsOpened=0,PhysicalAndroidTvTested=false,ActualJellyfinPlayerTested=false,MaximumConcurrentSourceConnections=maximum,RemainingConnections=active,Reports=reports};
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(output,"result.json"),JsonSerializer.Serialize(result,new JsonSerializerOptions{WriteIndented=true}));Console.WriteLine(JsonSerializer.Serialize(result));
|
||||||
|
}
|
||||||
|
finally{streams.Dispose();await app.StopAsync();}
|
||||||
|
}
|
||||||
|
private static async Task<string> Probe(string path)
|
||||||
|
{
|
||||||
|
var info=new ProcessStartInfo(Path.Combine(Path.GetDirectoryName(encoder),"ffprobe")){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
|
||||||
|
foreach(var a in new[]{"-v","error","-show_streams","-of","json",path})info.ArgumentList.Add(a);
|
||||||
|
using var p=Process.Start(info);var text=p.StandardOutput.ReadToEndAsync();var error=p.StandardError.ReadToEndAsync();await p.WaitForExitAsync();Require(p.ExitCode==0,"probe fixture");await error;return await text;
|
||||||
|
}
|
||||||
|
private static async Task<string[]> Hashes(string path)
|
||||||
|
{
|
||||||
|
var info=new ProcessStartInfo(Path.Combine(Path.GetDirectoryName(encoder),"ffprobe")){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
|
||||||
|
foreach(var a in new[]{"-v","error","-select_streams","v:0","-show_packets","-show_data_hash","sha256","-show_entries","packet=data_hash","-of","csv=p=0",path})info.ArgumentList.Add(a);
|
||||||
|
using var p=Process.Start(info);var text=p.StandardOutput.ReadToEndAsync();var error=p.StandardError.ReadToEndAsync();await p.WaitForExitAsync();Require(p.ExitCode==0,"hash encoded packets");await error;
|
||||||
|
return (await text).Split('\n').Select(l=>l.Split(',').FirstOrDefault(x=>x.StartsWith("SHA256:"))).Where(l=>l!=null).Select(l=>l.Trim()).ToArray();
|
||||||
|
}
|
||||||
|
private static Process Start(IEnumerable<string> args,string name)
|
||||||
|
{
|
||||||
|
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardError=true,RedirectStandardOutput=true};
|
||||||
|
foreach(var a in new[]{"-hide_banner","-loglevel","warning","-nostdin","-filter_threads","1","-threads","1"}.Concat(args))info.ArgumentList.Add(a);
|
||||||
|
var p=Process.Start(info);p.ErrorDataReceived+=(_,e)=>{if(e.Data!=null)lock(typeof(LosslessE2E))File.AppendAllText(Path.Combine(output,name+".log"),e.Data+"\n");};p.BeginErrorReadLine();return p;
|
||||||
|
}
|
||||||
|
private static async Task Fixture(HttpContext context)
|
||||||
|
{
|
||||||
|
maximum=Math.Max(maximum,Interlocked.Increment(ref active));
|
||||||
|
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
|
||||||
|
foreach(var a in new[]{"-hide_banner","-loglevel","error","-nostdin","-re","-stream_loop","-1","-i",fixture,"-map","0","-c","copy","-f","mpegts","pipe:1"})info.ArgumentList.Add(a);
|
||||||
|
using var p=Process.Start(info);p.ErrorDataReceived+=(_,_)=>{};p.BeginErrorReadLine();context.Response.ContentType="video/mp2t";
|
||||||
|
try{await p.StandardOutput.BaseStream.CopyToAsync(context.Response.Body,context.RequestAborted);}
|
||||||
|
catch(OperationCanceledException){}catch(IOException){}
|
||||||
|
finally{if(!p.HasExited)p.Kill(true);await p.WaitForExitAsync();Interlocked.Decrement(ref active);}
|
||||||
|
}
|
||||||
|
private static void Require(bool value,string message){if(!value)throw new Exception(message);Console.WriteLine("PASS "+message);}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using Jellyfin.Plugin.Enigma2;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
|
||||||
|
public static class LosslessSlateTests
|
||||||
|
{
|
||||||
|
public static BroadcastTracks Tracks(int width, int height, float rate) => new(new MediaStream[] {
|
||||||
|
new BroadcastMediaStream { Type=MediaStreamType.Video, Index=0, Codec="h264", Width=width, Height=height,
|
||||||
|
AverageFrameRate=rate, RealFrameRate=rate, Profile="Constrained Baseline", PixelFormat="yuv420p", SampleAspectRatio="1:1" },
|
||||||
|
new MediaStream { Type=MediaStreamType.Audio, Index=1, Codec="aac", Profile="LC", Channels=2, SampleRate=48000 }
|
||||||
|
});
|
||||||
|
public static void Run(Action<bool,string> check)
|
||||||
|
{
|
||||||
|
foreach (var rate in new[] { 24f, 25f, 30f, 50f, 60f, 120f })
|
||||||
|
{
|
||||||
|
var tracks=Tracks(3840,2160,rate);
|
||||||
|
var profile=LosslessSlateProfile.Create(tracks);
|
||||||
|
check(profile?.Rate==rate && profile.Width==3840 && profile.Height==2160 && tracks.Output[0].AverageFrameRate==rate,
|
||||||
|
$"status picture matches source rate {rate} and 4K size without changing channel metadata");
|
||||||
|
}
|
||||||
|
var original=Tracks(3840,2160,60000f/1001);
|
||||||
|
var video=(BroadcastMediaStream)original.Input[0];video.FrameRateRational="60000/1001";
|
||||||
|
check(LosslessSlateProfile.Create(original)==null && original.Output[0].AverageFrameRate==60000f/1001, "fractional cadence is preserved by native fallback instead of rounded for a status image");
|
||||||
|
var key=new BroadcastTracks(original.Input).Key;video.AverageFrameRate=24;
|
||||||
|
check(new BroadcastTracks(original.Input).Key!=key,"source rate changes invalidate cached status images");
|
||||||
|
video.Codec="hevc";video.PixelFormat="yuv420p10le";video.ColorTransfer="smpte2084";video.ColorPrimaries="bt2020";video.Level=153;
|
||||||
|
original.Input[1].Codec="ac3";original.Input[1].Channels=6;
|
||||||
|
var hdr=new BroadcastTracks(original.Input,true,false);
|
||||||
|
check(!hdr.Normalize && hdr.Output[0].Codec=="hevc" && hdr.Output[0].PixelFormat=="yuv420p10le" && hdr.Output[0].ColorTransfer=="smpte2084"
|
||||||
|
&& hdr.Output[0].Level==153 && hdr.Output[0].Width==3840 && hdr.Output[1].Codec=="ac3" && hdr.Output[1].Channels==6,
|
||||||
|
"legacy slate/Full HD options cannot normalize 4K HDR10 or surround audio");
|
||||||
|
check(LosslessSlateProfile.Create(hdr)==null,"HDR/HEVC keeps original transport instead of converting the channel to fit a status picture");
|
||||||
|
var interlaced=Tracks(1920,1080,25);interlaced.Input[0].IsInterlaced=true;
|
||||||
|
check(LosslessSlateProfile.Create(interlaced)==null && new BroadcastTracks(interlaced.Input).Output[0].IsInterlaced,
|
||||||
|
"interlaced broadcast is preserved without plugin deinterlacing");
|
||||||
|
check(LosslessSlateProfile.Create(BroadcastTracks.Fallback())==null,"unknown cold-start metadata cannot force a guessed status format");
|
||||||
|
var unknown=Tracks(1280,720,60);((BroadcastMediaStream)unknown.Input[0]).SampleAspectRatio=null;
|
||||||
|
check(LosslessSlateProfile.Create(unknown)==null,"unknown sample aspect ratio uses unmodified broadcast fallback");
|
||||||
|
var now=DateTime.UtcNow;var timeline=new ContinuityTimeline(now,false);
|
||||||
|
var first=Packet(12000,9000);var second=Packet(195000,189000);
|
||||||
|
timeline.Live(first,"producer",2,now);timeline.Live(second,"producer",2,now.AddSeconds(2));
|
||||||
|
long Decode(long sequence,byte[] packet)
|
||||||
|
{
|
||||||
|
var segment=timeline.Find(sequence);
|
||||||
|
return TransportStreamClock.ClockBounds(TransportStreamClock.Rebase(packet,segment.Start90k,false,segment.ClockOffset90k)).Value.Decode;
|
||||||
|
}
|
||||||
|
check(Decode(1,second)-Decode(0,first)==180000,"copied B-frames retain their decode-clock interval despite different first presentation timestamps");
|
||||||
|
timeline.Tick(now.AddSeconds(9),true);var retry=timeline.Find(2);
|
||||||
|
var recovered=Packet(372000,369000);timeline.Live(recovered,"producer",2,now.AddSeconds(10));
|
||||||
|
check(Decode(3,recovered)>=retry.Start90k+(long)(retry.Duration*90000),"short source stall followed by the same producer cannot rewind behind the inserted status picture");
|
||||||
|
timeline=new ContinuityTimeline(now,false);first=Packet((1L<<33)-6000,(1L<<33)-9000);second=Packet(93000,90000);
|
||||||
|
timeline.Live(first,"wrapped",1.1,now);timeline.Live(second,"wrapped",1.1,now.AddSeconds(1.1));
|
||||||
|
check(Decode(1,second)-Decode(0,first)==99000,"copied transport keeps a monotonic decode clock across the 33-bit timestamp wrap");
|
||||||
|
}
|
||||||
|
private static byte[] Packet(long pts,long dts)
|
||||||
|
{
|
||||||
|
var packet=new byte[188];packet[0]=71;packet[1]=65;packet[2]=0;packet[3]=16;
|
||||||
|
packet[6]=1;packet[7]=224;packet[10]=128;packet[11]=192;packet[12]=10;
|
||||||
|
void Stamp(int at,long time,int prefix)
|
||||||
|
{time&=(1L<<33)-1;packet[at]=(byte)((long)prefix|((time>>29)&14)|1);packet[at+1]=(byte)(time>>22);packet[at+2]=(byte)(1|((time>>14)&254));packet[at+3]=(byte)(time>>7);packet[at+4]=(byte)(1|(time<<1));}
|
||||||
|
Stamp(13,pts,48);Stamp(18,dts,16);return packet;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Enigma2;
|
||||||
|
|
||||||
|
public static class PatientProbeTests
|
||||||
|
{
|
||||||
|
public static async Task Run(Action<bool,string> check)
|
||||||
|
{
|
||||||
|
using var upstream = new RelayHandler();
|
||||||
|
using var relay = new LiveStreamRelay(new HttpClient(upstream),8096,TimeSpan.FromMilliseconds(200));
|
||||||
|
var url=relay.Register("http://receiver/patient");
|
||||||
|
using var local = new HttpClient(new LocalRelayHandler(relay));
|
||||||
|
var attempts=0;var snapshots=new System.Collections.Generic.List<string>();
|
||||||
|
var limits=new LiveStreamProbe.SamplingLimits(TimeSpan.FromMilliseconds(20),TimeSpan.FromMilliseconds(80),188*8,188*128);
|
||||||
|
const string good="""{"streams":[{"index":0,"codec_type":"video","codec_name":"h264","width":1920,"height":1080}]}""";
|
||||||
|
const string bad="""{"streams":[{"index":0,"codec_type":"video","codec_name":"h264","width":0,"height":0}]}""";
|
||||||
|
var probe=new LiveStreamProbe(local,(path,sid,extended,token)=>
|
||||||
|
{snapshots.Add(path);return Task.FromResult(++attempts>=3?good:bad);},limits,TimeSpan.FromSeconds(3),true,TimeSpan.FromMilliseconds(40));
|
||||||
|
var result=await Task.WhenAll(Enumerable.Range(0,4).Select(_=>probe.Get(url,CancellationToken.None)));
|
||||||
|
check(attempts==3 && result.All(x=>x[0].Width==1920),"patient startup retries fresh bounded sample windows shared by all four viewers");
|
||||||
|
check(upstream.Calls==1,"multiple metadata windows keep exactly one physical receiver connection");
|
||||||
|
check(snapshots.All(path=>!File.Exists(path)&&!File.Exists(path.Replace(".sample.ts",""))),"patient success cleans every sample and capture window");
|
||||||
|
await Task.Delay(350);
|
||||||
|
check(System.Text.Json.JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"),"patient startup releases receiver after final probe consumer");
|
||||||
|
|
||||||
|
var started=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var stopped=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var cancellable=new LiveStreamProbe(async (source,token)=>
|
||||||
|
{started.SetResult();try{await Task.Delay(10000,token);return good;}finally{stopped.SetResult();}},TimeSpan.FromSeconds(3));
|
||||||
|
using var first=new CancellationTokenSource();using var second=new CancellationTokenSource();
|
||||||
|
var a=cancellable.Get("cancel",first.Token);var b=cancellable.Get("cancel",second.Token);await started.Task;
|
||||||
|
first.Cancel();try{await a;}catch(OperationCanceledException){}
|
||||||
|
check(!stopped.Task.IsCompleted,"one viewer cancelling does not stop another viewer's patient startup");
|
||||||
|
second.Cancel();try{await b;}catch(OperationCanceledException){}
|
||||||
|
await stopped.Task.WaitAsync(TimeSpan.FromSeconds(1));
|
||||||
|
check(stopped.Task.IsCompleted,"last viewer cancellation stops producer immediately instead of waiting 90 seconds");
|
||||||
|
var bounded=new LiveStreamProbe(local,(path,sid,extended,token)=>Task.FromResult(bad),limits,TimeSpan.FromMilliseconds(250),true,TimeSpan.FromMilliseconds(20));
|
||||||
|
try { await bounded.Get(url,CancellationToken.None); throw new Exception("Expected bounded startup failure"); }
|
||||||
|
catch(InvalidOperationException error)
|
||||||
|
{ check(error.ToString().Contains("video_parameters_unavailable"),"patient deadline preserves the last input diagnosis instead of waiting forever"); }
|
||||||
|
}
|
||||||
|
private sealed class LocalRelayHandler(LiveStreamRelay relay) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token)
|
||||||
|
{
|
||||||
|
var sub=await relay.Subscribe(request.RequestUri.Segments.Last(),token);
|
||||||
|
return new(HttpStatusCode.OK){Content=new StreamContent(new SubscriptionStream(sub))};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private sealed class SubscriptionStream(LiveStreamRelay.Subscription subscription) : Stream
|
||||||
|
{
|
||||||
|
private byte[] chunk;private int at;private bool disposed;
|
||||||
|
public override async ValueTask<int> ReadAsync(Memory<byte> buffer,CancellationToken token=default)
|
||||||
|
{
|
||||||
|
if(chunk==null||at==chunk.Length){chunk=await subscription.Reader.ReadAsync(token);at=0;}
|
||||||
|
var n=Math.Min(buffer.Length,chunk.Length-at);chunk.AsMemory(at,n).CopyTo(buffer);at+=n;return n;
|
||||||
|
}
|
||||||
|
protected override void Dispose(bool disposing){if(disposing&&!disposed){disposed=true;subscription.DisposeAsync().AsTask().GetAwaiter().GetResult();}base.Dispose(disposing);}
|
||||||
|
public override bool CanRead=>true;public override bool CanSeek=>false;public override bool CanWrite=>false;
|
||||||
|
public override long Length=>throw new NotSupportedException();public override long Position{get=>0;set=>throw new NotSupportedException();}
|
||||||
|
public override int Read(byte[] b,int o,int n)=>ReadAsync(b.AsMemory(o,n)).AsTask().GetAwaiter().GetResult();
|
||||||
|
public override void Flush(){}public override long Seek(long o,SeekOrigin s)=>throw new NotSupportedException();
|
||||||
|
public override void SetLength(long l)=>throw new NotSupportedException();public override void Write(byte[] b,int o,int n)=>throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Enigma2;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
public static class PatientStartupE2E
|
||||||
|
{
|
||||||
|
private static int connections, active, maximum;
|
||||||
|
public static async Task Run(string encoder, string folder)
|
||||||
|
{
|
||||||
|
folder=Path.GetFullPath(folder);if(Directory.Exists(folder))throw new Exception("Choose a new output directory");Directory.CreateDirectory(folder);
|
||||||
|
var file=Path.Combine(folder,"fixture.ts");
|
||||||
|
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardError=true};
|
||||||
|
foreach(var a in new[]{"-v","error","-nostdin","-filter_threads","1","-f","lavfi","-i","testsrc2=size=320x180:rate=25","-f","lavfi","-i","sine=sample_rate=48000","-t","4","-c:v","libx264","-threads","1","-preset","ultrafast","-g","25","-c:a","aac","-ac","2","-f","mpegts",file})info.ArgumentList.Add(a);
|
||||||
|
using(var make=Process.Start(info)){var error=make.StandardError.ReadToEndAsync();await make.WaitForExitAsync();if(make.ExitCode!=0)throw new Exception(await error);}
|
||||||
|
var bytes=await File.ReadAllBytesAsync(file);
|
||||||
|
using var listener=new TcpListener(IPAddress.Loopback,0);listener.Start();var port=((IPEndPoint)listener.LocalEndpoint).Port;listener.Stop();
|
||||||
|
var builder=WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
|
||||||
|
using var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(),port,TimeSpan.FromMilliseconds(200),TimeSpan.FromSeconds(StartupBudget.Seconds),TimeSpan.FromSeconds(StartupBudget.StallSeconds));
|
||||||
|
builder.Services.AddSingleton(relay);builder.Services.AddControllers().AddApplicationPart(typeof(LiveRelayController).Assembly);
|
||||||
|
await using var app=builder.Build();app.MapControllers();
|
||||||
|
app.MapGet("/late.ts",async context=>
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref connections);maximum=Math.Max(maximum,Interlocked.Increment(ref active));
|
||||||
|
context.Response.ContentType="video/mp2t";await context.Response.StartAsync(context.RequestAborted);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(35),context.RequestAborted);
|
||||||
|
while(!context.RequestAborted.IsCancellationRequested)
|
||||||
|
for(var at=0;at<bytes.Length;at+=188*32)
|
||||||
|
{
|
||||||
|
var n=Math.Min(188*32,bytes.Length-at);
|
||||||
|
await context.Response.Body.WriteAsync(bytes.AsMemory(at,n),context.RequestAborted);await context.Response.Body.FlushAsync(context.RequestAborted);
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(4d*n/bytes.Length),context.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(OperationCanceledException){}catch(IOException){}
|
||||||
|
finally{Interlocked.Decrement(ref active);}
|
||||||
|
});
|
||||||
|
await app.StartAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var proxy=DispatchProxy.Create<IMediaEncoder,PatientEncoderProxy>();((PatientEncoderProxy)(object)proxy).ProbePath=Path.Combine(Path.GetDirectoryName(encoder),"ffprobe");
|
||||||
|
var probe=new LiveStreamProbe(proxy);var url=relay.Register($"http://127.0.0.1:{port}/late.ts");var watch=Stopwatch.StartNew();
|
||||||
|
var result=await Task.WhenAll(Enumerable.Range(0,4).Select(_=>probe.Get(url,CancellationToken.None,1))).WaitAsync(TimeSpan.FromSeconds(95));
|
||||||
|
if(watch.Elapsed.TotalSeconds<35||watch.Elapsed.TotalSeconds>=90||result.Any(x=>x[0].Width!=320||x[0].Height!=180))throw new Exception("Delayed source failed the configured budget");
|
||||||
|
await Task.Delay(800);
|
||||||
|
if(maximum!=1||connections!=1||active!=0)throw new Exception("Receiver sharing or cleanup failed");
|
||||||
|
var report=new{Passed=true,SyntheticOnly=true,ReceiverConnectionsOpened=0,ConfiguredStartupSeconds=StartupBudget.Seconds,DelayedFirstDataSeconds=35,
|
||||||
|
ElapsedSeconds=watch.Elapsed.TotalSeconds,ConcurrentRequesters=4,PhysicalFixtureConnections=connections,MaximumConcurrentFixtureConnections=maximum,RemainingConnections=active,
|
||||||
|
ActualFfprobeUsed=true,PhysicalAndroidTvTested=false};
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(report,new JsonSerializerOptions{WriteIndented=true}));Console.WriteLine(JsonSerializer.Serialize(report));
|
||||||
|
}
|
||||||
|
finally{relay.Dispose();await app.StopAsync();}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public class PatientEncoderProxy : DispatchProxy
|
||||||
|
{
|
||||||
|
public string ProbePath;
|
||||||
|
protected override object Invoke(MethodInfo method,object[] args)=>method.Name=="get_ProbePath"?ProbePath:null;
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
if (args.Length == 3 && args[0] == "--patient-startup-e2e") { await PatientStartupE2E.Run(args[1], args[2]); return; }
|
||||||
|
if (args.Length == 3 && args[0] == "--lossless-e2e") { await LosslessE2E.Run(args[1], args[2]); return; }
|
||||||
if (args.Length == 3 && args[0] == "--broadcast-tracks-e2e") { await BroadcastTracksE2E.Run(args[1], args[2]); return; }
|
if (args.Length == 3 && args[0] == "--broadcast-tracks-e2e") { await BroadcastTracksE2E.Run(args[1], args[2]); return; }
|
||||||
if (args.Length == 3 && args[0] == "--native-continuity-e2e") { await ContinuityE2E.Run(args[1], args[2], true, true); return; }
|
if (args.Length == 3 && args[0] == "--native-continuity-e2e") { await ContinuityE2E.Run(args[1], args[2], true, true); return; }
|
||||||
if (args.Length == 3 && args[0] == "--resolution-e2e") { await ResolutionE2E.Run(args[1], args[2]); return; }
|
if (args.Length == 3 && args[0] == "--resolution-e2e") { await ResolutionE2E.Run(args[1], args[2]); return; }
|
||||||
@@ -113,6 +115,7 @@ Check(true,"empty source rejected before playback");
|
|||||||
var liveVideo=LiveStreamProbe.ParseLiveMetadata(probes[0][0]);
|
var liveVideo=LiveStreamProbe.ParseLiveMetadata(probes[0][0]);
|
||||||
Check(liveVideo.Index==-1&&liveVideo.IsInterlaced&&liveVideo.AverageFrameRate==25&&probes[0][0].Index==0,"live automatic mapping preserves metadata without mutating cached probe");
|
Check(liveVideo.Index==-1&&liveVideo.IsInterlaced&&liveVideo.AverageFrameRate==25&&probes[0][0].Index==0,"live automatic mapping preserves metadata without mutating cached probe");
|
||||||
await AdaptiveProbeTests.Run(Check);
|
await AdaptiveProbeTests.Run(Check);
|
||||||
|
await PatientProbeTests.Run(Check);
|
||||||
await RelayStartupTests.Run(Check);
|
await RelayStartupTests.Run(Check);
|
||||||
var relayHandler=new RelayHandler();
|
var relayHandler=new RelayHandler();
|
||||||
using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100)))
|
using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100)))
|
||||||
@@ -133,6 +136,7 @@ await SavedChannelPlaybackTests.Run(Check);
|
|||||||
await ChannelSyncTests.Run(Check);
|
await ChannelSyncTests.Run(Check);
|
||||||
await PlaybackFeedbackTests.Run(Check);
|
await PlaybackFeedbackTests.Run(Check);
|
||||||
ContinuityTests.Run(Check);
|
ContinuityTests.Run(Check);
|
||||||
|
LosslessSlateTests.Run(Check);
|
||||||
NativeResolutionPolicyTests.Run(Check);
|
NativeResolutionPolicyTests.Run(Check);
|
||||||
public class PathsProxy : DispatchProxy
|
public class PathsProxy : DispatchProxy
|
||||||
{
|
{
|
||||||
|
|||||||
+11
-7
@@ -40,8 +40,12 @@ public static class ResolutionE2E
|
|||||||
{
|
{
|
||||||
foreach (var (name, size) in Sizes)
|
foreach (var (name, size) in Sizes)
|
||||||
{
|
{
|
||||||
var media = await manager.Open(relay.Register($"http://127.0.0.1:{port}/fixture/{name}.ts"), "en", CancellationToken.None);
|
var dimensions = size.Split('x');
|
||||||
Require(media.MediaStreams[0].Width == null && media.MediaStreams[0].Height == null && media.MediaStreams[0].Level == null,
|
var tracks = LosslessSlateTests.Tracks(int.Parse(dimensions[0]), int.Parse(dimensions[1]), 25);
|
||||||
|
((BroadcastMediaStream)tracks.Input[0]).SampleAspectRatio = name == "sd" ? "16:15" : "1:1";
|
||||||
|
tracks = new BroadcastTracks(tracks.Input);
|
||||||
|
var media = await manager.Open(relay.Register($"http://127.0.0.1:{port}/fixture/{name}.ts"), "en", CancellationToken.None, tracks);
|
||||||
|
Require(media.MediaStreams[0].Width == tracks.Input[0].Width && media.MediaStreams[0].Height == tracks.Input[0].Height,
|
||||||
"source metadata does not falsely cap the channel at Full HD: " + name);
|
"source metadata does not falsely cap the channel at Full HD: " + name);
|
||||||
var target = Path.Combine(output, name); Directory.CreateDirectory(target);
|
var target = Path.Combine(output, name); Directory.CreateDirectory(target);
|
||||||
var common = new[] { "-xerror", "-i", media.Path, "-t", "32", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy" };
|
var common = new[] { "-xerror", "-i", media.Path, "-t", "32", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy" };
|
||||||
@@ -66,11 +70,11 @@ public static class ResolutionE2E
|
|||||||
foreach (var arg in new[] { "-v", "error", "-threads", "1", "-select_streams", "v:0", "-show_entries", "frame=width,height,sample_aspect_ratio", "-of", "csv=p=0", Path.Combine(target, file) }) info.ArgumentList.Add(arg);
|
foreach (var arg in new[] { "-v", "error", "-threads", "1", "-select_streams", "v:0", "-show_entries", "frame=width,height,sample_aspect_ratio", "-of", "csv=p=0", Path.Combine(target, file) }) info.ArgumentList.Add(arg);
|
||||||
using var probe = Process.Start(info); var text = await probe.StandardOutput.ReadToEndAsync(); var error = await probe.StandardError.ReadToEndAsync(); await probe.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60));
|
using var probe = Process.Start(info); var text = await probe.StandardOutput.ReadToEndAsync(); var error = await probe.StandardError.ReadToEndAsync(); await probe.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60));
|
||||||
Require(probe.ExitCode == 0 && string.IsNullOrWhiteSpace(error), "probe actual decoded dimensions: " + name + " " + file);
|
Require(probe.ExitCode == 0 && string.IsNullOrWhiteSpace(error), "probe actual decoded dimensions: " + name + " " + file);
|
||||||
var dimensions = text.Split('\n').Where(line => line.Length > 0).Select(line => line.Trim()).Distinct().ToArray();
|
var decodedDimensions = text.Split('\n').Where(line => line.Length > 0).Select(line => line.Trim()).Distinct().ToArray();
|
||||||
var expected = size.Replace('x', ',') + ",";
|
var expected = size.Replace('x', ',') + ",";
|
||||||
Require(dimensions.Any(line => line.StartsWith(expected, StringComparison.Ordinal)), "native dimensions reach the decoded client frames: " + size);
|
Require(decodedDimensions.Any(line => line.StartsWith(expected, StringComparison.Ordinal)), "native dimensions reach the decoded client frames: " + size);
|
||||||
if (name == "sd") Require(dimensions.Any(line => line.StartsWith(expected + "16:15", StringComparison.Ordinal)), "SD sample aspect ratio survives slate/live switching");
|
if (name == "sd") Require(decodedDimensions.Any(line => line.StartsWith(expected + "16:15", StringComparison.Ordinal)), "SD sample aspect ratio survives slate/live switching");
|
||||||
reports.Add(new { Profile = name, File = file, Dimensions = dimensions });
|
reports.Add(new { Profile = name, File = file, Dimensions = decodedDimensions });
|
||||||
}
|
}
|
||||||
await Task.Delay(1500);
|
await Task.Delay(1500);
|
||||||
}
|
}
|
||||||
@@ -95,7 +99,7 @@ public static class ResolutionE2E
|
|||||||
var filter = "color=c=0x085f21:s=" + size + ":r=25" + (name == "sd" ? ",setsar=16/15" : "");
|
var filter = "color=c=0x085f21:s=" + size + ":r=25" + (name == "sd" ? ",setsar=16/15" : "");
|
||||||
var info = new ProcessStartInfo(encoder) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true };
|
var info = new ProcessStartInfo(encoder) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true };
|
||||||
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", filter,
|
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", filter,
|
||||||
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-c:v", "libx264", "-threads", "1", "-preset", "ultrafast", "-g", "25", "-c:a", "aac", "-f", "mpegts", "pipe:1" }) info.ArgumentList.Add(arg);
|
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-c:v", "libx264", "-threads", "1", "-preset", "ultrafast", "-g", "25", "-c:a", "aac", "-ac", "2", "-f", "mpegts", "pipe:1" }) info.ArgumentList.Add(arg);
|
||||||
using var process = Process.Start(info); process.ErrorDataReceived += (_, _) => { }; process.BeginErrorReadLine(); context.Response.ContentType = "video/mp2t";
|
using var process = Process.Start(info); process.ErrorDataReceived += (_, _) => { }; process.BeginErrorReadLine(); context.Response.ContentType = "video/mp2t";
|
||||||
try { await process.StandardOutput.BaseStream.CopyToAsync(context.Response.Body, context.RequestAborted); }
|
try { await process.StandardOutput.BaseStream.CopyToAsync(context.Response.Body, context.RequestAborted); }
|
||||||
catch (OperationCanceledException) { } catch (IOException) { }
|
catch (OperationCanceledException) { } catch (IOException) { }
|
||||||
|
|||||||
+58
-4
@@ -12,14 +12,17 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
VERSION = '7.0.14.0'
|
VERSION = '7.0.15.0'
|
||||||
ABI = '12.2.0.0'
|
ABI = '12.2.0.0'
|
||||||
GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a'
|
GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a'
|
||||||
DLL = 'Jellyfin.Plugin.Enigma2.dll'
|
DLL = 'Jellyfin.Plugin.Enigma2.dll'
|
||||||
PLUGINS = pathlib.Path('/var/lib/jellyfin/plugins')
|
PLUGINS = pathlib.Path('/var/lib/jellyfin/plugins')
|
||||||
BACKUPS = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups')
|
BACKUPS = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups')
|
||||||
LOGS = pathlib.Path('/var/log/jellyfin')
|
LOGS = pathlib.Path('/var/log/jellyfin')
|
||||||
|
WEB = pathlib.Path('/usr/share/jellyfin/web/decoder-channel-playback.js')
|
||||||
|
CONFIG = PLUGINS / 'configurations/Jellyfin.Plugin.Enigma2.xml'
|
||||||
PROTECTED = tuple(map(pathlib.Path, (
|
PROTECTED = tuple(map(pathlib.Path, (
|
||||||
'/etc/default/jellyfin', '/etc/jellyfin/encoding.xml',
|
'/etc/default/jellyfin', '/etc/jellyfin/encoding.xml',
|
||||||
'/usr/local/lib/jellyfin-enigma/ffmpeg', '/usr/local/lib/jellyfin-enigma/ffprobe',
|
'/usr/local/lib/jellyfin-enigma/ffmpeg', '/usr/local/lib/jellyfin-enigma/ffprobe',
|
||||||
@@ -217,14 +220,40 @@ def activate(old, new, stage, backup, verify, verify_rollback, control=service):
|
|||||||
shutil.rmtree(stage)
|
shutil.rmtree(stage)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginAssetControl:
|
||||||
|
"""Update only the plugin's assets while stopped; restore them on rollback."""
|
||||||
|
def __init__(self, old, new, assets, control=service):
|
||||||
|
self.old, self.new, self.assets, self.control = old, new, assets, control
|
||||||
|
|
||||||
|
def __call__(self, action):
|
||||||
|
if action == 'start':
|
||||||
|
candidate = self.new.exists() and not self.old.exists()
|
||||||
|
for destination, prepared, saved in self.assets:
|
||||||
|
shutil.copyfile(prepared if candidate else saved, destination)
|
||||||
|
self.control(action)
|
||||||
|
|
||||||
|
|
||||||
|
def enable_slates(content):
|
||||||
|
pattern = r'<EnableContinuitySlates>\s*(?:true|false)\s*</EnableContinuitySlates>'
|
||||||
|
tag = '<EnableContinuitySlates>true</EnableContinuitySlates>'
|
||||||
|
result = re.sub(pattern, tag, content) if re.search(pattern, content) else re.sub(r'(</[\w:]+>\s*)$', ' ' + tag + '\n' + r'\1', content, count=1)
|
||||||
|
if ET.fromstring(result).findtext('EnableContinuitySlates') != 'true':
|
||||||
|
raise ValueError('Cannot set the plugin status-picture option')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument('--maintenance-approved', action='store_true',
|
parser.add_argument('--maintenance-approved', action='store_true',
|
||||||
help='Required confirmation of an already agreed Jellyfin restart window')
|
help='Required confirmation of an already agreed Jellyfin restart window')
|
||||||
parser.add_argument('--dll', type=pathlib.Path, required=True)
|
parser.add_argument('--dll', type=pathlib.Path, required=True)
|
||||||
parser.add_argument('--expected-sha256', required=True)
|
parser.add_argument('--expected-sha256', required=True)
|
||||||
|
parser.add_argument('--web', type=pathlib.Path)
|
||||||
|
parser.add_argument('--expected-web-sha256')
|
||||||
|
parser.add_argument('--enable-slates', action='store_true')
|
||||||
|
parser.add_argument('--check-only', action='store_true')
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if not args.maintenance_approved:
|
if not args.maintenance_approved and not args.check_only:
|
||||||
parser.error('--maintenance-approved is required; this script cannot inspect playback sessions')
|
parser.error('--maintenance-approved is required; this script cannot inspect playback sessions')
|
||||||
expected = args.expected_sha256.lower()
|
expected = args.expected_sha256.lower()
|
||||||
if not re.fullmatch(r'[0-9a-f]{64}', expected):
|
if not re.fullmatch(r'[0-9a-f]{64}', expected):
|
||||||
@@ -235,6 +264,16 @@ def main(argv=None):
|
|||||||
new = PLUGINS / ('Enigma2_' + VERSION)
|
new = PLUGINS / ('Enigma2_' + VERSION)
|
||||||
if new.exists() or new.is_symlink():
|
if new.exists() or new.is_symlink():
|
||||||
raise ValueError('Target plugin directory already exists; refusing to replace it')
|
raise ValueError('Target plugin directory already exists; refusing to replace it')
|
||||||
|
if bool(args.web) != bool(args.expected_web_sha256):
|
||||||
|
raise ValueError('Web asset and its checksum must be supplied together')
|
||||||
|
if args.web and (not args.web.is_file() or sha256(args.web) != args.expected_web_sha256.lower() or not WEB.is_file()):
|
||||||
|
raise ValueError('Plugin web asset validation failed')
|
||||||
|
if args.enable_slates:
|
||||||
|
enable_slates(CONFIG.read_text(encoding='utf-8-sig'))
|
||||||
|
if args.check_only:
|
||||||
|
print(json.dumps({'preflight': True, 'activated': False, 'restart': False, 'candidate': VERSION,
|
||||||
|
'current': metadata['version'], 'web_included': bool(args.web), 'enable_slates_requested': args.enable_slates}))
|
||||||
|
return
|
||||||
before = hashes()
|
before = hashes()
|
||||||
old_hash = sha256(old / DLL)
|
old_hash = sha256(old / DLL)
|
||||||
boundary = log_boundary(LOGS)
|
boundary = log_boundary(LOGS)
|
||||||
@@ -242,7 +281,16 @@ def main(argv=None):
|
|||||||
backup = BACKUPS / ('plugin-only-' + stamp)
|
backup = BACKUPS / ('plugin-only-' + stamp)
|
||||||
backup.mkdir(mode=0o700, parents=True, exist_ok=False)
|
backup.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||||
stage = backup / 'candidate'
|
stage = backup / 'candidate'
|
||||||
|
assets = []
|
||||||
try:
|
try:
|
||||||
|
if args.web:
|
||||||
|
saved = backup / 'previous-web.js'; shutil.copy2(WEB, saved)
|
||||||
|
assets.append((WEB, args.web, saved))
|
||||||
|
if args.enable_slates:
|
||||||
|
saved = backup / 'previous-plugin-config.xml'; shutil.copy2(CONFIG, saved)
|
||||||
|
prepared = backup / 'candidate-plugin-config.xml'
|
||||||
|
prepared.write_text(enable_slates(CONFIG.read_text(encoding='utf-8-sig')), encoding='utf-8')
|
||||||
|
assets.append((CONFIG, prepared, saved))
|
||||||
shutil.copytree(old, stage, copy_function=shutil.copy2)
|
shutil.copytree(old, stage, copy_function=shutil.copy2)
|
||||||
shutil.copy2(args.dll, stage / DLL)
|
shutil.copy2(args.dll, stage / DLL)
|
||||||
updated = dict(metadata, version=VERSION, status='Active', targetAbi=ABI)
|
updated = dict(metadata, version=VERSION, status='Active', targetAbi=ABI)
|
||||||
@@ -251,9 +299,15 @@ def main(argv=None):
|
|||||||
shutil.chown(path, user='jellyfin', group='jellyfin')
|
shutil.chown(path, user='jellyfin', group='jellyfin')
|
||||||
if sha256(stage / DLL) != expected or hashes() != before:
|
if sha256(stage / DLL) != expected or hashes() != before:
|
||||||
raise RuntimeError('Pre-restart checksum verification failed')
|
raise RuntimeError('Pre-restart checksum verification failed')
|
||||||
|
def verify_plugin(directory, version, digest, candidate):
|
||||||
|
wait_ready(directory, version, digest, before, boundary)
|
||||||
|
for destination, prepared, saved in assets:
|
||||||
|
if sha256(destination) != sha256(prepared if candidate else saved):
|
||||||
|
raise RuntimeError('Plugin asset verification failed')
|
||||||
activate(old, new, stage, backup,
|
activate(old, new, stage, backup,
|
||||||
lambda: wait_ready(new, VERSION, expected, before, boundary),
|
lambda: verify_plugin(new, VERSION, expected, True),
|
||||||
lambda: wait_ready(old, metadata['version'], old_hash, before, boundary))
|
lambda: verify_plugin(old, metadata['version'], old_hash, False),
|
||||||
|
PluginAssetControl(old, new, assets))
|
||||||
finally:
|
finally:
|
||||||
if stage.exists():
|
if stage.exists():
|
||||||
shutil.rmtree(stage)
|
shutil.rmtree(stage)
|
||||||
|
|||||||
+10
-1
@@ -4,6 +4,7 @@ const vm = require('node:vm');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const listeners = {}, documentListeners = {}, elements = [];
|
const listeners = {}, documentListeners = {}, elements = [];
|
||||||
let clock = 0, interval, starts = 0, stops = 0, osd = 0, returned = 0, readyState = 0, remote = false, currentTime = 0, paused = false;
|
let clock = 0, interval, starts = 0, stops = 0, osd = 0, returned = 0, readyState = 0, remote = false, currentTime = 0, paused = false;
|
||||||
|
let startupTimeoutSeconds;
|
||||||
let phase = 'checking_stream', progressCalls = 0, languageQueries = [], progressDeferred;
|
let phase = 'checking_stream', progressCalls = 0, languageQueries = [], progressDeferred;
|
||||||
const catalogue = JSON.parse(fs.readFileSync(__dirname + '/Jellyfin.Plugin.Enigma2/Localization/startup-messages.json', 'utf8'));
|
const catalogue = JSON.parse(fs.readFileSync(__dirname + '/Jellyfin.Plugin.Enigma2/Localization/startup-messages.json', 'utf8'));
|
||||||
const video = { get readyState() { return readyState; }, get currentTime() { return currentTime; }, get paused() { return paused; }, currentSrc: 'stream' };
|
const video = { get readyState() { return readyState; }, get currentTime() { return currentTime; }, get paused() { return paused; }, currentSrc: 'stream' };
|
||||||
@@ -29,7 +30,7 @@ const window = {
|
|||||||
ajax: async options => {
|
ajax: async options => {
|
||||||
if (options.url.startsWith('DecoderIntegration/StartupMessages')) {
|
if (options.url.startsWith('DecoderIntegration/StartupMessages')) {
|
||||||
const language = new URLSearchParams(options.url.split('?')[1]).get('language'); languageQueries.push(language);
|
const language = new URLSearchParams(options.url.split('?')[1]).get('language'); languageQueries.push(language);
|
||||||
return { Language: language.split('-')[0], Texts: catalogue[language.split('-')[0]] };
|
return { Language: language.split('-')[0], Texts: catalogue[language.split('-')[0]], StartupTimeoutSeconds: startupTimeoutSeconds };
|
||||||
}
|
}
|
||||||
if (options.url.startsWith('DecoderIntegration/PlaybackProgress')) {
|
if (options.url.startsWith('DecoderIntegration/PlaybackProgress')) {
|
||||||
++progressCalls; if (progressDeferred) return progressDeferred;
|
++progressCalls; if (progressDeferred) return progressDeferred;
|
||||||
@@ -112,6 +113,14 @@ const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve()
|
|||||||
paused = false;
|
paused = false;
|
||||||
listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} }); await flush();
|
listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} }); await flush();
|
||||||
assert.equal(elements.length, 0); pending[2](); await flush();
|
assert.equal(elements.length, 0); pending[2](); await flush();
|
||||||
|
startupTimeoutSeconds = 135; clock = 0; currentTime = 0; readyState = 0;
|
||||||
|
const stoppedBefore = stops; click(); await flush();
|
||||||
|
clock = 90000; interval(); await flush();
|
||||||
|
assert.equal(stops, stoppedBefore, 'configured metadata budget must survive the old web deadline');
|
||||||
|
clock = 135001; interval(); await flush();
|
||||||
|
assert.equal(stops, stoppedBefore + 1, 'configured web startup remains bounded');
|
||||||
|
pending[3](); await flush();
|
||||||
|
console.log('PASS provider startup budget extends web waiting without disabling timeout or cancellation');
|
||||||
console.log('PASS per-device UI locale, real progress, first frame, later buffering and pause');
|
console.log('PASS per-device UI locale, real progress, first frame, later buffering and pause');
|
||||||
console.log('PASS default channels, explicit programmes, one start, native OSD, Escape, late completion and startup timeout');
|
console.log('PASS default channels, explicit programmes, one start, native OSD, Escape, late completion and startup timeout');
|
||||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
|
|||||||
+5
-39
@@ -1,39 +1,5 @@
|
|||||||
// Isolated headless browser; temporary server account is always removed.
|
// Retired production helper: its old implementation extracted an API key from SQLite.
|
||||||
const {execFileSync}=require('node:child_process');
|
// Offline regression: node test-web-bridge.cjs. Real playback needs an explicitly
|
||||||
const {chromium}=require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
// authenticated client session; never manufacture one from the server database.
|
||||||
const ssh=py=>execFileSync('ssh',['-o','BatchMode=yes','root@192.168.100.25','pct exec 100191 -- python3 -'],{input:py,encoding:'utf8'}).trim().split('\n').at(-1);
|
console.error('Legacy production browser test disabled. Use test-web-bridge.cjs for offline checks or an explicitly authenticated client session.');
|
||||||
const setup=`import sqlite3,json,secrets,urllib.request
|
process.exitCode = 2;
|
||||||
c=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()
|
|
||||||
def api(p,d):
|
|
||||||
r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"','Content-Type':'application/json'},data=json.dumps(d).encode());b=urllib.request.urlopen(r).read();return json.loads(b) if b else None
|
|
||||||
n='decoder-browser-check-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});api('/Users/'+u['Id']+'/Password',{'CurrentPw':'','NewPw':p});print(json.dumps({'id':u['Id'],'name':n,'password':p}))
|
|
||||||
`;
|
|
||||||
(async()=>{
|
|
||||||
const user=JSON.parse(ssh(setup));let browser;
|
|
||||||
try{
|
|
||||||
browser=await chromium.launch({headless:true,channel:'msedge'});const page=await browser.newPage({viewport:{width:1280,height:900}});
|
|
||||||
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
|
||||||
const base='http://192.168.100.191:8096/web/index.html';
|
|
||||||
await page.goto(base);await page.locator('#txtManualName').fill(user.name);await page.locator('#txtManualPassword').fill(user.password);await page.getByRole('checkbox').check();await page.getByRole('button',{name:'Sign In',exact:true}).click();
|
|
||||||
await page.waitForURL(/home/,{timeout:20000});
|
|
||||||
await page.waitForFunction(()=>JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers?.some(s=>s.AccessToken&&s.UserId));
|
|
||||||
await page.goto('http://192.168.100.191:8096/DecoderIntegration/Channels');
|
|
||||||
try { await page.locator('#channelRows section').first().waitFor({timeout:30000}); }
|
|
||||||
catch(error) { console.log(JSON.stringify({url:page.url(),text:await page.locator('body').innerText(),errors})); await page.screenshot({path:process.argv[2]||'decoder-channels.png'});throw error; }
|
|
||||||
await page.goto(base+'#/livetv');await page.waitForURL(/tab=2/,{timeout:15000});
|
|
||||||
const target=process.env.DECODER_TEST_CHANNEL || 'TVN HD';
|
|
||||||
const image=page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:new RegExp('^\\d+ '+target+'$')})}).locator('a.cardImageContainer');
|
|
||||||
await image.click({position:{x:20,y:20}});
|
|
||||||
await page.locator('#decoderPlaybackExit').waitFor({timeout:15000});
|
|
||||||
let video=false;
|
|
||||||
try { await page.waitForFunction(()=>[...document.querySelectorAll('video')].some(v=>v.currentTime>0&&v.readyState>=2),{},{timeout:20000});video=true; } catch {}
|
|
||||||
await page.screenshot({path:process.argv[2]||'decoder-web-player.png'});
|
|
||||||
if(await page.locator('#decoderPlaybackExit').count())await page.locator('#decoderPlaybackExit').click();
|
|
||||||
await page.waitForURL(/livetv.*tab=2/,{timeout:15000});
|
|
||||||
await page.locator('.card[data-type="TvChannel"]').first().waitFor({timeout:15000});
|
|
||||||
console.log(JSON.stringify({default_channels:'PASS',exit_to_channels:'PASS',channel:target,video:video?'PASS':'NO_FRAME_FROM_SOURCE',browser_errors:errors}));
|
|
||||||
}finally{
|
|
||||||
if(browser)await browser.close();
|
|
||||||
ssh(`import sqlite3,urllib.request,pathlib\nc=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()\nurllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Users/${user.id}',headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'},method='DELETE')).read()\npathlib.Path('/var/lib/jellyfin/decoder-integration/users/${user.id.replace(/-/g,'')}.json').unlink(missing_ok=True)\nprint('clean')`);
|
|
||||||
}
|
|
||||||
})().catch(e=>{console.error(e.message);process.exitCode=1});
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
|
|||||||
api.ajax({ type: 'GET', dataType: 'json', url: api.getUrl('DecoderIntegration/StartupMessages', { language }) }),
|
api.ajax({ type: 'GET', dataType: 'json', url: api.getUrl('DecoderIntegration/StartupMessages', { language }) }),
|
||||||
new Promise(resolve => { timer = setTimeout(() => resolve(null), 1000); })
|
new Promise(resolve => { timer = setTimeout(() => resolve(null), 1000); })
|
||||||
]);
|
]);
|
||||||
return response?.Texts ? { ...fallback, ...response.Texts } : fallback;
|
return response?.Texts ? { ...fallback, ...response.Texts, StartupTimeoutSeconds: response.StartupTimeoutSeconds } : fallback;
|
||||||
} catch { return fallback; } finally { clearTimeout(timer); }
|
} catch { return fallback; } finally { clearTimeout(timer); }
|
||||||
};
|
};
|
||||||
let generation = 0, starting = null, cancelButton = null, watcher = null, statusPanel = null, statusText = null, elapsedText = null, playbackSequence = 0;
|
let generation = 0, starting = null, cancelButton = null, watcher = null, statusPanel = null, statusText = null, elapsedText = null, playbackSequence = 0;
|
||||||
@@ -97,9 +97,11 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
|
|||||||
cancelButton.style.cssText = 'position:fixed;right:16px;top:72px;z-index:2147483647;background:#252525;color:white;border:1px solid #aaa;border-radius:6px;padding:12px;font:16px system-ui;cursor:pointer';
|
cancelButton.style.cssText = 'position:fixed;right:16px;top:72px;z-index:2147483647;background:#252525;color:white;border:1px solid #aaa;border-radius:6px;padding:12px;font:16px system-ui;cursor:pointer';
|
||||||
cancelButton.onclick = () => void leave(); document.body.append(cancelButton);
|
cancelButton.onclick = () => void leave(); document.body.append(cancelButton);
|
||||||
showStatus(item.Name);
|
showStatus(item.Name);
|
||||||
// Allow the bounded metadata probe (25 s) plus HLS startup (20 s).
|
// Use this provider's bounded startup budget; retain compatibility with older DLLs.
|
||||||
let opened = false, opening = false, hasPlayed = false, polling = false, pollFailures = 0, lastPoll = -Infinity;
|
let opened = false, opening = false, hasPlayed = false, polling = false, pollFailures = 0, lastPoll = -Infinity;
|
||||||
const began = Date.now(), deadline = began + 45000, initialVideo = document.querySelector('video'), initialSource = initialVideo?.currentSrc, initialSequence = playbackSequence;
|
const configuredWait = Number(messages.StartupTimeoutSeconds);
|
||||||
|
const waitMs = Number.isFinite(configuredWait) && configuredWait >= 45 && configuredWait <= 225 ? configuredWait * 1000 : 45000;
|
||||||
|
const began = Date.now(), deadline = began + waitMs, initialVideo = document.querySelector('video'), initialSource = initialVideo?.currentSrc, initialSequence = playbackSequence;
|
||||||
watcher = setInterval(() => {
|
watcher = setInterval(() => {
|
||||||
if (ticket !== generation) return;
|
if (ticket !== generation) return;
|
||||||
const video = document.querySelector('video');
|
const video = document.querySelector('video');
|
||||||
|
|||||||
Reference in New Issue
Block a user