#pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; 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.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 { public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable { private readonly ILogger _logger; private readonly IApplicationPaths _appPaths; private readonly IFileSystem _fileSystem; private readonly IMediaEncoder _mediaEncoder; private readonly IMediaSourceManager _mediaSourceManager; private readonly AsyncKeyedLocker _semaphoreLocks = new(o => { o.PoolSize = 20; o.PoolInitialFill = 1; }); public AttachmentExtractor( ILogger logger, IApplicationPaths appPaths, IFileSystem fileSystem, IMediaEncoder mediaEncoder, IMediaSourceManager mediaSourceManager) { _logger = logger; _appPaths = appPaths; _fileSystem = fileSystem; _mediaEncoder = mediaEncoder; _mediaSourceManager = mediaSourceManager; } /// public async Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment(BaseItem item, string mediaSourceId, int attachmentStreamIndex, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(item); if (string.IsNullOrWhiteSpace(mediaSourceId)) { throw new ArgumentNullException(nameof(mediaSourceId)); } var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationToken).ConfigureAwait(false); var mediaSource = mediaSources .FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)); if (mediaSource is null) { throw new ResourceNotFoundException($"MediaSource {mediaSourceId} not found"); } var mediaAttachment = mediaSource.MediaAttachments .FirstOrDefault(i => i.Index == attachmentStreamIndex); if (mediaAttachment is null) { throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {attachmentStreamIndex}"); } var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken) .ConfigureAwait(false); return (mediaAttachment, attachmentStream); } public async Task ExtractAllAttachments( string inputFile, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken) { var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)); if (shouldExtractOneByOne) { var attachmentIndexes = mediaSource.MediaAttachments.Select(a => a.Index); foreach (var i in attachmentIndexes) { var newName = Path.Join(outputPath, i.ToString(CultureInfo.InvariantCulture)); await ExtractAttachment(inputFile, mediaSource, i, newName, 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)); } } } } private async Task ExtractAllAttachmentsInternal( string inputPath, string outputPath, bool isExternal, CancellationToken cancellationToken) { 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 }) { _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}", outputPath, exitCode); try { Directory.Delete(outputPath); } catch (IOException ex) { _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputPath); } } } else if (!Directory.Exists(outputPath)) { failed = true; } if (failed) { _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); throw new InvalidOperationException( string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputPath)); } _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); } private async Task GetAttachmentStream( MediaSourceInfo mediaSource, MediaAttachment mediaAttachment, CancellationToken cancellationToken) { var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource, mediaAttachment, cancellationToken).ConfigureAwait(false); return AsyncFile.OpenRead(attachmentPath); } private async Task GetReadableFile( string mediaPath, 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(); var extractableAttachmentIds = new List(); try { 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, inputFile, 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 extractableAttachmentIds, CancellationToken cancellationToken) { var outputPaths = new List(); 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)) { await ExtractAttachmentInternal( _mediaEncoder.GetInputArgument(inputFile, mediaSource), attachmentStreamIndex, outputPath, cancellationToken).ConfigureAwait(false); } } } private async Task ExtractAttachmentInternal( string inputPath, int attachmentStreamIndex, string outputPath, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(inputPath); ArgumentException.ThrowIfNullOrEmpty(outputPath); Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputPath))); var processArgs = string.Format( CultureInfo.InvariantCulture, "-dump_attachment:{1} \"{2}\" -i {0} -t 0 -f null null", inputPath, attachmentStreamIndex, EncodingUtils.NormalizePath(outputPath)); 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 != 0) { failed = true; _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, exitCode); try { if (File.Exists(outputPath)) { _fileSystem.DeleteFile(outputPath); } } catch (IOException ex) { _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath); } } else if (!File.Exists(outputPath)) { failed = true; } if (failed) { _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); throw new InvalidOperationException( string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputPath)); } _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); } /// public void Dispose() { _semaphoreLocks.Dispose(); } } }