FFProbeVideoInfo.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. #pragma warning disable CS1591
  2. using System;
  3. using System.Collections.Generic;
  4. using System.Globalization;
  5. using System.IO;
  6. using System.Linq;
  7. using System.Threading;
  8. using System.Threading.Tasks;
  9. using DvdLib.Ifo;
  10. using MediaBrowser.Common.Configuration;
  11. using MediaBrowser.Controller.Chapters;
  12. using MediaBrowser.Controller.Configuration;
  13. using MediaBrowser.Controller.Entities;
  14. using MediaBrowser.Controller.Entities.Movies;
  15. using MediaBrowser.Controller.Entities.TV;
  16. using MediaBrowser.Controller.Library;
  17. using MediaBrowser.Controller.MediaEncoding;
  18. using MediaBrowser.Controller.Persistence;
  19. using MediaBrowser.Controller.Providers;
  20. using MediaBrowser.Controller.Subtitles;
  21. using MediaBrowser.Model.Configuration;
  22. using MediaBrowser.Model.Dlna;
  23. using MediaBrowser.Model.Dto;
  24. using MediaBrowser.Model.Entities;
  25. using MediaBrowser.Model.Globalization;
  26. using MediaBrowser.Model.MediaInfo;
  27. using MediaBrowser.Model.Providers;
  28. using Microsoft.Extensions.Logging;
  29. namespace MediaBrowser.Providers.MediaInfo
  30. {
  31. public class FFProbeVideoInfo
  32. {
  33. private readonly ILogger _logger;
  34. private readonly IMediaEncoder _mediaEncoder;
  35. private readonly IItemRepository _itemRepo;
  36. private readonly IBlurayExaminer _blurayExaminer;
  37. private readonly ILocalizationManager _localization;
  38. private readonly IEncodingManager _encodingManager;
  39. private readonly IServerConfigurationManager _config;
  40. private readonly ISubtitleManager _subtitleManager;
  41. private readonly IChapterManager _chapterManager;
  42. private readonly ILibraryManager _libraryManager;
  43. private readonly IMediaSourceManager _mediaSourceManager;
  44. private readonly long _dummyChapterDuration = TimeSpan.FromMinutes(5).Ticks;
  45. public FFProbeVideoInfo(
  46. ILogger logger,
  47. IMediaSourceManager mediaSourceManager,
  48. IMediaEncoder mediaEncoder,
  49. IItemRepository itemRepo,
  50. IBlurayExaminer blurayExaminer,
  51. ILocalizationManager localization,
  52. IEncodingManager encodingManager,
  53. IServerConfigurationManager config,
  54. ISubtitleManager subtitleManager,
  55. IChapterManager chapterManager,
  56. ILibraryManager libraryManager)
  57. {
  58. _logger = logger;
  59. _mediaEncoder = mediaEncoder;
  60. _itemRepo = itemRepo;
  61. _blurayExaminer = blurayExaminer;
  62. _localization = localization;
  63. _encodingManager = encodingManager;
  64. _config = config;
  65. _subtitleManager = subtitleManager;
  66. _chapterManager = chapterManager;
  67. _libraryManager = libraryManager;
  68. _mediaSourceManager = mediaSourceManager;
  69. }
  70. public async Task<ItemUpdateType> ProbeVideo<T>(
  71. T item,
  72. MetadataRefreshOptions options,
  73. CancellationToken cancellationToken)
  74. where T : Video
  75. {
  76. BlurayDiscInfo blurayDiscInfo = null;
  77. Model.MediaInfo.MediaInfo mediaInfoResult = null;
  78. if (!item.IsShortcut || options.EnableRemoteContentProbe)
  79. {
  80. string[] streamFileNames = null;
  81. if (item.VideoType == VideoType.Dvd)
  82. {
  83. streamFileNames = FetchFromDvdLib(item);
  84. if (streamFileNames.Length == 0)
  85. {
  86. _logger.LogError("No playable vobs found in dvd structure, skipping ffprobe.");
  87. return ItemUpdateType.MetadataImport;
  88. }
  89. }
  90. else if (item.VideoType == VideoType.BluRay)
  91. {
  92. var inputPath = item.Path;
  93. blurayDiscInfo = GetBDInfo(inputPath);
  94. streamFileNames = blurayDiscInfo.Files;
  95. if (streamFileNames.Length == 0)
  96. {
  97. _logger.LogError("No playable vobs found in bluray structure, skipping ffprobe.");
  98. return ItemUpdateType.MetadataImport;
  99. }
  100. }
  101. streamFileNames ??= Array.Empty<string>();
  102. mediaInfoResult = await GetMediaInfo(item, cancellationToken).ConfigureAwait(false);
  103. cancellationToken.ThrowIfCancellationRequested();
  104. }
  105. await Fetch(item, cancellationToken, mediaInfoResult, blurayDiscInfo, options).ConfigureAwait(false);
  106. return ItemUpdateType.MetadataImport;
  107. }
  108. private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(
  109. Video item,
  110. CancellationToken cancellationToken)
  111. {
  112. cancellationToken.ThrowIfCancellationRequested();
  113. var path = item.Path;
  114. var protocol = item.PathProtocol ?? MediaProtocol.File;
  115. if (item.IsShortcut)
  116. {
  117. path = item.ShortcutPath;
  118. protocol = _mediaSourceManager.GetPathProtocol(path);
  119. }
  120. return _mediaEncoder.GetMediaInfo(
  121. new MediaInfoRequest
  122. {
  123. ExtractChapters = true,
  124. MediaType = DlnaProfileType.Video,
  125. MediaSource = new MediaSourceInfo
  126. {
  127. Path = path,
  128. Protocol = protocol,
  129. VideoType = item.VideoType,
  130. IsoType = item.IsoType
  131. }
  132. },
  133. cancellationToken);
  134. }
  135. protected async Task Fetch(
  136. Video video,
  137. CancellationToken cancellationToken,
  138. Model.MediaInfo.MediaInfo mediaInfo,
  139. BlurayDiscInfo blurayInfo,
  140. MetadataRefreshOptions options)
  141. {
  142. List<MediaStream> mediaStreams;
  143. IReadOnlyList<MediaAttachment> mediaAttachments;
  144. ChapterInfo[] chapters;
  145. if (mediaInfo != null)
  146. {
  147. mediaStreams = mediaInfo.MediaStreams;
  148. mediaAttachments = mediaInfo.MediaAttachments;
  149. video.TotalBitrate = mediaInfo.Bitrate;
  150. // video.FormatName = (mediaInfo.Container ?? string.Empty)
  151. // .Replace("matroska", "mkv", StringComparison.OrdinalIgnoreCase);
  152. // For dvd's this may not always be accurate, so don't set the runtime if the item already has one
  153. var needToSetRuntime = video.VideoType != VideoType.Dvd || video.RunTimeTicks == null || video.RunTimeTicks.Value == 0;
  154. if (needToSetRuntime)
  155. {
  156. video.RunTimeTicks = mediaInfo.RunTimeTicks;
  157. }
  158. video.Size = mediaInfo.Size;
  159. if (video.VideoType == VideoType.VideoFile)
  160. {
  161. var extension = (Path.GetExtension(video.Path) ?? string.Empty).TrimStart('.');
  162. video.Container = extension;
  163. }
  164. else
  165. {
  166. video.Container = null;
  167. }
  168. video.Container = mediaInfo.Container;
  169. chapters = mediaInfo.Chapters == null ? Array.Empty<ChapterInfo>() : mediaInfo.Chapters;
  170. if (blurayInfo != null)
  171. {
  172. FetchBdInfo(video, ref chapters, mediaStreams, blurayInfo);
  173. }
  174. }
  175. else
  176. {
  177. mediaStreams = new List<MediaStream>();
  178. mediaAttachments = Array.Empty<MediaAttachment>();
  179. chapters = Array.Empty<ChapterInfo>();
  180. }
  181. await AddExternalSubtitles(video, mediaStreams, options, cancellationToken).ConfigureAwait(false);
  182. var libraryOptions = _libraryManager.GetLibraryOptions(video);
  183. if (mediaInfo != null)
  184. {
  185. FetchEmbeddedInfo(video, mediaInfo, options, libraryOptions);
  186. FetchPeople(video, mediaInfo, options);
  187. video.Timestamp = mediaInfo.Timestamp;
  188. video.Video3DFormat ??= mediaInfo.Video3DFormat;
  189. }
  190. var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video);
  191. video.Height = videoStream?.Height ?? 0;
  192. video.Width = videoStream?.Width ?? 0;
  193. video.DefaultVideoStreamIndex = videoStream == null ? (int?)null : videoStream.Index;
  194. video.HasSubtitles = mediaStreams.Any(i => i.Type == MediaStreamType.Subtitle);
  195. _itemRepo.SaveMediaStreams(video.Id, mediaStreams, cancellationToken);
  196. _itemRepo.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken);
  197. if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh ||
  198. options.MetadataRefreshMode == MetadataRefreshMode.Default)
  199. {
  200. if (chapters.Length == 0 && mediaStreams.Any(i => i.Type == MediaStreamType.Video))
  201. {
  202. chapters = CreateDummyChapters(video);
  203. }
  204. NormalizeChapterNames(chapters);
  205. var extractDuringScan = false;
  206. if (libraryOptions != null)
  207. {
  208. extractDuringScan = libraryOptions.ExtractChapterImagesDuringLibraryScan;
  209. }
  210. await _encodingManager.RefreshChapterImages(video, options.DirectoryService, chapters, extractDuringScan, false, cancellationToken).ConfigureAwait(false);
  211. _chapterManager.SaveChapters(video.Id, chapters);
  212. }
  213. }
  214. private void NormalizeChapterNames(ChapterInfo[] chapters)
  215. {
  216. for (int i = 0; i < chapters.Length; i++)
  217. {
  218. string name = chapters[i].Name;
  219. // Check if the name is empty and/or if the name is a time
  220. // Some ripping programs do that.
  221. if (string.IsNullOrWhiteSpace(name) ||
  222. TimeSpan.TryParse(name, out _))
  223. {
  224. chapters[i].Name = string.Format(
  225. CultureInfo.InvariantCulture,
  226. _localization.GetLocalizedString("ChapterNameValue"),
  227. (i + 1).ToString(CultureInfo.InvariantCulture));
  228. }
  229. }
  230. }
  231. private void FetchBdInfo(BaseItem item, ref ChapterInfo[] chapters, List<MediaStream> mediaStreams, BlurayDiscInfo blurayInfo)
  232. {
  233. var video = (Video)item;
  234. // video.PlayableStreamFileNames = blurayInfo.Files.ToList();
  235. // Use BD Info if it has multiple m2ts. Otherwise, treat it like a video file and rely more on ffprobe output
  236. if (blurayInfo.Files.Length > 1)
  237. {
  238. int? currentHeight = null;
  239. int? currentWidth = null;
  240. int? currentBitRate = null;
  241. var videoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video);
  242. // Grab the values that ffprobe recorded
  243. if (videoStream != null)
  244. {
  245. currentBitRate = videoStream.BitRate;
  246. currentWidth = videoStream.Width;
  247. currentHeight = videoStream.Height;
  248. }
  249. // Fill video properties from the BDInfo result
  250. mediaStreams.Clear();
  251. mediaStreams.AddRange(blurayInfo.MediaStreams);
  252. if (blurayInfo.RunTimeTicks.HasValue && blurayInfo.RunTimeTicks.Value > 0)
  253. {
  254. video.RunTimeTicks = blurayInfo.RunTimeTicks;
  255. }
  256. if (blurayInfo.Chapters != null)
  257. {
  258. double[] brChapter = blurayInfo.Chapters;
  259. chapters = new ChapterInfo[brChapter.Length];
  260. for (int i = 0; i < brChapter.Length; i++)
  261. {
  262. chapters[i] = new ChapterInfo
  263. {
  264. StartPositionTicks = TimeSpan.FromSeconds(brChapter[i]).Ticks
  265. };
  266. }
  267. }
  268. videoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video);
  269. // Use the ffprobe values if these are empty
  270. if (videoStream != null)
  271. {
  272. videoStream.BitRate = IsEmpty(videoStream.BitRate) ? currentBitRate : videoStream.BitRate;
  273. videoStream.Width = IsEmpty(videoStream.Width) ? currentWidth : videoStream.Width;
  274. videoStream.Height = IsEmpty(videoStream.Height) ? currentHeight : videoStream.Height;
  275. }
  276. }
  277. }
  278. private bool IsEmpty(int? num)
  279. {
  280. return !num.HasValue || num.Value == 0;
  281. }
  282. /// <summary>
  283. /// Gets information about the longest playlist on a bdrom.
  284. /// </summary>
  285. /// <param name="path">The path.</param>
  286. /// <returns>VideoStream.</returns>
  287. private BlurayDiscInfo GetBDInfo(string path)
  288. {
  289. if (string.IsNullOrWhiteSpace(path))
  290. {
  291. throw new ArgumentNullException(nameof(path));
  292. }
  293. try
  294. {
  295. return _blurayExaminer.GetDiscInfo(path);
  296. }
  297. catch (Exception ex)
  298. {
  299. _logger.LogError(ex, "Error getting BDInfo");
  300. return null;
  301. }
  302. }
  303. private void FetchEmbeddedInfo(Video video, Model.MediaInfo.MediaInfo data, MetadataRefreshOptions refreshOptions, LibraryOptions libraryOptions)
  304. {
  305. var isFullRefresh = refreshOptions.MetadataRefreshMode == MetadataRefreshMode.FullRefresh;
  306. if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.OfficialRating))
  307. {
  308. if (!string.IsNullOrWhiteSpace(data.OfficialRating) || isFullRefresh)
  309. {
  310. video.OfficialRating = data.OfficialRating;
  311. }
  312. }
  313. if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.Genres))
  314. {
  315. if (video.Genres.Length == 0 || isFullRefresh)
  316. {
  317. video.Genres = Array.Empty<string>();
  318. foreach (var genre in data.Genres)
  319. {
  320. video.AddGenre(genre);
  321. }
  322. }
  323. }
  324. if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.Studios))
  325. {
  326. if (video.Studios.Length == 0 || isFullRefresh)
  327. {
  328. video.SetStudios(data.Studios);
  329. }
  330. }
  331. if (video is MusicVideo musicVideo)
  332. {
  333. musicVideo.Album = data.Album;
  334. musicVideo.Artists = data.Artists;
  335. }
  336. if (data.ProductionYear.HasValue)
  337. {
  338. if (!video.ProductionYear.HasValue || isFullRefresh)
  339. {
  340. video.ProductionYear = data.ProductionYear;
  341. }
  342. }
  343. if (data.PremiereDate.HasValue)
  344. {
  345. if (!video.PremiereDate.HasValue || isFullRefresh)
  346. {
  347. video.PremiereDate = data.PremiereDate;
  348. }
  349. }
  350. if (data.IndexNumber.HasValue)
  351. {
  352. if (!video.IndexNumber.HasValue || isFullRefresh)
  353. {
  354. video.IndexNumber = data.IndexNumber;
  355. }
  356. }
  357. if (data.ParentIndexNumber.HasValue)
  358. {
  359. if (!video.ParentIndexNumber.HasValue || isFullRefresh)
  360. {
  361. video.ParentIndexNumber = data.ParentIndexNumber;
  362. }
  363. }
  364. if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.Name))
  365. {
  366. if (!string.IsNullOrWhiteSpace(data.Name) && libraryOptions.EnableEmbeddedTitles)
  367. {
  368. // Don't use the embedded name for extras because it will often be the same name as the movie
  369. if (!video.ExtraType.HasValue)
  370. {
  371. video.Name = data.Name;
  372. }
  373. }
  374. if (!string.IsNullOrWhiteSpace(data.ForcedSortName))
  375. {
  376. video.ForcedSortName = data.ForcedSortName;
  377. }
  378. }
  379. // If we don't have a ProductionYear try and get it from PremiereDate
  380. if (video.PremiereDate.HasValue && !video.ProductionYear.HasValue)
  381. {
  382. video.ProductionYear = video.PremiereDate.Value.ToLocalTime().Year;
  383. }
  384. if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.Overview))
  385. {
  386. if (string.IsNullOrWhiteSpace(video.Overview) || isFullRefresh)
  387. {
  388. video.Overview = data.Overview;
  389. }
  390. }
  391. }
  392. private void FetchPeople(Video video, Model.MediaInfo.MediaInfo data, MetadataRefreshOptions options)
  393. {
  394. var isFullRefresh = options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh;
  395. if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.Cast))
  396. {
  397. if (isFullRefresh || _libraryManager.GetPeople(video).Count == 0)
  398. {
  399. var people = new List<PersonInfo>();
  400. foreach (var person in data.People)
  401. {
  402. PeopleHelper.AddPerson(people, new PersonInfo
  403. {
  404. Name = person.Name,
  405. Type = person.Type,
  406. Role = person.Role
  407. });
  408. }
  409. _libraryManager.UpdatePeople(video, people);
  410. }
  411. }
  412. }
  413. private SubtitleOptions GetOptions()
  414. {
  415. return _config.GetConfiguration<SubtitleOptions>("subtitles");
  416. }
  417. /// <summary>
  418. /// Adds the external subtitles.
  419. /// </summary>
  420. /// <param name="video">The video.</param>
  421. /// <param name="currentStreams">The current streams.</param>
  422. /// <param name="options">The refreshOptions.</param>
  423. /// <param name="cancellationToken">The cancellation token.</param>
  424. /// <returns>Task.</returns>
  425. private async Task AddExternalSubtitles(
  426. Video video,
  427. List<MediaStream> currentStreams,
  428. MetadataRefreshOptions options,
  429. CancellationToken cancellationToken)
  430. {
  431. var subtitleResolver = new SubtitleResolver(_localization);
  432. var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
  433. var externalSubtitleStreams = subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, false);
  434. var enableSubtitleDownloading = options.MetadataRefreshMode == MetadataRefreshMode.Default ||
  435. options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh;
  436. var subtitleOptions = GetOptions();
  437. var libraryOptions = _libraryManager.GetLibraryOptions(video);
  438. string[] subtitleDownloadLanguages;
  439. bool skipIfEmbeddedSubtitlesPresent;
  440. bool skipIfAudioTrackMatches;
  441. bool requirePerfectMatch;
  442. bool enabled;
  443. if (libraryOptions.SubtitleDownloadLanguages == null)
  444. {
  445. subtitleDownloadLanguages = subtitleOptions.DownloadLanguages;
  446. skipIfEmbeddedSubtitlesPresent = subtitleOptions.SkipIfEmbeddedSubtitlesPresent;
  447. skipIfAudioTrackMatches = subtitleOptions.SkipIfAudioTrackMatches;
  448. requirePerfectMatch = subtitleOptions.RequirePerfectMatch;
  449. enabled = (subtitleOptions.DownloadEpisodeSubtitles &&
  450. video is Episode) ||
  451. (subtitleOptions.DownloadMovieSubtitles &&
  452. video is Movie);
  453. }
  454. else
  455. {
  456. subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages;
  457. skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent;
  458. skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches;
  459. requirePerfectMatch = libraryOptions.RequirePerfectSubtitleMatch;
  460. enabled = true;
  461. }
  462. if (enableSubtitleDownloading && enabled)
  463. {
  464. var downloadedLanguages = await new SubtitleDownloader(
  465. _logger,
  466. _subtitleManager).DownloadSubtitles(
  467. video,
  468. currentStreams.Concat(externalSubtitleStreams).ToList(),
  469. skipIfEmbeddedSubtitlesPresent,
  470. skipIfAudioTrackMatches,
  471. requirePerfectMatch,
  472. subtitleDownloadLanguages,
  473. libraryOptions.DisabledSubtitleFetchers,
  474. libraryOptions.SubtitleFetcherOrder,
  475. cancellationToken).ConfigureAwait(false);
  476. // Rescan
  477. if (downloadedLanguages.Count > 0)
  478. {
  479. externalSubtitleStreams = subtitleResolver.GetExternalSubtitleStreams(video, startIndex, options.DirectoryService, true);
  480. }
  481. }
  482. video.SubtitleFiles = externalSubtitleStreams.Select(i => i.Path).ToArray();
  483. currentStreams.AddRange(externalSubtitleStreams);
  484. }
  485. /// <summary>
  486. /// Creates dummy chapters.
  487. /// </summary>
  488. /// <param name="video">The video.</param>
  489. /// <returns>An array of dummy chapters.</returns>
  490. private ChapterInfo[] CreateDummyChapters(Video video)
  491. {
  492. var runtime = video.RunTimeTicks ?? 0;
  493. if (runtime < 0)
  494. {
  495. throw new ArgumentException(
  496. string.Format(
  497. CultureInfo.InvariantCulture,
  498. "{0} has invalid runtime of {1}",
  499. video.Name,
  500. runtime));
  501. }
  502. if (runtime < _dummyChapterDuration)
  503. {
  504. return Array.Empty<ChapterInfo>();
  505. }
  506. // Limit to 100 chapters just in case there's some incorrect metadata here
  507. int chapterCount = (int)Math.Min(runtime / _dummyChapterDuration, 100);
  508. var chapters = new ChapterInfo[chapterCount];
  509. long currentChapterTicks = 0;
  510. for (int i = 0; i < chapterCount; i++)
  511. {
  512. chapters[i] = new ChapterInfo
  513. {
  514. StartPositionTicks = currentChapterTicks
  515. };
  516. currentChapterTicks += _dummyChapterDuration;
  517. }
  518. return chapters;
  519. }
  520. private string[] FetchFromDvdLib(Video item)
  521. {
  522. var path = item.Path;
  523. var dvd = new Dvd(path);
  524. var primaryTitle = dvd.Titles.OrderByDescending(GetRuntime).FirstOrDefault();
  525. byte? titleNumber = null;
  526. if (primaryTitle != null)
  527. {
  528. titleNumber = primaryTitle.VideoTitleSetNumber;
  529. item.RunTimeTicks = GetRuntime(primaryTitle);
  530. }
  531. return _mediaEncoder.GetPrimaryPlaylistVobFiles(item.Path, titleNumber)
  532. .Select(Path.GetFileName)
  533. .ToArray();
  534. }
  535. private long GetRuntime(Title title)
  536. {
  537. return title.ProgramChains
  538. .Select(i => (TimeSpan)i.PlaybackTime)
  539. .Select(i => i.Ticks)
  540. .Sum();
  541. }
  542. }
  543. }