/// <summary> /// Normalizes the FF probe result. /// </summary> /// <param name="result">The result.</param> public static void NormalizeFFProbeResult(InternalMediaInfoResult result) { if (result == null) { throw new ArgumentNullException("result"); } if (result.format != null && result.format.tags != null) { result.format.tags = ConvertDictionaryToCaseInSensitive(result.format.tags); } if (result.streams != null) { // Convert all dictionaries to case insensitive foreach (var stream in result.streams) { if (stream.tags != null) { stream.tags = ConvertDictionaryToCaseInSensitive(stream.tags); } if (stream.disposition != null) { stream.disposition = ConvertDictionaryToCaseInSensitive(stream.disposition); } } } }
/// <summary> /// Fetches the specified audio. /// </summary> /// <param name="audio">The audio.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <param name="data">The data.</param> /// <returns>Task.</returns> protected Task Fetch(Audio audio, CancellationToken cancellationToken, InternalMediaInfoResult data) { var mediaInfo = MediaEncoderHelpers.GetMediaInfo(data); var mediaStreams = mediaInfo.MediaStreams; audio.FormatName = mediaInfo.Format; audio.TotalBitrate = mediaInfo.TotalBitrate; audio.HasEmbeddedImage = mediaStreams.Any(i => i.Type == MediaStreamType.EmbeddedImage); if (data.streams != null) { // Get the first audio stream var stream = data.streams.FirstOrDefault(s => string.Equals(s.codec_type, "audio", StringComparison.OrdinalIgnoreCase)); if (stream != null) { // Get duration from stream properties var duration = stream.duration; // If it's not there go into format properties if (string.IsNullOrEmpty(duration)) { duration = data.format.duration; } // If we got something, parse it if (!string.IsNullOrEmpty(duration)) { audio.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(duration, _usCulture)).Ticks; } } } if (data.format != null) { var extension = (Path.GetExtension(audio.Path) ?? string.Empty).TrimStart('.'); audio.Container = extension; if (!string.IsNullOrEmpty(data.format.size)) { audio.Size = long.Parse(data.format.size, _usCulture); } else { audio.Size = null; } if (data.format.tags != null) { FetchDataFromTags(audio, data.format.tags); } } return(_itemRepo.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken)); }
/// <summary> /// Adds the chapters. /// </summary> /// <param name="result">The result.</param> /// <param name="standardError">The standard error.</param> private void AddChapters(InternalMediaInfoResult result, string standardError) { var lines = standardError.Split('\n').Select(l => l.TrimStart()); var chapters = new List <ChapterInfo>(); ChapterInfo lastChapter = null; foreach (var line in lines) { if (line.StartsWith("Chapter", StringComparison.OrdinalIgnoreCase)) { // Example: // Chapter #0.2: start 400.534, end 4565.435 const string srch = "start "; var start = line.IndexOf(srch, StringComparison.OrdinalIgnoreCase); if (start == -1) { continue; } var subString = line.Substring(start + srch.Length); subString = subString.Substring(0, subString.IndexOf(',')); double seconds; if (double.TryParse(subString, NumberStyles.Any, UsCulture, out seconds)) { lastChapter = new ChapterInfo { StartPositionTicks = TimeSpan.FromSeconds(seconds).Ticks }; chapters.Add(lastChapter); } } else if (line.StartsWith("title", StringComparison.OrdinalIgnoreCase)) { if (lastChapter != null && string.IsNullOrEmpty(lastChapter.Name)) { var index = line.IndexOf(':'); if (index != -1) { lastChapter.Name = line.Substring(index + 1).Trim().TrimEnd('\r'); } } } } result.Chapters = chapters; }
protected async Task Fetch(Video video, CancellationToken cancellationToken, InternalMediaInfoResult data, IIsoMount isoMount, BlurayDiscInfo blurayInfo, IDirectoryService directoryService) { if (data.format != null) { // For dvd's this may not always be accurate, so don't set the runtime if the item already has one var needToSetRuntime = video.VideoType != VideoType.Dvd || video.RunTimeTicks == null || video.RunTimeTicks.Value == 0; if (needToSetRuntime && !string.IsNullOrEmpty(data.format.duration)) { video.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(data.format.duration, _usCulture)).Ticks; } } var mediaStreams = MediaEncoderHelpers.GetMediaInfo(data).MediaStreams; var chapters = data.Chapters ?? new List <ChapterInfo>(); if (video.VideoType == VideoType.BluRay || (video.IsoType.HasValue && video.IsoType.Value == IsoType.BluRay)) { FetchBdInfo(video, chapters, mediaStreams, blurayInfo); } AddExternalSubtitles(video, mediaStreams, directoryService); FetchWtvInfo(video, data); video.IsHD = mediaStreams.Any(i => i.Type == MediaStreamType.Video && i.Width.HasValue && i.Width.Value >= 1270); if (chapters.Count == 0 && mediaStreams.Any(i => i.Type == MediaStreamType.Video)) { AddDummyChapters(video, chapters); } var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video); video.VideoBitRate = videoStream == null ? null : videoStream.BitRate; video.DefaultVideoStreamIndex = videoStream == null ? (int?)null : videoStream.Index; video.HasSubtitles = mediaStreams.Any(i => i.Type == MediaStreamType.Subtitle); await _encodingManager.RefreshChapterImages(new ChapterImageRefreshOptions { Chapters = chapters, Video = video, ExtractImages = false, SaveChapters = false }, cancellationToken).ConfigureAwait(false); await _itemRepo.SaveMediaStreams(video.Id, mediaStreams, cancellationToken).ConfigureAwait(false); await _itemRepo.SaveChapters(video.Id, chapters, cancellationToken).ConfigureAwait(false); }
/// <summary> /// Fetches the specified audio. /// </summary> /// <param name="audio">The audio.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <param name="data">The data.</param> /// <returns>Task.</returns> protected Task Fetch(Audio audio, CancellationToken cancellationToken, InternalMediaInfoResult data) { var mediaStreams = MediaEncoderHelpers.GetMediaInfo(data).MediaStreams; audio.HasEmbeddedImage = mediaStreams.Any(i => i.Type == MediaStreamType.Video); if (data.streams != null) { // Get the first audio stream var stream = data.streams.FirstOrDefault(s => string.Equals(s.codec_type, "audio", StringComparison.OrdinalIgnoreCase)); if (stream != null) { // Get duration from stream properties var duration = stream.duration; // If it's not there go into format properties if (string.IsNullOrEmpty(duration)) { duration = data.format.duration; } // If we got something, parse it if (!string.IsNullOrEmpty(duration)) { audio.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(duration, _usCulture)).Ticks; } } } if (data.format.tags != null) { FetchDataFromTags(audio, data.format.tags); } return(_itemRepo.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken)); }
/// <summary> /// Normalizes the FF probe result. /// </summary> /// <param name="result">The result.</param> public static void NormalizeFFProbeResult(InternalMediaInfoResult result) { if (result.format != null && result.format.tags != null) { result.format.tags = ConvertDictionaryToCaseInSensitive(result.format.tags); } if (result.streams != null) { // Convert all dictionaries to case insensitive foreach (var stream in result.streams) { if (stream.tags != null) { stream.tags = ConvertDictionaryToCaseInSensitive(stream.tags); } if (stream.disposition != null) { stream.disposition = ConvertDictionaryToCaseInSensitive(stream.disposition); } } } }
public const int MaxSubtitleDescriptionExtractionLength = 100; // When extracting subtitles, the maximum length to consider (to avoid invalid filenames) private void FetchWtvInfo(Video video, InternalMediaInfoResult data) { if (data.format == null || data.format.tags == null) { return; } if (!video.LockedFields.Contains(MetadataFields.Genres)) { var genres = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/Genre"); if (!string.IsNullOrWhiteSpace(genres)) { //genres = FFProbeHelpers.GetDictionaryValue(data.format.tags, "genre"); } if (!string.IsNullOrWhiteSpace(genres)) { video.Genres = genres.Split(new[] { ';', '/', ',' }, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) .Select(i => i.Trim()) .ToList(); } } if (!video.LockedFields.Contains(MetadataFields.OfficialRating)) { var officialRating = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/ParentalRating"); if (!string.IsNullOrWhiteSpace(officialRating)) { video.OfficialRating = officialRating; } } if (!video.LockedFields.Contains(MetadataFields.Cast)) { var people = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/MediaCredits"); if (!string.IsNullOrEmpty(people)) { video.People = people.Split(new[] { ';', '/' }, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) .Select(i => new PersonInfo { Name = i.Trim(), Type = PersonType.Actor }) .ToList(); } } var year = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/OriginalReleaseTime"); if (!string.IsNullOrWhiteSpace(year)) { int val; if (int.TryParse(year, NumberStyles.Integer, _usCulture, out val)) { video.ProductionYear = val; } } var premiereDateString = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/MediaOriginalBroadcastDateTime"); if (!string.IsNullOrWhiteSpace(premiereDateString)) { DateTime val; // Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ // DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None) if (DateTime.TryParse(year, null, DateTimeStyles.None, out val)) { video.PremiereDate = val.ToUniversalTime(); } } var description = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/SubTitleDescription"); var episode = video as Episode; if (episode != null) { var subTitle = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/SubTitle"); // For below code, credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ // Sometimes for TV Shows the Subtitle field is empty and the subtitle description contains the subtitle, extract if possible. See ticket https://mcebuddy2x.codeplex.com/workitem/1910 // The format is -> EPISODE/TOTAL_EPISODES_IN_SEASON. SUBTITLE: DESCRIPTION // OR -> COMMENT. SUBTITLE: DESCRIPTION // e.g. -> 4/13. The Doctor's Wife: Science fiction drama. When he follows a Time Lord distress signal, the Doctor puts Amy, Rory and his beloved TARDIS in grave danger. Also in HD. [AD,S] // e.g. -> CBeebies Bedtime Hour. The Mystery: Animated adventures of two friends who live on an island in the middle of the big city. Some of Abney and Teal's favourite objects are missing. [S] if (String.IsNullOrWhiteSpace(subTitle) && !String.IsNullOrWhiteSpace(description) && description.Substring(0, Math.Min(description.Length, MaxSubtitleDescriptionExtractionLength)).Contains(":")) // Check within the Subtitle size limit, otherwise from description it can get too long creating an invalid filename { string[] parts = description.Split(':'); if (parts.Length > 0) { string subtitle = parts[0]; try { if (subtitle.Contains("/")) // It contains a episode number and season number { string[] numbers = subtitle.Split(' '); episode.IndexNumber = int.Parse(numbers[0].Replace(".", "").Split('/')[0]); int totalEpisodesInSeason = int.Parse(numbers[0].Replace(".", "").Split('/')[1]); description = String.Join(" ", numbers, 1, numbers.Length - 1).Trim(); // Skip the first, concatenate the rest, clean up spaces and save it } else { throw new Exception(); // Switch to default parsing } } catch // Default parsing { if (subtitle.Contains(".")) // skip the comment, keep the subtitle { description = String.Join(".", subtitle.Split('.'), 1, subtitle.Split('.').Length - 1).Trim(); // skip the first } else { description = subtitle.Trim(); // Clean up whitespaces and save it } } } } } if (!video.LockedFields.Contains(MetadataFields.Overview)) { if (!string.IsNullOrWhiteSpace(description)) { video.Overview = description; } } }
protected async Task Fetch(Video video, CancellationToken cancellationToken, InternalMediaInfoResult data, IIsoMount isoMount, BlurayDiscInfo blurayInfo, MetadataRefreshOptions options) { var mediaInfo = MediaEncoderHelpers.GetMediaInfo(data); var mediaStreams = mediaInfo.MediaStreams; video.TotalBitrate = mediaInfo.TotalBitrate; video.FormatName = (mediaInfo.Format ?? string.Empty) .Replace("matroska", "mkv", StringComparison.OrdinalIgnoreCase); if (data.format != null) { // For dvd's this may not always be accurate, so don't set the runtime if the item already has one var needToSetRuntime = video.VideoType != VideoType.Dvd || video.RunTimeTicks == null || video.RunTimeTicks.Value == 0; if (needToSetRuntime && !string.IsNullOrEmpty(data.format.duration)) { video.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(data.format.duration, _usCulture)).Ticks; } if (video.VideoType == VideoType.VideoFile) { var extension = (Path.GetExtension(video.Path) ?? string.Empty).TrimStart('.'); video.Container = extension; } else { video.Container = null; } if (!string.IsNullOrEmpty(data.format.size)) { video.Size = long.Parse(data.format.size, _usCulture); } else { video.Size = null; } } var mediaChapters = (data.Chapters ?? new MediaChapter[] { }).ToList(); var chapters = mediaChapters.Select(GetChapterInfo).ToList(); if (video.VideoType == VideoType.BluRay || (video.IsoType.HasValue && video.IsoType.Value == IsoType.BluRay)) { FetchBdInfo(video, chapters, mediaStreams, blurayInfo); } await AddExternalSubtitles(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); FetchWtvInfo(video, data); video.IsHD = mediaStreams.Any(i => i.Type == MediaStreamType.Video && i.Width.HasValue && i.Width.Value >= 1270); var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video); video.VideoBitRate = videoStream == null ? null : videoStream.BitRate; video.DefaultVideoStreamIndex = videoStream == null ? (int?)null : videoStream.Index; video.HasSubtitles = mediaStreams.Any(i => i.Type == MediaStreamType.Subtitle); ExtractTimestamp(video); UpdateFromMediaInfo(video, videoStream); await _itemRepo.SaveMediaStreams(video.Id, mediaStreams, cancellationToken).ConfigureAwait(false); if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || options.MetadataRefreshMode == MetadataRefreshMode.Default) { var chapterOptions = _chapterManager.GetConfiguration(); try { var remoteChapters = await DownloadChapters(video, chapters, chapterOptions, cancellationToken).ConfigureAwait(false); if (remoteChapters.Count > 0) { chapters = remoteChapters; } } catch (Exception ex) { _logger.ErrorException("Error downloading chapters", ex); } if (chapters.Count == 0 && mediaStreams.Any(i => i.Type == MediaStreamType.Video)) { AddDummyChapters(video, chapters); } NormalizeChapterNames(chapters); await _encodingManager.RefreshChapterImages(new ChapterImageRefreshOptions { Chapters = chapters, Video = video, ExtractImages = chapterOptions.ExtractDuringLibraryScan, SaveChapters = false }, cancellationToken).ConfigureAwait(false); await _chapterManager.SaveChapters(video.Id.ToString(), chapters, cancellationToken).ConfigureAwait(false); } }
private void FetchWtvInfo(Video video, InternalMediaInfoResult data) { if (data.format == null || data.format.tags == null) { return; } if (video.Genres.Count == 0) { if (!video.LockedFields.Contains(MetadataFields.Genres)) { var genres = FFProbeHelpers.GetDictionaryValue(data.format.tags, "genre"); if (!string.IsNullOrEmpty(genres)) { video.Genres = genres.Split(new[] { ';', '/', ',' }, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) .Select(i => i.Trim()) .ToList(); } } } if (string.IsNullOrEmpty(video.Overview)) { if (!video.LockedFields.Contains(MetadataFields.Overview)) { var overview = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/SubTitleDescription"); if (!string.IsNullOrWhiteSpace(overview)) { video.Overview = overview; } } } if (string.IsNullOrEmpty(video.OfficialRating)) { var officialRating = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/ParentalRating"); if (!string.IsNullOrWhiteSpace(officialRating)) { if (!video.LockedFields.Contains(MetadataFields.OfficialRating)) { video.OfficialRating = officialRating; } } } if (video.People.Count == 0) { if (!video.LockedFields.Contains(MetadataFields.Cast)) { var people = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/MediaCredits"); if (!string.IsNullOrEmpty(people)) { video.People = people.Split(new[] { ';', '/' }, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) .Select(i => new PersonInfo { Name = i.Trim(), Type = PersonType.Actor }) .ToList(); } } } if (!video.ProductionYear.HasValue) { var year = FFProbeHelpers.GetDictionaryValue(data.format.tags, "WM/OriginalReleaseTime"); if (!string.IsNullOrWhiteSpace(year)) { int val; if (int.TryParse(year, NumberStyles.Integer, _usCulture, out val)) { video.ProductionYear = val; } } } }
static void Main(string[] args) { if (args.Length != 2) { Console.WriteLine("Usage (this one is bugged for now): MediaInfoTest -images <directory>"); Console.WriteLine("Benchmark grabbing media info for all image files in this directory and subdirectories."); Console.WriteLine("Usage: MediaInfoTest -av <directory>"); Console.WriteLine("Benchmark grabbing media info for all audio and video files in this directory and subdirectories."); Console.WriteLine("Note: For both ffprobe executable must be in PATH"); Environment.Exit(1); } string mode = "av"; if (args[0] == "-images") { Console.WriteLine("Filtering for image files."); mode = "img"; } else if (args[0] == "-av") { Console.WriteLine("Filtering for audio and video files."); mode = "av"; } else { Console.WriteLine("Did not understand first argument"); Environment.Exit(2); } List <string> extensions = new List <string>(); using (Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(string.Format("MediaInfoTest.extensions-{0}.txt", mode))) using (StreamReader reader = new StreamReader(stream)) { while (!reader.EndOfStream) { var extension = "." + reader.ReadLine(); if (!extensions.Contains(extension)) { extensions.Add(extension); } } } var _logger = new ConsoleLogger(); DirectoryInfo dir = new DirectoryInfo(args[1]); if (!dir.Exists) { Console.WriteLine("Directory does not exist."); Environment.Exit(3); } Stopwatch sw = Stopwatch.StartNew(); Stopwatch inner_sw = new Stopwatch(); int count = 0; Dictionary <string, Stats> stats = new Dictionary <string, Stats>(); var files = dir.GetFiles("*.*", SearchOption.AllDirectories); var filteredFiles = files.Where(x => extensions.Contains(x.Extension.ToLowerInvariant())).ToList(); var skipped_extensions = files.Where(x => !extensions.Contains(x.Extension.ToLowerInvariant())).Select(x => x.Extension).Distinct().ToList(); var failures = new List <Failure>(); int total_files = filteredFiles.Count; foreach (FileInfo file in filteredFiles) { if (!stats.ContainsKey(file.Extension.ToLowerInvariant())) { stats.Add(file.Extension.ToLowerInvariant(), new Stats()); } if (file.Exists) { Console.Write("{1} of {2}: {0}", file.Name, count + 1, total_files); inner_sw.Restart(); MediaInfoWrapper info = new MediaInfoWrapper(file.FullName /*, _logger*/); //foreach (VideoStream stream in info.VideoStreams) //{ // Console.WriteLine("Title: {0} {1}", stream.Resolution, stream.CodecName); // Console.WriteLine("Codec: {0}", stream.CodecName); // Console.WriteLine("AVC: {0}", stream.Format); // Console.WriteLine("Profile: {0}", stream.CodecProfile); // Console.WriteLine("Resolution: {0}x{1}", stream.Size.Width, stream.Size.Height); //} //foreach (AudioStream stream in info.AudioStreams) //{ //} //foreach (SubtitleStream stream in info.Subtitles) //{ //} inner_sw.Stop(); stats[file.Extension.ToLowerInvariant()].TimeMediaInfo += inner_sw.Elapsed; count++; stats[file.Extension.ToLowerInvariant()].Count++; Console.Write(" -> MediaInfo Done (v{0}:a{1}:s{2});", info.VideoStreams.Count, info.AudioStreams.Count, info.Subtitles.Count); inner_sw.Restart(); var process = new ProcessOptions(); // Configure the process using the StartInfo properties. process.FileName = @"ffprobe"; process.UseShellExecute = false; process.RedirectStandardOutput = true; process.Arguments = string.Format("-analyzeduration 3000000 -i \"{0}\" -threads 0 -v warning -print_format json -show_streams -show_chapters -show_format", file.FullName.Replace("\"", "\\\"")); process.IsHidden = true; process.ErrorDialog = false; process.EnableRaisingEvents = true; var commonProcess = new CommonProcess(process); commonProcess.Start(); InternalMediaInfoResult info_ff = new InternalMediaInfoResult(); try { info_ff = JsonSerializer.DeserializeFromStream <InternalMediaInfoResult>(commonProcess.StandardOutput.BaseStream); } catch { commonProcess.Kill(); Console.Write(" -> Failed..."); } commonProcess.WaitForExit(1000); inner_sw.Stop(); stats[file.Extension.ToLowerInvariant()].TimeFFProbe += inner_sw.Elapsed; int videostreams = info_ff.streams.Where(x => x.codec_type == "video" && x.disposition["attached_pic"] == "0").Count(); int audiostreams = info_ff.streams.Where(x => x.codec_type == "audio").Count(); int substreams = info_ff.streams.Where(x => x.codec_type == "subtitle").Count(); Console.WriteLine(" -> FFProbe Done (v{0}:a{1}:s{2}).", videostreams, audiostreams, substreams); if (videostreams != info.VideoStreams.Count || audiostreams != info.AudioStreams.Count || substreams != info.Subtitles.Count) { Console.WriteLine("FFProbe and MediaInfo do not agree on the number of streams!", videostreams, audiostreams, substreams); failures.Add(new Failure() { file = file, mediainfo = info, ffprobe = info_ff }); } } else { Console.WriteLine(string.Format("File {0} not found!", file.Name)); } } sw.Stop(); Console.WriteLine(string.Format("Took {0} for {1} * 2 items ({2:f2} i/s).", sw.Elapsed, count, (count * 2) / sw.Elapsed.TotalSeconds)); foreach (var kvp in stats) { Console.WriteLine(string.Format("Extension: {0}, Count: {1}, Time MI: {2}, Speed MI: {4:f2} i/s, Time FF: {3}, Speed FF: {5:f2} i/s.", kvp.Key, kvp.Value.Count, kvp.Value.TimeMediaInfo, kvp.Value.TimeFFProbe, kvp.Value.Count / kvp.Value.TimeMediaInfo.TotalSeconds, kvp.Value.Count / kvp.Value.TimeFFProbe.TotalSeconds)); } foreach (var me in skipped_extensions) { Console.WriteLine(string.Format("Skipped Extension: {0}", me)); } foreach (var failure in failures) { Console.WriteLine(string.Format("Failure: Name: {0}; Streams MI: v{1}:a{2}:s{3}; Streams FF: v{4}:a{5}:s{6};", failure.file.FullName, failure.mediainfo.VideoStreams.Count, failure.mediainfo.AudioStreams.Count, failure.mediainfo.Subtitles.Count, failure.ffprobe.streams.Where(x => x.codec_type == "video" && x.disposition["attached_pic"] == "0").Count(), failure.ffprobe.streams.Where(x => x.codec_type == "audio").Count(), failure.ffprobe.streams.Where(x => x.codec_type == "subtitle").Count() )); } }