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 + } + }; + } +}