Cleanup extracted files (#13760)

* Cleanup extracted files

* Pagination and fixes

* Add migration for attachments to MigrateLibraryDb

* Unify attachment handling

* Don't extract again if files were already extracted

* Fix MKS attachment extraction

* Always run full extraction on mks

* Don't try to extract mjpeg streams as attachments

* Fallback to check if attachments were extracted to cache folder

* Fixup
This commit is contained in:
Tim Eisele
2025-04-03 17:17:14 +02:00
committed by GitHub
parent 0bde7bae05
commit 596b635511
17 changed files with 2397 additions and 416 deletions

View File

@@ -1,7 +1,4 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
@@ -9,28 +6,27 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.MediaEncoding.Encoder;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.MediaEncoding.Attachments
{
/// <inheritdoc cref="IAttachmentExtractor"/>
public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable
{
private readonly ILogger<AttachmentExtractor> _logger;
private readonly IApplicationPaths _appPaths;
private readonly IFileSystem _fileSystem;
private readonly IMediaEncoder _mediaEncoder;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IPathManager _pathManager;
private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
{
@@ -38,18 +34,26 @@ namespace MediaBrowser.MediaEncoding.Attachments
o.PoolInitialFill = 1;
});
/// <summary>
/// Initializes a new instance of the <see cref="AttachmentExtractor"/> class.
/// </summary>
/// <param name="logger">The <see cref="ILogger{AttachmentExtractor}"/>.</param>
/// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
/// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
/// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
/// <param name="pathManager">The <see cref="IPathManager"/>.</param>
public AttachmentExtractor(
ILogger<AttachmentExtractor> logger,
IApplicationPaths appPaths,
IFileSystem fileSystem,
IMediaEncoder mediaEncoder,
IMediaSourceManager mediaSourceManager)
IMediaSourceManager mediaSourceManager,
IPathManager pathManager)
{
_logger = logger;
_appPaths = appPaths;
_fileSystem = fileSystem;
_mediaEncoder = mediaEncoder;
_mediaSourceManager = mediaSourceManager;
_pathManager = pathManager;
}
/// <inheritdoc />
@@ -77,157 +81,151 @@ namespace MediaBrowser.MediaEncoding.Attachments
throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {attachmentStreamIndex}");
}
if (string.Equals(mediaAttachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
{
throw new ResourceNotFoundException($"Attachment with stream index {attachmentStreamIndex} can't be extracted for MediaSource {mediaSourceId}");
}
var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken)
.ConfigureAwait(false);
return (mediaAttachment, attachmentStream);
}
/// <inheritdoc />
public async Task ExtractAllAttachments(
string inputFile,
MediaSourceInfo mediaSource,
string outputPath,
CancellationToken cancellationToken)
{
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
if (shouldExtractOneByOne)
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
var attachmentIndexes = mediaSource.MediaAttachments.Select(a => a.Index);
foreach (var i in attachmentIndexes)
foreach (var attachment in mediaSource.MediaAttachments)
{
var newName = Path.Join(outputPath, i.ToString(CultureInfo.InvariantCulture));
await ExtractAttachment(inputFile, mediaSource, i, newName, cancellationToken).ConfigureAwait(false);
if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
{
await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false);
}
}
}
else
{
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
{
if (!Directory.Exists(outputPath))
{
await ExtractAllAttachmentsInternal(
_mediaEncoder.GetInputArgument(inputFile, mediaSource),
outputPath,
false,
cancellationToken).ConfigureAwait(false);
}
}
}
}
public async Task ExtractAllAttachmentsExternal(
string inputArgument,
string id,
string outputPath,
CancellationToken cancellationToken)
{
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
{
if (!File.Exists(Path.Join(outputPath, id)))
{
await ExtractAllAttachmentsInternal(
inputArgument,
outputPath,
true,
cancellationToken).ConfigureAwait(false);
if (Directory.Exists(outputPath))
{
File.Create(Path.Join(outputPath, id));
}
}
await ExtractAllAttachmentsInternal(
inputFile,
mediaSource,
false,
cancellationToken).ConfigureAwait(false);
}
}
private async Task ExtractAllAttachmentsInternal(
string inputPath,
string outputPath,
string inputFile,
MediaSourceInfo mediaSource,
bool isExternal,
CancellationToken cancellationToken)
{
var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
ArgumentException.ThrowIfNullOrEmpty(inputPath);
ArgumentException.ThrowIfNullOrEmpty(outputPath);
Directory.CreateDirectory(outputPath);
var processArgs = string.Format(
CultureInfo.InvariantCulture,
"-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null",
inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty,
inputPath);
int exitCode;
using (var process = new Process
{
StartInfo = new ProcessStartInfo
{
Arguments = processArgs,
FileName = _mediaEncoder.EncoderPath,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
WorkingDirectory = outputPath,
ErrorDialog = false
},
EnableRaisingEvents = true
})
var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
{
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
process.Start();
try
if (!Directory.Exists(outputFolder))
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
{
process.Kill(true);
exitCode = -1;
}
}
var failed = false;
if (exitCode != 0)
{
if (isExternal && exitCode == 1)
{
// ffmpeg returns exitCode 1 because there is no video or audio stream
// this can be ignored
Directory.CreateDirectory(outputFolder);
}
else
{
failed = true;
_logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputPath, exitCode);
try
var fileNames = Directory.GetFiles(outputFolder, "*", SearchOption.TopDirectoryOnly).Select(f => Path.GetFileName(f));
var missingFiles = mediaSource.MediaAttachments.Where(a => !fileNames.Contains(a.FileName) && !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase));
if (!missingFiles.Any())
{
Directory.Delete(outputPath);
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting extracted attachments {Path}", outputPath);
// Skip extraction if all files already exist
return;
}
}
}
else if (!Directory.Exists(outputPath))
{
failed = true;
}
if (failed)
{
_logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
var processArgs = string.Format(
CultureInfo.InvariantCulture,
"-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null",
inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty,
inputPath);
throw new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputPath));
int exitCode;
using (var process = new Process
{
StartInfo = new ProcessStartInfo
{
Arguments = processArgs,
FileName = _mediaEncoder.EncoderPath,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
WorkingDirectory = outputFolder,
ErrorDialog = false
},
EnableRaisingEvents = true
})
{
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
process.Start();
try
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
{
process.Kill(true);
exitCode = -1;
}
}
var failed = false;
if (exitCode != 0)
{
if (isExternal && exitCode == 1)
{
// ffmpeg returns exitCode 1 because there is no video or audio stream
// this can be ignored
}
else
{
failed = true;
_logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputFolder, exitCode);
try
{
Directory.Delete(outputFolder);
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting extracted attachments {Path}", outputFolder);
}
}
}
else if (!Directory.Exists(outputFolder))
{
failed = true;
}
if (failed)
{
_logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputFolder);
throw new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputFolder));
}
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputFolder);
}
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
}
private async Task<Stream> GetAttachmentStream(
@@ -235,192 +233,31 @@ namespace MediaBrowser.MediaEncoding.Attachments
MediaAttachment mediaAttachment,
CancellationToken cancellationToken)
{
var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource, mediaAttachment, cancellationToken).ConfigureAwait(false);
var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationToken)
.ConfigureAwait(false);
return AsyncFile.OpenRead(attachmentPath);
}
private async Task<string> GetReadableFile(
string mediaPath,
private async Task<string> ExtractAttachment(
string inputFile,
MediaSourceInfo mediaSource,
MediaAttachment mediaAttachment,
CancellationToken cancellationToken)
{
await CacheAllAttachments(mediaPath, inputFile, mediaSource, cancellationToken).ConfigureAwait(false);
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, mediaAttachment.Index);
await ExtractAttachment(inputFile, mediaSource, mediaAttachment.Index, outputPath, cancellationToken)
.ConfigureAwait(false);
return outputPath;
}
private async Task CacheAllAttachments(
string mediaPath,
string inputFile,
MediaSourceInfo mediaSource,
CancellationToken cancellationToken)
{
var outputFileLocks = new List<IDisposable>();
var extractableAttachmentIds = new List<int>();
try
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
{
foreach (var attachment in mediaSource.MediaAttachments)
{
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachment.Index);
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
if (File.Exists(outputPath))
{
releaser.Dispose();
continue;
}
outputFileLocks.Add(releaser);
extractableAttachmentIds.Add(attachment.Index);
}
if (extractableAttachmentIds.Count > 0)
{
await CacheAllAttachmentsInternal(mediaPath, _mediaEncoder.GetInputArgument(inputFile, mediaSource), mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to cache media attachments for File:{File}", mediaPath);
}
finally
{
outputFileLocks.ForEach(x => x.Dispose());
}
}
private async Task CacheAllAttachmentsInternal(
string mediaPath,
string inputFile,
MediaSourceInfo mediaSource,
List<int> extractableAttachmentIds,
CancellationToken cancellationToken)
{
var outputPaths = new List<string>();
var processArgs = string.Empty;
foreach (var attachmentId in extractableAttachmentIds)
{
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachmentId);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));
outputPaths.Add(outputPath);
processArgs += string.Format(
CultureInfo.InvariantCulture,
" -dump_attachment:{0} \"{1}\"",
attachmentId,
EncodingUtils.NormalizePath(outputPath));
}
processArgs += string.Format(
CultureInfo.InvariantCulture,
" -i {0} -t 0 -f null null",
inputFile);
int exitCode;
using (var process = new Process
{
StartInfo = new ProcessStartInfo
{
Arguments = processArgs,
FileName = _mediaEncoder.EncoderPath,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
EnableRaisingEvents = true
})
{
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
process.Start();
try
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
{
process.Kill(true);
exitCode = -1;
}
}
var failed = false;
if (exitCode == -1)
{
failed = true;
foreach (var outputPath in outputPaths)
{
try
{
_logger.LogWarning("Deleting extracted media attachment due to failure: {Path}", outputPath);
_fileSystem.DeleteFile(outputPath);
}
catch (FileNotFoundException)
{
// ffmpeg failed, so it is normal that one or more expected output files do not exist.
// There is no need to log anything for the user here.
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting extracted media attachment {Path}", outputPath);
}
}
}
else
{
foreach (var outputPath in outputPaths)
{
if (!File.Exists(outputPath))
{
_logger.LogError("ffmpeg media attachment extraction failed for {InputPath} to {OutputPath}", inputFile, outputPath);
failed = true;
continue;
}
_logger.LogInformation("ffmpeg media attachment extraction completed for {InputPath} to {OutputPath}", inputFile, outputPath);
}
}
if (failed)
{
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg media attachment extraction failed for {0}", inputFile));
}
}
private async Task ExtractAttachment(
string inputFile,
MediaSourceInfo mediaSource,
int attachmentStreamIndex,
string outputPath,
CancellationToken cancellationToken)
{
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
{
if (!File.Exists(outputPath))
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture));
if (!File.Exists(attachmentPath))
{
await ExtractAttachmentInternal(
_mediaEncoder.GetInputArgument(inputFile, mediaSource),
attachmentStreamIndex,
outputPath,
mediaAttachment.Index,
attachmentPath,
cancellationToken).ConfigureAwait(false);
}
return attachmentPath;
}
}
@@ -510,23 +347,6 @@ namespace MediaBrowser.MediaEncoding.Attachments
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
}
private string GetAttachmentCachePath(string mediaPath, MediaSourceInfo mediaSource, int attachmentStreamIndex)
{
string filename;
if (mediaSource.Protocol == MediaProtocol.File)
{
var date = _fileSystem.GetLastWriteTimeUtc(mediaPath);
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
}
else
{
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
}
var prefix = filename.AsSpan(0, 1);
return Path.Join(_appPaths.DataPath, "attachments", prefix, filename);
}
/// <inheritdoc />
public void Dispose()
{

View File

@@ -13,10 +13,10 @@ using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dto;
@@ -31,12 +31,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles
public sealed class SubtitleEncoder : ISubtitleEncoder, IDisposable
{
private readonly ILogger<SubtitleEncoder> _logger;
private readonly IApplicationPaths _appPaths;
private readonly IFileSystem _fileSystem;
private readonly IMediaEncoder _mediaEncoder;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly ISubtitleParser _subtitleParser;
private readonly IPathManager _pathManager;
/// <summary>
/// The _semaphoreLocks.
@@ -49,24 +49,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
public SubtitleEncoder(
ILogger<SubtitleEncoder> logger,
IApplicationPaths appPaths,
IFileSystem fileSystem,
IMediaEncoder mediaEncoder,
IHttpClientFactory httpClientFactory,
IMediaSourceManager mediaSourceManager,
ISubtitleParser subtitleParser)
ISubtitleParser subtitleParser,
IPathManager pathManager)
{
_logger = logger;
_appPaths = appPaths;
_fileSystem = fileSystem;
_mediaEncoder = mediaEncoder;
_httpClientFactory = httpClientFactory;
_mediaSourceManager = mediaSourceManager;
_subtitleParser = subtitleParser;
_pathManager = pathManager;
}
private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles");
private MemoryStream ConvertSubtitles(
Stream stream,
string inputFormat,
@@ -830,26 +828,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)
{
if (mediaSource.Protocol == MediaProtocol.File)
{
var ticksParam = string.Empty;
var date = _fileSystem.GetLastWriteTimeUtc(mediaSource.Path);
ReadOnlySpan<char> filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam).GetMD5() + outputSubtitleExtension;
var prefix = filename.Slice(0, 1);
return Path.Join(SubtitleCachePath, prefix, filename);
}
else
{
ReadOnlySpan<char> filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5() + outputSubtitleExtension;
var prefix = filename.Slice(0, 1);
return Path.Join(SubtitleCachePath, prefix, filename);
}
return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
}
/// <inheritdoc />

View File

@@ -398,24 +398,19 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
// If subtitles get burned in fonts may need to be extracted from the media file
if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
{
var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay)
{
var concatPath = Path.Join(_appPaths.CachePath, "concat", state.MediaSource.Id + ".concat");
await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false);
}
else
{
await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false);
}
if (state.SubtitleStream.IsExternal && Path.GetExtension(state.SubtitleStream.Path.AsSpan()).Equals(".mks", StringComparison.OrdinalIgnoreCase))
{
string subtitlePath = state.SubtitleStream.Path;
string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal));
string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture);
await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
await _attachmentExtractor.ExtractAllAttachments(state.SubtitleStream.Path, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false);
}
}