Merge branch 'analyzers'
This commit is contained in:
commit
d540f7e70e
@ -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
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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"
|
"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()
|
||||||
|
|
||||||
|
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 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);
|
@ -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>
|
||||||
|
@ -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", () => {
|
||||||
|
@ -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);
|
||||||
|
@ -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());
|
||||||
|
@ -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);
|
||||||
|
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;
|
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; }
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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();
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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" />
|
<img alt="Plugin Banner" src="https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/master/images/logo.png" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Analyzes the audio of television episodes to detect and skip over intros.
|
Analyzes the audio of television episodes to detect and skip over introductions and ending credits.
|
||||||
|
|
||||||
If you use the custom web interface on your server, you will be able to click a button to skip intros, like this:
|
If you use the custom web interface on your server, you will be able to click a button to skip intros, like this:
|
||||||
|
|
||||||
@ -20,13 +20,15 @@ However, if you want to use an unmodified installation of Jellyfin 10.8.z or use
|
|||||||
* `linuxserver/jellyfin` 10.8.z container: preinstalled
|
* `linuxserver/jellyfin` 10.8.z container: preinstalled
|
||||||
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg5` package
|
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg5` package
|
||||||
|
|
||||||
## Introduction requirements
|
## Introduction and end credit requirements
|
||||||
|
|
||||||
Show introductions will only be detected if they are:
|
Show introductions will only be detected if they are:
|
||||||
|
|
||||||
* Located within the first 25% of an episode, or the first 10 minutes, whichever is smaller
|
* Located within the first 25% of an episode, or the first 10 minutes, whichever is smaller
|
||||||
* Between 15 seconds and 2 minutes long
|
* Between 15 seconds and 2 minutes long
|
||||||
|
|
||||||
|
Ending credits will only be detected if they are shorter than 4 minutes.
|
||||||
|
|
||||||
All of these requirements can be customized as needed.
|
All of these requirements can be customized as needed.
|
||||||
|
|
||||||
## Installation instructions
|
## Installation instructions
|
||||||
|
Loading…
x
Reference in New Issue
Block a user