Add initial end credits detection code
This commit is contained in:
parent
ce52a0b979
commit
61178832c1
@ -1,6 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## v0.1.8.0 (no eta)
|
||||
* New features
|
||||
* Detect ending credits in television episodes
|
||||
* Internal changes
|
||||
* Move Chromaprint analysis code out of the episode analysis task
|
||||
* Add support for multiple analysis techinques
|
||||
|
@ -59,7 +59,9 @@ public class TestAudioFingerprinting
|
||||
3472417825, 3395841056, 3458735136, 3341420624, 1076496560, 1076501168, 1076501136, 1076497024
|
||||
};
|
||||
|
||||
var actual = FFmpegWrapper.Fingerprint(queueEpisode("audio/big_buck_bunny_intro.mp3"));
|
||||
var actual = FFmpegWrapper.Fingerprint(
|
||||
queueEpisode("audio/big_buck_bunny_intro.mp3"),
|
||||
AnalysisMode.Introduction);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
@ -91,8 +93,8 @@ public class TestAudioFingerprinting
|
||||
|
||||
var lhsEpisode = queueEpisode("audio/big_buck_bunny_intro.mp3");
|
||||
var rhsEpisode = queueEpisode("audio/big_buck_bunny_clip.mp3");
|
||||
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode);
|
||||
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode);
|
||||
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction);
|
||||
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction);
|
||||
|
||||
var (lhs, rhs) = chromaprint.CompareEpisodes(
|
||||
lhsEpisode.EpisodeId,
|
||||
@ -138,7 +140,7 @@ public class TestAudioFingerprinting
|
||||
{
|
||||
EpisodeId = Guid.NewGuid(),
|
||||
Path = "../../../" + path,
|
||||
FingerprintDuration = 60
|
||||
IntroFingerprintEnd = 60
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -30,6 +30,8 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
|
||||
private ILogger<ChromaprintAnalyzer> _logger;
|
||||
|
||||
private AnalysisMode _analysisMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
|
||||
/// </summary>
|
||||
@ -64,12 +66,14 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
// Episodes that were analyzed and do not have an introduction.
|
||||
var episodesWithoutIntros = new List<QueuedEpisode>();
|
||||
|
||||
this._analysisMode = mode;
|
||||
|
||||
// Compute fingerprints for all episodes in the season
|
||||
foreach (var episode in episodeAnalysisQueue)
|
||||
{
|
||||
try
|
||||
{
|
||||
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode);
|
||||
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@ -78,6 +82,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
}
|
||||
catch (FingerprintException ex)
|
||||
{
|
||||
// TODO: FIXME: move to debug level?
|
||||
_logger.LogWarning("Caught fingerprint error: {Ex}", ex);
|
||||
|
||||
// Fallback to an empty fingerprint on any error
|
||||
@ -112,6 +117,22 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Since the Fingerprint() function returns an array of Chromaprint points without time
|
||||
* information, the times reported from the index search function start from 0.
|
||||
*
|
||||
* While this is desired behavior for detecting introductions, it breaks credit
|
||||
* detection, as the audio we're analyzing was extracted from some point into the file.
|
||||
*
|
||||
* To fix this, add the starting time of the fingerprint to the reported time range.
|
||||
*/
|
||||
if (this._analysisMode == AnalysisMode.Credits)
|
||||
{
|
||||
currentIntro.IntroStart += currentEpisode.CreditsFingerprintStart;
|
||||
currentIntro.IntroEnd += currentEpisode.CreditsFingerprintStart;
|
||||
remainingIntro.IntroStart += remainingEpisode.CreditsFingerprintStart;
|
||||
remainingIntro.IntroEnd += remainingEpisode.CreditsFingerprintStart;
|
||||
}
|
||||
|
||||
// Only save the discovered intro if it is:
|
||||
// - the first intro discovered for this episode
|
||||
// - longer than the previously discovered intro
|
||||
@ -142,10 +163,13 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
return analysisQueue;
|
||||
}
|
||||
|
||||
// Adjust all introduction end times so that they end at silence.
|
||||
seasonIntros = AdjustIntroEndTimes(analysisQueue, seasonIntros);
|
||||
if (this._analysisMode == AnalysisMode.Introduction)
|
||||
{
|
||||
// Adjust all introduction end times so that they end at silence.
|
||||
seasonIntros = AdjustIntroEndTimes(analysisQueue, seasonIntros);
|
||||
}
|
||||
|
||||
Plugin.Instance!.UpdateTimestamps(seasonIntros);
|
||||
Plugin.Instance!.UpdateTimestamps(seasonIntros, this._analysisMode);
|
||||
|
||||
return episodesWithoutIntros.AsReadOnly();
|
||||
}
|
||||
@ -338,16 +362,20 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
// Since LHS had a contiguous time range, RHS must have one also.
|
||||
var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), maximumTimeSkip)!;
|
||||
|
||||
// Tweak the end timestamps just a bit to ensure as little content as possible is skipped over.
|
||||
if (lContiguous.Duration >= 90)
|
||||
if (this._analysisMode == AnalysisMode.Introduction)
|
||||
{
|
||||
lContiguous.End -= 2 * maximumTimeSkip;
|
||||
rContiguous.End -= 2 * maximumTimeSkip;
|
||||
}
|
||||
else if (lContiguous.Duration >= 30)
|
||||
{
|
||||
lContiguous.End -= maximumTimeSkip;
|
||||
rContiguous.End -= maximumTimeSkip;
|
||||
// Tweak the end timestamps just a bit to ensure as little content as possible is skipped over.
|
||||
// TODO: remove this
|
||||
if (lContiguous.Duration >= 90)
|
||||
{
|
||||
lContiguous.End -= 2 * maximumTimeSkip;
|
||||
rContiguous.End -= 2 * maximumTimeSkip;
|
||||
}
|
||||
else if (lContiguous.Duration >= 30)
|
||||
{
|
||||
lContiguous.End -= maximumTimeSkip;
|
||||
rContiguous.End -= maximumTimeSkip;
|
||||
}
|
||||
}
|
||||
|
||||
return (lContiguous, rContiguous);
|
||||
|
@ -72,6 +72,11 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
public int MaximumIntroDuration { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the upper limit (in minutes) on the length of each episode's audio track that will be analyzed when searching for ending credits.
|
||||
/// </summary>
|
||||
public int MaximumEpisodeCreditsDuration { get; set; } = 4;
|
||||
|
||||
// ===== Playback settings =====
|
||||
|
||||
/// <summary>
|
||||
|
@ -113,7 +113,7 @@ public class VisualizationController : ControllerBase
|
||||
{
|
||||
if (needle.EpisodeId == id)
|
||||
{
|
||||
return FFmpegWrapper.Fingerprint(needle);
|
||||
return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -33,7 +33,17 @@ public class QueuedEpisode
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the seconds of media file to fingerprint.
|
||||
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction.
|
||||
/// </summary>
|
||||
public int FingerprintDuration { get; set; }
|
||||
public int IntroFingerprintEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp (in seconds) to start looking for end credits.
|
||||
/// </summary>
|
||||
public int CreditsFingerprintStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total duration of this media file (in seconds).
|
||||
/// </summary>
|
||||
public int Duration { get; set; }
|
||||
}
|
||||
|
@ -134,27 +134,60 @@ public static class FFmpegWrapper
|
||||
/// Fingerprint a queued episode.
|
||||
/// </summary>
|
||||
/// <param name="episode">Queued episode to fingerprint.</param>
|
||||
/// <param name="mode">Portion of media file to fingerprint. Introduction = first 25% / 10 minutes and Credits = last 4 minutes.</param>
|
||||
/// <returns>Numerical fingerprint points.</returns>
|
||||
public static uint[] Fingerprint(QueuedEpisode episode)
|
||||
public static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode)
|
||||
{
|
||||
int start, end;
|
||||
|
||||
if (mode == AnalysisMode.Introduction)
|
||||
{
|
||||
start = 0;
|
||||
end = episode.IntroFingerprintEnd;
|
||||
}
|
||||
else if (mode == AnalysisMode.Credits)
|
||||
{
|
||||
start = episode.CreditsFingerprintStart;
|
||||
end = episode.Duration;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException("Unknown analysis mode " + mode.ToString());
|
||||
}
|
||||
|
||||
return Fingerprint(episode, mode, start, end);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprint a queued episode.
|
||||
/// </summary>
|
||||
/// <param name="episode">Queued episode to fingerprint.</param>
|
||||
/// <param name="mode">Portion of media file to fingerprint.</param>
|
||||
/// <param name="start">Time (in seconds) relative to the start of the file to start fingerprinting from.</param>
|
||||
/// <param name="end">Time (in seconds) relative to the start of the file to stop fingerprinting at.</param>
|
||||
/// <returns>Numerical fingerprint points.</returns>
|
||||
private static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode, int start, int end)
|
||||
{
|
||||
// Try to load this episode from cache before running ffmpeg.
|
||||
if (LoadCachedFingerprint(episode, out uint[] cachedFingerprint))
|
||||
if (LoadCachedFingerprint(episode, mode, out uint[] cachedFingerprint))
|
||||
{
|
||||
Logger?.LogTrace("Fingerprint cache hit on {File}", episode.Path);
|
||||
return cachedFingerprint;
|
||||
}
|
||||
|
||||
Logger?.LogDebug(
|
||||
"Fingerprinting {Duration} seconds from \"{File}\" (id {Id})",
|
||||
episode.FingerprintDuration,
|
||||
"Fingerprinting [{Start}, {End}] from \"{File}\" (id {Id})",
|
||||
start,
|
||||
end,
|
||||
episode.Path,
|
||||
episode.EpisodeId);
|
||||
|
||||
var args = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-i \"{0}\" -to {1} -ac 2 -f chromaprint -fp_format raw -",
|
||||
"-ss {0} -i \"{1}\" -to {2} -ac 2 -f chromaprint -fp_format raw -",
|
||||
start,
|
||||
episode.Path,
|
||||
episode.FingerprintDuration);
|
||||
end - start);
|
||||
|
||||
// Returns all fingerprint points as raw 32 bit unsigned integers (little endian).
|
||||
var rawPoints = GetOutput(args, string.Empty);
|
||||
@ -172,7 +205,7 @@ public static class FFmpegWrapper
|
||||
}
|
||||
|
||||
// Try to cache this fingerprint.
|
||||
CacheFingerprint(episode, results);
|
||||
CacheFingerprint(episode, mode, results);
|
||||
|
||||
return results.ToArray();
|
||||
}
|
||||
@ -226,9 +259,6 @@ public static class FFmpegWrapper
|
||||
limit,
|
||||
episode.EpisodeId);
|
||||
|
||||
// TODO: select the audio track that matches the user's preferred language, falling
|
||||
// back to the first track if nothing matches
|
||||
|
||||
// -vn, -sn, -dn: ignore video, subtitle, and data tracks
|
||||
var args = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
@ -367,9 +397,13 @@ public static class FFmpegWrapper
|
||||
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
|
||||
/// </summary>
|
||||
/// <param name="episode">Episode to try to load from cache.</param>
|
||||
/// <param name="mode">Analysis mode.</param>
|
||||
/// <param name="fingerprint">Array to store the fingerprint in.</param>
|
||||
/// <returns>true if the episode was successfully loaded from cache, false on any other error.</returns>
|
||||
private static bool LoadCachedFingerprint(QueuedEpisode episode, out uint[] fingerprint)
|
||||
private static bool LoadCachedFingerprint(
|
||||
QueuedEpisode episode,
|
||||
AnalysisMode mode,
|
||||
out uint[] fingerprint)
|
||||
{
|
||||
fingerprint = Array.Empty<uint>();
|
||||
|
||||
@ -379,7 +413,7 @@ public static class FFmpegWrapper
|
||||
return false;
|
||||
}
|
||||
|
||||
var path = GetFingerprintCachePath(episode);
|
||||
var path = GetFingerprintCachePath(episode, mode);
|
||||
|
||||
// If this episode isn't cached, bail out.
|
||||
if (!File.Exists(path))
|
||||
@ -387,7 +421,6 @@ public static class FFmpegWrapper
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: make async
|
||||
var raw = File.ReadAllLines(path, Encoding.UTF8);
|
||||
var result = new List<uint>();
|
||||
|
||||
@ -421,8 +454,12 @@ public static class FFmpegWrapper
|
||||
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
|
||||
/// </summary>
|
||||
/// <param name="episode">Episode to store in cache.</param>
|
||||
/// <param name="mode">Analysis mode.</param>
|
||||
/// <param name="fingerprint">Fingerprint of the episode to store.</param>
|
||||
private static void CacheFingerprint(QueuedEpisode episode, List<uint> fingerprint)
|
||||
private static void CacheFingerprint(
|
||||
QueuedEpisode episode,
|
||||
AnalysisMode mode,
|
||||
List<uint> fingerprint)
|
||||
{
|
||||
// Bail out if caching isn't enabled.
|
||||
if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false))
|
||||
@ -438,7 +475,10 @@ public static class FFmpegWrapper
|
||||
}
|
||||
|
||||
// Cache the episode.
|
||||
File.WriteAllLinesAsync(GetFingerprintCachePath(episode), lines, Encoding.UTF8).ConfigureAwait(false);
|
||||
File.WriteAllLinesAsync(
|
||||
GetFingerprintCachePath(episode, mode),
|
||||
lines,
|
||||
Encoding.UTF8).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -446,9 +486,25 @@ public static class FFmpegWrapper
|
||||
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
|
||||
/// </summary>
|
||||
/// <param name="episode">Episode.</param>
|
||||
private static string GetFingerprintCachePath(QueuedEpisode episode)
|
||||
/// <param name="mode">Analysis mode.</param>
|
||||
private static string GetFingerprintCachePath(QueuedEpisode episode, AnalysisMode mode)
|
||||
{
|
||||
return Path.Join(Plugin.Instance!.FingerprintCachePath, episode.EpisodeId.ToString("N"));
|
||||
var basePath = Path.Join(
|
||||
Plugin.Instance!.FingerprintCachePath,
|
||||
episode.EpisodeId.ToString("N"));
|
||||
|
||||
if (mode == AnalysisMode.Introduction)
|
||||
{
|
||||
return basePath;
|
||||
}
|
||||
else if (mode == AnalysisMode.Credits)
|
||||
{
|
||||
return basePath + "-credits";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException("Unknown analysis mode " + mode.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -6,6 +6,7 @@ using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
@ -24,6 +25,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
private ILibraryManager _libraryManager;
|
||||
private ILogger<Plugin> _logger;
|
||||
private string _introPath;
|
||||
private string _creditsPath; // TODO: FIXME: remove this
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
@ -51,6 +53,9 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
|
||||
_introPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml");
|
||||
|
||||
// TODO: FIXME: remove this
|
||||
_creditsPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "credits.csv");
|
||||
|
||||
// Create the base & cache directories (if needed).
|
||||
if (!Directory.Exists(FingerprintCachePath))
|
||||
{
|
||||
@ -68,6 +73,12 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
_logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex);
|
||||
}
|
||||
|
||||
// TODO: FIXME: remove this
|
||||
if (File.Exists(_creditsPath))
|
||||
{
|
||||
File.Delete(_creditsPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -163,16 +174,52 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
return GetItem(id).Path;
|
||||
}
|
||||
|
||||
internal void UpdateTimestamps(Dictionary<Guid, Intro> newIntros)
|
||||
internal void UpdateTimestamps(Dictionary<Guid, Intro> newIntros, AnalysisMode mode)
|
||||
{
|
||||
lock (_introsLock)
|
||||
switch (mode)
|
||||
{
|
||||
foreach (var intro in newIntros)
|
||||
{
|
||||
Plugin.Instance!.Intros[intro.Key] = intro.Value;
|
||||
}
|
||||
case AnalysisMode.Introduction:
|
||||
lock (_introsLock)
|
||||
{
|
||||
foreach (var intro in newIntros)
|
||||
{
|
||||
Plugin.Instance!.Intros[intro.Key] = intro.Value;
|
||||
}
|
||||
|
||||
Plugin.Instance!.SaveTimestamps();
|
||||
Plugin.Instance!.SaveTimestamps();
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case AnalysisMode.Credits:
|
||||
// TODO: FIXME: implement properly
|
||||
|
||||
lock (_introsLock)
|
||||
{
|
||||
foreach (var credit in newIntros)
|
||||
{
|
||||
var item = GetItem(credit.Value.EpisodeId) as Episode;
|
||||
if (item is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Format: series, season number, episode number, title, start, end
|
||||
var contents = string.Format(
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
"{0},{1},{2},{3},{4},{5}\n",
|
||||
item.SeriesName.Replace(",", string.Empty, StringComparison.Ordinal),
|
||||
item.AiredSeasonNumber ?? 0,
|
||||
item.IndexNumber ?? 0,
|
||||
item.Name.Replace(",", string.Empty, StringComparison.Ordinal),
|
||||
Math.Round(credit.Value.IntroStart, 2),
|
||||
Math.Round(credit.Value.IntroEnd, 2));
|
||||
|
||||
File.AppendAllText(_creditsPath, contents);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -186,17 +186,22 @@ public class QueueManager
|
||||
// Limit analysis to the first X% of the episode and at most Y minutes.
|
||||
// X and Y default to 25% and 10 minutes.
|
||||
var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
|
||||
if (duration >= 5 * 60)
|
||||
var fingerprintDuration = duration;
|
||||
|
||||
if (fingerprintDuration >= 5 * 60)
|
||||
{
|
||||
duration *= analysisPercent;
|
||||
fingerprintDuration *= analysisPercent;
|
||||
}
|
||||
|
||||
duration = Math.Min(duration, 60 * Plugin.Instance!.Configuration.AnalysisLengthLimit);
|
||||
fingerprintDuration = Math.Min(
|
||||
fingerprintDuration,
|
||||
60 * Plugin.Instance!.Configuration.AnalysisLengthLimit);
|
||||
|
||||
// Allocate a new list for each new season
|
||||
Plugin.Instance!.AnalysisQueue.TryAdd(episode.SeasonId, new List<QueuedEpisode>());
|
||||
|
||||
// Queue the episode for analysis
|
||||
var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumEpisodeCreditsDuration * 60;
|
||||
Plugin.Instance.AnalysisQueue[episode.SeasonId].Add(new QueuedEpisode()
|
||||
{
|
||||
SeriesName = episode.SeriesName,
|
||||
@ -204,7 +209,9 @@ public class QueueManager
|
||||
EpisodeId = episode.Id,
|
||||
Name = episode.Name,
|
||||
Path = episode.Path,
|
||||
FingerprintDuration = Convert.ToInt32(duration)
|
||||
Duration = Convert.ToInt32(duration),
|
||||
IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration),
|
||||
CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration),
|
||||
});
|
||||
|
||||
Plugin.Instance!.TotalQueued++;
|
||||
|
@ -12,6 +12,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
|
||||
/// <summary>
|
||||
/// Analyze all television episodes for introduction sequences.
|
||||
/// TODO: FIXME: rename task and file to DetectIntroductionsTask.
|
||||
/// </summary>
|
||||
public class AnalyzeEpisodesTask : IScheduledTask
|
||||
{
|
||||
@ -178,6 +179,7 @@ public class AnalyzeEpisodesTask : IScheduledTask
|
||||
|
||||
/// <summary>
|
||||
/// Verify that all episodes in a season exist in Jellyfin and as a file in storage.
|
||||
/// TODO: FIXME: move to queue manager.
|
||||
/// </summary>
|
||||
/// <param name="candidates">QueuedEpisodes.</param>
|
||||
/// <returns>Verified QueuedEpisodes and a flag indicating if any episode in this season has not been analyzed yet.</returns>
|
||||
|
@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
|
||||
#if !DEBUG
|
||||
#error Fix all FIXMEs introduced during initial credit implementation before release
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Analyze all television episodes for credits.
|
||||
/// </summary>
|
||||
public class DetectCreditsTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<DetectCreditsTask> _logger;
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
private readonly ILibraryManager? _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DetectCreditsTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
public DetectCreditsTask(
|
||||
ILoggerFactory loggerFactory,
|
||||
ILibraryManager libraryManager) : this(loggerFactory)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DetectCreditsTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory.</param>
|
||||
public DetectCreditsTask(ILoggerFactory loggerFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<DetectCreditsTask>();
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task name.
|
||||
/// </summary>
|
||||
public string Name => "Detect Credits";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task category.
|
||||
/// </summary>
|
||||
public string Category => "Intro Skipper";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task description.
|
||||
/// </summary>
|
||||
public string Description => "Analyzes the audio and video of all television episodes to find credits.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task key.
|
||||
/// </summary>
|
||||
public string Key => "CPBIntroSkipperDetectCredits";
|
||||
|
||||
/// <summary>
|
||||
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
|
||||
/// </summary>
|
||||
/// <param name="progress">Task progress.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
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(
|
||||
_loggerFactory.CreateLogger<QueueManager>(),
|
||||
_libraryManager);
|
||||
|
||||
queueManager.EnqueueAllEpisodes();
|
||||
|
||||
var queue = Plugin.Instance!.AnalysisQueue;
|
||||
|
||||
if (queue.Count == 0)
|
||||
{
|
||||
throw new FingerprintException(
|
||||
"No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly.");
|
||||
}
|
||||
|
||||
var totalProcessed = 0;
|
||||
var options = new ParallelOptions()
|
||||
{
|
||||
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
|
||||
};
|
||||
|
||||
// TODO: FIXME: if the queue is modified while the task is running, the task will fail.
|
||||
// clone the queue before running the task to prevent this.
|
||||
|
||||
// Analyze all episodes in the queue using the degrees of parallelism the user specified.
|
||||
Parallel.ForEach(queue, options, (season) =>
|
||||
{
|
||||
// TODO: FIXME: use VerifyEpisodes
|
||||
var episodes = season.Value.AsReadOnly();
|
||||
if (episodes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var first = episodes[0];
|
||||
|
||||
try
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 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).
|
||||
var analyzed = AnalyzeSeason(episodes, cancellationToken);
|
||||
Interlocked.Add(ref totalProcessed, analyzed);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued);
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprints all episodes in the provided season and stores the timestamps of all introductions.
|
||||
/// </summary>
|
||||
/// <param name="episodes">Episodes in this season.</param>
|
||||
/// <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(
|
||||
ReadOnlyCollection<QueuedEpisode> episodes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Skip seasons with an insufficient number of episodes.
|
||||
if (episodes.Count <= 1)
|
||||
{
|
||||
return episodes.Count;
|
||||
}
|
||||
|
||||
// Only analyze specials (season 0) if the user has opted in.
|
||||
var first = episodes[0];
|
||||
if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Analyzing {Count} episodes from {Name} season {Season}",
|
||||
episodes.Count,
|
||||
first.SeriesName,
|
||||
first.SeasonNumber);
|
||||
|
||||
// Analyze the season with Chromaprint
|
||||
var chromaprint = new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>());
|
||||
chromaprint.AnalyzeMediaFiles(episodes, AnalysisMode.Credits, cancellationToken);
|
||||
|
||||
return episodes.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get task triggers.
|
||||
/// </summary>
|
||||
/// <returns>Task triggers.</returns>
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
return Array.Empty<TaskTriggerInfo>();
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
<img alt="Plugin Banner" src="https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/master/images/logo.png" />
|
||||
</div>
|
||||
|
||||
Analyzes the audio of television episodes to detect and skip over intros.
|
||||
Analyzes the audio of television episodes to detect and skip over introductions and ending credits.
|
||||
|
||||
If you use the custom web interface on your server, you will be able to click a button to skip intros, like this:
|
||||
|
||||
@ -20,13 +20,15 @@ However, if you want to use an unmodified installation of Jellyfin 10.8.z or use
|
||||
* `linuxserver/jellyfin` 10.8.z container: preinstalled
|
||||
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg5` package
|
||||
|
||||
## Introduction requirements
|
||||
## Introduction and end credit requirements
|
||||
|
||||
Show introductions will only be detected if they are:
|
||||
|
||||
* Located within the first 25% of an episode, or the first 10 minutes, whichever is smaller
|
||||
* Between 15 seconds and 2 minutes long
|
||||
|
||||
Ending credits will only be detected if they are shorter than 4 minutes.
|
||||
|
||||
All of these requirements can be customized as needed.
|
||||
|
||||
## Installation instructions
|
||||
|
Loading…
x
Reference in New Issue
Block a user