< 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: 910
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 = CharsetDetector.DetectFromStream(stream).Detected;
 175                    stream.Position = 0;
 176
 177                    if (result is not null)
 178                    {
 179                        _logger.LogDebug("charset {CharSet} detected for {Path}", result.EncodingName, fileInfo.Path);
 180
 181                        using var reader = new StreamReader(stream, result.Encoding);
 182                        var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 183
 184                        return new MemoryStream(Encoding.UTF8.GetBytes(text));
 185                    }
 186                }
 187            }
 188
 189            return AsyncFile.OpenRead(fileInfo.Path);
 190        }
 191
 192        internal async Task<SubtitleInfo> GetReadableFile(
 193            MediaSourceInfo mediaSource,
 194            MediaStream subtitleStream,
 195            CancellationToken cancellationToken)
 196        {
 197            if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 198            {
 199                await ExtractAllExtractableSubtitles(mediaSource, cancellationToken).ConfigureAwait(false);
 200
 201                var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream);
 202                var outputFormat = GetExtractableSubtitleFormat(subtitleStream);
 203                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension);
 204
 205                return new SubtitleInfo()
 206                {
 207                    Path = outputPath,
 208                    Protocol = MediaProtocol.File,
 209                    Format = outputFormat,
 210                    IsExternal = false
 211                };
 212            }
 213
 214            var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec)
 215                .TrimStart('.');
 216
 217            // Handle PGS subtitles as raw streams for the client to render
 218            if (MediaStream.IsPgsFormat(currentFormat))
 219            {
 220                return new SubtitleInfo()
 221                {
 222                    Path = subtitleStream.Path,
 223                    Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
 224                    Format = "pgssub",
 225                    IsExternal = true
 226                };
 227            }
 228
 229            // Fallback to ffmpeg conversion
 230            if (!_subtitleParser.SupportsFileExtension(currentFormat))
 231            {
 232                // Convert
 233                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt");
 234
 235                await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwai
 236
 237                return new SubtitleInfo()
 238                {
 239                    Path = outputPath,
 240                    Protocol = MediaProtocol.File,
 241                    Format = "srt",
 242                    IsExternal = true
 243                };
 244            }
 245
 246            // It's possible that the subtitleStream and mediaSource don't share the same protocol (e.g. .STRM file with
 247            return new SubtitleInfo()
 248            {
 249                Path = subtitleStream.Path,
 250                Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
 251                Format = currentFormat,
 252                IsExternal = true
 253            };
 254        }
 255
 256        private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value)
 257        {
 0258            ArgumentException.ThrowIfNullOrEmpty(format);
 259
 0260            if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))
 261            {
 0262                value = new AssWriter();
 0263                return true;
 264            }
 265
 0266            if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
 267            {
 0268                value = new JsonWriter();
 0269                return true;
 270            }
 271
 0272            if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, S
 273            {
 0274                value = new SrtWriter();
 0275                return true;
 276            }
 277
 0278            if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase))
 279            {
 0280                value = new SsaWriter();
 0281                return true;
 282            }
 283
 0284            if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, S
 285            {
 0286                value = new VttWriter();
 0287                return true;
 288            }
 289
 0290            if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase))
 291            {
 0292                value = new TtmlWriter();
 0293                return true;
 294            }
 295
 0296            value = null;
 0297            return false;
 298        }
 299
 300        private ISubtitleWriter GetWriter(string format)
 301        {
 0302            if (TryGetWriter(format, out var writer))
 303            {
 0304                return writer;
 305            }
 306
 0307            throw new ArgumentException("Unsupported format: " + format);
 308        }
 309
 310        /// <summary>
 311        /// Converts the text subtitle to SRT.
 312        /// </summary>
 313        /// <param name="subtitleStream">The subtitle stream.</param>
 314        /// <param name="mediaSource">The input mediaSource.</param>
 315        /// <param name="outputPath">The output path.</param>
 316        /// <param name="cancellationToken">The cancellation token.</param>
 317        /// <returns>Task.</returns>
 318        private async Task ConvertTextSubtitleToSrt(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outp
 319        {
 320            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 321            {
 322                if (!File.Exists(outputPath))
 323                {
 324                    await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).C
 325                }
 326            }
 327        }
 328
 329        /// <summary>
 330        /// Converts the text subtitle to SRT internal.
 331        /// </summary>
 332        /// <param name="subtitleStream">The subtitle stream.</param>
 333        /// <param name="mediaSource">The input mediaSource.</param>
 334        /// <param name="outputPath">The output path.</param>
 335        /// <param name="cancellationToken">The cancellation token.</param>
 336        /// <returns>Task.</returns>
 337        /// <exception cref="ArgumentNullException">
 338        /// The <c>inputPath</c> or <c>outputPath</c> is <c>null</c>.
 339        /// </exception>
 340        private async Task ConvertTextSubtitleToSrtInternal(MediaStream subtitleStream, MediaSourceInfo mediaSource, str
 341        {
 342            var inputPath = subtitleStream.Path;
 343            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 344
 345            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 346
 347            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path (
 348
 349            var encodingParam = await GetSubtitleFileCharacterSet(subtitleStream, subtitleStream.Language, mediaSource, 
 350
 351            // FFmpeg automatically convert character encoding when it is UTF-16
 352            // If we specify character encoding, it rejects with "do not specify a character encoding" and "Unable to re
 353            if ((inputPath.EndsWith(".smi", StringComparison.Ordinal) || inputPath.EndsWith(".sami", StringComparison.Or
 354                (encodingParam.Equals("UTF-16BE", StringComparison.OrdinalIgnoreCase) ||
 355                 encodingParam.Equals("UTF-16LE", StringComparison.OrdinalIgnoreCase)))
 356            {
 357                encodingParam = string.Empty;
 358            }
 359            else if (!string.IsNullOrEmpty(encodingParam))
 360            {
 361                encodingParam = " -sub_charenc " + encodingParam;
 362            }
 363
 364            int exitCode;
 365
 366            using (var process = new Process
 367            {
 368                StartInfo = new ProcessStartInfo
 369                {
 370                    CreateNoWindow = true,
 371                    UseShellExecute = false,
 372                    FileName = _mediaEncoder.EncoderPath,
 373                    Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingP
 374                    WindowStyle = ProcessWindowStyle.Hidden,
 375                    ErrorDialog = false
 376                },
 377                EnableRaisingEvents = true
 378            })
 379            {
 380                _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
 381
 382                try
 383                {
 384                    process.Start();
 385                }
 386                catch (Exception ex)
 387                {
 388                    _logger.LogError(ex, "Error starting ffmpeg");
 389
 390                    throw;
 391                }
 392
 393                try
 394                {
 395                    await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
 396                    exitCode = process.ExitCode;
 397                }
 398                catch (OperationCanceledException)
 399                {
 400                    process.Kill(true);
 401                    exitCode = -1;
 402                }
 403            }
 404
 405            var failed = false;
 406
 407            if (exitCode == -1)
 408            {
 409                failed = true;
 410
 411                if (File.Exists(outputPath))
 412                {
 413                    try
 414                    {
 415                        _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
 416                        _fileSystem.DeleteFile(outputPath);
 417                    }
 418                    catch (IOException ex)
 419                    {
 420                        _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
 421                    }
 422                }
 423            }
 424            else if (!File.Exists(outputPath))
 425            {
 426                failed = true;
 427            }
 428
 429            if (failed)
 430            {
 431                _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
 432
 433                throw new FfmpegException(
 434                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath))
 435            }
 436
 437            await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 438
 439            _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
 440        }
 441
 442        private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
 443        {
 0444            if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
 0445                || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)
 0446                || string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
 447            {
 0448                return subtitleStream.Codec;
 449            }
 450            else
 451            {
 0452                return "srt";
 453            }
 454        }
 455
 456        private string GetExtractableSubtitleFileExtension(MediaStream subtitleStream)
 457        {
 458            // Using .pgssub as file extension is not allowed by ffmpeg. The file extension for pgs subtitles is .sup.
 0459            if (string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
 460            {
 0461                return "sup";
 462            }
 463            else
 464            {
 0465                return GetExtractableSubtitleFormat(subtitleStream);
 466            }
 467        }
 468
 469        private bool IsCodecCopyable(string codec)
 470        {
 0471            return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase)
 0472                || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)
 0473                || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)
 0474                || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase)
 0475                || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase);
 476        }
 477
 478        /// <summary>
 479        /// Extracts all extractable subtitles (text and pgs).
 480        /// </summary>
 481        /// <param name="mediaSource">The mediaSource.</param>
 482        /// <param name="cancellationToken">The cancellation token.</param>
 483        /// <returns>Task.</returns>
 484        private async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationTok
 485        {
 486            var locks = new List<IDisposable>();
 487            var extractableStreams = new List<MediaStream>();
 488
 489            try
 490            {
 491                var subtitleStreams = mediaSource.MediaStreams
 492                    .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true, IsExte
 493
 494                foreach (var subtitleStream in subtitleStreams)
 495                {
 496                    var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitl
 497
 498                    var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
 499
 500                    if (File.Exists(outputPath))
 501                    {
 502                        releaser.Dispose();
 503                        continue;
 504                    }
 505
 506                    locks.Add(releaser);
 507                    extractableStreams.Add(subtitleStream);
 508                }
 509
 510                if (extractableStreams.Count > 0)
 511                {
 512                    await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).Con
 513                }
 514            }
 515            catch (Exception ex)
 516            {
 517                _logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path);
 518            }
 519            finally
 520            {
 521                locks.ForEach(x => x.Dispose());
 522            }
 523        }
 524
 525        private async Task ExtractAllExtractableSubtitlesInternal(
 526            MediaSourceInfo mediaSource,
 527            List<MediaStream> subtitleStreams,
 528            CancellationToken cancellationToken)
 529        {
 530            var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 531            var outputPaths = new List<string>();
 532            var args = string.Format(
 533                CultureInfo.InvariantCulture,
 534                "-i {0} -copyts",
 535                inputPath);
 536
 537            foreach (var subtitleStream in subtitleStreams)
 538            {
 539                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFil
 540                var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
 541                var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 542
 543                if (streamIndex == -1)
 544                {
 545                    _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream"
 546                    continue;
 547                }
 548
 549                Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calcula
 550
 551                outputPaths.Add(outputPath);
 552                args += string.Format(
 553                    CultureInfo.InvariantCulture,
 554                    " -map 0:{0} -an -vn -c:s {1} \"{2}\"",
 555                    streamIndex,
 556                    outputCodec,
 557                    outputPath);
 558            }
 559
 560            int exitCode;
 561
 562            using (var process = new Process
 563            {
 564                StartInfo = new ProcessStartInfo
 565                {
 566                    CreateNoWindow = true,
 567                    UseShellExecute = false,
 568                    FileName = _mediaEncoder.EncoderPath,
 569                    Arguments = args,
 570                    WindowStyle = ProcessWindowStyle.Hidden,
 571                    ErrorDialog = false
 572                },
 573                EnableRaisingEvents = true
 574            })
 575            {
 576                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 577
 578                try
 579                {
 580                    process.Start();
 581                }
 582                catch (Exception ex)
 583                {
 584                    _logger.LogError(ex, "Error starting ffmpeg");
 585
 586                    throw;
 587                }
 588
 589                try
 590                {
 591                    await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
 592                    exitCode = process.ExitCode;
 593                }
 594                catch (OperationCanceledException)
 595                {
 596                    process.Kill(true);
 597                    exitCode = -1;
 598                }
 599            }
 600
 601            var failed = false;
 602
 603            if (exitCode == -1)
 604            {
 605                failed = true;
 606
 607                foreach (var outputPath in outputPaths)
 608                {
 609                    try
 610                    {
 611                        _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 612                        _fileSystem.DeleteFile(outputPath);
 613                    }
 614                    catch (FileNotFoundException)
 615                    {
 616                    }
 617                    catch (IOException ex)
 618                    {
 619                        _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 620                    }
 621                }
 622            }
 623            else
 624            {
 625                foreach (var outputPath in outputPaths)
 626                {
 627                    if (!File.Exists(outputPath))
 628                    {
 629                        _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath,
 630                        failed = true;
 631                        continue;
 632                    }
 633
 634                    if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase))
 635                    {
 636                        await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 637                    }
 638
 639                    _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", input
 640                }
 641            }
 642
 643            if (failed)
 644            {
 645                throw new FfmpegException(
 646                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath))
 647            }
 648        }
 649
 650        /// <summary>
 651        /// Extracts the text subtitle.
 652        /// </summary>
 653        /// <param name="mediaSource">The mediaSource.</param>
 654        /// <param name="subtitleStream">The subtitle stream.</param>
 655        /// <param name="outputCodec">The output codec.</param>
 656        /// <param name="outputPath">The output path.</param>
 657        /// <param name="cancellationToken">The cancellation token.</param>
 658        /// <returns>Task.</returns>
 659        /// <exception cref="ArgumentException">Must use inputPath list overload.</exception>
 660        private async Task ExtractTextSubtitle(
 661            MediaSourceInfo mediaSource,
 662            MediaStream subtitleStream,
 663            string outputCodec,
 664            string outputPath,
 665            CancellationToken cancellationToken)
 666        {
 667            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 668            {
 669                if (!File.Exists(outputPath))
 670                {
 671                    var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 672
 673                    var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 674
 675                    if (subtitleStream.IsExternal)
 676                    {
 677                        args = _mediaEncoder.GetExternalSubtitleInputArgument(subtitleStream.Path);
 678                    }
 679
 680                    await ExtractTextSubtitleInternal(
 681                        args,
 682                        subtitleStreamIndex,
 683                        outputCodec,
 684                        outputPath,
 685                        cancellationToken).ConfigureAwait(false);
 686                }
 687            }
 688        }
 689
 690        private async Task ExtractTextSubtitleInternal(
 691            string inputPath,
 692            int subtitleStreamIndex,
 693            string outputCodec,
 694            string outputPath,
 695            CancellationToken cancellationToken)
 696        {
 697            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 698
 699            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 700
 701            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path (
 702
 703            var processArgs = string.Format(
 704                CultureInfo.InvariantCulture,
 705                "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
 706                inputPath,
 707                subtitleStreamIndex,
 708                outputCodec,
 709                outputPath);
 710
 711            int exitCode;
 712
 713            using (var process = new Process
 714            {
 715                StartInfo = new ProcessStartInfo
 716                {
 717                    CreateNoWindow = true,
 718                    UseShellExecute = false,
 719                    FileName = _mediaEncoder.EncoderPath,
 720                    Arguments = processArgs,
 721                    WindowStyle = ProcessWindowStyle.Hidden,
 722                    ErrorDialog = false
 723                },
 724                EnableRaisingEvents = true
 725            })
 726            {
 727                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 728
 729                try
 730                {
 731                    process.Start();
 732                }
 733                catch (Exception ex)
 734                {
 735                    _logger.LogError(ex, "Error starting ffmpeg");
 736
 737                    throw;
 738                }
 739
 740                try
 741                {
 742                    await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
 743                    exitCode = process.ExitCode;
 744                }
 745                catch (OperationCanceledException)
 746                {
 747                    process.Kill(true);
 748                    exitCode = -1;
 749                }
 750            }
 751
 752            var failed = false;
 753
 754            if (exitCode == -1)
 755            {
 756                failed = true;
 757
 758                try
 759                {
 760                    _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 761                    _fileSystem.DeleteFile(outputPath);
 762                }
 763                catch (FileNotFoundException)
 764                {
 765                }
 766                catch (IOException ex)
 767                {
 768                    _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 769                }
 770            }
 771            else if (!File.Exists(outputPath))
 772            {
 773                failed = true;
 774            }
 775
 776            if (failed)
 777            {
 778                _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputP
 779
 780                throw new FfmpegException(
 781                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inpu
 782            }
 783
 784            _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, ou
 785
 786            if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
 787            {
 788                await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 789            }
 790        }
 791
 792        /// <summary>
 793        /// Sets the ass font.
 794        /// </summary>
 795        /// <param name="file">The file.</param>
 796        /// <param name="cancellationToken">The token to monitor for cancellation requests. The default value is <c>Syst
 797        /// <returns>Task.</returns>
 798        private async Task SetAssFont(string file, CancellationToken cancellationToken = default)
 799        {
 800            _logger.LogInformation("Setting ass font within {File}", file);
 801
 802            string text;
 803            Encoding encoding;
 804
 805            using (var fileStream = AsyncFile.OpenRead(file))
 806            using (var reader = new StreamReader(fileStream, true))
 807            {
 808                encoding = reader.CurrentEncoding;
 809
 810                text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 811            }
 812
 813            var newText = text.Replace(",Arial,", ",Arial Unicode MS,", StringComparison.Ordinal);
 814
 815            if (!string.Equals(text, newText, StringComparison.Ordinal))
 816            {
 817                var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.File
 818                await using (fileStream.ConfigureAwait(false))
 819                {
 820                    var writer = new StreamWriter(fileStream, encoding);
 821                    await using (writer.ConfigureAwait(false))
 822                    {
 823                        await writer.WriteAsync(newText.AsMemory(), cancellationToken).ConfigureAwait(false);
 824                    }
 825                }
 826            }
 827        }
 828
 829        private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleE
 830        {
 0831            return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
 832        }
 833
 834        /// <inheritdoc />
 835        public async Task<string> GetSubtitleFileCharacterSet(MediaStream subtitleStream, string language, MediaSourceIn
 836        {
 837            var subtitleCodec = subtitleStream.Codec;
 838            var path = subtitleStream.Path;
 839
 840            if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 841            {
 842                path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
 843                await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
 844                    .ConfigureAwait(false);
 845            }
 846
 847            using (var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false))
 848            {
 849                var charset = CharsetDetector.DetectFromStream(stream).Detected?.EncodingName ?? string.Empty;
 850
 851                // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
 852                if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) 
 853                    && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase)
 854                        || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase)))
 855                {
 856                    charset = string.Empty;
 857                }
 858
 859                _logger.LogDebug("charset {0} detected for {Path}", charset, path);
 860
 861                return charset;
 862            }
 863        }
 864
 865        private async Task<Stream> GetStream(string path, MediaProtocol protocol, CancellationToken cancellationToken)
 866        {
 867            switch (protocol)
 868            {
 869                case MediaProtocol.Http:
 870                    {
 871                        using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 872                            .GetAsync(new Uri(path), cancellationToken)
 873                            .ConfigureAwait(false);
 874                        return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 875                    }
 876
 877                case MediaProtocol.File:
 878                    return AsyncFile.OpenRead(path);
 879                default:
 880                    throw new ArgumentOutOfRangeException(nameof(protocol));
 881            }
 882        }
 883
 884        public async Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, Cancellat
 885        {
 886            var info = await GetReadableFile(mediaSource, subtitleStream, cancellationToken)
 887                .ConfigureAwait(false);
 888            return info.Path;
 889        }
 890
 891        /// <inheritdoc />
 892        public void Dispose()
 893        {
 21894            _semaphoreLocks.Dispose();
 21895        }
 896
 897#pragma warning disable CA1034 // Nested types should not be visible
 898        // Only public for the unit tests
 899        public readonly record struct SubtitleInfo
 900        {
 901            public string Path { get; init; }
 902
 903            public MediaProtocol Protocol { get; init; }
 904
 905            public string Format { get; init; }
 906
 907            public bool IsExternal { get; init; }
 908        }
 909    }
 910}