using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using Jellyfin.Extensions.Json; using Jellyfin.MediaEncoding.Keyframes; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.MediaEncoding; using Microsoft.Extensions.Logging; namespace Jellyfin.MediaEncoding.Hls.Playlist { /// public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator { private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IMediaEncoder _mediaEncoder; private readonly IApplicationPaths _applicationPaths; private readonly KeyframeExtractor _keyframeExtractor; private const string DefaultContainerExtension = ".ts"; /// /// Initializes a new instance of the class. /// /// An instance of the see interface. /// An instance of the see interface. /// An instance of the interface. /// An instance of the see interface. public DynamicHlsPlaylistGenerator(IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, IApplicationPaths applicationPaths, ILoggerFactory loggerFactory) { _serverConfigurationManager = serverConfigurationManager; _mediaEncoder = mediaEncoder; _applicationPaths = applicationPaths; _keyframeExtractor = new KeyframeExtractor(loggerFactory.CreateLogger()); } private string KeyframeCachePath => Path.Combine(_applicationPaths.DataPath, "keyframes"); /// public string CreateMainPlaylist(CreateMainPlaylistRequest request) { IReadOnlyList segments; if (IsExtractionAllowed(request.FilePath)) { segments = ComputeSegments(request.FilePath, request.DesiredSegmentLengthMs); } else { segments = ComputeEqualLengthSegments(request.DesiredSegmentLengthMs, request.TotalRuntimeTicks); } var segmentExtension = GetSegmentFileExtension(request.SegmentContainer); // http://ffmpeg.org/ffmpeg-all.html#toc-hls-2 var isHlsInFmp4 = string.Equals(segmentExtension, "mp4", StringComparison.OrdinalIgnoreCase); var hlsVersion = isHlsInFmp4 ? "7" : "3"; var builder = new StringBuilder(128); builder.AppendLine("#EXTM3U") .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD") .Append("#EXT-X-VERSION:") .Append(hlsVersion) .AppendLine() .Append("#EXT-X-TARGETDURATION:") .Append(Math.Ceiling(segments.Count > 0 ? segments.Max() : request.DesiredSegmentLengthMs)) .AppendLine() .AppendLine("#EXT-X-MEDIA-SEQUENCE:0"); var index = 0; if (isHlsInFmp4) { builder.Append("#EXT-X-MAP:URI=\"") .Append(request.EndpointPrefix) .Append("-1") .Append(segmentExtension) .Append(request.QueryString) .Append('"') .AppendLine(); } double currentRuntimeInSeconds = 0; foreach (var length in segments) { builder.Append("#EXTINF:") .Append(length.ToString("0.0000", CultureInfo.InvariantCulture)) .AppendLine(", nodesc") .Append(request.EndpointPrefix) .Append(index++) .Append(segmentExtension) .Append(request.QueryString) .Append("&runtimeTicks=") .Append(TimeSpan.FromSeconds(currentRuntimeInSeconds).Ticks) .Append("&actualSegmentLengthTicks=") .Append(TimeSpan.FromSeconds(length).Ticks) .AppendLine(); currentRuntimeInSeconds += length; } builder.AppendLine("#EXT-X-ENDLIST"); return builder.ToString(); } private IReadOnlyList ComputeSegments(string filePath, int desiredSegmentLengthMs) { KeyframeData keyframeData; var cachePath = GetCachePath(filePath); if (TryReadFromCache(cachePath, out var cachedResult)) { keyframeData = cachedResult; } else { keyframeData = _keyframeExtractor.GetKeyframeData(filePath, _mediaEncoder.ProbePath, string.Empty); CacheResult(cachePath, keyframeData); } long lastKeyframe = 0; var result = new List(); // Scale the segment length to ticks to match the keyframes var desiredSegmentLengthTicks = TimeSpan.FromMilliseconds(desiredSegmentLengthMs).Ticks; var desiredCutTime = desiredSegmentLengthTicks; for (var j = 0; j < keyframeData.KeyframeTicks.Count; j++) { var keyframe = keyframeData.KeyframeTicks[j]; if (keyframe >= desiredCutTime) { var currentSegmentLength = keyframe - lastKeyframe; result.Add(TimeSpan.FromTicks(currentSegmentLength).TotalSeconds); lastKeyframe = keyframe; desiredCutTime += desiredSegmentLengthTicks; } } result.Add(TimeSpan.FromTicks(keyframeData.TotalDuration - lastKeyframe).TotalSeconds); return result; } private void CacheResult(string cachePath, KeyframeData keyframeData) { var json = JsonSerializer.Serialize(keyframeData, _jsonOptions); Directory.CreateDirectory(Path.GetDirectoryName(cachePath) ?? throw new ArgumentException($"Provided path ({cachePath}) is not valid.", nameof(cachePath))); File.WriteAllText(cachePath, json); } private string GetCachePath(string filePath) { var lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath); ReadOnlySpan filename = (filePath + "_" + lastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5() + ".json"; var prefix = filename.Slice(0, 1); return Path.Join(KeyframeCachePath, prefix, filename); } private bool TryReadFromCache(string cachePath, [NotNullWhen(true)] out KeyframeData? cachedResult) { if (File.Exists(cachePath)) { var bytes = File.ReadAllBytes(cachePath); cachedResult = JsonSerializer.Deserialize(bytes, _jsonOptions); return cachedResult != null; } cachedResult = null; return false; } private bool IsExtractionAllowed(ReadOnlySpan filePath) { // Remove the leading dot var extension = Path.GetExtension(filePath)[1..]; var allowedExtensions = _serverConfigurationManager.GetEncodingOptions().AllowAutomaticKeyframeExtractionForExtensions; for (var i = 0; i < allowedExtensions.Length; i++) { var allowedExtension = allowedExtensions[i]; if (extension.Equals(allowedExtension, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } private static double[] ComputeEqualLengthSegments(long desiredSegmentLengthMs, long totalRuntimeTicks) { var segmentLengthTicks = TimeSpan.FromMilliseconds(desiredSegmentLengthMs).Ticks; var wholeSegments = totalRuntimeTicks / segmentLengthTicks; var remainingTicks = totalRuntimeTicks % segmentLengthTicks; var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1); var segments = new double[segmentsLen]; for (int i = 0; i < wholeSegments; i++) { segments[i] = desiredSegmentLengthMs; } if (remainingTicks != 0) { segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds; } return segments; } // TODO copied from DynamicHlsController private static string GetSegmentFileExtension(string segmentContainer) { if (!string.IsNullOrWhiteSpace(segmentContainer)) { return "." + segmentContainer; } return DefaultContainerExtension; } } }