< 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: 1053
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 12/6/2025 - 12:11:15 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: 10133/14/2026 - 12:13:58 AM Line coverage: 20.7% (16/77) Branch coverage: 0% (0/40) Total lines: 1053 12/6/2025 - 12:11:15 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: 10133/14/2026 - 12:13:58 AM Line coverage: 20.7% (16/77) Branch coverage: 0% (0/40) Total lines: 1053

Coverage delta

Coverage delta 1 -1

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) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 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) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 435            {
 436                failed = true;
 437
 438                try
 439                {
 440                    _logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
 441                    _fileSystem.DeleteFile(outputPath);
 442                }
 443                catch (FileNotFoundException)
 444                {
 445                }
 446                catch (IOException ex)
 447                {
 448                    _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
 449                }
 450            }
 451
 452            if (failed)
 453            {
 454                _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
 455
 456                throw new FfmpegException(
 457                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath))
 458            }
 459
 460            await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 461
 462            _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
 463        }
 464
 465        private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
 466        {
 0467            if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
 0468                || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)
 0469                || string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
 470            {
 0471                return subtitleStream.Codec;
 472            }
 473            else
 474            {
 0475                return "srt";
 476            }
 477        }
 478
 479        private string GetExtractableSubtitleFileExtension(MediaStream subtitleStream)
 480        {
 481            // Using .pgssub as file extension is not allowed by ffmpeg. The file extension for pgs subtitles is .sup.
 0482            if (string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
 483            {
 0484                return "sup";
 485            }
 486            else
 487            {
 0488                return GetExtractableSubtitleFormat(subtitleStream);
 489            }
 490        }
 491
 492        private bool IsCodecCopyable(string codec)
 493        {
 0494            return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase)
 0495                || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)
 0496                || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)
 0497                || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase)
 0498                || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase);
 499        }
 500
 501        /// <inheritdoc />
 502        public async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToke
 503        {
 504            var locks = new List<IDisposable>();
 505            var extractableStreams = new List<MediaStream>();
 506
 507            try
 508            {
 509                var subtitleStreams = mediaSource.MediaStreams
 510                    .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true });
 511
 512                foreach (var subtitleStream in subtitleStreams)
 513                {
 514                    if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnor
 515                    {
 516                        continue;
 517                    }
 518
 519                    var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitl
 520
 521                    var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
 522
 523                    if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0)
 524                    {
 525                        releaser.Dispose();
 526                        continue;
 527                    }
 528
 529                    locks.Add(releaser);
 530                    extractableStreams.Add(subtitleStream);
 531                }
 532
 533                if (extractableStreams.Count > 0)
 534                {
 535                    await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).Con
 536                    await ExtractAllExtractableSubtitlesMKS(mediaSource, extractableStreams, cancellationToken).Configur
 537                }
 538            }
 539            catch (Exception ex)
 540            {
 541                _logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path);
 542            }
 543            finally
 544            {
 545                locks.ForEach(x => x.Dispose());
 546            }
 547        }
 548
 549        private async Task ExtractAllExtractableSubtitlesMKS(
 550           MediaSourceInfo mediaSource,
 551           List<MediaStream> subtitleStreams,
 552           CancellationToken cancellationToken)
 553        {
 554            var mksFiles = new List<string>();
 555
 556            foreach (var subtitleStream in subtitleStreams)
 557            {
 558                if (string.IsNullOrEmpty(subtitleStream.Path) || !subtitleStream.Path.EndsWith(".mks", StringComparison.
 559                {
 560                    continue;
 561                }
 562
 563                if (!mksFiles.Contains(subtitleStream.Path))
 564                {
 565                    mksFiles.Add(subtitleStream.Path);
 566                }
 567            }
 568
 569            if (mksFiles.Count == 0)
 570            {
 571                return;
 572            }
 573
 574            foreach (string mksFile in mksFiles)
 575            {
 576                var inputPath = _mediaEncoder.GetInputArgument(mksFile, mediaSource);
 577                var outputPaths = new List<string>();
 578                var args = string.Format(
 579                    CultureInfo.InvariantCulture,
 580                    "-i {0} -copyts",
 581                    inputPath);
 582
 583                foreach (var subtitleStream in subtitleStreams)
 584                {
 585                    if (!subtitleStream.Path.Equals(mksFile, StringComparison.OrdinalIgnoreCase))
 586                    {
 587                        continue;
 588                    }
 589
 590                    var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitl
 591                    var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
 592                    var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 593
 594                    if (streamIndex == -1)
 595                    {
 596                        _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this str
 597                        continue;
 598                    }
 599
 600                    Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Cal
 601
 602                    outputPaths.Add(outputPath);
 603                    args += string.Format(
 604                        CultureInfo.InvariantCulture,
 605                        " -map 0:{0} -an -vn -c:s {1} \"{2}\"",
 606                        streamIndex,
 607                        outputCodec,
 608                        outputPath);
 609                }
 610
 611                await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
 612            }
 613        }
 614
 615        private async Task ExtractAllExtractableSubtitlesInternal(
 616            MediaSourceInfo mediaSource,
 617            List<MediaStream> subtitleStreams,
 618            CancellationToken cancellationToken)
 619        {
 620            var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 621            var outputPaths = new List<string>();
 622            var args = string.Format(
 623                CultureInfo.InvariantCulture,
 624                "-i {0} -copyts",
 625                inputPath);
 626
 627            foreach (var subtitleStream in subtitleStreams)
 628            {
 629                if (!string.IsNullOrEmpty(subtitleStream.Path) && subtitleStream.Path.EndsWith(".mks", StringComparison.
 630                {
 631                    _logger.LogDebug("Subtitle {Index} for file {InputPath} is part in an MKS file. Skipping", inputPath
 632                    continue;
 633                }
 634
 635                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFil
 636                var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
 637                var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 638
 639                if (streamIndex == -1)
 640                {
 641                    _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream"
 642                    continue;
 643                }
 644
 645                Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calcula
 646
 647                outputPaths.Add(outputPath);
 648                args += string.Format(
 649                    CultureInfo.InvariantCulture,
 650                    " -map 0:{0} -an -vn -c:s {1} \"{2}\"",
 651                    streamIndex,
 652                    outputCodec,
 653                    outputPath);
 654            }
 655
 656            if (outputPaths.Count == 0)
 657            {
 658                return;
 659            }
 660
 661            await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
 662        }
 663
 664        private async Task ExtractSubtitlesForFile(
 665            string inputPath,
 666            string args,
 667            List<string> outputPaths,
 668            CancellationToken cancellationToken)
 669        {
 670            int exitCode;
 671
 672            using (var process = new Process
 673            {
 674                StartInfo = new ProcessStartInfo
 675                {
 676                    CreateNoWindow = true,
 677                    UseShellExecute = false,
 678                    FileName = _mediaEncoder.EncoderPath,
 679                    Arguments = args,
 680                    WindowStyle = ProcessWindowStyle.Hidden,
 681                    ErrorDialog = false
 682                },
 683                EnableRaisingEvents = true
 684            })
 685            {
 686                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 687
 688                try
 689                {
 690                    process.Start();
 691                }
 692                catch (Exception ex)
 693                {
 694                    _logger.LogError(ex, "Error starting ffmpeg");
 695
 696                    throw;
 697                }
 698
 699                try
 700                {
 701                    var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinut
 702                    await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
 703                    exitCode = process.ExitCode;
 704                }
 705                catch (OperationCanceledException)
 706                {
 707                    process.Kill(true);
 708                    exitCode = -1;
 709                }
 710            }
 711
 712            var failed = false;
 713
 714            if (exitCode == -1)
 715            {
 716                failed = true;
 717
 718                foreach (var outputPath in outputPaths)
 719                {
 720                    try
 721                    {
 722                        _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 723                        _fileSystem.DeleteFile(outputPath);
 724                    }
 725                    catch (FileNotFoundException)
 726                    {
 727                    }
 728                    catch (IOException ex)
 729                    {
 730                        _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 731                    }
 732                }
 733            }
 734            else
 735            {
 736                foreach (var outputPath in outputPaths)
 737                {
 738                    if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 739                    {
 740                        _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath,
 741                        failed = true;
 742
 743                        try
 744                        {
 745                            _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 746                            _fileSystem.DeleteFile(outputPath);
 747                        }
 748                        catch (FileNotFoundException)
 749                        {
 750                        }
 751                        catch (IOException ex)
 752                        {
 753                            _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 754                        }
 755
 756                        continue;
 757                    }
 758
 759                    if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase))
 760                    {
 761                        await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 762                    }
 763
 764                    _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", input
 765                }
 766            }
 767
 768            if (failed)
 769            {
 770                throw new FfmpegException(
 771                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath))
 772            }
 773        }
 774
 775        /// <summary>
 776        /// Extracts the text subtitle.
 777        /// </summary>
 778        /// <param name="mediaSource">The mediaSource.</param>
 779        /// <param name="subtitleStream">The subtitle stream.</param>
 780        /// <param name="outputCodec">The output codec.</param>
 781        /// <param name="outputPath">The output path.</param>
 782        /// <param name="cancellationToken">The cancellation token.</param>
 783        /// <returns>Task.</returns>
 784        /// <exception cref="ArgumentException">Must use inputPath list overload.</exception>
 785        private async Task ExtractTextSubtitle(
 786            MediaSourceInfo mediaSource,
 787            MediaStream subtitleStream,
 788            string outputCodec,
 789            string outputPath,
 790            CancellationToken cancellationToken)
 791        {
 792            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 793            {
 794                if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 795                {
 796                    var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 797
 798                    var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 799
 800                    if (subtitleStream.IsExternal)
 801                    {
 802                        args = _mediaEncoder.GetExternalSubtitleInputArgument(subtitleStream.Path);
 803                    }
 804
 805                    await ExtractTextSubtitleInternal(
 806                        args,
 807                        subtitleStreamIndex,
 808                        outputCodec,
 809                        outputPath,
 810                        cancellationToken).ConfigureAwait(false);
 811                }
 812            }
 813        }
 814
 815        private async Task ExtractTextSubtitleInternal(
 816            string inputPath,
 817            int subtitleStreamIndex,
 818            string outputCodec,
 819            string outputPath,
 820            CancellationToken cancellationToken)
 821        {
 822            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 823
 824            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 825
 826            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path (
 827
 828            var processArgs = string.Format(
 829                CultureInfo.InvariantCulture,
 830                "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
 831                inputPath,
 832                subtitleStreamIndex,
 833                outputCodec,
 834                outputPath);
 835
 836            int exitCode;
 837
 838            using (var process = new Process
 839            {
 840                StartInfo = new ProcessStartInfo
 841                {
 842                    CreateNoWindow = true,
 843                    UseShellExecute = false,
 844                    FileName = _mediaEncoder.EncoderPath,
 845                    Arguments = processArgs,
 846                    WindowStyle = ProcessWindowStyle.Hidden,
 847                    ErrorDialog = false
 848                },
 849                EnableRaisingEvents = true
 850            })
 851            {
 852                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 853
 854                try
 855                {
 856                    process.Start();
 857                }
 858                catch (Exception ex)
 859                {
 860                    _logger.LogError(ex, "Error starting ffmpeg");
 861
 862                    throw;
 863                }
 864
 865                try
 866                {
 867                    var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinut
 868                    await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
 869                    exitCode = process.ExitCode;
 870                }
 871                catch (OperationCanceledException)
 872                {
 873                    process.Kill(true);
 874                    exitCode = -1;
 875                }
 876            }
 877
 878            var failed = false;
 879
 880            if (exitCode == -1)
 881            {
 882                failed = true;
 883
 884                try
 885                {
 886                    _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 887                    _fileSystem.DeleteFile(outputPath);
 888                }
 889                catch (FileNotFoundException)
 890                {
 891                }
 892                catch (IOException ex)
 893                {
 894                    _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 895                }
 896            }
 897            else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 898            {
 899                failed = true;
 900
 901                try
 902                {
 903                    _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 904                    _fileSystem.DeleteFile(outputPath);
 905                }
 906                catch (FileNotFoundException)
 907                {
 908                }
 909                catch (IOException ex)
 910                {
 911                    _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 912                }
 913            }
 914
 915            if (failed)
 916            {
 917                _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputP
 918
 919                throw new FfmpegException(
 920                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inpu
 921            }
 922
 923            _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, ou
 924
 925            if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
 926            {
 927                await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 928            }
 929        }
 930
 931        /// <summary>
 932        /// Sets the ass font.
 933        /// </summary>
 934        /// <param name="file">The file.</param>
 935        /// <param name="cancellationToken">The token to monitor for cancellation requests. The default value is <c>Syst
 936        /// <returns>Task.</returns>
 937        private async Task SetAssFont(string file, CancellationToken cancellationToken = default)
 938        {
 939            _logger.LogInformation("Setting ass font within {File}", file);
 940
 941            string text;
 942            Encoding encoding;
 943
 944            using (var fileStream = AsyncFile.OpenRead(file))
 945            using (var reader = new StreamReader(fileStream, true))
 946            {
 947                encoding = reader.CurrentEncoding;
 948
 949                text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 950            }
 951
 952            var newText = text.Replace(",Arial,", ",Arial Unicode MS,", StringComparison.Ordinal);
 953
 954            if (!string.Equals(text, newText, StringComparison.Ordinal))
 955            {
 956                var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.File
 957                await using (fileStream.ConfigureAwait(false))
 958                {
 959                    var writer = new StreamWriter(fileStream, encoding);
 960                    await using (writer.ConfigureAwait(false))
 961                    {
 962                        await writer.WriteAsync(newText.AsMemory(), cancellationToken).ConfigureAwait(false);
 963                    }
 964                }
 965            }
 966        }
 967
 968        private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleE
 969        {
 0970            return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
 971        }
 972
 973        /// <inheritdoc />
 974        public async Task<string> GetSubtitleFileCharacterSet(MediaStream subtitleStream, string language, MediaSourceIn
 975        {
 976            var subtitleCodec = subtitleStream.Codec;
 977            var path = subtitleStream.Path;
 978
 979            if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 980            {
 981                path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
 982                await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
 983                    .ConfigureAwait(false);
 984            }
 985
 986            var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
 987            var charset = result.Detected?.EncodingName ?? string.Empty;
 988
 989            // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
 990            if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || p
 991                && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase)
 992                    || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase)))
 993            {
 994                charset = string.Empty;
 995            }
 996
 997            _logger.LogDebug("charset {0} detected for {Path}", charset, path);
 998
 999            return charset;
 1000        }
 1001
 1002        private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancell
 1003        {
 1004            switch (protocol)
 1005            {
 1006                case MediaProtocol.Http:
 1007                {
 1008                    using var stream = await _httpClientFactory
 1009                      .CreateClient(NamedClient.Default)
 1010                      .GetStreamAsync(new Uri(path), cancellationToken)
 1011                      .ConfigureAwait(false);
 1012
 1013                    return await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
 1014                }
 1015
 1016                case MediaProtocol.File:
 1017                {
 1018                    return await CharsetDetector.DetectFromFileAsync(path, cancellationToken)
 1019                                          .ConfigureAwait(false);
 1020                }
 1021
 1022                default:
 1023                    throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol");
 1024            }
 1025        }
 1026
 1027        public async Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, Cancellat
 1028        {
 1029            var info = await GetReadableFile(mediaSource, subtitleStream, cancellationToken)
 1030                .ConfigureAwait(false);
 1031            return info.Path;
 1032        }
 1033
 1034        /// <inheritdoc />
 1035        public void Dispose()
 1036        {
 211037            _semaphoreLocks.Dispose();
 211038        }
 1039
 1040#pragma warning disable CA1034 // Nested types should not be visible
 1041        // Only public for the unit tests
 1042        public readonly record struct SubtitleInfo
 1043        {
 1044            public string Path { get; init; }
 1045
 1046            public MediaProtocol Protocol { get; init; }
 1047
 1048            public string Format { get; init; }
 1049
 1050            public bool IsExternal { get; init; }
 1051        }
 1052    }
 1053}