< Summary - Jellyfin

Information
Class: MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder
Assembly: MediaBrowser.MediaEncoding
File(s): /srv/git/jellyfin/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
Line coverage
8%
Covered lines: 41
Uncovered lines: 450
Coverable lines: 491
Total lines: 1056
Line coverage: 8.3%
Branch coverage
6%
Covered branches: 12
Total branches: 190
Branch coverage: 6.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/23/2026 - 12:11:06 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: 10534/16/2026 - 12:15:18 AM Line coverage: 36.3% (28/77) Branch coverage: 15% (6/40) Total lines: 10534/19/2026 - 12:14:27 AM Line coverage: 8.3% (41/489) Branch coverage: 6.4% (12/186) Total lines: 10535/4/2026 - 12:15:16 AM Line coverage: 8.3% (41/491) Branch coverage: 6.3% (12/190) Total lines: 1056 1/23/2026 - 12:11:06 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: 10534/16/2026 - 12:15:18 AM Line coverage: 36.3% (28/77) Branch coverage: 15% (6/40) Total lines: 10534/19/2026 - 12:14:27 AM Line coverage: 8.3% (41/489) Branch coverage: 6.4% (12/186) Total lines: 10535/4/2026 - 12:15:16 AM Line coverage: 8.3% (41/491) Branch coverage: 6.3% (12/190) Total lines: 1056

Coverage delta

Coverage delta 28 -28

