Add initial end credits detection code

This commit is contained in:
ConfusedPolarBear 2022-10-31 01:00:39 -05:00
parent ce52a0b979
commit 61178832c1
12 changed files with 408 additions and 50 deletions

View File

@ -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

View File

@ -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
};
}

View File

@ -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);

View File

@ -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>

View File

@ -113,7 +113,7 @@ public class VisualizationController : ControllerBase
{
if (needle.EpisodeId == id)
{
return FFmpegWrapper.Fingerprint(needle);
return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction);
}
}
}

View File

@ -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; }
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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++;

View File

@ -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>

View File

@ -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>();
}
}

View File

@ -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