// Copyright (C) 2024 Intro-Skipper contributors // 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; /// /// Chapter name analyzer. /// /// /// Initializes a new instance of the class. /// /// Logger. public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyzer { private readonly ILogger _logger = logger; private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); /// public async Task> AnalyzeMediaFiles( IReadOnlyList 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; } /// /// Searches a list of chapter names for one that matches the provided regular expression. /// Only public to allow for unit testing. /// /// Episode. /// Media item chapters. /// Regular expression pattern. /// Analysis mode. /// Intro object containing skippable time range, or null if no chapter matched. public Segment? FindMatchingChapter( QueuedEpisode episode, IReadOnlyList 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; } }