< 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: 457
Coverable lines: 498
Total lines: 1076
Line coverage: 8.2%
Branch coverage
5%
Covered branches: 12
Total branches: 202
Branch coverage: 5.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 2/13/2026 - 12:11:21 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: 10565/13/2026 - 12:15:27 AM Line coverage: 8.2% (41/498) Branch coverage: 5.9% (12/202) Total lines: 1076 2/13/2026 - 12:11:21 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: 10565/13/2026 - 12:15:27 AM Line coverage: 8.2% (41/498) Branch coverage: 5.9% (12/202) Total lines: 1076

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)
 0216                    ?? throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no subtitle cache (non-GUI
 217
 0218                return new SubtitleInfo()
 0219                {
 0220                    Path = outputPath,
 0221                    Protocol = MediaProtocol.File,
 0222                    Format = outputFormat,
 0223                    IsExternal = false
 0224                };
 225            }
 226
 4227            var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path)
 4228                .TrimStart('.');
 229
 230            // Handle PGS subtitles as raw streams for the client to render
 4231            if (MediaStream.IsPgsFormat(currentFormat))
 232            {
 0233                return new SubtitleInfo()
 0234                {
 0235                    Path = subtitleStream.Path,
 0236                    Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
 0237                    Format = "pgssub",
 0238                    IsExternal = true
 0239                };
 240            }
 241
 242            // Fallback to ffmpeg conversion
 4243            if (!_subtitleParser.SupportsFileExtension(currentFormat))
 244            {
 245                // Convert
 0246                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt")
 0247                    ?? throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no subtitle cache (non-GUI
 248
 0249                await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwai
 250
 0251                return new SubtitleInfo()
 0252                {
 0253                    Path = outputPath,
 0254                    Protocol = MediaProtocol.File,
 0255                    Format = "srt",
 0256                    IsExternal = true
 0257                };
 258            }
 259
 260            // It's possible that the subtitleStream and mediaSource don't share the same protocol (e.g. .STRM file with
 4261            return new SubtitleInfo()
 4262            {
 4263                Path = subtitleStream.Path,
 4264                Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
 4265                Format = currentFormat,
 4266                IsExternal = true
 4267            };
 4268        }
 269
 270        private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value)
 271        {
 0272            ArgumentException.ThrowIfNullOrEmpty(format);
 273
 0274            if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))
 275            {
 0276                value = new AssWriter();
 0277                return true;
 278            }
 279
 0280            if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
 281            {
 0282                value = new JsonWriter();
 0283                return true;
 284            }
 285
 0286            if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, S
 287            {
 0288                value = new SrtWriter();
 0289                return true;
 290            }
 291
 0292            if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase))
 293            {
 0294                value = new SsaWriter();
 0295                return true;
 296            }
 297
 0298            if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, S
 299            {
 0300                value = new VttWriter();
 0301                return true;
 302            }
 303
 0304            if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase))
 305            {
 0306                value = new TtmlWriter();
 0307                return true;
 308            }
 309
 0310            value = null;
 0311            return false;
 312        }
 313
 314        private ISubtitleWriter GetWriter(string format)
 315        {
 0316            if (TryGetWriter(format, out var writer))
 317            {
 0318                return writer;
 319            }
 320
 0321            throw new ArgumentException("Unsupported format: " + format);
 322        }
 323
 324        /// <summary>
 325        /// Converts the text subtitle to SRT.
 326        /// </summary>
 327        /// <param name="subtitleStream">The subtitle stream.</param>
 328        /// <param name="mediaSource">The input mediaSource.</param>
 329        /// <param name="outputPath">The output path.</param>
 330        /// <param name="cancellationToken">The cancellation token.</param>
 331        /// <returns>Task.</returns>
 332        private async Task ConvertTextSubtitleToSrt(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outp
 333        {
 0334            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 335            {
 0336                if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 337                {
 0338                    await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).C
 339                }
 0340            }
 0341        }
 342
 343        /// <summary>
 344        /// Converts the text subtitle to SRT internal.
 345        /// </summary>
 346        /// <param name="subtitleStream">The subtitle stream.</param>
 347        /// <param name="mediaSource">The input mediaSource.</param>
 348        /// <param name="outputPath">The output path.</param>
 349        /// <param name="cancellationToken">The cancellation token.</param>
 350        /// <returns>Task.</returns>
 351        /// <exception cref="ArgumentNullException">
 352        /// The <c>inputPath</c> or <c>outputPath</c> is <c>null</c>.
 353        /// </exception>
 354        private async Task ConvertTextSubtitleToSrtInternal(MediaStream subtitleStream, MediaSourceInfo mediaSource, str
 355        {
 0356            var inputPath = subtitleStream.Path;
 0357            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 358
 0359            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 360
 0361            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path (
 362
 0363            var encodingParam = await GetSubtitleFileCharacterSet(subtitleStream, subtitleStream.Language, mediaSource, 
 364
 365            // FFmpeg automatically convert character encoding when it is UTF-16
 366            // If we specify character encoding, it rejects with "do not specify a character encoding" and "Unable to re
 0367            if ((inputPath.EndsWith(".smi", StringComparison.Ordinal) || inputPath.EndsWith(".sami", StringComparison.Or
 0368                (encodingParam.Equals("UTF-16BE", StringComparison.OrdinalIgnoreCase) ||
 0369                 encodingParam.Equals("UTF-16LE", StringComparison.OrdinalIgnoreCase)))
 370            {
 0371                encodingParam = string.Empty;
 372            }
 0373            else if (!string.IsNullOrEmpty(encodingParam))
 374            {
 0375                encodingParam = " -sub_charenc " + encodingParam;
 376            }
 377
 378            int exitCode;
 379
 0380            using (var process = new Process
 0381            {
 0382                StartInfo = new ProcessStartInfo
 0383                {
 0384                    CreateNoWindow = true,
 0385                    UseShellExecute = false,
 0386                    FileName = _mediaEncoder.EncoderPath,
 0387                    Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingP
 0388                    WindowStyle = ProcessWindowStyle.Hidden,
 0389                    ErrorDialog = false
 0390                },
 0391                EnableRaisingEvents = true
 0392            })
 393            {
 0394                _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
 395
 396                try
 397                {
 0398                    process.Start();
 0399                }
 0400                catch (Exception ex)
 401                {
 0402                    _logger.LogError(ex, "Error starting ffmpeg");
 403
 0404                    throw;
 405                }
 406
 407                try
 408                {
 0409                    var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinut
 0410                    await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
 0411                    exitCode = process.ExitCode;
 0412                }
 0413                catch (OperationCanceledException)
 414                {
 0415                    process.Kill(true);
 0416                    exitCode = -1;
 0417                }
 0418            }
 419
 0420            var failed = false;
 421
 0422            if (exitCode == -1)
 423            {
 0424                failed = true;
 425
 0426                if (File.Exists(outputPath))
 427                {
 428                    try
 429                    {
 0430                        _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
 0431                        _fileSystem.DeleteFile(outputPath);
 0432                    }
 0433                    catch (IOException ex)
 434                    {
 0435                        _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
 0436                    }
 437                }
 438            }
 0439            else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 440            {
 0441                failed = true;
 442
 443                try
 444                {
 0445                    _logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
 0446                    _fileSystem.DeleteFile(outputPath);
 0447                }
 0448                catch (FileNotFoundException)
 449                {
 0450                }
 0451                catch (IOException ex)
 452                {
 0453                    _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
 0454                }
 455            }
 456
 0457            if (failed)
 458            {
 0459                _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
 460
 0461                throw new FfmpegException(
 0462                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath))
 463            }
 464
 0465            await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 466
 0467            _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
 0468        }
 469
 470        private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
 471        {
 0472            if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
 0473                || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)
 0474                || string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
 475            {
 0476                return subtitleStream.Codec;
 477            }
 478            else
 479            {
 0480                return "srt";
 481            }
 482        }
 483
 484        private string GetExtractableSubtitleFileExtension(MediaStream subtitleStream)
 485        {
 486            // Using .pgssub as file extension is not allowed by ffmpeg. The file extension for pgs subtitles is .sup.
 0487            if (string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
 488            {
 0489                return "sup";
 490            }
 491            else
 492            {
 0493                return GetExtractableSubtitleFormat(subtitleStream);
 494            }
 495        }
 496
 497        private bool IsCodecCopyable(string codec)
 498        {
 0499            return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase)
 0500                || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)
 0501                || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)
 0502                || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase)
 0503                || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase);
 504        }
 505
 506        /// <inheritdoc />
 507        public async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToke
 508        {
 0509            var locks = new List<IDisposable>();
 0510            var extractableStreams = new List<MediaStream>();
 511
 512            try
 513            {
 0514                var subtitleStreams = mediaSource.MediaStreams
 0515                    .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true });
 516
 0517                foreach (var subtitleStream in subtitleStreams)
 518                {
 0519                    if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnor
 520                    {
 521                        continue;
 522                    }
 523
 0524                    var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitl
 0525                    if (outputPath is null)
 526                    {
 527                        continue;
 528                    }
 529
 0530                    var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
 531
 0532                    if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0)
 533                    {
 0534                        releaser.Dispose();
 0535                        continue;
 536                    }
 537
 0538                    locks.Add(releaser);
 0539                    extractableStreams.Add(subtitleStream);
 0540                }
 541
 0542                if (extractableStreams.Count > 0)
 543                {
 0544                    await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).Con
 0545                    await ExtractAllExtractableSubtitlesMKS(mediaSource, extractableStreams, cancellationToken).Configur
 546                }
 0547            }
 0548            catch (Exception ex)
 549            {
 0550                _logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path);
 0551            }
 552            finally
 553            {
 0554                locks.ForEach(x => x.Dispose());
 555            }
 0556        }
 557
 558        private async Task ExtractAllExtractableSubtitlesMKS(
 559           MediaSourceInfo mediaSource,
 560           List<MediaStream> subtitleStreams,
 561           CancellationToken cancellationToken)
 562        {
 0563            var mksFiles = new List<string>();
 564
 0565            foreach (var subtitleStream in subtitleStreams)
 566            {
 0567                if (string.IsNullOrEmpty(subtitleStream.Path) || !subtitleStream.Path.EndsWith(".mks", StringComparison.
 568                {
 569                    continue;
 570                }
 571
 0572                if (!mksFiles.Contains(subtitleStream.Path))
 573                {
 0574                    mksFiles.Add(subtitleStream.Path);
 575                }
 576            }
 577
 0578            if (mksFiles.Count == 0)
 579            {
 0580                return;
 581            }
 582
 0583            foreach (string mksFile in mksFiles)
 584            {
 0585                var inputPath = _mediaEncoder.GetInputArgument(mksFile, mediaSource);
 0586                var outputPaths = new List<string>();
 0587                var args = string.Format(
 0588                    CultureInfo.InvariantCulture,
 0589                    "-i {0}",
 0590                    inputPath);
 591
 0592                foreach (var subtitleStream in subtitleStreams)
 593                {
 0594                    if (!subtitleStream.Path.Equals(mksFile, StringComparison.OrdinalIgnoreCase))
 595                    {
 596                        continue;
 597                    }
 598
 0599                    var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitl
 0600                    if (outputPath is null)
 601                    {
 602                        continue;
 603                    }
 604
 0605                    var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
 0606                    var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 607
 0608                    if (streamIndex == -1)
 609                    {
 0610                        _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this str
 0611                        continue;
 612                    }
 613
 0614                    Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Cal
 615
 0616                    outputPaths.Add(outputPath);
 0617                    args += string.Format(
 0618                        CultureInfo.InvariantCulture,
 0619                        " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
 0620                        streamIndex,
 0621                        outputCodec,
 0622                        outputPath);
 623                }
 624
 0625                await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
 626            }
 0627        }
 628
 629        private async Task ExtractAllExtractableSubtitlesInternal(
 630            MediaSourceInfo mediaSource,
 631            List<MediaStream> subtitleStreams,
 632            CancellationToken cancellationToken)
 633        {
 0634            var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 0635            var outputPaths = new List<string>();
 0636            var args = string.Format(
 0637                CultureInfo.InvariantCulture,
 0638                "-i {0}",
 0639                inputPath);
 640
 0641            foreach (var subtitleStream in subtitleStreams)
 642            {
 0643                if (!string.IsNullOrEmpty(subtitleStream.Path) && subtitleStream.Path.EndsWith(".mks", StringComparison.
 644                {
 0645                    _logger.LogDebug("Subtitle {Index} for file {InputPath} is part in an MKS file. Skipping", inputPath
 0646                    continue;
 647                }
 648
 0649                var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFil
 0650                if (outputPath is null)
 651                {
 652                    continue;
 653                }
 654
 0655                var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
 0656                var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 657
 0658                if (streamIndex == -1)
 659                {
 0660                    _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream"
 0661                    continue;
 662                }
 663
 0664                Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calcula
 665
 0666                outputPaths.Add(outputPath);
 0667                args += string.Format(
 0668                    CultureInfo.InvariantCulture,
 0669                    " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
 0670                    streamIndex,
 0671                    outputCodec,
 0672                    outputPath);
 673            }
 674
 0675            if (outputPaths.Count == 0)
 676            {
 0677                return;
 678            }
 679
 0680            await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
 0681        }
 682
 683        private async Task ExtractSubtitlesForFile(
 684            string inputPath,
 685            string args,
 686            List<string> outputPaths,
 687            CancellationToken cancellationToken)
 688        {
 689            int exitCode;
 690
 0691            using (var process = new Process
 0692            {
 0693                StartInfo = new ProcessStartInfo
 0694                {
 0695                    CreateNoWindow = true,
 0696                    UseShellExecute = false,
 0697                    FileName = _mediaEncoder.EncoderPath,
 0698                    Arguments = args,
 0699                    WindowStyle = ProcessWindowStyle.Hidden,
 0700                    ErrorDialog = false
 0701                },
 0702                EnableRaisingEvents = true
 0703            })
 704            {
 0705                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 706
 707                try
 708                {
 0709                    process.Start();
 0710                }
 0711                catch (Exception ex)
 712                {
 0713                    _logger.LogError(ex, "Error starting ffmpeg");
 714
 0715                    throw;
 716                }
 717
 718                try
 719                {
 0720                    var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinut
 0721                    await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
 0722                    exitCode = process.ExitCode;
 0723                }
 0724                catch (OperationCanceledException)
 725                {
 0726                    process.Kill(true);
 0727                    exitCode = -1;
 0728                }
 0729            }
 730
 0731            var failed = false;
 732
 0733            if (exitCode == -1)
 734            {
 0735                failed = true;
 736
 0737                foreach (var outputPath in outputPaths)
 738                {
 739                    try
 740                    {
 0741                        _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 0742                        _fileSystem.DeleteFile(outputPath);
 0743                    }
 0744                    catch (FileNotFoundException)
 745                    {
 0746                    }
 0747                    catch (IOException ex)
 748                    {
 0749                        _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 0750                    }
 751                }
 752            }
 753            else
 754            {
 0755                foreach (var outputPath in outputPaths)
 756                {
 0757                    if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 758                    {
 0759                        _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath,
 0760                        failed = true;
 761
 762                        try
 763                        {
 0764                            _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 0765                            _fileSystem.DeleteFile(outputPath);
 0766                        }
 0767                        catch (FileNotFoundException)
 768                        {
 0769                        }
 0770                        catch (IOException ex)
 771                        {
 0772                            _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 0773                        }
 774
 775                        continue;
 776                    }
 777
 0778                    if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase))
 779                    {
 0780                        await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 781                    }
 782
 0783                    _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", input
 0784                }
 785            }
 786
 0787            if (failed)
 788            {
 0789                throw new FfmpegException(
 0790                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath))
 791            }
 0792        }
 793
 794        /// <summary>
 795        /// Extracts the text subtitle.
 796        /// </summary>
 797        /// <param name="mediaSource">The mediaSource.</param>
 798        /// <param name="subtitleStream">The subtitle stream.</param>
 799        /// <param name="outputCodec">The output codec.</param>
 800        /// <param name="outputPath">The output path.</param>
 801        /// <param name="cancellationToken">The cancellation token.</param>
 802        /// <returns>Task.</returns>
 803        /// <exception cref="ArgumentException">Must use inputPath list overload.</exception>
 804        private async Task ExtractTextSubtitle(
 805            MediaSourceInfo mediaSource,
 806            MediaStream subtitleStream,
 807            string outputCodec,
 808            string outputPath,
 809            CancellationToken cancellationToken)
 810        {
 0811            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 812            {
 0813                if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 814                {
 0815                    var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
 816
 0817                    var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
 818
 0819                    if (subtitleStream.IsExternal)
 820                    {
 0821                        args = _mediaEncoder.GetExternalSubtitleInputArgument(subtitleStream.Path);
 822                    }
 823
 0824                    await ExtractTextSubtitleInternal(
 0825                        args,
 0826                        subtitleStreamIndex,
 0827                        outputCodec,
 0828                        outputPath,
 0829                        cancellationToken).ConfigureAwait(false);
 830                }
 0831            }
 0832        }
 833
 834        private async Task ExtractTextSubtitleInternal(
 835            string inputPath,
 836            int subtitleStreamIndex,
 837            string outputCodec,
 838            string outputPath,
 839            CancellationToken cancellationToken)
 840        {
 0841            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 842
 0843            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 844
 0845            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path (
 846
 0847            var processArgs = string.Format(
 0848                CultureInfo.InvariantCulture,
 0849                "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
 0850                inputPath,
 0851                subtitleStreamIndex,
 0852                outputCodec,
 0853                outputPath);
 854
 855            int exitCode;
 856
 0857            using (var process = new Process
 0858            {
 0859                StartInfo = new ProcessStartInfo
 0860                {
 0861                    CreateNoWindow = true,
 0862                    UseShellExecute = false,
 0863                    FileName = _mediaEncoder.EncoderPath,
 0864                    Arguments = processArgs,
 0865                    WindowStyle = ProcessWindowStyle.Hidden,
 0866                    ErrorDialog = false
 0867                },
 0868                EnableRaisingEvents = true
 0869            })
 870            {
 0871                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 872
 873                try
 874                {
 0875                    process.Start();
 0876                }
 0877                catch (Exception ex)
 878                {
 0879                    _logger.LogError(ex, "Error starting ffmpeg");
 880
 0881                    throw;
 882                }
 883
 884                try
 885                {
 0886                    var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinut
 0887                    await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
 0888                    exitCode = process.ExitCode;
 0889                }
 0890                catch (OperationCanceledException)
 891                {
 0892                    process.Kill(true);
 0893                    exitCode = -1;
 0894                }
 0895            }
 896
 0897            var failed = false;
 898
 0899            if (exitCode == -1)
 900            {
 0901                failed = true;
 902
 903                try
 904                {
 0905                    _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 0906                    _fileSystem.DeleteFile(outputPath);
 0907                }
 0908                catch (FileNotFoundException)
 909                {
 0910                }
 0911                catch (IOException ex)
 912                {
 0913                    _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 0914                }
 915            }
 0916            else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
 917            {
 0918                failed = true;
 919
 920                try
 921                {
 0922                    _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
 0923                    _fileSystem.DeleteFile(outputPath);
 0924                }
 0925                catch (FileNotFoundException)
 926                {
 0927                }
 0928                catch (IOException ex)
 929                {
 0930                    _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
 0931                }
 932            }
 933
 0934            if (failed)
 935            {
 0936                _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputP
 937
 0938                throw new FfmpegException(
 0939                    string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inpu
 940            }
 941
 0942            _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, ou
 943
 0944            if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
 945            {
 0946                await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
 947            }
 0948        }
 949
 950        /// <summary>
 951        /// Sets the ass font.
 952        /// </summary>
 953        /// <param name="file">The file.</param>
 954        /// <param name="cancellationToken">The token to monitor for cancellation requests. The default value is <c>Syst
 955        /// <returns>Task.</returns>
 956        private async Task SetAssFont(string file, CancellationToken cancellationToken = default)
 957        {
 0958            _logger.LogInformation("Setting ass font within {File}", file);
 959
 960            string text;
 961            Encoding encoding;
 962
 0963            using (var fileStream = AsyncFile.OpenRead(file))
 0964            using (var reader = new StreamReader(fileStream, true))
 965            {
 0966                encoding = reader.CurrentEncoding;
 967
 0968                text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 0969            }
 970
 0971            var newText = text.Replace(",Arial,", ",Arial Unicode MS,", StringComparison.Ordinal);
 972
 0973            if (!string.Equals(text, newText, StringComparison.Ordinal))
 974            {
 0975                var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.File
 0976                await using (fileStream.ConfigureAwait(false))
 977                {
 0978                    var writer = new StreamWriter(fileStream, encoding);
 0979                    await using (writer.ConfigureAwait(false))
 980                    {
 0981                        await writer.WriteAsync(newText.AsMemory(), cancellationToken).ConfigureAwait(false);
 982                    }
 983                }
 984            }
 0985        }
 986
 987        private string? GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitle
 988        {
 0989            return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
 990        }
 991
 992        /// <inheritdoc />
 993        public async Task<string> GetSubtitleFileCharacterSet(MediaStream subtitleStream, string language, MediaSourceIn
 994        {
 0995            var subtitleCodec = subtitleStream.Codec;
 0996            var path = subtitleStream.Path;
 997
 0998            if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 999            {
 01000                var cachePath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
 01001                if (cachePath is not null)
 1002                {
 01003                    path = cachePath;
 01004                    await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
 01005                        .ConfigureAwait(false);
 1006                }
 1007            }
 1008
 01009            var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
 01010            var charset = result.Detected?.EncodingName ?? string.Empty;
 1011
 1012            // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
 01013            if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || p
 01014                && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase)
 01015                    || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase)))
 1016            {
 01017                charset = string.Empty;
 1018            }
 1019
 01020            _logger.LogDebug("charset {0} detected for {Path}", charset, path);
 1021
 01022            return charset;
 01023        }
 1024
 1025        private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancell
 1026        {
 1027            switch (protocol)
 1028            {
 1029                case MediaProtocol.Http:
 1030                    {
 01031                        using var stream = await _httpClientFactory
 01032                          .CreateClient(NamedClient.Default)
 01033                          .GetStreamAsync(new Uri(path), cancellationToken)
 01034                          .ConfigureAwait(false);
 1035
 01036                        return await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(fal
 1037                    }
 1038
 1039                case MediaProtocol.File:
 1040                    {
 01041                        return await CharsetDetector.DetectFromFileAsync(path, cancellationToken)
 01042                                              .ConfigureAwait(false);
 1043                    }
 1044
 1045                default:
 01046                    throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol");
 1047            }
 01048        }
 1049
 1050        public async Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, Cancellat
 1051        {
 01052            var info = await GetReadableFile(mediaSource, subtitleStream, cancellationToken)
 01053                .ConfigureAwait(false);
 01054            return info.Path;
 01055        }
 1056
 1057        /// <inheritdoc />
 1058        public void Dispose()
 1059        {
 211060            _semaphoreLocks.Dispose();
 211061        }
 1062
 1063#pragma warning disable CA1034 // Nested types should not be visible
 1064        // Only public for the unit tests
 1065        public readonly record struct SubtitleInfo
 1066        {
 1067            public string Path { get; init; }
 1068
 1069            public MediaProtocol Protocol { get; init; }
 1070
 1071            public string Format { get; init; }
 1072
 1073            public bool IsExternal { get; init; }
 1074        }
 1075    }
 1076}

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()