parent
bcb5e5ea30
commit
a68914ca8f
@ -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
|
||||
};
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) =>
|
||||
{
|
||||
|
@ -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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user