From 61178832c1e2b95fe5df015c034b383e40eb5035 Mon Sep 17 00:00:00 2001 From: ConfusedPolarBear <33811686+ConfusedPolarBear@users.noreply.github.com> Date: Mon, 31 Oct 2022 01:00:39 -0500 Subject: [PATCH] Add initial end credits detection code --- CHANGELOG.md | 2 + .../TestAudioFingerprinting.cs | 10 +- .../Analyzers/Chromaprint.cs | 54 +++-- .../Configuration/PluginConfiguration.cs | 5 + .../Controllers/VisualizationController.cs | 2 +- .../Data/QueuedEpisode.cs | 14 +- .../FFmpegWrapper.cs | 90 ++++++-- .../Plugin.cs | 61 +++++- .../QueueManager.cs | 15 +- .../ScheduledTasks/AnalyzeEpisodesTask.cs | 2 + .../ScheduledTasks/DetectCreditsTask.cs | 197 ++++++++++++++++++ README.md | 6 +- 12 files changed, 408 insertions(+), 50 deletions(-) create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7727813..cd38246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## v0.1.8.0 (no eta) +* New features + * Detect ending credits in television episodes * Internal changes * Move Chromaprint analysis code out of the episode analysis task * Add support for multiple analysis techinques diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs index 5470b4f..e758469 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs @@ -59,7 +59,9 @@ public class TestAudioFingerprinting 3472417825, 3395841056, 3458735136, 3341420624, 1076496560, 1076501168, 1076501136, 1076497024 }; - var actual = FFmpegWrapper.Fingerprint(queueEpisode("audio/big_buck_bunny_intro.mp3")); + var actual = FFmpegWrapper.Fingerprint( + queueEpisode("audio/big_buck_bunny_intro.mp3"), + AnalysisMode.Introduction); Assert.Equal(expected, actual); } @@ -91,8 +93,8 @@ public class TestAudioFingerprinting var lhsEpisode = queueEpisode("audio/big_buck_bunny_intro.mp3"); var rhsEpisode = queueEpisode("audio/big_buck_bunny_clip.mp3"); - var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode); - var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode); + var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction); + var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction); var (lhs, rhs) = chromaprint.CompareEpisodes( lhsEpisode.EpisodeId, @@ -138,7 +140,7 @@ public class TestAudioFingerprinting { EpisodeId = Guid.NewGuid(), Path = "../../../" + path, - FingerprintDuration = 60 + IntroFingerprintEnd = 60 }; } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/Chromaprint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/Chromaprint.cs index 319d678..04f9da0 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/Chromaprint.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/Chromaprint.cs @@ -30,6 +30,8 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer private ILogger _logger; + private AnalysisMode _analysisMode; + /// /// Initializes a new instance of the class. /// @@ -64,12 +66,14 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer // Episodes that were analyzed and do not have an introduction. var episodesWithoutIntros = new List(); + this._analysisMode = mode; + // Compute fingerprints for all episodes in the season foreach (var episode in episodeAnalysisQueue) { try { - fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode); + fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode); if (cancellationToken.IsCancellationRequested) { @@ -78,6 +82,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer } catch (FingerprintException ex) { + // TODO: FIXME: move to debug level? _logger.LogWarning("Caught fingerprint error: {Ex}", ex); // Fallback to an empty fingerprint on any error @@ -112,6 +117,22 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer continue; } + /* Since the Fingerprint() function returns an array of Chromaprint points without time + * information, the times reported from the index search function start from 0. + * + * While this is desired behavior for detecting introductions, it breaks credit + * detection, as the audio we're analyzing was extracted from some point into the file. + * + * To fix this, add the starting time of the fingerprint to the reported time range. + */ + if (this._analysisMode == AnalysisMode.Credits) + { + currentIntro.IntroStart += currentEpisode.CreditsFingerprintStart; + currentIntro.IntroEnd += currentEpisode.CreditsFingerprintStart; + remainingIntro.IntroStart += remainingEpisode.CreditsFingerprintStart; + remainingIntro.IntroEnd += remainingEpisode.CreditsFingerprintStart; + } + // Only save the discovered intro if it is: // - the first intro discovered for this episode // - longer than the previously discovered intro @@ -142,10 +163,13 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer return analysisQueue; } - // Adjust all introduction end times so that they end at silence. - seasonIntros = AdjustIntroEndTimes(analysisQueue, seasonIntros); + if (this._analysisMode == AnalysisMode.Introduction) + { + // Adjust all introduction end times so that they end at silence. + seasonIntros = AdjustIntroEndTimes(analysisQueue, seasonIntros); + } - Plugin.Instance!.UpdateTimestamps(seasonIntros); + Plugin.Instance!.UpdateTimestamps(seasonIntros, this._analysisMode); return episodesWithoutIntros.AsReadOnly(); } @@ -338,16 +362,20 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer // Since LHS had a contiguous time range, RHS must have one also. var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), maximumTimeSkip)!; - // Tweak the end timestamps just a bit to ensure as little content as possible is skipped over. - if (lContiguous.Duration >= 90) + if (this._analysisMode == AnalysisMode.Introduction) { - lContiguous.End -= 2 * maximumTimeSkip; - rContiguous.End -= 2 * maximumTimeSkip; - } - else if (lContiguous.Duration >= 30) - { - lContiguous.End -= maximumTimeSkip; - rContiguous.End -= maximumTimeSkip; + // Tweak the end timestamps just a bit to ensure as little content as possible is skipped over. + // TODO: remove this + if (lContiguous.Duration >= 90) + { + lContiguous.End -= 2 * maximumTimeSkip; + rContiguous.End -= 2 * maximumTimeSkip; + } + else if (lContiguous.Duration >= 30) + { + lContiguous.End -= maximumTimeSkip; + rContiguous.End -= maximumTimeSkip; + } } return (lContiguous, rContiguous); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs index e382bf2..ce79fdd 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs @@ -72,6 +72,11 @@ public class PluginConfiguration : BasePluginConfiguration /// 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. + /// + public int MaximumEpisodeCreditsDuration { get; set; } = 4; + // ===== Playback settings ===== /// diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs index d4cec0d..f2331ab 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs @@ -113,7 +113,7 @@ public class VisualizationController : ControllerBase { if (needle.EpisodeId == id) { - return FFmpegWrapper.Fingerprint(needle); + return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction); } } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs index 915d33f..0964258 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs @@ -33,7 +33,17 @@ public class QueuedEpisode public string Name { get; set; } = string.Empty; /// - /// Gets or sets the seconds of media file to fingerprint. + /// Gets or sets the timestamp (in seconds) to stop searching for an introduction. /// - public int FingerprintDuration { get; set; } + public int IntroFingerprintEnd { get; set; } + + /// + /// Gets or sets the timestamp (in seconds) to start looking for end credits. + /// + public int CreditsFingerprintStart { get; set; } + + /// + /// Gets or sets the total duration of this media file (in seconds). + /// + public int Duration { get; set; } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs index 86dad74..fdae152 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs @@ -134,27 +134,60 @@ public static class FFmpegWrapper /// Fingerprint a queued episode. /// /// Queued episode to fingerprint. + /// Portion of media file to fingerprint. Introduction = first 25% / 10 minutes and Credits = last 4 minutes. /// Numerical fingerprint points. - public static uint[] Fingerprint(QueuedEpisode episode) + public static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode) + { + int start, end; + + if (mode == AnalysisMode.Introduction) + { + start = 0; + end = episode.IntroFingerprintEnd; + } + else if (mode == AnalysisMode.Credits) + { + start = episode.CreditsFingerprintStart; + end = episode.Duration; + } + else + { + throw new ArgumentException("Unknown analysis mode " + mode.ToString()); + } + + return Fingerprint(episode, mode, start, end); + } + + /// + /// Fingerprint a queued episode. + /// + /// Queued episode to fingerprint. + /// Portion of media file to fingerprint. + /// Time (in seconds) relative to the start of the file to start fingerprinting from. + /// Time (in seconds) relative to the start of the file to stop fingerprinting at. + /// Numerical fingerprint points. + private static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode, int start, int end) { // Try to load this episode from cache before running ffmpeg. - if (LoadCachedFingerprint(episode, out uint[] cachedFingerprint)) + if (LoadCachedFingerprint(episode, mode, out uint[] cachedFingerprint)) { Logger?.LogTrace("Fingerprint cache hit on {File}", episode.Path); return cachedFingerprint; } Logger?.LogDebug( - "Fingerprinting {Duration} seconds from \"{File}\" (id {Id})", - episode.FingerprintDuration, + "Fingerprinting [{Start}, {End}] from \"{File}\" (id {Id})", + start, + end, episode.Path, episode.EpisodeId); var args = string.Format( CultureInfo.InvariantCulture, - "-i \"{0}\" -to {1} -ac 2 -f chromaprint -fp_format raw -", + "-ss {0} -i \"{1}\" -to {2} -ac 2 -f chromaprint -fp_format raw -", + start, episode.Path, - episode.FingerprintDuration); + end - start); // Returns all fingerprint points as raw 32 bit unsigned integers (little endian). var rawPoints = GetOutput(args, string.Empty); @@ -172,7 +205,7 @@ public static class FFmpegWrapper } // Try to cache this fingerprint. - CacheFingerprint(episode, results); + CacheFingerprint(episode, mode, results); return results.ToArray(); } @@ -226,9 +259,6 @@ public static class FFmpegWrapper limit, episode.EpisodeId); - // TODO: select the audio track that matches the user's preferred language, falling - // back to the first track if nothing matches - // -vn, -sn, -dn: ignore video, subtitle, and data tracks var args = string.Format( CultureInfo.InvariantCulture, @@ -367,9 +397,13 @@ public static class FFmpegWrapper /// This function was created before the unified caching mechanism was introduced (in v0.1.7). /// /// Episode to try to load from cache. + /// Analysis mode. /// Array to store the fingerprint in. /// true if the episode was successfully loaded from cache, false on any other error. - private static bool LoadCachedFingerprint(QueuedEpisode episode, out uint[] fingerprint) + private static bool LoadCachedFingerprint( + QueuedEpisode episode, + AnalysisMode mode, + out uint[] fingerprint) { fingerprint = Array.Empty(); @@ -379,7 +413,7 @@ public static class FFmpegWrapper return false; } - var path = GetFingerprintCachePath(episode); + var path = GetFingerprintCachePath(episode, mode); // If this episode isn't cached, bail out. if (!File.Exists(path)) @@ -387,7 +421,6 @@ public static class FFmpegWrapper return false; } - // TODO: make async var raw = File.ReadAllLines(path, Encoding.UTF8); var result = new List(); @@ -421,8 +454,12 @@ public static class FFmpegWrapper /// This function was created before the unified caching mechanism was introduced (in v0.1.7). /// /// Episode to store in cache. + /// Analysis mode. /// Fingerprint of the episode to store. - private static void CacheFingerprint(QueuedEpisode episode, List fingerprint) + private static void CacheFingerprint( + QueuedEpisode episode, + AnalysisMode mode, + List fingerprint) { // Bail out if caching isn't enabled. if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false)) @@ -438,7 +475,10 @@ public static class FFmpegWrapper } // Cache the episode. - File.WriteAllLinesAsync(GetFingerprintCachePath(episode), lines, Encoding.UTF8).ConfigureAwait(false); + File.WriteAllLinesAsync( + GetFingerprintCachePath(episode, mode), + lines, + Encoding.UTF8).ConfigureAwait(false); } /// @@ -446,9 +486,25 @@ public static class FFmpegWrapper /// This function was created before the unified caching mechanism was introduced (in v0.1.7). /// /// Episode. - private static string GetFingerprintCachePath(QueuedEpisode episode) + /// Analysis mode. + private static string GetFingerprintCachePath(QueuedEpisode episode, AnalysisMode mode) { - return Path.Join(Plugin.Instance!.FingerprintCachePath, episode.EpisodeId.ToString("N")); + var basePath = Path.Join( + Plugin.Instance!.FingerprintCachePath, + episode.EpisodeId.ToString("N")); + + if (mode == AnalysisMode.Introduction) + { + return basePath; + } + else if (mode == AnalysisMode.Credits) + { + return basePath + "-credits"; + } + else + { + throw new ArgumentException("Unknown analysis mode " + mode.ToString()); + } } /// diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs index 94604d9..bf5d241 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs @@ -6,6 +6,7 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; @@ -24,6 +25,7 @@ public class Plugin : BasePlugin, IHasWebPages private ILibraryManager _libraryManager; private ILogger _logger; private string _introPath; + private string _creditsPath; // TODO: FIXME: remove this /// /// Initializes a new instance of the class. @@ -51,6 +53,9 @@ public class Plugin : BasePlugin, IHasWebPages FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay; _introPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml"); + // TODO: FIXME: remove this + _creditsPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "credits.csv"); + // Create the base & cache directories (if needed). if (!Directory.Exists(FingerprintCachePath)) { @@ -68,6 +73,12 @@ public class Plugin : BasePlugin, IHasWebPages { _logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex); } + + // TODO: FIXME: remove this + if (File.Exists(_creditsPath)) + { + File.Delete(_creditsPath); + } } /// @@ -163,16 +174,52 @@ public class Plugin : BasePlugin, IHasWebPages return GetItem(id).Path; } - internal void UpdateTimestamps(Dictionary newIntros) + internal void UpdateTimestamps(Dictionary newIntros, AnalysisMode mode) { - lock (_introsLock) + switch (mode) { - foreach (var intro in newIntros) - { - Plugin.Instance!.Intros[intro.Key] = intro.Value; - } + case AnalysisMode.Introduction: + lock (_introsLock) + { + foreach (var intro in newIntros) + { + Plugin.Instance!.Intros[intro.Key] = intro.Value; + } - Plugin.Instance!.SaveTimestamps(); + Plugin.Instance!.SaveTimestamps(); + } + + break; + + case AnalysisMode.Credits: + // TODO: FIXME: implement properly + + lock (_introsLock) + { + 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); + } + } + + break; } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/QueueManager.cs b/ConfusedPolarBear.Plugin.IntroSkipper/QueueManager.cs index 3395243..be905e6 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/QueueManager.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/QueueManager.cs @@ -186,17 +186,22 @@ public class QueueManager // Limit analysis to the first X% of the episode and at most Y minutes. // X and Y default to 25% and 10 minutes. var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds; - if (duration >= 5 * 60) + var fingerprintDuration = duration; + + if (fingerprintDuration >= 5 * 60) { - duration *= analysisPercent; + fingerprintDuration *= analysisPercent; } - duration = Math.Min(duration, 60 * Plugin.Instance!.Configuration.AnalysisLengthLimit); + fingerprintDuration = Math.Min( + fingerprintDuration, + 60 * Plugin.Instance!.Configuration.AnalysisLengthLimit); // Allocate a new list for each new season Plugin.Instance!.AnalysisQueue.TryAdd(episode.SeasonId, new List()); // Queue the episode for analysis + var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumEpisodeCreditsDuration * 60; Plugin.Instance.AnalysisQueue[episode.SeasonId].Add(new QueuedEpisode() { SeriesName = episode.SeriesName, @@ -204,7 +209,9 @@ public class QueueManager EpisodeId = episode.Id, Name = episode.Name, Path = episode.Path, - FingerprintDuration = Convert.ToInt32(duration) + Duration = Convert.ToInt32(duration), + IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration), + CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration), }); Plugin.Instance!.TotalQueued++; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/AnalyzeEpisodesTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/AnalyzeEpisodesTask.cs index 3b1c531..6d563fc 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/AnalyzeEpisodesTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/AnalyzeEpisodesTask.cs @@ -12,6 +12,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper; /// /// Analyze all television episodes for introduction sequences. +/// TODO: FIXME: rename task and file to DetectIntroductionsTask. /// public class AnalyzeEpisodesTask : IScheduledTask { @@ -178,6 +179,7 @@ public class AnalyzeEpisodesTask : IScheduledTask /// /// Verify that all episodes in a season exist in Jellyfin and as a file in storage. + /// TODO: FIXME: move to queue manager. /// /// QueuedEpisodes. /// Verified QueuedEpisodes and a flag indicating if any episode in this season has not been analyzed yet. diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs new file mode 100644 index 0000000..224155f --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +#if !DEBUG +#error Fix all FIXMEs introduced during initial credit implementation before release +#endif + +/// +/// Analyze all television episodes for credits. +/// +public class DetectCreditsTask : IScheduledTask +{ + private readonly ILogger _logger; + + private readonly ILoggerFactory _loggerFactory; + + private readonly ILibraryManager? _libraryManager; + + /// + /// Initializes a new instance of the class. + /// + /// Logger factory. + /// Library manager. + public DetectCreditsTask( + ILoggerFactory loggerFactory, + ILibraryManager libraryManager) : this(loggerFactory) + { + _libraryManager = libraryManager; + } + + /// + /// Initializes a new instance of the class. + /// + /// Logger factory. + public DetectCreditsTask(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + _loggerFactory = loggerFactory; + } + + /// + /// Gets the task name. + /// + public string Name => "Detect Credits"; + + /// + /// Gets the task category. + /// + public string Category => "Intro Skipper"; + + /// + /// Gets the task description. + /// + public string Description => "Analyzes the audio and video of all television episodes to find credits."; + + /// + /// Gets the task key. + /// + public string Key => "CPBIntroSkipperDetectCredits"; + + /// + /// Analyze all episodes in the queue. Only one instance of this task should be run at a time. + /// + /// Task progress. + /// Cancellation token. + /// Task. + public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + if (_libraryManager is null) + { + throw new InvalidOperationException("Library manager must not be null"); + } + + // Make sure the analysis queue matches what's currently in Jellyfin. + var queueManager = new QueueManager( + _loggerFactory.CreateLogger(), + _libraryManager); + + queueManager.EnqueueAllEpisodes(); + + var queue = Plugin.Instance!.AnalysisQueue; + + if (queue.Count == 0) + { + throw new FingerprintException( + "No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly."); + } + + var totalProcessed = 0; + var options = new ParallelOptions() + { + 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) => + { + // TODO: FIXME: use VerifyEpisodes + var episodes = season.Value.AsReadOnly(); + if (episodes.Count == 0) + { + return; + } + + var first = episodes[0]; + + try + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + // Increment totalProcessed by the number of episodes in this season that were actually analyzed + // (instead of just using the number of episodes in the current season). + var analyzed = AnalyzeSeason(episodes, cancellationToken); + Interlocked.Add(ref totalProcessed, analyzed); + } + catch (FingerprintException ex) + { + _logger.LogWarning( + "Unable to analyze {Series} season {Season}: unable to fingerprint: {Ex}", + first.SeriesName, + first.SeasonNumber, + ex); + } + catch (KeyNotFoundException ex) + { + _logger.LogWarning( + "Unable to analyze {Series} season {Season}: cache miss: {Ex}", + first.SeriesName, + first.SeasonNumber, + ex); + } + + progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued); + }); + + return Task.CompletedTask; + } + + /// + /// Fingerprints all episodes in the provided season and stores the timestamps of all introductions. + /// + /// Episodes in this season. + /// Cancellation token provided by the scheduled task. + /// Number of episodes from the provided season that were analyzed. + private int AnalyzeSeason( + ReadOnlyCollection episodes, + CancellationToken cancellationToken) + { + // Skip seasons with an insufficient number of episodes. + if (episodes.Count <= 1) + { + return episodes.Count; + } + + // Only analyze specials (season 0) if the user has opted in. + var first = episodes[0]; + if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero) + { + return 0; + } + + _logger.LogInformation( + "Analyzing {Count} episodes from {Name} season {Season}", + episodes.Count, + first.SeriesName, + first.SeasonNumber); + + // Analyze the season with Chromaprint + var chromaprint = new ChromaprintAnalyzer(_loggerFactory.CreateLogger()); + chromaprint.AnalyzeMediaFiles(episodes, AnalysisMode.Credits, cancellationToken); + + return episodes.Count; + } + + /// + /// Get task triggers. + /// + /// Task triggers. + public IEnumerable GetDefaultTriggers() + { + return Array.Empty(); + } +} diff --git a/README.md b/README.md index 8d3589d..e3ac776 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Plugin Banner -Analyzes the audio of television episodes to detect and skip over intros. +Analyzes the audio of television episodes to detect and skip over introductions and ending credits. If you use the custom web interface on your server, you will be able to click a button to skip intros, like this: @@ -20,13 +20,15 @@ However, if you want to use an unmodified installation of Jellyfin 10.8.z or use * `linuxserver/jellyfin` 10.8.z container: preinstalled * Debian Linux based native installs: provided by the `jellyfin-ffmpeg5` package -## Introduction requirements +## Introduction and end credit requirements Show introductions will only be detected if they are: * Located within the first 25% of an episode, or the first 10 minutes, whichever is smaller * Between 15 seconds and 2 minutes long +Ending credits will only be detected if they are shorter than 4 minutes. + All of these requirements can be customized as needed. ## Installation instructions