Merge branch 'algorithm_rewrite'

This commit is contained in:
ConfusedPolarBear 2022-09-24 22:49:17 -05:00
commit 3dc8b59bf1
22 changed files with 944 additions and 1050 deletions

View File

@ -2,7 +2,7 @@ name: 'Build Plugin'
on:
push:
branches: [ "master" ]
branches: [ "master", "algorithm_rewrite" ]
pull_request:
branches: [ "master" ]

View File

@ -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)

View File

@ -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);
}
/// <summary>
/// Test that the silencedetect wrapper is working.
/// </summary>
[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
}

View File

@ -71,4 +71,22 @@ public class TestTimeRanges
Assert.Equal(expected, actual);
}
/// <summary>
/// Tests that TimeRange intersections are detected correctly.
/// Tests each time range against a range of 5 to 10 seconds.
/// </summary>
[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));
}
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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;
/// <summary>
/// Wrapper for libchromaprint.
/// </summary>
public static class Chromaprint
{
/// <summary>
/// Gets or sets the logger.
/// </summary>
public static ILogger? Logger { get; set; }
private static Dictionary<string, string> ChromaprintLogs { get; set; } = new();
/// <summary>
/// Check that the installed version of ffmpeg supports chromaprint.
/// </summary>
/// <returns>true if a compatible version of ffmpeg is installed, false on any error.</returns>
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;
}
}
/// <summary>
/// Fingerprint a queued episode.
/// </summary>
/// <param name="episode">Queued episode to fingerprint.</param>
/// <returns>Numerical fingerprint points.</returns>
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<uint>();
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();
}
/// <summary>
/// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at.
/// </summary>
/// <param name="fingerprint">Chromaprint fingerprint.</param>
/// <returns>Inverted index.</returns>
public static Dictionary<uint, int> CreateInvertedIndex(uint[] fingerprint)
{
var invIndex = new Dictionary<uint, int>();
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;
}
/// <summary>
/// Runs ffmpeg and returns standard output.
/// </summary>
/// <param name="args">Arguments to pass to ffmpeg.</param>
/// <param name="timeout">Timeout (in seconds) to wait for ffmpeg to exit.</param>
private static ReadOnlySpan<byte> 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();
}
}
/// <summary>
/// Tries to load an episode's fingerprint from cache. If caching is not enabled, calling this function is a no-op.
/// </summary>
/// <param name="episode">Episode to try to load from cache.</param>
/// <param name="fingerprint">Array to store the fingerprint in.</param>
/// <returns>true if the episode was successfully loaded from cache, false on any other error.</returns>
private static bool LoadCachedFingerprint(QueuedEpisode episode, out uint[] fingerprint)
{
fingerprint = Array.Empty<uint>();
// 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<uint>();
// 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;
}
/// <summary>
/// Cache an episode's fingerprint to disk. If caching is not enabled, calling this function is a no-op.
/// </summary>
/// <param name="episode">Episode to store in cache.</param>
/// <param name="fingerprint">Fingerprint of the episode to store.</param>
private static void CacheFingerprint(QueuedEpisode episode, List<uint> fingerprint)
{
// Bail out if caching isn't enabled.
if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false))
{
return;
}
// Stringify each data point.
var lines = new List<string>();
foreach (var number in fingerprint)
{
lines.Add(number.ToString(CultureInfo.InvariantCulture));
}
// Cache the episode.
File.WriteAllLinesAsync(GetFingerprintCachePath(episode), lines, Encoding.UTF8).ConfigureAwait(false);
}
/// <summary>
/// Determines the path an episode should be cached at.
/// </summary>
/// <param name="episode">Episode.</param>
private static string GetFingerprintCachePath(QueuedEpisode episode)
{
return Path.Join(Plugin.Instance!.FingerprintCachePath, episode.EpisodeId.ToString("N"));
}
/// <summary>
/// Gets Chromaprint debugging logs.
/// </summary>
/// <returns>Markdown formatted logs.</returns>
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();
}
}

View File

@ -62,6 +62,11 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary>
public int MinimumIntroDuration { get; set; } = 15;
/// <summary>
/// Gets or sets the maximum length of similar audio that will be considered an introduction.
/// </summary>
public int MaximumIntroDuration { get; set; } = 120;
// ===== Playback settings =====
/// <summary>
@ -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.
/// </summary>
public bool SkipFirstEpisode { get; set; } = true;
/// <summary>
/// Gets or sets the amount of intro to play (in seconds).
/// </summary>
public int SecondsOfIntroToPlay { get; set; } = 3;
// ===== Internal algorithm settings =====
/// <summary>
/// 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).
/// </summary>
public int MaximumFingerprintPointDifferences { get; set; } = 6;
/// <summary>
/// Gets or sets the maximum number of seconds that can pass between two similar fingerprint points before a new time range is started.
/// </summary>
public double MaximumTimeSkip { get; set; } = 3.5;
/// <summary>
/// Gets or sets the amount to shift inverted indexes by.
/// </summary>
public int InvertedIndexShift { get; set; } = 2;
/// <summary>
/// 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.
/// </summary>
public int SilenceDetectionMaximumNoise { get; set; } = -50;
/// <summary>
/// Gets or sets the minimum duration of audio (in seconds) that is considered silent.
/// </summary>
public double SilenceDetectionMinimumDuration { get; set; } = 0.33;
}

View File

@ -139,6 +139,17 @@
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="MaximumIntroDuration">
Maximum introduction duration (in seconds)
</label>
<input id="MaximumIntroDuration" type="number" is="emby-input" min="1" />
<div class="fieldDescription">
Similar sounding audio which is longer than this duration will not be considered an
introduction.
</div>
</div>
<p>
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.
</p>
</details>
<details id="silence">
<summary>Silence detection options</summary>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SilenceDetectionMaximumNoise">
Noise tolerance
</label>
<input id="SilenceDetectionMaximumNoise" type="number" is="emby-input" min="-90"
max="0" />
<div class="fieldDescription">
Noise tolerance in negative decibels.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SilenceDetectionMinimumDuration">
Minimum silence duration
</label>
<input id="SilenceDetectionMinimumDuration" type="number" is="emby-input" min="0"
step="0.01" />
<div class="fieldDescription">
Minimum silence duration in seconds before adjusting introduction end time.
</div>
</div>
</details>
</fieldset>
<fieldset class="verticalSection-extrabottompadding">
@ -201,6 +238,16 @@
Seconds after the introduction starts to hide the skip prompt at.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SecondsOfIntroToPlay">
Seconds of intro to play
</label>
<input id="SecondsOfIntroToPlay" type="number" is="emby-input" min="0" />
<div class="fieldDescription">
Seconds of introduction that should be played. Defaults to 2.
</div>
</div>
</fieldset>
<div>
@ -212,12 +259,6 @@
<button id="btnEraseTimestamps" is="emby-button" class="raised block emby-button">
<span>Erase introduction timestamps</span>
</button>
<p>
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.
</p>
</div>
</form>
</div>
@ -228,12 +269,6 @@
<textarea id="supportBundle" rows="20" cols="75" readonly></textarea>
</details>
<details id="statistics">
<summary>Analysis Statistics (experimental)</summary>
<pre id="statisticsText" style="font-size: larger"></p>
</details>
<details id="visualizer">
<summary>Advanced</summary>
@ -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);

