Skip to content

Commit

Permalink
Merge branch 'analyzers'
Browse files Browse the repository at this point in the history
  • Loading branch information
ConfusedPolarBear committed Feb 8, 2023
2 parents 1d6ff8c + c6801d5 commit d540f7e
Show file tree
Hide file tree
Showing 26 changed files with 1,430 additions and 495 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Changelog

## v0.1.8.0 (no eta)
* New features
* Support adding skip intro button to web interface without using a fork
* Add localization support for the skip intro button and the automatic skip notification message
* Detect ending credits in television episodes
* Add support for using chapter names to locate introductions and ending credits
* Add support for using black frames to locate ending credits
* Internal changes
* Move Chromaprint analysis code out of the episode analysis task
* Add support for multiple analysis techinques
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ public void TestFingerprinting()
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);
}
Expand Down Expand Up @@ -91,8 +93,8 @@ public void TestIntroDetection()

var lhsEpisode = queueEpisode("audio/big_buck_bunny_intro.mp3");
var rhsEpisode = queueEpisode("audio/big_buck_bunny_clip.mp3");
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode);
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode);
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction);
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction);

var (lhs, rhs) = chromaprint.CompareEpisodes(
lhsEpisode.EpisodeId,
Expand Down Expand Up @@ -138,7 +140,7 @@ private QueuedEpisode queueEpisode(string path)
{
EpisodeId = Guid.NewGuid(),
Path = "../../../" + path,
FingerprintDuration = 60
IntroFingerprintEnd = 60
};
}

Expand Down
72 changes: 72 additions & 0 deletions ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
83 changes: 83 additions & 0 deletions ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"

Expand Down Expand Up @@ -77,7 +78,6 @@ func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamp
fmt.Println()
fmt.Println("[+] Saving report")

// TODO: also save analysis statistics
// Store timing data, server information, and plugin configuration
report.StartedAt = start
report.FinishedAt = time.Now()
Expand All @@ -95,6 +95,9 @@ func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamp
panic(err)
}

// Change report permissions
exec.Command("chown", "1000:1000", reportDestination).Run()

fmt.Println("[+] Done")
}

Expand All @@ -110,11 +113,13 @@ func runAnalysisAndWait(hostAddress, apiKey string, pollInterval time.Duration)
SendRequest("POST", hostAddress+"/Intros/EraseTimestamps", apiKey)
fmt.Println()

// The task ID changed with v0.1.7.
// Old task ID: 8863329048cc357f7dfebf080f2fe204
// New task ID: 6adda26c5261c40e8fa4a7e7df568be2
var taskIds = []string{
"f64d8ad58e3d7b98548e1a07697eb100", // v0.1.8
"8863329048cc357f7dfebf080f2fe204",
"6adda26c5261c40e8fa4a7e7df568be2"}

fmt.Println("[+] Starting analysis task")
for _, id := range []string{"8863329048cc357f7dfebf080f2fe204", "6adda26c5261c40e8fa4a7e7df568be2"} {
for _, id := range taskIds {
body := SendRequest("POST", hostAddress+"/ScheduledTasks/Running/"+id, apiKey)
fmt.Println()

Expand Down
Binary file not shown.
Binary file not shown.
131 changes: 131 additions & 0 deletions ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit d540f7e

Please sign in to comment.