Browse Source

switch ffmpeg to hls muxer for live streaming

segment muxer cannot make fMP4 init file.
'-strict -2' option doesn't work with segment muxer for flac remuxing.
nyanmisaka 4 năm trước cách đây
mục cha
commit
8c0778e827

+ 49 - 36
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -41,6 +41,9 @@ namespace Jellyfin.Api.Controllers
     [Authorize(Policy = Policies.DefaultAuthorization)]
     public class DynamicHlsController : BaseJellyfinApiController
     {
+        private const string DefaultEncoderPreset = "veryfast";
+        private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
+
         private readonly ILibraryManager _libraryManager;
         private readonly IUserManager _userManager;
         private readonly IDlnaManager _dlnaManager;
@@ -56,8 +59,7 @@ namespace Jellyfin.Api.Controllers
         private readonly ILogger<DynamicHlsController> _logger;
         private readonly EncodingHelper _encodingHelper;
         private readonly DynamicHlsHelper _dynamicHlsHelper;
-
-        private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
+        private readonly EncodingOptions _encodingOptions;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
@@ -92,6 +94,8 @@ namespace Jellyfin.Api.Controllers
             ILogger<DynamicHlsController> logger,
             DynamicHlsHelper dynamicHlsHelper)
         {
+            _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
+
             _libraryManager = libraryManager;
             _userManager = userManager;
             _dlnaManager = dlnaManager;
@@ -106,8 +110,7 @@ namespace Jellyfin.Api.Controllers
             _transcodingJobHelper = transcodingJobHelper;
             _logger = logger;
             _dynamicHlsHelper = dynamicHlsHelper;
-
-            _encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
+            _encodingOptions = serverConfigurationManager.GetEncodingOptions();
         }
 
         /// <summary>
@@ -1255,7 +1258,7 @@ namespace Jellyfin.Api.Controllers
 
                     if (segmentId == -1)
                     {
-                        _logger.LogDebug("Starting transcoding because fmp4 header file is being requested");
+                        _logger.LogDebug("Starting transcoding because fmp4 init file is being requested");
                         startTranscoding = true;
                         segmentId = 0;
                     }
@@ -1291,11 +1294,10 @@ namespace Jellyfin.Api.Controllers
                             streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId);
 
                             state.WaitForPath = segmentPath;
-                            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
                             job = await _transcodingJobHelper.StartFfMpeg(
                                 state,
                                 playlistPath,
-                                GetCommandLineArguments(playlistPath, encodingOptions, state, true, segmentId),
+                                GetCommandLineArguments(playlistPath, state, true, segmentId),
                                 Request,
                                 _transcodingJobType,
                                 cancellationTokenSource).ConfigureAwait(false);
@@ -1351,11 +1353,10 @@ namespace Jellyfin.Api.Controllers
             return result.ToArray();
         }
 
-        private string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding, int startNumber)
+        private string GetCommandLineArguments(string outputPath, StreamState state, bool isEncoding, int startNumber)
         {
-            var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
-
-            var threads = _encodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec);
+            var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+            var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
 
             if (state.BaseRequest.BreakOnNonKeyFrames)
             {
@@ -1367,11 +1368,9 @@ namespace Jellyfin.Api.Controllers
                 state.BaseRequest.BreakOnNonKeyFrames = false;
             }
 
-            var inputModifier = _encodingHelper.GetInputModifier(state, encodingOptions);
-
             // If isEncoding is true we're actually starting ffmpeg
             var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0";
-
+            var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
             var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
 
             var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
@@ -1379,7 +1378,7 @@ namespace Jellyfin.Api.Controllers
             var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer);
             var outputTsArg = outputPrefix + "%d" + outputExtension;
 
-            var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
+            var segmentFormat = outputExtension.TrimStart('.');
             if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
             {
                 segmentFormat = "mpegts";
@@ -1406,19 +1405,19 @@ namespace Jellyfin.Api.Controllers
                 _logger.LogError("Invalid HLS segment container: " + segmentFormat);
             }
 