View File

@ -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
/// <returns>Intro object if the provided item has an intro, null otherwise.</returns>
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;
}
/// <summary>

View File

@ -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();
}

View File

@ -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();
}
/// <summary>
/// Returns the statistics for the most recent analysis.
/// </summary>
/// <response code="200">Analysis statistics.</response>
/// <returns>AnalysisStatistics.</returns>
[HttpGet("Statistics")]
public ActionResult<AnalysisStatistics> GetAnalysisStatistics()
{
return Plugin.Instance!.AnalysisStatistics;
}
private string GetSeasonName(QueuedEpisode episode)
{
return "Season " + episode.SeasonNumber.ToString(CultureInfo.InvariantCulture);

View File

@ -1,143 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Threading;
using System.Text.Json;
using System.Text.Json.Serialization;
/// <summary>
/// Detailed statistics about the last analysis operation performed. All times are represented as milliseconds.
/// </summary>
public class AnalysisStatistics
{
/// <summary>
/// Gets the number of episodes that have been analyzed so far.
/// </summary>
public ThreadSafeInteger TotalAnalyzedEpisodes { get; } = new ThreadSafeInteger();
/// <summary>
/// Gets or sets the number of episodes that need to be analyzed.
/// </summary>
public int TotalQueuedEpisodes { get; set; }
/// <summary>
/// Gets the number of times an index search successfully located a pair of introductions.
/// </summary>
public ThreadSafeInteger IndexSearches { get; } = new ThreadSafeInteger();
/// <summary>
/// Gets the number of times a quick scan successfully located a pair of introductions.
/// </summary>
public ThreadSafeInteger QuickScans { get; } = new ThreadSafeInteger();
/// <summary>
/// Gets the number of times a full scan successfully located a pair of introductions.
/// </summary>
public ThreadSafeInteger FullScans { get; } = new ThreadSafeInteger();
/// <summary>
/// Gets the total CPU time spent waiting for audio fingerprints to be generated.
/// </summary>
public ThreadSafeInteger FingerprintCPUTime { get; } = new ThreadSafeInteger();
/// <summary>
/// Gets the total CPU time spent analyzing fingerprints in the initial pass.
/// </summary>
public ThreadSafeInteger FirstPassCPUTime { get; } = new ThreadSafeInteger();
/// <summary>
/// Gets the total CPU time spent analyzing fingerprints in the second pass.
/// </summary>
public ThreadSafeInteger SecondPassCPUTime { get; } = new ThreadSafeInteger();
/// <summary>
/// Gets the total task runtime across all threads.
/// </summary>
public ThreadSafeInteger TotalCPUTime { get; } = new ThreadSafeInteger();
/// <summary>
/// Gets the total task runtime as measured by a clock.
/// </summary>
public ThreadSafeInteger TotalTaskTime { get; } = new ThreadSafeInteger();
}
/// <summary>
/// Convenience wrapper around a thread safe integer.
/// </summary>
[JsonConverter(typeof(ThreadSafeIntegerJsonConverter))]
public class ThreadSafeInteger
{
private int value = 0;
/// <summary>
/// Gets the current value stored by this integer.
/// </summary>
public int Value
{
get
{
return value;
}
}
/// <summary>
/// Increment the value of this integer by 1.
/// </summary>
public void Increment()
{
Add(1);
}
/// <summary>
/// Adds the total milliseconds elapsed since a start time.
/// </summary>
/// <param name="start">Start time.</param>
public void AddDuration(DateTime start)
{
if (start == DateTime.MinValue)
{
return;
}
var elapsed = DateTime.Now.Subtract(start);
Add((int)elapsed.TotalMilliseconds);
}
/// <summary>
/// Adds the provided amount to this integer.
/// </summary>
/// <param name="amount">Amount to add.</param>
public void Add(int amount)
{
Interlocked.Add(ref value, amount);
}
}
/// <summary>
/// Serialize thread safe integers to a regular integer (instead of an object with a Value property).
/// </summary>
public class ThreadSafeIntegerJsonConverter : JsonConverter<ThreadSafeInteger>
{
/// <summary>
/// Deserialization of TSIs is not supported and will always throw a NotSupportedException.
/// </summary>
/// <param name="reader">Reader.</param>
/// <param name="typeToConvert">Type.</param>
/// <param name="options">Options.</param>
/// <returns>Never returns.</returns>
public override ThreadSafeInteger? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
throw new NotSupportedException();
}
/// <summary>
/// Serialize the provided TSI.
/// </summary>
/// <param name="writer">Writer.</param>
/// <param name="value">TSI.</param>
/// <param name="options">Options.</param>
public override void Write(Utf8JsonWriter writer, ThreadSafeInteger value, JsonSerializerOptions options)
{
writer.WriteNumberValue(value.Value);
}
}

View File

@ -1,4 +1,5 @@
using System;
using System.Text.Json.Serialization;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
@ -31,6 +32,17 @@ public class Intro
IntroEnd = 0;
}
/// <summary>
/// Initializes a new instance of the <see cref="Intro"/> class.
/// </summary>
/// <param name="intro">intro.</param>
public Intro(Intro intro)
{
EpisodeId = intro.EpisodeId;
IntroStart = intro.IntroStart;
IntroEnd = intro.IntroEnd;
}
/// <summary>
/// Initializes a new instance of the <see cref="Intro"/> class.
/// </summary>
@ -49,6 +61,12 @@ public class Intro
/// </summary>
public bool Valid => IntroEnd > 0;
/// <summary>
/// Gets the duration of this intro.
/// </summary>
[JsonIgnore]
public double Duration => IntroEnd - IntroStart;
/// <summary>
/// Gets or sets the introduction sequence start time.
/// </summary>

View File

@ -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;
/// <summary>
/// Histogram entry for episodes in a season.
/// </summary>
public struct SeasonHistogram
{
/// <summary>
/// Initializes a new instance of the <see cref="SeasonHistogram"/> struct.
/// </summary>
/// <param name="firstEpisode">First episode seen with this duration.</param>
public SeasonHistogram(Guid firstEpisode)
{
Episodes.Add(firstEpisode);
}
/// <summary>
/// Gets episodes with this duration.
/// </summary>
public Collection<Guid> Episodes { get; } = new Collection<Guid>();
/// <summary>
/// Gets the number of times an episode with an intro of this duration has been seen.
/// </summary>
public int Count => Episodes?.Count ?? 0;
}

View File

