DynamicHlsPlaylistGenerator.cs 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Diagnostics.CodeAnalysis;
  4. using System.Globalization;
  5. using System.IO;
  6. using System.Linq;
  7. using System.Text;
  8. using Jellyfin.MediaEncoding.Hls.Extractors;
  9. using Jellyfin.MediaEncoding.Keyframes;
  10. using MediaBrowser.Common.Configuration;
  11. using MediaBrowser.Controller.Configuration;
  12. using MediaBrowser.Controller.MediaEncoding;
  13. namespace Jellyfin.MediaEncoding.Hls.Playlist;
  14. /// <inheritdoc />
  15. public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator
  16. {
  17. private readonly IServerConfigurationManager _serverConfigurationManager;
  18. private readonly IKeyframeExtractor[] _extractors;
  19. /// <summary>
  20. /// Initializes a new instance of the <see cref="DynamicHlsPlaylistGenerator"/> class.
  21. /// </summary>
  22. /// <param name="serverConfigurationManager">An instance of the see <see cref="IServerConfigurationManager"/> interface.</param>
  23. /// <param name="extractors">An instance of <see cref="IEnumerable{IKeyframeExtractor}"/>.</param>
  24. public DynamicHlsPlaylistGenerator(IServerConfigurationManager serverConfigurationManager, IEnumerable<IKeyframeExtractor> extractors)
  25. {
  26. _serverConfigurationManager = serverConfigurationManager;
  27. _extractors = extractors.Where(e => e.IsMetadataBased).ToArray();
  28. }
  29. /// <inheritdoc />
  30. public string CreateMainPlaylist(CreateMainPlaylistRequest request)
  31. {
  32. IReadOnlyList<double> segments;
  33. // For video transcodes it is sufficient with equal length segments as ffmpeg will create new keyframes
  34. if (request.IsRemuxingVideo && TryExtractKeyframes(request.FilePath, out var keyframeData))
  35. {
  36. segments = ComputeSegments(keyframeData, request.DesiredSegmentLengthMs);
  37. }
  38. else
  39. {
  40. segments = ComputeEqualLengthSegments(request.DesiredSegmentLengthMs, request.TotalRuntimeTicks);
  41. }
  42. var segmentExtension = EncodingHelper.GetSegmentFileExtension(request.SegmentContainer);
  43. // http://ffmpeg.org/ffmpeg-all.html#toc-hls-2
  44. var isHlsInFmp4 = string.Equals(segmentExtension, ".mp4", StringComparison.OrdinalIgnoreCase);
  45. var hlsVersion = isHlsInFmp4 ? "7" : "3";
  46. var builder = new StringBuilder(128);
  47. builder.AppendLine("#EXTM3U")
  48. .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
  49. .Append("#EXT-X-VERSION:")
  50. .Append(hlsVersion)
  51. .AppendLine()
  52. .Append("#EXT-X-TARGETDURATION:")
  53. .Append(Math.Ceiling(segments.Count > 0 ? segments.Max() : request.DesiredSegmentLengthMs))
  54. .AppendLine()
  55. .AppendLine("#EXT-X-MEDIA-SEQUENCE:0");
  56. var index = 0;
  57. if (isHlsInFmp4)
  58. {
  59. // Init file that only includes fMP4 headers
  60. builder.Append("#EXT-X-MAP:URI=\"")
  61. .Append(request.EndpointPrefix)
  62. .Append("-1")
  63. .Append(segmentExtension)
  64. .Append(request.QueryString)
  65. .Append("&runtimeTicks=0")
  66. .Append("&actualSegmentLengthTicks=0")
  67. .Append('"')
  68. .AppendLine();
  69. }
  70. long currentRuntimeInSeconds = 0;
  71. foreach (var length in segments)
  72. {
  73. // Manually convert to ticks to avoid precision loss when converting double
  74. var lengthTicks = Convert.ToInt64(length * TimeSpan.TicksPerSecond);
  75. builder.Append("#EXTINF:")
  76. .Append(length.ToString("0.000000", CultureInfo.InvariantCulture))
  77. .AppendLine(", nodesc")
  78. .Append(request.EndpointPrefix)
  79. .Append(index++)
  80. .Append(segmentExtension)
  81. .Append(request.QueryString)
  82. .Append("&runtimeTicks=")
  83. .Append(currentRuntimeInSeconds)
  84. .Append("&actualSegmentLengthTicks=")
  85. .Append(lengthTicks)
  86. .AppendLine();
  87. currentRuntimeInSeconds += lengthTicks;
  88. }
  89. builder.AppendLine("#EXT-X-ENDLIST");
  90. return builder.ToString();
  91. }
  92. private bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData)
  93. {
  94. keyframeData = null;
  95. if (!IsExtractionAllowedForFile(filePath, _serverConfigurationManager.GetEncodingOptions().AllowOnDemandMetadataBasedKeyframeExtractionForExtensions))
  96. {
  97. return false;
  98. }
  99. var len = _extractors.Length;
  100. for (var i = 0; i < len; i++)
  101. {
  102. var extractor = _extractors[i];
  103. if (!extractor.TryExtractKeyframes(filePath, out var result))
  104. {
  105. continue;
  106. }
  107. keyframeData = result;
  108. return true;
  109. }
  110. return false;
  111. }
  112. internal static bool IsExtractionAllowedForFile(ReadOnlySpan<char> filePath, string[] allowedExtensions)
  113. {
  114. var extension = Path.GetExtension(filePath);
  115. if (extension.IsEmpty)
  116. {
  117. return false;
  118. }
  119. // Remove the leading dot
  120. var extensionWithoutDot = extension[1..];
  121. for (var i = 0; i < allowedExtensions.Length; i++)
  122. {
  123. var allowedExtension = allowedExtensions[i].AsSpan().TrimStart('.');
  124. if (extensionWithoutDot.Equals(allowedExtension, StringComparison.OrdinalIgnoreCase))
  125. {
  126. return true;
  127. }
  128. }
  129. return false;
  130. }
  131. internal static IReadOnlyList<double> ComputeSegments(KeyframeData keyframeData, int desiredSegmentLengthMs)
  132. {
  133. if (keyframeData.KeyframeTicks.Count > 0 && keyframeData.TotalDuration < keyframeData.KeyframeTicks[^1])
  134. {
  135. throw new ArgumentException("Invalid duration in keyframe data", nameof(keyframeData));
  136. }
  137. long lastKeyframe = 0;
  138. var result = new List<double>();
  139. // Scale the segment length to ticks to match the keyframes
  140. var desiredSegmentLengthTicks = TimeSpan.FromMilliseconds(desiredSegmentLengthMs).Ticks;
  141. var desiredCutTime = desiredSegmentLengthTicks;
  142. for (var j = 0; j < keyframeData.KeyframeTicks.Count; j++)
  143. {
  144. var keyframe = keyframeData.KeyframeTicks[j];
  145. if (keyframe >= desiredCutTime)
  146. {
  147. var currentSegmentLength = keyframe - lastKeyframe;
  148. result.Add(TimeSpan.FromTicks(currentSegmentLength).TotalSeconds);
  149. lastKeyframe = keyframe;
  150. desiredCutTime += desiredSegmentLengthTicks;
  151. }
  152. }
  153. result.Add(TimeSpan.FromTicks(keyframeData.TotalDuration - lastKeyframe).TotalSeconds);
  154. return result;
  155. }
  156. internal static double[] ComputeEqualLengthSegments(int desiredSegmentLengthMs, long totalRuntimeTicks)
  157. {
  158. if (desiredSegmentLengthMs == 0 || totalRuntimeTicks == 0)
  159. {
  160. throw new InvalidOperationException($"Invalid segment length ({desiredSegmentLengthMs}) or runtime ticks ({totalRuntimeTicks})");
  161. }
  162. var desiredSegmentLength = TimeSpan.FromMilliseconds(desiredSegmentLengthMs);
  163. var segmentLengthTicks = desiredSegmentLength.Ticks;
  164. var wholeSegments = totalRuntimeTicks / segmentLengthTicks;
  165. var remainingTicks = totalRuntimeTicks % segmentLengthTicks;
  166. var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1);
  167. var segments = new double[segmentsLen];
  168. for (int i = 0; i < wholeSegments; i++)
  169. {
  170. segments[i] = desiredSegmentLength.TotalSeconds;
  171. }
  172. if (remainingTicks != 0)
  173. {
  174. segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds;
  175. }
  176. return segments;
  177. }
  178. }