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 <nst799610810@gmail.com>
pull/11161/head
nyanmisaka 2 months ago
parent 1e964c9bc2
commit eca9bf41bc

@ -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();
}
/// <summary>
/// Gets the HLS arguments for transcoding.
/// </summary>
/// <returns>The command line arguments for HLS transcoding.</returns>
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");
}
}
/// <summary>
/// Gets the audio arguments for transcoding.
/// </summary>

@ -136,6 +136,11 @@ public sealed class TranscodingJob : IDisposable
/// </summary>
public TranscodingThrottler? TranscodingThrottler { get; set; }
/// <summary>
/// Gets or sets transcoding segment cleaner.
/// </summary>
public TranscodingSegmentCleaner? TranscodingSegmentCleaner { get; set; }
/// <summary>
/// Gets or sets last ping date.
/// </summary>
@ -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;
}
}

@ -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;
/// <summary>
/// Transcoding segment cleaner.
/// </summary>
public class TranscodingSegmentCleaner : IDisposable
{
private readonly TranscodingJob _job;
private readonly ILogger<TranscodingSegmentCleaner> _logger;
private readonly IConfigurationManager _config;
private readonly IFileSystem _fileSystem;
private readonly IMediaEncoder _mediaEncoder;
private Timer? _timer;
private int _segmentLength;
/// <summary>
/// Initializes a new instance of the <see cref="TranscodingSegmentCleaner"/> class.
/// </summary>
/// <param name="job">Transcoding job dto.</param>
/// <param name="logger">Instance of the <see cref="ILogger{TranscodingSegmentCleaner}"/> interface.</param>
/// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
/// <param name="segmentLength">The segment length of this transcoding job.</param>
public TranscodingSegmentCleaner(TranscodingJob job, ILogger<TranscodingSegmentCleaner> logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder, int segmentLength)
{
_job = job;
_logger = logger;
_config = config;
_fileSystem = fileSystem;
_mediaEncoder = mediaEncoder;
_segmentLength = segmentLength;
}
/// <summary>
/// Start timer.
/// </summary>
public void Start()
{
_timer = new Timer(TimerCallback, null, 20000, 20000);
}
/// <summary>
/// Stop cleaner.
/// </summary>
public void Stop()
{
DisposeTimer();
}
/// <summary>
/// Dispose cleaner.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Dispose cleaner.
/// </summary>
/// <param name="disposing">Disposing.</param>
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<Exception>? 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<Exception>(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;
}
}
}

@ -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<TranscodingSegmentCleaner>(), _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,

Loading…
Cancel
Save