425 lines
14 KiB
C#
Raw Normal View History

2022-05-01 00:33:22 -05:00
using System;
using System.Collections.Generic;
2022-05-03 01:09:50 -05:00
using System.Collections.ObjectModel;
2022-05-01 00:33:22 -05:00
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Fingerprint and analyze all queued episodes for common audio sequences.
2022-05-01 00:33:22 -05:00
/// </summary>
public class FingerprinterTask : IScheduledTask
{
2022-05-01 00:33:22 -05:00
/// <summary>
/// Minimum time (in seconds) for a contiguous time range to be considered an introduction.
/// </summary>
2022-05-09 22:56:03 -05:00
private const int MinimumIntroDuration = 15;
2022-05-01 00:33:22 -05:00
/// <summary>
/// Maximum number of bits (out of 32 total) that can be different between segments before they are considered dissimilar.
/// </summary>
2022-05-09 22:56:03 -05:00
private const double MaximumDifferences = 3;
2022-05-01 00:33:22 -05:00
/// <summary>
/// Maximum time (in seconds) permitted between timestamps before they are considered non-contiguous.
2022-05-01 00:33:22 -05:00
/// </summary>
2022-05-09 22:56:03 -05:00
private const double MaximumDistance = 3.25;
2022-05-01 00:33:22 -05:00
/// <summary>
/// Seconds of audio in one fingerprint point. This value is defined by the Chromaprint library and should not be changed.
2022-05-01 00:33:22 -05:00
/// </summary>
2022-05-09 22:56:03 -05:00
private const double SamplesToSeconds = 0.128;
2022-05-01 00:33:22 -05:00
2022-05-09 22:56:03 -05:00
private readonly ILogger<FingerprinterTask> _logger;
2022-05-01 00:33:22 -05:00
/// <summary>
/// Initializes a new instance of the <see cref="FingerprinterTask"/> class.
2022-05-01 00:33:22 -05:00
/// </summary>
/// <param name="logger">Logger.</param>
2022-05-01 00:33:22 -05:00
public FingerprinterTask(ILogger<FingerprinterTask> logger)
{
_logger = logger;
}
2022-05-09 22:56:03 -05:00
/// <summary>
/// Gets the last detected intro sequence. Only populated when a unit test is running.
/// </summary>
public static Intro LastIntro { get; private set; } = new Intro();
2022-05-01 00:33:22 -05:00
/// <summary>
/// Gets the task name.
2022-05-01 00:33:22 -05:00
/// </summary>
public string Name => "Analyze episodes";
/// <summary>
/// Gets the task category.
2022-05-01 00:33:22 -05:00
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
2022-05-01 00:33:22 -05:00
/// </summary>
public string Description => "Analyzes the audio of all television episodes to find introduction sequences.";
/// <summary>
/// Gets the task key.
2022-05-01 00:33:22 -05:00
/// </summary>
public string Key => "CPBIntroSkipperRunFingerprinter";
/// <summary>
/// Analyze all episodes in the queue.
/// </summary>
/// <param name="progress">Task progress.</param>
2022-05-01 00:33:22 -05:00
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task.</returns>
2022-05-01 00:33:22 -05:00
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var queue = Plugin.Instance!.AnalysisQueue;
var totalProcessed = 0;
foreach (var season in queue)
{
2022-05-01 00:33:22 -05:00
var first = season.Value[0];
/* 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)
2022-05-02 01:18:31 -05:00
{
continue;
}
2022-05-03 01:09:50 -05:00
_logger.LogInformation(
"Analyzing {Count} episodes from {Name} season {Season}",
2022-05-01 00:33:22 -05:00
season.Value.Count,
first.SeriesName,
first.SeasonNumber);
// Ensure there are an even number of episodes
var episodes = season.Value;
if (episodes.Count % 2 != 0)
{
2022-05-01 00:33:22 -05:00
episodes.Add(episodes[episodes.Count - 2]);
}
2022-05-03 01:09:50 -05:00
// Analyze each pair of episodes in the current season
var everFoundIntro = false;
var failures = 0;
2022-05-01 00:33:22 -05:00
for (var i = 0; i < episodes.Count; i += 2)
{
2022-05-02 01:18:31 -05:00
if (cancellationToken.IsCancellationRequested)
{
break;
}
2022-05-01 00:33:22 -05:00
var lhs = episodes[i];
var rhs = episodes[i + 1];
2022-05-01 00:33:22 -05:00
2022-05-03 01:09:50 -05:00
// TODO: make configurable
if (!everFoundIntro && failures >= 6)
{
_logger.LogWarning(
"Failed to find an introduction in {Series} season {Season}",
lhs.SeriesName,
lhs.SeasonNumber);
break;
}
2022-05-01 00:33:22 -05:00
// 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);
totalProcessed += 2;
progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued);
2022-05-01 00:33:22 -05:00
continue;
}
try
{
2022-05-03 01:09:50 -05:00
_logger.LogDebug("Analyzing {LHS} and {RHS}", lhs.Path, rhs.Path);
if (FingerprintEpisodes(lhs, rhs))
{
everFoundIntro = true;
}
else
{
failures += 2;
}
2022-05-01 00:33:22 -05:00
}
catch (FingerprintException ex)
{
_logger.LogError("Caught fingerprint error: {Ex}", ex);
}
finally
{
totalProcessed += 2;
progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued);
}
}
Plugin.Instance!.SaveTimestamps();
2022-05-02 01:18:31 -05:00
if (cancellationToken.IsCancellationRequested)
{
break;
}
2022-05-01 00:33:22 -05:00
}
return Task.CompletedTask;
}
2022-05-03 01:09:50 -05:00
/// <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>true if an intro was found in both episodes, otherwise false.</returns>
public bool FingerprintEpisodes(QueuedEpisode lhsEpisode, QueuedEpisode rhsEpisode)
2022-05-01 00:33:22 -05:00
{
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.
2022-05-03 01:09:50 -05:00
var limit = Math.Min(lhs.Count, rhs.Count);
2022-05-01 00:33:22 -05:00
2022-05-03 01:09:50 -05:00
// First, test if an intro can be found within the first 5 seconds of the episodes (±5/0.128 = ±40 samples).
var (lhsContiguous, rhsContiguous) = ShiftEpisodes(lhs, rhs, -40, 40);
2022-05-03 01:09:50 -05:00
lhsRanges.AddRange(lhsContiguous);
rhsRanges.AddRange(rhsContiguous);
2022-05-01 00:33:22 -05:00
2022-05-03 01:09:50 -05:00
// If no valid ranges were found, re-analyze the episodes considering all possible shifts.
if (lhsRanges.Count == 0)
{
_logger.LogDebug("using full scan");
2022-05-01 00:33:22 -05:00
(lhsContiguous, rhsContiguous) = ShiftEpisodes(lhs, rhs, -1 * limit, limit);
2022-05-03 01:09:50 -05:00
lhsRanges.AddRange(lhsContiguous);
rhsRanges.AddRange(rhsContiguous);
}
else
{
_logger.LogDebug("intro found with quick scan");
2022-05-01 00:33:22 -05:00
}
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.
StoreIntro(lhsEpisode.EpisodeId, 0, 0);
StoreIntro(rhsEpisode.EpisodeId, 0, 0);
2022-05-01 00:33:22 -05:00
2022-05-03 01:09:50 -05:00
return false;
2022-05-01 00:33:22 -05:00
}
// 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];
2022-05-03 01:09:50 -05:00
// If the intro starts early in the episode, move it to the beginning.
2022-05-01 00:33:22 -05:00
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);
2022-05-03 01:09:50 -05:00
return true;
}
/// <summary>
/// Shifts episodes through the range of provided shift amounts and returns discovered contiguous time ranges.
/// </summary>
/// <param name="lhs">First episode fingerprint.</param>
/// <param name="rhs">Second episode fingerprint.</param>
/// <param name="lower">Lower end of the shift range.</param>
/// <param name="upper">Upper end of the shift range.</param>
private static (List<TimeRange> Lhs, List<TimeRange> Rhs) ShiftEpisodes(
2022-05-03 01:09:50 -05:00
ReadOnlyCollection<uint> lhs,
ReadOnlyCollection<uint> rhs,
int lower,
int upper)
{
2022-05-03 01:09:50 -05:00
var lhsRanges = new List<TimeRange>();
var rhsRanges = new List<TimeRange>();
for (int amount = lower; amount <= upper; amount++)
{
var (lRange, rRange) = FindContiguous(lhs, rhs, amount);
2022-05-03 01:09:50 -05:00
if (lRange.End == 0 && rRange.End == 0)
{
continue;
}
lhsRanges.Add(lRange);
rhsRanges.Add(rRange);
}
return (lhsRanges, rhsRanges);
}
/// <summary>
/// Finds the longest contiguous region of similar audio between two fingerprints using the provided shift amount.
/// </summary>
/// <param name="lhs">First fingerprint to compare.</param>
/// <param name="rhs">Second fingerprint to compare.</param>
/// <param name="shiftAmount">Amount to shift one fingerprint by.</param>
private static (TimeRange Lhs, TimeRange Rhs) FindContiguous(
2022-05-03 01:09:50 -05:00
ReadOnlyCollection<uint> lhs,
ReadOnlyCollection<uint> rhs,
int shiftAmount)
{
2022-05-03 01:09:50 -05:00
var leftOffset = 0;
var rightOffset = 0;
// Calculate the offsets for the left and right hand sides.
if (shiftAmount < 0)
{
2022-05-03 01:09:50 -05:00
leftOffset -= shiftAmount;
}
else
{
2022-05-03 01:09:50 -05:00
rightOffset += shiftAmount;
}
// Store similar times for both LHS and RHS.
var lhsTimes = new List<double>();
var rhsTimes = new List<double>();
var upperLimit = Math.Min(lhs.Count, rhs.Count) - Math.Abs(shiftAmount);
// XOR all elements in LHS and RHS, using the shift amount from above.
for (var i = 0; i < upperLimit; i++)
{
2022-05-03 01:09:50 -05:00
// XOR both samples at the current position.
var lhsPosition = i + leftOffset;
var rhsPosition = i + rightOffset;
var diff = lhs[lhsPosition] ^ rhs[rhsPosition];
2022-05-07 21:11:59 -05:00
// If the difference between the samples is small, flag both times as similar.
2022-05-09 22:56:03 -05:00
if (CountBits(diff) > MaximumDifferences)
2022-05-03 01:09:50 -05:00
{
continue;
}
2022-05-09 22:56:03 -05:00
var lhsTime = lhsPosition * SamplesToSeconds;
var rhsTime = rhsPosition * SamplesToSeconds;
2022-05-03 01:09:50 -05:00
lhsTimes.Add(lhsTime);
rhsTimes.Add(rhsTime);
}
// Ensure the last timestamp is checked
lhsTimes.Add(double.MaxValue);
rhsTimes.Add(double.MaxValue);
2022-05-03 01:09:50 -05:00
// Now that both fingerprints have been compared at this shift, see if there's a contiguous time range.
2022-05-09 22:56:03 -05:00
var lContiguous = TimeRangeHelpers.FindContiguous(lhsTimes.ToArray(), MaximumDistance);
if (lContiguous is null || lContiguous.Duration < MinimumIntroDuration)
2022-05-03 01:09:50 -05:00
{
return (new TimeRange(), new TimeRange());
}
// Since LHS had a contiguous time range, RHS must have one also.
2022-05-09 22:56:03 -05:00
var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), MaximumDistance)!;
2022-05-03 01:09:50 -05:00
// 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;
2022-05-03 01:09:50 -05:00
}
else if (lContiguous.Duration >= 35)
{
lContiguous.End -= MaximumDistance;
rContiguous.End -= MaximumDistance;
2022-05-03 01:09:50 -05:00
}
return (lContiguous, rContiguous);
2022-05-01 00:33:22 -05:00
}
private static void StoreIntro(Guid episode, double introStart, double introEnd)
2022-05-01 00:33:22 -05:00
{
var intro = new Intro()
2022-05-01 00:33:22 -05:00
{
2022-05-01 01:24:57 -05:00
EpisodeId = episode,
Valid = introEnd > 0, // don't test introStart here as the intro could legitimately happen at the start.
2022-05-01 00:33:22 -05:00
IntroStart = introStart,
IntroEnd = introEnd
2022-05-01 00:33:22 -05:00
};
if (Plugin.Instance is null)
{
LastIntro = intro;
return;
}
Plugin.Instance.Intros[episode] = intro;
2022-05-01 00:33:22 -05:00
}
/// <summary>
/// Count the number of bits that are set in the provided number.
/// </summary>
/// <param name="number">Number to count bits in.</param>
/// <returns>Number of bits that are equal to 1.</returns>
public static int CountBits(uint number)
{
2022-05-01 00:33:22 -05:00
var count = 0;
for (var i = 0; i < 32; i++)
{
2022-05-01 00:33:22 -05:00
var low = (number >> i) & 1;
if (low == 1)
{
2022-05-01 00:33:22 -05:00
count++;
}
}
return count;
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
2022-05-01 00:33:22 -05:00
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromDays(24).Ticks
}
};
}
}