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