Add initial end credits detection code
This commit is contained in:
parent
ce52a0b979
commit
61178832c1
@ -1,6 +1,8 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v0.1.8.0 (no eta)
|
## v0.1.8.0 (no eta)
|
||||||
|
* New features
|
||||||
|
* Detect ending credits in television episodes
|
||||||
* Internal changes
|
* Internal changes
|
||||||
* Move Chromaprint analysis code out of the episode analysis task
|
* Move Chromaprint analysis code out of the episode analysis task
|
||||||
* Add support for multiple analysis techinques
|
* Add support for multiple analysis techinques
|
||||||
|
@ -59,7 +59,9 @@ public class TestAudioFingerprinting
|
|||||||
3472417825, 3395841056, 3458735136, 3341420624, 1076496560, 1076501168, 1076501136, 1076497024
|
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);
|
Assert.Equal(expected, actual);
|
||||||
}
|
}
|
||||||
@ -91,8 +93,8 @@ public class TestAudioFingerprinting
|
|||||||
|
|
||||||
var lhsEpisode = queueEpisode("audio/big_buck_bunny_intro.mp3");
|
var lhsEpisode = queueEpisode("audio/big_buck_bunny_intro.mp3");
|
||||||
var rhsEpisode = queueEpisode("audio/big_buck_bunny_clip.mp3");
|
var rhsEpisode = queueEpisode("audio/big_buck_bunny_clip.mp3");
|
||||||
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode);
|
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction);
|
||||||
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode);
|
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction);
|
||||||
|
|
||||||
var (lhs, rhs) = chromaprint.CompareEpisodes(
|
var (lhs, rhs) = chromaprint.CompareEpisodes(
|
||||||
lhsEpisode.EpisodeId,
|
lhsEpisode.EpisodeId,
|
||||||
@ -138,7 +140,7 @@ public class TestAudioFingerprinting
|
|||||||
{
|
{
|
||||||
EpisodeId = Guid.NewGuid(),
|
EpisodeId = Guid.NewGuid(),
|
||||||
Path = "../../../" + path,
|
Path = "../../../" + path,
|
||||||
FingerprintDuration = 60
|
IntroFingerprintEnd = 60
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,6 +30,8 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
|||||||
|
|
||||||
private ILogger<ChromaprintAnalyzer> _logger;
|
private ILogger<ChromaprintAnalyzer> _logger;
|
||||||
|
|
||||||
|
private AnalysisMode _analysisMode;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
|
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -64,12 +66,14 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
|||||||
// Episodes that were analyzed and do not have an introduction.
|
// Episodes that were analyzed and do not have an introduction.
|
||||||
var episodesWithoutIntros = new List<QueuedEpisode>();
|
var episodesWithoutIntros = new List<QueuedEpisode>();
|
||||||
|
|
||||||
|
this._analysisMode = mode;
|
||||||
|
|
||||||
// Compute fingerprints for all episodes in the season
|
// Compute fingerprints for all episodes in the season
|
||||||
foreach (var episode in episodeAnalysisQueue)
|
foreach (var episode in episodeAnalysisQueue)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode);
|
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode);
|
||||||
|
|
||||||
if (cancellationToken.IsCancellationRequested)
|
if (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@ -78,6 +82,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
|||||||
}
|
}
|
||||||
catch (FingerprintException ex)
|
catch (FingerprintException ex)
|
||||||
{
|
{
|
||||||
|
// TODO: FIXME: move to debug level?
|
||||||
_logger.LogWarning("Caught fingerprint error: {Ex}", ex);
|
_logger.LogWarning("Caught fingerprint error: {Ex}", ex);
|
||||||
|
|
||||||
// Fallback to an empty fingerprint on any error
|
// Fallback to an empty fingerprint on any error
|
||||||
@ -112,6 +117,22 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
|||||||
continue;
|
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:
|
// Only save the discovered intro if it is:
|
||||||
// - the first intro discovered for this episode
|
// - the first intro discovered for this episode
|
||||||
// - longer than the previously discovered intro
|
// - longer than the previously discovered intro
|
||||||
@ -142,10 +163,13 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
|||||||
return analysisQueue;
|
return analysisQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust all introduction end times so that they end at silence.
|
if (this._analysisMode == AnalysisMode.Introduction)
|
||||||
seasonIntros = AdjustIntroEndTimes(analysisQueue, seasonIntros);
|
{
|
||||||
|
// 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();
|
return episodesWithoutIntros.AsReadOnly();
|
||||||
}
|
}
|
||||||
@ -338,16 +362,20 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
|||||||
// Since LHS had a contiguous time range, RHS must have one also.
|
// Since LHS had a contiguous time range, RHS must have one also.
|
||||||
var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), maximumTimeSkip)!;
|
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 (this._analysisMode == AnalysisMode.Introduction)
|
||||||
if (lContiguous.Duration >= 90)
|
|
||||||
{
|
{
|
||||||
lContiguous.End -= 2 * maximumTimeSkip;
|
// Tweak the end timestamps just a bit to ensure as little content as possible is skipped over.
|
||||||
rContiguous.End -= 2 * maximumTimeSkip;
|
// TODO: remove this
|
||||||
}
|
if (lContiguous.Duration >= 90)
|
||||||
else if (lContiguous.Duration >= 30)
|
{
|
||||||
{
|
lContiguous.End -= 2 * maximumTimeSkip;
|
||||||
lContiguous.End -= maximumTimeSkip;
|
rContiguous.End -= 2 * maximumTimeSkip;
|
||||||
rContiguous.End -= maximumTimeSkip;
|
}
|
||||||
|
else if (lContiguous.Duration >= 30)
|
||||||
|
{
|
||||||
|
lContiguous.End -= maximumTimeSkip;
|
||||||
|
rContiguous.End -= maximumTimeSkip;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (lContiguous, rContiguous);
|
return (lContiguous, rContiguous);
|
||||||
|
@ -72,6 +72,11 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int MaximumIntroDuration { get; set; } = 120;
|
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 =====
|
// ===== Playback settings =====
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -113,7 +113,7 @@ public class VisualizationController : ControllerBase
|
|||||||
{
|
{
|
||||||
if (needle.EpisodeId == id)
|
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;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </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.
|
/// Fingerprint a queued episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="episode">Queued episode to fingerprint.</param>
|
/// <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>
|
/// <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.
|
// 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);
|
Logger?.LogTrace("Fingerprint cache hit on {File}", episode.Path);
|
||||||
return cachedFingerprint;
|
return cachedFingerprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger?.LogDebug(
|
Logger?.LogDebug(
|
||||||
"Fingerprinting {Duration} seconds from \"{File}\" (id {Id})",
|
"Fingerprinting [{Start}, {End}] from \"{File}\" (id {Id})",
|
||||||
episode.FingerprintDuration,
|
start,
|
||||||
|
end,
|
||||||
episode.Path,
|
episode.Path,
|
||||||
episode.EpisodeId);
|
episode.EpisodeId);
|
||||||
|
|
||||||
var args = string.Format(
|
var args = string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
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.Path,
|
||||||
episode.FingerprintDuration);
|
end - start);
|
||||||
|
|
||||||
// Returns all fingerprint points as raw 32 bit unsigned integers (little endian).
|
// Returns all fingerprint points as raw 32 bit unsigned integers (little endian).
|
||||||
var rawPoints = GetOutput(args, string.Empty);
|
var rawPoints = GetOutput(args, string.Empty);
|
||||||
@ -172,7 +205,7 @@ public static class FFmpegWrapper
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to cache this fingerprint.
|
// Try to cache this fingerprint.
|
||||||
CacheFingerprint(episode, results);
|
CacheFingerprint(episode, mode, results);
|
||||||
|
|
||||||
return results.ToArray();
|
return results.ToArray();
|
||||||
}
|
}
|
||||||
@ -226,9 +259,6 @@ public static class FFmpegWrapper
|
|||||||
limit,
|
limit,
|
||||||
episode.EpisodeId);
|
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
|
// -vn, -sn, -dn: ignore video, subtitle, and data tracks
|
||||||
var args = string.Format(
|
var args = string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
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).
|
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="episode">Episode to try to load from cache.</param>
|
/// <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>
|
/// <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>
|
/// <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>();
|
fingerprint = Array.Empty<uint>();
|
||||||
|
|
||||||
@ -379,7 +413,7 @@ public static class FFmpegWrapper
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var path = GetFingerprintCachePath(episode);
|
var path = GetFingerprintCachePath(episode, mode);
|
||||||
|
|
||||||
// If this episode isn't cached, bail out.
|
// If this episode isn't cached, bail out.
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
@ -387,7 +421,6 @@ public static class FFmpegWrapper
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: make async
|
|
||||||
var raw = File.ReadAllLines(path, Encoding.UTF8);
|
var raw = File.ReadAllLines(path, Encoding.UTF8);
|
||||||
var result = new List<uint>();
|
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).
|
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="episode">Episode to store in cache.</param>
|
/// <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>
|
/// <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.
|
// Bail out if caching isn't enabled.
|
||||||
if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false))
|
if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false))
|
||||||
@ -438,7 +475,10 @@ public static class FFmpegWrapper
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache the episode.
|
// Cache the episode.
|
||||||
File.WriteAllLinesAsync(GetFingerprintCachePath(episode), lines, Encoding.UTF8).ConfigureAwait(false);
|
File.WriteAllLinesAsync(
|
||||||
|
GetFingerprintCachePath(episode, mode),
|
||||||
|
lines,
|
||||||
|
Encoding.UTF8).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -446,9 +486,25 @@ public static class FFmpegWrapper
|
|||||||
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
|
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="episode">Episode.</param>
|
/// <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>
|
/// <summary>
|
||||||
|
@ -6,6 +6,7 @@ using MediaBrowser.Common.Configuration;
|
|||||||
using MediaBrowser.Common.Plugins;
|
using MediaBrowser.Common.Plugins;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Model.Plugins;
|
using MediaBrowser.Model.Plugins;
|
||||||
using MediaBrowser.Model.Serialization;
|
using MediaBrowser.Model.Serialization;
|
||||||
@ -24,6 +25,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
private ILibraryManager _libraryManager;
|
private ILibraryManager _libraryManager;
|
||||||
private ILogger<Plugin> _logger;
|
private ILogger<Plugin> _logger;
|
||||||
private string _introPath;
|
private string _introPath;
|
||||||
|
private string _creditsPath; // TODO: FIXME: remove this
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||||
@ -51,6 +53,9 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
|
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
|
||||||
_introPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml");
|
_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).
|
// Create the base & cache directories (if needed).
|
||||||
if (!Directory.Exists(FingerprintCachePath))
|
if (!Directory.Exists(FingerprintCachePath))
|
||||||
{
|
{
|
||||||
@ -68,6 +73,12 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
{
|
{
|
||||||
_logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex);
|
_logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: FIXME: remove this
|
||||||
|
if (File.Exists(_creditsPath))
|
||||||
|
{
|
||||||
|
File.Delete(_creditsPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -163,16 +174,52 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
return GetItem(id).Path;
|
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)
|
case AnalysisMode.Introduction:
|
||||||
{
|
lock (_introsLock)
|
||||||
Plugin.Instance!.Intros[intro.Key] = intro.Value;
|
{
|
||||||
}
|
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.
|
// Limit analysis to the first X% of the episode and at most Y minutes.
|
||||||
// X and Y default to 25% and 10 minutes.
|
// X and Y default to 25% and 10 minutes.
|
||||||
var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
|
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
|
// Allocate a new list for each new season
|
||||||
Plugin.Instance!.AnalysisQueue.TryAdd(episode.SeasonId, new List<QueuedEpisode>());
|
Plugin.Instance!.AnalysisQueue.TryAdd(episode.SeasonId, new List<QueuedEpisode>());
|
||||||
|
|
||||||
// Queue the episode for analysis
|
// Queue the episode for analysis
|
||||||
|
var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumEpisodeCreditsDuration * 60;
|
||||||
Plugin.Instance.AnalysisQueue[episode.SeasonId].Add(new QueuedEpisode()
|
Plugin.Instance.AnalysisQueue[episode.SeasonId].Add(new QueuedEpisode()
|
||||||
{
|
{
|
||||||
SeriesName = episode.SeriesName,
|
SeriesName = episode.SeriesName,
|
||||||
@ -204,7 +209,9 @@ public class QueueManager
|
|||||||
EpisodeId = episode.Id,
|
EpisodeId = episode.Id,
|
||||||
Name = episode.Name,
|
Name = episode.Name,
|
||||||
Path = episode.Path,
|
Path = episode.Path,
|
||||||
FingerprintDuration = Convert.ToInt32(duration)
|
Duration = Convert.ToInt32(duration),
|
||||||
|
IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration),
|
||||||
|
CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration),
|
||||||
});
|
});
|
||||||
|
|
||||||
Plugin.Instance!.TotalQueued++;
|
Plugin.Instance!.TotalQueued++;
|
||||||
|
@ -12,6 +12,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Analyze all television episodes for introduction sequences.
|
/// Analyze all television episodes for introduction sequences.
|
||||||
|
/// TODO: FIXME: rename task and file to DetectIntroductionsTask.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AnalyzeEpisodesTask : IScheduledTask
|
public class AnalyzeEpisodesTask : IScheduledTask
|
||||||
{
|
{
|
||||||
@ -178,6 +179,7 @@ public class AnalyzeEpisodesTask : IScheduledTask
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verify that all episodes in a season exist in Jellyfin and as a file in storage.
|
/// Verify that all episodes in a season exist in Jellyfin and as a file in storage.
|
||||||
|
/// TODO: FIXME: move to queue manager.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="candidates">QueuedEpisodes.</param>
|
/// <param name="candidates">QueuedEpisodes.</param>
|
||||||
/// <returns>Verified QueuedEpisodes and a flag indicating if any episode in this season has not been analyzed yet.</returns>
|
/// <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" />
|
<img alt="Plugin Banner" src="https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/master/images/logo.png" />
|
||||||
</div>
|
</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:
|
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
|
* `linuxserver/jellyfin` 10.8.z container: preinstalled
|
||||||
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg5` package
|
* 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:
|
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
|
* Located within the first 25% of an episode, or the first 10 minutes, whichever is smaller
|
||||||
* Between 15 seconds and 2 minutes long
|
* 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.
|
All of these requirements can be customized as needed.
|
||||||
|
|
||||||
## Installation instructions
|
## Installation instructions
|
||||||
|
Loading…
x
Reference in New Issue
Block a user