Metrics

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>
 3347        private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
 3348        {
 3349            o.PoolSize = 20;
 3350            o.PoolInitialFill = 1;
 3351        });
 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        {
 3363            _logger = logger;
 3364            _fileSystem = fileSystem;
 3365            _mediaEncoder = mediaEncoder;
 3366            _httpClientFactory = httpClientFactory;
 3367            _mediaSourceManager = mediaSourceManager;
 3368            _subtitleParser = subtitleParser;
 3369            _pathManager = pathManager;
 3370            _serverConfigurationManager = serverConfigurationManager;
 3371        }
 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        internal void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTim
 105        {
 106            // Drop subs that have fully elapsed before the requested start position
 8107            track.TrackEvents = track.TrackEvents
 8108                .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 && (i.EndPositionTicks - startPositionTi
 8109                .ToArray();
 110
 8111            if (endTimeTicks > 0)
 112            {
 7113                track.TrackEvents = track.TrackEvents
 7114                    .TakeWhile(i => i.StartPositionTicks <= endTimeTicks)
 7115                    .ToArray();
 116            }
 117
 8118            if (!preserveTimestamps)
 119            {
 10120                foreach (var trackEvent in track.TrackEvents)
 121                {
 3122                    trackEvent.EndPositionTicks = Math.Max(0, trackEvent.EndPositionTicks - startPositionTicks);
 3123                    trackEvent.StartPositionTicks = Math.Max(0, trackEvent.StartPositionTicks - startPositionTicks);
 124                }
 125            }
 8126        }
 127
 128        async Task<Stream> ISubtitleEncoder.GetSubtitles(BaseItem item, string mediaSourceId, int subtitleStreamIndex, s
 129        {
 0130            ArgumentNullException.ThrowIfNull(item);
 131
 0132            if (string.IsNullOrWhiteSpace(mediaSourceId))
 133            {
 0134                throw new ArgumentNullException(nameof(mediaSourceId));
 135            }
 136
 0137            var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationTo
 138
 0139            var mediaSource = mediaSources
 0140                .First(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
 141
 0142            var subtitleStream = mediaSource.MediaStreams
 0143               .First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex);
 144
 0145            var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken)
 0146                        .ConfigureAwait(false);
 147
 148            // Return the original if the same format is being requested
 149            // Character encoding was already handled in GetSubtitleStream
 150            // ASS is a superset of SSA, skipping the conversion and preserving the styles
 0151            if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase)
 0152                || (string.Equals(inputFormat, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)
 0153                    && string.Equals(outputFormat, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase)))
 154            {
 0155                return stream;
 156            }
 157
 0158            using (stream)
 159            {
 0160                return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOrigina
 161            }
 0162        }
 163
 164        private async Task<(Stream Stream, string Format)> GetSubtitleStream(
 165            MediaSourceInfo mediaSource,
 166            MediaStream subtitleStream,
 167            CancellationToken cancellationToken)
 168        {
 0169            var fileInfo = await GetReadableFile(mediaSource, subtitleStream, cancellationToken).ConfigureAwait(false);
 170
 0171            var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false);
 172
 0173            return (stream, fileInfo.Format);
 0174        }
 175
 176        private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
 177        {
 0178            if (fileInfo.Protocol == MediaProtocol.Http)
 179            {
 0180                var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(fal
 0181                var detected = result.Detected;
 182
 0183                if (detected is not null)
 184                {
 0185                    _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
 186
 0187                    using var stream = await _httpClientFactory.CreateClient(NamedClient.Default)
 0188                        .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken)
 0189                        .ConfigureAwait(false);
 190
 0191                    await using (stream.ConfigureAwait(false))
 192                    {
 0193                      using var reader = new StreamReader(stream, detected.Encoding);
 0194                      var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 195
 0196                      return new MemoryStream(Encoding.UTF8.GetBytes(text));
 197                    }
 0198                }
 0199            }
 200
 0201            return AsyncFile.OpenRead(fileInfo.Path);
 0202        }
 203
 204        internal async Task<SubtitleInfo> GetReadableFile(
 205            MediaSourceInfo mediaSource,
 206            MediaStream subtitleStream,
 207            CancellationToken cancellationToken)
 208        {
 4209            if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 210            {
 0211                await ExtractAllExtractableSubtitles(mediaSource, cancellationToken).ConfigureAwait(false);
 212
 0213                var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream);
 0214                var outputFormat = GetExtractableSubtitleFormat(subtitleStream);
 0215                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension);
 216
 0217                return new SubtitleInfo()
 0218                {
 0219                    Path = outputPath,
 0220                    Protocol = MediaProtocol.File,
 0221                    Format = outputFormat,
 0222                    IsExternal = false
 0223                };
 224            }
 225
 4226            var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path)
 4227                .TrimStart('.');
 228
 229            // Handle PGS subtitles as raw streams for the client to render
 4230            if (MediaStream.IsPgsFormat(currentFormat))
 231            {
 0232                return new SubtitleInfo()
 0233                {
 0234                    Path = subtitleStream.Path,
 0235                    Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
 0236                    Format = "pgssub",
 0237                    IsExternal = true
 0238                };
 239            }
 240
 241            // Fallback to ffmpeg conversion
 4242            if (!_subtitleParser.SupportsFileExtension(currentFormat))
 243            {
 244                // Convert
 0245                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt");
 246
 0247                await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwai
 248
 0249                return new SubtitleInfo()
 0250                {
 0251                    Path = outputPath,
 0252                    Protocol = MediaProtocol.File,
 0253                    Format = "srt",
 0254                    IsExternal = true
 0255                };
 256            }
 257
 258            // It's possible that the subtitleStream and mediaSource don't share the same protocol (e.g. .STRM file with
 4259            return new SubtitleInfo()
 4260            {
 4261                Path = subtitleStream.Path,
 4262                Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
 4263                Format = currentFormat,
 4264                IsExternal = true
 4265            };
 4266        }
 267
 268        private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value)
 269        {
 0270            ArgumentException.ThrowIfNullOrEmpty(format);
 271
 0272            if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))
 273            {
 0274                value = new AssWriter();
 0275                return true;
 276            }
 277
 0278            if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
 279            {
 0280                value = new JsonWriter();
 0281                return true;
 282            }
 283
 0284            if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, S
 285            {
 0286                value = new SrtWriter();
 0287                return true;
 288            }
 289
 0290            if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase))
 291            {
 0292                value = new SsaWriter();
 0293                return true;
 294            }
 295
 0296            if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, S
 297            {
 0298                value = new VttWriter();
 0299                return true;
 300            }
 301
 0302            if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase))
 303            {
 0304                value = new TtmlWriter();
 0305                return true;
 306            }
 307
 0308            value = null;
 0309            return false;
 310        }
 311
 312        private ISubtitleWriter GetWriter(string format)
 313        {
 0314            if (TryGetWriter(format, out var writer))
 315            {
 0316                return writer;
 317            }
 318
 0319            throw new ArgumentException("Unsupported format: " + format);
 320        }
 321
 322        /// <summary>
 323        /// Converts the text subtitle to SRT.
 324        /// </summary>
 325        /// <param name="subtitleStream">The subtitle stream.</param>
 326        /// <param name="mediaSource">The input mediaSource.</param>
 327        /// <param name="outputPath">The output path.</param>
 328        /// <param name="cancellationToken">The cancellation token.</param>
 329        /// <returns>Task.</returns>
 330        private async Task ConvertTextSubtitleToSrt(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outp
 331        {
 0332            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 333            {
 0334                if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 335                {
 0336                    await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).C
 337                }
 0338            }
 0339        }
 340
 341        /// <summary>
 342        /// Converts the text subtitle to SRT internal.
 343        /// </summary>
 344        /// <param name="subtitleStream">The subtitle stream.</param>
 345        /// <param name="mediaSource">The input mediaSource.</param>
 346        /// <param name="outputPath">The output path.</param>
 347        /// <param name="cancellationToken">The cancellation token.</param>
 348        /// <returns>Task.</returns>
 349        /// <exception cref="ArgumentNullException">
 350        /// The <c>inputPath</c> or <c>outputPath</c> is <c>null</c>.
 351        /// </exception>
 352        private async Task ConvertTextSubtitleToSrtInternal(MediaStream subtitleStream, MediaSourceInfo mediaSource, str
 353        {
 0354            var inputPath = subtitleStream.Path;
 0355            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 356
 0357            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 358
 0359            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path (
 360
 0361            var encodingParam = await GetSubtitleFileCharacterSet(subtitleStream, subtitleStream.Language, mediaSource, 
 362
 363            // FFmpeg automatically convert character encoding when it is UTF-16
 364            // If we specify character encoding, it rejects with "do not specify a character encoding" and "Unable to re
 0365            if ((inputPath.EndsWith(".smi", StringComparison.Ordinal) || inputPath.EndsWith(".sami", StringComparison.Or
 0366                (encodingParam.Equals("UTF-16BE", StringComparison.OrdinalIgnoreCase) ||
 0367                 encodingParam.Equals("UTF-16LE", StringComparison.OrdinalIgnoreCase)))
 368            {
 0369                encodingParam = string.Empty;
 370            }
 0371            else if (!string.IsNullOrEmpty(encodingParam))
 372            {
 0373                encodingParam = " -sub_charenc " + encodingParam;
 374            }
 375
 376            int exitCode;
 377
 0378            using (var process = new Process
 0379            {
 0380                StartInfo = new ProcessStartInfo
 0381                {
 0382                    CreateNoWindow = true,
 0383                    UseShellExecute = false,
 0384                    FileName = _mediaEncoder.EncoderPath,
 0385                    Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingP
 0386                    WindowStyle = ProcessWindowStyle.Hidden,
 0387                    ErrorDialog = false
 0388                },
 0389                EnableRaisingEvents = true
 0390            })
 391            {
 0392                _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
 393
 394                try
 395                {
 0396                    process.Start();
 0397                }
 0398                catch (Exception ex)
 399                {
 0400                    _logger.LogError(ex, "Error starting ffmpeg");
 401
 0402                    throw;
 403                }
 404
 405                try
 406                {
 0407                    var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinut
 0408                    await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
 0409                    exitCode = process.ExitCode;
 0410                }
 0411                catch (OperationCanceledException)
 412                {
 0413                    process.Kill(true);
 0414                    exitCode = -1;
 0415                }
 0416            }
 417
 0418            var failed = false;
 419
 0420            if (exitCode == -1)
 421            {
 0422                failed = true;
 423
 0424                if (File.Exists(outputPath))
 425                {
 426                    try
 427                    {
 0428                        _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
 0429                        _fileSystem.DeleteFile(outputPath);
 0430                    }
 0431                    catch (IOException ex)
 432                    {
 0433                        _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
 0434                    }
 435                }
 436            }
 0437            else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 438            {
 0439                failed = true;
 440
 441                try
 442                {
 0443                    _logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
 0444                    _fileSystem.DeleteFile(outputPath);
 0445                }
 0446                catch (FileNotFoundException)
 447                {
 0448                }
 0449                catch (IOException ex)
 450                {
 0451                    _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
 0452                }
 453            }
 454
 0455            if (failed)
 456            {
 0457                _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
 458
 0459                throw new FfmpegException(
 0460                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath))
 461            }
 462
 0463            await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 464
 0465            _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
 0466        }
 467
 468        private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
 469        {
 0470            if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
 0471                || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)
 0472                || string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
 473            {
 0474                return subtitleStream.Codec;
 475            }
 476            else
 477            {
 0478                return "srt";
 479            }
 480        }
 481
 482        private string GetExtractableSubtitleFileExtension(MediaStream subtitleStream)
 483        {
 484            // Using .pgssub as file extension is not allowed by ffmpeg. The file extension for pgs subtitles is .sup.
 0485            if (string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
 486            {
 0487                return "sup";
 488            }
 489            else
 490            {
 0491                return GetExtractableSubtitleFormat(subtitleStream);
 492            }
 493        }
 494
 495        private bool IsCodecCopyable(string codec)
 496        {
 0497            return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase)
 0498                || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)
 0499                || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)
 0500                || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase)
 0501                || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase);
 502        }
 503
 504        /// <inheritdoc />
 505        public async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToke
 506        {
 0507            var locks = new List<IDisposable>();
 0508            var extractableStreams = new List<MediaStream>();
 509
 510            try
 511            {
 0512                var subtitleStreams = mediaSource.MediaStreams
 0513                    .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true });
 514
 0515                foreach (var subtitleStream in subtitleStreams)
 516                {
 0517                    if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnor
 518                    {
 519                        continue;
 520                    }
 521
 0522                    var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitl
 523
 0524                    var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
 525
 0526                    if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0)
 527                    {
 0528                        releaser.Dispose();
 0529                        continue;
 530                    }
 531
 0532                    locks.Add(releaser);
 0533                    extractableStreams.Add(subtitleStream);
 0534                }
 535
 0536                if (extractableStreams.Count > 0)
 537                {
 0538                    await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).Con
 0539                    await ExtractAllExtractableSubtitlesMKS(mediaSource, extractableStreams, cancellationToken).Configur
 540                }
 0541            }
 0542            catch (Exception ex)
 543            {
 0544                _logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path);
 0545            }
 546            finally
 547            {
 0548                locks.ForEach(x => x.Dispose());
 549            }
 0550        }
 551
 552        private async Task ExtractAllExtractableSubtitlesMKS(
 553           MediaSourceInfo mediaSource,
 554           List<MediaStream> subtitleStreams,
 555           CancellationToken cancellationToken)
 556        {
 0557            var mksFiles = new List<string>();
 558
 0559            foreach (var subtitleStream in subtitleStreams)
 560            {
 0561                if (string.IsNullOrEmpty(subtitleStream.Path) || !subtitleStream.Path.EndsWith(".mks", StringComparison.
 562                {
 563                    continue;
 564                }
 565
 0566                if (!mksFiles.Contains(subtitleStream.Path))
 567                {
 0568                    mksFiles.Add(subtitleStream.Path);
 569                }
 570            }
 571
 0572            if (mksFiles.Count == 0)
 573            {
 0574                return;
 575            }
 576
 0577            foreach (string mksFile in mksFiles)
 578            {
 0579                var inputPath = _mediaEncoder.GetInputArgument(mksFile, mediaSource);
 0580                var outputPaths = new List<string>();
 0581                var args = string.Format(
 0582                    CultureInfo.InvariantCulture,
 0583                    "-i {0}",
 0584                    inputPath);
 585
 0586                foreach (var subtitleStream in subtitleStreams)
 587                {
 0588                    if (!subtitleStream.Path.Equals(mksFile, StringComparison.OrdinalIgnoreCase))
 589                    {
 590                        continue;
 591                    }
 592
 0593                    var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitl
 0594                    var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
 0595                    var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 596
 0597                    if (streamIndex == -1)
 598                    {
 0599                        _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this str
 0600                        continue;
 601                    }
 602
 0603                    Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Cal
 604
 0605                    outputPaths.Add(outputPath);
 0606                    args += string.Format(
 0607                        CultureInfo.InvariantCulture,
 0608                        " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
 0609                        streamIndex,
 0610                        outputCodec,
 0611                        outputPath);
 612                }
 613
 0614                await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
 615            }
 0616        }
 617
 618        private async Task ExtractAllExtractableSubtitlesInternal(
 619            MediaSourceInfo mediaSource,
 620            List<MediaStream> subtitleStreams,
 621            CancellationToken cancellationToken)
 622        {
 0623            var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 0624            var outputPaths = new List<string>();
 0625            var args = string.Format(
 0626                CultureInfo.InvariantCulture,
 0627                "-i {0}",
 0628                inputPath);
 629
 0630            foreach (var subtitleStream in subtitleStreams)
 631            {
 0632                if (!string.IsNullOrEmpty(subtitleStream.Path) && subtitleStream.Path.EndsWith(".mks", StringComparison.
 633                {
 0634                    _logger.LogDebug("Subtitle {Index} for file {InputPath} is part in an MKS file. Skipping", inputPath
 0635                    continue;
 636                }
 637
 0638                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFil
 0639                var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
 0640                var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 641
 0642                if (streamIndex == -1)
 643                {
 0644                    _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream"
 0645                    continue;
 646                }
 647
 0648                Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calcula
 649
 0650                outputPaths.Add(outputPath);
 0651                args += string.Format(
 0652                    CultureInfo.InvariantCulture,
 0653                    " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
 0654                    streamIndex,
 0655                    outputCodec,
 0656                    outputPath);
 657            }
 658
 0659            if (outputPaths.Count == 0)
 660            {
 0661                return;
 662            }
 663
 0664            await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
 0665        }
 666
 667        private async Task ExtractSubtitlesForFile(
 668            string inputPath,
 669            string args,
 670            List<string> outputPaths,
 671            CancellationToken cancellationToken)
 672        {
 673            int exitCode;
 674
 0675            using (var process = new Process
 0676            {
 0677                StartInfo = new ProcessStartInfo
 0678                {
 0679                    CreateNoWindow = true,
 0680                    UseShellExecute = false,
 0681                    FileName = _mediaEncoder.EncoderPath,
 0682                    Arguments = args,
 0683                    WindowStyle = ProcessWindowStyle.Hidden,
 0684                    ErrorDialog = false
 0685                },
 0686                EnableRaisingEvents = true
 0687            })
 688            {
 0689                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 690
 691                try
 692                {
 0693                    process.Start();
 0694                }
 0695                catch (Exception ex)
 696                {
 0697                    _logger.LogError(ex, "Error starting ffmpeg");
 698
 0699                    throw;
 700                }
 701
 702                try
 703                {
 0704                    var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinut
 0705                    await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
 0706                    exitCode = process.ExitCode;
 0707                }
 0708                catch (OperationCanceledException)
 709                {
 0710                    process.Kill(true);
 0711                    exitCode = -1;
 0712                }
 0713            }
 714
 0715            var failed = false;
 716
 0717            if (exitCode == -1)
 718            {
 0719                failed = true;
 720
 0721                foreach (var outputPath in outputPaths)
 722                {
 723                    try
 724                    {
 0725                        _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 0726                        _fileSystem.DeleteFile(outputPath);
 0727                    }
 0728                    catch (FileNotFoundException)
 729                    {
 0730                    }
 0731                    catch (IOException ex)
 732                    {
 0733                        _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 0734                    }
 735                }
 736            }
 737            else
 738            {
 0739                foreach (var outputPath in outputPaths)
 740                {
 0741                    if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 742                    {
 0743                        _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath,
 0744                        failed = true;
 745
 746                        try
 747                        {
 0748                            _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 0749                            _fileSystem.DeleteFile(outputPath);
 0750                        }
 0751                        catch (FileNotFoundException)
 752                        {
 0753                        }
 0754                        catch (IOException ex)
 755                        {
 0756                            _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 0757                        }
 758
 759                        continue;
 760                    }
 761
 0762                    if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase))
 763                    {
 0764                        await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 765                    }
 766
 0767                    _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", input
 0768                }
 769            }
 770
 0771            if (failed)
 772            {
 0773                throw new FfmpegException(
 0774                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath))
 775            }
 0776        }
 777
 778        /// <summary>
 779        /// Extracts the text subtitle.
 780        /// </summary>
 781        /// <param name="mediaSource">The mediaSource.</param>
 782        /// <param name="subtitleStream">The subtitle stream.</param>
 783        /// <param name="outputCodec">The output codec.</param>
 784        /// <param name="outputPath">The output path.</param>
 785        /// <param name="cancellationToken">The cancellation token.</param>
 786        /// <returns>Task.</returns>
 787        /// <exception cref="ArgumentException">Must use inputPath list overload.</exception>
 788        private async Task ExtractTextSubtitle(
 789            MediaSourceInfo mediaSource,
 790            MediaStream subtitleStream,
 791            string outputCodec,
 792            string outputPath,
 793            CancellationToken cancellationToken)
 794        {
 0795            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 796            {
 0797                if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 798                {
 0799                    var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 800
 0801                    var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 802
 0803                    if (subtitleStream.IsExternal)
 804                    {
 0805                        args = _mediaEncoder.GetExternalSubtitleInputArgument(subtitleStream.Path);
 806                    }
 807
 0808                    await ExtractTextSubtitleInternal(
 0809                        args,
 0810                        subtitleStreamIndex,
 0811                        outputCodec,
 0812                        outputPath,
 0813                        cancellationToken).ConfigureAwait(false);
 814                }
 0815            }
 0816        }
 817
 818        private async Task ExtractTextSubtitleInternal(
 819            string inputPath,
 820            int subtitleStreamIndex,
 821            string outputCodec,
 822            string outputPath,
 823            CancellationToken cancellationToken)
 824        {
 0825            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 826
 0827            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 828
 0829            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path (
 830
 0831            var processArgs = string.Format(
 0832                CultureInfo.InvariantCulture,
 0833                "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
 0834                inputPath,
 0835                subtitleStreamIndex,
 0836                outputCodec,
 0837                outputPath);
 838
 839            int exitCode;
 840
 0841            using (var process = new Process
 0842            {
 0843                StartInfo = new ProcessStartInfo
 0844                {
 0845                    CreateNoWindow = true,
 0846                    UseShellExecute = false,
 0847                    FileName = _mediaEncoder.EncoderPath,
 0848                    Arguments = processArgs,
 0849                    WindowStyle = ProcessWindowStyle.Hidden,
 0850                    ErrorDialog = false
 0851                },
 0852                EnableRaisingEvents = true
 0853            })
 854            {
 0855                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 856
 857                try
 858                {
 0859                    process.Start();
 0860                }
 0861                catch (Exception ex)
 862                {
 0863                    _logger.LogError(ex, "Error starting ffmpeg");
 864
 0865                    throw;
 866                }
 867
 868                try
 869                {
 0870                    var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinut
 0871                    await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
 0872                    exitCode = process.ExitCode;
 0873                }
 0874                catch (OperationCanceledException)
 875                {
 0876                    process.Kill(true);
 0877                    exitCode = -1;
 0878                }
 0879            }
 880
 0881            var failed = false;
 882
 0883            if (exitCode == -1)
 884            {
 0885                failed = true;
 886
 887                try
 888                {
 0889                    _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 0890                    _fileSystem.DeleteFile(outputPath);
 0891                }
 0892                catch (FileNotFoundException)
 893                {
 0894                }
 0895                catch (IOException ex)
 896                {
 0897                    _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 0898                }
 899            }
 0900            else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 901            {
 0902                failed = true;
 903
 904                try
 905                {
 0906                    _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 0907                    _fileSystem.DeleteFile(outputPath);
 0908                }
 0909                catch (FileNotFoundException)
 910                {
 0911                }
 0912                catch (IOException ex)
 913                {
 0914                    _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 0915                }
 916            }
 917
 0918            if (failed)
 919            {
 0920                _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputP
 921
 0922                throw new FfmpegException(
 0923                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inpu
 924            }
 925
 0926            _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, ou
 927
 0928            if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
 929            {
 0930                await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 931            }
 0932        }
 933
 934        /// <summary>
 935        /// Sets the ass font.
 936        /// </summary>
 937        /// <param name="file">The file.</param>
 938        /// <param name="cancellationToken">The token to monitor for cancellation requests. The default value is <c>Syst
 939        /// <returns>Task.</returns>
 940        private async Task SetAssFont(string file, CancellationToken cancellationToken = default)
 941        {
 0942            _logger.LogInformation("Setting ass font within {File}", file);
 943
 944            string text;
 945            Encoding encoding;
 946
 0947            using (var fileStream = AsyncFile.OpenRead(file))
 0948            using (var reader = new StreamReader(fileStream, true))
 949            {
 0950                encoding = reader.CurrentEncoding;
 951
 0952                text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 0953            }
 954
 0955            var newText = text.Replace(",Arial,", ",Arial Unicode MS,", StringComparison.Ordinal);
 956
 0957            if (!string.Equals(text, newText, StringComparison.Ordinal))
 958            {
 0959                var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.File
 0960                await using (fileStream.ConfigureAwait(false))
 961                {
 0962                    var writer = new StreamWriter(fileStream, encoding);
 0963                    await using (writer.ConfigureAwait(false))
 964                    {
 0965                        await writer.WriteAsync(newText.AsMemory(), cancellationToken).ConfigureAwait(false);
 966                    }
 967                }
 968            }
 0969        }
 970
 971        private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleE
 972        {
 0973            return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
 974        }
 975
 976        /// <inheritdoc />
 977        public async Task<string> GetSubtitleFileCharacterSet(MediaStream subtitleStream, string language, MediaSourceIn
 978        {
 0979            var subtitleCodec = subtitleStream.Codec;
 0980            var path = subtitleStream.Path;
 981
 0982            if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 983            {
 0984                path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
 0985                await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
 0986                    .ConfigureAwait(false);
 987            }
 988
 0989            var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
 0990            var charset = result.Detected?.EncodingName ?? string.Empty;
 991
 992            // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
 0993            if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || p
 0994                && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase)
 0995                    || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase)))
 996            {
 0997                charset = string.Empty;
 998            }
 999
 01000            _logger.LogDebug("charset {0} detected for {Path}", charset, path);
 1001
 01002            return charset;
 01003        }
 1004
 1005        private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancell
 1006        {
 1007            switch (protocol)
 1008            {
 1009                case MediaProtocol.Http:
 1010                {
 01011                    using var stream = await _httpClientFactory
 01012                      .CreateClient(NamedClient.Default)
 01013                      .GetStreamAsync(new Uri(path), cancellationToken)
 01014                      .ConfigureAwait(false);
 1015
 01016                    return await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
 1017                }
 1018
 1019                case MediaProtocol.File:
 1020                {
 01021                    return await CharsetDetector.DetectFromFileAsync(path, cancellationToken)
 01022                                          .ConfigureAwait(false);
 1023                }
 1024
 1025                default:
 01026                    throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol");
 1027            }
 01028        }
 1029
 1030        public async Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, Cancellat
 1031        {
 01032            var info = await GetReadableFile(mediaSource, subtitleStream, cancellationToken)
 01033                .ConfigureAwait(false);
 01034            return info.Path;
 01035        }
 1036
 1037        /// <inheritdoc />
 1038        public void Dispose()
 1039        {
 211040            _semaphoreLocks.Dispose();
 211041        }
 1042
 1043#pragma warning disable CA1034 // Nested types should not be visible
 1044        // Only public for the unit tests
 1045        public readonly record struct SubtitleInfo
 1046        {
 1047            public string Path { get; init; }
 1048
 1049            public MediaProtocol Protocol { get; init; }
 1050
 1051            public string Format { get; init; }
 1052
 1053            public bool IsExternal { get; init; }
 1054        }
 1055    }
 1056}

