< 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: 1009
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 9/14/2025 - 12:09:49 AM Line coverage: 19.7% (15/76) Branch coverage: 0% (0/40) Total lines: 10049/17/2025 - 12:11:23 AM Line coverage: 19.7% (15/76) Branch coverage: 0% (0/40) Total lines: 10069/27/2025 - 12:11:20 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: 1009 9/14/2025 - 12:09:49 AM Line coverage: 19.7% (15/76) Branch coverage: 0% (0/40) Total lines: 10049/17/2025 - 12:11:23 AM Line coverage: 19.7% (15/76) Branch coverage: 0% (0/40) Total lines: 10069/27/2025 - 12:11:20 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: 1009

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