-            var maxMuxingQueueSize = encodingOptions.MaxMuxingQueueSize > 128
-                ? encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
+            var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
+                ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
                 : "128";
 
             return string.Format(
                 CultureInfo.InvariantCulture,
                 "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts 0 -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -individual_header_trailer 0 -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"",
                 inputModifier,
-                _encodingHelper.GetInputArgument(state, encodingOptions),
+                _encodingHelper.GetInputArgument(state, _encodingOptions),
                 threads,
                 mapArgs,
-                GetVideoArguments(state, encodingOptions, startNumber),
-                GetAudioArguments(state, encodingOptions),
+                GetVideoArguments(state, startNumber),
+                GetAudioArguments(state),
                 maxMuxingQueueSize,
                 state.SegmentLength.ToString(CultureInfo.InvariantCulture),
                 segmentFormat,
@@ -1427,7 +1426,12 @@ namespace Jellyfin.Api.Controllers
                 outputPath).Trim();
         }
 
-        private string GetAudioArguments(StreamState state, EncodingOptions encodingOptions)
+        /// <summary>
+        /// Gets the audio arguments for transcoding.
+        /// </summary>
+        /// <param name="state">The <see cref="StreamState"/>.</param>
+        /// <returns>The command line arguments for audio transcoding.</returns>
+        private string GetAudioArguments(StreamState state)
         {
             if (state.AudioStream == null)
             {
@@ -1468,7 +1472,7 @@ namespace Jellyfin.Api.Controllers
 
             if (EncodingHelper.IsCopyCodec(audioCodec))
             {
-                var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
+                var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
 
                 if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
                 {
@@ -1499,23 +1503,34 @@ namespace Jellyfin.Api.Controllers
                 args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
             }
 
-            args += " " + _encodingHelper.GetAudioFilterParam(state, encodingOptions, true);
+            args += " " + _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
 
             return args;
         }
 
-        private string GetVideoArguments(StreamState state, EncodingOptions encodingOptions, int startNumber)
+        /// <summary>
+        /// Gets the video arguments for transcoding.
+        /// </summary>
+        /// <param name="state">The <see cref="StreamState"/>.</param>
+        /// <param name="startNumber">The first number in the hls sequence.</param>
+        /// <returns>The command line arguments for video transcoding.</returns>
+        private string GetVideoArguments(StreamState state, int startNumber)
         {
+            if (state.VideoStream == null)
+            {
+                return string.Empty;
+            }
+
             if (!state.IsOutputVideo)
             {
                 return string.Empty;
             }
 
-            var codec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
+            var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
 
             var args = "-codec:v:0 " + codec;
 
-            // Prefer hvc1 to hev1
+            // Prefer hvc1 to hev1.
             if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
                 || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
                 || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
@@ -1529,7 +1544,7 @@ namespace Jellyfin.Api.Controllers
             //     args += " -mpegts_m2ts_mode 1";
             // }
 
-            // See if we can save come cpu cycles by avoiding encoding
+            // See if we can save come cpu cycles by avoiding encoding.
             if (EncodingHelper.IsCopyCodec(codec))
             {
                 if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
@@ -1553,22 +1568,21 @@ namespace Jellyfin.Api.Controllers
                     state.SegmentLength);
 
                 var framerate = state.VideoStream?.RealFrameRate;
-
                 if (framerate.HasValue)
                 {
                     // This is to make sure keyframe interval is limited to our segment,
                     // as forcing keyframes is not enough.
                     // Example: we encoded half of desired length, then codec detected
                     // scene cut and inserted a keyframe; next forced keyframe would
-                    // be created outside of segment, which breaks seeking
-                    // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe
+                    // be created outside of segment, which breaks seeking.
+                    // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe.
                     gopArg = string.Format(
                         CultureInfo.InvariantCulture,
                         " -g {0} -keyint_min {0} -sc_threshold 0",
                         Math.Ceiling(state.SegmentLength * framerate.Value));
                 }
 
-                args += " " + _encodingHelper.GetVideoQualityParam(state, codec, encodingOptions, "veryfast");
+                args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset);
 
                 // Unable to force key frames using these encoders, set key frames by GOP.
                 if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
@@ -1602,16 +1616,15 @@ namespace Jellyfin.Api.Controllers
 
                 var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
 
-                // This is for graphical subs
                 if (hasGraphicalSubs)
                 {
-                    args += _encodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec);
+                    // Graphical subs overlay and resolution params.
+                    args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
                 }
-
-                // Add resolution params, if specified
                 else
                 {
-                    args += _encodingHelper.GetOutputSizeParam(state, encodingOptions, codec);
+                    // Resolution params.
+                    args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
                 }
 
                 // -start_at_zero is necessary to use with -ss when seeking,

+ 160 - 35
Jellyfin.Api/Controllers/VideoHlsController.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Globalization;
 using System.IO;
+using System.Runtime.InteropServices;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
@@ -37,7 +38,7 @@ namespace Jellyfin.Api.Controllers
     public class VideoHlsController : BaseJellyfinApiController
     {
         private const string DefaultEncoderPreset = "superfast";
-        private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
+        private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
 
         private readonly EncodingHelper _encodingHelper;
         private readonly IDlnaManager _dlnaManager;
@@ -290,30 +291,30 @@ namespace Jellyfin.Api.Controllers
                     _dlnaManager,
                     _deviceManager,
                     _transcodingJobHelper,
-                    TranscodingJobType,
+                    _transcodingJobType,
                     cancellationTokenSource.Token)
                 .ConfigureAwait(false);
 
             TranscodingJobDto? job = null;
-            var playlist = state.OutputFilePath;
+            var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
 
-            if (!System.IO.File.Exists(playlist))
+            if (!System.IO.File.Exists(playlistPath))
             {
-                var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlist);
+                var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
                 await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
                 try
                 {
-                    if (!System.IO.File.Exists(playlist))
+                    if (!System.IO.File.Exists(playlistPath))
                     {
                         // If the playlist doesn't already exist, startup ffmpeg
                         try
                         {
                             job = await _transcodingJobHelper.StartFfMpeg(
                                     state,
-                                    playlist,
-                                    GetCommandLineArguments(playlist, state),
+                                    playlistPath,
+                                    GetCommandLineArguments(playlistPath, state),
                                     Request,
-                                    TranscodingJobType,
+                                    _transcodingJobType,
                                     cancellationTokenSource)
                                 .ConfigureAwait(false);
                             job.IsLiveOutput = true;
@@ -327,7 +328,7 @@ namespace Jellyfin.Api.Controllers
                         minSegments = state.MinSegments;
                         if (minSegments > 0)
                         {
-                            await HlsHelpers.WaitForMinimumSegmentCount(playlist, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false);
+                            await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false);
                         }
                     }
                 }
@@ -337,14 +338,14 @@ namespace Jellyfin.Api.Controllers
                 }
             }
 
