< Summary - Jellyfin

Information
Class: MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder
Assembly: MediaBrowser.MediaEncoding
File(s): /srv/git/jellyfin/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
Line coverage
19%
Covered lines: 15
Uncovered lines: 61
Coverable lines: 76
Total lines: 1004
Line coverage: 19.7%
Branch coverage
0%
Covered branches: 0
Total branches: 40
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ConvertSubtitles(...)100%210%
FilterEvents(...)0%4260%
TryGetWriter(...)0%272160%
GetWriter(...)0%620%
GetExtractableSubtitleFormat(...)0%4260%
GetExtractableSubtitleFileExtension(...)0%620%
IsCodecCopyable(...)0%7280%
GetSubtitleCachePath(...)100%210%
Dispose()100%11100%

File(s)

/srv/git/jellyfin/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs

#LineLine coverage
 1#pragma warning disable CS1591
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Diagnostics;
 6using System.Diagnostics.CodeAnalysis;
 7using System.Globalization;
 8using System.IO;
 9using System.Linq;
 10using System.Net.Http;
 11using System.Text;
 12using System.Threading;
 13using System.Threading.Tasks;
 14using AsyncKeyedLock;
 15using MediaBrowser.Common;
 16using MediaBrowser.Common.Extensions;
 17using MediaBrowser.Common.Net;
 18using MediaBrowser.Controller.Entities;
 19using MediaBrowser.Controller.IO;
 20using MediaBrowser.Controller.Library;
 21using MediaBrowser.Controller.MediaEncoding;
 22using MediaBrowser.Model.Dto;
 23using MediaBrowser.Model.Entities;
 24using MediaBrowser.Model.IO;
 25using MediaBrowser.Model.MediaInfo;
 26using Microsoft.Extensions.Logging;
 27using UtfUnknown;
 28
 29namespace MediaBrowser.MediaEncoding.Subtitles
 30{
 31    public sealed class SubtitleEncoder : ISubtitleEncoder, IDisposable
 32    {
 33        private readonly ILogger<SubtitleEncoder> _logger;
 34        private readonly IFileSystem _fileSystem;
 35        private readonly IMediaEncoder _mediaEncoder;
 36        private readonly IHttpClientFactory _httpClientFactory;
 37        private readonly IMediaSourceManager _mediaSourceManager;
 38        private readonly ISubtitleParser _subtitleParser;
 39        private readonly IPathManager _pathManager;
 40
 41        /// <summary>
 42        /// The _semaphoreLocks.
 43        /// </summary>
 2544        private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
 2545        {
 2546            o.PoolSize = 20;
 2547            o.PoolInitialFill = 1;
 2548        });
 49
 50        public SubtitleEncoder(
 51            ILogger<SubtitleEncoder> logger,
 52            IFileSystem fileSystem,
 53            IMediaEncoder mediaEncoder,
 54            IHttpClientFactory httpClientFactory,
 55            IMediaSourceManager mediaSourceManager,
 56            ISubtitleParser subtitleParser,
 57            IPathManager pathManager)
 58        {
 2559            _logger = logger;
 2560            _fileSystem = fileSystem;
 2561            _mediaEncoder = mediaEncoder;
 2562            _httpClientFactory = httpClientFactory;
 2563            _mediaSourceManager = mediaSourceManager;
 2564            _subtitleParser = subtitleParser;
 2565            _pathManager = pathManager;
 2566        }
 67
 68        private MemoryStream ConvertSubtitles(
 69            Stream stream,
 70            string inputFormat,
 71            string outputFormat,
 72            long startTimeTicks,
 73            long endTimeTicks,
 74            bool preserveOriginalTimestamps,
 75            CancellationToken cancellationToken)
 76        {
 077            var ms = new MemoryStream();
 78
 79            try
 80            {
 081                var trackInfo = _subtitleParser.Parse(stream, inputFormat);
 82
 083                FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
 84
 085                var writer = GetWriter(outputFormat);
 86
 087                writer.Write(trackInfo, ms, cancellationToken);
 088                ms.Position = 0;
 089            }
 090            catch
 91            {
 092                ms.Dispose();
 093                throw;
 94            }
 95
 096            return ms;
 97        }
 98
 99        private void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTime
 100        {
 101            // Drop subs that are earlier than what we're looking for
 0102            track.TrackEvents = track.TrackEvents
 0103                .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 || (i.EndPositionTicks - startPositionTi
 0104                .ToArray();
 105
 0106            if (endTimeTicks > 0)
 107            {
 0108                track.TrackEvents = track.TrackEvents
 0109                    .TakeWhile(i => i.StartPositionTicks <= endTimeTicks)
 0110                    .ToArray();
 111            }
 112
 0113            if (!preserveTimestamps)
 114            {
 0115                foreach (var trackEvent in track.TrackEvents)
 116                {
 0117                    trackEvent.EndPositionTicks -= startPositionTicks;
 0118                    trackEvent.StartPositionTicks -= startPositionTicks;
 119                }
 120            }
 0121        }
 122
 123        async Task<Stream> ISubtitleEncoder.GetSubtitles(BaseItem item, string mediaSourceId, int subtitleStreamIndex, s
 124        {
 125            ArgumentNullException.ThrowIfNull(item);
 126
 127            if (string.IsNullOrWhiteSpace(mediaSourceId))
 128            {
 129                throw new ArgumentNullException(nameof(mediaSourceId));
 130            }
 131
 132            var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationTo
 133
 134            var mediaSource = mediaSources
 135                .First(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
 136
 137            var subtitleStream = mediaSource.MediaStreams
 138               .First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex);
 139
 140            var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken)
 141                        .ConfigureAwait(false);
 142
 143            // Return the original if the same format is being requested
 144            // Character encoding was already handled in GetSubtitleStream
 145            if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase))
 146            {
 147                return stream;
 148            }
 149
 150            using (stream)
 151            {
 152                return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOrigina
 153            }
 154        }
 155
 156        private async Task<(Stream Stream, string Format)> GetSubtitleStream(
 157            MediaSourceInfo mediaSource,
 158            MediaStream subtitleStream,
 159            CancellationToken cancellationToken)
 160        {
 161            var fileInfo = await GetReadableFile(mediaSource, subtitleStream, cancellationToken).ConfigureAwait(false);
 162
 163            var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false);
 164
 165            return (stream, fileInfo.Format);
 166        }
 167
 168        private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
 169        {
 170            if (fileInfo.IsExternal)
 171            {
 172                using (var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(
 173                {
 174                    var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(f
 175                    var detected = result.Detected;
 176                    stream.Position = 0;
 177
 178                    if (detected is not null)
 179                    {
 180                        _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
 181
 182                        using var reader = new StreamReader(stream, detected.Encoding);
 183                        var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 184
 185                        return new MemoryStream(Encoding.UTF8.GetBytes(text));
 186                    }
 187                }
 188            }
 189
 190            return AsyncFile.OpenRead(fileInfo.Path);
 191        }
 192
 193        internal async Task<SubtitleInfo> GetReadableFile(
 194            MediaSourceInfo mediaSource,
 195            MediaStream subtitleStream,
 196            CancellationToken cancellationToken)
 197        {
 198            if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 199            {
 200                await ExtractAllExtractableSubtitles(mediaSource, cancellationToken).ConfigureAwait(false);
 201
 202                var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream);
 203                var outputFormat = GetExtractableSubtitleFormat(subtitleStream);
 204                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension);
 205
 206                return new SubtitleInfo()
 207                {
 208                    Path = outputPath,
 209                    Protocol = MediaProtocol.File,
 210                    Format = outputFormat,
 211                    IsExternal = false
 212                };
 213            }
 214
 215            var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec)
 216                .TrimStart('.');
 217
 218            // Handle PGS subtitles as raw streams for the client to render
 219            if (MediaStream.IsPgsFormat(currentFormat))
 220            {
 221                return new SubtitleInfo()
 222                {
 223                    Path = subtitleStream.Path,
 224                    Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
 225                    Format = "pgssub",
 226                    IsExternal = true
 227                };
 228            }
 229
 230            // Fallback to ffmpeg conversion
 231            if (!_subtitleParser.SupportsFileExtension(currentFormat))
 232            {
 233                // Convert
 234                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt");
 235
 236                await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwai
 237
 238                return new SubtitleInfo()
 239                {
 240                    Path = outputPath,
 241                    Protocol = MediaProtocol.File,
 242                    Format = "srt",
 243                    IsExternal = true
 244                };
 245            }
 246
 247            // It's possible that the subtitleStream and mediaSource don't share the same protocol (e.g. .STRM file with
 248            return new SubtitleInfo()
 249            {
 250                Path = subtitleStream.Path,
 251                Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
 252                Format = currentFormat,
 253                IsExternal = true
 254            };
 255        }
 256
 257        private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value)
 258        {
 0259            ArgumentException.ThrowIfNullOrEmpty(format);
 260
 0261            if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))
 262            {
 0263                value = new AssWriter();
 0264                return true;
 265            }
 266
 0267            if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
 268            {
 0269                value = new JsonWriter();
 0270                return true;
 271            }
 272
 0273            if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, S
 274            {
 0275                value = new SrtWriter();
 0276                return true;
 277            }
 278
 0279            if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase))
 280            {
 0281                value = new SsaWriter();
 0282                return true;
 283            }
 284
 0285            if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, S
 286            {
 0287                value = new VttWriter();
 0288                return true;
 289            }
 290
 0291            if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase))
 292            {
 0293                value = new TtmlWriter();
 0294                return true;
 295            }
 296
 0297            value = null;
 0298            return false;
 299        }
 300
 301        private ISubtitleWriter GetWriter(string format)
 302        {
 0303            if (TryGetWriter(format, out var writer))
 304            {
 0305                return writer;
 306            }
 307
 0308            throw new ArgumentException("Unsupported format: " + format);
 309        }
 310
 311        /// <summary>
 312        /// Converts the text subtitle to SRT.
 313        /// </summary>
 314        /// <param name="subtitleStream">The subtitle stream.</param>
 315        /// <param name="mediaSource">The input mediaSource.</param>
 316        /// <param name="outputPath">The output path.</param>
 317        /// <param name="cancellationToken">The cancellation token.</param>
 318        /// <returns>Task.</returns>
 319        private async Task ConvertTextSubtitleToSrt(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outp
 320        {
 321            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 322            {
 323                if (!File.Exists(outputPath))
 324                {
 325                    await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).C
 326                }
 327            }
 328        }
 329
 330        /// <summary>
 331        /// Converts the text subtitle to SRT internal.
 332        /// </summary>
 333        /// <param name="subtitleStream">The subtitle stream.</param>
 334        /// <param name="mediaSource">The input mediaSource.</param>
 335        /// <param name="outputPath">The output path.</param>
 336        /// <param name="cancellationToken">The cancellation token.</param>
 337        /// <returns>Task.</returns>
 338        /// <exception cref="ArgumentNullException">
 339        /// The <c>inputPath</c> or <c>outputPath</c> is <c>null</c>.
 340        /// </exception>
 341        private async Task ConvertTextSubtitleToSrtInternal(MediaStream subtitleStream, MediaSourceInfo mediaSource, str
 342        {
 343            var inputPath = subtitleStream.Path;
 344            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 345
 346            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 347
 348            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path (
 349
 350            var encodingParam = await GetSubtitleFileCharacterSet(subtitleStream, subtitleStream.Language, mediaSource, 
 351
 352            // FFmpeg automatically convert character encoding when it is UTF-16
 353            // If we specify character encoding, it rejects with "do not specify a character encoding" and "Unable to re
 354            if ((inputPath.EndsWith(".smi", StringComparison.Ordinal) || inputPath.EndsWith(".sami", StringComparison.Or
 355                (encodingParam.Equals("UTF-16BE", StringComparison.OrdinalIgnoreCase) ||
 356                 encodingParam.Equals("UTF-16LE", StringComparison.OrdinalIgnoreCase)))
 357            {
 358                encodingParam = string.Empty;
 359            }
 360            else if (!string.IsNullOrEmpty(encodingParam))
 361            {
 362                encodingParam = " -sub_charenc " + encodingParam;
 363            }
 364
 365            int exitCode;
 366
 367            using (var process = new Process
 368            {
 369                StartInfo = new ProcessStartInfo
 370                {
 371                    CreateNoWindow = true,
 372                    UseShellExecute = false,
 373                    FileName = _mediaEncoder.EncoderPath,
 374                    Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingP
 375                    WindowStyle = ProcessWindowStyle.Hidden,
 376                    ErrorDialog = false
 377                },
 378                EnableRaisingEvents = true
 379            })
 380            {
 381                _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
 382
 383                try
 384                {
 385                    process.Start();
 386                }
 387                catch (Exception ex)
 388                {
 389                    _logger.LogError(ex, "Error starting ffmpeg");
 390
 391                    throw;
 392                }
 393
 394                try
 395                {
 396                    await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
 397                    exitCode = process.ExitCode;
 398                }
 399                catch (OperationCanceledException)
 400                {
 401                    process.Kill(true);
 402                    exitCode = -1;
 403                }
 404            }
 405
 406            var failed = false;
 407
 408            if (exitCode == -1)
 409            {
 410                failed = true;
 411
 412                if (File.Exists(outputPath))
 413                {
 414                    try
 415                    {
 416                        _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
 417                        _fileSystem.DeleteFile(outputPath);
 418                    }
 419                    catch (IOException ex)
 420                    {
 421                        _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
 422                    }
 423                }
 424            }
 425            else if (!File.Exists(outputPath))
 426            {
 427                failed = true;
 428            }
 429
 430            if (failed)
 431            {
 432                _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
 433
 434                throw new FfmpegException(
 435                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath))
 436            }
 437
 438            await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 439
 440            _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
 441        }
 442
 443        private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
 444        {
 0445            if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
 0446                || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)
 0447                || string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
 448            {
 0449                return subtitleStream.Codec;
 450            }
 451            else
 452            {
 0453                return "srt";
 454            }
 455        }
 456
 457        private string GetExtractableSubtitleFileExtension(MediaStream subtitleStream)
 458        {
 459            // Using .pgssub as file extension is not allowed by ffmpeg. The file extension for pgs subtitles is .sup.
 0460            if (string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
 461            {
 0462                return "sup";
 463            }
 464            else
 465            {
 0466                return GetExtractableSubtitleFormat(subtitleStream);
 467            }
 468        }
 469
 470        private bool IsCodecCopyable(string codec)
 471        {
 0472            return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase)
 0473                || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)
 0474                || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)
 0475                || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase)
 0476                || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase);
 477        }
 478
 479        /// <summary>
 480        /// Extracts all extractable subtitles (text and pgs).
 481        /// </summary>
 482        /// <param name="mediaSource">The mediaSource.</param>
 483        /// <param name="cancellationToken">The cancellation token.</param>
 484        /// <returns>Task.</returns>
 485        private async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationTok
 486        {
 487            var locks = new List<IDisposable>();
 488            var extractableStreams = new List<MediaStream>();
 489
 490            try
 491            {
 492                var subtitleStreams = mediaSource.MediaStreams
 493                    .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true });
 494
 495                foreach (var subtitleStream in subtitleStreams)
 496                {
 497                    if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnor
 498                    {
 499                        continue;
 500                    }
 501
 502                    var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitl
 503
 504                    var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
 505
 506                    if (File.Exists(outputPath))
 507                    {
 508                        releaser.Dispose();
 509                        continue;
 510                    }
 511
 512                    locks.Add(releaser);
 513                    extractableStreams.Add(subtitleStream);
 514                }
 515
 516                if (extractableStreams.Count > 0)
 517                {
 518                    await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).Con
 519                    await ExtractAllExtractableSubtitlesMKS(mediaSource, extractableStreams, cancellationToken).Configur
 520                }
 521            }
 522            catch (Exception ex)
 523            {
 524                _logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path);
 525            }
 526            finally
 527            {
 528                locks.ForEach(x => x.Dispose());
 529            }
 530        }
 531
 532        private async Task ExtractAllExtractableSubtitlesMKS(
 533           MediaSourceInfo mediaSource,
 534           List<MediaStream> subtitleStreams,
 535           CancellationToken cancellationToken)
 536        {
 537            var mksFiles = new List<string>();
 538
 539            foreach (var subtitleStream in subtitleStreams)
 540            {
 541                if (string.IsNullOrEmpty(subtitleStream.Path) || !subtitleStream.Path.EndsWith(".mks", StringComparison.
 542                {
 543                    continue;
 544                }
 545
 546                if (!mksFiles.Contains(subtitleStream.Path))
 547                {
 548                    mksFiles.Add(subtitleStream.Path);
 549                }
 550            }
 551
 552            if (mksFiles.Count == 0)
 553            {
 554                return;
 555            }
 556
 557            foreach (string mksFile in mksFiles)
 558            {
 559                var inputPath = _mediaEncoder.GetInputArgument(mksFile, mediaSource);
 560                var outputPaths = new List<string>();
 561                var args = string.Format(
 562                    CultureInfo.InvariantCulture,
 563                    "-i {0} -copyts",
 564                    inputPath);
 565
 566                foreach (var subtitleStream in subtitleStreams)
 567                {
 568                    if (!subtitleStream.Path.Equals(mksFile, StringComparison.OrdinalIgnoreCase))
 569                    {
 570                        continue;
 571                    }
 572
 573                    var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitl
 574                    var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
 575                    var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 576
 577                    if (streamIndex == -1)
 578                    {
 579                        _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this str
 580                        continue;
 581                    }
 582
 583                    Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Cal
 584
 585                    outputPaths.Add(outputPath);
 586                    args += string.Format(
 587                        CultureInfo.InvariantCulture,
 588                        " -map 0:{0} -an -vn -c:s {1} \"{2}\"",
 589                        streamIndex,
 590                        outputCodec,
 591                        outputPath);
 592                }
 593
 594                await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
 595            }
 596        }
 597
 598        private async Task ExtractAllExtractableSubtitlesInternal(
 599            MediaSourceInfo mediaSource,
 600            List<MediaStream> subtitleStreams,
 601            CancellationToken cancellationToken)
 602        {
 603            var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 604            var outputPaths = new List<string>();
 605            var args = string.Format(
 606                CultureInfo.InvariantCulture,
 607                "-i {0} -copyts",
 608                inputPath);
 609
 610            foreach (var subtitleStream in subtitleStreams)
 611            {
 612                if (!string.IsNullOrEmpty(subtitleStream.Path) && subtitleStream.Path.EndsWith(".mks", StringComparison.
 613                {
 614                    _logger.LogDebug("Subtitle {Index} for file {InputPath} is part in an MKS file. Skipping", inputPath
 615                    continue;
 616                }
 617
 618                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFil
 619                var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
 620                var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 621
 622                if (streamIndex == -1)
 623                {
 624                    _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream"
 625                    continue;
 626                }
 627
 628                Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calcula
 629
 630                outputPaths.Add(outputPath);
 631                args += string.Format(
 632                    CultureInfo.InvariantCulture,
 633                    " -map 0:{0} -an -vn -c:s {1} \"{2}\"",
 634                    streamIndex,
 635                    outputCodec,
 636                    outputPath);
 637            }
 638
 639            if (outputPaths.Count == 0)
 640            {
 641                return;
 642            }
 643
 644            await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
 645        }
 646
 647        private async Task ExtractSubtitlesForFile(
 648            string inputPath,
 649            string args,
 650            List<string> outputPaths,
 651            CancellationToken cancellationToken)
 652        {
 653            int exitCode;
 654
 655            using (var process = new Process
 656            {
 657                StartInfo = new ProcessStartInfo
 658                {
 659                    CreateNoWindow = true,
 660                    UseShellExecute = false,
 661                    FileName = _mediaEncoder.EncoderPath,
 662                    Arguments = args,
 663                    WindowStyle = ProcessWindowStyle.Hidden,
 664                    ErrorDialog = false
 665                },
 666                EnableRaisingEvents = true
 667            })
 668            {
 669                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 670
 671                try
 672                {
 673                    process.Start();
 674                }
 675                catch (Exception ex)
 676                {
 677                    _logger.LogError(ex, "Error starting ffmpeg");
 678
 679                    throw;
 680                }
 681
 682                try
 683                {
 684                    await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
 685                    exitCode = process.ExitCode;
 686                }
 687                catch (OperationCanceledException)
 688                {
 689                    process.Kill(true);
 690                    exitCode = -1;
 691                }
 692            }
 693
 694            var failed = false;
 695
 696            if (exitCode == -1)
 697            {
 698                failed = true;
 699
 700                foreach (var outputPath in outputPaths)
 701                {
 702                    try
 703                    {
 704                        _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 705                        _fileSystem.DeleteFile(outputPath);
 706                    }
 707                    catch (FileNotFoundException)
 708                    {
 709                    }
 710                    catch (IOException ex)
 711                    {
 712                        _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 713                    }
 714                }
 715            }
 716            else
 717            {
 718                foreach (var outputPath in outputPaths)
 719                {
 720                    if (!File.Exists(outputPath))
 721                    {
 722                        _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath,
 723                        failed = true;
 724                        continue;
 725                    }
 726
 727                    if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase))
 728                    {
 729                        await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 730                    }
 731
 732                    _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", input
 733                }
 734            }
 735
 736            if (failed)
 737            {
 738                throw new FfmpegException(
 739                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath))
 740            }
 741        }
 742
 743        /// <summary>
 744        /// Extracts the text subtitle.
 745        /// </summary>
 746        /// <param name="mediaSource">The mediaSource.</param>
 747        /// <param name="subtitleStream">The subtitle stream.</param>
 748        /// <param name="outputCodec">The output codec.</param>
 749        /// <param name="outputPath">The output path.</param>
 750        /// <param name="cancellationToken">The cancellation token.</param>
 751        /// <returns>Task.</returns>
 752        /// <exception cref="ArgumentException">Must use inputPath list overload.</exception>
 753        private async Task ExtractTextSubtitle(
 754            MediaSourceInfo mediaSource,
 755            MediaStream subtitleStream,
 756            string outputCodec,
 757            string outputPath,
 758            CancellationToken cancellationToken)
 759        {
 760            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 761            {
 762                if (!File.Exists(outputPath))
 763                {
 764                    var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 765
 766                    var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 767
 768                    if (subtitleStream.IsExternal)
 769                    {
 770                        args = _mediaEncoder.GetExternalSubtitleInputArgument(subtitleStream.Path);
 771                    }
 772
 773                    await ExtractTextSubtitleInternal(
 774                        args,
 775                        subtitleStreamIndex,
 776                        outputCodec,
 777                        outputPath,
 778                        cancellationToken).ConfigureAwait(false);
 779                }
 780            }
 781        }
 782
 783        private async Task ExtractTextSubtitleInternal(
 784            string inputPath,
 785            int subtitleStreamIndex,
 786            string outputCodec,
 787            string outputPath,
 788            CancellationToken cancellationToken)
 789        {
 790            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 791
 792            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 793
 794            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path (
 795
 796            var processArgs = string.Format(
 797                CultureInfo.InvariantCulture,
 798                "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
 799                inputPath,
 800                subtitleStreamIndex,
 801                outputCodec,
 802                outputPath);
 803
 804            int exitCode;
 805
 806            using (var process = new Process
 807            {
 808                StartInfo = new ProcessStartInfo
 809                {
 810                    CreateNoWindow = true,
 811                    UseShellExecute = false,
 812                    FileName = _mediaEncoder.EncoderPath,
 813                    Arguments = processArgs,
 814                    WindowStyle = ProcessWindowStyle.Hidden,
 815                    ErrorDialog = false
 816                },
 817                EnableRaisingEvents = true
 818            })
 819            {
 820                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 821
 822                try
 823                {
 824                    process.Start();
 825                }
 826                catch (Exception ex)
 827                {
 828                    _logger.LogError(ex, "Error starting ffmpeg");
 829
 830                    throw;
 831                }
 832
 833                try
 834                {
 835                    await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
 836                    exitCode = process.ExitCode;
 837                }
 838                catch (OperationCanceledException)
 839                {
 840                    process.Kill(true);
 841                    exitCode = -1;
 842                }
 843            }
 844
 845            var failed = false;
 846
 847            if (exitCode == -1)
 848            {
 849                failed = true;
 850
 851                try
 852                {
 853                    _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 854                    _fileSystem.DeleteFile(outputPath);
 855                }
 856                catch (FileNotFoundException)
 857                {
 858                }
 859                catch (IOException ex)
 860                {
 861                    _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 862                }
 863            }
 864            else if (!File.Exists(outputPath))
 865            {
 866                failed = true;
 867            }
 868
 869            if (failed)
 870            {
 871                _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputP
 872
 873                throw new FfmpegException(
 874                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inpu
 875            }
 876
 877            _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, ou
 878
 879            if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
 880            {
 881                await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 882            }
 883        }
 884
 885        /// <summary>
 886        /// Sets the ass font.
 887        /// </summary>
 888        /// <param name="file">The file.</param>
 889        /// <param name="cancellationToken">The token to monitor for cancellation requests. The default value is <c>Syst
 890        /// <returns>Task.</returns>
 891        private async Task SetAssFont(string file, CancellationToken cancellationToken = default)
 892        {
 893            _logger.LogInformation("Setting ass font within {File}", file);
 894
 895            string text;
 896            Encoding encoding;
 897
 898            using (var fileStream = AsyncFile.OpenRead(file))
 899            using (var reader = new StreamReader(fileStream, true))
 900            {
 901                encoding = reader.CurrentEncoding;
 902
 903                text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 904            }
 905
 906            var newText = text.Replace(",Arial,", ",Arial Unicode MS,", StringComparison.Ordinal);
 907
 908            if (!string.Equals(text, newText, StringComparison.Ordinal))
 909            {
 910                var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.File
 911                await using (fileStream.ConfigureAwait(false))
 912                {
 913                    var writer = new StreamWriter(fileStream, encoding);
 914                    await using (writer.ConfigureAwait(false))
 915                    {
 916                        await writer.WriteAsync(newText.AsMemory(), cancellationToken).ConfigureAwait(false);
 917                    }
 918                }
 919            }
 920        }
 921
 922        private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleE
 923        {
 0924            return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
 925        }
 926
 927        /// <inheritdoc />
 928        public async Task<string> GetSubtitleFileCharacterSet(MediaStream subtitleStream, string language, MediaSourceIn
 929        {
 930            var subtitleCodec = subtitleStream.Codec;
 931            var path = subtitleStream.Path;
 932
 933            if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 934            {
 935                path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
 936                await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
 937                    .ConfigureAwait(false);
 938            }
 939
 940            using (var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false))
 941            {
 942                var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false
 943                var charset = result.Detected?.EncodingName ?? string.Empty;
 944
 945                // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
 946                if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) 
 947                    && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase)
 948                        || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase)))
 949                {
 950                    charset = string.Empty;
 951                }
 952
 953                _logger.LogDebug("charset {0} detected for {Path}", charset, path);
 954
 955                return charset;
 956            }
 957        }
 958
 959        private async Task<Stream> GetStream(string path, MediaProtocol protocol, CancellationToken cancellationToken)
 960        {
 961            switch (protocol)
 962            {
 963                case MediaProtocol.Http:
 964                    {
 965                        using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 966                            .GetAsync(new Uri(path), cancellationToken)
 967                            .ConfigureAwait(false);
 968                        return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 969                    }
 970
 971                case MediaProtocol.File:
 972                    return AsyncFile.OpenRead(path);
 973                default:
 974                    throw new ArgumentOutOfRangeException(nameof(protocol));
 975            }
 976        }
 977
 978        public async Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, Cancellat
 979        {
 980            var info = await GetReadableFile(mediaSource, subtitleStream, cancellationToken)
 981                .ConfigureAwait(false);
 982            return info.Path;
 983        }
 984
 985        /// <inheritdoc />
 986        public void Dispose()
 987        {
 21988            _semaphoreLocks.Dispose();
 21989        }
 990
 991#pragma warning disable CA1034 // Nested types should not be visible
 992        // Only public for the unit tests
 993        public readonly record struct SubtitleInfo
 994        {
 995            public string Path { get; init; }
 996
 997            public MediaProtocol Protocol { get; init; }
 998
 999            public string Format { get; init; }
 1000
 1001            public bool IsExternal { get; init; }
 1002        }
 1003    }
 1004}