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.
+
+
+
@@ -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.
-
-
-
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): (?