|
|
|
#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<AttachmentExtractor> _logger;
|
|
|
|
private readonly IApplicationPaths _appPaths;
|
|
|
|
private readonly IFileSystem _fileSystem;
|
|
|
|
private readonly IMediaEncoder _mediaEncoder;
|
|
|
|
private readonly IMediaSourceManager _mediaSourceManager;
|
|
|
|
|
|
|
|
private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
|
|
|
|
{
|
|
|
|
o.PoolSize = 20;
|
|
|
|
o.PoolInitialFill = 1;
|
|
|
|
});
|
|
|
|
|
|
|
|
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)
|
|
|
|
{
|
|
|
|
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)
|
|
|
|
{
|
|
|
|
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 -i {0} -t 0 -f null null",
|
|
|
|
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<Stream> 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<string> 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<AsyncKeyedLockReleaser<string>>();
|
|
|
|
var extractableAttachmentIds = new List<int>();
|
|
|
|
|
|
|
|
try
|
|
|
|
{
|
|
|
|
foreach (var attachment in mediaSource.MediaAttachments)
|
|
|
|
{
|
|
|
|
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachment.Index);
|
|
|
|
|
|
|
|
var @outputFileLock = _semaphoreLocks.GetOrAdd(outputPath);
|
|
|
|
await @outputFileLock.SemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
if (File.Exists(outputPath))
|
|
|
|
{
|
|
|
|
@outputFileLock.Dispose();
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
outputFileLocks.Add(@outputFileLock);
|
|
|
|
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
|
|
|
|
{
|
|
|
|
foreach (var @outputFileLock in outputFileLocks)
|
|
|
|
{
|
|
|
|
@outputFileLock.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))
|
|
|
|
{
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// <inheritdoc />
|
|
|
|
public void Dispose()
|
|
|
|
{
|
|
|
|
_semaphoreLocks.Dispose();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|