2022-05-01 00:33:22 -05:00
|
|
|
using System;
|
|
|
|
using System.Collections.Generic;
|
2022-08-27 01:58:46 -05:00
|
|
|
using System.Collections.ObjectModel;
|
2022-05-16 19:20:35 -05:00
|
|
|
using System.Numerics;
|
2022-05-01 00:33:22 -05:00
|
|
|
using System.Threading;
|
|
|
|
using System.Threading.Tasks;
|
2022-06-22 22:03:34 -05:00
|
|
|
using MediaBrowser.Controller.Library;
|
2022-05-01 00:33:22 -05:00
|
|
|
using MediaBrowser.Model.Tasks;
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
|
|
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
|
|
|
|
|
|
|
/// <summary>
|
2022-08-22 23:18:13 -05:00
|
|
|
/// Analyze all television episodes for introduction sequences.
|
2022-05-01 00:33:22 -05:00
|
|
|
/// </summary>
|
2022-08-22 23:18:13 -05:00
|
|
|
public class AnalyzeEpisodesTask : IScheduledTask
|
2022-05-09 22:50:41 -05:00
|
|
|
{
|
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.
|
2022-06-01 13:45:34 -05:00
|
|
|
/// 6 bits means the audio must be at least 81% similar (1 - 6 / 32).
|
2022-05-01 00:33:22 -05:00
|
|
|
/// </summary>
|
2022-06-01 02:51:57 -05:00
|
|
|
private const double MaximumDifferences = 6;
|
2022-05-01 00:33:22 -05:00
|
|
|
|
|
|
|
/// <summary>
|
2022-05-10 02:10:39 -05:00
|
|
|
/// Maximum time (in seconds) permitted between timestamps before they are considered non-contiguous.
|
2022-05-01 00:33:22 -05:00
|
|
|
/// </summary>
|
2022-06-01 02:51:57 -05:00
|
|
|
private const double MaximumDistance = 3.5;
|
2022-05-01 00:33:22 -05:00
|
|
|
|
2022-08-28 22:25:27 -05:00
|
|
|
/// <summary>
|
|
|
|
/// Amount to shift inverted index offsets by.
|
|
|
|
/// </summary>
|
|
|
|
private const int InvertedIndexShift = 2;
|
|
|
|
|
2022-05-01 00:33:22 -05:00
|
|
|
/// <summary>
|
2022-05-10 02:10:39 -05:00
|
|
|
/// 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-08-22 23:18:13 -05:00
|
|
|
private readonly ILogger<AnalyzeEpisodesTask> _logger;
|
2022-05-09 03:31:10 -05:00
|
|
|
|
2022-06-22 22:03:34 -05:00
|
|
|
private readonly ILogger<QueueManager> _queueLogger;
|
|
|
|
|
|
|
|
private readonly ILibraryManager? _libraryManager;
|
|
|
|
|
2022-05-17 02:06:34 -05:00
|
|
|
/// <summary>
|
|
|
|
/// Lock which guards the shared dictionary of intros.
|
|
|
|
/// </summary>
|
|
|
|
private readonly object _introsLock = new object();
|
|
|
|
|
2022-07-04 02:03:10 -05:00
|
|
|
/// <summary>
|
|
|
|
/// Statistics for the currently running analysis task.
|
|
|
|
/// </summary>
|
|
|
|
private AnalysisStatistics analysisStatistics = new AnalysisStatistics();
|
|
|
|
|
2022-07-03 01:59:16 -05:00
|
|
|
/// <summary>
|
|
|
|
/// Minimum duration of similar audio that will be considered an introduction.
|
|
|
|
/// </summary>
|
|
|
|
private static int minimumIntroDuration = 15;
|
|
|
|
|
2022-05-01 00:33:22 -05:00
|
|
|
/// <summary>
|
2022-08-22 23:18:13 -05:00
|
|
|
/// Initializes a new instance of the <see cref="AnalyzeEpisodesTask"/> class.
|
2022-05-01 00:33:22 -05:00
|
|
|
/// </summary>
|
2022-06-22 22:03:34 -05:00
|
|
|
/// <param name="loggerFactory">Logger factory.</param>
|
|
|
|
/// <param name="libraryManager">Library manager.</param>
|
2022-08-22 23:18:13 -05:00
|
|
|
public AnalyzeEpisodesTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager) : this(loggerFactory)
|
2022-05-01 00:33:22 -05:00
|
|
|
{
|
2022-06-22 22:03:34 -05:00
|
|
|
_libraryManager = libraryManager;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// <summary>
|
2022-08-22 23:18:13 -05:00
|
|
|
/// Initializes a new instance of the <see cref="AnalyzeEpisodesTask"/> class.
|
2022-06-22 22:03:34 -05:00
|
|
|
/// </summary>
|
|
|
|
/// <param name="loggerFactory">Logger factory.</param>
|
2022-08-22 23:18:13 -05:00
|
|
|
public AnalyzeEpisodesTask(ILoggerFactory loggerFactory)
|
2022-06-22 22:03:34 -05:00
|
|
|
{
|
2022-08-22 23:18:13 -05:00
|
|
|
_logger = loggerFactory.CreateLogger<AnalyzeEpisodesTask>();
|
2022-06-22 22:03:34 -05:00
|
|
|
_queueLogger = loggerFactory.CreateLogger<QueueManager>();
|
|
|
|
|
2022-06-15 01:00:03 -05:00
|
|
|
EdlManager.Initialize(_logger);
|
2022-05-01 00:33:22 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
/// <summary>
|
2022-05-09 22:50:41 -05:00
|
|
|
/// Gets the task name.
|
2022-05-01 00:33:22 -05:00
|
|
|
/// </summary>
|
2022-08-25 01:00:02 -05:00
|
|
|
public string Name => "Detect Introductions";
|
2022-05-01 00:33:22 -05:00
|
|
|
|
|
|
|
/// <summary>
|
2022-05-09 22:50:41 -05:00
|
|
|
/// Gets the task category.
|
2022-05-01 00:33:22 -05:00
|
|
|
/// </summary>
|
|
|
|
public string Category => "Intro Skipper";
|
|
|
|
|
|
|
|
/// <summary>
|
2022-05-09 22:50:41 -05:00
|
|
|
/// 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>
|
2022-05-09 22:50:41 -05:00
|
|
|
/// Gets the task key.
|
2022-05-01 00:33:22 -05:00
|
|
|
/// </summary>
|
2022-08-25 01:00:02 -05:00
|
|
|
public string Key => "CPBIntroSkipperDetectIntroductions";
|
2022-05-01 00:33:22 -05:00
|
|
|
|
|
|
|
/// <summary>
|
2022-06-22 22:03:34 -05:00
|
|
|
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
|
2022-05-01 00:33:22 -05:00
|
|
|
/// </summary>
|
2022-05-10 02:10:39 -05:00
|
|
|
/// <param name="progress">Task progress.</param>
|
2022-05-01 00:33:22 -05:00
|
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
2022-05-09 22:50:41 -05:00
|
|
|
/// <returns>Task.</returns>
|
2022-05-01 00:33:22 -05:00
|
|
|
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
|
|
|
{
|
2022-06-22 22:03:34 -05:00
|
|
|
if (_libraryManager is null)
|
|
|
|
{
|
|
|
|
throw new InvalidOperationException("Library manager must not be null");
|
|
|
|
}
|
|
|
|
|
|
|
|
// Make sure the analysis queue matches what's currently in Jellyfin.
|
|
|
|
var queueManager = new QueueManager(_queueLogger, _libraryManager);
|
|
|
|
queueManager.EnqueueAllEpisodes();
|
|
|
|
|
2022-05-01 00:33:22 -05:00
|
|
|
var queue = Plugin.Instance!.AnalysisQueue;
|
2022-06-09 19:14:05 -05:00
|
|
|
|
|
|
|
if (queue.Count == 0)
|
|
|
|
{
|
|
|
|
throw new FingerprintException(
|
2022-06-27 00:21:30 -05:00
|
|
|
"No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly.");
|
2022-06-09 19:14:05 -05:00
|
|
|
}
|
|
|
|
|
2022-06-24 00:02:08 -05:00
|
|
|
// Log EDL settings
|
|
|
|
EdlManager.LogConfiguration();
|
|
|
|
|
2022-06-19 23:58:59 -05:00
|
|
|
// Include the previously processed episodes in the percentage reported to the UI.
|
|
|
|
var totalProcessed = CountProcessedEpisodes();
|
2022-06-09 20:34:18 -05:00
|
|
|
var options = new ParallelOptions()
|
|
|
|
{
|
|
|
|
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
|
|
|
|
};
|
2022-05-17 02:06:34 -05:00
|
|
|
|
2022-07-04 02:03:10 -05:00
|
|
|
var taskStart = DateTime.Now;
|
|
|
|
analysisStatistics = new AnalysisStatistics();
|
|
|
|
analysisStatistics.TotalQueuedEpisodes = Plugin.Instance!.TotalQueued;
|
|
|
|
|
2022-07-03 01:59:16 -05:00
|
|
|
minimumIntroDuration = Plugin.Instance!.Configuration.MinimumIntroDuration;
|
|
|
|
|
2022-07-25 22:03:06 -05:00
|
|
|
// TODO: if the queue is modified while the task is running, the task will fail.
|
|
|
|
// clone the queue before running the task to prevent this.
|
|
|
|
|
2022-06-24 00:02:08 -05:00
|
|
|
// Analyze all episodes in the queue using the degrees of parallelism the user specified.
|
2022-05-17 02:06:34 -05:00
|
|
|
Parallel.ForEach(queue, options, (season) =>
|
2022-05-09 22:50:41 -05:00
|
|
|
{
|
2022-07-04 02:03:10 -05:00
|
|
|
var workerStart = DateTime.Now;
|
2022-05-17 01:33:49 -05:00
|
|
|
var first = season.Value[0];
|
2022-06-15 01:00:03 -05:00
|
|
|
var writeEdl = false;
|
2022-05-17 01:33:49 -05:00
|
|
|
|
|
|
|
try
|
|
|
|
{
|
2022-08-27 01:58:46 -05:00
|
|
|
var episodes = new Collection<QueuedEpisode>(season.Value);
|
|
|
|
|
2022-06-19 23:58:59 -05:00
|
|
|
// 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).
|
2022-08-27 01:58:46 -05:00
|
|
|
var analyzed = AnalyzeSeason(episodes, cancellationToken);
|
2022-06-19 23:58:59 -05:00
|
|
|
Interlocked.Add(ref totalProcessed, analyzed);
|
2022-06-24 00:02:08 -05:00
|
|
|
writeEdl = analyzed > 0 || Plugin.Instance!.Configuration.RegenerateEdlFiles;
|
2022-05-17 01:33:49 -05:00
|
|
|
}
|
|
|
|
catch (FingerprintException ex)
|
|
|
|
{
|
|
|
|
_logger.LogWarning(
|
|
|
|
"Unable to analyze {Series} season {Season}: unable to fingerprint: {Ex}",
|
|
|
|
first.SeriesName,
|
|
|
|
first.SeasonNumber,
|
|
|
|
ex);
|
|
|
|
}
|
|
|
|
catch (KeyNotFoundException ex)
|
|
|
|
{
|
|
|
|
_logger.LogWarning(
|
|
|
|
"Unable to analyze {Series} season {Season}: cache miss: {Ex}",
|
|
|
|
first.SeriesName,
|
|
|
|
first.SeasonNumber,
|
|
|
|
ex);
|
|
|
|
}
|
|
|
|
|
2022-06-15 01:00:03 -05:00
|
|
|
if (writeEdl && Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
|
|
|
|
{
|
|
|
|
EdlManager.UpdateEDLFiles(season.Value.AsReadOnly());
|
|
|
|
}
|
|
|
|
|
2022-05-13 01:28:06 -05:00
|
|
|
progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued);
|
2022-07-04 02:03:10 -05:00
|
|
|
|
|
|
|
analysisStatistics.TotalCPUTime.AddDuration(workerStart);
|
|
|
|
Plugin.Instance!.AnalysisStatistics = analysisStatistics;
|
2022-05-16 17:06:46 -05:00
|
|
|
});
|
2022-05-13 01:28:06 -05:00
|
|
|
|
2022-07-08 01:02:50 -05:00
|
|
|
// Update analysis statistics
|
2022-07-04 02:03:10 -05:00
|
|
|
analysisStatistics.TotalTaskTime.AddDuration(taskStart);
|
|
|
|
Plugin.Instance!.AnalysisStatistics = analysisStatistics;
|
|
|
|
|
2022-06-24 00:02:08 -05:00
|
|
|
// Turn the regenerate EDL flag off after the scan completes.
|
|
|
|
if (Plugin.Instance!.Configuration.RegenerateEdlFiles)
|
|
|
|
{
|
|
|
|
_logger.LogInformation("Turning EDL file regeneration flag off");
|
|
|
|
Plugin.Instance!.Configuration.RegenerateEdlFiles = false;
|
|
|
|
Plugin.Instance!.SaveConfiguration();
|
|
|
|
}
|
|
|
|
|
2022-05-13 01:28:06 -05:00
|
|
|
return Task.CompletedTask;
|
|
|
|
}
|
|
|
|
|
2022-06-19 23:58:59 -05:00
|
|
|
/// <summary>
|
|
|
|
/// Count the number of previously processed episodes to ensure the reported progress is correct.
|
|
|
|
/// </summary>
|
|
|
|
/// <returns>Number of previously processed episodes.</returns>
|
|
|
|
private int CountProcessedEpisodes()
|
|
|
|
{
|
|
|
|
var previous = 0;
|
|
|
|
|
|
|
|
foreach (var season in Plugin.Instance!.AnalysisQueue)
|
|
|
|
{
|
|
|
|
foreach (var episode in season.Value)
|
|
|
|
{
|
|
|
|
if (!Plugin.Instance!.Intros.TryGetValue(episode.EpisodeId, out var intro) || !intro.Valid)
|
|
|
|
{
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
previous++;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return previous;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
/// Fingerprints all episodes in the provided season and stores the timestamps of all introductions.
|
|
|
|
/// </summary>
|
2022-08-25 22:11:39 -05:00
|
|
|
/// <param name="episodes">Episodes in this season.</param>
|
2022-06-19 23:58:59 -05:00
|
|
|
/// <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(
|
2022-08-27 01:58:46 -05:00
|
|
|
Collection<QueuedEpisode> episodes,
|
2022-05-13 01:28:06 -05:00
|
|
|
CancellationToken cancellationToken)
|
|
|
|
{
|
2022-08-27 01:58:46 -05:00
|
|
|
// All intros for this season.
|
2022-05-17 02:06:34 -05:00
|
|
|
var seasonIntros = new Dictionary<Guid, Intro>();
|
2022-08-27 01:58:46 -05:00
|
|
|
|
|
|
|
// Cache of all fingerprints for this season.
|
2022-08-25 23:01:46 -05:00
|
|
|
var fingerprintCache = new Dictionary<Guid, uint[]>();
|
2022-05-13 01:28:06 -05:00
|
|
|
|
2022-08-29 23:56:13 -05:00
|
|
|
// Original episode queue.
|
|
|
|
var originalEpisodes = new List<QueuedEpisode>(episodes);
|
2022-08-27 01:58:46 -05:00
|
|
|
|
2022-05-13 01:28:06 -05:00
|
|
|
/* 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.
|
|
|
|
*/
|
2022-08-25 22:11:39 -05:00
|
|
|
if (episodes.Count < 2 || episodes[0].SeasonNumber == 0)
|
2022-05-13 01:28:06 -05:00
|
|
|
{
|
2022-08-29 23:56:13 -05:00
|
|
|
return originalEpisodes.Count;
|
2022-05-13 01:28:06 -05:00
|
|
|
}
|
|
|
|
|
2022-08-25 22:11:39 -05:00
|
|
|
var first = episodes[0];
|
|
|
|
|
2022-08-25 01:37:57 -05:00
|
|
|
_logger.LogInformation(
|
|
|
|
"Analyzing {Count} episodes from {Name} season {Season}",
|
2022-08-25 22:11:39 -05:00
|
|
|
episodes.Count,
|
2022-08-25 01:37:57 -05:00
|
|
|
first.SeriesName,
|
|
|
|
first.SeasonNumber);
|
2022-05-13 01:28:06 -05:00
|
|
|
|
2022-08-27 01:58:46 -05:00
|
|
|
// Compute fingerprints for all episodes in the season
|
2022-08-25 23:01:46 -05:00
|
|
|
foreach (var episode in episodes)
|
|
|
|
{
|
|
|
|
try
|
|
|
|
{
|
2022-08-28 22:35:43 -05:00
|
|
|
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode);
|
2022-08-25 23:01:46 -05:00
|
|
|
}
|
|
|
|
catch (FingerprintException ex)
|
|
|
|
{
|
|
|
|
_logger.LogWarning("Caught fingerprint error: {Ex}", ex);
|
|
|
|
|
2022-08-27 01:58:46 -05:00
|
|
|
// Fallback to an empty fingerprint on any error
|
2022-08-25 23:01:46 -05:00
|
|
|
fingerprintCache[episode.EpisodeId] = Array.Empty<uint>();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-27 01:58:46 -05:00
|
|
|
// While there are still episodes in the queue
|
|
|
|
while (episodes.Count > 0)
|
2022-05-13 01:28:06 -05:00
|
|
|
{
|
2022-08-27 01:58:46 -05:00
|
|
|
// Pop the first episode from the queue
|
|
|
|
var currentEpisode = episodes[0];
|
|
|
|
episodes.RemoveAt(0);
|
2022-05-03 01:09:50 -05:00
|
|
|
|
2022-08-27 01:58:46 -05:00
|
|
|
// Search through all remaining episodes.
|
|
|
|
foreach (var remainingEpisode in episodes)
|
|
|
|
{
|
|
|
|
// 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]);
|
|
|
|
|
2022-08-28 22:25:27 -05:00
|
|
|
// If one of the intros isn't valid, ignore this comparison result.
|
|
|
|
if (!currentIntro.Valid)
|
|
|
|
{
|
|
|
|
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)
|
2022-08-25 22:11:39 -05:00
|
|
|
{
|
2022-08-27 01:58:46 -05:00
|
|
|
seasonIntros[currentIntro.EpisodeId] = currentIntro;
|
2022-08-28 22:25:27 -05:00
|
|
|
}
|
2022-08-25 22:11:39 -05:00
|
|
|
|
2022-08-28 22:25:27 -05:00
|
|
|
if (
|
|
|
|
!seasonIntros.TryGetValue(remainingIntro.EpisodeId, out var savedRemainingIntro) ||
|
|
|
|
remainingIntro.Duration > savedRemainingIntro.Duration)
|
|
|
|
{
|
|
|
|
seasonIntros[remainingIntro.EpisodeId] = remainingIntro;
|
2022-08-25 22:11:39 -05:00
|
|
|
}
|
2022-08-28 22:25:27 -05:00
|
|
|
|
|
|
|
break;
|
2022-05-02 01:18:31 -05:00
|
|
|
}
|
2022-05-13 01:13:13 -05:00
|
|
|
|
2022-08-27 01:58:46 -05:00
|
|
|
// If no intro is found at this point, the popped episode is not reinserted into the queue.
|
|
|
|
}
|
2022-08-25 22:11:39 -05:00
|
|
|
|
2022-08-29 23:56:13 -05:00
|
|
|
// Adjust all introduction end times so that they end at silence.
|
|
|
|
seasonIntros = AdjustIntroEndTimes(
|
|
|
|
new ReadOnlyCollection<QueuedEpisode>(originalEpisodes),
|
|
|
|
seasonIntros);
|
|
|
|
|
2022-05-17 02:06:34 -05:00
|
|
|
// Ensure only one thread at a time can update the shared intro dictionary.
|
|
|
|
lock (_introsLock)
|
|
|
|
{
|
|
|
|
foreach (var intro in seasonIntros)
|
|
|
|
{
|
|
|
|
Plugin.Instance!.Intros[intro.Key] = intro.Value;
|
|
|
|
}
|
|
|
|
}
|
2022-05-13 01:28:06 -05:00
|
|
|
|
2022-05-19 00:59:58 -05:00
|
|
|
lock (_introsLock)
|
|
|
|
{
|
|
|
|
Plugin.Instance!.SaveTimestamps();
|
|
|
|
}
|
2022-06-19 23:58:59 -05:00
|
|
|
|
2022-08-29 23:56:13 -05:00
|
|
|
return originalEpisodes.Count;
|
2022-05-01 00:33:22 -05:00
|
|
|
}
|
|
|
|
|
2022-05-13 01:13:13 -05:00
|
|
|
/// <summary>
|
|
|
|
/// Analyze two episodes to find an introduction sequence shared between them.
|
|
|
|
/// </summary>
|
|
|
|
/// <param name="lhsId">First episode id.</param>
|
2022-05-16 23:08:20 -05:00
|
|
|
/// <param name="lhsPoints">First episode fingerprint points.</param>
|
2022-05-13 01:13:13 -05:00
|
|
|
/// <param name="rhsId">Second episode id.</param>
|
2022-05-16 23:08:20 -05:00
|
|
|
/// <param name="rhsPoints">Second episode fingerprint points.</param>
|
2022-05-13 01:13:13 -05:00
|
|
|
/// <returns>Intros for the first and second episodes.</returns>
|
2022-08-25 22:11:39 -05:00
|
|
|
public (Intro Lhs, Intro Rhs) CompareEpisodes(
|
2022-05-13 01:13:13 -05:00
|
|
|
Guid lhsId,
|
2022-07-09 00:24:58 -05:00
|
|
|
uint[] lhsPoints,
|
2022-05-13 01:13:13 -05:00
|
|
|
Guid rhsId,
|
2022-08-25 23:01:46 -05:00
|
|
|
uint[] rhsPoints)
|
2022-05-13 01:13:13 -05:00
|
|
|
{
|
2022-08-25 23:01:46 -05:00
|
|
|
var start = DateTime.Now;
|
2022-05-01 00:33:22 -05:00
|
|
|
|
2022-07-05 16:16:48 -05:00
|
|
|
// Creates an inverted fingerprint point index for both episodes.
|
|
|
|
// For every point which is a 100% match, search for an introduction at that point.
|
2022-08-26 00:50:45 -05:00
|
|
|
var (lhsRanges, rhsRanges) = SearchInvertedIndex(lhsId, lhsPoints, rhsId, rhsPoints);
|
2022-05-01 00:33:22 -05:00
|
|
|
|
2022-07-05 16:16:48 -05:00
|
|
|
if (lhsRanges.Count > 0)
|
2022-05-03 01:09:50 -05:00
|
|
|
{
|
2022-07-05 16:16:48 -05:00
|
|
|
_logger.LogTrace("Index search successful");
|
|
|
|
analysisStatistics.IndexSearches.Increment();
|
2022-08-25 01:28:41 -05:00
|
|
|
analysisStatistics.AnalysisCPUTime.AddDuration(start);
|
2022-07-05 16:16:48 -05:00
|
|
|
|
|
|
|
return GetLongestTimeRange(lhsId, lhsRanges, rhsId, rhsRanges);
|
2022-05-01 00:33:22 -05:00
|
|
|
}
|
|
|
|
|
2022-07-05 16:16:48 -05:00
|
|
|
_logger.LogTrace(
|
|
|
|
"Unable to find a shared introduction sequence between {LHS} and {RHS}",
|
|
|
|
lhsId,
|
|
|
|
rhsId);
|
|
|
|
|
2022-08-25 01:28:41 -05:00
|
|
|
analysisStatistics.AnalysisCPUTime.AddDuration(start);
|
2022-07-05 16:16:48 -05:00
|
|
|
|
|
|
|
return (new Intro(lhsId), new Intro(rhsId));
|
|
|
|
}
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
/// Locates the longest range of similar audio and returns an Intro class for each range.
|
|
|
|
/// </summary>
|
|
|
|
/// <param name="lhsId">First episode id.</param>
|
|
|
|
/// <param name="lhsRanges">First episode shared timecodes.</param>
|
|
|
|
/// <param name="rhsId">Second episode id.</param>
|
|
|
|
/// <param name="rhsRanges">Second episode shared timecodes.</param>
|
|
|
|
/// <returns>Intros for the first and second episodes.</returns>
|
|
|
|
private (Intro Lhs, Intro Rhs) GetLongestTimeRange(
|
|
|
|
Guid lhsId,
|
|
|
|
List<TimeRange> lhsRanges,
|
|
|
|
Guid rhsId,
|
|
|
|
List<TimeRange> rhsRanges)
|
|
|
|
{
|
|
|
|
// Store the longest time range as the introduction.
|
2022-05-01 00:33:22 -05:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2022-07-05 16:16:48 -05:00
|
|
|
// Create Intro classes for each time range.
|
2022-05-16 23:08:20 -05:00
|
|
|
return (new Intro(lhsId, lhsIntro), new Intro(rhsId, rhsIntro));
|
2022-05-03 01:09:50 -05:00
|
|
|
}
|
|
|
|
|
2022-07-05 16:16:48 -05:00
|
|
|
/// <summary>
|
|
|
|
/// Search for a shared introduction sequence using inverted indexes.
|
|
|
|
/// </summary>
|
2022-08-26 00:50:45 -05:00
|
|
|
/// <param name="lhsId">LHS ID.</param>
|
2022-07-05 16:16:48 -05:00
|
|
|
/// <param name="lhsPoints">Left episode fingerprint points.</param>
|
2022-08-26 00:50:45 -05:00
|
|
|
/// <param name="rhsId">RHS ID.</param>
|
2022-07-05 16:16:48 -05:00
|
|
|
/// <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(
|
2022-08-26 00:50:45 -05:00
|
|
|
Guid lhsId,
|
2022-07-09 00:24:58 -05:00
|
|
|
uint[] lhsPoints,
|
2022-08-26 00:50:45 -05:00
|
|
|
Guid rhsId,
|
2022-07-09 00:24:58 -05:00
|
|
|
uint[] rhsPoints)
|
2022-07-05 16:16:48 -05:00
|
|
|
{
|
|
|
|
var lhsRanges = new List<TimeRange>();
|
|
|
|
var rhsRanges = new List<TimeRange>();
|
|
|
|
|
|
|
|
// Generate inverted indexes for the left and right episodes.
|
2022-08-28 22:35:43 -05:00
|
|
|
var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints);
|
|
|
|
var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints);
|
2022-07-05 16:16:48 -05:00
|
|
|
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)
|
|
|
|
{
|
2022-08-28 22:25:27 -05:00
|
|
|
var originalPoint = kvp.Key;
|
2022-07-05 16:16:48 -05:00
|
|
|
|
2022-08-28 22:25:27 -05:00
|
|
|
for (var i = -1 * InvertedIndexShift; i <= InvertedIndexShift; i++)
|
2022-07-05 16:16:48 -05:00
|
|
|
{
|
2022-08-28 22:25:27 -05:00
|
|
|
var modifiedPoint = (uint)(originalPoint + i);
|
|
|
|
|
|
|
|
if (rhsIndex.ContainsKey(modifiedPoint))
|
|
|
|
{
|
|
|
|
var lhsFirst = (int)lhsIndex[originalPoint];
|
|
|
|
var rhsFirst = (int)rhsIndex[modifiedPoint];
|
|
|
|
indexShifts.Add(rhsFirst - lhsFirst);
|
|
|
|
}
|
2022-07-05 16:16:48 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Use all discovered shifts to compare the episodes.
|
|
|
|
foreach (var shift in indexShifts)
|
|
|
|
{
|
|
|
|
var (lhsIndexContiguous, rhsIndexContiguous) = ShiftEpisodes(lhsPoints, rhsPoints, shift, shift);
|
|
|
|
lhsRanges.AddRange(lhsIndexContiguous);
|
|
|
|
rhsRanges.AddRange(rhsIndexContiguous);
|
|
|
|
}
|
|
|
|
|
|
|
|
return (lhsRanges, rhsRanges);
|
|
|
|
}
|
|
|
|
|
2022-05-03 01:09:50 -05:00
|
|
|
/// <summary>
|
2022-05-16 23:08:20 -05:00
|
|
|
/// Shifts a pair of episodes through the range of provided shift amounts and returns discovered contiguous time ranges.
|
2022-05-03 01:09:50 -05:00
|
|
|
/// </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>
|
2022-05-09 22:50:41 -05:00
|
|
|
private static (List<TimeRange> Lhs, List<TimeRange> Rhs) ShiftEpisodes(
|
2022-07-09 00:24:58 -05:00
|
|
|
uint[] lhs,
|
|
|
|
uint[] rhs,
|
2022-05-03 01:09:50 -05:00
|
|
|
int lower,
|
2022-05-09 22:50:41 -05:00
|
|
|
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++)
|
|
|
|
{
|
2022-05-09 22:50:41 -05:00
|
|
|
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>
|
2022-05-09 22:50:41 -05:00
|
|
|
private static (TimeRange Lhs, TimeRange Rhs) FindContiguous(
|
2022-07-09 00:24:58 -05:00
|
|
|
uint[] lhs,
|
|
|
|
uint[] rhs,
|
2022-05-09 22:50:41 -05:00
|
|
|
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.
|
2022-05-09 22:50:41 -05:00
|
|
|
if (shiftAmount < 0)
|
|
|
|
{
|
2022-05-03 01:09:50 -05:00
|
|
|
leftOffset -= shiftAmount;
|
2022-05-09 22:50:41 -05:00
|
|
|
}
|
|
|
|
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>();
|
2022-07-09 00:24:58 -05:00
|
|
|
var upperLimit = Math.Min(lhs.Length, rhs.Length) - Math.Abs(shiftAmount);
|
2022-05-03 01:09:50 -05:00
|
|
|
|
|
|
|
// XOR all elements in LHS and RHS, using the shift amount from above.
|
2022-05-09 22:50:41 -05:00
|
|
|
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
|
2022-05-09 22:50:41 -05:00
|
|
|
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);
|
2022-07-03 01:59:16 -05:00
|
|
|
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)
|
|
|
|
{
|
2022-05-10 02:10:39 -05:00
|
|
|
lContiguous.End -= 2 * MaximumDistance;
|
|
|
|
rContiguous.End -= 2 * MaximumDistance;
|
2022-05-03 01:09:50 -05:00
|
|
|
}
|
2022-06-01 02:51:57 -05:00
|
|
|
else if (lContiguous.Duration >= 30)
|
2022-05-03 01:09:50 -05:00
|
|
|
{
|
2022-05-10 02:10:39 -05:00
|
|
|
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
|
|
|
}
|
|
|
|
|
2022-08-29 23:56:13 -05:00
|
|
|
/// <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;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Since we only want to adjust the end timestamp of the intro, create a new TimeRange
|
|
|
|
// that covers the last few seconds.
|
|
|
|
var originalIntroEnd = new TimeRange(originalIntro.IntroEnd - 10, 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 less than half a second long
|
|
|
|
if (!originalIntroEnd.Intersects(currentRange) || currentRange.Duration < 0.5)
|
|
|
|
{
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2022-05-10 02:10:39 -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-09 22:50:41 -05:00
|
|
|
{
|
2022-05-16 19:20:35 -05:00
|
|
|
return BitOperations.PopCount(number);
|
2022-05-01 00:33:22 -05:00
|
|
|
}
|
|
|
|
|
2022-05-13 01:13:13 -05:00
|
|
|
private double GetIntroDuration(Guid id)
|
|
|
|
{
|
2022-06-24 16:00:33 -05:00
|
|
|
if (!Plugin.Instance!.Intros.TryGetValue(id, out var episode))
|
|
|
|
{
|
|
|
|
return 0;
|
|
|
|
}
|
|
|
|
|
2022-05-13 01:13:13 -05:00
|
|
|
return episode.Valid ? Math.Round(episode.IntroEnd - episode.IntroStart, 2) : 0;
|
|
|
|
}
|
|
|
|
|
2022-05-01 00:33:22 -05:00
|
|
|
/// <summary>
|
|
|
|
/// Get task triggers.
|
|
|
|
/// </summary>
|
2022-05-09 22:50:41 -05:00
|
|
|
/// <returns>Task triggers.</returns>
|
2022-05-01 00:33:22 -05:00
|
|
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
|
|
|
{
|
|
|
|
return new[]
|
|
|
|
{
|
|
|
|
new TaskTriggerInfo
|
|
|
|
{
|
|
|
|
Type = TaskTriggerInfo.TriggerDaily,
|
2022-08-22 23:18:13 -05:00
|
|
|
TimeOfDayTicks = TimeSpan.FromHours(0).Ticks
|
2022-05-01 00:33:22 -05:00
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|