Initial rewrite from Go

This commit is contained in:
ConfusedPolarBear 2022-05-01 00:33:22 -05:00
parent 82f8871c1b
commit a1862d0e2f
11 changed files with 916 additions and 1 deletions

View File

@ -16,7 +16,6 @@
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" /> <PackageReference Include="Jellyfin.Controller" Version="10.*-*" />
<PackageReference Include="Jellyfin.Model" Version="10.*-*" /> <PackageReference Include="Jellyfin.Model" Version="10.*-*" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup> </ItemGroup>

View File

@ -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;
/// <summary>
/// Skip intro controller.
/// </summary>
[Authorize]
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
public class SkipIntroController : ControllerBase
{
/// <summary>
/// Constructor.
/// </summary>
public SkipIntroController()
{
}
/// <summary>
/// Returns the timestamps of the introduction in a television episode.
/// </summary>
/// <param name="episodeId">ID of the episode. Required.</param>
/// <response code="200">Episode contains an intro.</response>
/// <response code="404">Failed to find an intro in the provided episode.</response>
[HttpGet("Episode/{id}/IntroTimestamps")]
public ActionResult<Intro> 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;
}
}

View File

@ -0,0 +1,29 @@
using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Exception raised when an error is encountered analyzing audio.
/// </summary>
public class FingerprintException: Exception {
/// <summary>
/// Constructor.
/// </summary>
public FingerprintException()
{
}
/// <summary>
/// Constructor.
/// </summary>
public FingerprintException(string message): base(message)
{
}
/// <summary>
/// Constructor.
/// </summary>
public FingerprintException(string message, Exception inner): base(message, inner)
{
}
}

View File

@ -0,0 +1,35 @@
using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Result of fingerprinting and analyzing two episodes in a season.
/// All times are measured in seconds relative to the beginning of the media file.
/// </summary>
[Serializable]
public class Intro {
/// <summary>
/// If this introduction is valid or not. Invalid results should not be returned through the API.
/// </summary>
public bool Valid { get; set; }
/// <summary>
/// Introduction sequence start time.
/// </summary>
public double IntroStart { get; set; }
/// <summary>
/// Introduction sequence end time.
/// </summary>
public double IntroEnd { get; set; }
/// <summary>
/// Recommended time to display the skip intro prompt.
/// </summary>
public double ShowSkipPromptAt { get; set; }
/// <summary>
/// Recommended time to hide the skip intro prompt.
/// </summary>
public double HideSkipPromptAt { get; set; }
}

View File

@ -0,0 +1,33 @@
using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Episode queued for analysis.
/// </summary>
public class QueuedEpisode {
/// <summary>
/// Series name.
/// </summary>
public string SeriesName { get; set; } = "";
/// <summary>
/// Season number.
/// </summary>
public int SeasonNumber { get; set; }
/// <summary>
/// Episode id.
/// </summary>
public Guid EpisodeId { get; set; }
/// <summary>
/// Full path to episode.
/// </summary>
public string Path { get; set; } = "";
/// <summary>
/// Seconds of media file to fingerprint.
/// </summary>
public int FingerprintDuration { get; set; }
}

View File

@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Range of contiguous time.
/// </summary>
public class TimeRange : IComparable
{
/// <summary>
/// Time range start (in seconds).
/// </summary>
public double Start { get; set; }
/// <summary>
/// Time range end (in seconds).
/// </summary>
public double End { get; set; }
/// <summary>
/// Duration of this time range (in seconds).
/// </summary>
public double Duration => End - Start;
/// <summary>
/// Constructor.
/// </summary>
public TimeRange(double start, double end)
{
Start = start;
End = end;
}
/// <summary>
/// Copy constructor.
/// </summary>
public TimeRange(TimeRange original)
{
Start = original.Start;
End = original.End;
}
/// <summary>
/// Compares this TimeRange to another TimeRange.
/// </summary>
/// <param name="obj">Other object to compare against.</param>
public int CompareTo(object? obj)
{
if (obj is not TimeRange tr)
{
return 0;
}
return this.Duration.CompareTo(tr.Duration);
}
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
public override int GetHashCode()
{
return this.Start.GetHashCode() + this.Duration.GetHashCode();
}
/// <inheritdoc/>
public static bool operator ==(TimeRange left, TimeRange right)
{
return left.Equals(right);
}
/// <inheritdoc/>
public static bool operator !=(TimeRange left, TimeRange right)
{
return !left.Equals(right);
}
/// <inheritdoc/>
public static bool operator <=(TimeRange left, TimeRange right)
{
return left.CompareTo(right) <= 0;
}
/// <inheritdoc/>
public static bool operator <(TimeRange left, TimeRange right)
{
return left.CompareTo(right) < 0;
}
/// <inheritdoc/>
public static bool operator >=(TimeRange left, TimeRange right)
{
return left.CompareTo(right) >= 0;
}
/// <inheritdoc/>
public static bool operator >(TimeRange left, TimeRange right)
{
return left.CompareTo(right) > 0;
}
}
/// <summary>
/// Time range helpers.
/// </summary>
public static class TimeRangeHelpers
{
/// <summary>
/// Finds the longest contiguous time range.
/// </summary>
/// <param name="times">Sorted timestamps to search.</param>
/// <param name="maximumDistance">Maximum distance permitted between contiguous timestamps.</param>
public static TimeRange? FindContiguous(double[] times, double maximumDistance)
{
if (times.Length == 0)
{
return null;
}
Array.Sort(times);
var ranges = new List<TimeRange>();
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;
}
}

