From eca9bf41bcf536708ad74236793b363db3af1e4d Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Sat, 16 Mar 2024 03:40:14 +0800 Subject: [PATCH] Add TranscodingSegmentCleaner to replace ffmpeg's hlsenc deletion FFmpeg deletes segments based on its own transcoding progress, but we need to delete segments based on client download progress. Since disk and GPU speeds vary, using hlsenc's built-in deletion will result in premature deletion of some segments. As a consequence, the server has to constantly respin new ffmpeg instances, resulting in choppy video playback. Signed-off-by: nyanmisaka --- .../Controllers/DynamicHlsController.cs | 29 +-- .../MediaEncoding/TranscodingJob.cs | 8 + .../TranscodingSegmentCleaner.cs | 188 ++++++++++++++++++ .../Transcoding/TranscodeManager.cs | 17 ++ 4 files changed, 214 insertions(+), 28 deletions(-) create mode 100644 MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 590cdc33f0..d70c51b86f 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -1604,7 +1604,7 @@ public class DynamicHlsController : BaseJellyfinApiController Path.GetFileNameWithoutExtension(outputPath)); } - var hlsArguments = GetHlsArguments(isEventPlaylist, state.SegmentLength); + var hlsArguments = string.Format(CultureInfo.InvariantCulture, "-hls_playlist_type {0} -hls_list_size 0", isEventPlaylist ? "event" : "vod"); return string.Format( CultureInfo.InvariantCulture, @@ -1625,33 +1625,6 @@ public class DynamicHlsController : BaseJellyfinApiController EncodingUtils.NormalizePath(outputPath)).Trim(); } - /// - /// Gets the HLS arguments for transcoding. - /// - /// The command line arguments for HLS transcoding. - private string GetHlsArguments(bool isEventPlaylist, int segmentLength) - { - var enableThrottling = _encodingOptions.EnableThrottling; - var enableSegmentDeletion = _encodingOptions.EnableSegmentDeletion; - - // Only enable segment deletion when throttling is enabled - if (enableThrottling && enableSegmentDeletion) - { - // Store enough segments for configured seconds of playback; this needs to be above throttling settings - var segmentCount = _encodingOptions.SegmentKeepSeconds / segmentLength; - - _logger.LogDebug("Using throttling and segment deletion, keeping {0} segments", segmentCount); - - return string.Format(CultureInfo.InvariantCulture, "-hls_list_size {0} -hls_flags delete_segments", segmentCount.ToString(CultureInfo.InvariantCulture)); - } - else - { - _logger.LogDebug("Using normal playback, is event playlist? {0}", isEventPlaylist); - - return string.Format(CultureInfo.InvariantCulture, "-hls_playlist_type {0} -hls_list_size 0", isEventPlaylist ? "event" : "vod"); - } - } - /// /// Gets the audio arguments for transcoding. /// diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs index 1e6d5933c8..2b6540ea88 100644 --- a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs @@ -136,6 +136,11 @@ public sealed class TranscodingJob : IDisposable /// public TranscodingThrottler? TranscodingThrottler { get; set; } + /// + /// Gets or sets transcoding segment cleaner. + /// + public TranscodingSegmentCleaner? TranscodingSegmentCleaner { get; set; } + /// /// Gets or sets last ping date. /// @@ -239,6 +244,7 @@ public sealed class TranscodingJob : IDisposable { #pragma warning disable CA1849 // Can't await in lock block TranscodingThrottler?.Stop().GetAwaiter().GetResult(); + TranscodingSegmentCleaner?.Stop(); var process = Process; @@ -276,5 +282,7 @@ public sealed class TranscodingJob : IDisposable CancellationTokenSource = null; TranscodingThrottler?.Dispose(); TranscodingThrottler = null; + TranscodingSegmentCleaner?.Dispose(); + TranscodingSegmentCleaner = null; } } diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs new file mode 100644 index 0000000000..6cbda8e0a7 --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// Transcoding segment cleaner. +/// +public class TranscodingSegmentCleaner : IDisposable +{ + private readonly TranscodingJob _job; + private readonly ILogger _logger; + private readonly IConfigurationManager _config; + private readonly IFileSystem _fileSystem; + private readonly IMediaEncoder _mediaEncoder; + private Timer? _timer; + private int _segmentLength; + + /// + /// Initializes a new instance of the class. + /// + /// Transcoding job dto. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// The segment length of this transcoding job. + public TranscodingSegmentCleaner(TranscodingJob job, ILogger logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder, int segmentLength) + { + _job = job; + _logger = logger; + _config = config; + _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + _segmentLength = segmentLength; + } + + /// + /// Start timer. + /// + public void Start() + { + _timer = new Timer(TimerCallback, null, 20000, 20000); + } + + /// + /// Stop cleaner. + /// + public void Stop() + { + DisposeTimer(); + } + + /// + /// Dispose cleaner. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose cleaner. + /// + /// Disposing. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + DisposeTimer(); + } + } + + private EncodingOptions GetOptions() + { + return _config.GetEncodingOptions(); + } + + private async void TimerCallback(object? state) + { + if (_job.HasExited) + { + DisposeTimer(); + return; + } + + var options = GetOptions(); + var enableSegmentDeletion = options.EnableSegmentDeletion; + var segmentKeepSeconds = Math.Max(options.SegmentKeepSeconds, 20); + + if (enableSegmentDeletion) + { + var downloadPositionTicks = _job.DownloadPositionTicks ?? 0; + var downloadPositionSeconds = Convert.ToInt64(TimeSpan.FromTicks(downloadPositionTicks).TotalSeconds); + + if (downloadPositionSeconds > 0 && segmentKeepSeconds > 0 && downloadPositionSeconds > segmentKeepSeconds) + { + var idxMaxToRemove = (downloadPositionSeconds - segmentKeepSeconds) / _segmentLength; + + if (idxMaxToRemove > 0) + { + await DeleteSegmentFiles(_job, 0, idxMaxToRemove, 0, 1500).ConfigureAwait(false); + } + } + } + } + + private async Task DeleteSegmentFiles(TranscodingJob job, long idxMin, long idxMax, int retryCount, int delayMs) + { + if (retryCount >= 10) + { + return; + } + + var path = job.Path ?? throw new ArgumentException("Path can't be null."); + + _logger.LogDebug("Deleting segment file(s) index {Min} to {Max} from {Path}", idxMin, idxMax, path); + + await Task.Delay(delayMs).ConfigureAwait(false); + + try + { + if (job.Type == TranscodingJobType.Hls) + { + DeleteHlsSegmentFiles(path, idxMin, idxMax); + } + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting segment file(s) {Path}", path); + + await DeleteSegmentFiles(job, idxMin, idxMax, retryCount + 1, 500).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting segment file(s) {Path}", path); + } + } + + private void DeleteHlsSegmentFiles(string outputFilePath, long idxMin, long idxMax) + { + var directory = Path.GetDirectoryName(outputFilePath) + ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath)); + + var name = Path.GetFileNameWithoutExtension(outputFilePath); + + var filesToDelete = _fileSystem.GetFilePaths(directory) + .Where(f => long.TryParse(Path.GetFileNameWithoutExtension(f).Replace(name, string.Empty, StringComparison.Ordinal), out var idx) && idx >= idxMin && idx <= idxMax); + + List? exs = null; + foreach (var file in filesToDelete) + { + try + { + _logger.LogDebug("Deleting HLS segment file {0}", file); + _fileSystem.DeleteFile(file); + } + catch (IOException ex) + { + (exs ??= new List(4)).Add(ex); + _logger.LogError(ex, "Error deleting HLS segment file {Path}", file); + } + } + + if (exs is not null) + { + throw new AggregateException("Error deleting HLS segment files", exs); + } + } + + private void DisposeTimer() + { + if (_timer is not null) + { + _timer.Dispose(); + _timer = null; + } + } +} diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index 8bace15c65..2a72cacdc9 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -546,6 +546,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable if (!transcodingJob.HasExited) { StartThrottler(state, transcodingJob); + StartSegmentCleaner(state, transcodingJob); } else if (transcodingJob.ExitCode != 0) { @@ -573,6 +574,22 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable && state.IsInputVideo && state.VideoType == VideoType.VideoFile; + private void StartSegmentCleaner(StreamState state, TranscodingJob transcodingJob) + { + if (EnableSegmentCleaning(state)) + { + transcodingJob.TranscodingSegmentCleaner = new TranscodingSegmentCleaner(transcodingJob, _loggerFactory.CreateLogger(), _serverConfigurationManager, _fileSystem, _mediaEncoder, state.SegmentLength); + transcodingJob.TranscodingSegmentCleaner.Start(); + } + } + + private static bool EnableSegmentCleaning(StreamState state) + => state.InputProtocol is MediaProtocol.File or MediaProtocol.Http + && state.IsInputVideo + && state.TranscodingType == TranscodingJobType.Hls + && state.RunTimeTicks.HasValue + && state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks; + private TranscodingJob OnTranscodeBeginning( string path, string? playSessionId,