Initial rewrite from Go
This commit is contained in:
parent
82f8871c1b
commit
a1862d0e2f
@ -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>
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
35
ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs
Normal file
35
ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs
Normal 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; }
|
||||||
|
}
|
33
ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs
Normal file
33
ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs
Normal 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; }
|
||||||
|
}
|
155
ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs
Normal file
155
ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
189
ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs
Normal file
189
ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
89
ConfusedPolarBear.Plugin.IntroSkipper/FPCalc.cs
Normal file
89
ConfusedPolarBear.Plugin.IntroSkipper/FPCalc.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
24
ConfusedPolarBear.Plugin.IntroSkipper/Fingerprinter.cs
Normal file
24
ConfusedPolarBear.Plugin.IntroSkipper/Fingerprinter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user