diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj
index 55821fe..7784686 100644
--- a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj
@@ -16,7 +16,6 @@
-
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs
new file mode 100644
index 0000000..43f9f1c
--- /dev/null
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Linq;
+using System.Net.Mime;
+using System.Text.Json;
+using Jellyfin.Data.Entities;
+using Jellyfin.Extensions.Json;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
+
+///
+/// Skip intro controller.
+///
+[Authorize]
+[ApiController]
+[Produces(MediaTypeNames.Application.Json)]
+public class SkipIntroController : ControllerBase
+{
+ ///
+ /// Constructor.
+ ///
+ public SkipIntroController()
+ {
+ }
+
+ ///
+ /// Returns the timestamps of the introduction in a television episode.
+ ///
+ /// ID of the episode. Required.
+ /// Episode contains an intro.
+ /// Failed to find an intro in the provided episode.
+ [HttpGet("Episode/{id}/IntroTimestamps")]
+ public ActionResult GetIntroTimestamps([FromRoute] Guid episodeId)
+ {
+ if (!Plugin.Instance!.Intros.ContainsKey(episodeId))
+ {
+ return NotFound();
+ }
+
+ var intro = Plugin.Instance!.Intros[episodeId];
+
+ // Check that the episode was analyzed successfully.
+ if (!intro.Valid)
+ {
+ return NotFound();
+ }
+
+ return intro;
+ }
+}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/FingerprintException.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/FingerprintException.cs
new file mode 100644
index 0000000..fb9a0a3
--- /dev/null
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/FingerprintException.cs
@@ -0,0 +1,29 @@
+using System;
+
+namespace ConfusedPolarBear.Plugin.IntroSkipper;
+
+///
+/// Exception raised when an error is encountered analyzing audio.
+///
+public class FingerprintException: Exception {
+ ///
+ /// Constructor.
+ ///
+ public FingerprintException()
+ {
+ }
+
+ ///
+ /// Constructor.
+ ///
+ public FingerprintException(string message): base(message)
+ {
+ }
+
+ ///
+ /// Constructor.
+ ///
+ public FingerprintException(string message, Exception inner): base(message, inner)
+ {
+ }
+}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs
new file mode 100644
index 0000000..d6302fd
--- /dev/null
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace ConfusedPolarBear.Plugin.IntroSkipper;
+
+///
+/// Result of fingerprinting and analyzing two episodes in a season.
+/// All times are measured in seconds relative to the beginning of the media file.
+///
+[Serializable]
+public class Intro {
+ ///
+ /// If this introduction is valid or not. Invalid results should not be returned through the API.
+ ///
+ public bool Valid { get; set; }
+
+ ///
+ /// Introduction sequence start time.
+ ///
+ public double IntroStart { get; set; }
+
+ ///
+ /// Introduction sequence end time.
+ ///
+ public double IntroEnd { get; set; }
+
+ ///
+ /// Recommended time to display the skip intro prompt.
+ ///
+ public double ShowSkipPromptAt { get; set; }
+
+ ///
+ /// Recommended time to hide the skip intro prompt.
+ ///
+ public double HideSkipPromptAt { get; set; }
+}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs
new file mode 100644
index 0000000..76f8a2f
--- /dev/null
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs
@@ -0,0 +1,33 @@
+using System;
+
+namespace ConfusedPolarBear.Plugin.IntroSkipper;
+
+///
+/// Episode queued for analysis.
+///
+public class QueuedEpisode {
+ ///
+ /// Series name.
+ ///
+ public string SeriesName { get; set; } = "";
+
+ ///
+ /// Season number.
+ ///
+ public int SeasonNumber { get; set; }
+
+ ///
+ /// Episode id.
+ ///
+ public Guid EpisodeId { get; set; }
+
+ ///
+ /// Full path to episode.
+ ///
+ public string Path { get; set; } = "";
+
+ ///
+ /// Seconds of media file to fingerprint.
+ ///
+ public int FingerprintDuration { get; set; }
+}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs
new file mode 100644
index 0000000..9561f0c
--- /dev/null
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Collections.Generic;
+
+namespace ConfusedPolarBear.Plugin.IntroSkipper;
+
+///
+/// Range of contiguous time.
+///
+public class TimeRange : IComparable
+{
+ ///
+ /// Time range start (in seconds).
+ ///
+ public double Start { get; set; }
+
+ ///
+ /// Time range end (in seconds).
+ ///
+ public double End { get; set; }
+
+ ///
+ /// Duration of this time range (in seconds).
+ ///
+ public double Duration => End - Start;
+
+ ///
+ /// Constructor.
+ ///
+ public TimeRange(double start, double end)
+ {
+ Start = start;
+ End = end;
+ }
+
+ ///
+ /// Copy constructor.
+ ///
+ public TimeRange(TimeRange original)
+ {
+ Start = original.Start;
+ End = original.End;
+ }
+
+ ///
+ /// Compares this TimeRange to another TimeRange.
+ ///
+ /// Other object to compare against.
+ public int CompareTo(object? obj)
+ {
+ if (obj is not TimeRange tr)
+ {
+ return 0;
+ }
+
+ return this.Duration.CompareTo(tr.Duration);
+ }
+
+ ///
+ public override bool Equals(object? obj)
+ {
+ if (obj is null || obj is not TimeRange tr)
+ {
+ return false;
+ }
+
+ return this.Start == tr.Start && this.Duration == tr.Duration;
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return this.Start.GetHashCode() + this.Duration.GetHashCode();
+ }
+
+ ///
+ public static bool operator ==(TimeRange left, TimeRange right)
+ {
+ return left.Equals(right);
+ }
+
+ ///
+ public static bool operator !=(TimeRange left, TimeRange right)
+ {
+ return !left.Equals(right);
+ }
+
+ ///
+ public static bool operator <=(TimeRange left, TimeRange right)
+ {
+ return left.CompareTo(right) <= 0;
+ }
+
+ ///
+ public static bool operator <(TimeRange left, TimeRange right)
+ {
+ return left.CompareTo(right) < 0;
+ }
+
+ ///
+ public static bool operator >=(TimeRange left, TimeRange right)
+ {
+ return left.CompareTo(right) >= 0;
+ }
+
+ ///
+ public static bool operator >(TimeRange left, TimeRange right)
+ {
+ return left.CompareTo(right) > 0;
+ }
+}
+
+///
+/// Time range helpers.
+///
+public static class TimeRangeHelpers
+{
+ ///
+ /// Finds the longest contiguous time range.
+ ///
+ /// Sorted timestamps to search.
+ /// Maximum distance permitted between contiguous timestamps.
+ public static TimeRange? FindContiguous(double[] times, double maximumDistance)
+ {
+ if (times.Length == 0)
+ {
+ return null;
+ }
+
+ Array.Sort(times);
+
+ var ranges = new List();
+ var currentRange = new TimeRange(times[0], 0);
+
+ // For all provided timestamps, check if it is contiguous with its neighbor.
+ for (var i = 0; i < times.Length - 1; i++)
+ {
+ var current = times[i];
+ var next = times[i + 1];
+
+ if (next - current <= maximumDistance)
+ {
+ currentRange.End = next;
+ continue;
+ }
+
+ ranges.Add(new TimeRange(currentRange));
+ currentRange.Start = next;
+ }
+
+ // Find and return the longest contiguous range.
+ ranges.Sort();
+
+ return (ranges.Count > 0) ? ranges[0] : null;
+ }
+}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs
new file mode 100644
index 0000000..85e3466
--- /dev/null
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Library;
+using Microsoft.Extensions.Logging;
+using Jellyfin.Data.Enums;
+
+namespace ConfusedPolarBear.Plugin.IntroSkipper;
+
+///
+/// Server entrypoint.
+///
+public class Entrypoint : IServerEntryPoint
+{
+ private readonly IUserManager _userManager;
+ private readonly IUserViewManager _userViewManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+
+ private readonly object _queueLock = new object();
+
+ ///
+ /// Constructor.
+ ///
+ /// User manager.
+ /// User view manager.
+ /// Library manager.
+ /// Logger.
+ public Entrypoint(
+ IUserManager userManager,
+ IUserViewManager userViewManager,
+ ILibraryManager libraryManager,
+ ILogger logger)
+ {
+ _userManager = userManager;
+ _userViewManager = userViewManager;
+ _libraryManager = libraryManager;
+ _logger = logger;
+ }
+
+ ///
+ /// Registers event handler.
+ ///
+ public Task RunAsync()
+ {
+ FPCalc.Logger = _logger;
+
+ // Assert that fpcalc is installed
+ if (!FPCalc.CheckFPCalcInstalled()) {
+ _logger.LogError("fpcalc is not installed on this system - episodes will not be analyzed");
+ return Task.CompletedTask;
+ }
+
+ // As soon as a new episode is added, queue it for later analysis.
+ _libraryManager.ItemAdded += ItemAdded;
+
+ // For all TV show libraries, enqueue all contained items.
+ foreach (var folder in _libraryManager.GetVirtualFolders()) {
+ if (folder.CollectionType != CollectionTypeOptions.TvShows) {
+ continue;
+ }
+
+ _logger.LogInformation(
+ "Running startup enqueue of items in library {Name} ({ItemId})",
+ folder.Name,
+ folder.ItemId);
+
+ QueueLibraryContents(folder.ItemId);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void QueueLibraryContents(string rawId) {
+ // FIXME: do smarterer
+
+ var query = new UserViewQuery() {
+ UserId = GetAdministrator(),
+ };
+
+ // Get all items from this library. Since intros may change within a season, sort the items before adding them.
+ var folder = _userViewManager.GetUserViews(query)[0];
+ var items = folder.GetItems(new InternalItemsQuery() {
+ OrderBy = new [] { ("SortName", SortOrder.Ascending) },
+ IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Episode },
+ Recursive = true,
+ });
+
+ // Queue all episodes on the server for fingerprinting.
+ foreach (var item in items.Items) {
+ if (item is not Episode episode) {
+ _logger.LogError("Item {Name} is not an episode", item.Name);
+ continue;
+ }
+
+ QueueEpisode(episode);
+ }
+ }
+
+ ///
+ /// Called when an item is added to the server.
+ ///
+ /// Sender.
+ /// ItemChangeEventArgs.
+ private void ItemAdded(object? sender, ItemChangeEventArgs e)
+ {
+ if (e.Item is not Episode episode) {
+ return;
+ }
+
+ _logger.LogDebug("Queuing fingerprint of new episode {Name}", episode.Name);
+
+ QueueEpisode(episode);
+ }
+
+ private void QueueEpisode(Episode episode) {
+ if (Plugin.Instance is null) {
+ throw new InvalidOperationException("plugin instance was null");
+ }
+
+ lock (_queueLock) {
+ var queue = Plugin.Instance.AnalysisQueue;
+
+ // Allocate a new list for each new season
+ if (!queue.ContainsKey(episode.SeasonId)) {
+ Plugin.Instance.AnalysisQueue[episode.SeasonId] = new List();
+ }
+
+ // Only fingerprint up to 25% of the episode.
+ var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
+ if (duration >= 5*60) {
+ duration /= 4;
+ }
+
+ Plugin.Instance.AnalysisQueue[episode.SeasonId].Add(new QueuedEpisode() {
+ SeriesName = episode.SeriesName,
+ SeasonNumber = episode.AiredSeasonNumber ?? 0,
+ EpisodeId = episode.Id,
+ Path = episode.Path,
+ FingerprintDuration = Convert.ToInt32(duration)
+ });
+
+ Plugin.Instance!.TotalQueued++;
+ }
+ }
+
+ ///
+ /// FIXME: don't do this.
+ ///
+ private Guid GetAdministrator() {
+ foreach (var user in _userManager.Users) {
+ if (!user.HasPermission(Jellyfin.Data.Enums.PermissionKind.IsAdministrator)) {
+ continue;
+ }
+
+ return user.Id;
+ }
+
+ throw new FingerprintException("Unable to find an administrator on this server.");
+ }
+
+ ///
+ /// Dispose.
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Dispose.
+ ///
+ protected virtual void Dispose(bool dispose)
+ {
+ if (!dispose)
+ {
+ return;
+ }
+
+ _libraryManager.ItemAdded -= ItemAdded;
+ }
+}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/FPCalc.cs b/ConfusedPolarBear.Plugin.IntroSkipper/FPCalc.cs
new file mode 100644
index 0000000..5287002
--- /dev/null
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/FPCalc.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+
+namespace ConfusedPolarBear.Plugin.IntroSkipper;
+
+///
+/// Wrapper for the fpcalc utility.
+///
+public static class FPCalc {
+ ///
+ /// Logger.
+ ///
+ public static ILogger? Logger { get; set; }
+
+ ///
+ /// Check that the fpcalc utility is installed.
+ ///
+ public static bool CheckFPCalcInstalled()
+ {
+ try
+ {
+ var version = getOutput("-version", 2000);
+ Logger?.LogDebug("fpcalc version: {Version}", version);
+ return version.StartsWith("fpcalc version", StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Fingerprint a queued episode.
+ ///
+ /// Queued episode to fingerprint.
+ public static ReadOnlyCollection Fingerprint(QueuedEpisode episode)
+ {
+ Logger?.LogDebug("Fingerprinting {Duration} seconds from {File}", episode.FingerprintDuration, episode.Path);
+
+ // FIXME: revisit escaping
+ var path = "\"" + episode.Path + "\"";
+ var duration = episode.FingerprintDuration.ToString(CultureInfo.InvariantCulture);
+ var args = " -raw -length " + duration + " " + path;
+
+ /* Returns output similar to the following:
+ * DURATION=123
+ * FINGERPRINT=123456789,987654321,123456789,987654321,123456789,987654321
+ */
+
+ var raw = getOutput(args);
+ var lines = raw.Split("\n");
+
+ if (lines.Length < 2)
+ {
+ Logger?.LogTrace("fpcalc output is {Raw}", raw);
+ throw new FingerprintException("fpcalc output was malformed");
+ }
+
+ // Remove the "FINGERPRINT=" prefix and split into an array of numbers.
+ var fingerprint = lines[1].Substring(12).Split(",");
+
+ var results = new List();
+ foreach (var rawNumber in fingerprint)
+ {
+ results.Add(Convert.ToUInt32(rawNumber, CultureInfo.InvariantCulture));
+ }
+
+ return results.AsReadOnly();
+ }
+
+ private static string getOutput(string args, int timeout = 60 * 1000)
+ {
+ var info = new ProcessStartInfo("fpcalc", args);
+ info.CreateNoWindow = true;
+ info.RedirectStandardOutput = true;
+
+ var fpcalc = new Process();
+ fpcalc.StartInfo = info;
+
+ fpcalc.Start();
+ fpcalc.WaitForExit(timeout);
+
+ return fpcalc.StandardOutput.ReadToEnd();
+ }
+}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Fingerprinter.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Fingerprinter.cs
new file mode 100644
index 0000000..fc43aa9
--- /dev/null
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Fingerprinter.cs
@@ -0,0 +1,24 @@
+namespace ConfusedPolarBear.Plugin.IntroSkipper;
+
+///
+/// Audio fingerprinter class.
+///
+public class Fingerprinter {
+ ///
+ /// First file to fingerprint and compare.
+ ///
+ public string FileA { get; private set; }
+
+ ///
+ /// Second file to fingerprint and compare.
+ ///
+ public string FileB { get; private set; }
+
+ ///
+ /// Constructor.
+ ///
+ public Fingerprinter(string fileA, string fileB) {
+ FileA = fileA;
+ FileB = fileB;
+ }
+}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs
index e385764..b015dea 100644
--- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Globalization;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
@@ -14,6 +15,21 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
///
public class Plugin : BasePlugin, IHasWebPages
{
+ ///
+ /// Results of fingerprinting all episodes.
+ ///
+ public Dictionary Intros { get; }
+
+ ///
+ /// Map of season ids to episodes that have been queued for fingerprinting.
+ ///
+ public Dictionary> AnalysisQueue { get; }
+
+ ///
+ /// Total number of episodes in the queue.
+ ///
+ public int TotalQueued { get; set; }
+
///
/// Initializes a new instance of the class.
///
@@ -22,6 +38,9 @@ public class Plugin : BasePlugin, IHasWebPages
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
+ Intros = new Dictionary();
+ AnalysisQueue = new Dictionary>();
+
Instance = this;
}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs
new file mode 100644
index 0000000..184cc3c
--- /dev/null
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs
@@ -0,0 +1,286 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace ConfusedPolarBear.Plugin.IntroSkipper;
+
+///
+/// Fingerprint all queued episodes at the set time.
+///
+public class FingerprinterTask : IScheduledTask {
+ private readonly ILogger _logger;
+
+ ///
+ /// Minimum time (in seconds) for a contiguous time range to be considered an introduction.
+ ///
+ private const int MINIMUM_INTRO_DURATION = 18;
+
+ ///
+ /// Maximum number of bits (out of 32 total) that can be different between segments before they are considered dissimilar.
+ ///
+ private const double MAXIMUM_DIFFERENCES = 5;
+
+ ///
+ /// Maximum time permitted between timestamps before they are considered non-contiguous.
+ ///
+ private const double MAXIMUM_DISTANCE = 3.25;
+
+ ///
+ /// Seconds of audio in one number from the fingerprint. Defined by Chromaprint.
+ ///
+ private const double SAMPLES_TO_SECONDS = 0.128;
+
+ ///
+ /// Constructor.
+ ///
+ public FingerprinterTask(ILogger logger)
+ {
+ _logger = logger;
+ _logger.LogInformation("Fingerprinting Task Scheduled!");
+ }
+
+ ///
+ /// Task name.
+ ///
+ public string Name => "Analyze episodes";
+
+ ///
+ /// Task category.
+ ///
+ public string Category => "Intro Skipper";
+
+ ///
+ /// Task description.
+ ///
+ public string Description => "Analyzes the audio of all television episodes to find introduction sequences.";
+
+ ///
+ /// Key.
+ ///
+ public string Key => "CPBIntroSkipperRunFingerprinter";
+
+ ///
+ /// Analyze all episodes in the queue.
+ ///
+ /// Progress.
+ /// Cancellation token.
+ public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken)
+ {
+ var queue = Plugin.Instance!.AnalysisQueue;
+ var totalProcessed = 0;
+
+ foreach (var season in queue) {
+ var first = season.Value[0];
+
+ _logger.LogDebug(
+ "Fingerprinting {Count} episodes from {Name} season {Season}",
+ season.Value.Count,
+ first.SeriesName,
+ first.SeasonNumber);
+
+ // Ensure there are an even number of episodes
+ var episodes = season.Value;
+ if (episodes.Count % 2 != 0) {
+ episodes.Add(episodes[episodes.Count - 2]);
+ }
+
+ for (var i = 0; i < episodes.Count; i += 2)
+ {
+ var lhs = episodes[i];
+ var rhs = episodes[i+1];
+
+ // FIXME: add retry logic
+ var alreadyDone = Plugin.Instance!.Intros;
+ if (alreadyDone.ContainsKey(lhs.EpisodeId) && alreadyDone.ContainsKey(rhs.EpisodeId))
+ {
+ _logger.LogDebug(
+ "Episodes {LHS} and {RHS} have both already been fingerprinted",
+ lhs.EpisodeId,
+ rhs.EpisodeId);
+
+ continue;
+ }
+
+ try
+ {
+ FingerprintEpisodes(lhs, rhs);
+ }
+ catch (FingerprintException ex)
+ {
+ _logger.LogError("Caught fingerprint error: {Ex}", ex);
+ }
+ finally
+ {
+ totalProcessed += 2;
+ progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued);
+ }
+ }
+
+ // TODO: after every season completes, serialize fingerprints to disk somewhere
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void FingerprintEpisodes(QueuedEpisode lhsEpisode, QueuedEpisode rhsEpisode)
+ {
+ var lhs = FPCalc.Fingerprint(lhsEpisode);
+ var rhs = FPCalc.Fingerprint(rhsEpisode);
+
+ var lhsRanges = new List();
+ var rhsRanges = new List();
+
+ // Compare all elements of the shortest fingerprint to the other fingerprint.
+ var high = Math.Min(lhs.Count, rhs.Count);
+
+ // TODO: see if bailing out early results in false positives.
+ for (var amount = -1 * high; amount < high; amount++) {
+ var leftOffset = 0;
+ var rightOffset = 0;
+
+ // Calculate the offsets for the left and right hand sides.
+ if (amount < 0) {
+ leftOffset -= amount;
+ } else {
+ rightOffset += amount;
+ }
+
+ // Store similar times for both LHS and RHS.
+ var lhsTimes = new List();
+ var rhsTimes = new List();
+
+ // XOR all elements in LHS and RHS, using the shift amount from above.
+ for (var i = 0; i < high - Math.Abs(amount); i++) {
+ // XOR both samples at the current position.
+ var lhsPosition = i + leftOffset;
+ var rhsPosition = i + rightOffset;
+ var diff = lhs[lhsPosition] ^ rhs[rhsPosition];
+
+ // If the difference between the samples is small (< 5/32), flag both times as similar.
+ if (countBits(diff) > MAXIMUM_DIFFERENCES)
+ {
+ continue;
+ }
+
+ var lhsTime = lhsPosition * SAMPLES_TO_SECONDS;
+ var rhsTime = rhsPosition * SAMPLES_TO_SECONDS;
+
+ lhsTimes.Add(lhsTime);
+ rhsTimes.Add(rhsTime);
+ }
+
+ // Ensure the last timestamp is checked
+ lhsTimes.Add(Double.MaxValue);
+ 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(), MAXIMUM_DISTANCE);
+ if (lContiguous is null || lContiguous.Duration < MINIMUM_INTRO_DURATION)
+ {
+ continue;
+ }
+
+ // Since LHS had a contiguous time range, RHS must have one also.
+ var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), MAXIMUM_DISTANCE)!;
+
+ // Tweak the end timestamps just a bit to ensure as little content as possible is skipped over.
+ if (lContiguous.Duration >= 90)
+ {
+ lContiguous.End -= 6;
+ rContiguous.End -= 6;
+ }
+ else if (lContiguous.Duration >= 35)
+ {
+ lContiguous.End -= 3;
+ rContiguous.End -= 3;
+ }
+
+ // Store the ranges for later.
+ lhsRanges.Add(lContiguous);
+ rhsRanges.Add(rContiguous);
+ }
+
+ if (lhsRanges.Count == 0)
+ {
+ _logger.LogDebug(
+ "Unable to find a shared introduction sequence between {LHS} and {RHS}",
+ lhsEpisode.Path,
+ rhsEpisode.Path);
+
+ // TODO: if an episode fails but others in the season succeed, reanalyze it against two that succeeded.
+
+ // TODO: is this the optimal way to indicate that an intro couldn't be found?
+ // the goal here is to not waste time every task run reprocessing episodes that we know will fail.
+ storeIntro(lhsEpisode.EpisodeId, 0, 0);
+ storeIntro(rhsEpisode.EpisodeId, 0, 0);
+
+ return;
+ }
+
+ // After comparing both episodes at all possible shift positions, store the longest time range as the intro.
+ lhsRanges.Sort();
+ rhsRanges.Sort();
+
+ var lhsIntro = lhsRanges[0];
+ var rhsIntro = rhsRanges[0];
+
+ // Do a tiny bit of post processing and store the results.
+ if (lhsIntro.Start <= 5)
+ {
+ lhsIntro.Start = 0;
+ }
+
+ if (rhsIntro.Start <= 5)
+ {
+ rhsIntro.Start = 0;
+ }
+
+ storeIntro(lhsEpisode.EpisodeId, lhsIntro.Start, lhsIntro.End);
+ storeIntro(rhsEpisode.EpisodeId, rhsIntro.Start, rhsIntro.End);
+ }
+
+ private static void storeIntro(Guid episode, double introStart, double introEnd)
+ {
+ // Recommend that the skip button appears 5 seconds ahead of the intro
+ // and that it disappears 10 seconds after the intro begins.
+ Plugin.Instance!.Intros[episode] = new Intro()
+ {
+ Valid = (introStart > 0) && (introEnd > 0),
+ IntroStart = introStart,
+ IntroEnd = introEnd,
+ ShowSkipPromptAt = Math.Min(0, introStart - 5),
+ HideSkipPromptAt = introStart + 10
+ };
+ }
+
+ private static int countBits(uint number) {
+ var count = 0;
+
+ for (var i = 0; i < 32; i++) {
+ var low = (number >> i) & 1;
+ if (low == 1) {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+ ///
+ /// Get task triggers.
+ ///
+ public IEnumerable GetDefaultTriggers()
+ {
+ return new[]
+ {
+ new TaskTriggerInfo
+ {
+ Type = TaskTriggerInfo.TriggerDaily,
+ TimeOfDayTicks = TimeSpan.FromDays(24).Ticks
+ }
+ };
+ }
+}