FFMpegManager.cs 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090
  1. using MediaBrowser.Common.Extensions;
  2. using MediaBrowser.Common.IO;
  3. using MediaBrowser.Common.Kernel;
  4. using MediaBrowser.Common.Serialization;
  5. using MediaBrowser.Controller.Entities;
  6. using MediaBrowser.Controller.Entities.Audio;
  7. using MediaBrowser.Controller.Library;
  8. using MediaBrowser.Model.Entities;
  9. using MediaBrowser.Model.IO;
  10. using System;
  11. using System.Collections.Generic;
  12. using System.ComponentModel;
  13. using System.Diagnostics;
  14. using System.IO;
  15. using System.Linq;
  16. using System.Reflection;
  17. using System.Runtime.InteropServices;
  18. using System.Threading;
  19. using System.Threading.Tasks;
  20. namespace MediaBrowser.Controller.MediaInfo
  21. {
  22. /// <summary>
  23. /// Class FFMpegManager
  24. /// </summary>
  25. public class FFMpegManager : BaseManager<Kernel>
  26. {
  27. /// <summary>
  28. /// Holds the list of new items to generate chapter image for when the NewItemTimer expires
  29. /// </summary>
  30. private readonly List<Video> _newlyAddedItems = new List<Video>();
  31. /// <summary>
  32. /// The amount of time to wait before generating chapter images
  33. /// </summary>
  34. private const int NewItemDelay = 300000;
  35. /// <summary>
  36. /// The current new item timer
  37. /// </summary>
  38. /// <value>The new item timer.</value>
  39. private Timer NewItemTimer { get; set; }
  40. /// <summary>
  41. /// Gets or sets the video image cache.
  42. /// </summary>
  43. /// <value>The video image cache.</value>
  44. internal FileSystemRepository VideoImageCache { get; set; }
  45. /// <summary>
  46. /// Gets or sets the image cache.
  47. /// </summary>
  48. /// <value>The image cache.</value>
  49. internal FileSystemRepository AudioImageCache { get; set; }
  50. /// <summary>
  51. /// Gets or sets the subtitle cache.
  52. /// </summary>
  53. /// <value>The subtitle cache.</value>
  54. internal FileSystemRepository SubtitleCache { get; set; }
  55. /// <summary>
  56. /// Gets or sets the zip client.
  57. /// </summary>
  58. /// <value>The zip client.</value>
  59. private IZipClient ZipClient { get; set; }
  60. /// <summary>
  61. /// Initializes a new instance of the <see cref="FFMpegManager" /> class.
  62. /// </summary>
  63. /// <param name="kernel">The kernel.</param>
  64. /// <param name="zipClient">The zip client.</param>
  65. /// <exception cref="System.ArgumentNullException">zipClient</exception>
  66. public FFMpegManager(Kernel kernel, IZipClient zipClient)
  67. : base(kernel)
  68. {
  69. if (zipClient == null)
  70. {
  71. throw new ArgumentNullException("zipClient");
  72. }
  73. ZipClient = zipClient;
  74. // Not crazy about this but it's the only way to suppress ffmpeg crash dialog boxes
  75. SetErrorMode(ErrorModes.SEM_FAILCRITICALERRORS | ErrorModes.SEM_NOALIGNMENTFAULTEXCEPT | ErrorModes.SEM_NOGPFAULTERRORBOX | ErrorModes.SEM_NOOPENFILEERRORBOX);
  76. VideoImageCache = new FileSystemRepository(VideoImagesDataPath);
  77. AudioImageCache = new FileSystemRepository(AudioImagesDataPath);
  78. SubtitleCache = new FileSystemRepository(SubtitleCachePath);
  79. Kernel.LibraryManager.LibraryChanged += LibraryManager_LibraryChanged;
  80. Task.Run(() => VersionedDirectoryPath = GetVersionedDirectoryPath());
  81. }
  82. /// <summary>
  83. /// Handles the LibraryChanged event of the LibraryManager control.
  84. /// </summary>
  85. /// <param name="sender">The source of the event.</param>
  86. /// <param name="e">The <see cref="ChildrenChangedEventArgs" /> instance containing the event data.</param>
  87. void LibraryManager_LibraryChanged(object sender, ChildrenChangedEventArgs e)
  88. {
  89. var videos = e.ItemsAdded.OfType<Video>().ToList();
  90. // Use a timer to prevent lots of these notifications from showing in a short period of time
  91. if (videos.Count > 0)
  92. {
  93. lock (_newlyAddedItems)
  94. {
  95. _newlyAddedItems.AddRange(videos);
  96. if (NewItemTimer == null)
  97. {
  98. NewItemTimer = new Timer(NewItemTimerCallback, null, NewItemDelay, Timeout.Infinite);
  99. }
  100. else
  101. {
  102. NewItemTimer.Change(NewItemDelay, Timeout.Infinite);
  103. }
  104. }
  105. }
  106. }
  107. /// <summary>
  108. /// Called when the new item timer expires
  109. /// </summary>
  110. /// <param name="state">The state.</param>
  111. private async void NewItemTimerCallback(object state)
  112. {
  113. List<Video> newItems;
  114. // Lock the list and release all resources
  115. lock (_newlyAddedItems)
  116. {
  117. newItems = _newlyAddedItems.ToList();
  118. _newlyAddedItems.Clear();
  119. NewItemTimer.Dispose();
  120. NewItemTimer = null;
  121. }
  122. // Limit the number of videos we generate images for
  123. // The idea is to catch new items that are added here and there
  124. // Mass image generation can be left to the scheduled task
  125. foreach (var video in newItems.Where(c => c.Chapters != null).Take(3))
  126. {
  127. try
  128. {
  129. await PopulateChapterImages(video, CancellationToken.None, true, true).ConfigureAwait(false);
  130. }
  131. catch (Exception ex)
  132. {
  133. Logger.ErrorException("Error creating chapter images for {0}", ex, video.Name);
  134. }
  135. }
  136. }
  137. /// <summary>
  138. /// Releases unmanaged and - optionally - managed resources.
  139. /// </summary>
  140. /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
  141. protected override void Dispose(bool dispose)
  142. {
  143. if (dispose)
  144. {
  145. if (NewItemTimer != null)
  146. {
  147. NewItemTimer.Dispose();
  148. }
  149. SetErrorMode(ErrorModes.SYSTEM_DEFAULT);
  150. Kernel.LibraryManager.LibraryChanged -= LibraryManager_LibraryChanged;
  151. AudioImageCache.Dispose();
  152. VideoImageCache.Dispose();
  153. }
  154. base.Dispose(dispose);
  155. }
  156. /// <summary>
  157. /// The FF probe resource pool count
  158. /// </summary>
  159. private const int FFProbeResourcePoolCount = 4;
  160. /// <summary>
  161. /// The audio image resource pool count
  162. /// </summary>
  163. private const int AudioImageResourcePoolCount = 4;
  164. /// <summary>
  165. /// The video image resource pool count
  166. /// </summary>
  167. private const int VideoImageResourcePoolCount = 2;
  168. /// <summary>
  169. /// The FF probe resource pool
  170. /// </summary>
  171. private readonly SemaphoreSlim FFProbeResourcePool = new SemaphoreSlim(FFProbeResourcePoolCount, FFProbeResourcePoolCount);
  172. /// <summary>
  173. /// The audio image resource pool
  174. /// </summary>
  175. private readonly SemaphoreSlim AudioImageResourcePool = new SemaphoreSlim(AudioImageResourcePoolCount, AudioImageResourcePoolCount);
  176. /// <summary>
  177. /// The video image resource pool
  178. /// </summary>
  179. private readonly SemaphoreSlim VideoImageResourcePool = new SemaphoreSlim(VideoImageResourcePoolCount, VideoImageResourcePoolCount);
  180. /// <summary>
  181. /// Gets or sets the versioned directory path.
  182. /// </summary>
  183. /// <value>The versioned directory path.</value>
  184. private string VersionedDirectoryPath { get; set; }
  185. /// <summary>
  186. /// Gets the FFMPEG version.
  187. /// </summary>
  188. /// <value>The FFMPEG version.</value>
  189. public string FFMpegVersion
  190. {
  191. get { return Path.GetFileNameWithoutExtension(VersionedDirectoryPath); }
  192. }
  193. /// <summary>
  194. /// The _ FF MPEG path
  195. /// </summary>
  196. private string _FFMpegPath;
  197. /// <summary>
  198. /// Gets the path to ffmpeg.exe
  199. /// </summary>
  200. /// <value>The FF MPEG path.</value>
  201. public string FFMpegPath
  202. {
  203. get
  204. {
  205. return _FFMpegPath ?? (_FFMpegPath = Path.Combine(VersionedDirectoryPath, "ffmpeg.exe"));
  206. }
  207. }
  208. /// <summary>
  209. /// The _ FF probe path
  210. /// </summary>
  211. private string _FFProbePath;
  212. /// <summary>
  213. /// Gets the path to ffprobe.exe
  214. /// </summary>
  215. /// <value>The FF probe path.</value>
  216. public string FFProbePath
  217. {
  218. get
  219. {
  220. return _FFProbePath ?? (_FFProbePath = Path.Combine(VersionedDirectoryPath, "ffprobe.exe"));
  221. }
  222. }
  223. /// <summary>
  224. /// The _video images data path
  225. /// </summary>
  226. private string _videoImagesDataPath;
  227. /// <summary>
  228. /// Gets the video images data path.
  229. /// </summary>
  230. /// <value>The video images data path.</value>
  231. public string VideoImagesDataPath
  232. {
  233. get
  234. {
  235. if (_videoImagesDataPath == null)
  236. {
  237. _videoImagesDataPath = Path.Combine(Kernel.ApplicationPaths.DataPath, "ffmpeg-video-images");
  238. if (!Directory.Exists(_videoImagesDataPath))
  239. {
  240. Directory.CreateDirectory(_videoImagesDataPath);
  241. }
  242. }
  243. return _videoImagesDataPath;
  244. }
  245. }
  246. /// <summary>
  247. /// The _audio images data path
  248. /// </summary>
  249. private string _audioImagesDataPath;
  250. /// <summary>
  251. /// Gets the audio images data path.
  252. /// </summary>
  253. /// <value>The audio images data path.</value>
  254. public string AudioImagesDataPath
  255. {
  256. get
  257. {
  258. if (_audioImagesDataPath == null)
  259. {
  260. _audioImagesDataPath = Path.Combine(Kernel.ApplicationPaths.DataPath, "ffmpeg-audio-images");
  261. if (!Directory.Exists(_audioImagesDataPath))
  262. {
  263. Directory.CreateDirectory(_audioImagesDataPath);
  264. }
  265. }
  266. return _audioImagesDataPath;
  267. }
  268. }
  269. /// <summary>
  270. /// The _subtitle cache path
  271. /// </summary>
  272. private string _subtitleCachePath;
  273. /// <summary>
  274. /// Gets the subtitle cache path.
  275. /// </summary>
  276. /// <value>The subtitle cache path.</value>
  277. public string SubtitleCachePath
  278. {
  279. get
  280. {
  281. if (_subtitleCachePath == null)
  282. {
  283. _subtitleCachePath = Path.Combine(Kernel.ApplicationPaths.CachePath, "ffmpeg-subtitles");
  284. if (!Directory.Exists(_subtitleCachePath))
  285. {
  286. Directory.CreateDirectory(_subtitleCachePath);
  287. }
  288. }
  289. return _subtitleCachePath;
  290. }
  291. }
  292. /// <summary>
  293. /// Gets the versioned directory path.
  294. /// </summary>
  295. /// <returns>System.String.</returns>
  296. private string GetVersionedDirectoryPath()
  297. {
  298. var assembly = GetType().Assembly;
  299. const string prefix = "MediaBrowser.Controller.MediaInfo.";
  300. const string srch = prefix + "ffmpeg";
  301. var resource = assembly.GetManifestResourceNames().First(r => r.StartsWith(srch));
  302. var filename = resource.Substring(resource.IndexOf(prefix, StringComparison.OrdinalIgnoreCase) + prefix.Length);
  303. var versionedDirectoryPath = Path.Combine(Kernel.ApplicationPaths.MediaToolsPath, Path.GetFileNameWithoutExtension(filename));
  304. if (!Directory.Exists(versionedDirectoryPath))
  305. {
  306. Directory.CreateDirectory(versionedDirectoryPath);
  307. }
  308. ExtractTools(assembly, resource, versionedDirectoryPath);
  309. return versionedDirectoryPath;
  310. }
  311. /// <summary>
  312. /// Extracts the tools.
  313. /// </summary>
  314. /// <param name="assembly">The assembly.</param>
  315. /// <param name="zipFileResourcePath">The zip file resource path.</param>
  316. /// <param name="targetPath">The target path.</param>
  317. private void ExtractTools(Assembly assembly, string zipFileResourcePath, string targetPath)
  318. {
  319. using (var resourceStream = assembly.GetManifestResourceStream(zipFileResourcePath))
  320. {
  321. ZipClient.ExtractAll(resourceStream, targetPath, false);
  322. }
  323. }
  324. /// <summary>
  325. /// Gets the probe size argument.
  326. /// </summary>
  327. /// <param name="item">The item.</param>
  328. /// <returns>System.String.</returns>
  329. public string GetProbeSizeArgument(BaseItem item)
  330. {
  331. var video = item as Video;
  332. return video != null ? GetProbeSizeArgument(video.VideoType, video.IsoType) : string.Empty;
  333. }
  334. /// <summary>
  335. /// Gets the probe size argument.
  336. /// </summary>
  337. /// <param name="videoType">Type of the video.</param>
  338. /// <param name="isoType">Type of the iso.</param>
  339. /// <returns>System.String.</returns>
  340. public string GetProbeSizeArgument(VideoType videoType, IsoType? isoType)
  341. {
  342. if (videoType == VideoType.Dvd || (isoType.HasValue && isoType.Value == IsoType.Dvd))
  343. {
  344. return "-probesize 1G -analyzeduration 200M";
  345. }
  346. return string.Empty;
  347. }
  348. /// <summary>
  349. /// Runs FFProbe against a BaseItem
  350. /// </summary>
  351. /// <param name="item">The item.</param>
  352. /// <param name="inputPath">The input path.</param>
  353. /// <param name="lastDateModified">The last date modified.</param>
  354. /// <param name="cache">The cache.</param>
  355. /// <param name="cancellationToken">The cancellation token.</param>
  356. /// <returns>Task{FFProbeResult}.</returns>
  357. /// <exception cref="System.ArgumentNullException">item</exception>
  358. public Task<FFProbeResult> RunFFProbe(BaseItem item, string inputPath, DateTime lastDateModified, FileSystemRepository cache, CancellationToken cancellationToken)
  359. {
  360. if (string.IsNullOrEmpty(inputPath))
  361. {
  362. throw new ArgumentNullException("inputPath");
  363. }
  364. if (cache == null)
  365. {
  366. throw new ArgumentNullException("cache");
  367. }
  368. // Put the ffmpeg version into the cache name so that it's unique per-version
  369. // We don't want to try and deserialize data based on an old version, which could potentially fail
  370. var resourceName = item.Id + "_" + lastDateModified.Ticks + "_" + FFMpegVersion;
  371. // Forumulate the cache file path
  372. var cacheFilePath = cache.GetResourcePath(resourceName, ".pb");
  373. cancellationToken.ThrowIfCancellationRequested();
  374. // Avoid File.Exists by just trying to deserialize
  375. try
  376. {
  377. return Task.FromResult(Kernel.ProtobufSerializer.DeserializeFromFile<FFProbeResult>(cacheFilePath));
  378. }
  379. catch (FileNotFoundException)
  380. {
  381. var extractChapters = false;
  382. var video = item as Video;
  383. var probeSizeArgument = string.Empty;
  384. if (video != null)
  385. {
  386. extractChapters = true;
  387. probeSizeArgument = GetProbeSizeArgument(video.VideoType, video.IsoType);
  388. }
  389. return RunFFProbeInternal(inputPath, extractChapters, cacheFilePath, probeSizeArgument, cancellationToken);
  390. }
  391. }
  392. /// <summary>
  393. /// Runs FFProbe against a BaseItem
  394. /// </summary>
  395. /// <param name="inputPath">The input path.</param>
  396. /// <param name="extractChapters">if set to <c>true</c> [extract chapters].</param>
  397. /// <param name="cacheFile">The cache file.</param>
  398. /// <param name="probeSizeArgument">The probe size argument.</param>
  399. /// <param name="cancellationToken">The cancellation token.</param>
  400. /// <returns>Task{FFProbeResult}.</returns>
  401. /// <exception cref="System.ApplicationException"></exception>
  402. private async Task<FFProbeResult> RunFFProbeInternal(string inputPath, bool extractChapters, string cacheFile, string probeSizeArgument, CancellationToken cancellationToken)
  403. {
  404. var process = new Process
  405. {
  406. StartInfo = new ProcessStartInfo
  407. {
  408. CreateNoWindow = true,
  409. UseShellExecute = false,
  410. // Must consume both or ffmpeg may hang due to deadlocks. See comments below.
  411. RedirectStandardOutput = true,
  412. RedirectStandardError = true,
  413. FileName = FFProbePath,
  414. Arguments = string.Format("{0} -i {1} -threads 0 -v info -print_format json -show_streams -show_format", probeSizeArgument, inputPath).Trim(),
  415. WindowStyle = ProcessWindowStyle.Hidden,
  416. ErrorDialog = false
  417. },
  418. EnableRaisingEvents = true
  419. };
  420. Logger.Debug("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
  421. process.Exited += ProcessExited;
  422. await FFProbeResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
  423. FFProbeResult result;
  424. string standardError = null;
  425. try
  426. {
  427. process.Start();
  428. Task<string> standardErrorReadTask = null;
  429. // MUST read both stdout and stderr asynchronously or a deadlock may occurr
  430. if (extractChapters)
  431. {
  432. standardErrorReadTask = process.StandardError.ReadToEndAsync();
  433. }
  434. else
  435. {
  436. process.BeginErrorReadLine();
  437. }
  438. result = JsonSerializer.DeserializeFromStream<FFProbeResult>(process.StandardOutput.BaseStream);
  439. if (extractChapters)
  440. {
  441. standardError = await standardErrorReadTask.ConfigureAwait(false);
  442. }
  443. }
  444. catch
  445. {
  446. // Hate having to do this
  447. try
  448. {
  449. process.Kill();
  450. }
  451. catch (InvalidOperationException ex1)
  452. {
  453. Logger.ErrorException("Error killing ffprobe", ex1);
  454. }
  455. catch (Win32Exception ex1)
  456. {
  457. Logger.ErrorException("Error killing ffprobe", ex1);
  458. }
  459. throw;
  460. }
  461. finally
  462. {
  463. FFProbeResourcePool.Release();
  464. }
  465. if (result == null)
  466. {
  467. throw new ApplicationException(string.Format("FFProbe failed for {0}", inputPath));
  468. }
  469. cancellationToken.ThrowIfCancellationRequested();
  470. if (extractChapters && !string.IsNullOrEmpty(standardError))
  471. {
  472. AddChapters(result, standardError);
  473. }
  474. Kernel.ProtobufSerializer.SerializeToFile(result, cacheFile);
  475. return result;
  476. }
  477. /// <summary>
  478. /// Adds the chapters.
  479. /// </summary>
  480. /// <param name="result">The result.</param>
  481. /// <param name="standardError">The standard error.</param>
  482. private void AddChapters(FFProbeResult result, string standardError)
  483. {
  484. var lines = standardError.Split('\n').Select(l => l.TrimStart());
  485. var chapters = new List<ChapterInfo> { };
  486. ChapterInfo lastChapter = null;
  487. foreach (var line in lines)
  488. {
  489. if (line.StartsWith("Chapter", StringComparison.OrdinalIgnoreCase))
  490. {
  491. // Example:
  492. // Chapter #0.2: start 400.534, end 4565.435
  493. const string srch = "start ";
  494. var start = line.IndexOf(srch, StringComparison.OrdinalIgnoreCase);
  495. if (start == -1)
  496. {
  497. continue;
  498. }
  499. var subString = line.Substring(start + srch.Length);
  500. subString = subString.Substring(0, subString.IndexOf(','));
  501. double seconds;
  502. if (double.TryParse(subString, out seconds))
  503. {
  504. lastChapter = new ChapterInfo
  505. {
  506. StartPositionTicks = TimeSpan.FromSeconds(seconds).Ticks
  507. };
  508. chapters.Add(lastChapter);
  509. }
  510. }
  511. else if (line.StartsWith("title", StringComparison.OrdinalIgnoreCase))
  512. {
  513. if (lastChapter != null && string.IsNullOrEmpty(lastChapter.Name))
  514. {
  515. var index = line.IndexOf(':');
  516. if (index != -1)
  517. {
  518. lastChapter.Name = line.Substring(index + 1).Trim().TrimEnd('\r');
  519. }
  520. }
  521. }
  522. }
  523. result.Chapters = chapters;
  524. }
  525. /// <summary>
  526. /// The first chapter ticks
  527. /// </summary>
  528. private static long FirstChapterTicks = TimeSpan.FromSeconds(15).Ticks;
  529. /// <summary>
  530. /// Extracts the chapter images.
  531. /// </summary>
  532. /// <param name="video">The video.</param>
  533. /// <param name="cancellationToken">The cancellation token.</param>
  534. /// <param name="extractImages">if set to <c>true</c> [extract images].</param>
  535. /// <param name="saveItem">if set to <c>true</c> [save item].</param>
  536. /// <returns>Task.</returns>
  537. /// <exception cref="System.ArgumentNullException"></exception>
  538. public async Task PopulateChapterImages(Video video, CancellationToken cancellationToken, bool extractImages, bool saveItem)
  539. {
  540. if (video.Chapters == null)
  541. {
  542. throw new ArgumentNullException();
  543. }
  544. var changesMade = false;
  545. foreach (var chapter in video.Chapters)
  546. {
  547. var filename = video.Id + "_" + video.DateModified.Ticks + "_" + chapter.StartPositionTicks;
  548. var path = VideoImageCache.GetResourcePath(filename, ".jpg");
  549. if (!VideoImageCache.ContainsFilePath(path))
  550. {
  551. if (extractImages)
  552. {
  553. // Disable for now on folder rips
  554. if (video.VideoType != VideoType.VideoFile)
  555. {
  556. continue;
  557. }
  558. // Add some time for the first chapter to make sure we don't end up with a black image
  559. var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(FirstChapterTicks, video.RunTimeTicks ?? 0)) : TimeSpan.FromTicks(chapter.StartPositionTicks);
  560. var success = await ExtractImage(GetInputArgument(video), time, path, cancellationToken).ConfigureAwait(false);
  561. if (success)
  562. {
  563. chapter.ImagePath = path;
  564. changesMade = true;
  565. }
  566. }
  567. }
  568. else if (!string.Equals(path, chapter.ImagePath, StringComparison.OrdinalIgnoreCase))
  569. {
  570. chapter.ImagePath = path;
  571. changesMade = true;
  572. }
  573. }
  574. if (saveItem && changesMade)
  575. {
  576. await Kernel.ItemRepository.SaveItem(video, CancellationToken.None).ConfigureAwait(false);
  577. }
  578. }
  579. /// <summary>
  580. /// Extracts an image from an Audio file and returns a Task whose result indicates whether it was successful or not
  581. /// </summary>
  582. /// <param name="input">The input.</param>
  583. /// <param name="outputPath">The output path.</param>
  584. /// <param name="cancellationToken">The cancellation token.</param>
  585. /// <returns>Task{System.Boolean}.</returns>
  586. /// <exception cref="System.ArgumentNullException">input</exception>
  587. public async Task<bool> ExtractImage(Audio input, string outputPath, CancellationToken cancellationToken)
  588. {
  589. if (input == null)
  590. {
  591. throw new ArgumentNullException("input");
  592. }
  593. if (string.IsNullOrEmpty(outputPath))
  594. {
  595. throw new ArgumentNullException("outputPath");
  596. }
  597. var process = new Process
  598. {
  599. StartInfo = new ProcessStartInfo
  600. {
  601. CreateNoWindow = true,
  602. UseShellExecute = false,
  603. FileName = FFMpegPath,
  604. Arguments = string.Format("-i {0} -threads 0 -v quiet -f image2 \"{1}\"", GetInputArgument(input), outputPath),
  605. WindowStyle = ProcessWindowStyle.Hidden,
  606. ErrorDialog = false
  607. }
  608. };
  609. await AudioImageResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
  610. await process.RunAsync().ConfigureAwait(false);
  611. AudioImageResourcePool.Release();
  612. var exitCode = process.ExitCode;
  613. process.Dispose();
  614. if (exitCode != -1 && File.Exists(outputPath))
  615. {
  616. return true;
  617. }
  618. Logger.Error("ffmpeg audio image extraction failed for {0}", input.Path);
  619. return false;
  620. }
  621. /// <summary>
  622. /// Determines whether [is subtitle cached] [the specified input].
  623. /// </summary>
  624. /// <param name="input">The input.</param>
  625. /// <param name="subtitleStreamIndex">Index of the subtitle stream.</param>
  626. /// <param name="outputExtension">The output extension.</param>
  627. /// <returns><c>true</c> if [is subtitle cached] [the specified input]; otherwise, <c>false</c>.</returns>
  628. public bool IsSubtitleCached(Video input, int subtitleStreamIndex, string outputExtension)
  629. {
  630. return SubtitleCache.ContainsFilePath(GetSubtitleCachePath(input, subtitleStreamIndex, outputExtension));
  631. }
  632. /// <summary>
  633. /// Gets the subtitle cache path.
  634. /// </summary>
  635. /// <param name="input">The input.</param>
  636. /// <param name="subtitleStreamIndex">Index of the subtitle stream.</param>
  637. /// <param name="outputExtension">The output extension.</param>
  638. /// <returns>System.String.</returns>
  639. public string GetSubtitleCachePath(Video input, int subtitleStreamIndex, string outputExtension)
  640. {
  641. return SubtitleCache.GetResourcePath(input.Id + "_" + subtitleStreamIndex + "_" + input.DateModified.Ticks, outputExtension);
  642. }
  643. /// <summary>
  644. /// Extracts the text subtitle.
  645. /// </summary>
  646. /// <param name="input">The input.</param>
  647. /// <param name="subtitleStreamIndex">Index of the subtitle stream.</param>
  648. /// <param name="outputPath">The output path.</param>
  649. /// <param name="cancellationToken">The cancellation token.</param>
  650. /// <returns>Task{System.Boolean}.</returns>
  651. /// <exception cref="System.ArgumentNullException">input</exception>
  652. public async Task<bool> ExtractTextSubtitle(Video input, int subtitleStreamIndex, string outputPath, CancellationToken cancellationToken)
  653. {
  654. if (input == null)
  655. {
  656. throw new ArgumentNullException("input");
  657. }
  658. if (cancellationToken == null)
  659. {
  660. throw new ArgumentNullException("cancellationToken");
  661. }
  662. var process = new Process
  663. {
  664. StartInfo = new ProcessStartInfo
  665. {
  666. CreateNoWindow = true,
  667. UseShellExecute = false,
  668. FileName = FFMpegPath,
  669. Arguments = string.Format("-i {0} -map 0:{1} -an -vn -c:s ass \"{2}\"", GetInputArgument(input), subtitleStreamIndex, outputPath),
  670. WindowStyle = ProcessWindowStyle.Hidden,
  671. ErrorDialog = false
  672. }
  673. };
  674. Logger.Debug("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
  675. await AudioImageResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
  676. await process.RunAsync().ConfigureAwait(false);
  677. AudioImageResourcePool.Release();
  678. var exitCode = process.ExitCode;
  679. process.Dispose();
  680. if (exitCode != -1 && File.Exists(outputPath))
  681. {
  682. return true;
  683. }
  684. Logger.Error("ffmpeg subtitle extraction failed for {0}", input.Path);
  685. return false;
  686. }
  687. /// <summary>
  688. /// Converts the text subtitle.
  689. /// </summary>
  690. /// <param name="mediaStream">The media stream.</param>
  691. /// <param name="outputPath">The output path.</param>
  692. /// <param name="cancellationToken">The cancellation token.</param>
  693. /// <returns>Task{System.Boolean}.</returns>
  694. /// <exception cref="System.ArgumentNullException">mediaStream</exception>
  695. /// <exception cref="System.ArgumentException">The given MediaStream is not an external subtitle stream</exception>
  696. public async Task<bool> ConvertTextSubtitle(MediaStream mediaStream, string outputPath, CancellationToken cancellationToken)
  697. {
  698. if (mediaStream == null)
  699. {
  700. throw new ArgumentNullException("mediaStream");
  701. }
  702. if (!mediaStream.IsExternal || string.IsNullOrEmpty(mediaStream.Path))
  703. {
  704. throw new ArgumentException("The given MediaStream is not an external subtitle stream");
  705. }
  706. var process = new Process
  707. {
  708. StartInfo = new ProcessStartInfo
  709. {
  710. CreateNoWindow = true,
  711. UseShellExecute = false,
  712. FileName = FFMpegPath,
  713. Arguments = string.Format("-i \"{0}\" \"{1}\"", mediaStream.Path, outputPath),
  714. WindowStyle = ProcessWindowStyle.Hidden,
  715. ErrorDialog = false
  716. }
  717. };
  718. Logger.Debug("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
  719. await AudioImageResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
  720. await process.RunAsync().ConfigureAwait(false);
  721. AudioImageResourcePool.Release();
  722. var exitCode = process.ExitCode;
  723. process.Dispose();
  724. if (exitCode != -1 && File.Exists(outputPath))
  725. {
  726. return true;
  727. }
  728. Logger.Error("ffmpeg subtitle conversion failed for {0}", mediaStream.Path);
  729. return false;
  730. }
  731. /// <summary>
  732. /// Extracts an image from a Video and returns a Task whose result indicates whether it was successful or not
  733. /// </summary>
  734. /// <param name="inputPath">The input path.</param>
  735. /// <param name="offset">The offset.</param>
  736. /// <param name="outputPath">The output path.</param>
  737. /// <param name="cancellationToken">The cancellation token.</param>
  738. /// <returns>Task{System.Boolean}.</returns>
  739. /// <exception cref="System.ArgumentNullException">video</exception>
  740. public async Task<bool> ExtractImage(string inputPath, TimeSpan offset, string outputPath, CancellationToken cancellationToken)
  741. {
  742. if (string.IsNullOrEmpty(inputPath))
  743. {
  744. throw new ArgumentNullException("inputPath");
  745. }
  746. if (string.IsNullOrEmpty(outputPath))
  747. {
  748. throw new ArgumentNullException("outputPath");
  749. }
  750. var process = new Process
  751. {
  752. StartInfo = new ProcessStartInfo
  753. {
  754. CreateNoWindow = true,
  755. UseShellExecute = false,
  756. FileName = FFMpegPath,
  757. Arguments = string.Format("-ss {0} -i {1} -threads 0 -v quiet -t 1 -f image2 \"{2}\"", Convert.ToInt32(offset.TotalSeconds), inputPath, outputPath),
  758. WindowStyle = ProcessWindowStyle.Hidden,
  759. ErrorDialog = false
  760. }
  761. };
  762. await VideoImageResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
  763. process.Start();
  764. var ranToCompletion = process.WaitForExit(10000);
  765. if (!ranToCompletion)
  766. {
  767. try
  768. {
  769. Logger.Info("Killing ffmpeg process");
  770. process.Kill();
  771. process.WaitForExit(1000);
  772. }
  773. catch (Win32Exception ex)
  774. {
  775. Logger.ErrorException("Error killing process", ex);
  776. }
  777. catch (InvalidOperationException ex)
  778. {
  779. Logger.ErrorException("Error killing process", ex);
  780. }
  781. catch (NotSupportedException ex)
  782. {
  783. Logger.ErrorException("Error killing process", ex);
  784. }
  785. }
  786. VideoImageResourcePool.Release();
  787. var exitCode = ranToCompletion ? process.ExitCode : -1;
  788. process.Dispose();
  789. if (exitCode == -1)
  790. {
  791. if (File.Exists(outputPath))
  792. {
  793. try
  794. {
  795. Logger.Info("Deleting extracted image due to failure: ", outputPath);
  796. File.Delete(outputPath);
  797. }
  798. catch (IOException ex)
  799. {
  800. Logger.ErrorException("Error deleting extracted image {0}", ex, outputPath);
  801. }
  802. }
  803. }
  804. else
  805. {
  806. if (File.Exists(outputPath))
  807. {
  808. return true;
  809. }
  810. }
  811. Logger.Error("ffmpeg video image extraction failed for {0}", inputPath);
  812. return false;
  813. }
  814. /// <summary>
  815. /// Gets the input argument.
  816. /// </summary>
  817. /// <param name="item">The item.</param>
  818. /// <returns>System.String.</returns>
  819. public string GetInputArgument(BaseItem item)
  820. {
  821. var video = item as Video;
  822. if (video != null)
  823. {
  824. if (video.VideoType == VideoType.BluRay)
  825. {
  826. return GetBlurayInputArgument(video.Path);
  827. }
  828. if (video.VideoType == VideoType.Dvd)
  829. {
  830. return GetDvdInputArgument(video.GetPlayableStreamFiles());
  831. }
  832. }
  833. return string.Format("file:\"{0}\"", item.Path);
  834. }
  835. /// <summary>
  836. /// Gets the input argument.
  837. /// </summary>
  838. /// <param name="item">The item.</param>
  839. /// <param name="mount">The mount.</param>
  840. /// <returns>System.String.</returns>
  841. public string GetInputArgument(Video item, IIsoMount mount)
  842. {
  843. if (item.VideoType == VideoType.Iso && item.IsoType.HasValue)
  844. {
  845. if (item.IsoType.Value == IsoType.BluRay)
  846. {
  847. return GetBlurayInputArgument(mount.MountedPath);
  848. }
  849. if (item.IsoType.Value == IsoType.Dvd)
  850. {
  851. return GetDvdInputArgument(item.GetPlayableStreamFiles(mount.MountedPath));
  852. }
  853. }
  854. return GetInputArgument(item);
  855. }
  856. /// <summary>
  857. /// Gets the bluray input argument.
  858. /// </summary>
  859. /// <param name="blurayRoot">The bluray root.</param>
  860. /// <returns>System.String.</returns>
  861. public string GetBlurayInputArgument(string blurayRoot)
  862. {
  863. return string.Format("bluray:\"{0}\"", blurayRoot);
  864. }
  865. /// <summary>
  866. /// Gets the DVD input argument.
  867. /// </summary>
  868. /// <param name="playableStreamFiles">The playable stream files.</param>
  869. /// <returns>System.String.</returns>
  870. public string GetDvdInputArgument(IEnumerable<string> playableStreamFiles)
  871. {
  872. // Get all streams
  873. var streamFilePaths = (playableStreamFiles ?? new string[] { }).ToArray();
  874. // If there's more than one we'll need to use the concat command
  875. if (streamFilePaths.Length > 1)
  876. {
  877. var files = string.Join("|", streamFilePaths);
  878. return string.Format("concat:\"{0}\"", files);
  879. }
  880. // Determine the input path for video files
  881. return string.Format("file:\"{0}\"", streamFilePaths[0]);
  882. }
  883. /// <summary>
  884. /// Processes the exited.
  885. /// </summary>
  886. /// <param name="sender">The sender.</param>
  887. /// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
  888. void ProcessExited(object sender, EventArgs e)
  889. {
  890. ((Process)sender).Dispose();
  891. }
  892. /// <summary>
  893. /// Sets the error mode.
  894. /// </summary>
  895. /// <param name="uMode">The u mode.</param>
  896. /// <returns>ErrorModes.</returns>
  897. [DllImport("kernel32.dll")]
  898. static extern ErrorModes SetErrorMode(ErrorModes uMode);
  899. /// <summary>
  900. /// Enum ErrorModes
  901. /// </summary>
  902. [Flags]
  903. public enum ErrorModes : uint
  904. {
  905. /// <summary>
  906. /// The SYSTE m_ DEFAULT
  907. /// </summary>
  908. SYSTEM_DEFAULT = 0x0,
  909. /// <summary>
  910. /// The SE m_ FAILCRITICALERRORS
  911. /// </summary>
  912. SEM_FAILCRITICALERRORS = 0x0001,
  913. /// <summary>
  914. /// The SE m_ NOALIGNMENTFAULTEXCEPT
  915. /// </summary>
  916. SEM_NOALIGNMENTFAULTEXCEPT = 0x0004,
  917. /// <summary>
  918. /// The SE m_ NOGPFAULTERRORBOX
  919. /// </summary>
  920. SEM_NOGPFAULTERRORBOX = 0x0002,
  921. /// <summary>
  922. /// The SE m_ NOOPENFILEERRORBOX
  923. /// </summary>
  924. SEM_NOOPENFILEERRORBOX = 0x8000
  925. }
  926. }
  927. }