diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5257d0e..a1302f2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: 'Build Plugin' on: push: - branches: [ "master" ] + branches: [ "master", "algorithm_rewrite" ] pull_request: branches: [ "master" ] diff --git a/CHANGELOG.md b/CHANGELOG.md index d656ee5..507dabc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## v0.1.7.0 (no eta) +* New features + * Rewrote fingerprint comparison algorithm to be faster (~30x speedup) and detect more introductions + * Add support for the `silencedetect` filter in ffmpeg + * Add support bundle + * Add maximum introduction duration + * Support playing a few seconds from the end of the introduction to verify that no episode content was skipped over + * Amount played is customizable and defaults to 2 seconds + * Support modifying introduction detection algorithm settings + * Add option to not skip the introduction in the first episode of a season + +* Fixes + * Fix scheduled task interval (#79) + * Prevent show names from becoming duplicated in the show name dropdown under the advanced section + * Prevent virtual episodes from being inserted into the analysis queue + ## v0.1.6.0 (2022-08-04) * New features * Generate EDL files with intro timestamps ([documentation](docs/edl.md)) (#21) diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs index c6e2570..c0da162 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs @@ -2,6 +2,7 @@ * which supports both chromaprint and the "-fp_format raw" flag. */ +using System; using System.Collections.Generic; using Xunit; using Microsoft.Extensions.Logging; @@ -13,7 +14,7 @@ public class TestAudioFingerprinting [FactSkipFFmpegTests] public void TestInstallationCheck() { - Assert.True(Chromaprint.CheckFFmpegVersion()); + Assert.True(FFmpegWrapper.CheckFFmpegVersion()); } [Theory] @@ -57,7 +58,7 @@ public class TestAudioFingerprinting 3472417825, 3395841056, 3458735136, 3341420624, 1076496560, 1076501168, 1076501136, 1076497024 }; - var actual = Chromaprint.Fingerprint(queueEpisode("audio/big_buck_bunny_intro.mp3")); + var actual = FFmpegWrapper.Fingerprint(queueEpisode("audio/big_buck_bunny_intro.mp3")); Assert.Equal(expected, actual); } @@ -77,7 +78,7 @@ public class TestAudioFingerprinting {77, 5}, }; - var actual = Chromaprint.CreateInvertedIndex(fpr); + var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr); Assert.Equal(expected, actual); } @@ -89,8 +90,14 @@ 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 (lhs, rhs) = task.FingerprintEpisodes(lhsEpisode, rhsEpisode); + var (lhs, rhs) = task.CompareEpisodes( + lhsEpisode.EpisodeId, + lhsFingerprint, + rhsEpisode.EpisodeId, + rhsFingerprint); Assert.True(lhs.Valid); Assert.Equal(0, lhs.IntroStart); @@ -101,20 +108,45 @@ public class TestAudioFingerprinting Assert.Equal(22.912, rhs.IntroEnd); } + /// + /// Test that the silencedetect wrapper is working. + /// + [FactSkipFFmpegTests] + public void TestSilenceDetection() + { + var clip = queueEpisode("audio/big_buck_bunny_clip.mp3"); + + var expected = new TimeRange[] + { + new TimeRange(44.6310, 44.8072), + new TimeRange(53.5905, 53.8070), + new TimeRange(53.8458, 54.2024), + new TimeRange(54.2611, 54.5935), + new TimeRange(54.7098, 54.9293), + new TimeRange(54.9294, 55.2590), + }; + + var actual = FFmpegWrapper.DetectSilence(clip, 60); + + Assert.Equal(expected, actual); + } + private QueuedEpisode queueEpisode(string path) { return new QueuedEpisode() { + EpisodeId = Guid.NewGuid(), Path = "../../../" + path, FingerprintDuration = 60 }; } } -public class FactSkipFFmpegTests : FactAttribute { - #if SKIP_FFMPEG_TESTS +public class FactSkipFFmpegTests : FactAttribute +{ +#if SKIP_FFMPEG_TESTS public FactSkipFFmpegTests() { Skip = "SKIP_FFMPEG_TESTS defined, skipping unit tests that require FFmpeg to be installed"; } - #endif +#endif } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestContiguous.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestContiguous.cs index 286aa0c..50121f6 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestContiguous.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestContiguous.cs @@ -71,4 +71,22 @@ public class TestTimeRanges Assert.Equal(expected, actual); } + + /// + /// Tests that TimeRange intersections are detected correctly. + /// Tests each time range against a range of 5 to 10 seconds. + /// + [Theory] + [InlineData(1, 4, false)] // too early + [InlineData(4, 6, true)] // intersects on the left + [InlineData(7, 8, true)] // in the middle + [InlineData(9, 12, true)] // intersects on the right + [InlineData(13, 15, false)] // too late + public void TestTimeRangeIntersection(int start, int end, bool expected) + { + var large = new TimeRange(5, 10); + var testRange = new TimeRange(start, end); + + Assert.Equal(expected, large.Intersects(testRange)); + } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestStatistics.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestStatistics.cs deleted file mode 100644 index 66b2e34..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestStatistics.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Text.Json; -using System.Text; -using Xunit; - -namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; - -public class TestStatistics -{ - [Fact] - public void TestTSISerialization() - { - var expected = "\"TotalAnalyzedEpisodes\":42,"; - - var stats = new AnalysisStatistics(); - stats.TotalAnalyzedEpisodes.Add(42); - - var actual = Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(stats)); - - Assert.Contains(expected, actual, StringComparison.OrdinalIgnoreCase); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs index 3da05ea..81a87f8 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs @@ -177,6 +177,8 @@ public class AutoSkip : IServerEntryPoint // Send the seek command _logger.LogDebug("Sending seek command to {Session}", deviceId); + var introEnd = (long)intro.IntroEnd - Plugin.Instance!.Configuration.SecondsOfIntroToPlay; + _sessionManager.SendPlaystateCommand( session.Id, session.Id, @@ -184,7 +186,7 @@ public class AutoSkip : IServerEntryPoint { Command = PlaystateCommand.Seek, ControllingUserId = session.UserId.ToString("N"), - SeekPositionTicks = (long)intro.IntroEnd * TimeSpan.TicksPerSecond, + SeekPositionTicks = introEnd * TimeSpan.TicksPerSecond, }, CancellationToken.None); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Chromaprint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Chromaprint.cs deleted file mode 100644 index 68fd809..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Chromaprint.cs +++ /dev/null @@ -1,327 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Text; -using Microsoft.Extensions.Logging; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Wrapper for libchromaprint. -/// -public static class Chromaprint -{ - /// - /// Gets or sets the logger. - /// - public static ILogger? Logger { get; set; } - - private static Dictionary ChromaprintLogs { get; set; } = new(); - - /// - /// Check that the installed version of ffmpeg supports chromaprint. - /// - /// true if a compatible version of ffmpeg is installed, false on any error. - public static bool CheckFFmpegVersion() - { - try - { - // Log the output of "ffmpeg -version". - ChromaprintLogs["version"] = Encoding.UTF8.GetString(GetOutput("-version", 2000)); - Logger?.LogDebug("ffmpeg version information: {Version}", ChromaprintLogs["version"]); - - // First, validate that the installed version of ffmpeg supports chromaprint at all. - var muxers = Encoding.UTF8.GetString(GetOutput("-muxers", 2000)); - ChromaprintLogs["muxer list"] = muxers; - Logger?.LogTrace("ffmpeg muxers: {Muxers}", muxers); - - if (!muxers.Contains("chromaprint", StringComparison.OrdinalIgnoreCase)) - { - ChromaprintLogs["error"] = "muxer_not_supported"; - Logger?.LogError("The installed version of ffmpeg does not support chromaprint"); - return false; - } - - // Second, validate that ffmpeg understands the "-fp_format raw" option. - var muxerHelp = Encoding.UTF8.GetString(GetOutput("-h muxer=chromaprint", 2000)); - ChromaprintLogs["muxer options"] = muxerHelp; - Logger?.LogTrace("ffmpeg chromaprint help: {MuxerHelp}", muxerHelp); - - if (!muxerHelp.Contains("-fp_format", StringComparison.OrdinalIgnoreCase)) - { - ChromaprintLogs["error"] = "fp_format_not_supported"; - Logger?.LogError("The installed version of ffmpeg does not support the -fp_format flag"); - return false; - } - else if (!muxerHelp.Contains("binary raw fingerprint", StringComparison.OrdinalIgnoreCase)) - { - ChromaprintLogs["error"] = "fp_format_raw_not_supported"; - Logger?.LogError("The installed version of ffmpeg does not support raw binary fingerprints"); - return false; - } - - Logger?.LogDebug("Installed version of ffmpeg meets fingerprinting requirements"); - ChromaprintLogs["error"] = "okay"; - return true; - } - catch - { - ChromaprintLogs["error"] = "unknown_error"; - return false; - } - } - - /// - /// Fingerprint a queued episode. - /// - /// Queued episode to fingerprint. - /// Numerical fingerprint points. - public static uint[] Fingerprint(QueuedEpisode episode) - { - // Try to load this episode from cache before running ffmpeg. - if (LoadCachedFingerprint(episode, out uint[] cachedFingerprint)) - { - Logger?.LogTrace("Fingerprint cache hit on {File}", episode.Path); - return cachedFingerprint; - } - - Logger?.LogDebug( - "Fingerprinting {Duration} seconds from \"{File}\" (length {Length}, id {Id})", - episode.FingerprintDuration, - episode.Path, - episode.Path.Length, - episode.EpisodeId); - - var args = string.Format( - CultureInfo.InvariantCulture, - "-i \"{0}\" -to {1} -ac 2 -f chromaprint -fp_format raw -", - episode.Path, - episode.FingerprintDuration); - - // Returns all fingerprint points as raw 32 bit unsigned integers (little endian). - var rawPoints = GetOutput(args); - if (rawPoints.Length == 0 || rawPoints.Length % 4 != 0) - { - Logger?.LogWarning("Chromaprint returned {Count} points for \"{Path}\"", rawPoints.Length, episode.Path); - throw new FingerprintException("chromaprint output for \"" + episode.Path + "\" was malformed"); - } - - var results = new List(); - for (var i = 0; i < rawPoints.Length; i += 4) - { - var rawPoint = rawPoints.Slice(i, 4); - results.Add(BitConverter.ToUInt32(rawPoint)); - } - - // Try to cache this fingerprint. - CacheFingerprint(episode, results); - - return results.ToArray(); - } - - /// - /// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at. - /// - /// Chromaprint fingerprint. - /// Inverted index. - public static Dictionary CreateInvertedIndex(uint[] fingerprint) - { - var invIndex = new Dictionary(); - - for (int i = 0; i < fingerprint.Length; i++) - { - // Get the current point. - var point = fingerprint[i]; - - // Append the current sample's timecode to the collection for this point. - invIndex[point] = i; - } - - return invIndex; - } - - /// - /// Runs ffmpeg and returns standard output. - /// - /// Arguments to pass to ffmpeg. - /// Timeout (in seconds) to wait for ffmpeg to exit. - private static ReadOnlySpan GetOutput(string args, int timeout = 60 * 1000) - { - var ffmpegPath = Plugin.Instance?.FFmpegPath ?? "ffmpeg"; - - // Prepend some flags to prevent FFmpeg from logging it's banner and progress information - // for each file that is fingerprinted. - var info = new ProcessStartInfo(ffmpegPath, args.Insert(0, "-hide_banner -loglevel warning ")) - { - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, - UseShellExecute = false, - ErrorDialog = false, - - // We only consume standardOutput. - RedirectStandardOutput = true, - RedirectStandardError = false - }; - - var ffmpeg = new Process - { - StartInfo = info - }; - - Logger?.LogDebug("Starting ffmpeg with the following arguments: {Arguments}", ffmpeg.StartInfo.Arguments); - ffmpeg.Start(); - - using (MemoryStream ms = new MemoryStream()) - { - var buf = new byte[4096]; - var bytesRead = 0; - - do - { - bytesRead = ffmpeg.StandardOutput.BaseStream.Read(buf, 0, buf.Length); - ms.Write(buf, 0, bytesRead); - } - while (bytesRead > 0); - - ffmpeg.WaitForExit(timeout); - - return ms.ToArray().AsSpan(); - } - } - - /// - /// Tries to load an episode's fingerprint from cache. If caching is not enabled, calling this function is a no-op. - /// - /// Episode to try to load from cache. - /// 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) - { - fingerprint = Array.Empty(); - - // If fingerprint caching isn't enabled, don't try to load anything. - if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false)) - { - return false; - } - - var path = GetFingerprintCachePath(episode); - - // If this episode isn't cached, bail out. - if (!File.Exists(path)) - { - return false; - } - - // TODO: make async - var raw = File.ReadAllLines(path, Encoding.UTF8); - var result = new List(); - - // Read each stringified uint. - result.EnsureCapacity(raw.Length); - - try - { - foreach (var rawNumber in raw) - { - result.Add(Convert.ToUInt32(rawNumber, CultureInfo.InvariantCulture)); - } - } - catch (FormatException) - { - // Occurs when the cached fingerprint is corrupt. - Logger?.LogDebug( - "Cached fingerprint for {Path} ({Id}) is corrupt, ignoring cache", - episode.Path, - episode.EpisodeId); - - return false; - } - - fingerprint = result.ToArray(); - return true; - } - - /// - /// Cache an episode's fingerprint to disk. If caching is not enabled, calling this function is a no-op. - /// - /// Episode to store in cache. - /// Fingerprint of the episode to store. - private static void CacheFingerprint(QueuedEpisode episode, List fingerprint) - { - // Bail out if caching isn't enabled. - if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false)) - { - return; - } - - // Stringify each data point. - var lines = new List(); - foreach (var number in fingerprint) - { - lines.Add(number.ToString(CultureInfo.InvariantCulture)); - } - - // Cache the episode. - File.WriteAllLinesAsync(GetFingerprintCachePath(episode), lines, Encoding.UTF8).ConfigureAwait(false); - } - - /// - /// Determines the path an episode should be cached at. - /// - /// Episode. - private static string GetFingerprintCachePath(QueuedEpisode episode) - { - return Path.Join(Plugin.Instance!.FingerprintCachePath, episode.EpisodeId.ToString("N")); - } - - /// - /// Gets Chromaprint debugging logs. - /// - /// Markdown formatted logs. - public static string GetChromaprintLogs() - { - var logs = new StringBuilder(1024); - - // Print the Chromaprint detection status at the top. - // Format: "* Chromaprint: `error`" - logs.Append("* Chromaprint: `"); - logs.Append(ChromaprintLogs["error"]); - logs.Append("`\n\n"); // Use two newlines to separate the bulleted list from the logs - - // Print all remaining logs - foreach (var kvp in ChromaprintLogs) - { - var name = kvp.Key; - var contents = kvp.Value; - - if (string.Equals(name, "error", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - /* Format: - * FFmpeg NAME: - * ``` - * LOGS - * ``` - */ - logs.Append("FFmpeg "); - logs.Append(name); - logs.Append(":\n```\n"); - logs.Append(contents); - - // ensure the closing triple backtick is on a separate line - if (!contents.EndsWith('\n')) - { - logs.Append('\n'); - } - - logs.Append("```\n\n"); - } - - return logs.ToString(); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs index 929feb9..8c2238d 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs @@ -62,6 +62,11 @@ public class PluginConfiguration : BasePluginConfiguration /// public int MinimumIntroDuration { get; set; } = 15; + /// + /// Gets or sets the maximum length of similar audio that will be considered an introduction. + /// + public int MaximumIntroDuration { get; set; } = 120; + // ===== Playback settings ===== /// @@ -83,4 +88,38 @@ public class PluginConfiguration : BasePluginConfiguration /// Gets or sets a value indicating whether the introduction in the first episode of a season should be skipped. /// public bool SkipFirstEpisode { get; set; } = true; + + /// + /// Gets or sets the amount of intro to play (in seconds). + /// + public int SecondsOfIntroToPlay { get; set; } = 3; + + // ===== Internal algorithm settings ===== + + /// + /// Gets or sets the maximum number of bits (out of 32 total) that can be different between two Chromaprint points before they are considered dissimilar. + /// Defaults to 6 (81% similar). + /// + public int MaximumFingerprintPointDifferences { get; set; } = 6; + + /// + /// Gets or sets the maximum number of seconds that can pass between two similar fingerprint points before a new time range is started. + /// + public double MaximumTimeSkip { get; set; } = 3.5; + + /// + /// Gets or sets the amount to shift inverted indexes by. + /// + public int InvertedIndexShift { get; set; } = 2; + + /// + /// Gets or sets the maximum amount of noise (in dB) that is considered silent. + /// Lowering this number will increase the filter's sensitivity to noise. + /// + public int SilenceDetectionMaximumNoise { get; set; } = -50; + + /// + /// Gets or sets the minimum duration of audio (in seconds) that is considered silent. + /// + public double SilenceDetectionMinimumDuration { get; set; } = 0.33; } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html index d4f43ec..1b2ed7e 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html @@ -139,6 +139,17 @@ +
+ + +
+ Similar sounding audio which is longer than this duration will not be considered an + introduction. +
+
+

