SubtitleEncoder.cs 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006
  1. #pragma warning disable CS1591
  2. using System;
  3. using System.Collections.Generic;
  4. using System.Diagnostics;
  5. using System.Diagnostics.CodeAnalysis;
  6. using System.Globalization;
  7. using System.IO;
  8. using System.Linq;
  9. using System.Net.Http;
  10. using System.Text;
  11. using System.Threading;
  12. using System.Threading.Tasks;
  13. using AsyncKeyedLock;
  14. using MediaBrowser.Common;
  15. using MediaBrowser.Common.Extensions;
  16. using MediaBrowser.Common.Net;
  17. using MediaBrowser.Controller.Entities;
  18. using MediaBrowser.Controller.IO;
  19. using MediaBrowser.Controller.Library;
  20. using MediaBrowser.Controller.MediaEncoding;
  21. using MediaBrowser.Model.Dto;
  22. using MediaBrowser.Model.Entities;
  23. using MediaBrowser.Model.IO;
  24. using MediaBrowser.Model.MediaInfo;
  25. using Microsoft.Extensions.Logging;
  26. using UtfUnknown;
  27. namespace MediaBrowser.MediaEncoding.Subtitles
  28. {
  29. public sealed class SubtitleEncoder : ISubtitleEncoder, IDisposable
  30. {
  31. private readonly ILogger<SubtitleEncoder> _logger;
  32. private readonly IFileSystem _fileSystem;
  33. private readonly IMediaEncoder _mediaEncoder;
  34. private readonly IHttpClientFactory _httpClientFactory;
  35. private readonly IMediaSourceManager _mediaSourceManager;
  36. private readonly ISubtitleParser _subtitleParser;
  37. private readonly IPathManager _pathManager;
  38. /// <summary>
  39. /// The _semaphoreLocks.
  40. /// </summary>
  41. private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
  42. {
  43. o.PoolSize = 20;
  44. o.PoolInitialFill = 1;
  45. });
  46. public SubtitleEncoder(
  47. ILogger<SubtitleEncoder> logger,
  48. IFileSystem fileSystem,
  49. IMediaEncoder mediaEncoder,
  50. IHttpClientFactory httpClientFactory,
  51. IMediaSourceManager mediaSourceManager,
  52. ISubtitleParser subtitleParser,
  53. IPathManager pathManager)
  54. {
  55. _logger = logger;
  56. _fileSystem = fileSystem;
  57. _mediaEncoder = mediaEncoder;
  58. _httpClientFactory = httpClientFactory;
  59. _mediaSourceManager = mediaSourceManager;
  60. _subtitleParser = subtitleParser;
  61. _pathManager = pathManager;
  62. }
  63. private MemoryStream ConvertSubtitles(
  64. Stream stream,
  65. string inputFormat,
  66. string outputFormat,
  67. long startTimeTicks,
  68. long endTimeTicks,
  69. bool preserveOriginalTimestamps,
  70. CancellationToken cancellationToken)
  71. {
  72. var ms = new MemoryStream();
  73. try
  74. {
  75. var trackInfo = _subtitleParser.Parse(stream, inputFormat);
  76. FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
  77. var writer = GetWriter(outputFormat);
  78. writer.Write(trackInfo, ms, cancellationToken);
  79. ms.Position = 0;
  80. }
  81. catch
  82. {
  83. ms.Dispose();
  84. throw;
  85. }
  86. return ms;
  87. }
  88. private void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps)
  89. {
  90. // Drop subs that are earlier than what we're looking for
  91. track.TrackEvents = track.TrackEvents
  92. .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 || (i.EndPositionTicks - startPositionTicks) < 0)
  93. .ToArray();
  94. if (endTimeTicks > 0)
  95. {
  96. track.TrackEvents = track.TrackEvents
  97. .TakeWhile(i => i.StartPositionTicks <= endTimeTicks)
  98. .ToArray();
  99. }
  100. if (!preserveTimestamps)
  101. {
  102. foreach (var trackEvent in track.TrackEvents)
  103. {
  104. trackEvent.EndPositionTicks -= startPositionTicks;
  105. trackEvent.StartPositionTicks -= startPositionTicks;
  106. }
  107. }
  108. }
  109. async Task<Stream> ISubtitleEncoder.GetSubtitles(BaseItem item, string mediaSourceId, int subtitleStreamIndex, string outputFormat, long startTimeTicks, long endTimeTicks, bool preserveOriginalTimestamps, CancellationToken cancellationToken)
  110. {
  111. ArgumentNullException.ThrowIfNull(item);
  112. if (string.IsNullOrWhiteSpace(mediaSourceId))
  113. {
  114. throw new ArgumentNullException(nameof(mediaSourceId));
  115. }
  116. var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationToken).ConfigureAwait(false);
  117. var mediaSource = mediaSources
  118. .First(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
  119. var subtitleStream = mediaSource.MediaStreams
  120. .First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex);
  121. var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken)
  122. .ConfigureAwait(false);
  123. // Return the original if the same format is being requested
  124. // Character encoding was already handled in GetSubtitleStream
  125. if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase))
  126. {
  127. return stream;
  128. }
  129. using (stream)
  130. {
  131. return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken);
  132. }
  133. }
  134. private async Task<(Stream Stream, string Format)> GetSubtitleStream(
  135. MediaSourceInfo mediaSource,
  136. MediaStream subtitleStream,
  137. CancellationToken cancellationToken)
  138. {
  139. var fileInfo = await GetReadableFile(mediaSource, subtitleStream, cancellationToken).ConfigureAwait(false);
  140. var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false);
  141. return (stream, fileInfo.Format);
  142. }
  143. private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
  144. {
  145. if (fileInfo.IsExternal)
  146. {
  147. var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
  148. await using (stream.ConfigureAwait(false))
  149. {
  150. var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
  151. var detected = result.Detected;
  152. stream.Position = 0;
  153. if (detected is not null)
  154. {
  155. _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
  156. using var reader = new StreamReader(stream, detected.Encoding);
  157. var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
  158. return new MemoryStream(Encoding.UTF8.GetBytes(text));
  159. }
  160. }
  161. }
  162. return AsyncFile.OpenRead(fileInfo.Path);
  163. }
  164. internal async Task<SubtitleInfo> GetReadableFile(
  165. MediaSourceInfo mediaSource,
  166. MediaStream subtitleStream,
  167. CancellationToken cancellationToken)
  168. {
  169. if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
  170. {
  171. await ExtractAllExtractableSubtitles(mediaSource, cancellationToken).ConfigureAwait(false);
  172. var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream);
  173. var outputFormat = GetExtractableSubtitleFormat(subtitleStream);
  174. var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension);
  175. return new SubtitleInfo()
  176. {
  177. Path = outputPath,
  178. Protocol = MediaProtocol.File,
  179. Format = outputFormat,
  180. IsExternal = false
  181. };
  182. }
  183. var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec)
  184. .TrimStart('.');
  185. // Handle PGS subtitles as raw streams for the client to render
  186. if (MediaStream.IsPgsFormat(currentFormat))
  187. {
  188. return new SubtitleInfo()
  189. {
  190. Path = subtitleStream.Path,
  191. Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
  192. Format = "pgssub",
  193. IsExternal = true
  194. };
  195. }
  196. // Fallback to ffmpeg conversion
  197. if (!_subtitleParser.SupportsFileExtension(currentFormat))
  198. {
  199. // Convert
  200. var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt");
  201. await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
  202. return new SubtitleInfo()
  203. {
  204. Path = outputPath,
  205. Protocol = MediaProtocol.File,
  206. Format = "srt",
  207. IsExternal = true
  208. };
  209. }
  210. // It's possible that the subtitleStream and mediaSource don't share the same protocol (e.g. .STRM file with local subs)
  211. return new SubtitleInfo()
  212. {
  213. Path = subtitleStream.Path,
  214. Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
  215. Format = currentFormat,
  216. IsExternal = true
  217. };
  218. }
  219. private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value)
  220. {
  221. ArgumentException.ThrowIfNullOrEmpty(format);
  222. if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))
  223. {
  224. value = new AssWriter();
  225. return true;
  226. }
  227. if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
  228. {
  229. value = new JsonWriter();
  230. return true;
  231. }
  232. if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase))
  233. {
  234. value = new SrtWriter();
  235. return true;
  236. }
  237. if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase))
  238. {
  239. value = new SsaWriter();
  240. return true;
  241. }
  242. if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase))
  243. {
  244. value = new VttWriter();
  245. return true;
  246. }
  247. if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase))
  248. {
  249. value = new TtmlWriter();
  250. return true;
  251. }
  252. value = null;
  253. return false;
  254. }
  255. private ISubtitleWriter GetWriter(string format)
  256. {
  257. if (TryGetWriter(format, out var writer))
  258. {
  259. return writer;
  260. }
  261. throw new ArgumentException("Unsupported format: " + format);
  262. }
  263. /// <summary>
  264. /// Converts the text subtitle to SRT.
  265. /// </summary>
  266. /// <param name="subtitleStream">The subtitle stream.</param>
  267. /// <param name="mediaSource">The input mediaSource.</param>
  268. /// <param name="outputPath">The output path.</param>
  269. /// <param name="cancellationToken">The cancellation token.</param>
  270. /// <returns>Task.</returns>
  271. private async Task ConvertTextSubtitleToSrt(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken)
  272. {
  273. using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
  274. {
  275. if (!File.Exists(outputPath))
  276. {
  277. await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
  278. }
  279. }
  280. }
  281. /// <summary>
  282. /// Converts the text subtitle to SRT internal.
  283. /// </summary>
  284. /// <param name="subtitleStream">The subtitle stream.</param>
  285. /// <param name="mediaSource">The input mediaSource.</param>
  286. /// <param name="outputPath">The output path.</param>
  287. /// <param name="cancellationToken">The cancellation token.</param>
  288. /// <returns>Task.</returns>
  289. /// <exception cref="ArgumentNullException">
  290. /// The <c>inputPath</c> or <c>outputPath</c> is <c>null</c>.
  291. /// </exception>
  292. private async Task ConvertTextSubtitleToSrtInternal(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken)
  293. {
  294. var inputPath = subtitleStream.Path;
  295. ArgumentException.ThrowIfNullOrEmpty(inputPath);
  296. ArgumentException.ThrowIfNullOrEmpty(outputPath);
  297. Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));
  298. var encodingParam = await GetSubtitleFileCharacterSet(subtitleStream, subtitleStream.Language, mediaSource, cancellationToken).ConfigureAwait(false);
  299. // FFmpeg automatically convert character encoding when it is UTF-16
  300. // If we specify character encoding, it rejects with "do not specify a character encoding" and "Unable to recode subtitle event"
  301. if ((inputPath.EndsWith(".smi", StringComparison.Ordinal) || inputPath.EndsWith(".sami", StringComparison.Ordinal)) &&
  302. (encodingParam.Equals("UTF-16BE", StringComparison.OrdinalIgnoreCase) ||
  303. encodingParam.Equals("UTF-16LE", StringComparison.OrdinalIgnoreCase)))
  304. {
  305. encodingParam = string.Empty;
  306. }
  307. else if (!string.IsNullOrEmpty(encodingParam))
  308. {
  309. encodingParam = " -sub_charenc " + encodingParam;
  310. }
  311. int exitCode;
  312. using (var process = new Process
  313. {
  314. StartInfo = new ProcessStartInfo
  315. {
  316. CreateNoWindow = true,
  317. UseShellExecute = false,
  318. FileName = _mediaEncoder.EncoderPath,
  319. Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
  320. WindowStyle = ProcessWindowStyle.Hidden,
  321. ErrorDialog = false
  322. },
  323. EnableRaisingEvents = true
  324. })
  325. {
  326. _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
  327. try
  328. {
  329. process.Start();
  330. }
  331. catch (Exception ex)
  332. {
  333. _logger.LogError(ex, "Error starting ffmpeg");
  334. throw;
  335. }
  336. try
  337. {
  338. await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
  339. exitCode = process.ExitCode;
  340. }
  341. catch (OperationCanceledException)
  342. {
  343. process.Kill(true);
  344. exitCode = -1;
  345. }
  346. }
  347. var failed = false;
  348. if (exitCode == -1)
  349. {
  350. failed = true;
  351. if (File.Exists(outputPath))
  352. {
  353. try
  354. {
  355. _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
  356. _fileSystem.DeleteFile(outputPath);
  357. }
  358. catch (IOException ex)
  359. {
  360. _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
  361. }
  362. }
  363. }
  364. else if (!File.Exists(outputPath))
  365. {
  366. failed = true;
  367. }
  368. if (failed)
  369. {
  370. _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
  371. throw new FfmpegException(
  372. string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath));
  373. }
  374. await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
  375. _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
  376. }
  377. private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
  378. {
  379. if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
  380. || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)
  381. || string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
  382. {
  383. return subtitleStream.Codec;
  384. }
  385. else
  386. {
  387. return "srt";
  388. }
  389. }
  390. private string GetExtractableSubtitleFileExtension(MediaStream subtitleStream)
  391. {
  392. // Using .pgssub as file extension is not allowed by ffmpeg. The file extension for pgs subtitles is .sup.
  393. if (string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
  394. {
  395. return "sup";
  396. }
  397. else
  398. {
  399. return GetExtractableSubtitleFormat(subtitleStream);
  400. }
  401. }
  402. private bool IsCodecCopyable(string codec)
  403. {
  404. return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase)
  405. || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)
  406. || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)
  407. || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase)
  408. || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase);
  409. }
  410. /// <summary>
  411. /// Extracts all extractable subtitles (text and pgs).
  412. /// </summary>
  413. /// <param name="mediaSource">The mediaSource.</param>
  414. /// <param name="cancellationToken">The cancellation token.</param>
  415. /// <returns>Task.</returns>
  416. private async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken)
  417. {
  418. var locks = new List<IDisposable>();
  419. var extractableStreams = new List<MediaStream>();
  420. try
  421. {
  422. var subtitleStreams = mediaSource.MediaStreams
  423. .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true });
  424. foreach (var subtitleStream in subtitleStreams)
  425. {
  426. if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
  427. {
  428. continue;
  429. }
  430. var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
  431. var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
  432. if (File.Exists(outputPath))
  433. {
  434. releaser.Dispose();
  435. continue;
  436. }
  437. locks.Add(releaser);
  438. extractableStreams.Add(subtitleStream);
  439. }
  440. if (extractableStreams.Count > 0)
  441. {
  442. await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);
  443. await ExtractAllExtractableSubtitlesMKS(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);
  444. }
  445. }
  446. catch (Exception ex)
  447. {
  448. _logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path);
  449. }
  450. finally
  451. {
  452. locks.ForEach(x => x.Dispose());
  453. }
  454. }
  455. private async Task ExtractAllExtractableSubtitlesMKS(
  456. MediaSourceInfo mediaSource,
  457. List<MediaStream> subtitleStreams,
  458. CancellationToken cancellationToken)
  459. {
  460. var mksFiles = new List<string>();
  461. foreach (var subtitleStream in subtitleStreams)
  462. {
  463. if (string.IsNullOrEmpty(subtitleStream.Path) || !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
  464. {
  465. continue;
  466. }
  467. if (!mksFiles.Contains(subtitleStream.Path))
  468. {
  469. mksFiles.Add(subtitleStream.Path);
  470. }
  471. }
  472. if (mksFiles.Count == 0)
  473. {
  474. return;
  475. }
  476. foreach (string mksFile in mksFiles)
  477. {
  478. var inputPath = _mediaEncoder.GetInputArgument(mksFile, mediaSource);
  479. var outputPaths = new List<string>();
  480. var args = string.Format(
  481. CultureInfo.InvariantCulture,
  482. "-i {0} -copyts",
  483. inputPath);
  484. foreach (var subtitleStream in subtitleStreams)
  485. {
  486. if (!subtitleStream.Path.Equals(mksFile, StringComparison.OrdinalIgnoreCase))
  487. {
  488. continue;
  489. }
  490. var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
  491. var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
  492. var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
  493. if (streamIndex == -1)
  494. {
  495. _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream", inputPath, subtitleStream.Index);
  496. continue;
  497. }
  498. Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));
  499. outputPaths.Add(outputPath);
  500. args += string.Format(
  501. CultureInfo.InvariantCulture,
  502. " -map 0:{0} -an -vn -c:s {1} \"{2}\"",
  503. streamIndex,
  504. outputCodec,
  505. outputPath);
  506. }
  507. await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
  508. }
  509. }
  510. private async Task ExtractAllExtractableSubtitlesInternal(
  511. MediaSourceInfo mediaSource,
  512. List<MediaStream> subtitleStreams,
  513. CancellationToken cancellationToken)
  514. {
  515. var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
  516. var outputPaths = new List<string>();
  517. var args = string.Format(
  518. CultureInfo.InvariantCulture,
  519. "-i {0} -copyts",
  520. inputPath);
  521. foreach (var subtitleStream in subtitleStreams)
  522. {
  523. if (!string.IsNullOrEmpty(subtitleStream.Path) && subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
  524. {
  525. _logger.LogDebug("Subtitle {Index} for file {InputPath} is part in an MKS file. Skipping", inputPath, subtitleStream.Index);
  526. continue;
  527. }
  528. var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
  529. var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
  530. var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
  531. if (streamIndex == -1)
  532. {
  533. _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream", inputPath, subtitleStream.Index);
  534. continue;
  535. }
  536. Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));
  537. outputPaths.Add(outputPath);
  538. args += string.Format(
  539. CultureInfo.InvariantCulture,
  540. " -map 0:{0} -an -vn -c:s {1} \"{2}\"",
  541. streamIndex,
  542. outputCodec,
  543. outputPath);
  544. }
  545. if (outputPaths.Count == 0)
  546. {
  547. return;
  548. }
  549. await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
  550. }
  551. private async Task ExtractSubtitlesForFile(
  552. string inputPath,
  553. string args,
  554. List<string> outputPaths,
  555. CancellationToken cancellationToken)
  556. {
  557. int exitCode;
  558. using (var process = new Process
  559. {
  560. StartInfo = new ProcessStartInfo
  561. {
  562. CreateNoWindow = true,
  563. UseShellExecute = false,
  564. FileName = _mediaEncoder.EncoderPath,
  565. Arguments = args,
  566. WindowStyle = ProcessWindowStyle.Hidden,
  567. ErrorDialog = false
  568. },
  569. EnableRaisingEvents = true
  570. })
  571. {
  572. _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
  573. try
  574. {
  575. process.Start();
  576. }
  577. catch (Exception ex)
  578. {
  579. _logger.LogError(ex, "Error starting ffmpeg");
  580. throw;
  581. }
  582. try
  583. {
  584. await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
  585. exitCode = process.ExitCode;
  586. }
  587. catch (OperationCanceledException)
  588. {
  589. process.Kill(true);
  590. exitCode = -1;
  591. }
  592. }
  593. var failed = false;
  594. if (exitCode == -1)
  595. {
  596. failed = true;
  597. foreach (var outputPath in outputPaths)
  598. {
  599. try
  600. {
  601. _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
  602. _fileSystem.DeleteFile(outputPath);
  603. }
  604. catch (FileNotFoundException)
  605. {
  606. }
  607. catch (IOException ex)
  608. {
  609. _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
  610. }
  611. }
  612. }
  613. else
  614. {
  615. foreach (var outputPath in outputPaths)
  616. {
  617. if (!File.Exists(outputPath))
  618. {
  619. _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
  620. failed = true;
  621. continue;
  622. }
  623. if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase))
  624. {
  625. await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
  626. }
  627. _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
  628. }
  629. }
  630. if (failed)
  631. {
  632. throw new FfmpegException(
  633. string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));
  634. }
  635. }
  636. /// <summary>
  637. /// Extracts the text subtitle.
  638. /// </summary>
  639. /// <param name="mediaSource">The mediaSource.</param>
  640. /// <param name="subtitleStream">The subtitle stream.</param>
  641. /// <param name="outputCodec">The output codec.</param>
  642. /// <param name="outputPath">The output path.</param>
  643. /// <param name="cancellationToken">The cancellation token.</param>
  644. /// <returns>Task.</returns>
  645. /// <exception cref="ArgumentException">Must use inputPath list overload.</exception>
  646. private async Task ExtractTextSubtitle(
  647. MediaSourceInfo mediaSource,
  648. MediaStream subtitleStream,
  649. string outputCodec,
  650. string outputPath,
  651. CancellationToken cancellationToken)
  652. {
  653. using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
  654. {
  655. if (!File.Exists(outputPath))
  656. {
  657. var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
  658. var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
  659. if (subtitleStream.IsExternal)
  660. {
  661. args = _mediaEncoder.GetExternalSubtitleInputArgument(subtitleStream.Path);
  662. }
  663. await ExtractTextSubtitleInternal(
  664. args,
  665. subtitleStreamIndex,
  666. outputCodec,
  667. outputPath,
  668. cancellationToken).ConfigureAwait(false);
  669. }
  670. }
  671. }
  672. private async Task ExtractTextSubtitleInternal(
  673. string inputPath,
  674. int subtitleStreamIndex,
  675. string outputCodec,
  676. string outputPath,
  677. CancellationToken cancellationToken)
  678. {
  679. ArgumentException.ThrowIfNullOrEmpty(inputPath);
  680. ArgumentException.ThrowIfNullOrEmpty(outputPath);
  681. Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));
  682. var processArgs = string.Format(
  683. CultureInfo.InvariantCulture,
  684. "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
  685. inputPath,
  686. subtitleStreamIndex,
  687. outputCodec,
  688. outputPath);
  689. int exitCode;
  690. using (var process = new Process
  691. {
  692. StartInfo = new ProcessStartInfo
  693. {
  694. CreateNoWindow = true,
  695. UseShellExecute = false,
  696. FileName = _mediaEncoder.EncoderPath,
  697. Arguments = processArgs,
  698. WindowStyle = ProcessWindowStyle.Hidden,
  699. ErrorDialog = false
  700. },
  701. EnableRaisingEvents = true
  702. })
  703. {
  704. _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
  705. try
  706. {
  707. process.Start();
  708. }
  709. catch (Exception ex)
  710. {
  711. _logger.LogError(ex, "Error starting ffmpeg");
  712. throw;
  713. }
  714. try
  715. {
  716. await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
  717. exitCode = process.ExitCode;
  718. }
  719. catch (OperationCanceledException)
  720. {
  721. process.Kill(true);
  722. exitCode = -1;
  723. }
  724. }
  725. var failed = false;
  726. if (exitCode == -1)
  727. {
  728. failed = true;
  729. try
  730. {
  731. _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
  732. _fileSystem.DeleteFile(outputPath);
  733. }
  734. catch (FileNotFoundException)
  735. {
  736. }
  737. catch (IOException ex)
  738. {
  739. _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
  740. }
  741. }
  742. else if (!File.Exists(outputPath))
  743. {
  744. failed = true;
  745. }
  746. if (failed)
  747. {
  748. _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
  749. throw new FfmpegException(
  750. string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath));
  751. }
  752. _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
  753. if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
  754. {
  755. await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
  756. }
  757. }
  758. /// <summary>
  759. /// Sets the ass font.
  760. /// </summary>
  761. /// <param name="file">The file.</param>
  762. /// <param name="cancellationToken">The token to monitor for cancellation requests. The default value is <c>System.Threading.CancellationToken.None</c>.</param>
  763. /// <returns>Task.</returns>
  764. private async Task SetAssFont(string file, CancellationToken cancellationToken = default)
  765. {
  766. _logger.LogInformation("Setting ass font within {File}", file);
  767. string text;
  768. Encoding encoding;
  769. using (var fileStream = AsyncFile.OpenRead(file))
  770. using (var reader = new StreamReader(fileStream, true))
  771. {
  772. encoding = reader.CurrentEncoding;
  773. text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
  774. }
  775. var newText = text.Replace(",Arial,", ",Arial Unicode MS,", StringComparison.Ordinal);
  776. if (!string.Equals(text, newText, StringComparison.Ordinal))
  777. {
  778. var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
  779. await using (fileStream.ConfigureAwait(false))
  780. {
  781. var writer = new StreamWriter(fileStream, encoding);
  782. await using (writer.ConfigureAwait(false))
  783. {
  784. await writer.WriteAsync(newText.AsMemory(), cancellationToken).ConfigureAwait(false);
  785. }
  786. }
  787. }
  788. }
  789. private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)
  790. {
  791. return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
  792. }
  793. /// <inheritdoc />
  794. public async Task<string> GetSubtitleFileCharacterSet(MediaStream subtitleStream, string language, MediaSourceInfo mediaSource, CancellationToken cancellationToken)
  795. {
  796. var subtitleCodec = subtitleStream.Codec;
  797. var path = subtitleStream.Path;
  798. if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
  799. {
  800. path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
  801. await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
  802. .ConfigureAwait(false);
  803. }
  804. var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
  805. await using (stream.ConfigureAwait(false))
  806. {
  807. var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
  808. var charset = result.Detected?.EncodingName ?? string.Empty;
  809. // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
  810. if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || path.EndsWith(".srt", StringComparison.Ordinal))
  811. && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase)
  812. || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase)))
  813. {
  814. charset = string.Empty;
  815. }
  816. _logger.LogDebug("charset {0} detected for {Path}", charset, path);
  817. return charset;
  818. }
  819. }
  820. private async Task<Stream> GetStream(string path, MediaProtocol protocol, CancellationToken cancellationToken)
  821. {
  822. switch (protocol)
  823. {
  824. case MediaProtocol.Http:
  825. {
  826. using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
  827. .GetAsync(new Uri(path), cancellationToken)
  828. .ConfigureAwait(false);
  829. return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
  830. }
  831. case MediaProtocol.File:
  832. return AsyncFile.OpenRead(path);
  833. default:
  834. throw new ArgumentOutOfRangeException(nameof(protocol));
  835. }
  836. }
  837. public async Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, CancellationToken cancellationToken)
  838. {
  839. var info = await GetReadableFile(mediaSource, subtitleStream, cancellationToken)
  840. .ConfigureAwait(false);
  841. return info.Path;
  842. }
  843. /// <inheritdoc />
  844. public void Dispose()
  845. {
  846. _semaphoreLocks.Dispose();
  847. }
  848. #pragma warning disable CA1034 // Nested types should not be visible
  849. // Only public for the unit tests
  850. public readonly record struct SubtitleInfo
  851. {
  852. public string Path { get; init; }
  853. public MediaProtocol Protocol { get; init; }
  854. public string Format { get; init; }
  855. public bool IsExternal { get; init; }
  856. }
  857. }
  858. }