Merge branch 'analyzers'
This commit is contained in:
commit
d540f7e70e
@ -1,6 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
* Move Chromaprint analysis code out of the episode analysis task
|
||||
* Add support for multiple analysis techinques
|
||||
|
@ -59,7 +59,9 @@ public class TestAudioFingerprinting
|
||||
3472417825, 3395841056, 3458735136, 3341420624, 1076496560, 1076501168, 1076501136, 1076497024
|
||||
};
|
||||
|
||||
var actual = FFmpegWrapper.Fingerprint(queueEpisode("audio/big_buck_bunny_intro.mp3"));
|
||||
var actual = FFmpegWrapper.Fingerprint(
|
||||
queueEpisode("audio/big_buck_bunny_intro.mp3"),
|
||||
AnalysisMode.Introduction);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
@ -91,8 +93,8 @@ public class TestAudioFingerprinting
|
||||
|
||||
var lhsEpisode = queueEpisode("audio/big_buck_bunny_intro.mp3");
|
||||
var rhsEpisode = queueEpisode("audio/big_buck_bunny_clip.mp3");
|
||||
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode);
|
||||
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode);
|
||||
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction);
|
||||
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction);
|
||||
|
||||
var (lhs, rhs) = chromaprint.CompareEpisodes(
|
||||
lhsEpisode.EpisodeId,
|
||||
@ -138,7 +140,7 @@ public class TestAudioFingerprinting
|
||||
{
|
||||
EpisodeId = Guid.NewGuid(),
|
||||
Path = "../../../" + path,
|
||||
FingerprintDuration = 60
|
||||
IntroFingerprintEnd = 60
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -77,7 +78,6 @@ func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamp
|
||||
fmt.Println()
|
||||
fmt.Println("[+] Saving report")
|
||||
|
||||
// TODO: also save analysis statistics
|
||||
// Store timing data, server information, and plugin configuration
|
||||
report.StartedAt = start
|
||||
report.FinishedAt = time.Now()
|
||||
@ -95,6 +95,9 @@ func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamp
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Change report permissions
|
||||
exec.Command("chown", "1000:1000", reportDestination).Run()
|
||||
|
||||
fmt.Println("[+] Done")
|
||||
}
|
||||
|
||||
@ -110,11 +113,13 @@ func runAnalysisAndWait(hostAddress, apiKey string, pollInterval time.Duration)
|
||||
SendRequest("POST", hostAddress+"/Intros/EraseTimestamps", apiKey)
|
||||
fmt.Println()
|
||||
|
||||
// The task ID changed with v0.1.7.
|
||||
// Old task ID: 8863329048cc357f7dfebf080f2fe204
|
||||
// New task ID: 6adda26c5261c40e8fa4a7e7df568be2
|
||||
var taskIds = []string{
|
||||
"f64d8ad58e3d7b98548e1a07697eb100", // v0.1.8
|
||||
"8863329048cc357f7dfebf080f2fe204",
|
||||
"6adda26c5261c40e8fa4a7e7df568be2"}
|
||||
|
||||
fmt.Println("[+] Starting analysis task")
|
||||
for _, id := range []string{"8863329048cc357f7dfebf080f2fe204", "6adda26c5261c40e8fa4a7e7df568be2"} {
|
||||
for _, id := range taskIds {
|
||||
body := SendRequest("POST", hostAddress+"/ScheduledTasks/Running/"+id, apiKey)
|
||||
fmt.Println()
|
||||
|
||||
|
BIN
ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4
Normal file
BIN
ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4
Normal file
Binary file not shown.
BIN
ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4
Normal file
BIN
ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4
Normal file
Binary file not shown.
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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)
|
||||
{
|
||||
@ -113,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
|
||||
@ -143,10 +163,13 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
return analysisQueue;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@ -339,7 +362,10 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
// Since LHS had a contiguous time range, RHS must have one also.
|
||||
var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), maximumTimeSkip)!;
|
||||
|
||||
if (this._analysisMode == AnalysisMode.Introduction)
|
||||
{
|
||||
// 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;
|
||||
@ -350,6 +376,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
lContiguous.End -= maximumTimeSkip;
|
||||
rContiguous.End -= maximumTimeSkip;
|
||||
}
|
||||
}
|
||||
|
||||
return (lContiguous, rContiguous);
|
||||
}
|
@ -72,6 +72,33 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
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 =====
|
||||
|
||||
/// <summary>
|
||||
|
@ -291,10 +291,6 @@
|
||||
<span>Save</span>
|
||||
</button>
|
||||
<br />
|
||||
|
||||
<button id="btnEraseTimestamps" is="emby-button" class="raised block emby-button">
|
||||
<span>Erase introduction timestamps</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -340,7 +336,17 @@
|
||||
<button id="btnEraseSeasonTimestamps" type="button">
|
||||
Erase all timestamps for this season
|
||||
</button>
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<button id="btnEraseIntroTimestamps">
|
||||
Erase all introduction timestamps (globally)
|
||||
</button>
|
||||
<br />
|
||||
|
||||
<button id="btnEraseCreditTimestamps">
|
||||
Erase all end credits timestamps (globally)
|
||||
</button>
|
||||
<br />
|
||||
<br />
|
||||
|
||||
@ -414,7 +420,8 @@
|
||||
// settings elements
|
||||
var visualizer = document.querySelector("details#visualizer");
|
||||
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).
|
||||
var configurationFields = [
|
||||
@ -703,6 +710,28 @@
|
||||
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')
|
||||
.addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
@ -748,19 +777,12 @@
|
||||
selectSeason.addEventListener("change", seasonChanged);
|
||||
selectEpisode1.addEventListener("change", episodeChanged);
|
||||
selectEpisode2.addEventListener("change", episodeChanged);
|
||||
btnEraseTimestamps.addEventListener("click", (e) => {
|
||||
Dashboard.confirm(
|
||||
"Are you sure you want to erase all previously discovered introduction timestamps?",
|
||||
"Confirm timestamp erasure",
|
||||
(result) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reset all intro timestamps on the server so a new fingerprint comparison algorithm can be tested
|
||||
fetchWithAuth("Intros/EraseTimestamps", "POST", null);
|
||||
btnEraseIntroTimestamps.addEventListener("click", (e) => {
|
||||
eraseTimestamps("Introduction");
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
btnEraseCreditTimestamps.addEventListener("click", (e) => {
|
||||
eraseTimestamps("Credits");
|
||||
e.preventDefault();
|
||||
});
|
||||
btnSeasonEraseTimestamps.addEventListener("click", () => {
|
||||
|
@ -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.
|
||||
/// </summary>
|
||||
/// <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="404">Failed to find an intro in the provided episode.</response>
|
||||
/// <returns>Detected intro.</returns>
|
||||
[HttpGet("Episode/{id}/IntroTimestamps")]
|
||||
[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)
|
||||
{
|
||||
@ -50,42 +53,69 @@ public class SkipIntroController : ControllerBase
|
||||
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="mode">Mode.</param>
|
||||
/// <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.
|
||||
return Plugin.Instance!.Intros.TryGetValue(id, out var intro) ? new Intro(intro) : null;
|
||||
try
|
||||
{
|
||||
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>
|
||||
/// Erases all previously discovered introduction timestamps.
|
||||
/// </summary>
|
||||
/// <param name="mode">Mode.</param>
|
||||
/// <response code="204">Operation successful.</response>
|
||||
/// <returns>No content.</returns>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpPost("Intros/EraseTimestamps")]
|
||||
public ActionResult ResetIntroTimestamps()
|
||||
public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode)
|
||||
{
|
||||
if (mode == AnalysisMode.Introduction)
|
||||
{
|
||||
Plugin.Instance!.Intros.Clear();
|
||||
}
|
||||
else if (mode == AnalysisMode.Credits)
|
||||
{
|
||||
Plugin.Instance!.Credits.Clear();
|
||||
}
|
||||
|
||||
Plugin.Instance!.SaveTimestamps();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <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>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpGet("Intros/All")]
|
||||
public ActionResult<List<IntroWithMetadata>> GetAllIntros()
|
||||
public ActionResult<List<IntroWithMetadata>> GetAllTimestamps(
|
||||
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
|
||||
{
|
||||
List<IntroWithMetadata> intros = new();
|
||||
|
||||
var timestamps = mode == AnalysisMode.Introduction ?
|
||||
Plugin.Instance!.Intros :
|
||||
Plugin.Instance!.Credits;
|
||||
|
||||
// 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
|
||||
var rawItem = Plugin.Instance!.GetItem(intro.Key);
|
||||
|
@ -69,10 +69,7 @@ public class TroubleshootingController : ControllerBase
|
||||
|
||||
bundle.Append("* Queue contents: ");
|
||||
bundle.Append(Plugin.Instance!.TotalQueued);
|
||||
bundle.Append(" episodes, ");
|
||||
bundle.Append(Plugin.Instance!.AnalysisQueue.Count);
|
||||
bundle.Append(" seasons");
|
||||
bundle.Append('\n');
|
||||
bundle.Append(" episodes\n");
|
||||
|
||||
bundle.Append("* Warnings: `");
|
||||
bundle.Append(WarningManager.GetWarnings());
|
||||
|
@ -40,7 +40,7 @@ public class VisualizationController : ControllerBase
|
||||
var showSeasons = new Dictionary<string, HashSet<string>>();
|
||||
|
||||
// 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.
|
||||
var episodes = kvp.Value;
|
||||
@ -104,16 +104,14 @@ public class VisualizationController : ControllerBase
|
||||
[HttpGet("Episode/{Id}/Chromaprint")]
|
||||
public ActionResult<uint[]> GetEpisodeFingerprint([FromRoute] Guid id)
|
||||
{
|
||||
var queue = Plugin.Instance!.AnalysisQueue;
|
||||
|
||||
// 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)
|
||||
{
|
||||
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>
|
||||
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 firstSeasonName = GetSeasonName(first);
|
||||
|
28
ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs
Normal file
28
ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs
Normal 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; }
|
||||
}
|
@ -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 at.
|
||||
/// </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; }
|
||||
}
|
||||
|
@ -53,21 +53,16 @@ public class Entrypoint : IServerEntryPoint
|
||||
|
||||
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");
|
||||
var queueManager = new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager);
|
||||
queueManager.EnqueueAllEpisodes();
|
||||
queueManager.GetMediaItems();
|
||||
}
|
||||
catch (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;
|
||||
}
|
||||
|
||||
|
@ -16,16 +16,17 @@ public static class FFmpegWrapper
|
||||
{
|
||||
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>
|
||||
/// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
|
||||
/// </summary>
|
||||
private static readonly Regex SilenceDetectionExpression = new(
|
||||
"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>
|
||||
/// Gets or sets the logger.
|
||||
/// </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>
|
||||
/// 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)
|
||||
{
|
||||
// Try to load this episode from cache before running ffmpeg.
|
||||
if (LoadCachedFingerprint(episode, out uint[] cachedFingerprint))
|
||||
int start, end;
|
||||
|
||||
if (mode == AnalysisMode.Introduction)
|
||||
{
|
||||
Logger?.LogTrace("Fingerprint cache hit on {File}", episode.Path);
|
||||
return cachedFingerprint;
|
||||
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());
|
||||
}
|
||||
|
||||
Logger?.LogDebug(
|
||||
"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();
|
||||
return Fingerprint(episode, mode, start, end);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -231,9 +181,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,
|
||||
@ -249,7 +196,12 @@ public static class FFmpegWrapper
|
||||
var currentRange = new 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));
|
||||
foreach (Match match in SilenceDetectionExpression.Matches(raw))
|
||||
{
|
||||
@ -270,6 +222,138 @@ public static class FFmpegWrapper
|
||||
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>
|
||||
/// Runs ffmpeg and returns standard output (or error).
|
||||
/// 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";
|
||||
|
||||
// The silencedetect filter outputs silence timestamps at the info log level.
|
||||
var logLevel = args.Contains("silencedetect", StringComparison.OrdinalIgnoreCase) ?
|
||||
"info" :
|
||||
"warning";
|
||||
// The silencedetect and blackframe filters output data at the info log level.
|
||||
var useInfoLevel = args.Contains("silencedetect", StringComparison.OrdinalIgnoreCase) ||
|
||||
args.Contains("blackframe", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var logLevel = useInfoLevel ? "info" : "warning";
|
||||
|
||||
var cacheOutput =
|
||||
(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>
|
||||
/// 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).
|
||||
/// </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>();
|
||||
|
||||
@ -384,7 +525,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))
|
||||
@ -392,7 +533,6 @@ public static class FFmpegWrapper
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: make async
|
||||
var raw = File.ReadAllLines(path, Encoding.UTF8);
|
||||
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).
|
||||
/// </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))
|
||||
@ -443,7 +587,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>
|
||||
@ -451,46 +598,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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets Chromaprint debugging logs.
|
||||
/// </summary>
|
||||
/// <returns>Markdown formatted logs.</returns>
|
||||
public static string GetChromaprintLogs()
|
||||
else if (mode == AnalysisMode.Credits)
|
||||
{
|
||||
// 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 + "-credits";
|
||||
}
|
||||
|
||||
// Print all remaining logs
|
||||
foreach (var kvp in ChromaprintLogs)
|
||||
else
|
||||
{
|
||||
if (kvp.Key == "error" || kvp.Key == "version")
|
||||
{
|
||||
continue;
|
||||
throw new ArgumentException("Unknown analysis mode " + mode.ToString());
|
||||
}
|
||||
|
||||
logs += FormatFFmpegLog(kvp.Key);
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
private static string FormatFFmpegLog(string key)
|
||||
|
@ -7,7 +7,10 @@ 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.Controller.Persistence;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -23,8 +26,10 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
private readonly object _introsLock = new();
|
||||
private IXmlSerializer _xmlSerializer;
|
||||
private ILibraryManager _libraryManager;
|
||||
private IItemRepository _itemRepository;
|
||||
private ILogger<Plugin> _logger;
|
||||
private string _introPath;
|
||||
private string _creditsPath;
|
||||
|
||||
/// <summary>
|
||||
/// 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="serverConfiguration">Server configuration manager.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
/// <param name="itemRepository">Item repository.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public Plugin(
|
||||
IApplicationPaths applicationPaths,
|
||||
IXmlSerializer xmlSerializer,
|
||||
IServerConfigurationManager serverConfiguration,
|
||||
ILibraryManager libraryManager,
|
||||
IItemRepository itemRepository,
|
||||
ILogger<Plugin> logger)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
@ -46,13 +53,15 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
|
||||
_xmlSerializer = xmlSerializer;
|
||||
_libraryManager = libraryManager;
|
||||
_itemRepository = itemRepository;
|
||||
_logger = logger;
|
||||
|
||||
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
|
||||
|
||||
var introsDirectory = Path.Join(applicationPaths.PluginConfigurationsPath, "intros");
|
||||
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).
|
||||
if (!Directory.Exists(FingerprintCachePath))
|
||||
@ -113,9 +122,14 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
public Dictionary<Guid, Intro> Intros { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mapping of season ids to episodes that have been queued for fingerprinting.
|
||||
/// Gets all discovered ending credits.
|
||||
/// </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>
|
||||
/// 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>();
|
||||
|
||||
// Serialize intros
|
||||
foreach (var intro in Plugin.Instance!.Intros)
|
||||
{
|
||||
introList.Add(intro.Value);
|
||||
}
|
||||
|
||||
_xmlSerializer.SerializeToFile(introList, _introPath);
|
||||
|
||||
// Serialize credits
|
||||
introList.Clear();
|
||||
|
||||
foreach (var intro in Plugin.Instance!.Credits)
|
||||
{
|
||||
introList.Add(intro.Value);
|
||||
}
|
||||
|
||||
_xmlSerializer.SerializeToFile(introList, _creditsPath);
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,13 +191,12 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// </summary>
|
||||
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);
|
||||
var introList = (List<Intro>)_xmlSerializer.DeserializeFromFile(
|
||||
typeof(List<Intro>),
|
||||
_introPath);
|
||||
|
||||
foreach (var intro in introList)
|
||||
{
|
||||
@ -180,6 +204,19 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(_creditsPath))
|
||||
{
|
||||
var creditList = (List<Intro>)_xmlSerializer.DeserializeFromFile(
|
||||
typeof(List<Intro>),
|
||||
_creditsPath);
|
||||
|
||||
foreach (var credit in creditList)
|
||||
{
|
||||
Plugin.Instance!.Credits[credit.EpisodeId] = credit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
@ -247,14 +284,31 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
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)
|
||||
{
|
||||
foreach (var intro in newIntros)
|
||||
foreach (var intro in newTimestamps)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
@ -2,12 +2,13 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
/// <summary>
|
||||
@ -19,7 +20,8 @@ public class QueueManager
|
||||
private ILogger<QueueManager> _logger;
|
||||
|
||||
private double analysisPercent;
|
||||
private IList<string> selectedLibraries;
|
||||
private List<string> selectedLibraries;
|
||||
private Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QueueManager"/> class.
|
||||
@ -31,13 +33,15 @@ public class QueueManager
|
||||
_logger = logger;
|
||||
_libraryManager = libraryManager;
|
||||
|
||||
selectedLibraries = new List<string>();
|
||||
selectedLibraries = new();
|
||||
_queuedEpisodes = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterates through all libraries on the server and queues all episodes for analysis.
|
||||
/// Gets all media items on the server.
|
||||
/// </summary>
|
||||
public void EnqueueAllEpisodes()
|
||||
/// <returns>Queued media items.</returns>
|
||||
public ReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems()
|
||||
{
|
||||
// Assert that ffmpeg with chromaprint is installed
|
||||
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.");
|
||||
}
|
||||
|
||||
Plugin.Instance!.AnalysisQueue.Clear();
|
||||
Plugin.Instance!.TotalQueued = 0;
|
||||
|
||||
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())
|
||||
{
|
||||
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 (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);
|
||||
}
|
||||
}
|
||||
|
||||
Plugin.Instance!.QueuedMediaItems.Clear();
|
||||
foreach (var kvp in _queuedEpisodes)
|
||||
{
|
||||
Plugin.Instance!.QueuedMediaItems[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return new(_queuedEpisodes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -156,7 +161,7 @@ public class QueueManager
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@ -186,27 +191,81 @@ 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>());
|
||||
_queuedEpisodes.TryAdd(episode.SeasonId, new List<QueuedEpisode>());
|
||||
|
||||
// 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,
|
||||
SeasonNumber = episode.AiredSeasonNumber ?? 0,
|
||||
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++;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>();
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
<img alt="Plugin Banner" src="https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/master/images/logo.png" />
|
||||
</div>
|
||||
|
||||
Analyzes the audio of television episodes to detect and skip over intros.
|
||||
Analyzes the audio of television episodes to detect and skip over introductions and ending credits.
|
||||
|
||||
If you use the custom web interface on your server, you will be able to click a button to skip intros, like this:
|
||||
|
||||
@ -20,13 +20,15 @@ However, if you want to use an unmodified installation of Jellyfin 10.8.z or use
|
||||
* `linuxserver/jellyfin` 10.8.z container: preinstalled
|
||||
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg5` package
|
||||
|
||||
## Introduction requirements
|
||||
## Introduction and end credit requirements
|
||||
|
||||
Show introductions will only be detected if they are:
|
||||
|
||||
* Located within the first 25% of an episode, or the first 10 minutes, whichever is smaller
|
||||
* Between 15 seconds and 2 minutes long
|
||||
|
||||
Ending credits will only be detected if they are shorter than 4 minutes.
|
||||
|
||||
All of these requirements can be customized as needed.
|
||||
|
||||
## Installation instructions
|
||||
|
Loading…
x
Reference in New Issue
Block a user