The amount of each episode's audio that will be analyzed is determined using both the percentage of audio and maximum runtime of audio to analyze. The minimum of @@ -154,6 +165,32 @@ Increasing either of these settings will cause episode analysis to take much longer.

+ +
+ Silence detection options + +
+ + +
+ Noise tolerance in negative decibels. +
+
+ +
+ + +
+ Minimum silence duration in seconds before adjusting introduction end time. +
+
+
@@ -201,6 +238,16 @@ Seconds after the introduction starts to hide the skip prompt at. + +
+ + +
+ Seconds of introduction that should be played. Defaults to 2. +
+
@@ -212,12 +259,6 @@ - -

- Erasing introduction timestamps is only necessary after upgrading the plugin if specifically - requested to do so in the plugin's changelog. After the timestamps are erased, run the - Analyze episodes scheduled task to re-analyze all media on the server. -

@@ -228,12 +269,6 @@ -
- Analysis Statistics (experimental) - -

-
-
Advanced @@ -342,20 +377,26 @@ // settings elements var visualizer = document.querySelector("details#visualizer"); - var statistics = document.querySelector("details#statistics"); var support = document.querySelector("details#support"); var btnEraseTimestamps = document.querySelector("button#btnEraseTimestamps"); // all plugin configuration fields that can be get or set with .value (i.e. strings or numbers). var configurationFields = [ + // analysis "MaxParallelism", "SelectedLibraries", "AnalysisPercent", "AnalysisLengthLimit", "MinimumIntroDuration", + "MaximumIntroDuration", "EdlAction", + // playback "ShowPromptAdjustment", - "HidePromptAdjustment" + "HidePromptAdjustment", + "SecondsOfIntroToPlay", + // internals + "SilenceDetectionMaximumNoise", + "SilenceDetectionMinimumDuration", ] var booleanConfigurationFields = [ @@ -385,6 +426,11 @@ return; } + // ensure the series select is empty + while (selectShow.options.length > 0) { + selectShow.remove(0); + } + Dashboard.showLoadingMsg(); shows = await getJson("Intros/Shows"); @@ -435,59 +481,6 @@ Dashboard.alert("Press Ctrl+C to copy support bundle"); } - async function statisticsToggled() { - if (!statistics.open) { - return; - } - - // Blank any old statistics - const text = document.querySelector("pre#statisticsText"); - text.textContent = "All CPU times are displayed as seconds.\n\n"; - - Dashboard.showLoadingMsg(); - - // Load the statistics from the server - let stats = await getJson("Intros/Statistics"); - - // Select which fields to print and label them with more friendly descriptions - let fields = "TotalTaskTime,TotalCPUTime,FingerprintCPUTime,FirstPassCPUTime,SecondPassCPUTime," + - "IndexSearches,QuickScans,FullScans,TotalQueuedEpisodes"; - - let friendlyNames = { - TotalTaskTime: "Total task time", - TotalCPUTime: "Total CPU time", - FingerprintCPUTime: "Fingerprint CPU time", - FirstPassCPUTime: "First pass CPU time", - SecondPassCPUTime: "Second pass CPU time", - IndexSearches: "Index searches", - QuickScans: "Quick scans", - FullScans: "Full scans", - TotalQueuedEpisodes: "Episodes queued", - }; - - // Print all friendly names and data points - for (var f of fields.split(",")) { - let name = friendlyNames[f].padEnd(25); - let value = stats[f]; - - // If this statistic is a measure of CPU time, divide by 1,000 to turn milliseconds into seconds. - if (name.includes("time")) { - value = Math.round(value / 1000); - } - - text.textContent += name + value + "\n"; - } - - // Calculate the percentage of time (to two decimal places) spent waiting for fingerprints - const percentWait = Math.round((stats.FingerprintCPUTime * 10_000) / stats.TotalCPUTime) / 100; - - // Breakdown CPU time by analysis component - text.textContent += "\nCPU time breakdown:\n"; - text.textContent += "Fingerprint generation " + percentWait + "%\n"; - - Dashboard.hideLoadingMsg(); - } - // show changed, populate seasons async function showChanged() { clearSelect(selectSeason); @@ -717,7 +710,6 @@ }); visualizer.addEventListener("toggle", visualizerToggled); - statistics.addEventListener("toggle", statisticsToggled); support.addEventListener("toggle", supportToggled); txtOffset.addEventListener("change", renderTroubleshooter); selectShow.addEventListener("change", showChanged); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs index d4d07b7..d6c97f6 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs @@ -44,6 +44,7 @@ public class SkipIntroController : ControllerBase var config = Plugin.Instance!.Configuration; intro.ShowSkipPromptAt = Math.Max(0, intro.IntroStart - config.ShowPromptAdjustment); intro.HideSkipPromptAt = intro.IntroStart + config.HidePromptAdjustment; + intro.IntroEnd -= config.SecondsOfIntroToPlay; return intro; } @@ -53,7 +54,8 @@ public class SkipIntroController : ControllerBase /// Intro object if the provided item has an intro, null otherwise. private Intro? GetIntro(Guid id) { - return Plugin.Instance!.Intros.TryGetValue(id, out var intro) ? intro : null; + // 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; } /// diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/TroubleshootingController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/TroubleshootingController.cs index 3554feb..8923daf 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/TroubleshootingController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/TroubleshootingController.cs @@ -58,7 +58,7 @@ public class TroubleshootingController : ControllerBase bundle.Append(" seasons"); bundle.Append('\n'); - bundle.Append(Chromaprint.GetChromaprintLogs()); + bundle.Append(FFmpegWrapper.GetChromaprintLogs()); return bundle.ToString(); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs index 6d4d042..d4cec0d 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 Chromaprint.Fingerprint(needle); + return FFmpegWrapper.Fingerprint(needle); } } } @@ -166,17 +166,6 @@ public class VisualizationController : ControllerBase return NoContent(); } - /// - /// Returns the statistics for the most recent analysis. - /// - /// Analysis statistics. - /// AnalysisStatistics. - [HttpGet("Statistics")] - public ActionResult GetAnalysisStatistics() - { - return Plugin.Instance!.AnalysisStatistics; - } - private string GetSeasonName(QueuedEpisode episode) { return "Season " + episode.SeasonNumber.ToString(CultureInfo.InvariantCulture); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisStatistics.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisStatistics.cs deleted file mode 100644 index d0f1e97..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisStatistics.cs +++ /dev/null @@ -1,143 +0,0 @@ -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -using System; -using System.Threading; -using System.Text.Json; -using System.Text.Json.Serialization; - -/// -/// Detailed statistics about the last analysis operation performed. All times are represented as milliseconds. -/// -public class AnalysisStatistics -{ - /// - /// Gets the number of episodes that have been analyzed so far. - /// - public ThreadSafeInteger TotalAnalyzedEpisodes { get; } = new ThreadSafeInteger(); - - /// - /// Gets or sets the number of episodes that need to be analyzed. - /// - public int TotalQueuedEpisodes { get; set; } - - /// - /// Gets the number of times an index search successfully located a pair of introductions. - /// - public ThreadSafeInteger IndexSearches { get; } = new ThreadSafeInteger(); - - /// - /// Gets the number of times a quick scan successfully located a pair of introductions. - /// - public ThreadSafeInteger QuickScans { get; } = new ThreadSafeInteger(); - - /// - /// Gets the number of times a full scan successfully located a pair of introductions. - /// - public ThreadSafeInteger FullScans { get; } = new ThreadSafeInteger(); - - /// - /// Gets the total CPU time spent waiting for audio fingerprints to be generated. - /// - public ThreadSafeInteger FingerprintCPUTime { get; } = new ThreadSafeInteger(); - - /// - /// Gets the total CPU time spent analyzing fingerprints in the initial pass. - /// - public ThreadSafeInteger FirstPassCPUTime { get; } = new ThreadSafeInteger(); - - /// - /// Gets the total CPU time spent analyzing fingerprints in the second pass. - /// - public ThreadSafeInteger SecondPassCPUTime { get; } = new ThreadSafeInteger(); - - /// - /// Gets the total task runtime across all threads. - /// - public ThreadSafeInteger TotalCPUTime { get; } = new ThreadSafeInteger(); - - /// - /// Gets the total task runtime as measured by a clock. - /// - public ThreadSafeInteger TotalTaskTime { get; } = new ThreadSafeInteger(); -} - -/// -/// Convenience wrapper around a thread safe integer. -/// -[JsonConverter(typeof(ThreadSafeIntegerJsonConverter))] -public class ThreadSafeInteger -{ - private int value = 0; - - /// - /// Gets the current value stored by this integer. - /// - public int Value - { - get - { - return value; - } - } - - /// - /// Increment the value of this integer by 1. - /// - public void Increment() - { - Add(1); - } - - /// - /// Adds the total milliseconds elapsed since a start time. - /// - /// Start time. - public void AddDuration(DateTime start) - { - if (start == DateTime.MinValue) - { - return; - } - - var elapsed = DateTime.Now.Subtract(start); - Add((int)elapsed.TotalMilliseconds); - } - - /// - /// Adds the provided amount to this integer. - /// - /// Amount to add. - public void Add(int amount) - { - Interlocked.Add(ref value, amount); - } -} - -/// -/// Serialize thread safe integers to a regular integer (instead of an object with a Value property). -/// -public class ThreadSafeIntegerJsonConverter : JsonConverter -{ - /// - /// Deserialization of TSIs is not supported and will always throw a NotSupportedException. - /// - /// Reader. - /// Type. - /// Options. - /// Never returns. - public override ThreadSafeInteger? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - throw new NotSupportedException(); - } - - /// - /// Serialize the provided TSI. - /// - /// Writer. - /// TSI. - /// Options. - public override void Write(Utf8JsonWriter writer, ThreadSafeInteger value, JsonSerializerOptions options) - { - writer.WriteNumberValue(value.Value); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs index 0875b80..731778c 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json.Serialization; namespace ConfusedPolarBear.Plugin.IntroSkipper; @@ -31,6 +32,17 @@ public class Intro IntroEnd = 0; } + /// + /// Initializes a new instance of the class. + /// + /// intro. + public Intro(Intro intro) + { + EpisodeId = intro.EpisodeId; + IntroStart = intro.IntroStart; + IntroEnd = intro.IntroEnd; + } + /// /// Initializes a new instance of the class. /// @@ -49,6 +61,12 @@ public class Intro /// public bool Valid => IntroEnd > 0; + /// + /// Gets the duration of this intro. + /// + [JsonIgnore] + public double Duration => IntroEnd - IntroStart; + /// /// Gets or sets the introduction sequence start time. /// diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/SeasonHistogram.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/SeasonHistogram.cs deleted file mode 100644 index 4cd4a20..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/SeasonHistogram.cs +++ /dev/null @@ -1,31 +0,0 @@ -#pragma warning disable CA1815 // Override equals and operator equals on value types - -using System; -using System.Collections.ObjectModel; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Histogram entry for episodes in a season. -/// -public struct SeasonHistogram -{ - /// - /// Initializes a new instance of the struct. - /// - /// First episode seen with this duration. - public SeasonHistogram(Guid firstEpisode) - { - Episodes.Add(firstEpisode); - } - - /// - /// Gets episodes with this duration. - /// - public Collection Episodes { get; } = new Collection(); - - /// - /// Gets the number of times an episode with an intro of this duration has been seen. - /// - public int Count => Episodes?.Count ?? 0; -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs index 7e7b51b..451df2f 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs @@ -69,6 +69,18 @@ public class TimeRange : IComparable return tr.Duration.CompareTo(Duration); } + + /// + /// Tests if this TimeRange object intersects the provided TimeRange. + /// + /// Second TimeRange object to test. + /// true if tr intersects the current TimeRange, false otherwise. + public bool Intersects(TimeRange tr) + { + return + (Start < tr.Start && tr.Start < End) || + (Start < tr.End && tr.End < End); + } } #pragma warning restore CA1036 diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs index 5113ef4..6a5dff2 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs @@ -46,7 +46,7 @@ public class Entrypoint : IServerEntryPoint /// Task. public Task RunAsync() { - Chromaprint.Logger = _logger; + FFmpegWrapper.Logger = _logger; #if DEBUG LogVersion(); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs new file mode 100644 index 0000000..4f38984 --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs @@ -0,0 +1,507 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +/// +/// Wrapper for libchromaprint and the silencedetect filter. +/// +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): (?