Merge branch 'algorithm_rewrite'
This commit is contained in:
commit
3dc8b59bf1
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@ -2,7 +2,7 @@ name: 'Build Plugin'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
branches: [ "master", "algorithm_rewrite" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
|
16
CHANGELOG.md
16
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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
@ -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
|
||||
|
@ -46,7 +46,7 @@ public class Entrypoint : IServerEntryPoint
|
||||
/// <returns>Task.</returns>
|
||||
public Task RunAsync()
|
||||
{
|
||||
Chromaprint.Logger = _logger;
|
||||
FFmpegWrapper.Logger = _logger;
|
||||
|
||||
#if DEBUG
|
||||
LogVersion();
|
||||
|
507
ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs
Normal file
507
ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs
Normal 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();
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
_logger.LogInformation(
|
||||
"Analyzing {Count} episodes from {Name} season {Season}",
|
||||
episodes.Count,
|
||||
first.SeriesName,
|
||||
first.SeasonNumber);
|
||||
|
||||
// Compute fingerprints for all episodes in the season
|
||||
foreach (var episode in episodeAnalysisQueue)
|
||||
{
|
||||
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",
|
||||
first.SeriesName,
|
||||
first.SeasonNumber);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Ensure there are an even number of episodes
|
||||
if (episodes.Count % 2 != 0)
|
||||
{
|
||||
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,32 +422,41 @@ 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];
|
||||
indexShifts.Add(rhsFirst - lhsFirst);
|
||||
var modifiedPoint = (uint)(originalPoint + i);
|
||||
|
||||
if (rhsIndex.ContainsKey(modifiedPoint))
|
||||
{
|
||||
var lhsFirst = (int)lhsIndex[originalPoint];
|
||||
var rhsFirst = (int)rhsIndex[modifiedPoint];
|
||||
indexShifts.Add(rhsFirst - lhsFirst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
@ -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.
|
||||
|
Loading…
x
Reference in New Issue
Block a user