Merge branch 'analyzers'

This commit is contained in:
ConfusedPolarBear 2023-02-07 23:50:28 -06:00
commit d540f7e70e
26 changed files with 1434 additions and 499 deletions

View File

@ -1,6 +1,12 @@
# Changelog # Changelog
## v0.1.8.0 (no eta) ## v0.1.8.0 (no eta)
* New features
* Support adding skip intro button to web interface without using a fork
* Add localization support for the skip intro button and the automatic skip notification message
* Detect ending credits in television episodes
* Add support for using chapter names to locate introductions and ending credits
* Add support for using black frames to locate ending credits
* 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

View File

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

View File

@ -0,0 +1,72 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using Xunit;
public class TestBlackFrames
{
[FactSkipFFmpegTests]
public void TestBlackFrameDetection()
{
var range = 1e-5;
var expected = new List<BlackFrame>();
expected.AddRange(CreateFrameSequence(2, 3));
expected.AddRange(CreateFrameSequence(5, 6));
expected.AddRange(CreateFrameSequence(8, 9.96));
var actual = FFmpegWrapper.DetectBlackFrames(queueFile("rainbow.mp4"), new(0, 10), 85);
for (var i = 0; i < expected.Count; i++)
{
var (e, a) = (expected[i], actual[i]);
Assert.Equal(e.Percentage, a.Percentage);
Assert.InRange(a.Time, e.Time - range, e.Time + range);
}
}
[FactSkipFFmpegTests]
public void TestEndCreditDetection()
{
var range = 1;
var analyzer = CreateBlackFrameAnalyzer();
var episode = queueFile("credits.mp4");
episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds;
var result = analyzer.AnalyzeMediaFile(episode, AnalysisMode.Credits, 85);
Assert.NotNull(result);
Assert.InRange(result.IntroStart, 300 - range, 300 + range);
}
private QueuedEpisode queueFile(string path)
{
return new()
{
EpisodeId = Guid.NewGuid(),
Name = path,
Path = "../../../video/" + path
};
}
private BlackFrame[] CreateFrameSequence(double start, double end)
{
var frames = new List<BlackFrame>();
for (var i = start; i < end; i += 0.04)
{
frames.Add(new(100, i));
}
return frames.ToArray();
}
private BlackFrameAnalyzer CreateBlackFrameAnalyzer()
{
var logger = new LoggerFactory().CreateLogger<BlackFrameAnalyzer>();
return new(logger);
}
}

View File

@ -0,0 +1,83 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
using Xunit;
public class TestChapterAnalyzer
{
[Theory]
[InlineData("Opening")]
[InlineData("OP")]
[InlineData("Intro")]
[InlineData("Intro Start")]
[InlineData("Introduction")]
public void TestIntroductionExpression(string chapterName)
{
var chapters = CreateChapters(chapterName, AnalysisMode.Introduction);
var introChapter = FindChapter(chapters, AnalysisMode.Introduction);
Assert.NotNull(introChapter);
Assert.Equal(60, introChapter.IntroStart);
Assert.Equal(90, introChapter.IntroEnd);
}
[Theory]
[InlineData("End Credits")]
[InlineData("Ending")]
[InlineData("Credit start")]
[InlineData("Closing Credits")]
[InlineData("Credits")]
public void TestEndCreditsExpression(string chapterName)
{
var chapters = CreateChapters(chapterName, AnalysisMode.Credits);
var creditsChapter = FindChapter(chapters, AnalysisMode.Credits);
Assert.NotNull(creditsChapter);
Assert.Equal(1890, creditsChapter.IntroStart);
Assert.Equal(2000, creditsChapter.IntroEnd);
}
private Intro? FindChapter(Collection<ChapterInfo> chapters, AnalysisMode mode)
{
var logger = new LoggerFactory().CreateLogger<ChapterAnalyzer>();
var analyzer = new ChapterAnalyzer(logger);
var config = new Configuration.PluginConfiguration();
var expression = mode == AnalysisMode.Introduction ?
config.ChapterAnalyzerIntroductionPattern :
config.ChapterAnalyzerEndCreditsPattern;
return analyzer.FindMatchingChapter(new() { Duration = 2000 }, chapters, expression, mode);
}
private Collection<ChapterInfo> CreateChapters(string name, AnalysisMode mode)
{
var chapters = new[]{
CreateChapter("Cold Open", 0),
CreateChapter(mode == AnalysisMode.Introduction ? name : "Introduction", 60),
CreateChapter("Main Episode", 90),
CreateChapter(mode == AnalysisMode.Credits ? name : "Credits", 1890)
};
return new(new List<ChapterInfo>(chapters));
}
/// <summary>
/// Create a ChapterInfo object.
/// </summary>
/// <param name="name">Chapter name.</param>
/// <param name="position">Chapter position (in seconds).</param>
/// <returns>ChapterInfo.</returns>
private ChapterInfo CreateChapter(string name, int position)
{
return new()
{
Name = name,
StartPositionTicks = TimeSpan.FromSeconds(position).Ticks
};
}
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec"
"strings" "strings"
"time" "time"
@ -77,7 +78,6 @@ func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamp
fmt.Println() fmt.Println()
fmt.Println("[+] Saving report") fmt.Println("[+] Saving report")
// TODO: also save analysis statistics
// Store timing data, server information, and plugin configuration // Store timing data, server information, and plugin configuration
report.StartedAt = start report.StartedAt = start
report.FinishedAt = time.Now() report.FinishedAt = time.Now()
@ -95,6 +95,9 @@ func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamp
panic(err) panic(err)
} }
// Change report permissions
exec.Command("chown", "1000:1000", reportDestination).Run()
fmt.Println("[+] Done") fmt.Println("[+] Done")
} }
@ -110,11 +113,13 @@ func runAnalysisAndWait(hostAddress, apiKey string, pollInterval time.Duration)
SendRequest("POST", hostAddress+"/Intros/EraseTimestamps", apiKey) SendRequest("POST", hostAddress+"/Intros/EraseTimestamps", apiKey)
fmt.Println() fmt.Println()
// The task ID changed with v0.1.7. var taskIds = []string{
// Old task ID: 8863329048cc357f7dfebf080f2fe204 "f64d8ad58e3d7b98548e1a07697eb100", // v0.1.8
// New task ID: 6adda26c5261c40e8fa4a7e7df568be2 "8863329048cc357f7dfebf080f2fe204",
"6adda26c5261c40e8fa4a7e7df568be2"}
fmt.Println("[+] Starting analysis task") fmt.Println("[+] Starting analysis task")
for _, id := range []string{"8863329048cc357f7dfebf080f2fe204", "6adda26c5261c40e8fa4a7e7df568be2"} { for _, id := range taskIds {
body := SendRequest("POST", hostAddress+"/ScheduledTasks/Running/"+id, apiKey) body := SendRequest("POST", hostAddress+"/ScheduledTasks/Running/"+id, apiKey)
fmt.Println() fmt.Println()

View File

@ -0,0 +1,131 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using Microsoft.Extensions.Logging;
/// <summary>
/// Media file analyzer used to detect end credits that consist of text overlaid on a black background.
/// Bisects the end of the video file to perform an efficient search.
/// </summary>
public class BlackFrameAnalyzer : IMediaFileAnalyzer
{
private readonly TimeSpan _maximumError = new(0, 0, 4);
private readonly ILogger<BlackFrameAnalyzer> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="BlackFrameAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger)
{
_logger = logger;
}
/// <inheritdoc />
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
if (mode != AnalysisMode.Credits)
{
throw new NotImplementedException("mode must equal Credits");
}
var creditTimes = new Dictionary<Guid, Intro>();
foreach (var episode in analysisQueue)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var intro = AnalyzeMediaFile(
episode,
mode,
Plugin.Instance!.Configuration.BlackFrameMinimumPercentage);
if (intro is null)
{
continue;
}
creditTimes[episode.EpisodeId] = intro;
}
Plugin.Instance!.UpdateTimestamps(creditTimes, mode);
return analysisQueue
.Where(x => !creditTimes.ContainsKey(x.EpisodeId))
.ToList()
.AsReadOnly();
}
/// <summary>
/// Analyzes an individual media file. Only public because of unit tests.
/// </summary>
/// <param name="episode">Media file to analyze.</param>
/// <param name="mode">Analysis mode.</param>
/// <param name="minimum">Percentage of the frame that must be black.</param>
/// <returns>Credits timestamp.</returns>
public Intro? AnalyzeMediaFile(QueuedEpisode episode, AnalysisMode mode, int minimum)
{
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
// Start by analyzing the last N minutes of the file.
var start = TimeSpan.FromSeconds(config.MaximumEpisodeCreditsDuration);
var end = TimeSpan.FromSeconds(config.MinimumCreditsDuration);
var firstFrameTime = 0.0;
// Continue bisecting the end of the file until the range that contains the first black
// frame is smaller than the maximum permitted error.
while (start - end > _maximumError)
{
// Analyze the middle two seconds from the current bisected range
var midpoint = (start + end) / 2;
var scanTime = episode.Duration - midpoint.TotalSeconds;
var tr = new TimeRange(scanTime, scanTime + 2);
_logger.LogTrace(
"{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]",
episode.Name,
episode.Duration,
start,
end,
tr.Start,
tr.End);
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum);
_logger.LogTrace(
"{Episode} at {Start} has {Count} black frames",
episode.Name,
tr.Start,
frames.Length);
if (frames.Length == 0)
{
// Since no black frames were found, slide the range closer to the end
start = midpoint;
}
else
{
// Some black frames were found, slide the range closer to the start
end = midpoint;
firstFrameTime = frames[0].Time + scanTime;
}
}
if (firstFrameTime > 0)
{
return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration));
}
return null;
}
}

