< Summary - Jellyfin

Information
Class: MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder
Assembly: MediaBrowser.MediaEncoding
File(s): /srv/git/jellyfin/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
Line coverage
20%
Covered lines: 16
Uncovered lines: 61
Coverable lines: 77
Total lines: 1013
Line coverage: 20.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 10/25/2025 - 12:09:58 AM Line coverage: 19.7% (15/76) Branch coverage: 0% (0/40) Total lines: 100112/3/2025 - 12:11:26 AM Line coverage: 20.7% (16/77) Branch coverage: 0% (0/40) Total lines: 10091/27/2026 - 12:13:24 AM Line coverage: 20.7% (16/77) Branch coverage: 0% (0/40) Total lines: 1013 10/25/2025 - 12:09:58 AM Line coverage: 19.7% (15/76) Branch coverage: 0% (0/40) Total lines: 100112/3/2025 - 12:11:26 AM Line coverage: 20.7% (16/77) Branch coverage: 0% (0/40) Total lines: 10091/27/2026 - 12:13:24 AM Line coverage: 20.7% (16/77) Branch coverage: 0% (0/40) Total lines: 1013

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