From a68914ca8f74ad82e62844054aa6dd00ac7eec7c Mon Sep 17 00:00:00 2001 From: ConfusedPolarBear <33811686+ConfusedPolarBear@users.noreply.github.com> Date: Thu, 24 Nov 2022 00:43:23 -0600 Subject: [PATCH] Add chapter name based media file analyzer Closes #58 --- .../TestChapterAnalyzer.cs | 83 +++++++++++ .../Analyzers/ChapterAnalyzer.cs | 138 ++++++++++++++++++ .../Analyzers/ChromaprintAnalyzer.cs | 1 - .../Configuration/PluginConfiguration.cs | 16 +- .../Controllers/SkipIntroController.cs | 14 +- .../Plugin.cs | 119 ++++++++------- .../ScheduledTasks/DetectCreditsTask.cs | 3 - .../ScheduledTasks/DetectIntroductionsTask.cs | 4 + 8 files changed, 312 insertions(+), 66 deletions(-) create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs new file mode 100644 index 0000000..e1da0ab --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs @@ -0,0 +1,83 @@ +namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; +using Xunit; + +public class TestChapterAnalyzer +{ + [Theory] + [InlineData("Opening")] + [InlineData("OP")] + [InlineData("Intro")] + [InlineData("Intro Start")] + [InlineData("Introduction")] + public void TestIntroductionExpression(string chapterName) + { + var chapters = CreateChapters(chapterName, AnalysisMode.Introduction); + var introChapter = FindChapter(chapters, AnalysisMode.Introduction); + + Assert.NotNull(introChapter); + Assert.Equal(60, introChapter.IntroStart); + Assert.Equal(90, introChapter.IntroEnd); + } + + [Theory] + [InlineData("End Credits")] + [InlineData("Ending")] + [InlineData("Credit start")] + [InlineData("Closing Credits")] + [InlineData("Credits")] + public void TestEndCreditsExpression(string chapterName) + { + var chapters = CreateChapters(chapterName, AnalysisMode.Credits); + var creditsChapter = FindChapter(chapters, AnalysisMode.Credits); + + Assert.NotNull(creditsChapter); + Assert.Equal(1890, creditsChapter.IntroStart); + Assert.Equal(2000, creditsChapter.IntroEnd); + } + + private Intro? FindChapter(Collection chapters, AnalysisMode mode) + { + var logger = new LoggerFactory().CreateLogger(); + var analyzer = new ChapterAnalyzer(logger); + + var config = new Configuration.PluginConfiguration(); + var expression = mode == AnalysisMode.Introduction ? + config.ChapterAnalyzerIntroductionPattern : + config.ChapterAnalyzerEndCreditsPattern; + + return analyzer.FindMatchingChapter(Guid.Empty, 2000, chapters, expression, mode); + } + + private Collection CreateChapters(string name, AnalysisMode mode) + { + var chapters = new[]{ + CreateChapter("Cold Open", 0), + CreateChapter(mode == AnalysisMode.Introduction ? name : "Introduction", 60), + CreateChapter("Main Episode", 90), + CreateChapter(mode == AnalysisMode.Credits ? name : "Credits", 1890) + }; + + return new(new List(chapters)); + } + + /// + /// Create a ChapterInfo object. + /// + /// Chapter name. + /// Chapter position (in seconds). + /// ChapterInfo. + private ChapterInfo CreateChapter(string name, int position) + { + return new() + { + Name = name, + StartPositionTicks = TimeSpan.FromSeconds(position).Ticks + }; + } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs new file mode 100644 index 0000000..86efd53 --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs @@ -0,0 +1,138 @@ +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text.RegularExpressions; +using System.Threading; +using Microsoft.Extensions.Logging; +using MediaBrowser.Model.Entities; + +/// +/// Chapter name analyzer. +/// +public class ChapterAnalyzer : IMediaFileAnalyzer +{ + private ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + public ChapterAnalyzer(ILogger logger) + { + _logger = logger; + } + + /// + public ReadOnlyCollection AnalyzeMediaFiles( + ReadOnlyCollection analysisQueue, + AnalysisMode mode, + CancellationToken cancellationToken) + { + var skippableRanges = new Dictionary(); + var expression = mode == AnalysisMode.Introduction ? + Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern : + Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern; + + foreach (var episode in analysisQueue) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + var skipRange = FindMatchingChapter( + episode.EpisodeId, + episode.Duration, + new(Plugin.Instance!.GetChapters(episode.EpisodeId)), + expression, + mode); + + if (skipRange is null) + { + continue; + } + + skippableRanges.Add(episode.EpisodeId, skipRange); + } + + Plugin.Instance!.UpdateTimestamps(skippableRanges, mode); + + return analysisQueue; + } + + /// + /// Searches a list of chapter names for one that matches the provided regular expression. + /// Only public to allow for unit testing. + /// + /// Item id. + /// Duration of media file in seconds. + /// Media item chapters. + /// Regular expression pattern. + /// Analysis mode. + /// Intro object containing skippable time range, or null if no chapter matched. + public Intro? FindMatchingChapter( + Guid id, + int duration, + Collection chapters, + string expression, + AnalysisMode mode) + { + Intro? matchingChapter = null; + + var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration(); + var minDuration = config.MinimumIntroDuration; + int maxDuration = mode == AnalysisMode.Introduction ? + config.MaximumIntroDuration : + config.MaximumEpisodeCreditsDuration; + + if (mode == AnalysisMode.Credits) + { + // Since the ending credits chapter may be the last chapter in the file, append a virtual + // chapter at the very end of the file. + chapters.Add(new ChapterInfo() + { + StartPositionTicks = TimeSpan.FromSeconds(duration).Ticks + }); + } + + // Check all chapters + for (int i = 0; i < chapters.Count - 1; i++) + { + // Calculate chapter position and duration + var current = chapters[i]; + var next = chapters[i + 1]; + + var currentRange = new TimeRange( + TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds, + TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds); + + // Skip chapters with that don't have a name or are too short/long + if (string.IsNullOrEmpty(current.Name) || + currentRange.Duration < minDuration || + currentRange.Duration > maxDuration) + { + continue; + } + + // Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex + // between function invocations. + var match = Regex.IsMatch( + current.Name, + expression, + RegexOptions.None, + TimeSpan.FromSeconds(1)); + + if (!match) + { + continue; + } + + matchingChapter = new Intro(id, currentRange); + break; + } + + return matchingChapter; + } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs index 04f9da0..f226835 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs @@ -82,7 +82,6 @@ public ReadOnlyCollection AnalyzeMediaFiles( } catch (FingerprintException ex) { - // TODO: FIXME: move to debug level? _logger.LogWarning("Caught fingerprint error: {Ex}", ex); // Fallback to an empty fingerprint on any error diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs index f4789db..463d5b6 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs @@ -73,15 +73,27 @@ public PluginConfiguration() public int MaximumIntroDuration { get; set; } = 120; /// - /// Gets or sets the upper limit (in minutes) on the length of each episode's audio track that will be analyzed when searching for ending credits. + /// Gets or sets the upper limit (in seconds) on the length of each episode's audio track that will be analyzed when searching for ending credits. /// - public int MaximumEpisodeCreditsDuration { get; set; } = 4; + public int MaximumEpisodeCreditsDuration { get; set; } = 240; /// /// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame. /// public int BlackFrameMinimumPercentage { get; set; } = 85; + /// + /// Gets or sets the regular expression used to detect introduction chapters. + /// + public string ChapterAnalyzerIntroductionPattern { get; set; } = + @"(^|\s)(Intro|Introduction|OP|Opening)(\s|$)"; + + /// + /// Gets or sets the regular expression used to detect ending credit chapters. + /// + public string ChapterAnalyzerEndCreditsPattern { get; set; } = + @"(^|\s)(Credits?|Ending)(\s|$)"; + // ===== Playback settings ===== /// diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs index d6c97f6..b4631b7 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs @@ -73,18 +73,24 @@ public ActionResult ResetIntroTimestamps() } /// - /// Get all introductions. Only used by the end to end testing script. + /// Get all introductions or credits. Only used by the end to end testing script. /// - /// All introductions have been returned. + /// Mode. + /// All timestamps have been returned. /// List of IntroWithMetadata objects. [Authorize(Policy = "RequiresElevation")] [HttpGet("Intros/All")] - public ActionResult> GetAllIntros() + public ActionResult> GetAllTimestamps( + [FromQuery] AnalysisMode mode = AnalysisMode.Introduction) { List intros = new(); + var timestamps = mode == AnalysisMode.Introduction ? + Plugin.Instance!.Intros : + Plugin.Instance!.Credits; + // Get metadata for all intros - foreach (var intro in Plugin.Instance!.Intros) + foreach (var intro in timestamps) { // Get the details of the item from Jellyfin var rawItem = Plugin.Instance!.GetItem(intro.Key); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs index 84ea81c..be89666 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs @@ -8,6 +8,8 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; @@ -23,9 +25,10 @@ public class Plugin : BasePlugin, IHasWebPages private readonly object _introsLock = new(); private IXmlSerializer _xmlSerializer; private ILibraryManager _libraryManager; + private IItemRepository _itemRepository; private ILogger _logger; private string _introPath; - private string _creditsPath; // TODO: FIXME: remove this + private string _creditsPath; /// /// Initializes a new instance of the class. @@ -34,12 +37,14 @@ public class Plugin : BasePlugin, IHasWebPages /// Instance of the interface. /// Server configuration manager. /// Library manager. + /// Item repository. /// Logger. public Plugin( IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IServerConfigurationManager serverConfiguration, ILibraryManager libraryManager, + IItemRepository itemRepository, ILogger logger) : base(applicationPaths, xmlSerializer) { @@ -47,14 +52,13 @@ public Plugin( _xmlSerializer = xmlSerializer; _libraryManager = libraryManager; + _itemRepository = itemRepository; _logger = logger; FingerprintCachePath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "cache"); FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay; _introPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml"); - - // TODO: FIXME: remove this - _creditsPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "credits.csv"); + _creditsPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "credits.xml"); // Create the base & cache directories (if needed). if (!Directory.Exists(FingerprintCachePath)) @@ -73,12 +77,6 @@ public Plugin( { _logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex); } - - // TODO: FIXME: remove this - if (File.Exists(_creditsPath)) - { - File.Delete(_creditsPath); - } } /// @@ -91,6 +89,11 @@ public Plugin( /// public Dictionary Intros { get; } = new(); + /// + /// Gets all discovered ending credits. + /// + public Dictionary Credits { get; } = new(); + /// /// Gets the most recent media item queue. /// @@ -131,12 +134,23 @@ public void SaveTimestamps() { var introList = new List(); + // Serialize intros foreach (var intro in Plugin.Instance!.Intros) { introList.Add(intro.Value); } _xmlSerializer.SerializeToFile(introList, _introPath); + + // Serialize credits + introList.Clear(); + + foreach (var intro in Plugin.Instance!.Credits) + { + introList.Add(intro.Value); + } + + _xmlSerializer.SerializeToFile(introList, _creditsPath); } } @@ -145,17 +159,29 @@ public void SaveTimestamps() /// public void RestoreTimestamps() { - if (!File.Exists(_introPath)) + if (File.Exists(_introPath)) { - return; - } + // Since dictionaries can't be easily serialized, analysis results are stored on disk as a list. + var introList = (List)_xmlSerializer.DeserializeFromFile( + typeof(List), + _introPath); - // Since dictionaries can't be easily serialized, analysis results are stored on disk as a list. - var introList = (List)_xmlSerializer.DeserializeFromFile(typeof(List), _introPath); + foreach (var intro in introList) + { + Plugin.Instance!.Intros[intro.EpisodeId] = intro; + } + } - foreach (var intro in introList) + if (File.Exists(_creditsPath)) { - Plugin.Instance!.Intros[intro.EpisodeId] = intro; + var creditList = (List)_xmlSerializer.DeserializeFromFile( + typeof(List), + _creditsPath); + + foreach (var credit in creditList) + { + Plugin.Instance!.Credits[credit.EpisodeId] = credit; + } } } @@ -174,52 +200,33 @@ internal string GetItemPath(Guid id) return GetItem(id).Path; } - internal void UpdateTimestamps(Dictionary newIntros, AnalysisMode mode) + /// + /// Gets all chapters for this item. + /// + /// Item id. + /// List of chapters. + internal List GetChapters(Guid id) { - switch (mode) + return _itemRepository.GetChapters(GetItem(id)); + } + + internal void UpdateTimestamps(Dictionary newTimestamps, AnalysisMode mode) + { + lock (_introsLock) { - case AnalysisMode.Introduction: - lock (_introsLock) + foreach (var intro in newTimestamps) + { + if (mode == AnalysisMode.Introduction) { - foreach (var intro in newIntros) - { - Plugin.Instance!.Intros[intro.Key] = intro.Value; - } - - Plugin.Instance!.SaveTimestamps(); + Plugin.Instance!.Intros[intro.Key] = intro.Value; } - - break; - - case AnalysisMode.Credits: - // TODO: FIXME: implement properly - - lock (_introsLock) + else if (mode == AnalysisMode.Credits) { - foreach (var credit in newIntros) - { - var item = GetItem(credit.Value.EpisodeId) as Episode; - if (item is null) - { - continue; - } - - // Format: series, season number, episode number, title, start, end - var contents = string.Format( - System.Globalization.CultureInfo.InvariantCulture, - "{0},{1},{2},{3},{4},{5}\n", - item.SeriesName.Replace(",", string.Empty, StringComparison.Ordinal), - item.AiredSeasonNumber ?? 0, - item.IndexNumber ?? 0, - item.Name.Replace(",", string.Empty, StringComparison.Ordinal), - Math.Round(credit.Value.IntroStart, 2), - Math.Round(credit.Value.IntroEnd, 2)); - - File.AppendAllText(_creditsPath, contents); - } + Plugin.Instance!.Credits[intro.Key] = intro.Value; } + } - break; + Plugin.Instance!.SaveTimestamps(); } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs index 92f9bbc..4a9fd9e 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs @@ -98,9 +98,6 @@ public Task ExecuteAsync(IProgress progress, CancellationToken cancellat MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism }; - // TODO: FIXME: if the queue is modified while the task is running, the task will fail. - // clone the queue before running the task to prevent this. - // Analyze all episodes in the queue using the degrees of parallelism the user specified. Parallel.ForEach(queue, options, (season) => { diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntroductionsTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntroductionsTask.cs index 5b232b8..240feb7 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntroductionsTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntroductionsTask.cs @@ -252,6 +252,10 @@ private int AnalyzeSeason( first.SeriesName, first.SeasonNumber); + // Chapter analyzer + var chapter = new ChapterAnalyzer(_loggerFactory.CreateLogger()); + chapter.AnalyzeMediaFiles(episodes, AnalysisMode.Introduction, cancellationToken); + // Analyze the season with Chromaprint var chromaprint = new ChromaprintAnalyzer(_loggerFactory.CreateLogger()); chromaprint.AnalyzeMediaFiles(episodes, AnalysisMode.Introduction, cancellationToken);