diff --git a/CHANGELOG.md b/CHANGELOG.md index 7727813..821a8f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog ## v0.1.8.0 (no eta) +* New features + * Support adding skip intro button to web interface without using a fork + * Add localization support for the skip intro button and the automatic skip notification message + * Detect ending credits in television episodes + * Add support for using chapter names to locate introductions and ending credits + * Add support for using black frames to locate ending credits * 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 void TestFingerprinting() 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 void TestIntroDetection() 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 @@ private QueuedEpisode queueEpisode(string path) { EpisodeId = Guid.NewGuid(), Path = "../../../" + path, - FingerprintDuration = 60 + IntroFingerprintEnd = 60 }; } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs new file mode 100644 index 0000000..16caf27 --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs @@ -0,0 +1,72 @@ +namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Xunit; + +public class TestBlackFrames +{ + [FactSkipFFmpegTests] + public void TestBlackFrameDetection() + { + var range = 1e-5; + + var expected = new List(); + expected.AddRange(CreateFrameSequence(2, 3)); + expected.AddRange(CreateFrameSequence(5, 6)); + expected.AddRange(CreateFrameSequence(8, 9.96)); + + var actual = FFmpegWrapper.DetectBlackFrames(queueFile("rainbow.mp4"), new(0, 10), 85); + + for (var i = 0; i < expected.Count; i++) + { + var (e, a) = (expected[i], actual[i]); + Assert.Equal(e.Percentage, a.Percentage); + Assert.InRange(a.Time, e.Time - range, e.Time + range); + } + } + + [FactSkipFFmpegTests] + public void TestEndCreditDetection() + { + var range = 1; + + var analyzer = CreateBlackFrameAnalyzer(); + + var episode = queueFile("credits.mp4"); + episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds; + + var result = analyzer.AnalyzeMediaFile(episode, AnalysisMode.Credits, 85); + Assert.NotNull(result); + Assert.InRange(result.IntroStart, 300 - range, 300 + range); + } + + private QueuedEpisode queueFile(string path) + { + return new() + { + EpisodeId = Guid.NewGuid(), + Name = path, + Path = "../../../video/" + path + }; + } + + private BlackFrame[] CreateFrameSequence(double start, double end) + { + var frames = new List(); + + for (var i = start; i < end; i += 0.04) + { + frames.Add(new(100, i)); + } + + return frames.ToArray(); + } + + private BlackFrameAnalyzer CreateBlackFrameAnalyzer() + { + var logger = new LoggerFactory().CreateLogger(); + return new(logger); + } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs new file mode 100644 index 0000000..96feb8e --- /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(new() { Duration = 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.Tests/e2e_tests/verifier/report_generator.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_generator.go index 4814022..2c4fb3b 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_generator.go +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_generator.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "strings" "time" @@ -77,7 +78,6 @@ func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamp fmt.Println() fmt.Println("[+] Saving report") - // TODO: also save analysis statistics // Store timing data, server information, and plugin configuration report.StartedAt = start report.FinishedAt = time.Now() @@ -95,6 +95,9 @@ func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamp panic(err) } + // Change report permissions + exec.Command("chown", "1000:1000", reportDestination).Run() + fmt.Println("[+] Done") } @@ -110,11 +113,13 @@ func runAnalysisAndWait(hostAddress, apiKey string, pollInterval time.Duration) SendRequest("POST", hostAddress+"/Intros/EraseTimestamps", apiKey) fmt.Println() - // The task ID changed with v0.1.7. - // Old task ID: 8863329048cc357f7dfebf080f2fe204 - // New task ID: 6adda26c5261c40e8fa4a7e7df568be2 + var taskIds = []string{ + "f64d8ad58e3d7b98548e1a07697eb100", // v0.1.8 + "8863329048cc357f7dfebf080f2fe204", + "6adda26c5261c40e8fa4a7e7df568be2"} + fmt.Println("[+] Starting analysis task") - for _, id := range []string{"8863329048cc357f7dfebf080f2fe204", "6adda26c5261c40e8fa4a7e7df568be2"} { + for _, id := range taskIds { body := SendRequest("POST", hostAddress+"/ScheduledTasks/Running/"+id, apiKey) fmt.Println() diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4 b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4 new file mode 100644 index 0000000..c8fa8d2 Binary files /dev/null and b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4 differ diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4 b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4 new file mode 100644 index 0000000..8e01cce Binary files /dev/null and b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4 differ diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs new file mode 100644 index 0000000..89a4018 --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs @@ -0,0 +1,131 @@ +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using Microsoft.Extensions.Logging; + +/// +/// Media file analyzer used to detect end credits that consist of text overlaid on a black background. +/// Bisects the end of the video file to perform an efficient search. +/// +public class BlackFrameAnalyzer : IMediaFileAnalyzer +{ + private readonly TimeSpan _maximumError = new(0, 0, 4); + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + public BlackFrameAnalyzer(ILogger logger) + { + _logger = logger; + } + + /// + public ReadOnlyCollection AnalyzeMediaFiles( + ReadOnlyCollection analysisQueue, + AnalysisMode mode, + CancellationToken cancellationToken) + { + if (mode != AnalysisMode.Credits) + { + throw new NotImplementedException("mode must equal Credits"); + } + + var creditTimes = new Dictionary(); + + foreach (var episode in analysisQueue) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + var intro = AnalyzeMediaFile( + episode, + mode, + Plugin.Instance!.Configuration.BlackFrameMinimumPercentage); + + if (intro is null) + { + continue; + } + + creditTimes[episode.EpisodeId] = intro; + } + + Plugin.Instance!.UpdateTimestamps(creditTimes, mode); + + return analysisQueue + .Where(x => !creditTimes.ContainsKey(x.EpisodeId)) + .ToList() + .AsReadOnly(); + } + + /// + /// Analyzes an individual media file. Only public because of unit tests. + /// + /// Media file to analyze. + /// Analysis mode. + /// Percentage of the frame that must be black. + /// Credits timestamp. + public Intro? AnalyzeMediaFile(QueuedEpisode episode, AnalysisMode mode, int minimum) + { + var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration(); + + // Start by analyzing the last N minutes of the file. + var start = TimeSpan.FromSeconds(config.MaximumEpisodeCreditsDuration); + var end = TimeSpan.FromSeconds(config.MinimumCreditsDuration); + var firstFrameTime = 0.0; + + // Continue bisecting the end of the file until the range that contains the first black + // frame is smaller than the maximum permitted error. + while (start - end > _maximumError) + { + // Analyze the middle two seconds from the current bisected range + var midpoint = (start + end) / 2; + var scanTime = episode.Duration - midpoint.TotalSeconds; + var tr = new TimeRange(scanTime, scanTime + 2); + + _logger.LogTrace( + "{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]", + episode.Name, + episode.Duration, + start, + end, + tr.Start, + tr.End); + + var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum); + _logger.LogTrace( + "{Episode} at {Start} has {Count} black frames", + episode.Name, + tr.Start, + frames.Length); + + if (frames.Length == 0) + { + // Since no black frames were found, slide the range closer to the end + start = midpoint; + } + else + { + // Some black frames were found, slide the range closer to the start + end = midpoint; + firstFrameTime = frames[0].Time + scanTime; + } + } + + if (firstFrameTime > 0) + { + return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration)); + } + + return null; + } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs new file mode 100644 index 0000000..558c0e9 --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs @@ -0,0 +1,159 @@ +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +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; + + if (string.IsNullOrWhiteSpace(expression)) + { + return analysisQueue; + } + + foreach (var episode in analysisQueue) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + var skipRange = FindMatchingChapter( + episode, + 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 + .Where(x => !skippableRanges.ContainsKey(x.EpisodeId)) + .ToList() + .AsReadOnly(); + } + + /// + /// Searches a list of chapter names for one that matches the provided regular expression. + /// Only public to allow for unit testing. + /// + /// Episode. + /// Media item chapters. + /// Regular expression pattern. + /// Analysis mode. + /// Intro object containing skippable time range, or null if no chapter matched. + public Intro? FindMatchingChapter( + QueuedEpisode episode, + 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() + { + StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks + }); + } + + // Check all chapters + for (int i = 0; i < chapters.Count - 1; i++) + { + var current = chapters[i]; + var next = chapters[i + 1]; + + if (string.IsNullOrWhiteSpace(current.Name)) + { + continue; + } + + var currentRange = new TimeRange( + TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds, + TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds); + + var baseMessage = string.Format( + CultureInfo.InvariantCulture, + "{0}: Chapter \"{1}\" ({2} - {3})", + episode.Path, + current.Name, + currentRange.Start, + currentRange.End); + + if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration) + { + _logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage); + 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) + { + _logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage); + continue; + } + + matchingChapter = new(episode.EpisodeId, currentRange); + _logger.LogTrace("{Base}: okay", baseMessage); + break; + } + + return matchingChapter; + } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/Chromaprint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs similarity index 89% rename from ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/Chromaprint.cs rename to ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs index 73a8eb3..c230c1b 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/Chromaprint.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.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 ReadOnlyCollection AnalyzeMediaFiles( // 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) { @@ -113,6 +117,22 @@ public ReadOnlyCollection AnalyzeMediaFiles( 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 @@ -143,10 +163,13 @@ public ReadOnlyCollection AnalyzeMediaFiles( 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(); } @@ -339,16 +362,20 @@ public ReadOnlyCollection AnalyzeMediaFiles( // 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 f0def72..6944832 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs @@ -72,6 +72,33 @@ public PluginConfiguration() /// public int MaximumIntroDuration { get; set; } = 120; + /// + /// Gets or sets the minimum length of similar audio that will be considered ending credits. + /// + public int MinimumCreditsDuration { get; set; } = 15; + + /// + /// 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; } = 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/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html index 33cf2e5..a41f043 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html @@ -291,10 +291,6 @@ Save
- - @@ -340,7 +336,17 @@