View File

@ -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;
/// <summary>
/// Server entrypoint.
/// </summary>
public class Entrypoint : IServerEntryPoint
{
private readonly IUserManager _userManager;
private readonly IUserViewManager _userViewManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<Entrypoint> _logger;
private readonly object _queueLock = new object();
/// <summary>
/// Constructor.
/// </summary>
/// <param name="userManager">User manager.</param>
/// <param name="userViewManager">User view manager.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public Entrypoint(
IUserManager userManager,
IUserViewManager userViewManager,
ILibraryManager libraryManager,
ILogger<Entrypoint> logger)
{
_userManager = userManager;
_userViewManager = userViewManager;
_libraryManager = libraryManager;
_logger = logger;
}
/// <summary>
/// Registers event handler.
/// </summary>
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);
}
}
/// <summary>
/// Called when an item is added to the server.
/// </summary>
/// <param name="sender">Sender.</param>
/// <param name="e">ItemChangeEventArgs.</param>
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<QueuedEpisode>();
}
// 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++;
}
}
/// <summary>
/// FIXME: don't do this.
/// </summary>
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.");
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Dispose.
/// </summary>
protected virtual void Dispose(bool dispose)
{
if (!dispose)
{
return;
}
_libraryManager.ItemAdded -= ItemAdded;
}
}

View File

@ -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;
/// <summary>
/// Wrapper for the fpcalc utility.
/// </summary>
public static class FPCalc {
/// <summary>
/// Logger.
/// </summary>
public static ILogger? Logger { get; set; }
/// <summary>
/// Check that the fpcalc utility is installed.
/// </summary>
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;
}
}
/// <summary>
/// Fingerprint a queued episode.
/// </summary>
/// <param name="episode">Queued episode to fingerprint.</param>
public static ReadOnlyCollection<uint> 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<uint>();
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();
}
}

View File

@ -0,0 +1,24 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Audio fingerprinter class.
/// </summary>
public class Fingerprinter {
/// <summary>
/// First file to fingerprint and compare.
/// </summary>
public string FileA { get; private set; }
/// <summary>
/// Second file to fingerprint and compare.
/// </summary>
public string FileB { get; private set; }
/// <summary>
/// Constructor.
/// </summary>
public Fingerprinter(string fileA, string fileB) {
FileA = fileA;
FileB = fileB;
}
}

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.ObjectModel;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
@ -14,6 +15,21 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// </summary> /// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{ {
/// <summary>
/// Results of fingerprinting all episodes.
/// </summary>
public Dictionary<Guid, Intro> Intros { get; }
/// <summary>
/// Map of season ids to episodes that have been queued for fingerprinting.
/// </summary>
public Dictionary<Guid, List<QueuedEpisode>> AnalysisQueue { get; }
/// <summary>
/// Total number of episodes in the queue.
/// </summary>
public int TotalQueued { get; set; }
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class. /// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary> /// </summary>
@ -22,6 +38,9 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer) : base(applicationPaths, xmlSerializer)
{ {
Intros = new Dictionary<Guid, Intro>();
AnalysisQueue = new Dictionary<Guid, List<QueuedEpisode>>();
Instance = this; Instance = this;
} }

View File

@ -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;
/// <summary>
/// Fingerprint all queued episodes at the set time.
/// </summary>
public class FingerprinterTask : IScheduledTask {
private readonly ILogger<FingerprinterTask> _logger;
/// <summary>
/// Minimum time (in seconds) for a contiguous time range to be considered an introduction.
/// </summary>
private const int MINIMUM_INTRO_DURATION = 18;
/// <summary>
/// Maximum number of bits (out of 32 total) that can be different between segments before they are considered dissimilar.
/// </summary>
private const double MAXIMUM_DIFFERENCES = 5;
/// <summary>
/// Maximum time permitted between timestamps before they are considered non-contiguous.
/// </summary>
private const double MAXIMUM_DISTANCE = 3.25;
/// <summary>
/// Seconds of audio in one number from the fingerprint. Defined by Chromaprint.
/// </summary>
private const double SAMPLES_TO_SECONDS = 0.128;
/// <summary>
/// Constructor.
/// </summary>
public FingerprinterTask(ILogger<FingerprinterTask> logger)
{
_logger = logger;
_logger.LogInformation("Fingerprinting Task Scheduled!");
}
/// <summary>
/// Task name.
/// </summary>
public string Name => "Analyze episodes";
/// <summary>
/// Task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Task description.
/// </summary>
public string Description => "Analyzes the audio of all television episodes to find introduction sequences.";
/// <summary>
/// Key.
/// </summary>
public string Key => "CPBIntroSkipperRunFingerprinter";
/// <summary>
/// Analyze all episodes in the queue.
/// </summary>
/// <param name="progress">Progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task ExecuteAsync(IProgress<double> 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<TimeRange>();
var rhsRanges = new List<TimeRange>();
// 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<double>();
var rhsTimes = new List<double>();
// 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;
}
/// <summary>
/// Get task triggers.
/// </summary>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromDays(24).Ticks
}
};
}
}