using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; namespace Jellyfin.Plugin.Enigma2; /// Bounded, never-ending HLS timeline shared by language-specific viewers. public sealed class ContinuityTimeline { // 112 frames at 50 Hz == 105 AAC frames at 48 kHz: exact AV boundary. public const double SegmentSeconds = 2.24; public sealed record Segment(long Sequence, long DiscontinuitiesBefore, bool Discontinuity, double Duration, byte[] Video, string Slate, long Start90k, long? ClockOffset90k = null); private readonly double slateSeconds; private readonly List segments = new(); private readonly object gate = new(); private long next, discontinuities; private long clock90k = 126000; private string previousEpoch; private string clockEpoch; private long sourceOffset90k, sourceWrap90k, lastDecode90k; private DateTime lastAppend, lastLive; public ContinuityTimeline(DateTime now, bool withSlates = true, double slateSeconds = SegmentSeconds) { this.slateSeconds = slateSeconds; lastAppend = now; lastLive = DateTime.MinValue; if (!withSlates) return; Add(null, "waiting", "slate", slateSeconds, now); Add(null, "waiting", "slate", slateSeconds, now); } private void Add(byte[] video, string slate, string epoch, double duration, DateTime now, long? offset = null, long? start = null, long? end = null) { var discontinuity = next > 0 && (slate != null || previousEpoch != epoch); segments.Add(new(next++, discontinuities, discontinuity, duration, video, slate, start ?? clock90k, offset)); clock90k = end ?? clock90k + (long)Math.Round(duration * 90000); if (discontinuity) discontinuities++; previousEpoch = epoch; lastAppend = now; while (segments.Count > 8) segments.RemoveAt(0); } public void Live(byte[] video, string epoch, double duration, DateTime now) { if (video == null || video.Length == 0 || video.Length > 32 * 1024 * 1024 || duration <= 0 || duration > 30) throw new ArgumentException("Invalid live segment"); lock (gate) { lastLive = now; var bounds = TransportStreamClock.ClockBounds(video); if (bounds == null) { Add(video, null, epoch, duration, now); return; } var times = bounds.Value; // Keep one offset for an entire producer epoch. Anchoring each segment // to its first PTS corrupts DTS order when copied video has B-frames. if (clockEpoch != epoch || previousEpoch != epoch) { clockEpoch = epoch; sourceWrap90k = 0; sourceOffset90k = clock90k + 9000 - times.Earliest; } else if (times.Decode + sourceWrap90k < lastDecode90k - (1L << 32)) sourceWrap90k += 1L << 33; lastDecode90k = times.Decode + sourceWrap90k; var offset = sourceOffset90k + sourceWrap90k; var end = Math.Max(times.Decode + (long)Math.Round(duration * 90000), times.Latest + 9000) + offset; Add(video, null, epoch, duration, now, offset, times.Decode + offset, end); } } public void Tick(DateTime now, bool retrying) { lock (gate) if ((now - lastAppend).TotalSeconds >= 2 && (now - lastLive).TotalSeconds >= 5) Add(null, retrying ? "retrying" : "waiting", "slate", slateSeconds, now); } public Segment Find(long sequence) { lock (gate) return segments.FirstOrDefault(s => s.Sequence == sequence); } public string Playlist() { lock (gate) { var window = segments.TakeLast(4).ToArray(); var text = new StringBuilder($"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:{Math.Max(5, Math.Ceiling(window.Select(s => s.Duration).DefaultIfEmpty(5).Max()))}\n"); if (window.Length == 0) return text.ToString(); text.Append("#EXT-X-MEDIA-SEQUENCE:").Append(window[0].Sequence).Append('\n'); text.Append("#EXT-X-DISCONTINUITY-SEQUENCE:").Append(window[0].DiscontinuitiesBefore).Append('\n'); foreach (var segment in window) { if (segment.Discontinuity) text.Append("#EXT-X-DISCONTINUITY\n"); text.Append("#EXTINF:").Append(segment.Duration.ToString("0.000000", CultureInfo.InvariantCulture)).Append(",\n"); text.Append(segment.Sequence).Append(".ts\n"); } return text.ToString(); // Never ENDLIST: only the viewer closes playback. } } }