#pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; using UtfUnknown; namespace MediaBrowser.MediaEncoding.Subtitles { public sealed class SubtitleEncoder : ISubtitleEncoder, IDisposable { private readonly ILogger _logger; private readonly IApplicationPaths _appPaths; private readonly IFileSystem _fileSystem; private readonly IMediaEncoder _mediaEncoder; private readonly IHttpClientFactory _httpClientFactory; private readonly IMediaSourceManager _mediaSourceManager; private readonly ISubtitleParser _subtitleParser; /// /// The _semaphoreLocks. /// private readonly AsyncKeyedLocker _semaphoreLocks = new(o => { o.PoolSize = 20; o.PoolInitialFill = 1; }); public SubtitleEncoder( ILogger logger, IApplicationPaths appPaths, IFileSystem fileSystem, IMediaEncoder mediaEncoder, IHttpClientFactory httpClientFactory, IMediaSourceManager mediaSourceManager, ISubtitleParser subtitleParser) { _logger = logger; _appPaths = appPaths; _fileSystem = fileSystem; _mediaEncoder = mediaEncoder; _httpClientFactory = httpClientFactory; _mediaSourceManager = mediaSourceManager; _subtitleParser = subtitleParser; } private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles"); private MemoryStream ConvertSubtitles( Stream stream, string inputFormat, string outputFormat, long startTimeTicks, long endTimeTicks, bool preserveOriginalTimestamps, CancellationToken cancellationToken) { var ms = new MemoryStream(); try { var trackInfo = _subtitleParser.Parse(stream, inputFormat); FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); var writer = GetWriter(outputFormat); writer.Write(trackInfo, ms, cancellationToken); ms.Position = 0; } catch { ms.Dispose(); throw; } return ms; } private void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps) { // Drop subs that are earlier than what we're looking for track.TrackEvents = track.TrackEvents .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 || (i.EndPositionTicks - startPositionTicks) < 0) .ToArray(); if (endTimeTicks > 0) { track.TrackEvents = track.TrackEvents .TakeWhile(i => i.StartPositionTicks <= endTimeTicks) .ToArray(); } if (!preserveTimestamps) { foreach (var trackEvent in track.TrackEvents) { trackEvent.EndPositionTicks -= startPositionTicks; trackEvent.StartPositionTicks -= startPositionTicks; } } } async Task ISubtitleEncoder.GetSubtitles(BaseItem item, string mediaSourceId, int subtitleStreamIndex, string outputFormat, long startTimeTicks, long endTimeTicks, bool preserveOriginalTimestamps, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(item); if (string.IsNullOrWhiteSpace(mediaSourceId)) { throw new ArgumentNullException(nameof(mediaSourceId)); } var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationToken).ConfigureAwait(false); var mediaSource = mediaSources .First(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)); var subtitleStream = mediaSource.MediaStreams .First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex); var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken) .ConfigureAwait(false); // Return the original if the same format is being requested // Character encoding was already handled in GetSubtitleStream if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase)) { return stream; } using (stream) { return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken); } } private async Task<(Stream Stream, string Format)> GetSubtitleStream( MediaSourceInfo mediaSource, MediaStream subtitleStream, CancellationToken cancellationToken) { var fileInfo = await GetReadableFile(mediaSource, subtitleStream, cancellationToken).ConfigureAwait(false); var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false); return (stream, fileInfo.Format); } private async Task GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) { if (fileInfo.IsExternal) { using (var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false)) { var result = CharsetDetector.DetectFromStream(stream).Detected; stream.Position = 0; if (result is not null) { _logger.LogDebug("charset {CharSet} detected for {Path}", result.EncodingName, fileInfo.Path); using var reader = new StreamReader(stream, result.Encoding); var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); return new MemoryStream(Encoding.UTF8.GetBytes(text)); } } } return AsyncFile.OpenRead(fileInfo.Path); } internal async Task GetReadableFile( MediaSourceInfo mediaSource, MediaStream subtitleStream, CancellationToken cancellationToken) { if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { await ExtractAllTextSubtitles(mediaSource, cancellationToken).ConfigureAwait(false); var outputFormat = GetTextSubtitleFormat(subtitleStream); var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFormat); return new SubtitleInfo() { Path = outputPath, Protocol = MediaProtocol.File, Format = outputFormat, IsExternal = false }; } var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec) .TrimStart('.'); // Fallback to ffmpeg conversion if (!_subtitleParser.SupportsFileExtension(currentFormat)) { // Convert var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt"); await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); return new SubtitleInfo() { Path = outputPath, Protocol = MediaProtocol.File, Format = "srt", IsExternal = true }; } // It's possible that the subtitleStream and mediaSource don't share the same protocol (e.g. .STRM file with local subs) return new SubtitleInfo() { Path = subtitleStream.Path, Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path), Format = currentFormat, IsExternal = true }; } private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value) { ArgumentException.ThrowIfNullOrEmpty(format); if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase)) { value = new AssWriter(); return true; } if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { value = new JsonWriter(); return true; } if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase)) { value = new SrtWriter(); return true; } if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)) { value = new SsaWriter(); return true; } if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase)) { value = new VttWriter(); return true; } if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase)) { value = new TtmlWriter(); return true; } value = null; return false; } private ISubtitleWriter GetWriter(string format) { if (TryGetWriter(format, out var writer)) { return writer; } throw new ArgumentException("Unsupported format: " + format); } /// /// Converts the text subtitle to SRT. /// /// The subtitle stream. /// The input mediaSource. /// The output path. /// The cancellation token. /// Task. private async Task ConvertTextSubtitleToSrt(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken) { using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!File.Exists(outputPath)) { await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); } } } /// /// Converts the text subtitle to SRT internal. /// /// The subtitle stream. /// The input mediaSource. /// The output path. /// The cancellation token. /// Task. /// /// The inputPath or outputPath is null. /// private async Task ConvertTextSubtitleToSrtInternal(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken) { var inputPath = subtitleStream.Path; ArgumentException.ThrowIfNullOrEmpty(inputPath); ArgumentException.ThrowIfNullOrEmpty(outputPath); Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath))); var encodingParam = await GetSubtitleFileCharacterSet(subtitleStream, subtitleStream.Language, mediaSource, cancellationToken).ConfigureAwait(false); // FFmpeg automatically convert character encoding when it is UTF-16 // If we specify character encoding, it rejects with "do not specify a character encoding" and "Unable to recode subtitle event" if ((inputPath.EndsWith(".smi", StringComparison.Ordinal) || inputPath.EndsWith(".sami", StringComparison.Ordinal)) && (encodingParam.Equals("UTF-16BE", StringComparison.OrdinalIgnoreCase) || encodingParam.Equals("UTF-16LE", StringComparison.OrdinalIgnoreCase))) { encodingParam = string.Empty; } else if (!string.IsNullOrEmpty(encodingParam)) { encodingParam = " -sub_charenc " + encodingParam; } int exitCode; using (var process = new Process { StartInfo = new ProcessStartInfo { CreateNoWindow = true, UseShellExecute = false, FileName = _mediaEncoder.EncoderPath, Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath), WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false }, EnableRaisingEvents = true }) { _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); try { process.Start(); } catch (Exception ex) { _logger.LogError(ex, "Error starting ffmpeg"); throw; } try { await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false); exitCode = process.ExitCode; } catch (OperationCanceledException) { process.Kill(true); exitCode = -1; } } var failed = false; if (exitCode == -1) { failed = true; if (File.Exists(outputPath)) { try { _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath); _fileSystem.DeleteFile(outputPath); } catch (IOException ex) { _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath); } } } else if (!File.Exists(outputPath)) { failed = true; } if (failed) { _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath); throw new FfmpegException( string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath)); } await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath); } private string GetTextSubtitleFormat(MediaStream subtitleStream) { if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)) { return subtitleStream.Codec; } else { return "srt"; } } private bool IsCodecCopyable(string codec) { return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase); } /// /// Extracts all text subtitles. /// /// The mediaSource. /// The cancellation token. /// Task. private async Task ExtractAllTextSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken) { var locks = new List>(); var extractableStreams = new List(); try { var subtitleStreams = mediaSource.MediaStreams .Where(stream => stream is { IsTextSubtitleStream: true, SupportsExternalStream: true, IsExternal: false }); foreach (var subtitleStream in subtitleStreams) { var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream)); var @lock = _semaphoreLocks.GetOrAdd(outputPath); await @lock.SemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); if (File.Exists(outputPath)) { @lock.Dispose(); continue; } locks.Add(@lock); extractableStreams.Add(subtitleStream); } if (extractableStreams.Count > 0) { await ExtractAllTextSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false); } } catch (Exception ex) { _logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path); } finally { foreach (var @lock in locks) { @lock.Dispose(); } } } private async Task ExtractAllTextSubtitlesInternal( MediaSourceInfo mediaSource, List subtitleStreams, CancellationToken cancellationToken) { var inputPath = mediaSource.Path; var outputPaths = new List(); var args = string.Format( CultureInfo.InvariantCulture, "-i \"{0}\" -copyts", inputPath); foreach (var subtitleStream in subtitleStreams) { var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream)); var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); if (streamIndex == -1) { _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream", inputPath, subtitleStream.Index); continue; } Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid.")); outputPaths.Add(outputPath); args += string.Format( CultureInfo.InvariantCulture, " -map 0:{0} -an -vn -c:s {1} \"{2}\"", streamIndex, outputCodec, outputPath); } int exitCode; using (var process = new Process { StartInfo = new ProcessStartInfo { CreateNoWindow = true, UseShellExecute = false, FileName = _mediaEncoder.EncoderPath, Arguments = args, WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false }, EnableRaisingEvents = true }) { _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); try { process.Start(); } catch (Exception ex) { _logger.LogError(ex, "Error starting ffmpeg"); throw; } try { await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false); exitCode = process.ExitCode; } catch (OperationCanceledException) { process.Kill(true); exitCode = -1; } } var failed = false; if (exitCode == -1) { failed = true; foreach (var outputPath in outputPaths) { try { _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath); _fileSystem.DeleteFile(outputPath); } catch (FileNotFoundException) { } catch (IOException ex) { _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath); } } } else { foreach (var outputPath in outputPaths) { if (!File.Exists(outputPath)) { _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); failed = true; continue; } if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase)) { await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); } _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); } } if (failed) { throw new FfmpegException( string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath)); } } /// /// Extracts the text subtitle. /// /// The mediaSource. /// The subtitle stream. /// The output codec. /// The output path. /// The cancellation token. /// Task. /// Must use inputPath list overload. private async Task ExtractTextSubtitle( MediaSourceInfo mediaSource, MediaStream subtitleStream, string outputCodec, string outputPath, CancellationToken cancellationToken) { using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!File.Exists(outputPath)) { var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource); if (subtitleStream.IsExternal) { args = _mediaEncoder.GetExternalSubtitleInputArgument(subtitleStream.Path); } await ExtractTextSubtitleInternal( args, subtitleStreamIndex, outputCodec, outputPath, cancellationToken).ConfigureAwait(false); } } } private async Task ExtractTextSubtitleInternal( string inputPath, int subtitleStreamIndex, string outputCodec, string outputPath, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(inputPath); ArgumentException.ThrowIfNullOrEmpty(outputPath); Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath))); var processArgs = string.Format( CultureInfo.InvariantCulture, "-i \"{0}\" -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"", inputPath, subtitleStreamIndex, outputCodec, outputPath); int exitCode; using (var process = new Process { StartInfo = new ProcessStartInfo { CreateNoWindow = true, UseShellExecute = false, FileName = _mediaEncoder.EncoderPath, Arguments = processArgs, WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false }, EnableRaisingEvents = true }) { _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); try { process.Start(); } catch (Exception ex) { _logger.LogError(ex, "Error starting ffmpeg"); throw; } try { await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false); exitCode = process.ExitCode; } catch (OperationCanceledException) { process.Kill(true); exitCode = -1; } } var failed = false; if (exitCode == -1) { failed = true; try { _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath); _fileSystem.DeleteFile(outputPath); } catch (FileNotFoundException) { } catch (IOException ex) { _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath); } } else if (!File.Exists(outputPath)) { failed = true; } if (failed) { _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); throw new FfmpegException( string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath)); } _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase)) { await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); } } /// /// Sets the ass font. /// /// The file. /// The token to monitor for cancellation requests. The default value is System.Threading.CancellationToken.None. /// Task. private async Task SetAssFont(string file, CancellationToken cancellationToken = default) { _logger.LogInformation("Setting ass font within {File}", file); string text; Encoding encoding; using (var fileStream = AsyncFile.OpenRead(file)) using (var reader = new StreamReader(fileStream, true)) { encoding = reader.CurrentEncoding; text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); } var newText = text.Replace(",Arial,", ",Arial Unicode MS,", StringComparison.Ordinal); if (!string.Equals(text, newText, StringComparison.Ordinal)) { var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); await using (fileStream.ConfigureAwait(false)) { var writer = new StreamWriter(fileStream, encoding); await using (writer.ConfigureAwait(false)) { await writer.WriteAsync(newText.AsMemory(), cancellationToken).ConfigureAwait(false); } } } } private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension) { if (mediaSource.Protocol == MediaProtocol.File) { var ticksParam = string.Empty; var date = _fileSystem.GetLastWriteTimeUtc(mediaSource.Path); ReadOnlySpan filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam).GetMD5() + outputSubtitleExtension; var prefix = filename.Slice(0, 1); return Path.Join(SubtitleCachePath, prefix, filename); } else { ReadOnlySpan filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5() + outputSubtitleExtension; var prefix = filename.Slice(0, 1); return Path.Join(SubtitleCachePath, prefix, filename); } } /// public async Task GetSubtitleFileCharacterSet(MediaStream subtitleStream, string language, MediaSourceInfo mediaSource, CancellationToken cancellationToken) { var subtitleCodec = subtitleStream.Codec; var path = subtitleStream.Path; if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec); await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken) .ConfigureAwait(false); } using (var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false)) { var charset = CharsetDetector.DetectFromStream(stream).Detected?.EncodingName ?? string.Empty; // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || path.EndsWith(".srt", StringComparison.Ordinal)) && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase) || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase))) { charset = string.Empty; } _logger.LogDebug("charset {0} detected for {Path}", charset, path); return charset; } } private async Task GetStream(string path, MediaProtocol protocol, CancellationToken cancellationToken) { switch (protocol) { case MediaProtocol.Http: { using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(new Uri(path), cancellationToken) .ConfigureAwait(false); return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); } case MediaProtocol.File: return AsyncFile.OpenRead(path); default: throw new ArgumentOutOfRangeException(nameof(protocol)); } } /// public void Dispose() { _semaphoreLocks.Dispose(); } #pragma warning disable CA1034 // Nested types should not be visible // Only public for the unit tests public readonly record struct SubtitleInfo { public string Path { get; init; } public MediaProtocol Protocol { get; init; } public string Format { get; init; } public bool IsExternal { get; init; } } } }