-            job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlist, TranscodingJobType);
+            job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType);
 
             if (job != null)
             {
                 _transcodingJobHelper.OnTranscodeEndRequest(job);
             }
 
-            var playlistText = HlsHelpers.GetLivePlaylistText(playlist, state.SegmentLength);
+            var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state);
 
             return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8"));
         }
@@ -360,14 +361,43 @@ namespace Jellyfin.Api.Controllers
             var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
             var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
             var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
-            var format = !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) ? "." + state.Request.SegmentContainer : ".ts";
-            var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + format;
+            var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
 
-            var segmentFormat = format.TrimStart('.');
+            var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+            var outputPrefix = Path.Combine(Path.GetDirectoryName(outputPath), outputFileNameWithoutExtension);
+            var outputExtension = HlsHelpers.GetSegmentFileExtension(state.Request.SegmentContainer);
+            var outputTsArg = outputPrefix + "%d" + outputExtension;
+
+            var segmentFormat = outputExtension.TrimStart('.');
             if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
             {
                 segmentFormat = "mpegts";
             }
+            else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
+            {
+                var outputFmp4HeaderArg = string.Empty;
+                var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+                if (isWindows)
+                {
+                    // on Windows, the path of fmp4 header file needs to be configured
+                    outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"";
+                }
+                else
+                {
+                    // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
+                    outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"";
+                }
+
+                segmentFormat = "fmp4" + outputFmp4HeaderArg;
+            }
+            else
+            {
+                _logger.LogError("Invalid HLS segment container: " + segmentFormat);
+            }
+
+            var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
+                ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
+                : "128";
 
             var baseUrlParam = string.Format(
                 CultureInfo.InvariantCulture,
@@ -376,20 +406,19 @@ namespace Jellyfin.Api.Controllers
 
             return string.Format(
                     CultureInfo.InvariantCulture,
-                    "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {7} -individual_header_trailer 0 -segment_format {8} -segment_list_entry_prefix {9} -segment_list_type m3u8 -segment_start_number 0 -segment_list \"{10}\" -y \"{11}\"",
+                    "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts 0 -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -individual_header_trailer 0 -hls_segment_type {8} -start_number 0 -hls_base_url {9} -hls_playlist_type event -hls_segment_filename \"{10}\" -y \"{11}\"",
                     inputModifier,
                     _encodingHelper.GetInputArgument(state, _encodingOptions),
                     threads,
                     _encodingHelper.GetMapArgs(state),
                     GetVideoArguments(state),
                     GetAudioArguments(state),
+                    maxMuxingQueueSize,
                     state.SegmentLength.ToString(CultureInfo.InvariantCulture),
-                    string.Empty,
                     segmentFormat,
                     baseUrlParam,
-                    outputPath,
-                    outputTsArg)
-                .Trim();
+                    outputTsArg,
+                    outputPath).Trim();
         }
 
         /// <summary>
