Merge pull request #1838 from Unhelpful/media-attachments
Add support for embedded attachments in media filespull/2240/head
commit
423d50e978
@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Api.Attachments
|
||||
{
|
||||
[Route("/Videos/{Id}/{MediaSourceId}/Attachments/{Index}", "GET", Summary = "Gets specified attachment.")]
|
||||
public class GetAttachment
|
||||
{
|
||||
[ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[ApiMember(Name = "MediaSourceId", Description = "MediaSourceId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
|
||||
public string MediaSourceId { get; set; }
|
||||
|
||||
[ApiMember(Name = "Index", Description = "The attachment stream index", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "GET")]
|
||||
public int Index { get; set; }
|
||||
}
|
||||
|
||||
public class AttachmentService : BaseApiService
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IAttachmentExtractor _attachmentExtractor;
|
||||
|
||||
public AttachmentService(
|
||||
ILogger<AttachmentService> logger,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IHttpResultFactory httpResultFactory,
|
||||
ILibraryManager libraryManager,
|
||||
IAttachmentExtractor attachmentExtractor)
|
||||
: base(logger, serverConfigurationManager, httpResultFactory)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_attachmentExtractor = attachmentExtractor;
|
||||
}
|
||||
|
||||
public async Task<object> Get(GetAttachment request)
|
||||
{
|
||||
var (attachment, attachmentStream) = await GetAttachment(request).ConfigureAwait(false);
|
||||
var mime = string.IsNullOrWhiteSpace(attachment.MimeType) ? "application/octet-stream" : attachment.MimeType;
|
||||
|
||||
return ResultFactory.GetResult(Request, attachmentStream, mime);
|
||||
}
|
||||
|
||||
private Task<(MediaAttachment, Stream)> GetAttachment(GetAttachment request)
|
||||
{
|
||||
var item = _libraryManager.GetItemById(request.Id);
|
||||
|
||||
return _attachmentExtractor.GetAttachment(item,
|
||||
request.MediaSourceId,
|
||||
request.Index,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.MediaEncoding
|
||||
{
|
||||
public interface IAttachmentExtractor
|
||||
{
|
||||
Task<(MediaAttachment attachment, Stream stream)> GetAttachment(
|
||||
BaseItem item,
|
||||
string mediaSourceId,
|
||||
int attachmentStreamIndex,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.Persistence
|
||||
{
|
||||
public class MediaAttachmentQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the index.
|
||||
/// </summary>
|
||||
/// <value>The index.</value>
|
||||
public int? Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item identifier.
|
||||
/// </summary>
|
||||
/// <value>The item identifier.</value>
|
||||
public Guid ItemId { get; set; }
|
||||
}
|
||||
}
|
@ -0,0 +1,281 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
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 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 ConcurrentDictionary<string, SemaphoreSlim> _semaphoreLocks =
|
||||
new ConcurrentDictionary<string, SemaphoreSlim>();
|
||||
|
||||
private bool _disposed = false;
|
||||
|
||||
public AttachmentExtractor(
|
||||
ILogger<AttachmentExtractor> logger,
|
||||
IApplicationPaths appPaths,
|
||||
IFileSystem fileSystem,
|
||||
IMediaEncoder mediaEncoder,
|
||||
IMediaSourceManager mediaSourceManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_appPaths = appPaths;
|
||||
_fileSystem = fileSystem;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(MediaAttachment attachment, Stream stream)> GetAttachment(BaseItem item, string mediaSourceId, int attachmentStreamIndex, CancellationToken cancellationToken)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(item));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mediaSourceId))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(mediaSourceId));
|
||||
}
|
||||
|
||||
var mediaSources = await _mediaSourceManager.GetPlayackMediaSources(item, null, true, false, cancellationToken).ConfigureAwait(false);
|
||||
var mediaSource = mediaSources
|
||||
.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
|
||||
if (mediaSource == null)
|
||||
{
|
||||
throw new ResourceNotFoundException($"MediaSource {mediaSourceId} not found");
|
||||
}
|
||||
|
||||
var mediaAttachment = mediaSource.MediaAttachments
|
||||
.FirstOrDefault(i => i.Index == attachmentStreamIndex);
|
||||
if (mediaAttachment == 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);
|
||||
}
|
||||
|
||||
private async Task<Stream> GetAttachmentStream(
|
||||
MediaSourceInfo mediaSource,
|
||||
MediaAttachment mediaAttachment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource.Protocol, mediaAttachment, cancellationToken).ConfigureAwait(false);
|
||||
return File.OpenRead(attachmentPath);
|
||||
}
|
||||
|
||||
private async Task<string> GetReadableFile(
|
||||
string mediaPath,
|
||||
string inputFile,
|
||||
MediaProtocol protocol,
|
||||
MediaAttachment mediaAttachment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outputPath = GetAttachmentCachePath(mediaPath, protocol, mediaAttachment.Index);
|
||||
await ExtractAttachment(inputFile, protocol, mediaAttachment.Index, outputPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
private async Task ExtractAttachment(
|
||||
string inputFile,
|
||||
MediaProtocol protocol,
|
||||
int attachmentStreamIndex,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var semaphore = _semaphoreLocks.GetOrAdd(outputPath, key => new SemaphoreSlim(1, 1));
|
||||
|
||||
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(outputPath))
|
||||
{
|
||||
await ExtractAttachmentInternal(
|
||||
_mediaEncoder.GetInputArgument(new[] { inputFile }, protocol),
|
||||
attachmentStreamIndex,
|
||||
outputPath,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExtractAttachmentInternal(
|
||||
string inputPath,
|
||||
int attachmentStreamIndex,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(inputPath))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(inputPath));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(outputPath));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
|
||||
|
||||
var processArgs = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-dump_attachment:{1} {2} -i {0} -t 0 -f null null",
|
||||
inputPath,
|
||||
attachmentStreamIndex,
|
||||
outputPath);
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
Arguments = processArgs,
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
ErrorDialog = false
|
||||
};
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
||||
|
||||
process.Start();
|
||||
|
||||
var processTcs = new TaskCompletionSource<bool>();
|
||||
process.EnableRaisingEvents = true;
|
||||
process.Exited += (sender, args) => processTcs.TrySetResult(true);
|
||||
var unregister = cancellationToken.Register(() => processTcs.TrySetResult(process.HasExited));
|
||||
var ranToCompletion = await processTcs.Task.ConfigureAwait(false);
|
||||
unregister.Dispose();
|
||||
|
||||
if (!ranToCompletion)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Killing ffmpeg attachment extraction process");
|
||||
process.Kill();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error killing attachment extraction process");
|
||||
}
|
||||
}
|
||||
|
||||
var exitCode = ranToCompletion ? process.ExitCode : -1;
|
||||
|
||||
process.Dispose();
|
||||
|
||||
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)
|
||||
{
|
||||
var msg = $"ffmpeg attachment extraction failed for {inputPath} to {outputPath}";
|
||||
|
||||
_logger.LogError(msg);
|
||||
|
||||
throw new InvalidOperationException(msg);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("ffmpeg attachment extraction completed for {Path} to {Path}", inputPath, outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetAttachmentCachePath(string mediaPath, MediaProtocol protocol, int attachmentStreamIndex)
|
||||
{
|
||||
string filename;
|
||||
if (protocol == MediaProtocol.File)
|
||||
{
|
||||
var date = _fileSystem.GetLastWriteTimeUtc(mediaPath);
|
||||
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D");
|
||||
}
|
||||
else
|
||||
{
|
||||
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D");
|
||||
}
|
||||
|
||||
var prefix = filename.Substring(0, 1);
|
||||
return Path.Combine(_appPaths.DataPath, "attachments", prefix, filename);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases unmanaged and - optionally - managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
</packages>
|
@ -0,0 +1,50 @@
|
||||
namespace MediaBrowser.Model.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// Class MediaAttachment
|
||||
/// </summary>
|
||||
public class MediaAttachment
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the codec.
|
||||
/// </summary>
|
||||
/// <value>The codec.</value>
|
||||
public string Codec { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the codec tag.
|
||||
/// </summary>
|
||||
/// <value>The codec tag.</value>
|
||||
public string CodecTag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the comment.
|
||||
/// </summary>
|
||||
/// <value>The comment.</value>
|
||||
public string Comment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the index.
|
||||
/// </summary>
|
||||
/// <value>The index.</value>
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the filename.
|
||||
/// </summary>
|
||||
/// <value>The filename.</value>
|
||||
public string FileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type.
|
||||
/// </summary>
|
||||
/// <value>The MIME type.</value>
|
||||
public string MimeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the delivery URL.
|
||||
/// </summary>
|
||||
/// <value>The delivery URL.</value>
|
||||
public string DeliveryUrl { get; set; }
|
||||
}
|
||||
}
|
Loading…
Reference in new issue