| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001 | #pragma warning disable CS1591using System;using System.Collections.Generic;using System.Diagnostics;using System.Diagnostics.CodeAnalysis;using System.Globalization;using System.IO;using System.Linq;using System.Net.Http;using System.Text;using System.Threading;using System.Threading.Tasks;using AsyncKeyedLock;using MediaBrowser.Common;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;using MediaBrowser.Model.Entities;using MediaBrowser.Model.IO;using MediaBrowser.Model.MediaInfo;using Microsoft.Extensions.Logging;using UtfUnknown;namespace MediaBrowser.MediaEncoding.Subtitles{    public sealed class SubtitleEncoder : ISubtitleEncoder, IDisposable    {        private readonly ILogger<SubtitleEncoder> _logger;        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.        /// </summary>        private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>        {            o.PoolSize = 20;            o.PoolInitialFill = 1;        });        public SubtitleEncoder(            ILogger<SubtitleEncoder> logger,            IFileSystem fileSystem,            IMediaEncoder mediaEncoder,            IHttpClientFactory httpClientFactory,            IMediaSourceManager mediaSourceManager,            ISubtitleParser subtitleParser,            IPathManager pathManager)        {            _logger = logger;            _fileSystem = fileSystem;            _mediaEncoder = mediaEncoder;            _httpClientFactory = httpClientFactory;            _mediaSourceManager = mediaSourceManager;            _subtitleParser = subtitleParser;            _pathManager = pathManager;        }        private MemoryStream ConvertSubtitles(            Stream stream,            string inputFormat,            string outputFormat,            long startTimeTicks,            long endTimeTicks,            bool preserveOriginalTimestamps,            CancellationToken cancellationToken)        {            var ms = new MemoryStream();            try            {                var trackInfo = _subtitleParser.Parse(stream, inputFormat);                FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);                var writer = GetWriter(outputFormat);                writer.Write(trackInfo, ms, cancellationToken);                ms.Position = 0;            }            catch            {                ms.Dispose();                throw;            }            return ms;        }        private void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps)        {            // Drop subs that are earlier than what we're looking for            track.TrackEvents = track.TrackEvents                .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 || (i.EndPositionTicks - startPositionTicks) < 0)                .ToArray();            if (endTimeTicks > 0)            {                track.TrackEvents = track.TrackEvents                    .TakeWhile(i => i.StartPositionTicks <= endTimeTicks)                    .ToArray();            }            if (!preserveTimestamps)            {                foreach (var trackEvent in track.TrackEvents)                {                    trackEvent.EndPositionTicks -= startPositionTicks;                    trackEvent.StartPositionTicks -= startPositionTicks;                }            }        }        async Task<Stream> ISubtitleEncoder.GetSubtitles(BaseItem item, string mediaSourceId, int subtitleStreamIndex, string outputFormat, long startTimeTicks, long endTimeTicks, bool preserveOriginalTimestamps, 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                .First(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));            var subtitleStream = mediaSource.MediaStreams               .First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex);            var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken)                        .ConfigureAwait(false);            // Return the original if the same format is being requested            // Character encoding was already handled in GetSubtitleStream            if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase))            {                return stream;            }            using (stream)            {                return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken);            }        }        private async Task<(Stream Stream, string Format)> GetSubtitleStream(            MediaSourceInfo mediaSource,            MediaStream subtitleStream,            CancellationToken cancellationToken)        {            var fileInfo = await GetReadableFile(mediaSource, subtitleStream, cancellationToken).ConfigureAwait(false);            var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false);            return (stream, fileInfo.Format);        }        private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)        {            if (fileInfo.IsExternal)            {                var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);                await using (stream.ConfigureAwait(false))                {                    var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);                    var detected = result.Detected;                    stream.Position = 0;                    if (detected is not null)                    {                        _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);                        using var reader = new StreamReader(stream, detected.Encoding);                        var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);                        return new MemoryStream(Encoding.UTF8.GetBytes(text));                    }                }            }            return AsyncFile.OpenRead(fileInfo.Path);        }        internal async Task<SubtitleInfo> GetReadableFile(            MediaSourceInfo mediaSource,            MediaStream subtitleStream,            CancellationToken cancellationToken)        {            if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))            {                await ExtractAllExtractableSubtitles(mediaSource, cancellationToken).ConfigureAwait(false);                var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream);                var outputFormat = GetExtractableSubtitleFormat(subtitleStream);                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension);                return new SubtitleInfo()                {                    Path = outputPath,                    Protocol = MediaProtocol.File,                    Format = outputFormat,                    IsExternal = false                };            }            var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec)                .TrimStart('.');            // Handle PGS subtitles as raw streams for the client to render            if (MediaStream.IsPgsFormat(currentFormat))            {                return new SubtitleInfo()                {                    Path = subtitleStream.Path,                    Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),                    Format = "pgssub",                    IsExternal = true                };            }            // Fallback to ffmpeg conversion            if (!_subtitleParser.SupportsFileExtension(currentFormat))            {                // Convert                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt");                await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);                return new SubtitleInfo()                {                    Path = outputPath,                    Protocol = MediaProtocol.File,                    Format = "srt",                    IsExternal = true                };            }            // It's possible that the subtitleStream and mediaSource don't share the same protocol (e.g. .STRM file with local subs)            return new SubtitleInfo()            {                Path = subtitleStream.Path,                Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),                Format = currentFormat,                IsExternal = true            };        }        private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value)        {            ArgumentException.ThrowIfNullOrEmpty(format);            if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))            {                value = new AssWriter();                return true;            }            if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))            {                value = new JsonWriter();                return true;            }            if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase))            {                value = new SrtWriter();                return true;            }            if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase))            {                value = new SsaWriter();                return true;            }            if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase))            {                value = new VttWriter();                return true;            }            if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase))            {                value = new TtmlWriter();                return true;            }            value = null;            return false;        }        private ISubtitleWriter GetWriter(string format)        {            if (TryGetWriter(format, out var writer))            {                return writer;            }            throw new ArgumentException("Unsupported format: " + format);        }        /// <summary>        /// Converts the text subtitle to SRT.        /// </summary>        /// <param name="subtitleStream">The subtitle stream.</param>        /// <param name="mediaSource">The input mediaSource.</param>        /// <param name="outputPath">The output path.</param>        /// <param name="cancellationToken">The cancellation token.</param>        /// <returns>Task.</returns>        private async Task ConvertTextSubtitleToSrt(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken)        {            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))            {                if (!File.Exists(outputPath))                {                    await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);                }            }        }        /// <summary>        /// Converts the text subtitle to SRT internal.        /// </summary>        /// <param name="subtitleStream">The subtitle stream.</param>        /// <param name="mediaSource">The input mediaSource.</param>        /// <param name="outputPath">The output path.</param>        /// <param name="cancellationToken">The cancellation token.</param>        /// <returns>Task.</returns>        /// <exception cref="ArgumentNullException">        /// The <c>inputPath</c> or <c>outputPath</c> is <c>null</c>.        /// </exception>        private async Task ConvertTextSubtitleToSrtInternal(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken)        {            var inputPath = subtitleStream.Path;            ArgumentException.ThrowIfNullOrEmpty(inputPath);            ArgumentException.ThrowIfNullOrEmpty(outputPath);            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));            var encodingParam = await GetSubtitleFileCharacterSet(subtitleStream, subtitleStream.Language, mediaSource, cancellationToken).ConfigureAwait(false);            // FFmpeg automatically convert character encoding when it is UTF-16            // If we specify character encoding, it rejects with "do not specify a character encoding" and "Unable to recode subtitle event"            if ((inputPath.EndsWith(".smi", StringComparison.Ordinal) || inputPath.EndsWith(".sami", StringComparison.Ordinal)) &&                (encodingParam.Equals("UTF-16BE", StringComparison.OrdinalIgnoreCase) ||                 encodingParam.Equals("UTF-16LE", StringComparison.OrdinalIgnoreCase)))            {                encodingParam = string.Empty;            }            else if (!string.IsNullOrEmpty(encodingParam))            {                encodingParam = " -sub_charenc " + encodingParam;            }            int exitCode;            using (var process = new Process            {                StartInfo = new ProcessStartInfo                {                    CreateNoWindow = true,                    UseShellExecute = false,                    FileName = _mediaEncoder.EncoderPath,                    Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),                    WindowStyle = ProcessWindowStyle.Hidden,                    ErrorDialog = false                },                EnableRaisingEvents = true            })            {                _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);                try                {                    process.Start();                }                catch (Exception ex)                {                    _logger.LogError(ex, "Error starting ffmpeg");                    throw;                }                try                {                    await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);                    exitCode = process.ExitCode;                }                catch (OperationCanceledException)                {                    process.Kill(true);                    exitCode = -1;                }            }            var failed = false;            if (exitCode == -1)            {                failed = true;                if (File.Exists(outputPath))                {                    try                    {                        _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);                        _fileSystem.DeleteFile(outputPath);                    }                    catch (IOException ex)                    {                        _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);                    }                }            }            else if (!File.Exists(outputPath))            {                failed = true;            }            if (failed)            {                _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);                throw new FfmpegException(                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath));            }            await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);            _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);        }        private string GetExtractableSubtitleFormat(MediaStream subtitleStream)        {            if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)                || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)                || string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))            {                return subtitleStream.Codec;            }            else            {                return "srt";            }        }        private string GetExtractableSubtitleFileExtension(MediaStream subtitleStream)        {            // Using .pgssub as file extension is not allowed by ffmpeg. The file extension for pgs subtitles is .sup.            if (string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))            {                return "sup";            }            else            {                return GetExtractableSubtitleFormat(subtitleStream);            }        }        private bool IsCodecCopyable(string codec)        {            return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase)                || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)                || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)                || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase)                || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase);        }        /// <inheritdoc />        public async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken)        {            var locks = new List<IDisposable>();            var extractableStreams = new List<MediaStream>();            try            {                var subtitleStreams = mediaSource.MediaStreams                    .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true });                foreach (var subtitleStream in subtitleStreams)                {                    if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))                    {                        continue;                    }                    var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));                    var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);                    if (File.Exists(outputPath))                    {                        releaser.Dispose();                        continue;                    }                    locks.Add(releaser);                    extractableStreams.Add(subtitleStream);                }                if (extractableStreams.Count > 0)                {                    await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);                    await ExtractAllExtractableSubtitlesMKS(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);                }            }            catch (Exception ex)            {                _logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path);            }            finally            {                locks.ForEach(x => x.Dispose());            }        }        private async Task ExtractAllExtractableSubtitlesMKS(           MediaSourceInfo mediaSource,           List<MediaStream> subtitleStreams,           CancellationToken cancellationToken)        {            var mksFiles = new List<string>();            foreach (var subtitleStream in subtitleStreams)            {                if (string.IsNullOrEmpty(subtitleStream.Path) || !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))                {                    continue;                }                if (!mksFiles.Contains(subtitleStream.Path))                {                    mksFiles.Add(subtitleStream.Path);                }            }            if (mksFiles.Count == 0)            {                return;            }            foreach (string mksFile in mksFiles)            {                var inputPath = _mediaEncoder.GetInputArgument(mksFile, mediaSource);                var outputPaths = new List<string>();                var args = string.Format(                    CultureInfo.InvariantCulture,                    "-i {0} -copyts",                    inputPath);                foreach (var subtitleStream in subtitleStreams)                {                    if (!subtitleStream.Path.Equals(mksFile, StringComparison.OrdinalIgnoreCase))                    {                        continue;                    }                    var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));                    var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";                    var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);                    if (streamIndex == -1)                    {                        _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream", inputPath, subtitleStream.Index);                        continue;                    }                    Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));                    outputPaths.Add(outputPath);                    args += string.Format(                        CultureInfo.InvariantCulture,                        " -map 0:{0} -an -vn -c:s {1} \"{2}\"",                        streamIndex,                        outputCodec,                        outputPath);                }                await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);            }        }        private async Task ExtractAllExtractableSubtitlesInternal(            MediaSourceInfo mediaSource,            List<MediaStream> subtitleStreams,            CancellationToken cancellationToken)        {            var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);            var outputPaths = new List<string>();            var args = string.Format(                CultureInfo.InvariantCulture,                "-i {0} -copyts",                inputPath);            foreach (var subtitleStream in subtitleStreams)            {                if (!string.IsNullOrEmpty(subtitleStream.Path) && subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))                {                    _logger.LogDebug("Subtitle {Index} for file {InputPath} is part in an MKS file. Skipping", inputPath, subtitleStream.Index);                    continue;                }                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));                var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";                var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);                if (streamIndex == -1)                {                    _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream", inputPath, subtitleStream.Index);                    continue;                }                Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));                outputPaths.Add(outputPath);                args += string.Format(                    CultureInfo.InvariantCulture,                    " -map 0:{0} -an -vn -c:s {1} \"{2}\"",                    streamIndex,                    outputCodec,                    outputPath);            }            if (outputPaths.Count == 0)            {                return;            }            await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);        }        private async Task ExtractSubtitlesForFile(            string inputPath,            string args,            List<string> outputPaths,            CancellationToken cancellationToken)        {            int exitCode;            using (var process = new Process            {                StartInfo = new ProcessStartInfo                {                    CreateNoWindow = true,                    UseShellExecute = false,                    FileName = _mediaEncoder.EncoderPath,                    Arguments = args,                    WindowStyle = ProcessWindowStyle.Hidden,                    ErrorDialog = false                },                EnableRaisingEvents = true            })            {                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);                try                {                    process.Start();                }                catch (Exception ex)                {                    _logger.LogError(ex, "Error starting ffmpeg");                    throw;                }                try                {                    await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).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 subtitle due to failure: {Path}", outputPath);                        _fileSystem.DeleteFile(outputPath);                    }                    catch (FileNotFoundException)                    {                    }                    catch (IOException ex)                    {                        _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);                    }                }            }            else            {                foreach (var outputPath in outputPaths)                {                    if (!File.Exists(outputPath))                    {                        _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);                        failed = true;                        continue;                    }                    if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase))                    {                        await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);                    }                    _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);                }            }            if (failed)            {                throw new FfmpegException(                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));            }        }        /// <summary>        /// Extracts the text subtitle.        /// </summary>        /// <param name="mediaSource">The mediaSource.</param>        /// <param name="subtitleStream">The subtitle stream.</param>        /// <param name="outputCodec">The output codec.</param>        /// <param name="outputPath">The output path.</param>        /// <param name="cancellationToken">The cancellation token.</param>        /// <returns>Task.</returns>        /// <exception cref="ArgumentException">Must use inputPath list overload.</exception>        private async Task ExtractTextSubtitle(            MediaSourceInfo mediaSource,            MediaStream subtitleStream,            string outputCodec,            string outputPath,            CancellationToken cancellationToken)        {            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))            {                if (!File.Exists(outputPath))                {                    var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);                    var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);                    if (subtitleStream.IsExternal)                    {                        args = _mediaEncoder.GetExternalSubtitleInputArgument(subtitleStream.Path);                    }                    await ExtractTextSubtitleInternal(                        args,                        subtitleStreamIndex,                        outputCodec,                        outputPath,                        cancellationToken).ConfigureAwait(false);                }            }        }        private async Task ExtractTextSubtitleInternal(            string inputPath,            int subtitleStreamIndex,            string outputCodec,            string outputPath,            CancellationToken cancellationToken)        {            ArgumentException.ThrowIfNullOrEmpty(inputPath);            ArgumentException.ThrowIfNullOrEmpty(outputPath);            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));            var processArgs = string.Format(                CultureInfo.InvariantCulture,                "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",                inputPath,                subtitleStreamIndex,                outputCodec,                outputPath);            int exitCode;            using (var process = new Process            {                StartInfo = new ProcessStartInfo                {                    CreateNoWindow = true,                    UseShellExecute = false,                    FileName = _mediaEncoder.EncoderPath,                    Arguments = processArgs,                    WindowStyle = ProcessWindowStyle.Hidden,                    ErrorDialog = false                },                EnableRaisingEvents = true            })            {                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);                try                {                    process.Start();                }                catch (Exception ex)                {                    _logger.LogError(ex, "Error starting ffmpeg");                    throw;                }                try                {                    await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);                    exitCode = process.ExitCode;                }                catch (OperationCanceledException)                {                    process.Kill(true);                    exitCode = -1;                }            }            var failed = false;            if (exitCode == -1)            {                failed = true;                try                {                    _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);                    _fileSystem.DeleteFile(outputPath);                }                catch (FileNotFoundException)                {                }                catch (IOException ex)                {                    _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);                }            }            else if (!File.Exists(outputPath))            {                failed = true;            }            if (failed)            {                _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);                throw new FfmpegException(                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath));            }            _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);            if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))            {                await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);            }        }        /// <summary>        /// Sets the ass font.        /// </summary>        /// <param name="file">The file.</param>        /// <param name="cancellationToken">The token to monitor for cancellation requests. The default value is <c>System.Threading.CancellationToken.None</c>.</param>        /// <returns>Task.</returns>        private async Task SetAssFont(string file, CancellationToken cancellationToken = default)        {            _logger.LogInformation("Setting ass font within {File}", file);            string text;            Encoding encoding;            using (var fileStream = AsyncFile.OpenRead(file))            using (var reader = new StreamReader(fileStream, true))            {                encoding = reader.CurrentEncoding;                text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);            }            var newText = text.Replace(",Arial,", ",Arial Unicode MS,", StringComparison.Ordinal);            if (!string.Equals(text, newText, StringComparison.Ordinal))            {                var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);                await using (fileStream.ConfigureAwait(false))                {                    var writer = new StreamWriter(fileStream, encoding);                    await using (writer.ConfigureAwait(false))                    {                        await writer.WriteAsync(newText.AsMemory(), cancellationToken).ConfigureAwait(false);                    }                }            }        }        private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)        {            return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);        }        /// <inheritdoc />        public async Task<string> GetSubtitleFileCharacterSet(MediaStream subtitleStream, string language, MediaSourceInfo mediaSource, CancellationToken cancellationToken)        {            var subtitleCodec = subtitleStream.Codec;            var path = subtitleStream.Path;            if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))            {                path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);                await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)                    .ConfigureAwait(false);            }            var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);            await using (stream.ConfigureAwait(false))            {                var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);                var charset = result.Detected?.EncodingName ?? string.Empty;                // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding                if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || path.EndsWith(".srt", StringComparison.Ordinal))                    && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase)                        || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase)))                {                    charset = string.Empty;                }                _logger.LogDebug("charset {0} detected for {Path}", charset, path);                return charset;            }        }        private async Task<Stream> GetStream(string path, MediaProtocol protocol, CancellationToken cancellationToken)        {            switch (protocol)            {                case MediaProtocol.Http:                    {                        using var response = await _httpClientFactory.CreateClient(NamedClient.Default)                            .GetAsync(new Uri(path), cancellationToken)                            .ConfigureAwait(false);                        return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);                    }                case MediaProtocol.File:                    return AsyncFile.OpenRead(path);                default:                    throw new ArgumentOutOfRangeException(nameof(protocol));            }        }        public async Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, CancellationToken cancellationToken)        {            var info = await GetReadableFile(mediaSource, subtitleStream, cancellationToken)                .ConfigureAwait(false);            return info.Path;        }        /// <inheritdoc />        public void Dispose()        {            _semaphoreLocks.Dispose();        }#pragma warning disable CA1034 // Nested types should not be visible        // Only public for the unit tests        public readonly record struct SubtitleInfo        {            public string Path { get; init; }            public MediaProtocol Protocol { get; init; }            public string Format { get; init; }            public bool IsExternal { get; init; }        }    }}
 |