rlauuzo 6ccf002e51
Recaps and Previews Support (#357)
* Recaps and Previews Support

* Add draft UI of preview / recap edit

* remove intro/credit tasks

* Update configPage.html

* rename task

* Reorganize settings by relation

* More standardized formatting

* Some additional formatting

* fix a typo

* Update configPage.html

* Allow missing recap / prview data

* More risk to corrupt than benefit

* Update TimeStamps.cs

* Update PluginConfiguration.cs

* Update configPage.html

* Update PluginConfiguration.cs

* Add chapter regex to settings

* Move all UI into UI section

* Move ending seconds with similar

* Add default

* fixes

* Update SkipIntroController.cs

* Autoskip all segments

* Check if adjacent segment

* Update AutoSkip.cs

* Update AutoSkip.cs

* Settings apply to all segment types

* Update SegmentProvider

* Update configPage.html

Whoops

* Update Plugin.cs

* Update AutoSkip.cs

* Let’s call it missing instead

* Update BaseItemAnalyzerTask.cs

* Update BaseItemAnalyzerTask.cs

* Update BaseItemAnalyzerTask.cs

* Move "select" all below list

* Clarify button wording

* Update configPage.html

* Nope, long client list will hide it

* Simplify wording

* Update QueuedEpisode.cs

* fix unit test for ffmpeg7

* Add migration

* Restore DataContract

* update

* Update configPage.html

* remove analyzed status

* Update AutoSkip.cs

* Update configPage.html typo

* Store analyzed items in seasoninfo

* Update VisualizationController.cs

* update

* Update IntroSkipperDbContext.cs

* Add preview / recap delete

* This keeps changing itself

* Update SkipIntroController.cs

* Rather add it to be removed

---------

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: TwistedUmbrellaX <1173913+AbandonedCart@users.noreply.github.com>
Co-authored-by: Kilian von Pflugk <github@jumoog.io>
2024-11-21 09:42:55 -05:00

173 lines
6.2 KiB
C#

// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.Analyzers;
/// <summary>
/// Chapter name analyzer.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="ChapterAnalyzer"/> class.
/// </remarks>
/// <param name="logger">Logger.</param>
public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyzer
{
private readonly ILogger<ChapterAnalyzer> _logger = logger;
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <inheritdoc />
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
var expression = mode switch
{
AnalysisMode.Introduction => _config.ChapterAnalyzerIntroductionPattern,
AnalysisMode.Credits => _config.ChapterAnalyzerEndCreditsPattern,
AnalysisMode.Recap => _config.ChapterAnalyzerRecapPattern,
AnalysisMode.Preview => _config.ChapterAnalyzerPreviewPattern,
_ => throw new ArgumentOutOfRangeException(nameof(mode), $"Unexpected analysis mode: {mode}")
};
if (string.IsNullOrWhiteSpace(expression))
{
return analysisQueue;
}
var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
foreach (var episode in episodesWithoutIntros)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var skipRange = FindMatchingChapter(
episode,
Plugin.Instance!.GetChapters(episode.EpisodeId),
expression,
mode);
if (skipRange is null || !skipRange.Valid)
{
continue;
}
episode.IsAnalyzed = true;
await Plugin.Instance!.UpdateTimestampAsync(skipRange, mode).ConfigureAwait(false);
}
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="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 Segment? FindMatchingChapter(
QueuedEpisode episode,
IReadOnlyList<ChapterInfo> chapters,
string expression,
AnalysisMode mode)
{
var count = chapters.Count;
if (count == 0)
{
return null;
}
var creditDuration = episode.IsMovie ? _config.MaximumMovieCreditsDuration : _config.MaximumCreditsDuration;
var reversed = mode == AnalysisMode.Credits;
var (minDuration, maxDuration) = reversed
? (_config.MinimumCreditsDuration, creditDuration)
: (_config.MinimumIntroDuration, _config.MaximumIntroDuration);
// Check all chapters
for (int i = reversed ? count - 1 : 0; reversed ? i >= 0 : i < count; i += reversed ? -1 : 1)
{
var chapter = chapters[i];
var next = chapters.ElementAtOrDefault(i + 1) ??
new ChapterInfo { StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks }; // Since the ending credits chapter may be the last chapter in the file, append a virtual chapter.
if (string.IsNullOrWhiteSpace(chapter.Name))
{
continue;
}
var currentRange = new TimeRange(
TimeSpan.FromTicks(chapter.StartPositionTicks).TotalSeconds,
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
var baseMessage = string.Format(
CultureInfo.InvariantCulture,
"{0}: Chapter \"{1}\" ({2} - {3})",
episode.Path,
chapter.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(
chapter.Name,
expression,
RegexOptions.IgnoreCase,
TimeSpan.FromSeconds(1));
if (!match)
{
_logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
continue;
}
// Check if the next (or previous for Credits) chapter also matches
var adjacentChapter = reversed ? chapters.ElementAtOrDefault(i - 1) : next;
if (adjacentChapter != null && !string.IsNullOrWhiteSpace(adjacentChapter.Name))
{
// Check for possibility of overlapping keywords
var overlap = Regex.IsMatch(
adjacentChapter.Name,
expression,
RegexOptions.None,
TimeSpan.FromSeconds(1));
if (overlap)
{
_logger.LogTrace("{Base}: ignoring (adjacent chapter also matches)", baseMessage);
continue;
}
}
_logger.LogTrace("{Base}: okay", baseMessage);
return new Segment(episode.EpisodeId, currentRange);
}
return null;
}
}