@ -69,6 +69,18 @@ public class TimeRange : IComparable
return tr.Duration.CompareTo(Duration);
}
/// <summary>
/// Tests if this TimeRange object intersects the provided TimeRange.
/// </summary>
/// <param name="tr">Second TimeRange object to test.</param>
/// <returns>true if tr intersects the current TimeRange, false otherwise.</returns>
public bool Intersects(TimeRange tr)
{
return
(Start < tr.Start && tr.Start < End) ||
(Start < tr.End && tr.End < End);
}
}
#pragma warning restore CA1036

View File

@ -46,7 +46,7 @@ public class Entrypoint : IServerEntryPoint
/// <returns>Task.</returns>
public Task RunAsync()
{
Chromaprint.Logger = _logger;
FFmpegWrapper.Logger = _logger;
#if DEBUG
LogVersion();

View File

@ -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;
/// <summary>
/// Wrapper for libchromaprint and the silencedetect filter.
/// </summary>
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
/// <summary>
/// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
/// </summary>
private static readonly Regex SilenceDetectionExpression = new(
"silence_(?<type>start|end): (?<time>[0-9\\.]+)");
/// <summary>
/// Gets or sets the logger.
/// </summary>
public static ILogger? Logger { get; set; }
private static Dictionary<string, string> ChromaprintLogs { get; set; } = new();
private static Dictionary<Guid, Dictionary<uint, int>> InvertedIndexCache { get; set; } = new();
/// <summary>
/// Check that the installed version of ffmpeg supports chromaprint.
/// </summary>
/// <returns>true if a compatible version of ffmpeg is installed, false on any error.</returns>
public static bool CheckFFmpegVersion()
{
try
{
// Always log ffmpeg's version information.
if (!CheckFFmpegRequirement(
"-version",
"ffmpeg",
"version",
"Unknown error with FFmpeg version"))
{
ChromaprintLogs["error"] = "unknown_error";
return false;
}
// First, validate that the installed version of ffmpeg supports chromaprint at all.
if (!CheckFFmpegRequirement(
"-muxers",
"chromaprint",
"muxer list",
"The installed version of ffmpeg does not support chromaprint"))
{
ChromaprintLogs["error"] = "chromaprint_not_supported";
return false;
}
// Second, validate that the Chromaprint muxer understands the "-fp_format raw" option.
if (!CheckFFmpegRequirement(
"-h muxer=chromaprint",
"binary raw fingerprint",
"chromaprint options",
"The installed version of ffmpeg does not support raw binary fingerprints"))
{
ChromaprintLogs["error"] = "fp_format_not_supported";
return false;
}
// Third, validate that ffmpeg supports of the all required silencedetect options.
if (!CheckFFmpegRequirement(
"-h filter=silencedetect",
"noise tolerance",
"silencedetect options",
"The installed version of ffmpeg does not support the silencedetect filter"))
{
ChromaprintLogs["error"] = "silencedetect_not_supported";
return false;
}
Logger?.LogDebug("Installed version of ffmpeg meets fingerprinting requirements");
ChromaprintLogs["error"] = "okay";
return true;
}
catch
{
ChromaprintLogs["error"] = "unknown_error";
return false;
}
}
/// <summary>
/// Run an FFmpeg command with the provided arguments and validate that the output contains
/// the provided string.
/// </summary>
/// <param name="arguments">Arguments to pass to FFmpeg.</param>
/// <param name="mustContain">String that the output must contain. Case insensitive.</param>
/// <param name="bundleName">Support bundle key to store FFmpeg's output under.</param>
/// <param name="errorMessage">Error message to log if this requirement is not met.</param>
/// <returns>true on success, false on error.</returns>
private static bool CheckFFmpegRequirement(
string arguments,
string mustContain,
string bundleName,
string errorMessage)
{
Logger?.LogDebug("Checking FFmpeg requirement {Arguments}", arguments);
var output = Encoding.UTF8.GetString(GetOutput(arguments, string.Empty, false, 2000));
Logger?.LogTrace("Output of ffmpeg {Arguments}: {Output}", arguments, output);
ChromaprintLogs[bundleName] = output;
if (!output.Contains(mustContain, StringComparison.OrdinalIgnoreCase))
{
Logger?.LogError("{ErrorMessage}", errorMessage);
return false;
}
Logger?.LogDebug("FFmpeg requirement {Arguments} met", arguments);
return true;
}
/// <summary>
/// Fingerprint a queued episode.
/// </summary>
/// <param name="episode">Queued episode to fingerprint.</param>
/// <returns>Numerical fingerprint points.</returns>
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}\" (id {Id})",
episode.FingerprintDuration,
episode.Path,
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, string.Empty);
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<uint>();
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();
}
/// <summary>
/// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at.
/// </summary>
/// <param name="id">Episode ID.</param>
/// <param name="fingerprint">Chromaprint fingerprint.</param>
/// <returns>Inverted index.</returns>
public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint)
{
lock (InvertedIndexCacheLock)
{
if (InvertedIndexCache.TryGetValue(id, out var cached))
{
return cached;
}
}
var invIndex = new Dictionary<uint, int>();
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;
}
lock (InvertedIndexCacheLock)
{
InvertedIndexCache[id] = invIndex;
}
return invIndex;
}
/// <summary>
/// Detect ranges of silence in the provided episode.
/// </summary>
/// <param name="episode">Queued episode.</param>
/// <param name="limit">Maximum amount of audio (in seconds) to detect silence in.</param>
/// <returns>Array of TimeRange objects that are silent in the queued episode.</returns>
public static TimeRange[] DetectSilence(QueuedEpisode episode, int limit)
{
Logger?.LogTrace(
"Detecting silence in \"{File}\" (limit {Limit}, id {Id})",
episode.Path,
limit,
episode.EpisodeId);
// TODO: select the audio track that matches the user's preferred language, falling
// back to the first track if nothing matches
// -vn, -sn, -dn: ignore video, subtitle, and data tracks
var args = string.Format(
CultureInfo.InvariantCulture,
"-vn -sn -dn " +
"-i \"{0}\" -to {1} -af \"silencedetect=noise={2}dB:duration=0.1\" -f null -",
episode.Path,
limit,
Plugin.Instance?.Configuration.SilenceDetectionMaximumNoise ?? -50);
// Cache the output of this command to "GUID-intro-silence-v1"
var cacheKey = episode.EpisodeId.ToString("N") + "-intro-silence-v1";
var currentRange = new TimeRange();
var silenceRanges = new List<TimeRange>();
// Each match will have a type (either "start" or "end") and a timecode (a double).
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
foreach (Match match in SilenceDetectionExpression.Matches(raw))
{
var isStart = match.Groups["type"].Value == "start";
var time = Convert.ToDouble(match.Groups["time"].Value, CultureInfo.InvariantCulture);
if (isStart)
{
currentRange.Start = time;
}
else
{
currentRange.End = time;
silenceRanges.Add(new TimeRange(currentRange));
}
}
return silenceRanges.ToArray();
}
/// <summary>
/// Runs ffmpeg and returns standard output (or error).
/// If caching is enabled, will use cacheFilename to cache the output of this command.
/// </summary>
/// <param name="args">Arguments to pass to ffmpeg.</param>
/// <param name="cacheFilename">Filename to cache the output of this command to, or string.Empty if this command should not be cached.</param>
/// <param name="stderr">If standard error should be returned.</param>
/// <param name="timeout">Timeout (in miliseconds) to wait for ffmpeg to exit.</param>
private static ReadOnlySpan<byte> GetOutput(
string args,
string cacheFilename,
bool stderr = false,
int timeout = 60 * 1000)
{
var ffmpegPath = Plugin.Instance?.FFmpegPath ?? "ffmpeg";
// The silencedetect filter outputs silence timestamps at the info log level.
var logLevel = args.Contains("silencedetect", StringComparison.OrdinalIgnoreCase) ?
"info" :
"warning";
var cacheOutput =
(Plugin.Instance?.Configuration.CacheFingerprints ?? false) &&
!string.IsNullOrEmpty(cacheFilename);
// If caching is enabled, try to load the output of this command from the cached file.
if (cacheOutput)
{
// Calculate the absolute path to the cached file.
cacheFilename = Path.Join(Plugin.Instance!.FingerprintCachePath, cacheFilename);
// If the cached file exists, return whatever it holds.
if (File.Exists(cacheFilename))
{
Logger?.LogTrace("Returning contents of cache {Cache}", cacheFilename);
return File.ReadAllBytes(cacheFilename);
}
Logger?.LogTrace("Not returning contents of cache {Cache} (not found)", cacheFilename);
}
// Prepend some flags to prevent FFmpeg from logging it's banner and progress information
// for each file that is fingerprinted.
var prependArgument = string.Format(
CultureInfo.InvariantCulture,
"-hide_banner -loglevel {0} ",
logLevel);
var info = new ProcessStartInfo(ffmpegPath, args.Insert(0, prependArgument))
{
WindowStyle = ProcessWindowStyle.Hidden,
CreateNoWindow = true,
UseShellExecute = false,
ErrorDialog = false,
RedirectStandardOutput = !stderr,
RedirectStandardError = stderr
};
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
{
var streamReader = stderr ? ffmpeg.StandardError : ffmpeg.StandardOutput;
bytesRead = streamReader.BaseStream.Read(buf, 0, buf.Length);
ms.Write(buf, 0, bytesRead);
}
while (bytesRead > 0);
ffmpeg.WaitForExit(timeout);
var output = ms.ToArray();
// If caching is enabled, cache the output of this command.
if (cacheOutput)
{
File.WriteAllBytes(cacheFilename, output);
}
return output;
}
}
/// <summary>
/// Tries to load an episode's fingerprint from cache. If caching is not enabled, calling this function is a no-op.
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
/// </summary>
/// <param name="episode">Episode to try to load from cache.</param>
/// <param name="fingerprint">Array to store the fingerprint in.</param>
/// <returns>true if the episode was successfully loaded from cache, false on any other error.</returns>
private static bool LoadCachedFingerprint(QueuedEpisode episode, out uint[] fingerprint)
{
fingerprint = Array.Empty<uint>();
// 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<uint>();
// 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;
}
/// <summary>
/// Cache an episode's fingerprint to disk. If caching is not enabled, calling this function is a no-op.
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
/// </summary>
/// <param name="episode">Episode to store in cache.</param>
/// <param name="fingerprint">Fingerprint of the episode to store.</param>
private static void CacheFingerprint(QueuedEpisode episode, List<uint> fingerprint)
{
// Bail out if caching isn't enabled.
if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false))
{
return;
}
// Stringify each data point.
var lines = new List<string>();
foreach (var number in fingerprint)
{
lines.Add(number.ToString(CultureInfo.InvariantCulture));
}
// Cache the episode.
File.WriteAllLinesAsync(GetFingerprintCachePath(episode), lines, Encoding.UTF8).ConfigureAwait(false);
}
/// <summary>
/// Determines the path an episode should be cached at.
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
/// </summary>
/// <param name="episode">Episode.</param>
private static string GetFingerprintCachePath(QueuedEpisode episode)
{
return Path.Join(Plugin.Instance!.FingerprintCachePath, episode.EpisodeId.ToString("N"));
}
/// <summary>
/// Gets Chromaprint debugging logs.
/// </summary>
/// <returns>Markdown formatted logs.</returns>
public static string GetChromaprintLogs()
{
var logs = new StringBuilder(1024);
// Print the FFmpeg detection status at the top.
// Format: "* FFmpeg: `error`"
logs.Append("* FFmpeg: `");
logs.Append(ChromaprintLogs["error"]);
logs.Append("`\n\n"); // Use two newlines to separate the bulleted list from the logs
// Don't print FFmpeg's logs if no error was detected during initialization.
if (ChromaprintLogs["error"] == "okay")
{
return logs.ToString();
}
// 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();
}
}

