2020-07-22 04:34:51 -07:00
#pragma warning disable CS1591
2019-01-13 13:02:23 -07:00
using System ;
2024-01-18 08:38:47 -07:00
using System.Collections.Generic ;
2020-03-26 16:10:16 -07:00
using System.Diagnostics ;
2021-05-20 13:10:19 -07:00
using System.Diagnostics.CodeAnalysis ;
2018-12-14 02:40:55 -07:00
using System.Globalization ;
using System.IO ;
using System.Linq ;
2020-08-31 10:07:40 -07:00
using System.Net.Http ;
2018-12-14 02:40:55 -07:00
using System.Text ;
using System.Threading ;
using System.Threading.Tasks ;
2024-01-03 08:47:25 -07:00
using AsyncKeyedLock ;
2021-10-10 09:48:11 -07:00
using MediaBrowser.Common ;
2019-01-06 12:59:13 -07:00
using MediaBrowser.Common.Configuration ;
using MediaBrowser.Common.Extensions ;
using MediaBrowser.Common.Net ;
using MediaBrowser.Controller.Entities ;
using MediaBrowser.Controller.Library ;
using MediaBrowser.Controller.MediaEncoding ;
2018-12-14 02:40:55 -07:00
using MediaBrowser.Model.Dto ;
2019-01-06 12:59:13 -07:00
using MediaBrowser.Model.Entities ;
2019-01-13 12:26:04 -07:00
using MediaBrowser.Model.IO ;
2019-01-06 12:59:13 -07:00
using MediaBrowser.Model.MediaInfo ;
using Microsoft.Extensions.Logging ;
2019-01-16 12:50:40 -07:00
using UtfUnknown ;
2018-12-14 02:40:55 -07:00
namespace MediaBrowser.MediaEncoding.Subtitles
{
2024-01-03 08:47:25 -07:00
public sealed class SubtitleEncoder : ISubtitleEncoder , IDisposable
2018-12-14 02:40:55 -07:00
{
2020-06-05 17:15:56 -07:00
private readonly ILogger < SubtitleEncoder > _logger ;
2018-12-14 02:40:55 -07:00
private readonly IApplicationPaths _appPaths ;
private readonly IFileSystem _fileSystem ;
private readonly IMediaEncoder _mediaEncoder ;
2020-08-31 10:07:40 -07:00
private readonly IHttpClientFactory _httpClientFactory ;
2018-12-14 02:40:55 -07:00
private readonly IMediaSourceManager _mediaSourceManager ;
2022-08-01 11:25:42 -07:00
private readonly ISubtitleParser _subtitleParser ;
2018-12-14 02:40:55 -07:00
2020-08-04 08:08:09 -07:00
/// <summary>
/// The _semaphoreLocks.
/// </summary>
2024-01-03 08:47:25 -07:00
private readonly AsyncKeyedLocker < string > _semaphoreLocks = new ( o = >
{
o . PoolSize = 20 ;
o . PoolInitialFill = 1 ;
} ) ;
2020-08-04 08:08:09 -07:00
2019-01-17 15:55:05 -07:00
public SubtitleEncoder (
2019-10-26 13:53:53 -07:00
ILogger < SubtitleEncoder > logger ,
2019-01-04 10:46:52 -07:00
IApplicationPaths appPaths ,
IFileSystem fileSystem ,
IMediaEncoder mediaEncoder ,
2020-08-31 10:07:40 -07:00
IHttpClientFactory httpClientFactory ,
2022-08-01 11:25:42 -07:00
IMediaSourceManager mediaSourceManager ,
ISubtitleParser subtitleParser )
2018-12-14 02:40:55 -07:00
{
2019-10-26 13:53:53 -07:00
_logger = logger ;
2018-12-14 02:40:55 -07:00
_appPaths = appPaths ;
_fileSystem = fileSystem ;
_mediaEncoder = mediaEncoder ;
2020-08-31 10:07:40 -07:00
_httpClientFactory = httpClientFactory ;
2019-01-04 10:46:52 -07:00
_mediaSourceManager = mediaSourceManager ;
2022-08-01 11:25:42 -07:00
_subtitleParser = subtitleParser ;
2018-12-14 02:40:55 -07:00
}
2019-01-13 13:31:14 -07:00
private string SubtitleCachePath = > Path . Combine ( _appPaths . DataPath , "subtitles" ) ;
2018-12-14 02:40:55 -07:00
2023-11-14 12:21:34 -07:00
private MemoryStream ConvertSubtitles (
2019-10-26 13:53:53 -07:00
Stream stream ,
2018-12-14 02:40:55 -07:00
string inputFormat ,
string outputFormat ,
long startTimeTicks ,
2019-01-20 00:17:31 -07:00
long endTimeTicks ,
2018-12-14 02:40:55 -07:00
bool preserveOriginalTimestamps ,
CancellationToken cancellationToken )
{
var ms = new MemoryStream ( ) ;
try
{
2022-08-01 11:25:42 -07:00
var trackInfo = _subtitleParser . Parse ( stream , inputFormat ) ;
2018-12-14 02:40:55 -07:00
FilterEvents ( trackInfo , startTimeTicks , endTimeTicks , preserveOriginalTimestamps ) ;
var writer = GetWriter ( outputFormat ) ;
writer . Write ( trackInfo , ms , cancellationToken ) ;
ms . Position = 0 ;
}
catch
{
ms . Dispose ( ) ;
throw ;
}
return ms ;
}
2019-01-20 00:17:31 -07:00
private void FilterEvents ( SubtitleTrackInfo track , long startPositionTicks , long endTimeTicks , bool preserveTimestamps )
2018-12-14 02:40:55 -07:00
{
// 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 ( ) ;
2019-01-20 00:17:31 -07:00
if ( endTimeTicks > 0 )
2018-12-14 02:40:55 -07:00
{
track . TrackEvents = track . TrackEvents
2019-01-20 09:06:40 -07:00
. TakeWhile ( i = > i . StartPositionTicks < = endTimeTicks )
2018-12-14 02:40:55 -07:00
. ToArray ( ) ;
}
if ( ! preserveTimestamps )
{
foreach ( var trackEvent in track . TrackEvents )
{
trackEvent . EndPositionTicks - = startPositionTicks ;
trackEvent . StartPositionTicks - = startPositionTicks ;
}
}
}
async Task < Stream > ISubtitleEncoder . GetSubtitles ( BaseItem item , string mediaSourceId , int subtitleStreamIndex , string outputFormat , long startTimeTicks , long endTimeTicks , bool preserveOriginalTimestamps , CancellationToken cancellationToken )
{
2022-10-06 11:21:23 -07:00
ArgumentNullException . ThrowIfNull ( item ) ;
2020-06-15 14:43:52 -07:00
2018-12-14 02:40:55 -07:00
if ( string . IsNullOrWhiteSpace ( mediaSourceId ) )
{
2019-01-06 13:50:43 -07:00
throw new ArgumentNullException ( nameof ( mediaSourceId ) ) ;
2018-12-14 02:40:55 -07:00
}
2019-12-04 13:39:27 -07:00
var mediaSources = await _mediaSourceManager . GetPlaybackMediaSources ( item , null , true , false , cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
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 ) ;
2023-11-04 18:01:14 -07:00
var ( stream , inputFormat ) = await GetSubtitleStream ( mediaSource , subtitleStream , cancellationToken )
2018-12-14 02:40:55 -07:00
. 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 ) )
{
2023-11-04 18:01:14 -07:00
return stream ;
2018-12-14 02:40:55 -07:00
}
2023-11-04 18:01:14 -07:00
using ( stream )
2018-12-14 02:40:55 -07:00
{
2019-01-06 12:59:13 -07:00
return ConvertSubtitles ( stream , inputFormat , outputFormat , startTimeTicks , endTimeTicks , preserveOriginalTimestamps , cancellationToken ) ;
2018-12-14 02:40:55 -07:00
}
}
2021-12-24 14:18:24 -07:00
private async Task < ( Stream Stream , string Format ) > GetSubtitleStream (
2019-01-06 12:59:13 -07:00
MediaSourceInfo mediaSource ,
2018-12-14 02:40:55 -07:00
MediaStream subtitleStream ,
CancellationToken cancellationToken )
{
2021-02-21 08:53:20 -07:00
var fileInfo = await GetReadableFile ( mediaSource , subtitleStream , cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
2021-02-21 08:53:20 -07:00
var stream = await GetSubtitleStream ( fileInfo , cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
2019-01-06 12:59:13 -07:00
return ( stream , fileInfo . Format ) ;
2018-12-14 02:40:55 -07:00
}
2021-02-21 08:53:20 -07:00
private async Task < Stream > GetSubtitleStream ( SubtitleInfo fileInfo , CancellationToken cancellationToken )
2018-12-14 02:40:55 -07:00
{
2021-02-21 08:53:20 -07:00
if ( fileInfo . IsExternal )
2018-12-14 02:40:55 -07:00
{
2021-02-21 08:53:20 -07:00
using ( var stream = await GetStream ( fileInfo . Path , fileInfo . Protocol , cancellationToken ) . ConfigureAwait ( false ) )
2018-12-14 02:40:55 -07:00
{
2019-10-26 13:53:53 -07:00
var result = CharsetDetector . DetectFromStream ( stream ) . Detected ;
2020-03-12 09:18:49 -07:00
stream . Position = 0 ;
2019-10-26 13:53:53 -07:00
2022-12-05 07:01:13 -07:00
if ( result is not null )
2018-12-14 02:40:55 -07:00
{
2021-02-21 08:53:20 -07:00
_logger . LogDebug ( "charset {CharSet} detected for {Path}" , result . EncodingName , fileInfo . Path ) ;
2018-12-14 02:40:55 -07:00
2019-10-26 13:53:53 -07:00
using var reader = new StreamReader ( stream , result . Encoding ) ;
2022-10-13 09:10:55 -07:00
var text = await reader . ReadToEndAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
2019-10-26 13:53:53 -07:00
return new MemoryStream ( Encoding . UTF8 . GetBytes ( text ) ) ;
2018-12-14 02:40:55 -07:00
}
}
2020-03-12 09:18:49 -07:00
}
2020-03-23 09:39:12 -07:00
2021-06-12 13:20:35 -07:00
return AsyncFile . OpenRead ( fileInfo . Path ) ;
2018-12-14 02:40:55 -07:00
}
2021-09-18 06:08:17 -07:00
internal async Task < SubtitleInfo > GetReadableFile (
2020-09-23 23:41:42 -07:00
MediaSourceInfo mediaSource ,
2018-12-14 02:40:55 -07:00
MediaStream subtitleStream ,
CancellationToken cancellationToken )
{
2022-05-04 07:20:48 -07:00
if ( ! subtitleStream . IsExternal | | subtitleStream . Path . EndsWith ( ".mks" , StringComparison . OrdinalIgnoreCase ) )
2018-12-14 02:40:55 -07:00
{
2024-07-15 05:48:09 -07:00
await ExtractAllExtractableSubtitles ( mediaSource , cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
2024-07-15 05:48:09 -07:00
var outputFileExtension = GetExtractableSubtitleFileExtension ( subtitleStream ) ;
var outputFormat = GetExtractableSubtitleFormat ( subtitleStream ) ;
var outputPath = GetSubtitleCachePath ( mediaSource , subtitleStream . Index , "." + outputFileExtension ) ;
2018-12-14 02:40:55 -07:00
2023-01-11 17:22:01 -07:00
return new SubtitleInfo ( )
{
Path = outputPath ,
Protocol = MediaProtocol . File ,
Format = outputFormat ,
IsExternal = false
} ;
2018-12-14 02:40:55 -07:00
}
var currentFormat = ( Path . GetExtension ( subtitleStream . Path ) ? ? subtitleStream . Codec )
. TrimStart ( '.' ) ;
2024-07-15 05:48:09 -07:00
// Handle PGS subtitles as raw streams for the client to render
if ( MediaStream . IsPgsFormat ( currentFormat ) )
{
return new SubtitleInfo ( )
{
Path = subtitleStream . Path ,
Protocol = _mediaSourceManager . GetPathProtocol ( subtitleStream . Path ) ,
Format = "pgssub" ,
IsExternal = true
} ;
}
2022-08-01 11:25:42 -07:00
// Fallback to ffmpeg conversion
if ( ! _subtitleParser . SupportsFileExtension ( currentFormat ) )
2018-12-14 02:40:55 -07:00
{
2019-01-06 12:59:13 -07:00
// Convert
2021-02-21 08:53:20 -07:00
var outputPath = GetSubtitleCachePath ( mediaSource , subtitleStream . Index , ".srt" ) ;
2018-12-14 02:40:55 -07:00
2022-08-13 18:46:33 -07:00
await ConvertTextSubtitleToSrt ( subtitleStream , mediaSource , outputPath , cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
2023-01-11 17:22:01 -07:00
return new SubtitleInfo ( )
{
Path = outputPath ,
Protocol = MediaProtocol . File ,
Format = "srt" ,
IsExternal = true
} ;
2019-01-06 12:59:13 -07:00
}
2022-08-01 11:25:42 -07:00
// It's possible that the subtitleStream and mediaSource don't share the same protocol (e.g. .STRM file with local subs)
2023-01-11 17:22:01 -07:00
return new SubtitleInfo ( )
{
Path = subtitleStream . Path ,
Protocol = _mediaSourceManager . GetPathProtocol ( subtitleStream . Path ) ,
Format = currentFormat ,
IsExternal = true
} ;
2018-12-14 02:40:55 -07:00
}
2021-05-20 13:10:19 -07:00
private bool TryGetWriter ( string format , [ NotNullWhen ( true ) ] out ISubtitleWriter ? value )
2018-12-14 02:40:55 -07:00
{
2022-10-13 10:08:00 -07:00
ArgumentException . ThrowIfNullOrEmpty ( format ) ;
2022-06-20 06:57:57 -07:00
if ( string . Equals ( format , SubtitleFormat . ASS , StringComparison . OrdinalIgnoreCase ) )
{
value = new AssWriter ( ) ;
return true ;
}
2018-12-14 02:40:55 -07:00
if ( string . Equals ( format , "json" , StringComparison . OrdinalIgnoreCase ) )
{
2021-05-20 13:10:19 -07:00
value = new JsonWriter ( ) ;
return true ;
2018-12-14 02:40:55 -07:00
}
2020-06-15 14:43:52 -07:00
2022-06-23 06:13:35 -07:00
if ( string . Equals ( format , SubtitleFormat . SRT , StringComparison . OrdinalIgnoreCase ) | | string . Equals ( format , SubtitleFormat . SUBRIP , StringComparison . OrdinalIgnoreCase ) )
2018-12-14 02:40:55 -07:00
{
2021-05-20 13:10:19 -07:00
value = new SrtWriter ( ) ;
return true ;
2018-12-14 02:40:55 -07:00
}
2020-06-15 14:43:52 -07:00
2022-06-20 06:57:57 -07:00
if ( string . Equals ( format , SubtitleFormat . SSA , StringComparison . OrdinalIgnoreCase ) )
{
value = new SsaWriter ( ) ;
return true ;
}
2023-04-23 04:33:01 -07:00
if ( string . Equals ( format , SubtitleFormat . VTT , StringComparison . OrdinalIgnoreCase ) | | string . Equals ( format , SubtitleFormat . WEBVTT , StringComparison . OrdinalIgnoreCase ) )
2018-12-14 02:40:55 -07:00
{
2021-05-20 13:10:19 -07:00
value = new VttWriter ( ) ;
return true ;
2018-12-14 02:40:55 -07:00
}
2020-06-15 14:43:52 -07:00
2018-12-14 02:40:55 -07:00
if ( string . Equals ( format , SubtitleFormat . TTML , StringComparison . OrdinalIgnoreCase ) )
{
2021-05-20 13:10:19 -07:00
value = new TtmlWriter ( ) ;
return true ;
2018-12-14 02:40:55 -07:00
}
2021-05-20 13:10:19 -07:00
value = null ;
return false ;
2018-12-14 02:40:55 -07:00
}
private ISubtitleWriter GetWriter ( string format )
{
2021-05-20 13:10:19 -07:00
if ( TryGetWriter ( format , out var writer ) )
2018-12-14 02:40:55 -07:00
{
return writer ;
}
throw new ArgumentException ( "Unsupported format: " + format ) ;
}
/// <summary>
/// Converts the text subtitle to SRT.
/// </summary>
2022-08-13 18:46:33 -07:00
/// <param name="subtitleStream">The subtitle stream.</param>
2020-09-23 23:41:42 -07:00
/// <param name="mediaSource">The input mediaSource.</param>
2018-12-14 02:40:55 -07:00
/// <param name="outputPath">The output path.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
2022-08-13 18:46:33 -07:00
private async Task ConvertTextSubtitleToSrt ( MediaStream subtitleStream , MediaSourceInfo mediaSource , string outputPath , CancellationToken cancellationToken )
2018-12-14 02:40:55 -07:00
{
2024-01-03 08:47:25 -07:00
using ( await _semaphoreLocks . LockAsync ( outputPath , cancellationToken ) . ConfigureAwait ( false ) )
2018-12-14 02:40:55 -07:00
{
2019-01-26 14:59:53 -07:00
if ( ! File . Exists ( outputPath ) )
2018-12-14 02:40:55 -07:00
{
2022-08-13 18:46:33 -07:00
await ConvertTextSubtitleToSrtInternal ( subtitleStream , mediaSource , outputPath , cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
}
}
}
/// <summary>
/// Converts the text subtitle to SRT internal.
/// </summary>
2022-08-13 18:46:33 -07:00
/// <param name="subtitleStream">The subtitle stream.</param>
2020-09-23 23:41:42 -07:00
/// <param name="mediaSource">The input mediaSource.</param>
2018-12-14 02:40:55 -07:00
/// <param name="outputPath">The output path.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
2019-01-13 13:37:13 -07:00
/// <exception cref="ArgumentNullException">
2020-08-04 08:08:09 -07:00
/// The <c>inputPath</c> or <c>outputPath</c> is <c>null</c>.
2018-12-14 02:40:55 -07:00
/// </exception>
2022-08-13 18:46:33 -07:00
private async Task ConvertTextSubtitleToSrtInternal ( MediaStream subtitleStream , MediaSourceInfo mediaSource , string outputPath , CancellationToken cancellationToken )
2018-12-14 02:40:55 -07:00
{
2022-08-13 18:46:33 -07:00
var inputPath = subtitleStream . Path ;
2022-10-13 10:08:00 -07:00
ArgumentException . ThrowIfNullOrEmpty ( inputPath ) ;
2018-12-14 02:40:55 -07:00
2022-10-13 10:08:00 -07:00
ArgumentException . ThrowIfNullOrEmpty ( outputPath ) ;
2018-12-14 02:40:55 -07:00
2021-05-20 13:10:19 -07:00
Directory . CreateDirectory ( Path . GetDirectoryName ( outputPath ) ? ? throw new ArgumentException ( $"Provided path ({outputPath}) is not valid." , nameof ( outputPath ) ) ) ;
2018-12-14 02:40:55 -07:00
2022-08-13 18:46:33 -07:00
var encodingParam = await GetSubtitleFileCharacterSet ( subtitleStream , subtitleStream . Language , mediaSource , cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
2019-07-14 07:05:45 -07:00
// 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"
2020-08-20 03:16:24 -07:00
if ( ( inputPath . EndsWith ( ".smi" , StringComparison . Ordinal ) | | inputPath . EndsWith ( ".sami" , StringComparison . Ordinal ) ) & &
2020-04-15 10:30:23 -07:00
( encodingParam . Equals ( "UTF-16BE" , StringComparison . OrdinalIgnoreCase ) | |
encodingParam . Equals ( "UTF-16LE" , StringComparison . OrdinalIgnoreCase ) ) )
2019-07-14 07:05:45 -07:00
{
2020-08-04 08:08:09 -07:00
encodingParam = string . Empty ;
2019-07-14 07:05:45 -07:00
}
else if ( ! string . IsNullOrEmpty ( encodingParam ) )
2018-12-14 02:40:55 -07:00
{
encodingParam = " -sub_charenc " + encodingParam ;
}
2020-03-26 17:53:08 -07:00
int exitCode ;
2018-12-14 02:40:55 -07:00
2020-04-11 10:25:50 -07:00
using ( var process = new Process
2022-08-13 18:46:33 -07:00
{
StartInfo = new ProcessStartInfo
2020-04-11 10:25:50 -07:00
{
2022-08-13 18:46:33 -07:00
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
} )
2018-12-14 02:40:55 -07:00
{
2020-03-26 17:53:08 -07:00
_logger . LogInformation ( "{0} {1}" , process . StartInfo . FileName , process . StartInfo . Arguments ) ;
2018-12-14 02:40:55 -07:00
try
{
2020-03-26 17:53:08 -07:00
process . Start ( ) ;
2018-12-14 02:40:55 -07:00
}
catch ( Exception ex )
{
2020-03-26 17:53:08 -07:00
_logger . LogError ( ex , "Error starting ffmpeg" ) ;
throw ;
2018-12-14 02:40:55 -07:00
}
2023-10-09 15:18:50 -07:00
try
2020-03-26 17:53:08 -07:00
{
2023-10-09 15:18:50 -07:00
await process . WaitForExitAsync ( TimeSpan . FromMinutes ( 30 ) ) . ConfigureAwait ( false ) ;
exitCode = process . ExitCode ;
}
catch ( OperationCanceledException )
{
process . Kill ( true ) ;
exitCode = - 1 ;
2020-03-26 17:53:08 -07:00
}
}
2018-12-14 02:40:55 -07:00
var failed = false ;
if ( exitCode = = - 1 )
{
failed = true ;
2019-01-26 14:59:53 -07:00
if ( File . Exists ( outputPath ) )
2018-12-14 02:40:55 -07:00
{
try
{
2023-04-06 10:05:05 -07:00
_logger . LogInformation ( "Deleting converted subtitle due to failure: {Path}" , outputPath ) ;
2018-12-14 02:40:55 -07:00
_fileSystem . DeleteFile ( outputPath ) ;
}
catch ( IOException ex )
{
2019-01-06 12:59:13 -07:00
_logger . LogError ( ex , "Error deleting converted subtitle {Path}" , outputPath ) ;
2018-12-14 02:40:55 -07:00
}
}
}
2019-01-26 14:59:53 -07:00
else if ( ! File . Exists ( outputPath ) )
2018-12-14 02:40:55 -07:00
{
failed = true ;
}
if ( failed )
{
2019-09-20 03:42:08 -07:00
_logger . LogError ( "ffmpeg subtitle conversion failed for {Path}" , inputPath ) ;
2018-12-14 02:40:55 -07:00
2021-03-08 19:04:47 -07:00
throw new FfmpegException (
2019-09-20 03:42:08 -07:00
string . Format ( CultureInfo . InvariantCulture , "ffmpeg subtitle conversion failed for {0}" , inputPath ) ) ;
2018-12-14 02:40:55 -07:00
}
2019-09-20 03:42:08 -07:00
2020-08-20 03:16:24 -07:00
await SetAssFont ( outputPath , cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
2019-01-06 12:59:13 -07:00
_logger . LogInformation ( "ffmpeg subtitle conversion succeeded for {Path}" , inputPath ) ;
2018-12-14 02:40:55 -07:00
}
2024-07-15 05:48:09 -07:00
private string GetExtractableSubtitleFormat ( MediaStream subtitleStream )
2024-01-18 08:38:47 -07:00
{
if ( string . Equals ( subtitleStream . Codec , "ass" , StringComparison . OrdinalIgnoreCase )
2024-07-15 05:48:09 -07:00
| | string . Equals ( subtitleStream . Codec , "ssa" , StringComparison . OrdinalIgnoreCase )
| | string . Equals ( subtitleStream . Codec , "pgssub" , StringComparison . OrdinalIgnoreCase ) )
2024-01-18 08:38:47 -07:00
{
return subtitleStream . Codec ;
}
else
{
return "srt" ;
}
}
2024-07-15 05:48:09 -07:00
private string GetExtractableSubtitleFileExtension ( MediaStream subtitleStream )
{
// Using .pgssub as file extension is not allowed by ffmpeg. The file extension for pgs subtitles is .sup.
if ( string . Equals ( subtitleStream . Codec , "pgssub" , StringComparison . OrdinalIgnoreCase ) )
{
return "sup" ;
}
else
{
return GetExtractableSubtitleFormat ( subtitleStream ) ;
}
}
2024-01-18 08:38:47 -07:00
private bool IsCodecCopyable ( string codec )
{
return string . Equals ( codec , "ass" , StringComparison . OrdinalIgnoreCase )
| | string . Equals ( codec , "ssa" , StringComparison . OrdinalIgnoreCase )
| | string . Equals ( codec , "srt" , StringComparison . OrdinalIgnoreCase )
2024-07-15 05:48:09 -07:00
| | string . Equals ( codec , "subrip" , StringComparison . OrdinalIgnoreCase )
| | string . Equals ( codec , "pgssub" , StringComparison . OrdinalIgnoreCase ) ;
2024-01-18 08:38:47 -07:00
}
/// <summary>
2024-07-15 05:48:09 -07:00
/// Extracts all extractable subtitles (text and pgs).
2024-01-18 08:38:47 -07:00
/// </summary>
/// <param name="mediaSource">The mediaSource.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
2024-07-15 05:48:09 -07:00
private async Task ExtractAllExtractableSubtitles ( MediaSourceInfo mediaSource , CancellationToken cancellationToken )
2024-01-18 08:38:47 -07:00
{
2024-05-05 18:21:54 -07:00
var locks = new List < IDisposable > ( ) ;
2024-01-18 09:00:00 -07:00
var extractableStreams = new List < MediaStream > ( ) ;
2024-01-18 08:38:47 -07:00
try
{
var subtitleStreams = mediaSource . MediaStreams
2024-07-15 05:48:09 -07:00
. Where ( stream = > stream is { IsExtractableSubtitleStream : true , SupportsExternalStream : true , IsExternal : false } ) ;
2024-01-18 08:38:47 -07:00
foreach ( var subtitleStream in subtitleStreams )
{
2024-07-15 05:48:09 -07:00
var outputPath = GetSubtitleCachePath ( mediaSource , subtitleStream . Index , "." + GetExtractableSubtitleFileExtension ( subtitleStream ) ) ;
2024-01-18 08:38:47 -07:00
2024-05-05 18:21:54 -07:00
var releaser = await _semaphoreLocks . LockAsync ( outputPath , cancellationToken ) . ConfigureAwait ( false ) ;
2024-01-18 08:38:47 -07:00
if ( File . Exists ( outputPath ) )
{
2024-05-05 18:21:54 -07:00
releaser . Dispose ( ) ;
2024-01-18 08:38:47 -07:00
continue ;
}
2024-05-05 18:21:54 -07:00
locks . Add ( releaser ) ;
2024-01-18 08:38:47 -07:00
extractableStreams . Add ( subtitleStream ) ;
}
if ( extractableStreams . Count > 0 )
2024-01-18 09:00:00 -07:00
{
2024-07-15 05:48:09 -07:00
await ExtractAllExtractableSubtitlesInternal ( mediaSource , extractableStreams , cancellationToken ) . ConfigureAwait ( false ) ;
2024-01-18 09:00:00 -07:00
}
2024-01-18 08:38:47 -07:00
}
catch ( Exception ex )
{
_logger . LogWarning ( ex , "Unable to get streams for File:{File}" , mediaSource . Path ) ;
}
finally
{
2024-05-05 18:21:54 -07:00
locks . ForEach ( x = > x . Dispose ( ) ) ;
2024-01-18 08:38:47 -07:00
}
}
2024-07-15 05:48:09 -07:00
private async Task ExtractAllExtractableSubtitlesInternal (
2024-01-18 08:38:47 -07:00
MediaSourceInfo mediaSource ,
List < MediaStream > subtitleStreams ,
CancellationToken cancellationToken )
{
var inputPath = mediaSource . Path ;
2024-01-18 09:00:00 -07:00
var outputPaths = new List < string > ( ) ;
2024-01-18 08:38:47 -07:00
var args = string . Format (
CultureInfo . InvariantCulture ,
2024-02-10 22:51:09 -07:00
"-i \"{0}\" -copyts" ,
2024-01-18 08:38:47 -07:00
inputPath ) ;
foreach ( var subtitleStream in subtitleStreams )
{
2024-07-15 05:48:09 -07:00
var outputPath = GetSubtitleCachePath ( mediaSource , subtitleStream . Index , "." + GetExtractableSubtitleFileExtension ( subtitleStream ) ) ;
2024-01-18 08:38:47 -07:00
var outputCodec = IsCodecCopyable ( subtitleStream . Codec ) ? "copy" : "srt" ;
2024-01-18 09:00:00 -07:00
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 ;
}
2024-01-18 08:38:47 -07:00
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}\"" ,
2024-01-18 09:00:00 -07:00
streamIndex ,
2024-01-18 08:38:47 -07:00
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 ;
2024-01-18 09:00:00 -07:00
continue ;
2024-01-18 08:38:47 -07:00
}
2024-01-18 09:00:00 -07:00
if ( outputPath . EndsWith ( "ass" , StringComparison . OrdinalIgnoreCase ) )
{
await SetAssFont ( outputPath , cancellationToken ) . ConfigureAwait ( false ) ;
2024-01-18 08:38:47 -07:00
}
2024-01-18 09:00:00 -07:00
_logger . LogInformation ( "ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}" , inputPath , outputPath ) ;
2024-01-18 08:38:47 -07:00
}
}
if ( failed )
{
throw new FfmpegException (
string . Format ( CultureInfo . InvariantCulture , "ffmpeg subtitle extraction failed for {0}" , inputPath ) ) ;
}
}
2018-12-14 02:40:55 -07:00
/// <summary>
/// Extracts the text subtitle.
/// </summary>
2020-09-23 23:41:42 -07:00
/// <param name="mediaSource">The mediaSource.</param>
2022-05-04 07:20:48 -07:00
/// <param name="subtitleStream">The subtitle stream.</param>
2018-12-14 02:40:55 -07:00
/// <param name="outputCodec">The output codec.</param>
/// <param name="outputPath">The output path.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
2020-08-04 08:08:09 -07:00
/// <exception cref="ArgumentException">Must use inputPath list overload.</exception>
2019-01-06 12:59:13 -07:00
private async Task ExtractTextSubtitle (
2020-09-23 23:41:42 -07:00
MediaSourceInfo mediaSource ,
2022-05-04 07:20:48 -07:00
MediaStream subtitleStream ,
2019-01-06 12:59:13 -07:00
string outputCodec ,
string outputPath ,
CancellationToken cancellationToken )
2018-12-14 02:40:55 -07:00
{
2024-01-03 08:47:25 -07:00
using ( await _semaphoreLocks . LockAsync ( outputPath , cancellationToken ) . ConfigureAwait ( false ) )
2018-12-14 02:40:55 -07:00
{
2019-01-26 14:59:53 -07:00
if ( ! File . Exists ( outputPath ) )
2018-12-14 02:40:55 -07:00
{
2024-01-03 08:47:25 -07:00
var subtitleStreamIndex = EncodingHelper . FindIndex ( mediaSource . MediaStreams , subtitleStream ) ;
2022-05-04 07:20:48 -07:00
var args = _mediaEncoder . GetInputArgument ( mediaSource . Path , mediaSource ) ;
if ( subtitleStream . IsExternal )
{
args = _mediaEncoder . GetExternalSubtitleInputArgument ( subtitleStream . Path ) ;
}
2019-10-26 13:53:53 -07:00
await ExtractTextSubtitleInternal (
2022-05-04 07:20:48 -07:00
args ,
2019-10-26 13:53:53 -07:00
subtitleStreamIndex ,
outputCodec ,
outputPath ,
cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
}
}
}
2019-01-06 12:59:13 -07:00
private async Task ExtractTextSubtitleInternal (
string inputPath ,
int subtitleStreamIndex ,
string outputCodec ,
string outputPath ,
CancellationToken cancellationToken )
2018-12-14 02:40:55 -07:00
{
2022-10-13 10:08:00 -07:00
ArgumentException . ThrowIfNullOrEmpty ( inputPath ) ;
2018-12-14 02:40:55 -07:00
2022-10-13 10:08:00 -07:00
ArgumentException . ThrowIfNullOrEmpty ( outputPath ) ;
2018-12-14 02:40:55 -07:00
2021-05-20 13:10:19 -07:00
Directory . CreateDirectory ( Path . GetDirectoryName ( outputPath ) ? ? throw new ArgumentException ( $"Provided path ({outputPath}) is not valid." , nameof ( outputPath ) ) ) ;
2018-12-14 02:40:55 -07:00
2019-10-26 13:53:53 -07:00
var processArgs = string . Format (
CultureInfo . InvariantCulture ,
2024-02-10 22:51:09 -07:00
"-i \"{0}\" -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"" ,
2019-10-26 13:53:53 -07:00
inputPath ,
subtitleStreamIndex ,
outputCodec ,
outputPath ) ;
2018-12-14 02:40:55 -07:00
2020-03-26 17:53:08 -07:00
int exitCode ;
2018-12-14 02:40:55 -07:00
2020-04-11 10:25:50 -07:00
using ( var process = new Process
2022-08-13 18:46:33 -07:00
{
StartInfo = new ProcessStartInfo
2020-04-11 10:25:50 -07:00
{
2022-08-13 18:46:33 -07:00
CreateNoWindow = true ,
UseShellExecute = false ,
FileName = _mediaEncoder . EncoderPath ,
Arguments = processArgs ,
WindowStyle = ProcessWindowStyle . Hidden ,
ErrorDialog = false
} ,
EnableRaisingEvents = true
} )
2018-12-14 02:40:55 -07:00
{
2020-03-26 17:53:08 -07:00
_logger . LogInformation ( "{File} {Arguments}" , process . StartInfo . FileName , process . StartInfo . Arguments ) ;
2018-12-14 02:40:55 -07:00
try
{
2020-03-26 17:53:08 -07:00
process . Start ( ) ;
2018-12-14 02:40:55 -07:00
}
catch ( Exception ex )
{
2020-03-26 17:53:08 -07:00
_logger . LogError ( ex , "Error starting ffmpeg" ) ;
throw ;
2018-12-14 02:40:55 -07:00
}
2023-10-09 15:18:50 -07:00
try
2020-03-26 17:53:08 -07:00
{
2023-10-09 15:18:50 -07:00
await process . WaitForExitAsync ( TimeSpan . FromMinutes ( 30 ) ) . ConfigureAwait ( false ) ;
exitCode = process . ExitCode ;
}
catch ( OperationCanceledException )
{
process . Kill ( true ) ;
exitCode = - 1 ;
2020-03-26 17:53:08 -07:00
}
}
2018-12-14 02:40:55 -07:00
var failed = false ;
if ( exitCode = = - 1 )
{
failed = true ;
try
{
2019-01-06 12:59:13 -07:00
_logger . LogWarning ( "Deleting extracted subtitle due to failure: {Path}" , outputPath ) ;
2018-12-14 02:40:55 -07:00
_fileSystem . DeleteFile ( outputPath ) ;
}
catch ( FileNotFoundException )
{
}
catch ( IOException ex )
{
2019-01-06 12:59:13 -07:00
_logger . LogError ( ex , "Error deleting extracted subtitle {Path}" , outputPath ) ;
2018-12-14 02:40:55 -07:00
}
}
2019-01-26 14:59:53 -07:00
else if ( ! File . Exists ( outputPath ) )
2018-12-14 02:40:55 -07:00
{
failed = true ;
}
if ( failed )
{
2021-11-09 14:29:33 -07:00
_logger . LogError ( "ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}" , inputPath , outputPath ) ;
2018-12-14 02:40:55 -07:00
2021-11-09 14:29:33 -07:00
throw new FfmpegException (
string . Format ( CultureInfo . InvariantCulture , "ffmpeg subtitle extraction failed for {0} to {1}" , inputPath , outputPath ) ) ;
2018-12-14 02:40:55 -07:00
}
2023-04-06 10:38:34 -07:00
_logger . LogInformation ( "ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}" , inputPath , outputPath ) ;
2018-12-14 02:40:55 -07:00
if ( string . Equals ( outputCodec , "ass" , StringComparison . OrdinalIgnoreCase ) )
{
2020-08-20 03:16:24 -07:00
await SetAssFont ( outputPath , cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
}
}
/// <summary>
/// Sets the ass font.
/// </summary>
/// <param name="file">The file.</param>
2020-08-20 03:16:24 -07:00
/// <param name="cancellationToken">The token to monitor for cancellation requests. The default value is <c>System.Threading.CancellationToken.None</c>.</param>
2018-12-14 02:40:55 -07:00
/// <returns>Task.</returns>
2020-08-20 03:16:24 -07:00
private async Task SetAssFont ( string file , CancellationToken cancellationToken = default )
2018-12-14 02:40:55 -07:00
{
2019-01-06 12:59:13 -07:00
_logger . LogInformation ( "Setting ass font within {File}" , file ) ;
2018-12-14 02:40:55 -07:00
string text ;
Encoding encoding ;
2021-06-12 13:20:35 -07:00
using ( var fileStream = AsyncFile . OpenRead ( file ) )
2019-01-06 12:59:13 -07:00
using ( var reader = new StreamReader ( fileStream , true ) )
2018-12-14 02:40:55 -07:00
{
2019-01-06 12:59:13 -07:00
encoding = reader . CurrentEncoding ;
2018-12-14 02:40:55 -07:00
2022-10-13 09:10:55 -07:00
text = await reader . ReadToEndAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
2018-12-14 02:40:55 -07:00
}
2020-08-20 03:16:24 -07:00
var newText = text . Replace ( ",Arial," , ",Arial Unicode MS," , StringComparison . Ordinal ) ;
2018-12-14 02:40:55 -07:00
2020-08-20 03:16:24 -07:00
if ( ! string . Equals ( text , newText , StringComparison . Ordinal ) )
2018-12-14 02:40:55 -07:00
{
2022-01-22 15:36:42 -07:00
var fileStream = new FileStream ( file , FileMode . Create , FileAccess . Write , FileShare . None , IODefaults . FileStreamBufferSize , FileOptions . Asynchronous ) ;
await using ( fileStream . ConfigureAwait ( false ) )
2018-12-14 02:40:55 -07:00
{
2022-06-04 16:23:40 -07:00
var writer = new StreamWriter ( fileStream , encoding ) ;
await using ( writer . ConfigureAwait ( false ) )
{
await writer . WriteAsync ( newText . AsMemory ( ) , cancellationToken ) . ConfigureAwait ( false ) ;
}
2018-12-14 02:40:55 -07:00
}
}
}
2021-02-21 08:53:20 -07:00
private string GetSubtitleCachePath ( MediaSourceInfo mediaSource , int subtitleStreamIndex , string outputSubtitleExtension )
2018-12-14 02:40:55 -07:00
{
2020-09-23 23:41:42 -07:00
if ( mediaSource . Protocol = = MediaProtocol . File )
2018-12-14 02:40:55 -07:00
{
var ticksParam = string . Empty ;
2021-02-21 08:53:20 -07:00
var date = _fileSystem . GetLastWriteTimeUtc ( mediaSource . Path ) ;
2018-12-14 02:40:55 -07:00
2021-02-21 08:53:20 -07:00
ReadOnlySpan < char > filename = ( mediaSource . Path + "_" + subtitleStreamIndex . ToString ( CultureInfo . InvariantCulture ) + "_" + date . Ticks . ToString ( CultureInfo . InvariantCulture ) + ticksParam ) . GetMD5 ( ) + outputSubtitleExtension ;
2018-12-14 02:40:55 -07:00
2020-07-29 04:17:01 -07:00
var prefix = filename . Slice ( 0 , 1 ) ;
2018-12-14 02:40:55 -07:00
2020-07-29 04:17:01 -07:00
return Path . Join ( SubtitleCachePath , prefix , filename ) ;
2018-12-14 02:40:55 -07:00
}
else
{
2021-02-21 08:53:20 -07:00
ReadOnlySpan < char > filename = ( mediaSource . Path + "_" + subtitleStreamIndex . ToString ( CultureInfo . InvariantCulture ) ) . GetMD5 ( ) + outputSubtitleExtension ;
2018-12-14 02:40:55 -07:00
2020-07-29 04:17:01 -07:00
var prefix = filename . Slice ( 0 , 1 ) ;
2018-12-14 02:40:55 -07:00
2020-07-29 04:17:01 -07:00
return Path . Join ( SubtitleCachePath , prefix , filename ) ;
2018-12-14 02:40:55 -07:00
}
}
2019-10-26 13:53:53 -07:00
/// <inheritdoc />
2022-08-13 18:46:33 -07:00
public async Task < string > GetSubtitleFileCharacterSet ( MediaStream subtitleStream , string language , MediaSourceInfo mediaSource , CancellationToken cancellationToken )
2018-12-14 02:40:55 -07:00
{
2022-08-13 18:46:33 -07:00
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 ) )
2019-10-26 13:53:53 -07:00
{
2021-05-20 13:10:19 -07:00
var charset = CharsetDetector . DetectFromStream ( stream ) . Detected ? . EncodingName ? ? string . Empty ;
2018-12-14 02:40:55 -07:00
2020-03-29 23:46:05 -07:00
// UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
2020-08-20 03:16:24 -07:00
if ( ( path . EndsWith ( ".ass" , StringComparison . Ordinal ) | | path . EndsWith ( ".ssa" , StringComparison . Ordinal ) | | path . EndsWith ( ".srt" , StringComparison . Ordinal ) )
2020-03-29 23:46:05 -07:00
& & ( string . Equals ( charset , "utf-16le" , StringComparison . OrdinalIgnoreCase )
| | string . Equals ( charset , "utf-16be" , StringComparison . OrdinalIgnoreCase ) ) )
{
2020-08-04 08:08:09 -07:00
charset = string . Empty ;
2020-03-29 23:46:05 -07:00
}
2021-05-20 13:10:19 -07:00
_logger . LogDebug ( "charset {0} detected for {Path}" , charset , path ) ;
2018-12-14 02:40:55 -07:00
2019-10-26 13:53:53 -07:00
return charset ;
}
2018-12-14 02:40:55 -07:00
}
2020-08-31 10:07:40 -07:00
private async Task < Stream > GetStream ( string path , MediaProtocol protocol , CancellationToken cancellationToken )
2018-12-14 02:40:55 -07:00
{
2019-10-26 13:53:53 -07:00
switch ( protocol )
2018-12-14 02:40:55 -07:00
{
2019-10-26 13:53:53 -07:00
case MediaProtocol . Http :
2022-08-13 18:46:33 -07:00
{
using var response = await _httpClientFactory . CreateClient ( NamedClient . Default )
. GetAsync ( new Uri ( path ) , cancellationToken )
. ConfigureAwait ( false ) ;
return await response . Content . ReadAsStreamAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
}
2018-12-14 02:40:55 -07:00
2020-03-24 08:12:06 -07:00
case MediaProtocol . File :
2021-06-12 13:20:35 -07:00
return AsyncFile . OpenRead ( path ) ;
2020-03-24 08:12:06 -07:00
default :
throw new ArgumentOutOfRangeException ( nameof ( protocol ) ) ;
2019-10-26 13:53:53 -07:00
}
2018-12-14 02:40:55 -07:00
}
2020-08-04 08:08:09 -07:00
2024-01-03 08:47:25 -07:00
/// <inheritdoc />
public void Dispose ( )
{
_semaphoreLocks . Dispose ( ) ;
}
2023-01-11 17:22:01 -07:00
#pragma warning disable CA1034 // Nested types should not be visible
// Only public for the unit tests
public readonly record struct SubtitleInfo
2020-08-04 08:08:09 -07:00
{
2023-01-11 17:22:01 -07:00
public string Path { get ; init ; }
2020-08-04 08:08:09 -07:00
2023-01-11 17:22:01 -07:00
public MediaProtocol Protocol { get ; init ; }
2020-08-04 08:08:09 -07:00
2023-01-11 17:22:01 -07:00
public string Format { get ; init ; }
2020-08-04 08:08:09 -07:00
2023-01-11 17:22:01 -07:00
public bool IsExternal { get ; init ; }
2020-08-04 08:08:09 -07:00
}
2018-12-14 02:40:55 -07:00
}
2018-12-14 16:48:06 -07:00
}