< Summary - Jellyfin

Information
Class: MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder
Assembly: MediaBrowser.MediaEncoding
File(s): /srv/git/jellyfin/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
Line coverage
17%
Covered lines: 15
Uncovered lines: 70
Coverable lines: 85
Total lines: 931
Line coverage: 17.6%
Branch coverage
0%
Covered branches: 0
Total branches: 42
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%
get_SubtitleCachePath()100%210%
ConvertSubtitles(...)100%210%
FilterEvents(...)0%4260%
TryGetWriter(...)0%272160%
GetWriter(...)0%620%
GetExtractableSubtitleFormat(...)0%4260%
GetExtractableSubtitleFileExtension(...)0%620%
IsCodecCopyable(...)0%7280%
GetSubtitleCachePath(...)0%620%
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.Configuration;
 17using MediaBrowser.Common.Extensions;
 18using MediaBrowser.Common.Net;
 19using MediaBrowser.Controller.Entities;
 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 IApplicationPaths _appPaths;
 35        private readonly IFileSystem _fileSystem;
 36        private readonly IMediaEncoder _mediaEncoder;
 37        private readonly IHttpClientFactory _httpClientFactory;
 38        private readonly IMediaSourceManager _mediaSourceManager;
 39        private readonly ISubtitleParser _subtitleParser;
 40
 41        /// <summary>
 42        /// The _semaphoreLocks.
 43        /// </summary>
 2644        private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
 2645        {
 2646            o.PoolSize = 20;
 2647            o.PoolInitialFill = 1;
 2648        });
 49
 50        public SubtitleEncoder(
 51            ILogger<SubtitleEncoder> logger,
 52            IApplicationPaths appPaths,
 53            IFileSystem fileSystem,
 54            IMediaEncoder mediaEncoder,
 55            IHttpClientFactory httpClientFactory,
 56            IMediaSourceManager mediaSourceManager,
 57            ISubtitleParser subtitleParser)
 58        {
 2659            _logger = logger;
 2660            _appPaths = appPaths;
 2661            _fileSystem = fileSystem;
 2662            _mediaEncoder = mediaEncoder;
 2663            _httpClientFactory = httpClientFactory;
 2664            _mediaSourceManager = mediaSourceManager;
 2665            _subtitleParser = subtitleParser;
 2666        }
 67
 068        private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles");
 69
 70        private MemoryStream ConvertSubtitles(
 71            Stream stream,
 72            string inputFormat,
 73            string outputFormat,
 74            long startTimeTicks,
 75            long endTimeTicks,
 76            bool preserveOriginalTimestamps,
 77            CancellationToken cancellationToken)
 78        {
 079            var ms = new MemoryStream();
 80
 81            try
 82            {
 083                var trackInfo = _subtitleParser.Parse(stream, inputFormat);
 84
 085                FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
 86
 087                var writer = GetWriter(outputFormat);
 88
 089                writer.Write(trackInfo, ms, cancellationToken);
 090                ms.Position = 0;
 091            }
 092            catch
 93            {
 094                ms.Dispose();
 095                throw;
 96            }
 97
 098            return ms;
 99        }
 100
 101        private void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTime
 102        {
 103            // Drop subs that are earlier than what we're looking for
 0104            track.TrackEvents = track.TrackEvents
 0105                .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 || (i.EndPositionTicks - startPositionTi
 0106                .ToArray();
 107
 0108            if (endTimeTicks > 0)
 109            {
 0110                track.TrackEvents = track.TrackEvents
 0111                    .TakeWhile(i => i.StartPositionTicks <= endTimeTicks)
 0112                    .ToArray();
 113            }
 114
 0115            if (!preserveTimestamps)
 116            {
 0117                foreach (var trackEvent in track.TrackEvents)
 118                {
 0119                    trackEvent.EndPositionTicks -= startPositionTicks;
 0120                    trackEvent.StartPositionTicks -= startPositionTicks;
 121                }
 122            }
 0123        }
 124
 125        async Task<Stream> ISubtitleEncoder.GetSubtitles(BaseItem item, string mediaSourceId, int subtitleStreamIndex, s
 126        {
 127            ArgumentNullException.ThrowIfNull(item);
 128
 129            if (string.IsNullOrWhiteSpace(mediaSourceId))
 130            {
 131                throw new ArgumentNullException(nameof(mediaSourceId));
 132            }
 133
 134            var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationTo
 135
 136            var mediaSource = mediaSources
 137                .First(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
 138
 139            var subtitleStream = mediaSource.MediaStreams
 140               .First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex);
 141
 142            var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken)
 143                        .ConfigureAwait(false);
 144
 145            // Return the original if the same format is being requested
 146            // Character encoding was already handled in GetSubtitleStream
 147            if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase))
 148            {
 149                return stream;
 150            }
 151
 152            using (stream)
 153            {
 154                return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOrigina
 155            }
 156        }
 157
 158        private async Task<(Stream Stream, string Format)> GetSubtitleStream(
 159            MediaSourceInfo mediaSource,
 160            MediaStream subtitleStream,
 161            CancellationToken cancellationToken)
 162        {
 163            var fileInfo = await GetReadableFile(mediaSource, subtitleStream, cancellationToken).ConfigureAwait(false);
 164
 165            var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false);
 166
 167            return (stream, fileInfo.Format);
 168        }
 169
 170        private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
 171        {
 172            if (fileInfo.IsExternal)
 173            {
 174                using (var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(
 175                {
 176                    var result = CharsetDetector.DetectFromStream(stream).Detected;
 177                    stream.Position = 0;
 178
 179                    if (result is not null)
 180                    {
 181                        _logger.LogDebug("charset {CharSet} detected for {Path}", result.EncodingName, fileInfo.Path);
 182
 183                        using var reader = new StreamReader(stream, result.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        /// <summary>
 481        /// Extracts all extractable subtitles (text and pgs).
 482        /// </summary>
 483        /// <param name="mediaSource">The mediaSource.</param>
 484        /// <param name="cancellationToken">The cancellation token.</param>
 485        /// <returns>Task.</returns>
 486        private async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationTok
 487        {
 488            var locks = new List<IDisposable>();
 489            var extractableStreams = new List<MediaStream>();
 490
 491            try
 492            {
 493                var subtitleStreams = mediaSource.MediaStreams
 494                    .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true, IsExte
 495
 496                foreach (var subtitleStream in subtitleStreams)
 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                }
 516            }
 517            catch (Exception ex)
 518            {
 519                _logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path);
 520            }
 521            finally
 522            {
 523                locks.ForEach(x => x.Dispose());
 524            }
 525        }
 526
 527        private async Task ExtractAllExtractableSubtitlesInternal(
 528            MediaSourceInfo mediaSource,
 529            List<MediaStream> subtitleStreams,
 530            CancellationToken cancellationToken)
 531        {
 532            var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 533            var outputPaths = new List<string>();
 534            var args = string.Format(
 535                CultureInfo.InvariantCulture,
 536                "-i {0} -copyts",
 537                inputPath);
 538
 539            foreach (var subtitleStream in subtitleStreams)
 540            {
 541                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFil
 542                var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
 543                var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 544
 545                if (streamIndex == -1)
 546                {
 547                    _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream"
 548                    continue;
 549                }
 550
 551                Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calcula
 552
 553                outputPaths.Add(outputPath);
 554                args += string.Format(
 555                    CultureInfo.InvariantCulture,
 556                    " -map 0:{0} -an -vn -c:s {1} \"{2}\"",
 557                    streamIndex,
 558                    outputCodec,
 559                    outputPath);
 560            }
 561
 562            int exitCode;
 563
 564            using (var process = new Process
 565            {
 566                StartInfo = new ProcessStartInfo
 567                {
 568                    CreateNoWindow = true,
 569                    UseShellExecute = false,
 570                    FileName = _mediaEncoder.EncoderPath,
 571                    Arguments = args,
 572                    WindowStyle = ProcessWindowStyle.Hidden,
 573                    ErrorDialog = false
 574                },
 575                EnableRaisingEvents = true
 576            })
 577            {
 578                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 579
 580                try
 581                {
 582                    process.Start();
 583                }
 584                catch (Exception ex)
 585                {
 586                    _logger.LogError(ex, "Error starting ffmpeg");
 587
 588                    throw;
 589                }
 590
 591                try
 592                {
 593                    await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
 594                    exitCode = process.ExitCode;
 595                }
 596                catch (OperationCanceledException)
 597                {
 598                    process.Kill(true);
 599                    exitCode = -1;
 600                }
 601            }
 602
 603            var failed = false;
 604
 605            if (exitCode == -1)
 606            {
 607                failed = true;
 608
 609                foreach (var outputPath in outputPaths)
 610                {
 611                    try
 612                    {
 613                        _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 614                        _fileSystem.DeleteFile(outputPath);
 615                    }
 616                    catch (FileNotFoundException)
 617                    {
 618                    }
 619                    catch (IOException ex)
 620                    {
 621                        _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 622                    }
 623                }
 624            }
 625            else
 626            {
 627                foreach (var outputPath in outputPaths)
 628                {
 629                    if (!File.Exists(outputPath))
 630                    {
 631                        _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath,
 632                        failed = true;
 633                        continue;
 634                    }
 635
 636                    if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase))
 637                    {
 638                        await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 639                    }
 640
 641                    _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", input
 642                }
 643            }
 644
 645            if (failed)
 646            {
 647                throw new FfmpegException(
 648                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath))
 649            }
 650        }
 651
 652        /// <summary>
 653        /// Extracts the text subtitle.
 654        /// </summary>
 655        /// <param name="mediaSource">The mediaSource.</param>
 656        /// <param name="subtitleStream">The subtitle stream.</param>
 657        /// <param name="outputCodec">The output codec.</param>
 658        /// <param name="outputPath">The output path.</param>
 659        /// <param name="cancellationToken">The cancellation token.</param>
 660        /// <returns>Task.</returns>
 661        /// <exception cref="ArgumentException">Must use inputPath list overload.</exception>
 662        private async Task ExtractTextSubtitle(
 663            MediaSourceInfo mediaSource,
 664            MediaStream subtitleStream,
 665            string outputCodec,
 666            string outputPath,
 667            CancellationToken cancellationToken)
 668        {
 669            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 670            {
 671                if (!File.Exists(outputPath))
 672                {
 673                    var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 674
 675                    var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 676
 677                    if (subtitleStream.IsExternal)
 678                    {
 679                        args = _mediaEncoder.GetExternalSubtitleInputArgument(subtitleStream.Path);
 680                    }
 681
 682                    await ExtractTextSubtitleInternal(
 683                        args,
 684                        subtitleStreamIndex,
 685                        outputCodec,
 686                        outputPath,
 687                        cancellationToken).ConfigureAwait(false);
 688                }
 689            }
 690        }
 691
 692        private async Task ExtractTextSubtitleInternal(
 693            string inputPath,
 694            int subtitleStreamIndex,
 695            string outputCodec,
 696            string outputPath,
 697            CancellationToken cancellationToken)
 698        {
 699            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 700
 701            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 702
 703            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path (
 704
 705            var processArgs = string.Format(
 706                CultureInfo.InvariantCulture,
 707                "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
 708                inputPath,
 709                subtitleStreamIndex,
 710                outputCodec,
 711                outputPath);
 712
 713            int exitCode;
 714
 715            using (var process = new Process
 716            {
 717                StartInfo = new ProcessStartInfo
 718                {
 719                    CreateNoWindow = true,
 720                    UseShellExecute = false,
 721                    FileName = _mediaEncoder.EncoderPath,
 722                    Arguments = processArgs,
 723                    WindowStyle = ProcessWindowStyle.Hidden,
 724                    ErrorDialog = false
 725                },
 726                EnableRaisingEvents = true
 727            })
 728            {
 729                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 730
 731                try
 732                {
 733                    process.Start();
 734                }
 735                catch (Exception ex)
 736                {
 737                    _logger.LogError(ex, "Error starting ffmpeg");
 738
 739                    throw;
 740                }
 741
 742                try
 743                {
 744                    await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
 745                    exitCode = process.ExitCode;
 746                }
 747                catch (OperationCanceledException)
 748                {
 749                    process.Kill(true);
 750                    exitCode = -1;
 751                }
 752            }
 753
 754            var failed = false;
 755
 756            if (exitCode == -1)
 757            {
 758                failed = true;
 759
 760                try
 761                {
 762                    _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 763                    _fileSystem.DeleteFile(outputPath);
 764                }
 765                catch (FileNotFoundException)
 766                {
 767                }
 768                catch (IOException ex)
 769                {
 770                    _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 771                }
 772            }
 773            else if (!File.Exists(outputPath))
 774            {
 775                failed = true;
 776            }
 777
 778            if (failed)
 779            {
 780                _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputP
 781
 782                throw new FfmpegException(
 783                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inpu
 784            }
 785
 786            _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, ou
 787
 788            if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
 789            {
 790                await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 791            }
 792        }
 793
 794        /// <summary>
 795        /// Sets the ass font.
 796        /// </summary>
 797        /// <param name="file">The file.</param>
 798        /// <param name="cancellationToken">The token to monitor for cancellation requests. The default value is <c>Syst
 799        /// <returns>Task.</returns>
 800        private async Task SetAssFont(string file, CancellationToken cancellationToken = default)
 801        {
 802            _logger.LogInformation("Setting ass font within {File}", file);
 803
 804            string text;
 805            Encoding encoding;
 806
 807            using (var fileStream = AsyncFile.OpenRead(file))
 808            using (var reader = new StreamReader(fileStream, true))
 809            {
 810                encoding = reader.CurrentEncoding;
 811
 812                text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 813            }
 814
 815            var newText = text.Replace(",Arial,", ",Arial Unicode MS,", StringComparison.Ordinal);
 816
 817            if (!string.Equals(text, newText, StringComparison.Ordinal))
 818            {
 819                var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.File
 820                await using (fileStream.ConfigureAwait(false))
 821                {
 822                    var writer = new StreamWriter(fileStream, encoding);
 823                    await using (writer.ConfigureAwait(false))
 824                    {
 825                        await writer.WriteAsync(newText.AsMemory(), cancellationToken).ConfigureAwait(false);
 826                    }
 827                }
 828            }
 829        }
 830
 831        private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleE
 832        {
 0833            if (mediaSource.Protocol == MediaProtocol.File)
 834            {
 0835                var ticksParam = string.Empty;
 836
 0837                var date = _fileSystem.GetLastWriteTimeUtc(mediaSource.Path);
 838
 0839                ReadOnlySpan<char> filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.Invaria
 840
 0841                var prefix = filename.Slice(0, 1);
 842
 0843                return Path.Join(SubtitleCachePath, prefix, filename);
 844            }
 845            else
 846            {
 0847                ReadOnlySpan<char> filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.Invaria
 848
 0849                var prefix = filename.Slice(0, 1);
 850
 0851                return Path.Join(SubtitleCachePath, prefix, filename);
 852            }
 853        }
 854
 855        /// <inheritdoc />
 856        public async Task<string> GetSubtitleFileCharacterSet(MediaStream subtitleStream, string language, MediaSourceIn
 857        {
 858            var subtitleCodec = subtitleStream.Codec;
 859            var path = subtitleStream.Path;
 860
 861            if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 862            {
 863                path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
 864                await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
 865                    .ConfigureAwait(false);
 866            }
 867
 868            using (var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false))
 869            {
 870                var charset = CharsetDetector.DetectFromStream(stream).Detected?.EncodingName ?? string.Empty;
 871
 872                // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
 873                if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) 
 874                    && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase)
 875                        || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase)))
 876                {
 877                    charset = string.Empty;
 878                }
 879
 880                _logger.LogDebug("charset {0} detected for {Path}", charset, path);
 881
 882                return charset;
 883            }
 884        }
 885
 886        private async Task<Stream> GetStream(string path, MediaProtocol protocol, CancellationToken cancellationToken)
 887        {
 888            switch (protocol)
 889            {
 890                case MediaProtocol.Http:
 891                    {
 892                        using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 893                            .GetAsync(new Uri(path), cancellationToken)
 894                            .ConfigureAwait(false);
 895                        return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 896                    }
 897
 898                case MediaProtocol.File:
 899                    return AsyncFile.OpenRead(path);
 900                default:
 901                    throw new ArgumentOutOfRangeException(nameof(protocol));
 902            }
 903        }
 904
 905        public async Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, Cancellat
 906        {
 907            var info = await GetReadableFile(mediaSource, subtitleStream, cancellationToken)
 908                .ConfigureAwait(false);
 909            return info.Path;
 910        }
 911
 912        /// <inheritdoc />
 913        public void Dispose()
 914        {
 22915            _semaphoreLocks.Dispose();
 22916        }
 917
 918#pragma warning disable CA1034 // Nested types should not be visible
 919        // Only public for the unit tests
 920        public readonly record struct SubtitleInfo
 921        {
 922            public string Path { get; init; }
 923
 924            public MediaProtocol Protocol { get; init; }
 925
 926            public string Format { get; init; }
 927
 928            public bool IsExternal { get; init; }
 929        }
 930    }
 931}