View File

@ -80,11 +80,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary>
public int TotalQueued { get; set; }
/// <summary>
/// Gets or sets the statistics from the most recent analysis run.
/// </summary>
public AnalysisStatistics AnalysisStatistics { get; set; } = new AnalysisStatistics();
/// <summary>
/// Gets the directory to cache fingerprints in.
/// </summary>

View File

@ -40,7 +40,7 @@ public class QueueManager
public void EnqueueAllEpisodes()
{
// Assert that ffmpeg with chromaprint is installed
if (!Chromaprint.CheckFFmpegVersion())
if (!FFmpegWrapper.CheckFFmpegVersion())
{
throw new FingerprintException(
"ffmpeg with chromaprint is not installed on this system - episodes will not be analyzed. If Jellyfin is running natively, install jellyfin-ffmpeg5. If Jellyfin is running in a container, upgrade it to the latest version of 10.8.0.");
@ -136,6 +136,7 @@ public class QueueManager
},
IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Episode },
Recursive = true,
IsVirtualItem = false
};
_logger.LogDebug("Getting items");
@ -174,7 +175,11 @@ public class QueueManager
if (string.IsNullOrEmpty(episode.Path))
{
_logger.LogWarning("Not queuing episode {Id} as no path was provided by Jellyfin", episode.Id);
_logger.LogWarning(
"Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin",
episode.Name,
episode.SeriesName,
episode.Id);
return;
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Numerics;
using System.Threading;
using System.Threading.Tasks;
@ -14,64 +15,35 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// </summary>
public class AnalyzeEpisodesTask : IScheduledTask
{
/// <summary>
/// Maximum number of bits (out of 32 total) that can be different between segments before they are considered dissimilar.
/// 6 bits means the audio must be at least 81% similar (1 - 6 / 32).
/// </summary>
private const double MaximumDifferences = 6;
/// <summary>
/// Maximum time (in seconds) permitted between timestamps before they are considered non-contiguous.
/// </summary>
private const double MaximumDistance = 3.5;
/// <summary>
/// Seconds of audio in one fingerprint point. This value is defined by the Chromaprint library and should not be changed.
/// </summary>
private const double SamplesToSeconds = 0.128;
/// <summary>
/// Bucket size used in the reanalysis histogram.
/// </summary>
private const int ReanalysisBucketWidth = 5;
/// <summary>
/// Maximum time (in seconds) that an intro's duration can be different from a typical intro's duration before marking it for reanalysis.
/// </summary>
private const double ReanalysisTolerance = ReanalysisBucketWidth * 1.5;
private readonly ILogger<AnalyzeEpisodesTask> _logger;
private readonly ILogger<QueueManager> _queueLogger;
private readonly ILibraryManager? _libraryManager;
/// <summary>
/// Lock which guards the fingerprint cache dictionary.
/// </summary>
private readonly object _fingerprintCacheLock = new object();
/// <summary>
/// Lock which guards the shared dictionary of intros.
/// </summary>
private readonly object _introsLock = new object();
/// <summary>
/// Temporary fingerprint cache to speed up reanalysis.
/// Fingerprints are removed from this after a season is analyzed.
/// </summary>
private Dictionary<Guid, uint[]> _fingerprintCache;
/// <summary>
/// Statistics for the currently running analysis task.
/// </summary>
private AnalysisStatistics analysisStatistics = new AnalysisStatistics();
/// <summary>
/// Minimum duration of similar audio that will be considered an introduction.
/// </summary>
private static int minimumIntroDuration = 15;
private static int maximumDifferences = 6;
private static int invertedIndexShift = 2;
private static double maximumTimeSkip = 3.5;
private static double silenceDetectionMinimumDuration = 0.33;
/// <summary>
/// Initializes a new instance of the <see cref="AnalyzeEpisodesTask"/> class.
/// </summary>
@ -91,8 +63,6 @@ public class AnalyzeEpisodesTask : IScheduledTask
_logger = loggerFactory.CreateLogger<AnalyzeEpisodesTask>();
_queueLogger = loggerFactory.CreateLogger<QueueManager>();
_fingerprintCache = new Dictionary<Guid, uint[]>();
EdlManager.Initialize(_logger);
}
@ -141,19 +111,23 @@ public class AnalyzeEpisodesTask : IScheduledTask
"No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly.");
}
// Load analysis settings from configuration
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
maximumDifferences = config.MaximumFingerprintPointDifferences;
invertedIndexShift = config.InvertedIndexShift;
maximumTimeSkip = config.MaximumTimeSkip;
silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration;
// Log EDL settings
EdlManager.LogConfiguration();
// Include the previously processed episodes in the percentage reported to the UI.
var totalProcessed = CountProcessedEpisodes();
var totalProcessed = 0;
var options = new ParallelOptions()
{
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
};
var taskStart = DateTime.Now;
analysisStatistics = new AnalysisStatistics();
analysisStatistics.TotalQueuedEpisodes = Plugin.Instance!.TotalQueued;
minimumIntroDuration = Plugin.Instance!.Configuration.MinimumIntroDuration;
@ -169,9 +143,16 @@ public class AnalyzeEpisodesTask : IScheduledTask
try
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
var episodes = new ReadOnlyCollection<QueuedEpisode>(season.Value);
// Increment totalProcessed by the number of episodes in this season that were actually analyzed
// (instead of just using the number of episodes in the current season).
var analyzed = AnalyzeSeason(season, cancellationToken);
var analyzed = AnalyzeSeason(episodes, cancellationToken);
Interlocked.Add(ref totalProcessed, analyzed);
writeEdl = analyzed > 0 || Plugin.Instance!.Configuration.RegenerateEdlFiles;
}
@ -192,30 +173,14 @@ public class AnalyzeEpisodesTask : IScheduledTask
ex);
}
// Clear this season's episodes from the temporary fingerprint cache.
lock (_fingerprintCacheLock)
{
foreach (var ep in season.Value)
{
_fingerprintCache.Remove(ep.EpisodeId);
}
}
if (writeEdl && Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
{
EdlManager.UpdateEDLFiles(season.Value.AsReadOnly());
}
progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued);
analysisStatistics.TotalCPUTime.AddDuration(workerStart);
Plugin.Instance!.AnalysisStatistics = analysisStatistics;
});
// Update analysis statistics
analysisStatistics.TotalTaskTime.AddDuration(taskStart);
Plugin.Instance!.AnalysisStatistics = analysisStatistics;
// Turn the regenerate EDL flag off after the scan completes.
if (Plugin.Instance!.Configuration.RegenerateEdlFiles)
{
@ -254,118 +219,118 @@ public class AnalyzeEpisodesTask : IScheduledTask
/// <summary>
/// Fingerprints all episodes in the provided season and stores the timestamps of all introductions.
/// </summary>
/// <param name="season">Pairing of season GUID to a list of QueuedEpisode objects.</param>
/// <param name="episodes">Episodes in this season.</param>
/// <param name="cancellationToken">Cancellation token provided by the scheduled task.</param>
/// <returns>Number of episodes from the provided season that were analyzed.</returns>
private int AnalyzeSeason(
KeyValuePair<Guid, List<QueuedEpisode>> season,
ReadOnlyCollection<QueuedEpisode> episodes,
CancellationToken cancellationToken)
{
// All intros for this season.
var seasonIntros = new Dictionary<Guid, Intro>();
var episodes = season.Value;
var first = episodes[0];
// Cache of all fingerprints for this season.
var fingerprintCache = new Dictionary<Guid, uint[]>();
// Episode analysis queue.
var episodeAnalysisQueue = new List<QueuedEpisode>(episodes);
/* Don't analyze specials or seasons with an insufficient number of episodes.
* A season with only 1 episode can't be analyzed as it would compare the episode to itself,
* which would result in the entire episode being marked as an introduction, as the audio is identical.
*/
if (season.Value.Count < 2 || first.SeasonNumber == 0)
if (episodes.Count < 2 || episodes[0].SeasonNumber == 0)
{
return episodes.Count;
}
var unanalyzed = false;
var first = episodes[0];
// Only log an analysis message if there are unanalyzed episodes in this season.
foreach (var episode in episodes)
{
if (!Plugin.Instance!.Intros.ContainsKey(episode.EpisodeId))
{
unanalyzed = true;
break;
}
}
if (unanalyzed)
{
_logger.LogInformation(
"Analyzing {Count} episodes from {Name} season {Season}",
season.Value.Count,
first.SeriesName,
first.SeasonNumber);
}
else
{
_logger.LogDebug(
"All episodes from {Name} season {Season} have already been analyzed",
episodes.Count,
first.SeriesName,
first.SeasonNumber);
return 0;
}
// Ensure there are an even number of episodes
if (episodes.Count % 2 != 0)
// Compute fingerprints for all episodes in the season
foreach (var episode in episodeAnalysisQueue)
{
episodes.Add(episodes[episodes.Count - 2]);
}
// Analyze each pair of episodes in the current season
var everFoundIntro = false;
var failures = 0;
for (var i = 0; i < episodes.Count; i += 2)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var lhs = episodes[i];
var rhs = episodes[i + 1];
if (!everFoundIntro && failures >= 20)
{
_logger.LogWarning(
"Failed to find an introduction in {Series} season {Season}",
lhs.SeriesName,
lhs.SeasonNumber);
break;
}
if (Plugin.Instance!.Intros.ContainsKey(lhs.EpisodeId) && Plugin.Instance!.Intros.ContainsKey(rhs.EpisodeId))
{
_logger.LogTrace(
"Episodes {LHS} and {RHS} have both already been fingerprinted",
lhs.EpisodeId,
rhs.EpisodeId);
continue;
}
try
{
_logger.LogTrace("Analyzing {LHS} and {RHS}", lhs.Path, rhs.Path);
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode);
var (lhsIntro, rhsIntro) = FingerprintEpisodes(lhs, rhs);
seasonIntros[lhsIntro.EpisodeId] = lhsIntro;
seasonIntros[rhsIntro.EpisodeId] = rhsIntro;
analysisStatistics.TotalAnalyzedEpisodes.Add(2);
if (!lhsIntro.Valid)
if (cancellationToken.IsCancellationRequested)
{
failures += 2;
continue;
return episodes.Count;
}
everFoundIntro = true;
}
catch (FingerprintException ex)
{
_logger.LogError("Caught fingerprint error: {Ex}", ex);
_logger.LogWarning("Caught fingerprint error: {Ex}", ex);
// Fallback to an empty fingerprint on any error
fingerprintCache[episode.EpisodeId] = Array.Empty<uint>();
}
}
// While there are still episodes in the queue
while (episodeAnalysisQueue.Count > 0)
{
// Pop the first episode from the queue
var currentEpisode = episodeAnalysisQueue[0];
episodeAnalysisQueue.RemoveAt(0);
// Search through all remaining episodes.
foreach (var remainingEpisode in episodeAnalysisQueue)
{
// Compare the current episode to all remaining episodes in the queue.
var (currentIntro, remainingIntro) = CompareEpisodes(
currentEpisode.EpisodeId,
fingerprintCache[currentEpisode.EpisodeId],
remainingEpisode.EpisodeId,
fingerprintCache[remainingEpisode.EpisodeId]);
// Ignore this comparison result if:
// - one of the intros isn't valid, or
// - the introduction exceeds the configured limit
if (
!remainingIntro.Valid ||
remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration)
{
continue;
}
// Only save the discovered intro if it is:
// - the first intro discovered for this episode
// - longer than the previously discovered intro
if (
!seasonIntros.TryGetValue(currentIntro.EpisodeId, out var savedCurrentIntro) ||
currentIntro.Duration > savedCurrentIntro.Duration)
{
seasonIntros[currentIntro.EpisodeId] = currentIntro;
}
if (
!seasonIntros.TryGetValue(remainingIntro.EpisodeId, out var savedRemainingIntro) ||
remainingIntro.Duration > savedRemainingIntro.Duration)
{
seasonIntros[remainingIntro.EpisodeId] = remainingIntro;
}
break;
}
// If no intro is found at this point, the popped episode is not reinserted into the queue.
}
if (cancellationToken.IsCancellationRequested)
{
return episodes.Count;
}
// Adjust all introduction end times so that they end at silence.
seasonIntros = AdjustIntroEndTimes(episodes, seasonIntros);
// Ensure only one thread at a time can update the shared intro dictionary.
lock (_introsLock)
{
@ -375,17 +340,6 @@ public class AnalyzeEpisodesTask : IScheduledTask
}
}
// Only run the second pass if the user hasn't requested cancellation and we found an intro
if (!cancellationToken.IsCancellationRequested && everFoundIntro)
{
var start = DateTime.Now;
// Run a second pass over this season to remove outliers and fix episodes that failed in the first pass.
RunSecondPass(season.Value);
analysisStatistics.SecondPassCPUTime.AddDuration(start);
}
lock (_introsLock)
{
Plugin.Instance!.SaveTimestamps();
@ -394,34 +348,6 @@ public class AnalyzeEpisodesTask : IScheduledTask
return episodes.Count;
}
/// <summary>
/// Analyze two episodes to find an introduction sequence shared between them.
/// </summary>
/// <param name="lhsEpisode">First episode to analyze.</param>
/// <param name="rhsEpisode">Second episode to analyze.</param>
/// <returns>Intros for the first and second episodes.</returns>
public (Intro Lhs, Intro Rhs) FingerprintEpisodes(QueuedEpisode lhsEpisode, QueuedEpisode rhsEpisode)
{
var start = DateTime.Now;
var lhsFingerprint = Chromaprint.Fingerprint(lhsEpisode);
var rhsFingerprint = Chromaprint.Fingerprint(rhsEpisode);
analysisStatistics.FingerprintCPUTime.AddDuration(start);
// Cache the fingerprints for quicker recall in the second pass (if one is needed).
lock (_fingerprintCacheLock)
{
_fingerprintCache[lhsEpisode.EpisodeId] = lhsFingerprint;
_fingerprintCache[rhsEpisode.EpisodeId] = rhsFingerprint;
}
return FingerprintEpisodes(
lhsEpisode.EpisodeId,
lhsFingerprint,
rhsEpisode.EpisodeId,
rhsFingerprint,
true);
}
/// <summary>
/// Analyze two episodes to find an introduction sequence shared between them.
/// </summary>
@ -429,68 +355,31 @@ public class AnalyzeEpisodesTask : IScheduledTask
/// <param name="lhsPoints">First episode fingerprint points.</param>
/// <param name="rhsId">Second episode id.</param>
/// <param name="rhsPoints">Second episode fingerprint points.</param>
/// <param name="isFirstPass">If this was called as part of the first analysis pass, add the elapsed time to the statistics.</param>
/// <returns>Intros for the first and second episodes.</returns>
public (Intro Lhs, Intro Rhs) FingerprintEpisodes(
public (Intro Lhs, Intro Rhs) CompareEpisodes(
Guid lhsId,
uint[] lhsPoints,
Guid rhsId,
uint[] rhsPoints,
bool isFirstPass)
uint[] rhsPoints)
{
// If this isn't running as part of the first analysis pass, don't count this CPU time as first pass time.
var start = isFirstPass ? DateTime.Now : DateTime.MinValue;
var start = DateTime.Now;
// ===== Method 1: Inverted indexes =====
// Creates an inverted fingerprint point index for both episodes.
// For every point which is a 100% match, search for an introduction at that point.
var (lhsRanges, rhsRanges) = SearchInvertedIndex(lhsPoints, rhsPoints);
var (lhsRanges, rhsRanges) = SearchInvertedIndex(lhsId, lhsPoints, rhsId, rhsPoints);
if (lhsRanges.Count > 0)
{
_logger.LogTrace("Index search successful");
analysisStatistics.IndexSearches.Increment();
analysisStatistics.FirstPassCPUTime.AddDuration(start);
return GetLongestTimeRange(lhsId, lhsRanges, rhsId, rhsRanges);
}
// ===== Method 2: Quick scan =====
// Tests if an intro can be found within the first 5 seconds of the episodes. ±5/0.128 = ±40 samples.
(lhsRanges, rhsRanges) = ShiftEpisodes(lhsPoints, rhsPoints, -40, 40);
if (lhsRanges.Count > 0)
{
_logger.LogTrace("Quick scan successful");
analysisStatistics.QuickScans.Increment();
analysisStatistics.FirstPassCPUTime.AddDuration(start);
return GetLongestTimeRange(lhsId, lhsRanges, rhsId, rhsRanges);
}
// ===== Method 3: Full scan =====
// Compares all elements of the shortest fingerprint to the other fingerprint.
var limit = Math.Min(lhsPoints.Length, rhsPoints.Length);
(lhsRanges, rhsRanges) = ShiftEpisodes(lhsPoints, rhsPoints, -1 * limit, limit);
if (lhsRanges.Count > 0)
{
_logger.LogTrace("Full scan successful");
analysisStatistics.FullScans.Increment();
analysisStatistics.FirstPassCPUTime.AddDuration(start);
return GetLongestTimeRange(lhsId, lhsRanges, rhsId, rhsRanges);
}
// No method was able to find an introduction, return nothing.
_logger.LogTrace(
"Unable to find a shared introduction sequence between {LHS} and {RHS}",
lhsId,
rhsId);
analysisStatistics.FirstPassCPUTime.AddDuration(start);
return (new Intro(lhsId), new Intro(rhsId));
}
@ -533,34 +422,43 @@ public class AnalyzeEpisodesTask : IScheduledTask
/// <summary>
/// Search for a shared introduction sequence using inverted indexes.
/// </summary>
/// <param name="lhsId">LHS ID.</param>
/// <param name="lhsPoints">Left episode fingerprint points.</param>
/// <param name="rhsId">RHS ID.</param>
/// <param name="rhsPoints">Right episode fingerprint points.</param>
/// <returns>List of shared TimeRanges between the left and right episodes.</returns>
private (List<TimeRange> Lhs, List<TimeRange> Rhs) SearchInvertedIndex(
Guid lhsId,
uint[] lhsPoints,
Guid rhsId,
uint[] rhsPoints)
{
var lhsRanges = new List<TimeRange>();
var rhsRanges = new List<TimeRange>();
// Generate inverted indexes for the left and right episodes.
var lhsIndex = Chromaprint.CreateInvertedIndex(lhsPoints);
var rhsIndex = Chromaprint.CreateInvertedIndex(rhsPoints);
var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints);
var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints);
var indexShifts = new HashSet<int>();
// For all audio points in the left episode, check if the right episode has a point which matches exactly.
// If an exact match is found, calculate the shift that must be used to align the points.
foreach (var kvp in lhsIndex)
{
var point = kvp.Key;
var originalPoint = kvp.Key;
if (rhsIndex.ContainsKey(point))
for (var i = -1 * invertedIndexShift; i <= invertedIndexShift; i++)
{
var lhsFirst = (int)lhsIndex[point];
var rhsFirst = (int)rhsIndex[point];
var modifiedPoint = (uint)(originalPoint + i);
if (rhsIndex.ContainsKey(modifiedPoint))
{
var lhsFirst = (int)lhsIndex[originalPoint];
var rhsFirst = (int)rhsIndex[modifiedPoint];
indexShifts.Add(rhsFirst - lhsFirst);
}
}
}
// Use all discovered shifts to compare the episodes.
foreach (var shift in indexShifts)
@ -643,7 +541,7 @@ public class AnalyzeEpisodesTask : IScheduledTask
var diff = lhs[lhsPosition] ^ rhs[rhsPosition];
// If the difference between the samples is small, flag both times as similar.
if (CountBits(diff) > MaximumDifferences)
if (CountBits(diff) > maximumDifferences)
{
continue;
}
@ -660,30 +558,110 @@ public class AnalyzeEpisodesTask : IScheduledTask
rhsTimes.Add(double.MaxValue);
// Now that both fingerprints have been compared at this shift, see if there's a contiguous time range.
var lContiguous = TimeRangeHelpers.FindContiguous(lhsTimes.ToArray(), MaximumDistance);
var lContiguous = TimeRangeHelpers.FindContiguous(lhsTimes.ToArray(), maximumTimeSkip);
if (lContiguous is null || lContiguous.Duration < minimumIntroDuration)
{
return (new TimeRange(), new TimeRange());
}
// Since LHS had a contiguous time range, RHS must have one also.
var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), MaximumDistance)!;
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)
{
lContiguous.End -= 2 * MaximumDistance;
rContiguous.End -= 2 * MaximumDistance;
lContiguous.End -= 2 * maximumTimeSkip;
rContiguous.End -= 2 * maximumTimeSkip;
}
else if (lContiguous.Duration >= 30)
{
lContiguous.End -= MaximumDistance;
rContiguous.End -= MaximumDistance;
lContiguous.End -= maximumTimeSkip;
rContiguous.End -= maximumTimeSkip;
}
return (lContiguous, rContiguous);
}
/// <summary>
/// Adjusts the end timestamps of all intros so that they end at silence.
/// </summary>
/// <param name="episodes">QueuedEpisodes to adjust.</param>
/// <param name="originalIntros">Original introductions.</param>
private Dictionary<Guid, Intro> AdjustIntroEndTimes(
ReadOnlyCollection<QueuedEpisode> episodes,
Dictionary<Guid, Intro> originalIntros)
{
// The minimum duration of audio that must be silent before adjusting the intro's end.
var minimumSilence = Plugin.Instance!.Configuration.SilenceDetectionMinimumDuration;
Dictionary<Guid, Intro> modifiedIntros = new();
// For all episodes
foreach (var episode in episodes)
{
_logger.LogTrace(
"Adjusting introduction end time for {Name} ({Id})",
episode.Name,
episode.EpisodeId);
// If no intro was found for this episode, skip it.
if (!originalIntros.TryGetValue(episode.EpisodeId, out var originalIntro))
{
_logger.LogTrace("{Name} does not have an intro", episode.Name);
continue;
}
// Only adjust the end timestamp of the intro
var originalIntroEnd = new TimeRange(originalIntro.IntroEnd - 15, originalIntro.IntroEnd);
_logger.LogTrace(
"{Name} original intro: {Start} - {End}",
episode.Name,
originalIntro.IntroStart,
originalIntro.IntroEnd);
// Detect silence in the media file up to the end of the intro.
var silence = FFmpegWrapper.DetectSilence(episode, (int)originalIntro.IntroEnd + 2);
// For all periods of silence
foreach (var currentRange in silence)
{
_logger.LogTrace(
"{Name} silence: {Start} - {End}",
episode.Name,
currentRange.Start,
currentRange.End);
// Ignore any silence that:
// * doesn't intersect the ending of the intro, or
// * is shorter than the user defined minimum duration, or
// * starts before the introduction does
if (
!originalIntroEnd.Intersects(currentRange) ||
currentRange.Duration < silenceDetectionMinimumDuration ||
currentRange.Start < originalIntro.IntroStart)
{
continue;
}
// Adjust the end timestamp of the intro to match the start of the silence region.
originalIntro.IntroEnd = currentRange.Start;
break;
}
_logger.LogTrace(
"{Name} adjusted intro: {Start} - {End}",
episode.Name,
originalIntro.IntroStart,
originalIntro.IntroEnd);
// Add the (potentially) modified intro back.
modifiedIntros[episode.EpisodeId] = originalIntro;
}
return modifiedIntros;
}
/// <summary>
/// Count the number of bits that are set in the provided number.
/// </summary>
@ -694,194 +672,6 @@ public class AnalyzeEpisodesTask : IScheduledTask
return BitOperations.PopCount(number);
}
/// <summary>
/// Reanalyze the most recently analyzed season.
/// Looks for and fixes intro durations that were either not found or are statistical outliers.
/// </summary>
/// <param name="episodes">List of episodes that was just analyzed.</param>
private void RunSecondPass(List<QueuedEpisode> episodes)
{
// First, assert that at least half of the episodes in this season have an intro.
var validCount = 0;
var totalCount = episodes.Count;
foreach (var episode in episodes)
{
if (Plugin.Instance!.Intros.TryGetValue(episode.EpisodeId, out var intro) && intro.Valid)
{
validCount++;
}
}
var percentValid = (validCount * 100) / totalCount;
_logger.LogTrace("Found intros in {Valid}/{Total} ({Percent}%) of episodes", validCount, totalCount, percentValid);
if (percentValid < 50)
{
return;
}
// Create a histogram of all episode durations
var histogram = new Dictionary<int, SeasonHistogram>();
foreach (var episode in episodes)
{
var id = episode.EpisodeId;
var duration = GetIntroDuration(id);
if (duration < minimumIntroDuration)
{
continue;
}
// Bucket the duration into equally sized groups
var bucket = Convert.ToInt32(Math.Floor(duration / ReanalysisBucketWidth)) * ReanalysisBucketWidth;
// TryAdd returns true when the key was successfully added (i.e. for newly created buckets).
// Newly created buckets are initialized with the provided episode ID, so nothing else needs to be done for them.
if (histogram.TryAdd(bucket, new SeasonHistogram(id)))
{
continue;
}
histogram[bucket].Episodes.Add(id);
}
// Find the bucket that was seen most often, as this is likely to be the true intro length.
var maxDuration = 0;
var maxBucket = new SeasonHistogram(Guid.Empty);
foreach (var entry in histogram)
{
if (entry.Value.Count > maxBucket.Count)
{
maxDuration = entry.Key;
maxBucket = entry.Value;
}
}
// Ensure that the most frequently seen bucket has a majority
percentValid = (maxBucket.Count * 100) / validCount;
_logger.LogTrace(
"Intro duration {Duration} appeared {Frequency} times ({Percent}%)",
maxDuration,
maxBucket.Count,
percentValid);
if (percentValid < 50 || maxBucket.Episodes[0].Equals(Guid.Empty))
{
return;
}
_logger.LogTrace("Second pass is processing {Count} episodes", totalCount - maxBucket.Count);
// Calculate a range of intro durations that are most likely to be correct.
var maxEpisode = episodes.Find(x => x.EpisodeId == maxBucket.Episodes[0]);
if (maxEpisode is null)
{
_logger.LogError("Second pass failed to get episode from bucket");
return;
}
var lhsDuration = GetIntroDuration(maxEpisode.EpisodeId);
var (lowTargetDuration, highTargetDuration) = (
lhsDuration - ReanalysisTolerance,
lhsDuration + ReanalysisTolerance);
// TODO: add limit and make it customizable
var count = maxBucket.Episodes.Count - 1;
var goodFingerprints = new List<uint[]>();
foreach (var id in maxBucket.Episodes)
{
if (!_fingerprintCache.TryGetValue(id, out var fp))
{
_logger.LogTrace("Second pass: max bucket episode {Id} not in cache, skipping", id);
continue;
}
goodFingerprints.Add(fp);
}
foreach (var episode in episodes)
{
// Don't reanalyze episodes from the max bucket
if (maxBucket.Episodes.Contains(episode.EpisodeId))
{
continue;
}
var oldDuration = GetIntroDuration(episode.EpisodeId);
// If the episode's intro duration is close enough to the targeted bucket, leave it alone.
if (Math.Abs(lhsDuration - oldDuration) <= ReanalysisTolerance)
{
_logger.LogTrace(
"Not reanalyzing episode {Path} (intro is {Initial}, target is {Max})",
episode.Path,
Math.Round(oldDuration, 2),
maxDuration);
continue;
}
_logger.LogTrace(
"Reanalyzing episode {Path} (intro is {Initial}, target is {Max})",
episode.Path,
Math.Round(oldDuration, 2),
maxDuration);
// Analyze the episode again, ignoring whatever is returned for the known good episode.
foreach (var lhsFingerprint in goodFingerprints)
{
if (!_fingerprintCache.TryGetValue(episode.EpisodeId, out var fp))
{
_logger.LogTrace("Unable to get cached fingerprint for {Id}, skipping", episode.EpisodeId);
continue;
}
var (_, newRhs) = FingerprintEpisodes(
maxEpisode.EpisodeId,
lhsFingerprint,
episode.EpisodeId,
fp,
false);
// Ensure that the new intro duration is within the targeted bucket and longer than what was found previously.
var newDuration = Math.Round(newRhs.IntroEnd - newRhs.IntroStart, 2);
if (newDuration < oldDuration || newDuration < lowTargetDuration || newDuration > highTargetDuration)
{
_logger.LogTrace(
"Ignoring reanalysis for {Path} (was {Initial}, now is {New})",
episode.Path,
oldDuration,
newDuration);
continue;
}
_logger.LogTrace(
"Reanalysis succeeded for {Path} (was {Initial}, now is {New})",
episode.Path,
oldDuration,
newDuration);
lock (_introsLock)
{
Plugin.Instance!.Intros[episode.EpisodeId] = newRhs;
}
break;
}
}
}
private double GetIntroDuration(Guid id)
{
if (!Plugin.Instance!.Intros.TryGetValue(id, out var episode))
{
return 0;
}
return episode.Valid ? Math.Round(episode.IntroEnd - episode.IntroStart, 2) : 0;
}
/// <summary>
/// Get task triggers.
/// </summary>

View File

@ -23,9 +23,9 @@ Plugin versions v0.1.0 and older require `fpcalc` to be installed.
Show introductions will only be detected if they are:
* Located within the first 25% of an episode, or the first 10 minutes, whichever is smaller
* At least 15 seconds long
* Between 15 seconds and 2 minutes long
Both of these requirements can be customized as needed.
All of these requirements can be customized as needed.
## Step 1: Optional: use the modified web interface
While this plugin is fully compatible with an unmodified version of Jellyfin 10.8.0, using a modified web interface allows you to click a button to skip intros. If you skip this step and do not use the modified web interface, you will have to enable the "Automatically skip intros" option in the plugin settings.