View File

@ -0,0 +1,159 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using Microsoft.Extensions.Logging;
using MediaBrowser.Model.Entities;
/// <summary>
/// Chapter name analyzer.
/// </summary>
public class ChapterAnalyzer : IMediaFileAnalyzer
{
private ILogger<ChapterAnalyzer> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ChapterAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public ChapterAnalyzer(ILogger<ChapterAnalyzer> logger)
{
_logger = logger;
}
/// <inheritdoc />
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
var skippableRanges = new Dictionary<Guid, Intro>();
var expression = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern :
Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern;
if (string.IsNullOrWhiteSpace(expression))
{
return analysisQueue;
}
foreach (var episode in analysisQueue)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var skipRange = FindMatchingChapter(
episode,
new(Plugin.Instance!.GetChapters(episode.EpisodeId)),
expression,
mode);
if (skipRange is null)
{
continue;
}
skippableRanges.Add(episode.EpisodeId, skipRange);
}
Plugin.Instance!.UpdateTimestamps(skippableRanges, mode);
return analysisQueue
.Where(x => !skippableRanges.ContainsKey(x.EpisodeId))
.ToList()
.AsReadOnly();
}
/// <summary>
/// Searches a list of chapter names for one that matches the provided regular expression.
/// Only public to allow for unit testing.
/// </summary>
/// <param name="episode">Episode.</param>
/// <param name="chapters">Media item chapters.</param>
/// <param name="expression">Regular expression pattern.</param>
/// <param name="mode">Analysis mode.</param>
/// <returns>Intro object containing skippable time range, or null if no chapter matched.</returns>
public Intro? FindMatchingChapter(
QueuedEpisode episode,
Collection<ChapterInfo> chapters,
string expression,
AnalysisMode mode)
{
Intro? matchingChapter = null;
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
var minDuration = config.MinimumIntroDuration;
int maxDuration = mode == AnalysisMode.Introduction ?
config.MaximumIntroDuration :
config.MaximumEpisodeCreditsDuration;
if (mode == AnalysisMode.Credits)
{
// Since the ending credits chapter may be the last chapter in the file, append a virtual
// chapter at the very end of the file.
chapters.Add(new()
{
StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks
});
}
// Check all chapters
for (int i = 0; i < chapters.Count - 1; i++)
{
var current = chapters[i];
var next = chapters[i + 1];
if (string.IsNullOrWhiteSpace(current.Name))
{
continue;
}
var currentRange = new TimeRange(
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
var baseMessage = string.Format(
CultureInfo.InvariantCulture,
"{0}: Chapter \"{1}\" ({2} - {3})",
episode.Path,
current.Name,
currentRange.Start,
currentRange.End);
if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration)
{
_logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage);
continue;
}
// Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex
// between function invocations.
var match = Regex.IsMatch(
current.Name,
expression,
RegexOptions.None,
TimeSpan.FromSeconds(1));
if (!match)
{
_logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
continue;
}
matchingChapter = new(episode.EpisodeId, currentRange);
_logger.LogTrace("{Base}: okay", baseMessage);
break;
}
return matchingChapter;
}
}

View File

@ -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)
{ {
@ -113,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
@ -143,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();
} }
@ -339,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);

View File

@ -72,6 +72,33 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public int MaximumIntroDuration { get; set; } = 120; public int MaximumIntroDuration { get; set; } = 120;
/// <summary>
/// Gets or sets the minimum length of similar audio that will be considered ending credits.
/// </summary>
public int MinimumCreditsDuration { get; set; } = 15;
/// <summary>
/// Gets or sets the upper limit (in seconds) on the length of each episode's audio track that will be analyzed when searching for ending credits.
/// </summary>
public int MaximumEpisodeCreditsDuration { get; set; } = 240;
/// <summary>
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
/// </summary>
public int BlackFrameMinimumPercentage { get; set; } = 85;
/// <summary>
/// Gets or sets the regular expression used to detect introduction chapters.
/// </summary>
public string ChapterAnalyzerIntroductionPattern { get; set; } =
@"(^|\s)(Intro|Introduction|OP|Opening)(\s|$)";
/// <summary>
/// Gets or sets the regular expression used to detect ending credit chapters.
/// </summary>
public string ChapterAnalyzerEndCreditsPattern { get; set; } =
@"(^|\s)(Credits?|Ending)(\s|$)";
// ===== Playback settings ===== // ===== Playback settings =====
/// <summary> /// <summary>

View File