@@ -399,14 +428,49 @@ namespace Jellyfin.Api.Controllers
         /// <returns>The command line arguments for audio transcoding.</returns>
         private string GetAudioArguments(StreamState state)
         {
-            var codec = _encodingHelper.GetAudioEncoder(state);
+            if (state.AudioStream == null)
+            {
+                return string.Empty;
+            }
 
-            if (EncodingHelper.IsCopyCodec(codec))
+            var audioCodec = _encodingHelper.GetAudioEncoder(state);
+
+            if (!state.IsOutputVideo)
+            {
+                if (EncodingHelper.IsCopyCodec(audioCodec))
+                {
+                    return "-acodec copy -strict -2";
+                }
+
+                var audioTranscodeParams = new List<string>();
+
+                audioTranscodeParams.Add("-acodec " + audioCodec);
+
+                if (state.OutputAudioBitrate.HasValue)
+                {
+                    audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture));
+                }
+
+                if (state.OutputAudioChannels.HasValue)
+                {
+                    audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture));
+                }
+
+                if (state.OutputAudioSampleRate.HasValue)
+                {
+                    audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture));
+                }
+
+                audioTranscodeParams.Add("-vn");
+                return string.Join(' ', audioTranscodeParams);
+            }
+
+            if (EncodingHelper.IsCopyCodec(audioCodec))
             {
-                return "-codec:a:0 copy";
+                return "-codec:a:0 copy -strict -2";
             }
 
-            var args = "-codec:a:0 " + codec;
+            var args = "-codec:a:0 " + audioCodec;
 
             var channels = state.OutputAudioChannels;
 