Introduction timestamp editor

+
+ + +
+ +

@@ -414,7 +420,8 @@

Fingerprint Visualizer

// settings elements var visualizer = document.querySelector("details#visualizer"); var support = document.querySelector("details#support"); - var btnEraseTimestamps = document.querySelector("button#btnEraseTimestamps"); + var btnEraseIntroTimestamps = document.querySelector("button#btnEraseIntroTimestamps"); + var btnEraseCreditTimestamps = document.querySelector("button#btnEraseCreditTimestamps"); // all plugin configuration fields that can be get or set with .value (i.e. strings or numbers). var configurationFields = [ @@ -703,6 +710,28 @@

Fingerprint Visualizer

return new Date(seconds * 1000).toISOString().substr(14, 5); } + // erase all intro/credits timestamps + function eraseTimestamps(mode) { + const lower = mode.toLocaleLowerCase(); + const title = "Confirm timestamp erasure"; + const body = "Are you sure you want to erase all previously discovered " + + mode.toLocaleLowerCase() + + " timestamps?"; + + Dashboard.confirm( + body, + title, + (result) => { + if (!result) { + return; + } + + fetchWithAuth("Intros/EraseTimestamps?mode=" + mode, "POST", null); + + Dashboard.alert(mode + " timestamps erased"); + }); + } + document.querySelector('#TemplateConfigPage') .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); @@ -748,19 +777,12 @@