Methods/Properties

.ctor(Microsoft.Extensions.Logging.ILogger`1<MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>,MediaBrowser.Model.IO.IFileSystem,MediaBrowser.Controller.MediaEncoding.IMediaEncoder,System.Net.Http.IHttpClientFactory,MediaBrowser.Controller.Library.IMediaSourceManager,MediaBrowser.MediaEncoding.Subtitles.ISubtitleParser,MediaBrowser.Controller.IO.IPathManager,MediaBrowser.Controller.Configuration.IServerConfigurationManager)
ConvertSubtitles(System.IO.Stream,System.String,System.String,System.Int64,System.Int64,System.Boolean,System.Threading.CancellationToken)
FilterEvents(MediaBrowser.Model.MediaInfo.SubtitleTrackInfo,System.Int64,System.Int64,System.Boolean)
MediaBrowser-Controller-MediaEncoding-ISubtitleEncoder-GetSubtitles()
GetSubtitleStream()
GetSubtitleStream()
GetReadableFile()
TryGetWriter(System.String,MediaBrowser.MediaEncoding.Subtitles.ISubtitleWriter&)
GetWriter(System.String)
ConvertTextSubtitleToSrt()
ConvertTextSubtitleToSrtInternal()
GetExtractableSubtitleFormat(MediaBrowser.Model.Entities.MediaStream)
GetExtractableSubtitleFileExtension(MediaBrowser.Model.Entities.MediaStream)
IsCodecCopyable(System.String)
ExtractAllExtractableSubtitles()
ExtractAllExtractableSubtitlesMKS()
ExtractAllExtractableSubtitlesInternal()
ExtractSubtitlesForFile()
ExtractTextSubtitle()
ExtractTextSubtitleInternal()
SetAssFont()
GetSubtitleCachePath(MediaBrowser.Model.Dto.MediaSourceInfo,System.Int32,System.String)
GetSubtitleFileCharacterSet()
DetectCharset()
GetSubtitleFilePath()
Dispose()