@ -291,10 +291,6 @@
<span>Save</span> <span>Save</span>
</button> </button>
<br /> <br />
<button id="btnEraseTimestamps" is="emby-button" class="raised block emby-button">
<span>Erase introduction timestamps</span>
</button>
</div> </div>
</form> </form>
</div> </div>
@ -340,7 +336,17 @@
<button id="btnEraseSeasonTimestamps" type="button"> <button id="btnEraseSeasonTimestamps" type="button">
Erase all timestamps for this season Erase all timestamps for this season
</button> </button>
<hr />
</div> </div>
<button id="btnEraseIntroTimestamps">
Erase all introduction timestamps (globally)
</button>
<br />
<button id="btnEraseCreditTimestamps">
Erase all end credits timestamps (globally)
</button>
<br /> <br />
<br /> <br />
@ -414,7 +420,8 @@
// settings elements // settings elements
var visualizer = document.querySelector("details#visualizer"); var visualizer = document.querySelector("details#visualizer");
var support = document.querySelector("details#support"); var support = document.querySelector("details#support");
var btnEraseTimestamps = document.querySelector("button#btnEraseTimestamps"); var btnEraseIntroTimestamps = document.querySelector("button#btnEraseIntroTimestamps");
var btnEraseCreditTimestamps = document.querySelector("button#btnEraseCreditTimestamps");
// all plugin configuration fields that can be get or set with .value (i.e. strings or numbers). // all plugin configuration fields that can be get or set with .value (i.e. strings or numbers).
var configurationFields = [ var configurationFields = [
@ -703,6 +710,28 @@
return new Date(seconds * 1000).toISOString().substr(14, 5); return new Date(seconds * 1000).toISOString().substr(14, 5);
} }
// erase all intro/credits timestamps
function eraseTimestamps(mode) {
const lower = mode.toLocaleLowerCase();
const title = "Confirm timestamp erasure";
const body = "Are you sure you want to erase all previously discovered " +
mode.toLocaleLowerCase() +
" timestamps?";
Dashboard.confirm(
body,
title,
(result) => {
if (!result) {
return;
}
fetchWithAuth("Intros/EraseTimestamps?mode=" + mode, "POST", null);
Dashboard.alert(mode + " timestamps erased");
});
}
document.querySelector('#TemplateConfigPage') document.querySelector('#TemplateConfigPage')
.addEventListener('pageshow', function () { .addEventListener('pageshow', function () {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
@ -748,19 +777,12 @@
selectSeason.addEventListener("change", seasonChanged); selectSeason.addEventListener("change", seasonChanged);
selectEpisode1.addEventListener("change", episodeChanged); selectEpisode1.addEventListener("change", episodeChanged);
selectEpisode2.addEventListener("change", episodeChanged); selectEpisode2.addEventListener("change", episodeChanged);
btnEraseTimestamps.addEventListener("click", (e) => { btnEraseIntroTimestamps.addEventListener("click", (e) => {
Dashboard.confirm( eraseTimestamps("Introduction");
"Are you sure you want to erase all previously discovered introduction timestamps?", e.preventDefault();
"Confirm timestamp erasure", });
(result) => { btnEraseCreditTimestamps.addEventListener("click", (e) => {
if (!result) { eraseTimestamps("Credits");
return;
}
// reset all intro timestamps on the server so a new fingerprint comparison algorithm can be tested
fetchWithAuth("Intros/EraseTimestamps", "POST", null);
});
e.preventDefault(); e.preventDefault();
}); });
btnSeasonEraseTimestamps.addEventListener("click", () => { btnSeasonEraseTimestamps.addEventListener("click", () => {

View File

@ -27,14 +27,17 @@ public class SkipIntroController : ControllerBase
/// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format. /// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format.
/// </summary> /// </summary>
/// <param name="id">ID of the episode. Required.</param> /// <param name="id">ID of the episode. Required.</param>
/// <param name="mode">Timestamps to return. Optional. Defaults to Introduction for backwards compatibility.</param>
/// <response code="200">Episode contains an intro.</response> /// <response code="200">Episode contains an intro.</response>
/// <response code="404">Failed to find an intro in the provided episode.</response> /// <response code="404">Failed to find an intro in the provided episode.</response>
/// <returns>Detected intro.</returns> /// <returns>Detected intro.</returns>
[HttpGet("Episode/{id}/IntroTimestamps")] [HttpGet("Episode/{id}/IntroTimestamps")]
[HttpGet("Episode/{id}/IntroTimestamps/v1")] [HttpGet("Episode/{id}/IntroTimestamps/v1")]
public ActionResult<Intro> GetIntroTimestamps([FromRoute] Guid id) public ActionResult<Intro> GetIntroTimestamps(
[FromRoute] Guid id,
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
{ {
var intro = GetIntro(id); var intro = GetIntro(id, mode);
if (intro is null || !intro.Valid) if (intro is null || !intro.Valid)
{ {
@ -50,42 +53,69 @@ public class SkipIntroController : ControllerBase
return intro; return intro;
} }
/// <summary>Lookup and return the intro timestamps for the provided item.</summary> /// <summary>Lookup and return the skippable timestamps for the provided item.</summary>
/// <param name="id">Unique identifier of this episode.</param> /// <param name="id">Unique identifier of this episode.</param>
/// <param name="mode">Mode.</param>
/// <returns>Intro object if the provided item has an intro, null otherwise.</returns> /// <returns>Intro object if the provided item has an intro, null otherwise.</returns>
private Intro? GetIntro(Guid id) private Intro? GetIntro(Guid id, AnalysisMode mode)
{ {
// Returns a copy to avoid mutating the original Intro object stored in the dictionary. try
return Plugin.Instance!.Intros.TryGetValue(id, out var intro) ? new Intro(intro) : null; {
var timestamp = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Intros[id] :
Plugin.Instance!.Credits[id];
// A copy is returned to avoid mutating the original Intro object stored in the dictionary.
return new(timestamp);
}
catch (KeyNotFoundException)
{
return null;
}
} }
/// <summary> /// <summary>
/// Erases all previously discovered introduction timestamps. /// Erases all previously discovered introduction timestamps.
/// </summary> /// </summary>
/// <param name="mode">Mode.</param>
/// <response code="204">Operation successful.</response> /// <response code="204">Operation successful.</response>
/// <returns>No content.</returns> /// <returns>No content.</returns>
[Authorize(Policy = "RequiresElevation")] [Authorize(Policy = "RequiresElevation")]
[HttpPost("Intros/EraseTimestamps")] [HttpPost("Intros/EraseTimestamps")]
public ActionResult ResetIntroTimestamps() public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode)
{ {
Plugin.Instance!.Intros.Clear(); if (mode == AnalysisMode.Introduction)
{
Plugin.Instance!.Intros.Clear();
}
else if (mode == AnalysisMode.Credits)
{
Plugin.Instance!.Credits.Clear();
}
Plugin.Instance!.SaveTimestamps(); Plugin.Instance!.SaveTimestamps();
return NoContent(); return NoContent();
} }
/// <summary> /// <summary>
/// Get all introductions. Only used by the end to end testing script. /// Get all introductions or credits. Only used by the end to end testing script.
/// </summary> /// </summary>
/// <response code="200">All introductions have been returned.</response> /// <param name="mode">Mode.</param>
/// <response code="200">All timestamps have been returned.</response>
/// <returns>List of IntroWithMetadata objects.</returns> /// <returns>List of IntroWithMetadata objects.</returns>
[Authorize(Policy = "RequiresElevation")] [Authorize(Policy = "RequiresElevation")]
[HttpGet("Intros/All")] [HttpGet("Intros/All")]
public ActionResult<List<IntroWithMetadata>> GetAllIntros() public ActionResult<List<IntroWithMetadata>> GetAllTimestamps(
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
{ {
List<IntroWithMetadata> intros = new(); List<IntroWithMetadata> intros = new();
var timestamps = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Intros :
Plugin.Instance!.Credits;
// Get metadata for all intros // Get metadata for all intros
foreach (var intro in Plugin.Instance!.Intros) foreach (var intro in timestamps)
{ {
// Get the details of the item from Jellyfin // Get the details of the item from Jellyfin
var rawItem = Plugin.Instance!.GetItem(intro.Key); var rawItem = Plugin.Instance!.GetItem(intro.Key);

View File

@ -69,10 +69,7 @@ public class TroubleshootingController : ControllerBase
bundle.Append("* Queue contents: "); bundle.Append("* Queue contents: ");
bundle.Append(Plugin.Instance!.TotalQueued); bundle.Append(Plugin.Instance!.TotalQueued);
bundle.Append(" episodes, "); bundle.Append(" episodes\n");
bundle.Append(Plugin.Instance!.AnalysisQueue.Count);
bundle.Append(" seasons");
bundle.Append('\n');
bundle.Append("* Warnings: `"); bundle.Append("* Warnings: `");
bundle.Append(WarningManager.GetWarnings()); bundle.Append(WarningManager.GetWarnings());

View File

@ -40,7 +40,7 @@ public class VisualizationController : ControllerBase
var showSeasons = new Dictionary<string, HashSet<string>>(); var showSeasons = new Dictionary<string, HashSet<string>>();
// Loop through all seasons in the analysis queue // Loop through all seasons in the analysis queue
foreach (var kvp in Plugin.Instance!.AnalysisQueue) foreach (var kvp in Plugin.Instance!.QueuedMediaItems)
{ {
// Check that this season contains at least one episode. // Check that this season contains at least one episode.
var episodes = kvp.Value; var episodes = kvp.Value;
@ -104,16 +104,14 @@ public class VisualizationController : ControllerBase
[HttpGet("Episode/{Id}/Chromaprint")] [HttpGet("Episode/{Id}/Chromaprint")]
public ActionResult<uint[]> GetEpisodeFingerprint([FromRoute] Guid id) public ActionResult<uint[]> GetEpisodeFingerprint([FromRoute] Guid id)
{ {
var queue = Plugin.Instance!.AnalysisQueue;
// Search through all queued episodes to find the requested id // Search through all queued episodes to find the requested id
foreach (var season in queue) foreach (var season in Plugin.Instance!.QueuedMediaItems)
{ {
foreach (var needle in season.Value) foreach (var needle in season.Value)
{ {
if (needle.EpisodeId == id) if (needle.EpisodeId == id)
{ {
return FFmpegWrapper.Fingerprint(needle); return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction);
} }
} }
} }
@ -180,7 +178,7 @@ public class VisualizationController : ControllerBase
/// <returns>Boolean indicating if the requested season was found.</returns> /// <returns>Boolean indicating if the requested season was found.</returns>
private bool LookupSeasonByName(string series, string season, out List<QueuedEpisode> episodes) private bool LookupSeasonByName(string series, string season, out List<QueuedEpisode> episodes)
{ {
foreach (var queuedEpisodes in Plugin.Instance!.AnalysisQueue) foreach (var queuedEpisodes in Plugin.Instance!.QueuedMediaItems)
{ {
var first = queuedEpisodes.Value[0]; var first = queuedEpisodes.Value[0];
var firstSeasonName = GetSeasonName(first); var firstSeasonName = GetSeasonName(first);

View File

@ -0,0 +1,28 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// A frame of video that partially (or entirely) consists of black pixels.
/// </summary>
public class BlackFrame
{
/// <summary>
/// Initializes a new instance of the <see cref="BlackFrame"/> class.
/// </summary>
/// <param name="percent">Percentage of the frame that is black.</param>
/// <param name="time">Time this frame appears at.</param>
public BlackFrame(int percent, double time)
{
Percentage = percent;
Time = time;
}
/// <summary>
/// Gets or sets the percentage of the frame that is black.
/// </summary>
public int Percentage { get; set; }
/// <summary>
/// Gets or sets the time (in seconds) this frame appeared at.
/// </summary>
public double Time { get; set; }
}

View File

@ -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 at.
/// </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 at.
/// </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

@ -53,21 +53,16 @@ public class Entrypoint : IServerEntryPoint
try try
{ {
// Enqueue all episodes at startup so the fingerprint visualizer works before the task is started. // Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible
_logger.LogInformation("Running startup enqueue"); _logger.LogInformation("Running startup enqueue");
var queueManager = new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager); var queueManager = new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager);
queueManager.EnqueueAllEpisodes(); queueManager.GetMediaItems();
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError("Unable to run startup enqueue: {Exception}", ex); _logger.LogError("Unable to run startup enqueue: {Exception}", ex);
} }
_logger.LogDebug(
"Total enqueued seasons: {Count} ({Episodes} episodes)",
Plugin.Instance!.AnalysisQueue.Count,
Plugin.Instance!.TotalQueued);
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@ -16,16 +16,17 @@ public static class FFmpegWrapper
{ {
private static readonly object InvertedIndexCacheLock = new(); private static readonly object InvertedIndexCacheLock = new();
// FFmpeg logs lines similar to the following:
// [silencedetect @ 0x000000000000] silence_start: 12.34
// [silencedetect @ 0x000000000000] silence_end: 56.123 | silence_duration: 43.783
/// <summary> /// <summary>
/// Used with FFmpeg's silencedetect filter to extract the start and end times of silence. /// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
/// </summary> /// </summary>
private static readonly Regex SilenceDetectionExpression = new( private static readonly Regex SilenceDetectionExpression = new(
"silence_(?<type>start|end): (?<time>[0-9\\.]+)"); "silence_(?<type>start|end): (?<time>[0-9\\.]+)");
/// <summary>
/// Used with FFmpeg's blackframe filter to extract the time and percentage of black pixels.
/// </summary>
private static readonly Regex BlackFrameRegex = new("(pblack|t):[0-9.]+");
/// <summary> /// <summary>
/// Gets or sets the logger. /// Gets or sets the logger.
/// </summary> /// </summary>
@ -103,83 +104,32 @@ public static class FFmpegWrapper
} }
} }
/// <summary>
/// Run an FFmpeg command with the provided arguments and validate that the output contains
/// the provided string.
/// </summary>
/// <param name="arguments">Arguments to pass to FFmpeg.</param>
/// <param name="mustContain">String that the output must contain. Case insensitive.</param>
/// <param name="bundleName">Support bundle key to store FFmpeg's output under.</param>
/// <param name="errorMessage">Error message to log if this requirement is not met.</param>
/// <returns>true on success, false on error.</returns>
private static bool CheckFFmpegRequirement(
string arguments,
string mustContain,
string bundleName,
string errorMessage)
{
Logger?.LogDebug("Checking FFmpeg requirement {Arguments}", arguments);
var output = Encoding.UTF8.GetString(GetOutput(arguments, string.Empty, false, 2000));
Logger?.LogTrace("Output of ffmpeg {Arguments}: {Output}", arguments, output);
ChromaprintLogs[bundleName] = output;
if (!output.Contains(mustContain, StringComparison.OrdinalIgnoreCase))
{
Logger?.LogError("{ErrorMessage}", errorMessage);
return false;
}
Logger?.LogDebug("FFmpeg requirement {Arguments} met", arguments);
return true;
}
/// <summary> /// <summary>
/// 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)
{ {
// Try to load this episode from cache before running ffmpeg. int start, end;
if (LoadCachedFingerprint(episode, out uint[] cachedFingerprint))
if (mode == AnalysisMode.Introduction)
{ {
Logger?.LogTrace("Fingerprint cache hit on {File}", episode.Path); start = 0;
return cachedFingerprint; end = episode.IntroFingerprintEnd;
}
else if (mode == AnalysisMode.Credits)
{
start = episode.CreditsFingerprintStart;
end = episode.Duration;
}
else
{
throw new ArgumentException("Unknown analysis mode " + mode.ToString());
} }
Logger?.LogDebug( return Fingerprint(episode, mode, start, end);
"Fingerprinting {Duration} seconds from \"{File}\" (id {Id})",
episode.FingerprintDuration,
episode.Path,
episode.EpisodeId);
var args = string.Format(
CultureInfo.InvariantCulture,
"-i \"{0}\" -to {1} -ac 2 -f chromaprint -fp_format raw -",
episode.Path,
episode.FingerprintDuration);
// Returns all fingerprint points as raw 32 bit unsigned integers (little endian).
var rawPoints = GetOutput(args, string.Empty);
if (rawPoints.Length == 0 || rawPoints.Length % 4 != 0)
{
Logger?.LogWarning("Chromaprint returned {Count} points for \"{Path}\"", rawPoints.Length, episode.Path);
throw new FingerprintException("chromaprint output for \"" + episode.Path + "\" was malformed");
}
var results = new List<uint>();
for (var i = 0; i < rawPoints.Length; i += 4)
{
var rawPoint = rawPoints.Slice(i, 4);
results.Add(BitConverter.ToUInt32(rawPoint));
}
// Try to cache this fingerprint.
CacheFingerprint(episode, results);
return results.ToArray();
} }
/// <summary> /// <summary>
@ -231,9 +181,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,
@ -249,7 +196,12 @@ public static class FFmpegWrapper
var currentRange = new TimeRange(); var currentRange = new TimeRange();
var silenceRanges = new List<TimeRange>(); var silenceRanges = new List<TimeRange>();
// Each match will have a type (either "start" or "end") and a timecode (a double). /* Each match will have a type (either "start" or "end") and a timecode (a double).
*
* Sample output:
* [silencedetect @ 0x000000000000] silence_start: 12.34
* [silencedetect @ 0x000000000000] silence_end: 56.123 | silence_duration: 43.783
*/
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true)); var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
foreach (Match match in SilenceDetectionExpression.Matches(raw)) foreach (Match match in SilenceDetectionExpression.Matches(raw))
{ {
@ -270,6 +222,138 @@ public static class FFmpegWrapper
return silenceRanges.ToArray(); return silenceRanges.ToArray();
} }
/// <summary>
/// Finds the location of all black frames in a media file within a time range.
/// </summary>
/// <param name="episode">Media file to analyze.</param>
/// <param name="range">Time range to search.</param>
/// <param name="minimum">Percentage of the frame that must be black.</param>
/// <returns>Array of frames that are mostly black.</returns>
public static BlackFrame[] DetectBlackFrames(
QueuedEpisode episode,
TimeRange range,
int minimum)
{
// Seek to the start of the time range and find frames that are at least 50% black.
var args = string.Format(
CultureInfo.InvariantCulture,
"-ss {0} -i \"{1}\" -to {2} -an -dn -sn -vf \"blackframe=amount=50\" -f null -",
range.Start,
episode.Path,
range.End - range.Start);
// Cache the results to GUID-blackframes-START-END-v1.
var cacheKey = string.Format(
CultureInfo.InvariantCulture,
"{0}-blackframes-{1}-{2}-v1",
episode.EpisodeId.ToString("N"),
range.Start,
range.End);
var blackFrames = new List<BlackFrame>();
/* Run the blackframe filter.
*
* Sample output:
* [Parsed_blackframe_0 @ 0x0000000] frame:1 pblack:99 pts:43 t:0.043000 type:B last_keyframe:0
* [Parsed_blackframe_0 @ 0x0000000] frame:2 pblack:99 pts:85 t:0.085000 type:B last_keyframe:0
*/
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
foreach (var line in raw.Split('\n'))
{
var matches = BlackFrameRegex.Matches(line);
if (matches.Count != 2)
{
continue;
}
var (strPercent, strTime) = (
matches[0].Value.Split(':')[1],
matches[1].Value.Split(':')[1]
);
var bf = new BlackFrame(
Convert.ToInt32(strPercent, CultureInfo.InvariantCulture),
Convert.ToDouble(strTime, CultureInfo.InvariantCulture));
if (bf.Percentage > minimum)
{
blackFrames.Add(bf);
}
}
return blackFrames.ToArray();
}
/// <summary>
/// Gets Chromaprint debugging logs.
/// </summary>
/// <returns>Markdown formatted logs.</returns>
public static string GetChromaprintLogs()
{
// Print the FFmpeg detection status at the top.
// Format: "* FFmpeg: `error`"
// Append two newlines to separate the bulleted list from the logs
var logs = string.Format(
CultureInfo.InvariantCulture,
"* FFmpeg: `{0}`\n\n",
ChromaprintLogs["error"]);
// Always include ffmpeg version information
logs += FormatFFmpegLog("version");
// Don't print feature detection logs if the plugin started up okay
if (ChromaprintLogs["error"] == "okay")
{
return logs;
}
// Print all remaining logs
foreach (var kvp in ChromaprintLogs)
{
if (kvp.Key == "error" || kvp.Key == "version")
{
continue;
}
logs += FormatFFmpegLog(kvp.Key);
}
return logs;
}
/// <summary>
/// Run an FFmpeg command with the provided arguments and validate that the output contains
/// the provided string.
/// </summary>
/// <param name="arguments">Arguments to pass to FFmpeg.</param>
/// <param name="mustContain">String that the output must contain. Case insensitive.</param>
/// <param name="bundleName">Support bundle key to store FFmpeg's output under.</param>
/// <param name="errorMessage">Error message to log if this requirement is not met.</param>
/// <returns>true on success, false on error.</returns>
private static bool CheckFFmpegRequirement(
string arguments,
string mustContain,
string bundleName,
string errorMessage)
{
Logger?.LogDebug("Checking FFmpeg requirement {Arguments}", arguments);
var output = Encoding.UTF8.GetString(GetOutput(arguments, string.Empty, false, 2000));
Logger?.LogTrace("Output of ffmpeg {Arguments}: {Output}", arguments, output);
ChromaprintLogs[bundleName] = output;
if (!output.Contains(mustContain, StringComparison.OrdinalIgnoreCase))
{
Logger?.LogError("{ErrorMessage}", errorMessage);
return false;
}
Logger?.LogDebug("FFmpeg requirement {Arguments} met", arguments);
return true;
}
/// <summary> /// <summary>
/// Runs ffmpeg and returns standard output (or error). /// Runs ffmpeg and returns standard output (or error).
/// If caching is enabled, will use cacheFilename to cache the output of this command. /// If caching is enabled, will use cacheFilename to cache the output of this command.
@ -286,10 +370,11 @@ public static class FFmpegWrapper
{ {
var ffmpegPath = Plugin.Instance?.FFmpegPath ?? "ffmpeg"; var ffmpegPath = Plugin.Instance?.FFmpegPath ?? "ffmpeg";
// The silencedetect filter outputs silence timestamps at the info log level. // The silencedetect and blackframe filters output data at the info log level.
var logLevel = args.Contains("silencedetect", StringComparison.OrdinalIgnoreCase) ? var useInfoLevel = args.Contains("silencedetect", StringComparison.OrdinalIgnoreCase) ||
"info" : args.Contains("blackframe", StringComparison.OrdinalIgnoreCase);
"warning";
var logLevel = useInfoLevel ? "info" : "warning";
var cacheOutput = var cacheOutput =
(Plugin.Instance?.Configuration.CacheFingerprints ?? false) && (Plugin.Instance?.Configuration.CacheFingerprints ?? false) &&
@ -367,14 +452,70 @@ public static class FFmpegWrapper
} }
} }
/// <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, mode, out uint[] cachedFingerprint))
{
Logger?.LogTrace("Fingerprint cache hit on {File}", episode.Path);
return cachedFingerprint;
}
Logger?.LogDebug(
"Fingerprinting [{Start}, {End}] from \"{File}\" (id {Id})",
start,
end,
episode.Path,
episode.EpisodeId);
var args = string.Format(
CultureInfo.InvariantCulture,
"-ss {0} -i \"{1}\" -to {2} -ac 2 -f chromaprint -fp_format raw -",
start,
episode.Path,
end - start);
// Returns all fingerprint points as raw 32 bit unsigned integers (little endian).
var rawPoints = GetOutput(args, string.Empty);
if (rawPoints.Length == 0 || rawPoints.Length % 4 != 0)
{
Logger?.LogWarning("Chromaprint returned {Count} points for \"{Path}\"", rawPoints.Length, episode.Path);
throw new FingerprintException("chromaprint output for \"" + episode.Path + "\" was malformed");
}
var results = new List<uint>();
for (var i = 0; i < rawPoints.Length; i += 4)
{
var rawPoint = rawPoints.Slice(i, 4);
results.Add(BitConverter.ToUInt32(rawPoint));
}
// Try to cache this fingerprint.
CacheFingerprint(episode, mode, results);
return results.ToArray();
}
/// <summary> /// <summary>
/// Tries to load an episode's fingerprint from cache. If caching is not enabled, calling this function is a no-op. /// Tries to load an episode's fingerprint from cache. If caching is not enabled, calling this function is a no-op.
/// 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>();
@ -384,7 +525,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))
@ -392,7 +533,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>();
@ -426,8 +566,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))
@ -443,7 +587,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>
@ -451,46 +598,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"));
/// <summary> if (mode == AnalysisMode.Introduction)
/// Gets Chromaprint debugging logs.
/// </summary>
/// <returns>Markdown formatted logs.</returns>
public static string GetChromaprintLogs()
{
// Print the FFmpeg detection status at the top.
// Format: "* FFmpeg: `error`"
// Append two newlines to separate the bulleted list from the logs
var logs = string.Format(
CultureInfo.InvariantCulture,
"* FFmpeg: `{0}`\n\n",
ChromaprintLogs["error"]);
// Always include ffmpeg version information
logs += FormatFFmpegLog("version");
// Don't print feature detection logs if the plugin started up okay
if (ChromaprintLogs["error"] == "okay")
{ {
return logs; return basePath;
} }
else if (mode == AnalysisMode.Credits)
// Print all remaining logs
foreach (var kvp in ChromaprintLogs)
{ {
if (kvp.Key == "error" || kvp.Key == "version") return basePath + "-credits";
{ }
continue; else
} {
throw new ArgumentException("Unknown analysis mode " + mode.ToString());
logs += FormatFFmpegLog(kvp.Key);
} }
return logs;
} }
private static string FormatFFmpegLog(string key) private static string FormatFFmpegLog(string key)

View File

@ -7,7 +7,10 @@ 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.Controller.Persistence;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -23,8 +26,10 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
private readonly object _introsLock = new(); private readonly object _introsLock = new();
private IXmlSerializer _xmlSerializer; private IXmlSerializer _xmlSerializer;
private ILibraryManager _libraryManager; private ILibraryManager _libraryManager;
private IItemRepository _itemRepository;
private ILogger<Plugin> _logger; private ILogger<Plugin> _logger;
private string _introPath; private string _introPath;
private string _creditsPath;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class. /// Initializes a new instance of the <see cref="Plugin"/> class.
@ -33,12 +38,14 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param> /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
/// <param name="serverConfiguration">Server configuration manager.</param> /// <param name="serverConfiguration">Server configuration manager.</param>
/// <param name="libraryManager">Library manager.</param> /// <param name="libraryManager">Library manager.</param>
/// <param name="itemRepository">Item repository.</param>
/// <param name="logger">Logger.</param> /// <param name="logger">Logger.</param>
public Plugin( public Plugin(
IApplicationPaths applicationPaths, IApplicationPaths applicationPaths,
IXmlSerializer xmlSerializer, IXmlSerializer xmlSerializer,
IServerConfigurationManager serverConfiguration, IServerConfigurationManager serverConfiguration,
ILibraryManager libraryManager, ILibraryManager libraryManager,
IItemRepository itemRepository,
ILogger<Plugin> logger) ILogger<Plugin> logger)
: base(applicationPaths, xmlSerializer) : base(applicationPaths, xmlSerializer)
{ {
@ -46,13 +53,15 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
_xmlSerializer = xmlSerializer; _xmlSerializer = xmlSerializer;
_libraryManager = libraryManager; _libraryManager = libraryManager;
_itemRepository = itemRepository;
_logger = logger; _logger = logger;
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay; FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
var introsDirectory = Path.Join(applicationPaths.PluginConfigurationsPath, "intros"); var introsDirectory = Path.Join(applicationPaths.PluginConfigurationsPath, "intros");
FingerprintCachePath = Path.Join(introsDirectory, "cache"); FingerprintCachePath = Path.Join(introsDirectory, "cache");
_introPath = Path.Join(introsDirectory, "intros.xml"); _introPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml");
_creditsPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "credits.xml");
// Create the base & cache directories (if needed). // Create the base & cache directories (if needed).
if (!Directory.Exists(FingerprintCachePath)) if (!Directory.Exists(FingerprintCachePath))
@ -113,9 +122,14 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
public Dictionary<Guid, Intro> Intros { get; } = new(); public Dictionary<Guid, Intro> Intros { get; } = new();
/// <summary> /// <summary>
/// Gets the mapping of season ids to episodes that have been queued for fingerprinting. /// Gets all discovered ending credits.
/// </summary> /// </summary>
public Dictionary<Guid, List<QueuedEpisode>> AnalysisQueue { get; } = new(); public Dictionary<Guid, Intro> Credits { get; } = new();
/// <summary>
/// Gets the most recent media item queue.
/// </summary>
public Dictionary<Guid, List<QueuedEpisode>> QueuedMediaItems { get; } = new();
/// <summary> /// <summary>
/// Gets or sets the total number of episodes in the queue. /// Gets or sets the total number of episodes in the queue.
@ -152,12 +166,23 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{ {
var introList = new List<Intro>(); var introList = new List<Intro>();
// Serialize intros
foreach (var intro in Plugin.Instance!.Intros) foreach (var intro in Plugin.Instance!.Intros)
{ {
introList.Add(intro.Value); introList.Add(intro.Value);
} }
_xmlSerializer.SerializeToFile(introList, _introPath); _xmlSerializer.SerializeToFile(introList, _introPath);
// Serialize credits
introList.Clear();
foreach (var intro in Plugin.Instance!.Credits)
{
introList.Add(intro.Value);
}
_xmlSerializer.SerializeToFile(introList, _creditsPath);
} }
} }
@ -166,17 +191,29 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary> /// </summary>
public void RestoreTimestamps() public void RestoreTimestamps()
{ {
if (!File.Exists(_introPath)) if (File.Exists(_introPath))
{ {
return; // Since dictionaries can't be easily serialized, analysis results are stored on disk as a list.
var introList = (List<Intro>)_xmlSerializer.DeserializeFromFile(
typeof(List<Intro>),
_introPath);
foreach (var intro in introList)
{
Plugin.Instance!.Intros[intro.EpisodeId] = intro;
}
} }
// Since dictionaries can't be easily serialized, analysis results are stored on disk as a list. if (File.Exists(_creditsPath))
var introList = (List<Intro>)_xmlSerializer.DeserializeFromFile(typeof(List<Intro>), _introPath);
foreach (var intro in introList)
{ {
Plugin.Instance!.Intros[intro.EpisodeId] = intro; var creditList = (List<Intro>)_xmlSerializer.DeserializeFromFile(
typeof(List<Intro>),
_creditsPath);
foreach (var credit in creditList)
{
Plugin.Instance!.Credits[credit.EpisodeId] = credit;
}
} }
} }
@ -247,13 +284,30 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
return GetItem(id).Path; return GetItem(id).Path;
} }
internal void UpdateTimestamps(Dictionary<Guid, Intro> newIntros) /// <summary>
/// Gets all chapters for this item.
/// </summary>
/// <param name="id">Item id.</param>
/// <returns>List of chapters.</returns>
internal List<ChapterInfo> GetChapters(Guid id)
{
return _itemRepository.GetChapters(GetItem(id));
}
internal void UpdateTimestamps(Dictionary<Guid, Intro> newTimestamps, AnalysisMode mode)
{ {
lock (_introsLock) lock (_introsLock)
{ {
foreach (var intro in newIntros) foreach (var intro in newTimestamps)
{ {
Plugin.Instance!.Intros[intro.Key] = intro.Value; if (mode == AnalysisMode.Introduction)
{
Plugin.Instance!.Intros[intro.Key] = intro.Value;
}
else if (mode == AnalysisMode.Credits)
{
Plugin.Instance!.Credits[intro.Key] = intro.Value;
}
} }
Plugin.Instance!.SaveTimestamps(); Plugin.Instance!.SaveTimestamps();

View File

@ -2,12 +2,13 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq; using System.Linq;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
/// <summary> /// <summary>
@ -19,7 +20,8 @@ public class QueueManager
private ILogger<QueueManager> _logger; private ILogger<QueueManager> _logger;
private double analysisPercent; private double analysisPercent;
private IList<string> selectedLibraries; private List<string> selectedLibraries;
private Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="QueueManager"/> class. /// Initializes a new instance of the <see cref="QueueManager"/> class.
@ -31,13 +33,15 @@ public class QueueManager
_logger = logger; _logger = logger;
_libraryManager = libraryManager; _libraryManager = libraryManager;
selectedLibraries = new List<string>(); selectedLibraries = new();
_queuedEpisodes = new();
} }
/// <summary> /// <summary>
/// Iterates through all libraries on the server and queues all episodes for analysis. /// Gets all media items on the server.
/// </summary> /// </summary>
public void EnqueueAllEpisodes() /// <returns>Queued media items.</returns>
public ReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems()
{ {
// Assert that ffmpeg with chromaprint is installed // Assert that ffmpeg with chromaprint is installed
if (!FFmpegWrapper.CheckFFmpegVersion()) if (!FFmpegWrapper.CheckFFmpegVersion())
@ -46,20 +50,13 @@ public class QueueManager
"ffmpeg with chromaprint is not installed on this system - episodes will not be analyzed. If Jellyfin is running natively, install jellyfin-ffmpeg5. If Jellyfin is running in a container, upgrade it to the latest version of 10.8.0."); "ffmpeg with chromaprint is not installed on this system - episodes will not be analyzed. If Jellyfin is running natively, install jellyfin-ffmpeg5. If Jellyfin is running in a container, upgrade it to the latest version of 10.8.0.");
} }
Plugin.Instance!.AnalysisQueue.Clear();
Plugin.Instance!.TotalQueued = 0; Plugin.Instance!.TotalQueued = 0;
LoadAnalysisSettings(); LoadAnalysisSettings();
// For all selected TV show libraries, enqueue all contained items. // For all selected libraries, enqueue all contained episodes.
foreach (var folder in _libraryManager.GetVirtualFolders()) foreach (var folder in _libraryManager.GetVirtualFolders())
{ {
if (folder.CollectionType != CollectionTypeOptions.TvShows)
{
_logger.LogDebug("Not analyzing library \"{Name}\": not a TV show library", folder.Name);
continue;
}
// If libraries have been selected for analysis, ensure this library was selected. // If libraries have been selected for analysis, ensure this library was selected.
if (selectedLibraries.Count > 0 && !selectedLibraries.Contains(folder.Name)) if (selectedLibraries.Count > 0 && !selectedLibraries.Contains(folder.Name))
{ {
@ -81,6 +78,14 @@ public class QueueManager
_logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex); _logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex);
} }
} }
Plugin.Instance!.QueuedMediaItems.Clear();
foreach (var kvp in _queuedEpisodes)
{
Plugin.Instance!.QueuedMediaItems[kvp.Key] = kvp.Value;
}
return new(_queuedEpisodes);
} }
/// <summary> /// <summary>
@ -156,7 +161,7 @@ public class QueueManager
{ {
if (item is not Episode episode) if (item is not Episode episode)
{ {
_logger.LogError("Item {Name} is not an episode", item.Name); _logger.LogDebug("Item {Name} is not an episode", item.Name);
continue; continue;
} }
@ -186,27 +191,81 @@ 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>()); _queuedEpisodes.TryAdd(episode.SeasonId, new List<QueuedEpisode>());
// Queue the episode for analysis // Queue the episode for analysis
Plugin.Instance.AnalysisQueue[episode.SeasonId].Add(new QueuedEpisode() var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumEpisodeCreditsDuration;
_queuedEpisodes[episode.SeasonId].Add(new QueuedEpisode()
{ {
SeriesName = episode.SeriesName, SeriesName = episode.SeriesName,
SeasonNumber = episode.AiredSeasonNumber ?? 0, SeasonNumber = episode.AiredSeasonNumber ?? 0,
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++;
} }
/// <summary>
/// Verify that a collection of queued media items still exist in Jellyfin and in storage.
/// This is done to ensure that we don't analyze items that were deleted between the call to GetMediaItems() and popping them from the queue.
/// </summary>
/// <param name="candidates">Queued media items.</param>
/// <param name="mode">Analysis mode.</param>
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
public (ReadOnlyCollection<QueuedEpisode> VerifiedItems, bool AnyUnanalyzed)
VerifyQueue(ReadOnlyCollection<QueuedEpisode> candidates, AnalysisMode mode)
{
var unanalyzed = false;
var verified = new List<QueuedEpisode>();
var timestamps = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Intros :
Plugin.Instance!.Credits;
foreach (var candidate in candidates)
{
try
{
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
if (File.Exists(path))
{
verified.Add(candidate);
}
if (!timestamps.ContainsKey(candidate.EpisodeId))
{
unanalyzed = true;
}
}
catch (Exception ex)
{
_logger.LogDebug(
"Skipping {Mode} analysis of {Name} ({Id}): {Exception}",
mode,
candidate.Name,
candidate.EpisodeId,
ex);
}
}
return (verified.AsReadOnly(), unanalyzed);
}
} }

View File

@ -1,274 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Analyze all television episodes for introduction sequences.
/// </summary>
public class AnalyzeEpisodesTask : IScheduledTask
{
private readonly ILogger<AnalyzeEpisodesTask> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager? _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="AnalyzeEpisodesTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
public AnalyzeEpisodesTask(
ILoggerFactory loggerFactory,
ILibraryManager libraryManager) : this(loggerFactory)
{
_libraryManager = libraryManager;
}
/// <summary>
/// Initializes a new instance of the <see cref="AnalyzeEpisodesTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
public AnalyzeEpisodesTask(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<AnalyzeEpisodesTask>();
_loggerFactory = loggerFactory;
EdlManager.Initialize(_logger);
}
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Introductions";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes the audio of all television episodes to find introduction sequences.";
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectIntroductions";
/// <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.");
}
// Log EDL settings
EdlManager.LogConfiguration();
var totalProcessed = 0;
var options = new ParallelOptions()
{
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
};
// TODO: if the queue is modified while the task is running, the task will fail.
// clone the queue before running the task to prevent this.
// Analyze all episodes in the queue using the degrees of parallelism the user specified.
Parallel.ForEach(queue, options, (season) =>
{
var (episodes, unanalyzed) = VerifyEpisodes(season.Value.AsReadOnly());
if (episodes.Count == 0)
{
return;
}
var first = episodes[0];
var writeEdl = false;
if (!unanalyzed)
{
_logger.LogDebug(
"All episodes in {Name} season {Season} have already been analyzed",
first.SeriesName,
first.SeasonNumber);
return;
}
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);
writeEdl = analyzed > 0 || Plugin.Instance!.Configuration.RegenerateEdlFiles;
}
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);
}
if (writeEdl && Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
{
EdlManager.UpdateEDLFiles(episodes);
}
progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued);
});
// Turn the regenerate EDL flag off after the scan completes.
if (Plugin.Instance!.Configuration.RegenerateEdlFiles)
{
_logger.LogInformation("Turning EDL file regeneration flag off");
Plugin.Instance!.Configuration.RegenerateEdlFiles = false;
Plugin.Instance!.SaveConfiguration();
}
return Task.CompletedTask;
}
/// <summary>
/// Verify that all episodes in a season exist in Jellyfin and as a file in storage.
/// </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>
private (
ReadOnlyCollection<QueuedEpisode> VerifiedEpisodes,
bool AnyUnanalyzed)
VerifyEpisodes(ReadOnlyCollection<QueuedEpisode> candidates)
{
var unanalyzed = false;
var verified = new List<QueuedEpisode>();
foreach (var candidate in candidates)
{
try
{
// Verify that the episode exists in Jellyfin and in storage
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
if (File.Exists(path))
{
verified.Add(candidate);
}
// Flag this season for analysis if the current episode hasn't been analyzed yet
if (!Plugin.Instance.Intros.ContainsKey(candidate.EpisodeId))
{
unanalyzed = true;
}
}
catch (Exception ex)
{
_logger.LogDebug(
"Skipping analysis of {Name} ({Id}): {Exception}",
candidate.Name,
candidate.EpisodeId,
ex);
}
}
return (verified.AsReadOnly(), unanalyzed);
}
/// <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.Introduction, cancellationToken);
return episodes.Count;
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromHours(0).Ticks
}
};
}
}

