Add chapter name based media file analyzer

Closes #58
This commit is contained in:
ConfusedPolarBear 2022-11-24 00:43:23 -06:00
parent bcb5e5ea30
commit a68914ca8f
8 changed files with 312 additions and 66 deletions

View File

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

View File

@ -0,0 +1,138 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
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;
foreach (var episode in analysisQueue)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var skipRange = FindMatchingChapter(
episode.EpisodeId,
episode.Duration,
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;
}
/// <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="id">Item id.</param>
/// <param name="duration">Duration of media file in seconds.</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(
Guid id,
int duration,
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 ChapterInfo()
{
StartPositionTicks = TimeSpan.FromSeconds(duration).Ticks
});
}
// Check all chapters
for (int i = 0; i < chapters.Count - 1; i++)
{
// Calculate chapter position and duration
var current = chapters[i];
var next = chapters[i + 1];
var currentRange = new TimeRange(
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
// Skip chapters with that don't have a name or are too short/long
if (string.IsNullOrEmpty(current.Name) ||
currentRange.Duration < minDuration ||
currentRange.Duration > maxDuration)
{
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)
{
continue;
}
matchingChapter = new Intro(id, currentRange);
break;
}
return matchingChapter;
}
}

View File

@ -82,7 +82,6 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
}
catch (FingerprintException ex)
{
// TODO: FIXME: move to debug level?
_logger.LogWarning("Caught fingerprint error: {Ex}", ex);
// Fallback to an empty fingerprint on any error

View File

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

View File

@ -73,18 +73,24 @@ public class SkipIntroController : ControllerBase
}
/// <summary>
/// Get all introductions. Only used by the end to end testing script.
/// Get all introductions or credits. Only used by the end to end testing script.
/// </summary>
/// <response code="200">All introductions have been returned.</response>
/// <param name="mode">Mode.</param>
/// <response code="200">All timestamps have been returned.</response>
/// <returns>List of IntroWithMetadata objects.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpGet("Intros/All")]
public ActionResult<List<IntroWithMetadata>> GetAllIntros()
public ActionResult<List<IntroWithMetadata>> GetAllTimestamps(
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
{
List<IntroWithMetadata> intros = new();
var timestamps = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Intros :
Plugin.Instance!.Credits;
// Get metadata for all intros
foreach (var intro in Plugin.Instance!.Intros)
foreach (var intro in timestamps)
{
// Get the details of the item from Jellyfin
var rawItem = Plugin.Instance!.GetItem(intro.Key);

View File

@ -8,6 +8,8 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging;
@ -23,9 +25,10 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
private readonly object _introsLock = new();
private IXmlSerializer _xmlSerializer;
private ILibraryManager _libraryManager;
private IItemRepository _itemRepository;
private ILogger<Plugin> _logger;
private string _introPath;
private string _creditsPath; // TODO: FIXME: remove this
private string _creditsPath;
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
@ -34,12 +37,14 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
/// <param name="serverConfiguration">Server configuration manager.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="itemRepository">Item repository.</param>
/// <param name="logger">Logger.</param>
public Plugin(
IApplicationPaths applicationPaths,
IXmlSerializer xmlSerializer,
IServerConfigurationManager serverConfiguration,
ILibraryManager libraryManager,
IItemRepository itemRepository,
ILogger<Plugin> logger)
: base(applicationPaths, xmlSerializer)
{
@ -47,14 +52,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
_xmlSerializer = xmlSerializer;
_libraryManager = libraryManager;
_itemRepository = itemRepository;
_logger = logger;
FingerprintCachePath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "cache");
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
_introPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml");
// TODO: FIXME: remove this
_creditsPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "credits.csv");
_creditsPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "credits.xml");
// Create the base & cache directories (if needed).
if (!Directory.Exists(FingerprintCachePath))
@ -73,12 +77,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
_logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex);
}
// TODO: FIXME: remove this
if (File.Exists(_creditsPath))
{
File.Delete(_creditsPath);
}
}
/// <summary>
@ -91,6 +89,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary>
public Dictionary<Guid, Intro> Intros { get; } = new();
/// <summary>
/// Gets all discovered ending credits.
/// </summary>
public Dictionary<Guid, Intro> Credits { get; } = new();
/// <summary>
/// Gets the most recent media item queue.
/// </summary>
@ -131,12 +134,23 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
var introList = new List<Intro>();
// Serialize intros
foreach (var intro in Plugin.Instance!.Intros)
{
introList.Add(intro.Value);
}
_xmlSerializer.SerializeToFile(introList, _introPath);
// Serialize credits
introList.Clear();
foreach (var intro in Plugin.Instance!.Credits)
{
introList.Add(intro.Value);
}
_xmlSerializer.SerializeToFile(introList, _creditsPath);
}
}
@ -145,17 +159,29 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary>
public void RestoreTimestamps()
{
if (!File.Exists(_introPath))
if (File.Exists(_introPath))
{
return;
// Since dictionaries can't be easily serialized, analysis results are stored on disk as a list.
var introList = (List<Intro>)_xmlSerializer.DeserializeFromFile(
typeof(List<Intro>),
_introPath);
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.
var introList = (List<Intro>)_xmlSerializer.DeserializeFromFile(typeof(List<Intro>), _introPath);
foreach (var intro in introList)
if (File.Exists(_creditsPath))
{
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;
}
}
}
@ -174,52 +200,33 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
return GetItem(id).Path;
}
internal void UpdateTimestamps(Dictionary<Guid, Intro> newIntros, AnalysisMode mode)
/// <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)
{
switch (mode)
return _itemRepository.GetChapters(GetItem(id));
}
internal void UpdateTimestamps(Dictionary<Guid, Intro> newTimestamps, AnalysisMode mode)
{
lock (_introsLock)
{
case AnalysisMode.Introduction:
lock (_introsLock)
foreach (var intro in newTimestamps)
{
if (mode == AnalysisMode.Introduction)
{
foreach (var intro in newIntros)
{
Plugin.Instance!.Intros[intro.Key] = intro.Value;
}
Plugin.Instance!.SaveTimestamps();
Plugin.Instance!.Intros[intro.Key] = intro.Value;
}
break;
case AnalysisMode.Credits:
// TODO: FIXME: implement properly
lock (_introsLock)
else if (mode == AnalysisMode.Credits)
{
foreach (var credit in newIntros)
{
var item = GetItem(credit.Value.EpisodeId) as Episode;
if (item is null)
{
continue;
}
// Format: series, season number, episode number, title, start, end
var contents = string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"{0},{1},{2},{3},{4},{5}\n",
item.SeriesName.Replace(",", string.Empty, StringComparison.Ordinal),
item.AiredSeasonNumber ?? 0,
item.IndexNumber ?? 0,
item.Name.Replace(",", string.Empty, StringComparison.Ordinal),
Math.Round(credit.Value.IntroStart, 2),
Math.Round(credit.Value.IntroEnd, 2));
File.AppendAllText(_creditsPath, contents);
}
Plugin.Instance!.Credits[intro.Key] = intro.Value;
}
}
break;
Plugin.Instance!.SaveTimestamps();
}
}

View File

@ -98,9 +98,6 @@ public class DetectCreditsTask : IScheduledTask
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
};
// TODO: FIXME: if the queue is modified while the task is running, the task will fail.
// clone the queue before running the task to prevent this.
// Analyze all episodes in the queue using the degrees of parallelism the user specified.
Parallel.ForEach(queue, options, (season) =>
{

View File

@ -252,6 +252,10 @@ public class DetectIntroductionsTask : IScheduledTask
first.SeriesName,
first.SeasonNumber);
// Chapter analyzer
var chapter = new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>());
chapter.AnalyzeMediaFiles(episodes, AnalysisMode.Introduction, cancellationToken);
// Analyze the season with Chromaprint
var chromaprint = new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>());
chromaprint.AnalyzeMediaFiles(episodes, AnalysisMode.Introduction, cancellationToken);