Fingerprint Visualizer

selectSeason.addEventListener("change", seasonChanged); selectEpisode1.addEventListener("change", episodeChanged); selectEpisode2.addEventListener("change", episodeChanged); - btnEraseTimestamps.addEventListener("click", (e) => { - Dashboard.confirm( - "Are you sure you want to erase all previously discovered introduction timestamps?", - "Confirm timestamp erasure", - (result) => { - if (!result) { - return; - } - - // reset all intro timestamps on the server so a new fingerprint comparison algorithm can be tested - fetchWithAuth("Intros/EraseTimestamps", "POST", null); - }); - + btnEraseIntroTimestamps.addEventListener("click", (e) => { + eraseTimestamps("Introduction"); + e.preventDefault(); + }); + btnEraseCreditTimestamps.addEventListener("click", (e) => { + eraseTimestamps("Credits"); e.preventDefault(); }); btnSeasonEraseTimestamps.addEventListener("click", () => { diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs index 3c89ea4..773d315 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs @@ -27,14 +27,17 @@ public SkipIntroController() /// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format. ///
/// ID of the episode. Required. + /// Timestamps to return. Optional. Defaults to Introduction for backwards compatibility. /// Episode contains an intro. /// Failed to find an intro in the provided episode. /// Detected intro. [HttpGet("Episode/{id}/IntroTimestamps")] [HttpGet("Episode/{id}/IntroTimestamps/v1")] - public ActionResult GetIntroTimestamps([FromRoute] Guid id) + public ActionResult GetIntroTimestamps( + [FromRoute] Guid id, + [FromQuery] AnalysisMode mode = AnalysisMode.Introduction) { - var intro = GetIntro(id); + var intro = GetIntro(id, mode); if (intro is null || !intro.Valid) { @@ -50,42 +53,69 @@ public ActionResult GetIntroTimestamps([FromRoute] Guid id) return intro; } - /// Lookup and return the intro timestamps for the provided item. + /// Lookup and return the skippable timestamps for the provided item. /// Unique identifier of this episode. + /// Mode. /// Intro object if the provided item has an intro, null otherwise. - private Intro? GetIntro(Guid id) + private Intro? GetIntro(Guid id, AnalysisMode mode) { - // Returns a copy to avoid mutating the original Intro object stored in the dictionary. - return Plugin.Instance!.Intros.TryGetValue(id, out var intro) ? new Intro(intro) : null; + try + { + var timestamp = mode == AnalysisMode.Introduction ? + Plugin.Instance!.Intros[id] : + Plugin.Instance!.Credits[id]; + + // A copy is returned to avoid mutating the original Intro object stored in the dictionary. + return new(timestamp); + } + catch (KeyNotFoundException) + { + return null; + } } /// /// Erases all previously discovered introduction timestamps. /// + /// Mode. /// Operation successful. /// No content. [Authorize(Policy = "RequiresElevation")] [HttpPost("Intros/EraseTimestamps")] - public ActionResult ResetIntroTimestamps() + public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode) { - Plugin.Instance!.Intros.Clear(); + if (mode == AnalysisMode.Introduction) + { + Plugin.Instance!.Intros.Clear(); + } + else if (mode == AnalysisMode.Credits) + { + Plugin.Instance!.Credits.Clear(); + } + Plugin.Instance!.SaveTimestamps(); return NoContent(); } /// - /// 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/Controllers/TroubleshootingController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/TroubleshootingController.cs index 2218bf0..b8319cb 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/TroubleshootingController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/TroubleshootingController.cs @@ -69,10 +69,7 @@ public ActionResult GetSupportBundle() bundle.Append("* Queue contents: "); bundle.Append(Plugin.Instance!.TotalQueued); - bundle.Append(" episodes, "); - bundle.Append(Plugin.Instance!.AnalysisQueue.Count); - bundle.Append(" seasons"); - bundle.Append('\n'); + bundle.Append(" episodes\n"); bundle.Append("* Warnings: `"); bundle.Append(WarningManager.GetWarnings()); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs index d4cec0d..daef672 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs @@ -40,7 +40,7 @@ public ActionResult>> GetShowSeasons() var showSeasons = new Dictionary>(); // Loop through all seasons in the analysis queue - foreach (var kvp in Plugin.Instance!.AnalysisQueue) + foreach (var kvp in Plugin.Instance!.QueuedMediaItems) { // Check that this season contains at least one episode. var episodes = kvp.Value; @@ -104,16 +104,14 @@ public ActionResult> GetSeasonEpisodes( [HttpGet("Episode/{Id}/Chromaprint")] public ActionResult GetEpisodeFingerprint([FromRoute] Guid id) { - var queue = Plugin.Instance!.AnalysisQueue; - // Search through all queued episodes to find the requested id - foreach (var season in queue) + foreach (var season in Plugin.Instance!.QueuedMediaItems) { foreach (var needle in season.Value) { if (needle.EpisodeId == id) { - return FFmpegWrapper.Fingerprint(needle); + return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction); } } } @@ -180,7 +178,7 @@ private string GetSeasonName(QueuedEpisode episode) /// Boolean indicating if the requested season was found. private bool LookupSeasonByName(string series, string season, out List episodes) { - foreach (var queuedEpisodes in Plugin.Instance!.AnalysisQueue) + foreach (var queuedEpisodes in Plugin.Instance!.QueuedMediaItems) { var first = queuedEpisodes.Value[0]; var firstSeasonName = GetSeasonName(first); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs new file mode 100644 index 0000000..df3a957 --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs @@ -0,0 +1,28 @@ +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +/// +/// A frame of video that partially (or entirely) consists of black pixels. +/// +public class BlackFrame +{ + /// + /// Initializes a new instance of the class. + /// + /// Percentage of the frame that is black. + /// Time this frame appears at. + public BlackFrame(int percent, double time) + { + Percentage = percent; + Time = time; + } + + /// + /// Gets or sets the percentage of the frame that is black. + /// + public int Percentage { get; set; } + + /// + /// Gets or sets the time (in seconds) this frame appeared at. + /// + public double Time { get; set; } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs index 915d33f..bb65789 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 at. /// - public int FingerprintDuration { get; set; } + public int IntroFingerprintEnd { get; set; } + + /// + /// Gets or sets the timestamp (in seconds) to start looking for end credits at. + /// + 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/Entrypoint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs index 9a04831..4ef12b3 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs @@ -53,21 +53,16 @@ public Task RunAsync() try { - // Enqueue all episodes at startup so the fingerprint visualizer works before the task is started. + // Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible _logger.LogInformation("Running startup enqueue"); var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager); - queueManager.EnqueueAllEpisodes(); + queueManager.GetMediaItems(); } catch (Exception ex) { _logger.LogError("Unable to run startup enqueue: {Exception}", ex); } - _logger.LogDebug( - "Total enqueued seasons: {Count} ({Episodes} episodes)", - Plugin.Instance!.AnalysisQueue.Count, - Plugin.Instance!.TotalQueued); - return Task.CompletedTask; } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs index 2751d45..fc0c9d4 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs @@ -16,16 +16,17 @@ public static class FFmpegWrapper { private static readonly object InvertedIndexCacheLock = new(); - // FFmpeg logs lines similar to the following: - // [silencedetect @ 0x000000000000] silence_start: 12.34 - // [silencedetect @ 0x000000000000] silence_end: 56.123 | silence_duration: 43.783 - /// /// Used with FFmpeg's silencedetect filter to extract the start and end times of silence. /// private static readonly Regex SilenceDetectionExpression = new( "silence_(?start|end): (?