View File

@ -0,0 +1,198 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
/// <summary>
/// Common code shared by all media item analyzer tasks.
/// </summary>
public class BaseItemAnalyzerTask
{
private readonly AnalysisMode _analysisMode;
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="BaseItemAnalyzerTask"/> class.
/// </summary>
/// <param name="mode">Analysis mode.</param>
/// <param name="logger">Task logger.</param>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
public BaseItemAnalyzerTask(
AnalysisMode mode,
ILogger logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_analysisMode = mode;
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
if (mode == AnalysisMode.Introduction)
{
EdlManager.Initialize(_logger);
}
}
/// <summary>
/// Analyze all media items on the server.
/// </summary>
/// <param name="progress">Progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public void AnalyzeItems(
IProgress<double> progress,
CancellationToken cancellationToken)
{
var queueManager = new QueueManager(
_loggerFactory.CreateLogger<QueueManager>(),
_libraryManager);
var queue = queueManager.GetMediaItems();
var totalQueued = 0;
foreach (var kvp in queue)
{
totalQueued += kvp.Value.Count;
}
if (totalQueued == 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.");
}
if (this._analysisMode == AnalysisMode.Introduction)
{
EdlManager.LogConfiguration();
}
var totalProcessed = 0;
var options = new ParallelOptions()
{
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
};
Parallel.ForEach(queue, options, (season) =>
{
var writeEdl = false;
// Since the first run of the task can run for multiple hours, ensure that none
// of the current media items were deleted from Jellyfin since the task was started.
var (episodes, unanalyzed) = queueManager.VerifyQueue(
season.Value.AsReadOnly(),
this._analysisMode);
if (episodes.Count == 0)
{
return;
}
var first = episodes[0];
if (!unanalyzed)
{
_logger.LogDebug(
"All episodes in {Name} season {Season} have already been analyzed",
first.SeriesName,
first.SeasonNumber);
return;
}
try
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
var analyzed = AnalyzeItems(episodes, cancellationToken);
Interlocked.Add(ref totalProcessed, analyzed);
writeEdl = analyzed > 0 || Plugin.Instance!.Configuration.RegenerateEdlFiles;
}
catch (FingerprintException ex)
{
_logger.LogWarning(
"Unable to analyze {Series} season {Season}: unable to fingerprint: {Ex}",
first.SeriesName,
first.SeasonNumber,
ex);
}
if (
writeEdl &&
Plugin.Instance!.Configuration.EdlAction != EdlAction.None &&
_analysisMode == AnalysisMode.Introduction)
{
EdlManager.UpdateEDLFiles(episodes);
}
progress.Report((totalProcessed * 100) / totalQueued);
});
if (
_analysisMode == AnalysisMode.Introduction &&
Plugin.Instance!.Configuration.RegenerateEdlFiles)
{
_logger.LogInformation("Turning EDL file regeneration flag off");
Plugin.Instance!.Configuration.RegenerateEdlFiles = false;
Plugin.Instance!.SaveConfiguration();
}
}
/// <summary>
/// Analyze a group of media items for skippable segments.
/// </summary>
/// <param name="items">Media items to analyze.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of items that were successfully analyzed.</returns>
private int AnalyzeItems(
ReadOnlyCollection<QueuedEpisode> items,
CancellationToken cancellationToken)
{
var totalItems = items.Count;
// Only analyze specials (season 0) if the user has opted in.
var first = items[0];
if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
{
return 0;
}
_logger.LogInformation(
"Analyzing {Count} files from {Name} season {Season}",
items.Count,
first.SeriesName,
first.SeasonNumber);
var analyzers = new Collection<IMediaFileAnalyzer>();
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
if (this._analysisMode == AnalysisMode.Credits)
{
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
}
// Use each analyzer to find skippable ranges in all media files, removing successfully
// analyzed items from the queue.
foreach (var analyzer in analyzers)
{
items = analyzer.AnalyzeMediaFiles(items, this._analysisMode, cancellationToken);
}
return totalItems;
}
}

View File

@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Analyze all television episodes for credits.
/// TODO: analyze all media files.
/// </summary>
public class DetectCreditsTask : IScheduledTask
{
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)
{
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <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 was null");
}
var baseAnalyzer = new BaseItemAnalyzerTask(
AnalysisMode.Credits,
_loggerFactory.CreateLogger<DetectCreditsTask>(),
_loggerFactory,
_libraryManager);
baseAnalyzer.AnalyzeItems(progress, cancellationToken);
return Task.CompletedTask;
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return Array.Empty<TaskTriggerInfo>();
}
}

View File

@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Analyze all television episodes for introduction sequences.
/// </summary>
public class DetectIntroductionsTask : IScheduledTask
{
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectIntroductionsTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
public DetectIntroductionsTask(
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Introductions";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes the audio of all television episodes to find introduction sequences.";
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectIntroductions";
/// <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 was null");
}
var baseAnalyzer = new BaseItemAnalyzerTask(
AnalysisMode.Introduction,
_loggerFactory.CreateLogger<DetectIntroductionsTask>(),
_loggerFactory,
_libraryManager);
baseAnalyzer.AnalyzeItems(progress, cancellationToken);
return Task.CompletedTask;
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromHours(0).Ticks
}
};
}
}

View File

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