jellyfin/MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs
nyanmisaka eca9bf41bc 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>
2024-03-16 07:35:05 +08:00

189 lines
5.8 KiB
C#

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;
}
}
}