@@ -439,6 +503,11 @@ namespace Jellyfin.Api.Controllers
         /// <returns>The command line arguments for video transcoding.</returns>
         private string GetVideoArguments(StreamState state)
         {
+            if (state.VideoStream == null)
+            {
+                return string.Empty;
+            }
+
             if (!state.IsOutputVideo)
             {
                 return string.Empty;
@@ -448,17 +517,25 @@ namespace Jellyfin.Api.Controllers
 
             var args = "-codec:v:0 " + codec;
 
+            // Prefer hvc1 to hev1.
+            if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+            {
+                args += " -tag:v:0 hvc1";
+            }
+
             // if (state.EnableMpegtsM2TsMode)
             // {
             //     args += " -mpegts_m2ts_mode 1";
             // }
 
-            // See if we can save come cpu cycles by avoiding encoding
-            if (codec.Equals("copy", StringComparison.OrdinalIgnoreCase))
+            // See if we can save come cpu cycles by avoiding encoding.
+            if (EncodingHelper.IsCopyCodec(codec))
             {
-                // if h264_mp4toannexb is ever added, do not use it for live tv
-                if (state.VideoStream != null &&
-                    !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
+                // If h264_mp4toannexb is ever added, do not use it for live tv.
+                if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
                 {
                     string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream);
                     if (!string.IsNullOrEmpty(bitStreamArgs))
@@ -469,26 +546,74 @@ namespace Jellyfin.Api.Controllers
             }
             else
             {
+                var gopArg = string.Empty;
                 var keyFrameArg = string.Format(
                     CultureInfo.InvariantCulture,
                     " -force_key_frames \"expr:gte(t,n_forced*{0})\"",
                     state.SegmentLength.ToString(CultureInfo.InvariantCulture));
 
-                var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
+                var framerate = state.VideoStream?.RealFrameRate;
+                if (framerate.HasValue)
+                {
+                    // This is to make sure keyframe interval is limited to our segment,
+                    // as forcing keyframes is not enough.
+                    // Example: we encoded half of desired length, then codec detected
+                    // scene cut and inserted a keyframe; next forced keyframe would
+                    // be created outside of segment, which breaks seeking.
+                    // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe.
+                    gopArg = string.Format(
+                        CultureInfo.InvariantCulture,
+                        " -g {0} -keyint_min {0} -sc_threshold 0",
+                        Math.Ceiling(state.SegmentLength * framerate.Value));
+                }
 
-                args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset) + keyFrameArg;
+                args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset);
 
-                // Add resolution params, if specified
-                if (!hasGraphicalSubs)
+                // Unable to force key frames using these encoders, set key frames by GOP.
+                if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(codec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(codec, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(codec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
                 {
-                    args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
+                    args += " " + gopArg;
+                }
+                else if (string.Equals(codec, "libx264", StringComparison.OrdinalIgnoreCase)
+                        || string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
+                        || string.Equals(codec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
+                        || string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase))
+                {
+                    args += " " + keyFrameArg;
+                }
+                else
+                {
+                    args += " " + keyFrameArg + gopArg;
                 }
 
-                // This is for internal graphical subs
+                // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
+                if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
+                {
+                    args += " -bf 0";
+                }
+
+                var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
+
                 if (hasGraphicalSubs)
                 {
+                    // Graphical subs overlay and resolution params.
                     args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
                 }
+                else
+                {
+                    // Resolution params.
+                    args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
+                }
+
+                if (!(state.SubtitleStream != null && state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream))
+                {
+                    args += " -start_at_zero";
+                }
             }
 
             args += " -flags -global_header";

+ 63 - 7
Jellyfin.Api/Helpers/HlsHelpers.cs

@@ -1,8 +1,10 @@
 using System;
 using System.Globalization;
 using System.IO;
+using System.Runtime.InteropServices;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Api.Models.StreamingDtos;
 using MediaBrowser.Model.IO;
 using Microsoft.Extensions.Logging;
 
@@ -69,25 +71,79 @@ namespace Jellyfin.Api.Helpers
             }
         }
 
+        /// <summary>
+        /// Gets the extension of segment container.
+        /// </summary>
+        /// <param name="segmentContainer">The name of the segment container.</param>
+        /// <returns>The string text of extension.</returns>
+        public static string GetSegmentFileExtension(string? segmentContainer)
+        {
+            if (!string.IsNullOrWhiteSpace(segmentContainer))
+            {
+                return "." + segmentContainer;
+            }
+
+            return ".ts";
+        }
+
+        /// <summary>
+        /// Gets the #EXT-X-MAP string.
+        /// </summary>
+        /// <param name="outputPath">The output path of the file.</param>
+        /// <param name="state">The <see cref="StreamState"/>.</param>
+        /// <param name="isOsDepends">Get a normal string or depends on OS.</param>
+        /// <returns>The string text of #EXT-X-MAP.</returns>
+        public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends)
+        {
+            var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+            var outputPrefix = Path.Combine(Path.GetDirectoryName(outputPath), outputFileNameWithoutExtension);
+            var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer);
+
+            // on Linux/Unix
+            // #EXT-X-MAP:URI="prefix-1.mp4"
+            var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension;
+            if (!isOsDepends)
+            {
+                return fmp4InitFileName;
+            }
+
+            var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+            if (isWindows)
+            {
+                // on Windows
+                // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4"
+                fmp4InitFileName = outputPrefix + "-1" + outputExtension;
+            }
+
+            return fmp4InitFileName;
+        }
+
         /// <summary>
         /// Gets the hls playlist text.
         /// </summary>
         /// <param name="path">The path to the playlist file.</param>
-        /// <param name="segmentLength">The segment length.</param>
+        /// <param name="state">The <see cref="StreamState"/>.</param>
         /// <returns>The playlist text as a string.</returns>
-        public static string GetLivePlaylistText(string path, int segmentLength)
+        public static string GetLivePlaylistText(string path, StreamState state)
         {
             using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
             using var reader = new StreamReader(stream);
 
             var text = reader.ReadToEnd();
 
-            text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture);
-
-            var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture);
+            var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
+            if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
+            {
+                var fmp4InitFileName = GetFmp4InitFileName(path, state, true);
+                var baseUrlParam = string.Format(
+                    CultureInfo.InvariantCulture,
+                    "hls/{0}/",
+                    Path.GetFileNameWithoutExtension(path));
+                var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false);
 
-            text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
-            // text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
+                // Replace fMP4 init file URI.
+                text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture);
+            }
 
             return text;
         }