Files
jellyfin-jellyfin-1/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs

351 lines
14 KiB
C#
Raw Normal View History

2019-10-18 07:52:32 -04:00
using System;
2020-03-24 16:12:06 +01:00
using System.Diagnostics;
2019-10-18 07:52:32 -04:00
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
2024-01-03 16:47:25 +01:00
using AsyncKeyedLock;
2019-10-18 07:52:32 -04:00
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
2019-10-18 07:52:32 -04:00
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.MediaEncoding.Encoder;
2019-10-18 07:52:32 -04:00
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.MediaEncoding.Attachments
{
/// <inheritdoc cref="IAttachmentExtractor"/>
2024-01-03 16:47:25 +01:00
public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable
2019-10-18 07:52:32 -04:00
{
2020-06-05 18:15:56 -06:00
private readonly ILogger<AttachmentExtractor> _logger;
2019-10-18 07:52:32 -04:00
private readonly IFileSystem _fileSystem;
private readonly IMediaEncoder _mediaEncoder;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IPathManager _pathManager;
2019-10-18 07:52:32 -04:00
2024-01-03 16:47:25 +01:00
private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
{
o.PoolSize = 20;
o.PoolInitialFill = 1;
});
2019-12-26 23:09:00 +01:00
/// <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>
2019-10-18 07:52:32 -04:00
public AttachmentExtractor(
2019-11-07 11:55:39 -05:00
ILogger<AttachmentExtractor> logger,
2019-10-18 07:52:32 -04:00
IFileSystem fileSystem,
IMediaEncoder mediaEncoder,
IMediaSourceManager mediaSourceManager,
IPathManager pathManager)
2019-10-18 07:52:32 -04:00
{
2019-11-07 11:55:39 -05:00
_logger = logger;
2019-10-18 07:52:32 -04:00
_fileSystem = fileSystem;
_mediaEncoder = mediaEncoder;
_mediaSourceManager = mediaSourceManager;
_pathManager = pathManager;
2019-10-18 07:52:32 -04:00
}
2019-12-26 23:09:00 +01:00
/// <inheritdoc />
2021-12-24 14:18:24 -07:00
public async Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment(BaseItem item, string mediaSourceId, int attachmentStreamIndex, CancellationToken cancellationToken)
2019-10-18 07:52:32 -04:00
{
ArgumentNullException.ThrowIfNull(item);
2019-10-18 07:52:32 -04:00
if (string.IsNullOrWhiteSpace(mediaSourceId))
{
throw new ArgumentNullException(nameof(mediaSourceId));
}
2020-01-11 00:08:47 +01:00
var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationToken).ConfigureAwait(false);
var mediaSource = mediaSources
.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
2022-12-05 15:00:20 +01:00
if (mediaSource is null)
{
throw new ResourceNotFoundException($"MediaSource {mediaSourceId} not found");
}
2019-12-26 23:09:00 +01:00
var mediaAttachment = mediaSource.MediaAttachments
.FirstOrDefault(i => i.Index == attachmentStreamIndex);
2022-12-05 15:00:20 +01:00
if (mediaAttachment is null)
{
throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {attachmentStreamIndex}");
}
2019-12-26 23:09:00 +01:00
if (string.Equals(mediaAttachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
{
throw new ResourceNotFoundException($"Attachment with stream index {attachmentStreamIndex} can't be extracted for MediaSource {mediaSourceId}");
}
2019-10-18 07:52:32 -04:00
var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken)
.ConfigureAwait(false);
return (mediaAttachment, attachmentStream);
}
/// <inheritdoc />
public async Task ExtractAllAttachments(
string inputFile,
MediaSourceInfo mediaSource,
CancellationToken cancellationToken)
{
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
foreach (var attachment in mediaSource.MediaAttachments)
{
if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
{
await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false);
}
}
2022-03-13 11:20:09 +01:00
}
else
2022-03-12 21:46:29 +01:00
{
await ExtractAllAttachmentsInternal(
inputFile,
mediaSource,
false,
cancellationToken).ConfigureAwait(false);
}
}
private async Task ExtractAllAttachmentsInternal(
string inputFile,
MediaSourceInfo mediaSource,
bool isExternal,
CancellationToken cancellationToken)
{
var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
ArgumentException.ThrowIfNullOrEmpty(inputPath);
var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
{
2025-05-04 16:40:34 +02:00
var directory = Directory.CreateDirectory(outputFolder);
var fileNames = directory.GetFiles("*", SearchOption.TopDirectoryOnly).Select(f => f.Name).ToHashSet();
var missingFiles = mediaSource.MediaAttachments.Where(a => a.FileName is not null && !fileNames.Contains(a.FileName) && !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase));
2025-04-26 17:36:17 +02:00
if (!missingFiles.Any())
{
2025-04-26 17:36:17 +02:00
// Skip extraction if all files already exist
return;
}
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);
2019-10-18 07:52:32 -04:00
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);
}
}
private async Task<Stream> GetAttachmentStream(
MediaSourceInfo mediaSource,
MediaAttachment mediaAttachment,
CancellationToken cancellationToken)
{
var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationToken)
.ConfigureAwait(false);
return AsyncFile.OpenRead(attachmentPath);
}
private async Task<string> ExtractAttachment(
2019-12-26 23:09:00 +01:00
string inputFile,
MediaSourceInfo mediaSource,
MediaAttachment mediaAttachment,
2019-10-18 07:52:32 -04:00
CancellationToken cancellationToken)
{
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
2019-10-18 07:52:32 -04:00
{
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture));
if (!File.Exists(attachmentPath))
2019-10-18 07:52:32 -04:00
{
2019-12-26 23:09:00 +01:00
await ExtractAttachmentInternal(
_mediaEncoder.GetInputArgument(inputFile, mediaSource),
mediaAttachment.Index,
attachmentPath,
2019-12-26 23:09:00 +01:00
cancellationToken).ConfigureAwait(false);
2019-10-18 07:52:32 -04:00
}
return attachmentPath;
2019-10-18 07:52:32 -04:00
}
}
private async Task ExtractAttachmentInternal(
string inputPath,
int attachmentStreamIndex,
string outputPath,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(inputPath);
2019-10-18 07:52:32 -04:00
ArgumentException.ThrowIfNullOrEmpty(outputPath);
2019-10-18 07:52:32 -04:00
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputPath)));
2019-10-18 07:52:32 -04:00
var processArgs = string.Format(
CultureInfo.InvariantCulture,
"-dump_attachment:{1} \"{2}\" -i {0} -t 0 -f null null",
inputPath,
attachmentStreamIndex,
EncodingUtils.NormalizePath(outputPath));
2019-10-18 07:52:32 -04:00
int exitCode;
2019-10-18 07:52:32 -04:00
using (var process = new Process
{
2020-04-11 13:46:31 -04:00
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);
2019-10-18 07:52:32 -04:00
process.Start();
2019-10-18 07:52:32 -04:00
try
2019-10-18 07:52:32 -04:00
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
{
process.Kill(true);
exitCode = -1;
2019-10-18 07:52:32 -04:00
}
}
2020-01-11 00:08:47 +01:00
2019-10-18 07:52:32 -04:00
var failed = false;
if (exitCode != 0)
2019-10-18 07:52:32 -04:00
{
failed = true;
2019-11-04 11:16:57 -05:00
_logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, exitCode);
2019-10-18 07:52:32 -04:00
try
{
if (File.Exists(outputPath))
{
_fileSystem.DeleteFile(outputPath);
}
2019-10-18 07:52:32 -04:00
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath);
}
}
else if (!File.Exists(outputPath))
{
failed = true;
}
if (failed)
{
2021-11-09 22:29:33 +01:00
_logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
2019-10-18 07:52:32 -04:00
2021-11-09 22:29:33 +01:00
throw new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputPath));
2019-10-18 07:52:32 -04:00
}
2023-04-06 19:38:34 +02:00
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
2019-10-18 07:52:32 -04:00
}
2024-01-03 16:47:25 +01:00
/// <inheritdoc />
public void Dispose()
{
_semaphoreLocks.Dispose();
}
2019-10-18 07:52:32 -04:00
}
}