diff --git a/IntroSkipper.Tests/TestAudioFingerprinting.cs b/IntroSkipper.Tests/TestAudioFingerprinting.cs index 73aabdc..068f63c 100644 --- a/IntroSkipper.Tests/TestAudioFingerprinting.cs +++ b/IntroSkipper.Tests/TestAudioFingerprinting.cs @@ -31,8 +31,7 @@ public class TestAudioFingerprinting [InlineData(19, 2_465_585_877)] public void TestBitCounting(int expectedBits, uint number) { - var chromaprint = CreateChromaprintAnalyzer(); - Assert.Equal(expectedBits, chromaprint.CountBits(number)); + Assert.Equal(expectedBits, ChromaprintAnalyzer.CountBits(number)); } [FactSkipFFmpegTests] @@ -86,7 +85,8 @@ public class TestAudioFingerprinting {77, 5}, }; - var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction); + var analyzer = CreateChromaprintAnalyzer(); + var actual = analyzer.CreateInvertedIndex(Guid.NewGuid(), fpr); Assert.Equal(expected, actual); } @@ -127,12 +127,12 @@ public class TestAudioFingerprinting var expected = new TimeRange[] { - new(44.6310, 44.8072), - new(53.5905, 53.8070), - new(53.8458, 54.2024), - new(54.2611, 54.5935), - new(54.7098, 54.9293), - new(54.9294, 55.2590), + new(44.631042, 44.807167), + new(53.590521, 53.806979), + new(53.845833, 54.202417), + new(54.261104, 54.593479), + new(54.709792, 54.929312), + new(54.929396, 55.258979), }; var range = new TimeRange(0, 60); diff --git a/IntroSkipper.Tests/TestBlackFrames.cs b/IntroSkipper.Tests/TestBlackFrames.cs index e459d14..b539c8a 100644 --- a/IntroSkipper.Tests/TestBlackFrames.cs +++ b/IntroSkipper.Tests/TestBlackFrames.cs @@ -18,7 +18,7 @@ public class TestBlackFrames var range = 1e-5; var expected = new List(); - expected.AddRange(CreateFrameSequence(2.04, 3)); + expected.AddRange(CreateFrameSequence(2, 3)); expected.AddRange(CreateFrameSequence(5, 6)); expected.AddRange(CreateFrameSequence(8, 9.96)); @@ -43,7 +43,7 @@ public class TestBlackFrames var episode = QueueFile("credits.mp4"); episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds; - var result = analyzer.AnalyzeMediaFile(episode, 240, 30, 85); + var result = analyzer.AnalyzeMediaFile(episode, 240, 85); Assert.NotNull(result); Assert.InRange(result.Start, 300 - range, 300 + range); } diff --git a/IntroSkipper/Analyzers/AnalyzerHelper.cs b/IntroSkipper/Analyzers/AnalyzerHelper.cs deleted file mode 100644 index 70133a9..0000000 --- a/IntroSkipper/Analyzers/AnalyzerHelper.cs +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (C) 2024 Intro-Skipper contributors -// SPDX-License-Identifier: GPL-3.0-only. - -using System; -using System.Collections.Generic; -using System.Linq; -using IntroSkipper.Configuration; -using IntroSkipper.Data; -using Microsoft.Extensions.Logging; - -namespace IntroSkipper.Analyzers -{ - /// - /// Analyzer Helper. - /// - public class AnalyzerHelper - { - private readonly ILogger _logger; - private readonly double _silenceDetectionMinimumDuration; - - /// - /// Initializes a new instance of the class. - /// - /// Logger. - public AnalyzerHelper(ILogger logger) - { - var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); - _silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration; - _logger = logger; - } - - /// - /// Adjusts the end timestamps of all intros so that they end at silence. - /// - /// QueuedEpisodes to adjust. - /// Original introductions. - /// Analysis mode. - /// Modified Intro Timestamps. - public IReadOnlyList AdjustIntroTimes( - IReadOnlyList episodes, - IReadOnlyList originalIntros, - AnalysisMode mode) - { - return originalIntros.Select(i => AdjustIntroForEpisode(episodes.FirstOrDefault(e => originalIntros.Any(i => i.EpisodeId == e.EpisodeId)), i, mode)).ToList(); - } - - private Segment AdjustIntroForEpisode(QueuedEpisode? episode, Segment originalIntro, AnalysisMode mode) - { - if (episode is null) - { - return new Segment(originalIntro.EpisodeId); - } - - _logger.LogTrace("{Name} original intro: {Start} - {End}", episode.Name, originalIntro.Start, originalIntro.End); - - var adjustedIntro = new Segment(originalIntro); - var originalIntroStart = new TimeRange(Math.Max(0, (int)originalIntro.Start - 5), (int)originalIntro.Start + 10); - var originalIntroEnd = new TimeRange((int)originalIntro.End - 10, Math.Min(episode.Duration, (int)originalIntro.End + 5)); - - if (!AdjustIntroBasedOnChapters(episode, adjustedIntro, originalIntroStart, originalIntroEnd) && mode == AnalysisMode.Introduction) - { - AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd); - } - - return adjustedIntro; - } - - private bool AdjustIntroBasedOnChapters(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroStart, TimeRange originalIntroEnd) - { - var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? []; - double previousTime = 0; - - for (int i = 0; i <= chapters.Count; i++) - { - double currentTime = i < chapters.Count - ? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds - : episode.Duration; - - if (originalIntroStart.Start < previousTime && previousTime < originalIntroStart.End) - { - adjustedIntro.Start = previousTime; - _logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime); - } - - if (originalIntroEnd.Start < currentTime && currentTime < originalIntroEnd.End) - { - adjustedIntro.End = currentTime; - _logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime); - return true; - } - - previousTime = currentTime; - } - - return false; - } - - private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroEnd) - { - var silence = FFmpegWrapper.DetectSilence(episode, originalIntroEnd); - - foreach (var currentRange in silence) - { - _logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, currentRange.Start, currentRange.End); - - if (IsValidSilenceForIntroAdjustment(currentRange, originalIntroEnd, adjustedIntro)) - { - adjustedIntro.End = currentRange.Start; - break; - } - } - } - - private bool IsValidSilenceForIntroAdjustment(TimeRange silenceRange, TimeRange originalIntroEnd, Segment adjustedIntro) - { - return originalIntroEnd.Intersects(silenceRange) && - silenceRange.Duration >= _silenceDetectionMinimumDuration && - silenceRange.Start >= adjustedIntro.Start; - } - } -} diff --git a/IntroSkipper/Analyzers/BlackFrameAnalyzer.cs b/IntroSkipper/Analyzers/BlackFrameAnalyzer.cs index ae11304..611f250 100644 --- a/IntroSkipper/Analyzers/BlackFrameAnalyzer.cs +++ b/IntroSkipper/Analyzers/BlackFrameAnalyzer.cs @@ -18,13 +18,9 @@ namespace IntroSkipper.Analyzers; /// public class BlackFrameAnalyzer(ILogger logger) : IMediaFileAnalyzer { - private static readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); + private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); private readonly TimeSpan _maximumError = new(0, 0, 4); private readonly ILogger _logger = logger; - private readonly int _minimumCreditsDuration = _config.MinimumCreditsDuration; - private readonly int _maximumCreditsDuration = _config.MaximumCreditsDuration; - private readonly int _maximumMovieCreditsDuration = _config.MaximumMovieCreditsDuration; - private readonly int _blackFrameMinimumPercentage = _config.BlackFrameMinimumPercentage; /// public async Task> AnalyzeMediaFiles( @@ -37,92 +33,40 @@ public class BlackFrameAnalyzer(ILogger logger) : IMediaFile throw new NotImplementedException("mode must equal Credits"); } - var creditTimes = new List(); + var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList(); - bool isFirstEpisode = true; + var searchStart = 0.0; - double searchStart = _minimumCreditsDuration; - - var searchDistance = 2 * _minimumCreditsDuration; - - foreach (var episode in analysisQueue.Where(e => !e.GetAnalyzed(mode))) + foreach (var episode in episodesWithoutIntros) { if (cancellationToken.IsCancellationRequested) { break; } - var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration; - - var chapters = Plugin.Instance!.GetChapters(episode.EpisodeId); - var lastSuitableChapter = chapters.LastOrDefault(c => - { - var start = TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds; - return start >= _minimumCreditsDuration && start <= creditDuration; - }); - - if (lastSuitableChapter is not null) + if (!AnalyzeChapters(episode, out var credit)) { - searchStart = TimeSpan.FromTicks(lastSuitableChapter.StartPositionTicks).TotalSeconds; - isFirstEpisode = false; - } - - if (isFirstEpisode) - { - var scanTime = episode.Duration - searchStart; - var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here. - - var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage); - - while (frames.Length > 0) // While black frames are found increase searchStart + if (searchStart < _config.MinimumCreditsDuration) { - searchStart += searchDistance; - - scanTime = episode.Duration - searchStart; - tr = new TimeRange(scanTime - 0.5, scanTime); - - frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage); - - if (searchStart > creditDuration) - { - searchStart = creditDuration; - break; - } + searchStart = FindSearchStart(episode); } - if (searchStart == _minimumCreditsDuration) // Skip if no black frames were found - { - continue; - } - - isFirstEpisode = false; + credit = AnalyzeMediaFile( + episode, + searchStart, + _config.BlackFrameMinimumPercentage); } - var credit = AnalyzeMediaFile( - episode, - searchStart, - searchDistance, - _blackFrameMinimumPercentage); - if (credit is null || !credit.Valid) { - // If no credits were found, reset the first-episode search logic for the next episode in the sequence. - searchStart = _minimumCreditsDuration; - isFirstEpisode = true; continue; } - searchStart = episode.Duration - credit.Start + (0.5 * searchDistance); - - creditTimes.Add(credit); - episode.SetAnalyzed(mode, true); + episode.IsAnalyzed = true; + await Plugin.Instance!.UpdateTimestampAsync(credit, mode).ConfigureAwait(false); + searchStart = episode.Duration - credit.Start + _config.MinimumCreditsDuration; } - var analyzerHelper = new AnalyzerHelper(_logger); - var adjustedCreditTimes = analyzerHelper.AdjustIntroTimes(analysisQueue, creditTimes, mode); - - await Plugin.Instance!.UpdateTimestamps(adjustedCreditTimes, mode).ConfigureAwait(false); - return analysisQueue; } @@ -131,20 +75,18 @@ public class BlackFrameAnalyzer(ILogger logger) : IMediaFile /// /// Media file to analyze. /// Search Start Piont. - /// Search Distance. /// Percentage of the frame that must be black. /// Credits timestamp. - public Segment? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int searchDistance, int minimum) + public Segment? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int minimum) { // Start by analyzing the last N minutes of the file. + var searchDistance = 2 * _config.MinimumCreditsDuration; var upperLimit = searchStart; - var lowerLimit = Math.Max(searchStart - searchDistance, _minimumCreditsDuration); + var lowerLimit = Math.Max(searchStart - searchDistance, _config.MinimumCreditsDuration); var start = TimeSpan.FromSeconds(upperLimit); var end = TimeSpan.FromSeconds(lowerLimit); var firstFrameTime = 0.0; - var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration; - // 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) @@ -177,7 +119,7 @@ public class BlackFrameAnalyzer(ILogger logger) : IMediaFile if (midpoint - TimeSpan.FromSeconds(lowerLimit) < _maximumError) { - lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), _minimumCreditsDuration); + lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), _config.MinimumCreditsDuration); // Reset end for a new search with the increased duration end = TimeSpan.FromSeconds(lowerLimit); @@ -191,7 +133,7 @@ public class BlackFrameAnalyzer(ILogger logger) : IMediaFile if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError) { - upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), creditDuration); + upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), episode.Duration - episode.CreditsFingerprintStart); // Reset start for a new search with the increased duration start = TimeSpan.FromSeconds(upperLimit); @@ -206,4 +148,71 @@ public class BlackFrameAnalyzer(ILogger logger) : IMediaFile return null; } + + private bool AnalyzeChapters(QueuedEpisode episode, out Segment? segment) + { + // Get last chapter that falls within the valid credits duration range + var suitableChapters = Plugin.Instance!.GetChapters(episode.EpisodeId) + .Select(c => TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds) + .Where(s => s >= episode.CreditsFingerprintStart && + s <= episode.Duration - _config.MinimumCreditsDuration) + .OrderByDescending(s => s).ToList(); + + // If suitable chapters found, use them to find the search start point + foreach (var chapterStart in suitableChapters) + { + // Check for black frames at chapter start + var startRange = new TimeRange(chapterStart, chapterStart + 1); + var hasBlackFramesAtStart = FFmpegWrapper.DetectBlackFrames( + episode, + startRange, + _config.BlackFrameMinimumPercentage).Length > 0; + + if (!hasBlackFramesAtStart) + { + break; + } + + // Verify no black frames before chapter start + var beforeRange = new TimeRange(chapterStart - 5, chapterStart - 4); + var hasBlackFramesBefore = FFmpegWrapper.DetectBlackFrames( + episode, + beforeRange, + _config.BlackFrameMinimumPercentage).Length > 0; + + if (!hasBlackFramesBefore) + { + segment = new(episode.EpisodeId, new TimeRange(chapterStart, episode.Duration)); + return true; + } + } + + segment = null; + return false; + } + + private double FindSearchStart(QueuedEpisode episode) + { + var searchStart = 3 * _config.MinimumCreditsDuration; + var scanTime = episode.Duration - searchStart; + var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here. + + // Keep increasing search start time while black frames are found, to avoid false positives + while (FFmpegWrapper.DetectBlackFrames(episode, tr, _config.BlackFrameMinimumPercentage).Length > 0) + { + // Increase by 2x minimum credits duration each iteration + searchStart += 2 * _config.MinimumCreditsDuration; + scanTime = episode.Duration - searchStart; + tr = new TimeRange(scanTime - 0.5, scanTime); + + // Don't search past the required credits duration from the end + if (searchStart > episode.Duration - episode.CreditsFingerprintStart) + { + searchStart = episode.Duration - episode.CreditsFingerprintStart; + break; + } + } + + return searchStart; + } } diff --git a/IntroSkipper/Analyzers/ChapterAnalyzer.cs b/IntroSkipper/Analyzers/ChapterAnalyzer.cs index 653c42c..693bda9 100644 --- a/IntroSkipper/Analyzers/ChapterAnalyzer.cs +++ b/IntroSkipper/Analyzers/ChapterAnalyzer.cs @@ -25,6 +25,7 @@ namespace IntroSkipper.Analyzers; public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyzer { private readonly ILogger _logger = logger; + private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); /// public async Task> AnalyzeMediaFiles( @@ -32,18 +33,23 @@ public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyz AnalysisMode mode, CancellationToken cancellationToken) { - var skippableRanges = new List(); - - var expression = mode == AnalysisMode.Introduction ? - Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern : - Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern; + 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; } - foreach (var episode in analysisQueue.Where(e => !e.GetAnalyzed(mode))) + var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList(); + + foreach (var episode in episodesWithoutIntros) { if (cancellationToken.IsCancellationRequested) { @@ -52,7 +58,7 @@ public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyz var skipRange = FindMatchingChapter( episode, - Plugin.Instance.GetChapters(episode.EpisodeId), + Plugin.Instance!.GetChapters(episode.EpisodeId), expression, mode); @@ -61,12 +67,10 @@ public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyz continue; } - skippableRanges.Add(skipRange); - episode.SetAnalyzed(mode, true); + episode.IsAnalyzed = true; + await Plugin.Instance!.UpdateTimestampAsync(skipRange, mode).ConfigureAwait(false); } - await Plugin.Instance.UpdateTimestamps(skippableRanges, mode).ConfigureAwait(false); - return analysisQueue; } @@ -91,12 +95,11 @@ public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyz return null; } - var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); - var creditDuration = episode.IsMovie ? config.MaximumMovieCreditsDuration : config.MaximumCreditsDuration; - var reversed = mode != AnalysisMode.Introduction; + var creditDuration = episode.IsMovie ? _config.MaximumMovieCreditsDuration : _config.MaximumCreditsDuration; + var reversed = mode == AnalysisMode.Credits; var (minDuration, maxDuration) = reversed - ? (config.MinimumCreditsDuration, creditDuration) - : (config.MinimumIntroDuration, config.MaximumIntroDuration); + ? (_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) @@ -133,7 +136,7 @@ public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyz var match = Regex.IsMatch( chapter.Name, expression, - RegexOptions.None, + RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); if (!match) diff --git a/IntroSkipper/Analyzers/ChromaprintAnalyzer.cs b/IntroSkipper/Analyzers/ChromaprintAnalyzer.cs index 95a75d2..5521e5f 100644 --- a/IntroSkipper/Analyzers/ChromaprintAnalyzer.cs +++ b/IntroSkipper/Analyzers/ChromaprintAnalyzer.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Numerics; using System.Threading; @@ -15,87 +14,45 @@ using Microsoft.Extensions.Logging; namespace IntroSkipper.Analyzers; /// -/// Chromaprint audio analyzer. +/// Initializes a new instance of the class. /// -public class ChromaprintAnalyzer : IMediaFileAnalyzer +/// Logger. +public class ChromaprintAnalyzer(ILogger logger) : IMediaFileAnalyzer { /// /// Seconds of audio in one fingerprint point. /// This value is defined by the Chromaprint library and should not be changed. /// private const double SamplesToSeconds = 0.1238; - - private readonly int _minimumIntroDuration; - - private readonly int _maximumDifferences; - - private readonly int _invertedIndexShift; - - private readonly double _maximumTimeSkip; - - private readonly ILogger _logger; - + private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); + private readonly ILogger _logger = logger; + private readonly Dictionary> _invertedIndexCache = []; private AnalysisMode _analysisMode; - /// - /// Initializes a new instance of the class. - /// - /// Logger. - public ChromaprintAnalyzer(ILogger logger) - { - var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); - _maximumDifferences = config.MaximumFingerprintPointDifferences; - _invertedIndexShift = config.InvertedIndexShift; - _maximumTimeSkip = config.MaximumTimeSkip; - _minimumIntroDuration = config.MinimumIntroDuration; - - _logger = logger; - } - /// public async Task> AnalyzeMediaFiles( IReadOnlyList analysisQueue, AnalysisMode mode, CancellationToken cancellationToken) { + // Episodes that were not analyzed. + var episodeAnalysisQueue = analysisQueue.Where(e => !e.IsAnalyzed).ToList(); + + if (episodeAnalysisQueue.Count <= 1) + { + return analysisQueue; + } + + _analysisMode = mode; + // All intros for this season. var seasonIntros = new Dictionary(); // Cache of all fingerprints for this season. var fingerprintCache = new Dictionary(); - // Episode analysis queue based on not analyzed episodes - var episodeAnalysisQueue = new List(analysisQueue); - - // Episodes that were analyzed and do not have an introduction. - var episodesWithoutIntros = episodeAnalysisQueue.Where(e => !e.GetAnalyzed(mode)).ToList(); - - _analysisMode = mode; - - if (episodesWithoutIntros.Count == 0 || episodeAnalysisQueue.Count <= 1) - { - return analysisQueue; - } - - var episodesWithFingerprint = new List(episodesWithoutIntros); - - // Load fingerprints from cache if available. - episodesWithFingerprint.AddRange(episodeAnalysisQueue.Where(e => e.GetAnalyzed(mode) && File.Exists(FFmpegWrapper.GetFingerprintCachePath(e, mode)))); - - // Ensure at least two fingerprints are present. - if (episodesWithFingerprint.Count == 1) - { - var indexInAnalysisQueue = episodeAnalysisQueue.FindIndex(episode => episode == episodesWithoutIntros[0]); - episodesWithFingerprint.AddRange(episodeAnalysisQueue - .Where((episode, index) => Math.Abs(index - indexInAnalysisQueue) <= 1 && index != indexInAnalysisQueue)); - } - - seasonIntros = episodesWithFingerprint - .Where(e => e.GetAnalyzed(mode)) - .ToDictionary(e => e.EpisodeId, e => Plugin.Instance!.GetSegmentByMode(e.EpisodeId, mode)); - // Compute fingerprints for all episodes in the season - foreach (var episode in episodesWithFingerprint) + foreach (var episode in episodeAnalysisQueue) { try { @@ -123,15 +80,14 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer } // While there are still episodes in the queue - while (episodesWithoutIntros.Count > 0) + while (episodeAnalysisQueue.Count > 0) { // Pop the first episode from the queue - var currentEpisode = episodesWithoutIntros[0]; - episodesWithoutIntros.RemoveAt(0); - episodesWithFingerprint.Remove(currentEpisode); + var currentEpisode = episodeAnalysisQueue[0]; + episodeAnalysisQueue.RemoveAt(0); // Search through all remaining episodes. - foreach (var remainingEpisode in episodesWithFingerprint) + foreach (var remainingEpisode in episodeAnalysisQueue) { // Compare the current episode to all remaining episodes in the queue. var (currentIntro, remainingIntro) = CompareEpisodes( @@ -192,27 +148,15 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer break; } - // If no intro is found at this point, the popped episode is not reinserted into the queue. - if (seasonIntros.ContainsKey(currentEpisode.EpisodeId)) + // If an intro is found for this episode, adjust its times and save it else add it to the list of episodes without intros. + if (seasonIntros.TryGetValue(currentEpisode.EpisodeId, out var intro)) { - episodesWithFingerprint.Add(currentEpisode); - episodeAnalysisQueue.FirstOrDefault(x => x.EpisodeId == currentEpisode.EpisodeId)?.SetAnalyzed(mode, true); + currentEpisode.IsAnalyzed = true; + await Plugin.Instance!.UpdateTimestampAsync(intro, mode).ConfigureAwait(false); } } - // If cancellation was requested, report that no episodes were analyzed. - if (cancellationToken.IsCancellationRequested) - { - return analysisQueue; - } - - // Adjust all introduction times. - var analyzerHelper = new AnalyzerHelper(_logger); - var adjustedSeasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, [.. seasonIntros.Values], _analysisMode); - - await Plugin.Instance!.UpdateTimestamps(adjustedSeasonIntros, _analysisMode).ConfigureAwait(false); - - return episodeAnalysisQueue; + return analysisQueue; } /// @@ -302,8 +246,8 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer var rhsRanges = new List(); // Generate inverted indexes for the left and right episodes. - var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, _analysisMode); - var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, _analysisMode); + var lhsIndex = CreateInvertedIndex(lhsId, lhsPoints); + var rhsIndex = CreateInvertedIndex(rhsId, rhsPoints); var indexShifts = new HashSet(); // For all audio points in the left episode, check if the right episode has a point which matches exactly. @@ -312,7 +256,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer { var originalPoint = kvp.Key; - for (var i = -1 * _invertedIndexShift; i <= _invertedIndexShift; i++) + for (var i = -1 * _config.InvertedIndexShift; i <= _config.InvertedIndexShift; i++) { var modifiedPoint = (uint)(originalPoint + i); @@ -377,7 +321,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer var diff = lhs[lhsPosition] ^ rhs[rhsPosition]; // If the difference between the samples is small, flag both times as similar. - if (CountBits(diff) > _maximumDifferences) + if (CountBits(diff) > _config.MaximumFingerprintPointDifferences) { continue; } @@ -394,23 +338,156 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer rhsTimes.Add(double.MaxValue); // Now that both fingerprints have been compared at this shift, see if there's a contiguous time range. - var lContiguous = TimeRangeHelpers.FindContiguous(lhsTimes.ToArray(), _maximumTimeSkip); - if (lContiguous is null || lContiguous.Duration < _minimumIntroDuration) + var lContiguous = TimeRangeHelpers.FindContiguous([.. lhsTimes], _config.MaximumTimeSkip); + if (lContiguous is null || lContiguous.Duration < _config.MinimumIntroDuration) { return (new TimeRange(), new TimeRange()); } // Since LHS had a contiguous time range, RHS must have one also. - var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), _maximumTimeSkip)!; + var rContiguous = TimeRangeHelpers.FindContiguous([.. rhsTimes], _config.MaximumTimeSkip)!; return (lContiguous, rContiguous); } + /// + /// Adjusts the end timestamps of all intros so that they end at silence. + /// + /// QueuedEpisode to adjust. + /// Original introduction. + private Segment AdjustIntroTimes( + QueuedEpisode episode, + Segment originalIntro) + { + _logger.LogTrace( + "{Name} original intro: {Start} - {End}", + episode.Name, + originalIntro.Start, + originalIntro.End); + + var originalIntroStart = new TimeRange( + Math.Max(0, (int)originalIntro.Start - 5), + (int)originalIntro.Start + 10); + + var originalIntroEnd = new TimeRange( + (int)originalIntro.End - 10, + Math.Min(episode.Duration, (int)originalIntro.End + 5)); + + // Try to adjust based on chapters first, fall back to silence detection for intros + if (!AdjustIntroBasedOnChapters(episode, originalIntro, originalIntroStart, originalIntroEnd) && + _analysisMode == AnalysisMode.Introduction) + { + AdjustIntroBasedOnSilence(episode, originalIntro, originalIntroEnd); + } + + _logger.LogTrace( + "{Name} adjusted intro: {Start} - {End}", + episode.Name, + originalIntro.Start, + originalIntro.End); + + return originalIntro; + } + + private bool AdjustIntroBasedOnChapters( + QueuedEpisode episode, + Segment intro, + TimeRange originalIntroStart, + TimeRange originalIntroEnd) + { + var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? []; + double previousTime = 0; + + for (int i = 0; i <= chapters.Count; i++) + { + double currentTime = i < chapters.Count + ? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds + : episode.Duration; + + if (IsTimeWithinRange(previousTime, originalIntroStart)) + { + intro.Start = previousTime; + _logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime); + } + + if (IsTimeWithinRange(currentTime, originalIntroEnd)) + { + intro.End = currentTime; + _logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime); + return true; + } + + previousTime = currentTime; + } + + return false; + } + + private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment intro, TimeRange originalIntroEnd) + { + var silenceRanges = FFmpegWrapper.DetectSilence(episode, originalIntroEnd); + + foreach (var silenceRange in silenceRanges) + { + _logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, silenceRange.Start, silenceRange.End); + + if (IsValidSilenceForIntroAdjustment(silenceRange, originalIntroEnd, intro)) + { + intro.End = silenceRange.Start; + break; + } + } + } + + private bool IsValidSilenceForIntroAdjustment( + TimeRange silenceRange, + TimeRange originalIntroEnd, + Segment adjustedIntro) + { + return originalIntroEnd.Intersects(silenceRange) && + silenceRange.Duration >= _config.SilenceDetectionMinimumDuration && + silenceRange.Start >= adjustedIntro.Start; + } + + private static bool IsTimeWithinRange(double time, TimeRange range) + { + return range.Start < time && time < range.End; + } + + /// + /// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at. + /// + /// Episode ID. + /// Chromaprint fingerprint. + /// Inverted index. + public Dictionary CreateInvertedIndex(Guid id, uint[] fingerprint) + { + if (_invertedIndexCache.TryGetValue(id, out var cached)) + { + return cached; + } + + var invIndex = new Dictionary(); + + for (int i = 0; i < fingerprint.Length; i++) + { + // Get the current point. + var point = fingerprint[i]; + + // Append the current sample's timecode to the collection for this point. + invIndex[point] = i; + } + + _invertedIndexCache[id] = invIndex; + + return invIndex; + } + /// /// Count the number of bits that are set in the provided number. /// /// Number to count bits in. /// Number of bits that are equal to 1. - public int CountBits(uint number) + public static int CountBits(uint number) { return BitOperations.PopCount(number); } diff --git a/IntroSkipper/Configuration/PluginConfiguration.cs b/IntroSkipper/Configuration/PluginConfiguration.cs index e2cbda7..3189c8e 100644 --- a/IntroSkipper/Configuration/PluginConfiguration.cs +++ b/IntroSkipper/Configuration/PluginConfiguration.cs @@ -1,6 +1,7 @@ // Copyright (C) 2024 Intro-Skipper contributors // SPDX-License-Identifier: GPL-3.0-only. +using System.Collections.Generic; using System.Diagnostics; using IntroSkipper.Data; using MediaBrowser.Model.Plugins; @@ -21,11 +22,6 @@ public class PluginConfiguration : BasePluginConfiguration // ===== Analysis settings ===== - /// - /// Gets or sets the max degree of parallelism used when analyzing episodes. - /// - public int MaxParallelism { get; set; } = 2; - /// /// Gets or sets the comma separated list of library names to analyze. /// @@ -47,15 +43,10 @@ public class PluginConfiguration : BasePluginConfiguration public string ClientList { get; set; } = string.Empty; /// - /// Gets or sets a value indicating whether to scan for intros during a scheduled task. + /// Gets or sets a value indicating whether to automatically scan newly added items. /// public bool AutoDetectIntros { get; set; } = true; - /// - /// Gets or sets a value indicating whether to scan for credits during a scheduled task. - /// - public bool AutoDetectCredits { get; set; } = true; - /// /// Gets or sets a value indicating whether to analyze season 0. /// @@ -87,6 +78,26 @@ public class PluginConfiguration : BasePluginConfiguration // ===== Custom analysis settings ===== + /// + /// Gets or sets a value indicating whether Introductions should be analyzed. + /// + public bool ScanIntroduction { get; set; } = true; + + /// + /// Gets or sets a value indicating whether Credits should be analyzed. + /// + public bool ScanCredits { get; set; } = true; + + /// + /// Gets or sets a value indicating whether Recaps should be analyzed. + /// + public bool ScanRecap { get; set; } = true; + + /// + /// Gets or sets a value indicating whether Previews should be analyzed. + /// + public bool ScanPreview { get; set; } = true; + /// /// Gets or sets the percentage of each episode's audio track to analyze. /// @@ -131,20 +142,32 @@ public class PluginConfiguration : BasePluginConfiguration /// Gets or sets the regular expression used to detect introduction chapters. /// public string ChapterAnalyzerIntroductionPattern { get; set; } = - @"(^|\s)(Intro|Introduction|OP|Opening)(\s|$)"; + @"(^|\s)(Intro|Introduction|OP|Opening)(?!\sEnd)(\s|$)"; /// /// Gets or sets the regular expression used to detect ending credit chapters. /// public string ChapterAnalyzerEndCreditsPattern { get; set; } = - @"(^|\s)(Credits?|ED|Ending|End|Outro)(\s|$)"; + @"(^|\s)(Credits?|ED|Ending|Outro)(?!\sEnd)(\s|$)"; + + /// + /// Gets or sets the regular expression used to detect Preview chapters. + /// + public string ChapterAnalyzerPreviewPattern { get; set; } = + @"(^|\s)(Preview|PV|Sneak\s?Peek|Coming\s?(Up|Soon)|Next\s+(time|on|episode)|Extra|Teaser|Trailer)(?!\sEnd)(\s|:|$)"; + + /// + /// Gets or sets the regular expression used to detect Recap chapters. + /// + public string ChapterAnalyzerRecapPattern { get; set; } = + @"(^|\s)(Re?cap|Sum{1,2}ary|Prev(ious(ly)?)?|(Last|Earlier)(\s\w+)?|Catch[ -]up)(?!\sEnd)(\s|:|$)"; // ===== Playback settings ===== /// /// Gets or sets a value indicating whether to show the skip intro button. /// - public bool SkipButtonEnabled { get; set; } = false; + public bool SkipButtonEnabled { get; set; } /// /// Gets a value indicating whether to show the skip intro warning. @@ -156,11 +179,26 @@ public class PluginConfiguration : BasePluginConfiguration /// public bool AutoSkip { get; set; } + /// + /// Gets or sets the list of segment types to auto skip. + /// + public string TypeList { get; set; } = string.Empty; + /// /// Gets or sets a value indicating whether credits should be automatically skipped. /// public bool AutoSkipCredits { get; set; } + /// + /// Gets or sets a value indicating whether recap should be automatically skipped. + /// + public bool AutoSkipRecap { get; set; } + + /// + /// Gets or sets a value indicating whether preview should be automatically skipped. + /// + public bool AutoSkipPreview { get; set; } + /// /// Gets or sets the seconds before the intro starts to show the skip prompt at. /// @@ -191,11 +229,6 @@ public class PluginConfiguration : BasePluginConfiguration /// public int SecondsOfIntroStartToPlay { get; set; } - /// - /// Gets or sets the amount of credit at start to play (in seconds). - /// - public int SecondsOfCreditsStartToPlay { get; set; } - // ===== Internal algorithm settings ===== /// @@ -240,12 +273,12 @@ public class PluginConfiguration : BasePluginConfiguration /// /// Gets or sets the notification text sent after automatically skipping an introduction. /// - public string AutoSkipNotificationText { get; set; } = "Intro skipped"; + public string AutoSkipNotificationText { get; set; } = "Segment skipped"; /// - /// Gets or sets the notification text sent after automatically skipping credits. + /// Gets or sets the max degree of parallelism used when analyzing episodes. /// - public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped"; + public int MaxParallelism { get; set; } = 2; /// /// Gets or sets the number of threads for a ffmpeg process. diff --git a/IntroSkipper/Configuration/UserInterfaceConfiguration.cs b/IntroSkipper/Configuration/UserInterfaceConfiguration.cs index 407e3d3..f544d98 100644 --- a/IntroSkipper/Configuration/UserInterfaceConfiguration.cs +++ b/IntroSkipper/Configuration/UserInterfaceConfiguration.cs @@ -14,8 +14,10 @@ namespace IntroSkipper.Configuration; /// Skip button end credits text. /// Auto Skip Intro. /// Auto Skip Credits. +/// Auto Skip Recap. +/// Auto Skip Preview. /// Auto Skip Clients. -public class UserInterfaceConfiguration(bool visible, string introText, string creditsText, bool autoSkip, bool autoSkipCredits, string clientList) +public class UserInterfaceConfiguration(bool visible, string introText, string creditsText, bool autoSkip, bool autoSkipCredits, bool autoSkipRecap, bool autoSkipPreview, string clientList) { /// /// Gets or sets a value indicating whether to show the skip intro button. @@ -42,6 +44,16 @@ public class UserInterfaceConfiguration(bool visible, string introText, string c /// public bool AutoSkipCredits { get; set; } = autoSkipCredits; + /// + /// Gets or sets a value indicating whether auto skip recap. + /// + public bool AutoSkipRecap { get; set; } = autoSkipRecap; + + /// + /// Gets or sets a value indicating whether auto skip preview. + /// + public bool AutoSkipPreview { get; set; } = autoSkipPreview; + /// /// Gets or sets a value indicating clients to auto skip for. /// diff --git a/IntroSkipper/Configuration/configPage.html b/IntroSkipper/Configuration/configPage.html index d40b2c7..d135265 100644 --- a/IntroSkipper/Configuration/configPage.html +++ b/IntroSkipper/Configuration/configPage.html @@ -32,30 +32,20 @@
-
If enabled, introductions will be automatically analyzed for new media
-
- -
- - -
- If enabled, credits will be automatically analyzed for new media +
If enabled, new media will be automatically analyzed for skippable segments when added to the library

- Note: Not selecting at least one automatic detection type will disable automatic scans. To configure the scheduled task, see scheduled tasks. + Note: To configure the scheduled task, see scheduled tasks.
@@ -108,6 +98,34 @@ Per the jellyfin MediaSegments API, records must be updated individually and may be slow to regenerate.

+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
@@ -188,6 +206,43 @@
+
+ Chapter Detection Options + +
+
+ + +
Enter a regular expression to detect introduction chapters. +
Default: (^|\s)(Intro|Introduction|OP|Opening)(?!\sEnd)(\s|$) +
+
+ +
+ + +
Enter a regular expression to detect credits chapters. +
Default: (^|\s)(Credits?|ED|Ending|Outro)(?!\sEnd)(\s|$) +
+
+ +
+ + +
Enter a regular expression to detect preview chapters. +
Default: (^|\s)(Preview|PV|Sneak\s?Peek|Coming\s?(Up|Soon)|Next\s+(time|on|episode)|Teaser|Trailer)(?!\sEnd)(\s|:|$) +
+
+ +
+ + +
Enter a regular expression to detect recap chapters. +
Default: (^|\s)(Re?cap|Sum{1,2}ary|Prev(ious(ly)?)?|(Last|Earlier)(\s\w+)?|Catch[ -]up)(?!\sEnd)(\s|:|$) +
+
+
+
Process Configuration @@ -251,76 +306,57 @@
+
-
- If checked, intros will be automatically skipped for all clients.
- Note: Cannot be disabled from client popup (gear icon) settings.
- If you access Jellyfin through a reverse proxy, it must be configured to proxy websockets.
+
+
+

Limit auto skip to the following clients

+
+ + +
+ +
+
+

Auto skip the following types

+
+
+ +
-
If checked, auto skip will play the introduction of the first episode in a season.
-
+
If checked, auto skip will play the segments of the first episode in a season.
- + -
Seconds of introduction start that should be played. Defaults to 0.
-
+
Seconds of segment start that should be played. Defaults to 0.
- + -
Seconds of introduction ending that should be played. Defaults to 2.
+
Seconds of segment ending that should be played. Defaults to 2.
-
- - -
- If checked, credits will be automatically skipped for all clients.
- Note: Cannot be disabled from client popup (gear icon) settings.
- If you access Jellyfin through a reverse proxy, it must be configured to proxy websockets.
-
-
- -
- - -
Seconds of credits start that should be played. Defaults to 0.
-
-
- -
- Auto Skip Client List -
-
- - -
Clients enabled in this list will always skip automatically, regardless of the button settings below.
-
-
- (Restart required!) If checked, a skip button will be added to the server and displayed according to the settings below.
+ Restart required! If checked, a skip button will be added to the server according to the UI settings.
This button is separate from the Media Segment Actions in Jellyfin 10.10 and compatible clients.
@@ -379,12 +415,6 @@
Message shown after automatically skipping an introduction. Leave blank to disable notification.
- -
- - -
Message shown after automatically skipping credits. Leave blank to disable notification.
-
@@ -439,10 +469,26 @@ + +

- +

@@ -483,6 +529,30 @@
+
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+

@@ -511,6 +581,30 @@
+
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+

@@ -592,14 +686,24 @@
- -
+
+
+ +
- -
-
- - + +
+ + +
+ + +
+
+ + +
+


@@ -640,7 +744,9 @@ var support = document.querySelector("details#support"); var storage = document.querySelector("details#storage"); var btnEraseIntroTimestamps = document.querySelector("button#btnEraseIntroTimestamps"); + var btnEraseRecapTimestamps = document.querySelector("button#btnEraseRecapTimestamps"); var btnEraseCreditTimestamps = document.querySelector("button#btnEraseCreditTimestamps"); + var btnErasePreviewTimestamps = document.querySelector("button#btnErasePreviewTimestamps"); // all plugin configuration fields that can be get or set with .value (i.e. strings or numbers). var configurationFields = [ @@ -662,23 +768,28 @@ "HidePromptAdjustment", "RemainingSecondsOfIntro", "SecondsOfIntroStartToPlay", - "SecondsOfCreditsStartToPlay", // internals "SilenceDetectionMaximumNoise", "SilenceDetectionMinimumDuration", + "ChapterAnalyzerIntroductionPattern", + "ChapterAnalyzerEndCreditsPattern", + "ChapterAnalyzerPreviewPattern", + "ChapterAnalyzerRecapPattern", + "TypeList", // UI customization "SkipButtonIntroText", "SkipButtonEndCreditsText", "AutoSkipNotificationText", - "AutoSkipCreditsNotificationText", ]; - var booleanConfigurationFields = ["AutoDetectIntros", "AutoDetectCredits", "AnalyzeMovies", "AnalyzeSeasonZero", "SelectAllLibraries", "UpdateMediaSegments", "RebuildMediaSegments", "CacheFingerprints", "AutoSkip", "AutoSkipCredits", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonEnabled"]; + var booleanConfigurationFields = ["AutoDetectIntros", "AnalyzeMovies", "AnalyzeSeasonZero", "SelectAllLibraries", "UpdateMediaSegments", "RebuildMediaSegments", "ScanIntroduction", "ScanCredits", "ScanRecap", "ScanPreview", "CacheFingerprints", "AutoSkip", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonEnabled"]; // visualizer elements var analyzerActionsSection = document.querySelector("div#analyzerActionsSection"); var actionIntro = analyzerActionsSection.querySelector("select#actionIntro"); var actionCredits = analyzerActionsSection.querySelector("select#actionCredits"); + var actionRecap = analyzerActionsSection.querySelector("select#actionRecap"); + var actionPreview = analyzerActionsSection.querySelector("select#actionPreview"); var saveAnalyzerActionsButton = analyzerActionsSection.querySelector("button#saveAnalyzerActions"); var canvas = document.querySelector("canvas#troubleshooter"); var selectShow = document.querySelector("select#troubleshooterShow"); @@ -711,15 +822,11 @@ var librariesContainer = document.querySelector("div.folderAccessListContainer"); var skipFirstEpisode = document.querySelector("div#divSkipFirstEpisode"); var secondsOfIntroStartToPlay = document.querySelector("div#divSecondsOfIntroStartToPlay"); - var autoSkipClientList = document.getElementById("AutoSkipClientList"); - var secondsOfCreditsStartToPlay = document.querySelector("div#divSecondsOfCreditsStartToPlay"); + var autoSkipClientList = document.querySelector("div.AutoSkipClientListContainer"); var movieCreditsDuration = document.getElementById("movieCreditsDuration"); - var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText"); - var autoSkipCredits = document.querySelector("input#AutoSkipCredits"); - var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText"); function skipButtonVisibleChanged() { - if (autoSkip.checked && autoSkipCredits.checked) { + if (autoSkip.checked) { skipButtonSettings.style.display = "none"; } else if (skipButtonVisible.checked) { skipButtonSettings.style.display = "unset"; @@ -730,56 +837,20 @@ skipButtonVisible.addEventListener("change", skipButtonVisibleChanged); - function skipButtonVisibleText() { - if (autoSkip.checked && autoSkipCredits.checked) { + function autoSkipChanged() { + if (autoSkip.checked) { autoSkipClientList.style.display = "none"; skipButtonVisibleLabel.textContent = "Button unavailable due to auto skip"; - } else if (autoSkip.checked) { - autoSkipClientList.style.display = "unset"; - autoSkipClientList.style.width = "100%"; - skipButtonVisibleLabel.textContent = "Show Skip Credit Button"; - } else if (autoSkipCredits.checked) { - autoSkipClientList.style.display = "unset"; - autoSkipClientList.style.width = "100%"; - skipButtonVisibleLabel.textContent = "Show Skip Intro Button"; } else { autoSkipClientList.style.display = "unset"; autoSkipClientList.style.width = "100%"; - skipButtonVisibleLabel.textContent = "Show All Skip Buttons"; + skipButtonVisibleLabel.textContent = "Show Segment Skip Buttons"; } skipButtonVisibleChanged(); } - function autoSkipChanged() { - if (autoSkip.checked) { - skipFirstEpisode.style.display = "unset"; - autoSkipNotificationText.style.display = "unset"; - secondsOfIntroStartToPlay.style.display = "unset"; - } else { - skipFirstEpisode.style.display = "none"; - autoSkipNotificationText.style.display = "none"; - secondsOfIntroStartToPlay.style.display = "none"; - } - skipButtonVisibleText(); - } - autoSkip.addEventListener("change", autoSkipChanged); - function autoSkipCreditsChanged() { - if (autoSkipCredits.checked) { - autoSkipCreditsNotificationText.style.display = "unset"; - secondsOfCreditsStartToPlay.style.display = "unset"; - } else { - autoSkipCreditsNotificationText.style.display = "none"; - secondsOfCreditsStartToPlay.style.display = "none"; - } - skipButtonVisibleText(); - } - - autoSkipCredits.addEventListener("change", autoSkipCreditsChanged); - - skipButtonVisibleText(); // run once on launch for legacy installs - function selectAllLibrariesChanged() { if (selectAllLibraries.checked) { librariesContainer.style.display = "none"; @@ -800,12 +871,12 @@ const container = document.getElementById(containerId); const checkedItems = new Set(document.getElementById(textFieldId).value.split(", ").filter(Boolean)); const fragment = document.createDocumentFragment(); - items.forEach((item) => { + for (const item of items) { const label = document.createElement("label"); label.className = "emby-checkbox-label"; label.innerHTML = '" + '' + item + ""; fragment.appendChild(label); - }); + } container.innerHTML = ""; container.appendChild(fragment); container.addEventListener( @@ -823,6 +894,11 @@ generateCheckboxList(devices, "autoSkipCheckboxes", "ClientList"); } + async function generateAutoSkipTypeList() { + const types = ["Introduction", "Credits", "Recap", "Preview"]; + generateCheckboxList(types, "autoSkipTypeCheckboxes", "TypeList"); + } + async function populateLibraries() { const response = await getJson("Library/VirtualFolders"); const tvLibraries = response.filter((item) => item.CollectionType === undefined || item.CollectionType === "tvshows" || item.CollectionType === "movies"); @@ -975,9 +1051,11 @@ Dashboard.showLoadingMsg(); // show the analyzer actions editor. saveAnalyzerActionsButton.style.display = "block"; - const analyzerActions = (await getJson("Intros/AnalyzerActions/" + encodeURI(selectSeason.value))) || { Introduction: "Default", Credits: "Default" }; + const analyzerActions = await getJson("Intros/AnalyzerActions/" + encodeURI(selectSeason.value)); actionIntro.value = analyzerActions.Introduction || "Default"; actionCredits.value = analyzerActions.Credits || "Default"; + actionRecap.value = analyzerActions.Recap || "Default"; + actionPreview.value = analyzerActions.Preview || "Default"; analyzerActionsSection.style.display = "unset"; // show the erase season button @@ -1030,7 +1108,7 @@ timestampError.value += selectEpisode2.value + " fingerprints missing or incomplete.\n"; } - if (timestampError.value == "") { + if (timestampError.value === "") { timestampErrorDiv.style.display = "none"; } else { timestampErrorDiv.style.display = "unset"; @@ -1065,7 +1143,7 @@ rightEpisodeEditor.style.display = "none"; - if (timestampError.value == "") { + if (timestampError.value === "") { timestampErrorDiv.style.display = "none"; } else { timestampErrorDiv.style.display = "unset"; @@ -1078,13 +1156,17 @@ // Try to get the timestamps of each intro, falling back a default value of zero if no intro was found const leftEpisodeJson = await getJson("Episode/" + selectShow.value + "/Timestamps"); - // Update the editor for the first and second episodes + // Update the editor for the movie timestampEditor.style.display = "unset"; document.querySelector("#editLeftEpisodeTitle").textContent = selectShow.value; document.querySelector("#editLeftIntroEpisodeStartEdit").value = leftEpisodeJson.Introduction.Start; document.querySelector("#editLeftIntroEpisodeEndEdit").value = leftEpisodeJson.Introduction.End; document.querySelector("#editLeftCreditEpisodeStartEdit").value = leftEpisodeJson.Credits.Start; document.querySelector("#editLeftCreditEpisodeEndEdit").value = leftEpisodeJson.Credits.End; + document.querySelector("#editLeftRecapEpisodeStartEdit").value = leftEpisodeJson.Recap.Start; + document.querySelector("#editLeftRecapEpisodeEndEdit").value = leftEpisodeJson.Recap.End; + document.querySelector("#editLeftPreviewEpisodeStartEdit").value = leftEpisodeJson.Preview.Start; + document.querySelector("#editLeftPreviewEpisodeEndEdit").value = leftEpisodeJson.Preview.End; // Update display inputs const inputs = document.querySelectorAll('#timestampEditor input[type="number"]'); @@ -1149,13 +1231,19 @@ document.querySelector("#editLeftIntroEpisodeEndEdit").value = leftEpisodeJson.Introduction.End; document.querySelector("#editLeftCreditEpisodeStartEdit").value = leftEpisodeJson.Credits.Start; document.querySelector("#editLeftCreditEpisodeEndEdit").value = leftEpisodeJson.Credits.End; - + document.querySelector("#editLeftRecapEpisodeStartEdit").value = leftEpisodeJson.Recap.Start; + document.querySelector("#editLeftRecapEpisodeEndEdit").value = leftEpisodeJson.Recap.End; + document.querySelector("#editLeftPreviewEpisodeStartEdit").value = leftEpisodeJson.Preview.Start; + document.querySelector("#editLeftPreviewEpisodeEndEdit").value = leftEpisodeJson.Preview.End; document.querySelector("#editRightEpisodeTitle").textContent = rightEpisode.text; document.querySelector("#editRightIntroEpisodeStartEdit").value = rightEpisodeJson.Introduction.Start; document.querySelector("#editRightIntroEpisodeEndEdit").value = rightEpisodeJson.Introduction.End; document.querySelector("#editRightCreditEpisodeStartEdit").value = rightEpisodeJson.Credits.Start; document.querySelector("#editRightCreditEpisodeEndEdit").value = rightEpisodeJson.Credits.End; - + document.querySelector("#editRightRecapEpisodeStartEdit").value = rightEpisodeJson.Recap.Start; + document.querySelector("#editRightRecapEpisodeEndEdit").value = rightEpisodeJson.Recap.End; + document.querySelector("#editRightPreviewEpisodeStartEdit").value = rightEpisodeJson.Preview.Start; + document.querySelector("#editRightPreviewEpisodeEndEdit").value = rightEpisodeJson.Preview.End; // Update display inputs const inputs = document.querySelectorAll('#timestampEditor input[type="number"]'); inputs.forEach((input) => { @@ -1227,14 +1315,14 @@ switch (e.key) { case "ArrowDown": - if (timestampError.value != "") { + if (timestampError.value !== "") { // if the control key is pressed, shift LHS by 10s. Otherwise, shift by 1. offsetDelta = e.ctrlKey ? 10 / 0.1238 : 1; } break; case "ArrowUp": - if (timestampError.value != "") { + if (timestampError.value !== "") { offsetDelta = e.ctrlKey ? -10 / 0.1238 : -1; } break; @@ -1251,11 +1339,11 @@ return; } - if (offsetDelta != 0) { + if (offsetDelta !== 0) { txtOffset.value = Number(txtOffset.value) + Math.floor(offsetDelta); } - if (episodeDelta != 0) { + if (episodeDelta !== 0) { // calculate the number of episodes remaining in the LHS and RHS episode pickers const lhsRemaining = selectEpisode1.selectedIndex; const rhsRemaining = selectEpisode2.length - selectEpisode2.selectedIndex - 1; @@ -1332,8 +1420,8 @@ populateLibraries(); selectAllLibrariesChanged(); autoSkipChanged(); - autoSkipCreditsChanged(); persistSkipChanged(); + generateAutoSkipTypeList(); generateAutoSkipClientList(); Dashboard.hideLoadingMsg(); @@ -1372,10 +1460,18 @@ eraseTimestamps("Introduction"); e.preventDefault(); }); + btnEraseRecapTimestamps.addEventListener("click", (e) => { + eraseTimestamps("Recap"); + e.preventDefault(); + }); btnEraseCreditTimestamps.addEventListener("click", (e) => { eraseTimestamps("Credits"); e.preventDefault(); }); + btnErasePreviewTimestamps.addEventListener("click", (e) => { + eraseTimestamps("Preview"); + e.preventDefault(); + }); btnSeasonEraseTimestamps.addEventListener("click", () => { Dashboard.confirm("Are you sure you want to erase all timestamps for this season?", "Confirm timestamp erasure", (result) => { if (!result) { @@ -1418,6 +1514,8 @@ analyzerActions: { Introduction: actionIntro.value, Credits: actionCredits.value, + Recap: actionRecap.value, + Preview: actionPreview.value, }, }; @@ -1432,25 +1530,49 @@ const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value; const newLhs = { Introduction: { + ItemId: lhsId, Start: getEditValue("editLeftIntroEpisodeStartEdit"), End: getEditValue("editLeftIntroEpisodeEndEdit"), }, Credits: { + ItemId: lhsId, Start: getEditValue("editLeftCreditEpisodeStartEdit"), End: getEditValue("editLeftCreditEpisodeEndEdit"), }, + Recap: { + ItemId: lhsId, + Start: getEditValue("editLeftRecapEpisodeStartEdit"), + End: getEditValue("editLeftRecapEpisodeEndEdit"), + }, + Preview: { + ItemId: lhsId, + Start: getEditValue("editLeftPreviewEpisodeStartEdit"), + End: getEditValue("editLeftPreviewEpisodeEndEdit"), + }, }; const rhsId = selectEpisode2.options[selectEpisode2.selectedIndex].value; const newRhs = { Introduction: { + ItemId: rhsId, Start: getEditValue("editRightIntroEpisodeStartEdit"), End: getEditValue("editRightIntroEpisodeEndEdit"), }, Credits: { + ItemId: rhsId, Start: getEditValue("editRightCreditEpisodeStartEdit"), End: getEditValue("editRightCreditEpisodeEndEdit"), }, + Recap: { + ItemId: rhsId, + Start: getEditValue("editRightRecapEpisodeStartEdit"), + End: getEditValue("editRightRecapEpisodeEndEdit"), + }, + Preview: { + ItemId: rhsId, + Start: getEditValue("editRightPreviewEpisodeStartEdit"), + End: getEditValue("editRightPreviewEpisodeEndEdit"), + }, }; fetchWithAuth("Episode/" + lhsId + "/Timestamps", "POST", JSON.stringify(newLhs)); diff --git a/IntroSkipper/Configuration/inject.js b/IntroSkipper/Configuration/inject.js index d59f110..ac614cb 100644 --- a/IntroSkipper/Configuration/inject.js +++ b/IntroSkipper/Configuration/inject.js @@ -233,7 +233,8 @@ const introSkipper = { position > segment.IntroStart && position < segment.IntroEnd - 3) ) { - return { ...segment, SegmentType: key }; + segment["SegmentType"] = key; + return segment; } } return { SegmentType: "None" }; diff --git a/IntroSkipper/Controllers/SkipIntroController.cs b/IntroSkipper/Controllers/SkipIntroController.cs index c1fdb53..9a10e54 100644 --- a/IntroSkipper/Controllers/SkipIntroController.cs +++ b/IntroSkipper/Controllers/SkipIntroController.cs @@ -73,16 +73,25 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan return NotFound(); } - if (timestamps?.Introduction.End > 0.0) + if (timestamps == null) { - var seg = new Segment(id, new TimeRange(timestamps.Introduction.Start, timestamps.Introduction.End)); - await Plugin.Instance!.UpdateTimestamps([seg], AnalysisMode.Introduction).ConfigureAwait(false); + return NoContent(); } - if (timestamps?.Credits.End > 0.0) + var segmentTypes = new[] { - var seg = new Segment(id, new TimeRange(timestamps.Credits.Start, timestamps.Credits.End)); - await Plugin.Instance!.UpdateTimestamps([seg], AnalysisMode.Credits).ConfigureAwait(false); + (AnalysisMode.Introduction, timestamps.Introduction), + (AnalysisMode.Credits, timestamps.Credits), + (AnalysisMode.Recap, timestamps.Recap), + (AnalysisMode.Preview, timestamps.Preview) + }; + + foreach (var (mode, segment) in segmentTypes) + { + if (segment.Valid) + { + await Plugin.Instance!.UpdateTimestampAsync(segment, mode).ConfigureAwait(false); + } } if (Plugin.Instance.Configuration.UpdateMediaSegments) @@ -118,7 +127,7 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan } var times = new TimeStamps(); - var segments = Plugin.Instance!.GetSegmentsById(id); + var segments = Plugin.Instance!.GetTimestamps(id); if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment)) { @@ -130,6 +139,16 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan times.Credits = creditSegment; } + if (segments.TryGetValue(AnalysisMode.Recap, out var recapSegment)) + { + times.Recap = recapSegment; + } + + if (segments.TryGetValue(AnalysisMode.Preview, out var previewSegment)) + { + times.Preview = previewSegment; + } + return times; } @@ -143,27 +162,30 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan public ActionResult> GetSkippableSegments([FromRoute] Guid id) { var segments = GetIntros(id); + var result = new Dictionary(); if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment)) { - segments[AnalysisMode.Introduction] = introSegment; + result[AnalysisMode.Introduction] = introSegment; } if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment)) { - segments[AnalysisMode.Credits] = creditSegment; + result[AnalysisMode.Credits] = creditSegment; } - return segments; + return result; } /// Lookup and return the skippable timestamps for the provided item. /// Unique identifier of this episode. /// Intro object if the provided item has an intro, null otherwise. - private static Dictionary GetIntros(Guid id) + internal static Dictionary GetIntros(Guid id) { - var timestamps = Plugin.Instance!.GetSegmentsById(id); + var timestamps = Plugin.Instance!.GetTimestamps(id); var intros = new Dictionary(); + var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds; + var config = Plugin.Instance.Configuration; foreach (var (mode, timestamp) in timestamps) { @@ -174,11 +196,10 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan // Create new Intro to avoid mutating the original stored in dictionary var segment = new Intro(timestamp); - var config = Plugin.Instance.Configuration; - // Calculate intro end time based on mode - segment.IntroEnd = mode == AnalysisMode.Credits - ? GetAdjustedIntroEnd(id, segment.IntroEnd, config) + // Calculate intro end time + segment.IntroEnd = runTime > 0 && runTime < segment.IntroEnd + 1 + ? runTime : segment.IntroEnd - config.RemainingSecondsOfIntro; // Set skip button prompt visibility times @@ -202,14 +223,6 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan return intros; } - private static double GetAdjustedIntroEnd(Guid id, double segmentEnd, PluginConfiguration config) - { - var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds; - return runTime > 0 && runTime < segmentEnd + 1 - ? runTime - : segmentEnd - config.RemainingSecondsOfIntro; - } - /// /// Erases all previously discovered introduction timestamps. /// @@ -230,9 +243,9 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan db.DbSegment.RemoveRange(segments); await db.SaveChangesAsync().ConfigureAwait(false); - if (eraseCache) + if (eraseCache && mode is AnalysisMode.Introduction or AnalysisMode.Credits) { - FFmpegWrapper.DeleteCacheFiles(mode); + await Task.Run(() => FFmpegWrapper.DeleteCacheFiles(mode)).ConfigureAwait(false); } return NoContent(); @@ -254,6 +267,8 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan config.SkipButtonEndCreditsText, config.AutoSkip, config.AutoSkipCredits, + config.AutoSkipRecap, + config.AutoSkipPreview, config.ClientList); } } diff --git a/IntroSkipper/Controllers/VisualizationController.cs b/IntroSkipper/Controllers/VisualizationController.cs index 7f0c237..6a8a0c1 100644 --- a/IntroSkipper/Controllers/VisualizationController.cs +++ b/IntroSkipper/Controllers/VisualizationController.cs @@ -96,7 +96,13 @@ public class VisualizationController(ILogger logger, Me return NotFound(); } - return Ok(Plugin.Instance!.GetAnalyzerAction(seasonId)); + var analyzerActions = new Dictionary(); + foreach (var mode in Enum.GetValues()) + { + analyzerActions[mode] = Plugin.Instance!.GetAnalyzerAction(seasonId, mode); + } + + return Ok(analyzerActions); } /// @@ -175,17 +181,10 @@ public class VisualizationController(ILogger logger, Me foreach (var episode in episodes) { cancellationToken.ThrowIfCancellationRequested(); - var segments = Plugin.Instance!.GetSegmentsById(episode.EpisodeId); - if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment)) - { - db.DbSegment.Remove(new DbSegment(introSegment, AnalysisMode.Introduction)); - } + var existingSegments = db.DbSegment.Where(s => s.ItemId == episode.EpisodeId); - if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment)) - { - db.DbSegment.Remove(new DbSegment(creditSegment, AnalysisMode.Credits)); - } + db.DbSegment.RemoveRange(existingSegments); if (eraseCache) { @@ -193,6 +192,13 @@ public class VisualizationController(ILogger logger, Me } } + var seasonInfo = db.DbSeasonInfo.Where(s => s.SeasonId == seasonId); + + foreach (var info in seasonInfo) + { + db.Entry(info).Property(s => s.EpisodeIds).CurrentValue = []; + } + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); if (Plugin.Instance.Configuration.UpdateMediaSegments) @@ -216,7 +222,7 @@ public class VisualizationController(ILogger logger, Me [HttpPost("AnalyzerActions/UpdateSeason")] public async Task UpdateAnalyzerActions([FromBody] UpdateAnalyzerActionsRequest request) { - await Plugin.Instance!.UpdateAnalyzerActionAsync(request.Id, request.AnalyzerActions).ConfigureAwait(false); + await Plugin.Instance!.SetAnalyzerActionAsync(request.Id, request.AnalyzerActions).ConfigureAwait(false); return NoContent(); } diff --git a/IntroSkipper/Data/AnalysisMode.cs b/IntroSkipper/Data/AnalysisMode.cs index 8c26dff..f2971d1 100644 --- a/IntroSkipper/Data/AnalysisMode.cs +++ b/IntroSkipper/Data/AnalysisMode.cs @@ -17,4 +17,14 @@ public enum AnalysisMode /// Detect credits. /// Credits, + + /// + /// Detect previews. + /// + Preview, + + /// + /// Detect recaps. + /// + Recap, } diff --git a/IntroSkipper/Data/QueuedEpisode.cs b/IntroSkipper/Data/QueuedEpisode.cs index 2f684ea..18b875c 100644 --- a/IntroSkipper/Data/QueuedEpisode.cs +++ b/IntroSkipper/Data/QueuedEpisode.cs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: GPL-3.0-only. using System; -using System.Collections.Generic; namespace IntroSkipper.Data; @@ -11,8 +10,6 @@ namespace IntroSkipper.Data; ///
public class QueuedEpisode { - private readonly bool[] _isAnalyzed = new bool[2]; - /// /// Gets or sets the series name. /// @@ -28,16 +25,16 @@ public class QueuedEpisode ///
public Guid EpisodeId { get; set; } + /// + /// Gets or sets the season id. + /// + public Guid SeasonId { get; set; } + /// /// Gets or sets the series id. /// public Guid SeriesId { get; set; } - /// - /// Gets a value indicating whether this media has been already analyzed. - /// - public IReadOnlyList IsAnalyzed => _isAnalyzed; - /// /// Gets or sets the full path to episode. /// @@ -58,6 +55,11 @@ public class QueuedEpisode /// public bool IsMovie { get; set; } + /// + /// Gets or sets a value indicating whether an episode has been analyzed. + /// + public bool IsAnalyzed { get; set; } + /// /// Gets or sets the timestamp (in seconds) to stop searching for an introduction at. /// @@ -72,24 +74,4 @@ public class QueuedEpisode /// Gets or sets the total duration of this media file (in seconds). /// public int Duration { get; set; } - - /// - /// Sets a value indicating whether this media has been already analyzed. - /// - /// Analysis mode. - /// Value to set. - public void SetAnalyzed(AnalysisMode mode, bool value) - { - _isAnalyzed[(int)mode] = value; - } - - /// - /// Sets a value indicating whether this media has been already analyzed. - /// - /// Analysis mode. - /// Value of the analyzed mode. - public bool GetAnalyzed(AnalysisMode mode) - { - return _isAnalyzed[(int)mode]; - } } diff --git a/IntroSkipper/Data/TimeStamps.cs b/IntroSkipper/Data/TimeStamps.cs index 8ce57bf..0b85f5f 100644 --- a/IntroSkipper/Data/TimeStamps.cs +++ b/IntroSkipper/Data/TimeStamps.cs @@ -18,5 +18,15 @@ namespace IntroSkipper.Data /// Gets or sets Credits. /// public Segment Credits { get; set; } = new Segment(); + + /// + /// Gets or sets Recap. + /// + public Segment Recap { get; set; } = new Segment(); + + /// + /// Gets or sets Preview. + /// + public Segment Preview { get; set; } = new Segment(); } } diff --git a/IntroSkipper/Db/DbSeasonInfo.cs b/IntroSkipper/Db/DbSeasonInfo.cs index fc880b9..83dc8c6 100644 --- a/IntroSkipper/Db/DbSeasonInfo.cs +++ b/IntroSkipper/Db/DbSeasonInfo.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only. using System; +using System.Collections.Generic; using IntroSkipper.Data; namespace IntroSkipper.Db; @@ -20,11 +21,13 @@ public class DbSeasonInfo /// Season ID. /// Analysis mode. /// Analyzer action. - public DbSeasonInfo(Guid seasonId, AnalysisMode mode, AnalyzerAction action) + /// Episode IDs. + public DbSeasonInfo(Guid seasonId, AnalysisMode mode, AnalyzerAction action, IEnumerable? episodeIds = null) { SeasonId = seasonId; Type = mode; Action = action; + EpisodeIds = episodeIds ?? []; } /// @@ -48,4 +51,9 @@ public class DbSeasonInfo /// Gets the analyzer action. /// public AnalyzerAction Action { get; private set; } + + /// + /// Gets the season number. + /// + public IEnumerable EpisodeIds { get; private set; } = []; } diff --git a/IntroSkipper/Db/DbSegment.cs b/IntroSkipper/Db/DbSegment.cs index 583a311..7024ad7 100644 --- a/IntroSkipper/Db/DbSegment.cs +++ b/IntroSkipper/Db/DbSegment.cs @@ -17,14 +17,14 @@ public class DbSegment /// /// Initializes a new instance of the class. /// - /// Segment. - /// Analysis mode. - public DbSegment(Segment segment, AnalysisMode mode) + /// The segment to initialize the instance with. + /// The type of analysis that was used to determine this segment. + public DbSegment(Segment segment, AnalysisMode type) { ItemId = segment.EpisodeId; Start = segment.Start; End = segment.End; - Type = mode; + Type = type; } /// @@ -35,22 +35,31 @@ public class DbSegment } /// - /// Gets the item ID. + /// Gets or sets the episode id. /// - public Guid ItemId { get; private set; } + public Guid ItemId { get; set; } /// - /// Gets the start time. + /// Gets or sets the start time. /// - public double Start { get; private set; } + public double Start { get; set; } /// - /// Gets the end time. + /// Gets or sets the end time. /// - public double End { get; private set; } + public double End { get; set; } /// - /// Gets the analysis mode. + /// Gets the type of analysis that was used to determine this segment. /// public AnalysisMode Type { get; private set; } + + /// + /// Converts the instance to a object. + /// + /// A object. + internal Segment ToSegment() + { + return new Segment(ItemId, new TimeRange(Start, End)); + } } diff --git a/IntroSkipper/Db/IntroSkipperDbContext.cs b/IntroSkipper/Db/IntroSkipperDbContext.cs index cf51c4d..920d926 100644 --- a/IntroSkipper/Db/IntroSkipperDbContext.cs +++ b/IntroSkipper/Db/IntroSkipperDbContext.cs @@ -2,7 +2,12 @@ // SPDX-License-Identifier: GPL-3.0-only. using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using IntroSkipper.Data; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; namespace IntroSkipper.Db; @@ -12,26 +17,48 @@ namespace IntroSkipper.Db; /// /// Initializes a new instance of the class. /// -/// The path to the SQLite database file. -public class IntroSkipperDbContext(string dbPath) : DbContext +public class IntroSkipperDbContext : DbContext { - private readonly string _dbPath = dbPath ?? throw new ArgumentNullException(nameof(dbPath)); + private readonly string _dbPath; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the SQLite database file. + public IntroSkipperDbContext(string dbPath) + { + _dbPath = dbPath; + DbSegment = Set(); + DbSeasonInfo = Set(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The options. + public IntroSkipperDbContext(DbContextOptions options) : base(options) + { + var folder = Environment.SpecialFolder.LocalApplicationData; + var path = Environment.GetFolderPath(folder); + _dbPath = System.IO.Path.Join(path, "introskipper.db"); + DbSegment = Set(); + DbSeasonInfo = Set(); + } /// /// Gets or sets the containing the segments. /// - public DbSet DbSegment { get; set; } = null!; + public DbSet DbSegment { get; set; } /// /// Gets or sets the containing the season information. /// - public DbSet DbSeasonInfo { get; set; } = null!; + public DbSet DbSeasonInfo { get; set; } /// protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - optionsBuilder.UseSqlite($"Data Source={_dbPath}") - .EnableSensitiveDataLogging(false); + optionsBuilder.UseSqlite($"Data Source={_dbPath}"); } /// @@ -42,15 +69,15 @@ public class IntroSkipperDbContext(string dbPath) : DbContext entity.ToTable("DbSegment"); entity.HasKey(s => new { s.ItemId, s.Type }); - entity.Property(e => e.ItemId) + entity.HasIndex(e => e.ItemId); + + entity.Property(e => e.Start) + .HasDefaultValue(0.0) .IsRequired(); - entity.Property(e => e.Type) + entity.Property(e => e.End) + .HasDefaultValue(0.0) .IsRequired(); - - entity.Property(e => e.Start); - - entity.Property(e => e.End); }); modelBuilder.Entity(entity => @@ -58,13 +85,20 @@ public class IntroSkipperDbContext(string dbPath) : DbContext entity.ToTable("DbSeasonInfo"); entity.HasKey(s => new { s.SeasonId, s.Type }); - entity.Property(e => e.SeasonId) + entity.HasIndex(e => e.SeasonId); + + entity.Property(e => e.Action) + .HasDefaultValue(AnalyzerAction.Default) .IsRequired(); - entity.Property(e => e.Type) - .IsRequired(); - - entity.Property(e => e.Action); + entity.Property(e => e.EpisodeIds) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new List(), + new ValueComparer>( + (c1, c2) => (c1 ?? new List()).SequenceEqual(c2 ?? new List()), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => c.ToList())); }); base.OnModelCreating(modelBuilder); @@ -75,6 +109,39 @@ public class IntroSkipperDbContext(string dbPath) : DbContext /// public void ApplyMigrations() { - Database.Migrate(); + // If migrations table exists, just apply pending migrations normally + if (Database.GetAppliedMigrations().Any() || !Database.CanConnect()) + { + Database.Migrate(); + return; + } + + // For databases without migration history + try + { + // Backup existing data + List segments; + using (var db = new IntroSkipperDbContext(_dbPath)) + { + segments = [.. db.DbSegment.AsEnumerable().Where(s => s.ToSegment().Valid)]; + } + + // Delete old database + Database.EnsureDeleted(); + + // Create new database with proper migration history + Database.Migrate(); + + // Restore the data + using (var db = new IntroSkipperDbContext(_dbPath)) + { + db.DbSegment.AddRange(segments); + db.SaveChanges(); + } + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to apply migrations", ex); + } } } diff --git a/IntroSkipper/Db/IntroSkipperDbContextFactory.cs b/IntroSkipper/Db/IntroSkipperDbContextFactory.cs new file mode 100644 index 0000000..c738d73 --- /dev/null +++ b/IntroSkipper/Db/IntroSkipperDbContextFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace IntroSkipper.Db; + +/// +/// IntroSkipperDbContext factory. +/// +public class IntroSkipperDbContextFactory : IDesignTimeDbContextFactory +{ + /// + public IntroSkipperDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite("Data Source=introskipper.db") + .EnableSensitiveDataLogging(false); + + return new IntroSkipperDbContext(optionsBuilder.Options); + } +} diff --git a/IntroSkipper/FFmpegWrapper.cs b/IntroSkipper/FFmpegWrapper.cs index c04693b..9aa363f 100644 --- a/IntroSkipper/FFmpegWrapper.cs +++ b/IntroSkipper/FFmpegWrapper.cs @@ -36,8 +36,6 @@ public static partial class FFmpegWrapper private static Dictionary ChromaprintLogs { get; set; } = []; - private static ConcurrentDictionary<(Guid Id, AnalysisMode Mode), Dictionary> InvertedIndexCache { get; set; } = new(); - /// /// Check that the installed version of ffmpeg supports chromaprint. /// @@ -134,36 +132,6 @@ public static partial class FFmpegWrapper return Fingerprint(episode, mode, start, end); } - /// - /// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at. - /// - /// Episode ID. - /// Chromaprint fingerprint. - /// Mode. - /// Inverted index. - public static Dictionary CreateInvertedIndex(Guid id, uint[] fingerprint, AnalysisMode mode) - { - if (InvertedIndexCache.TryGetValue((id, mode), out var cached)) - { - return cached; - } - - var invIndex = new Dictionary(); - - for (int i = 0; i < fingerprint.Length; i++) - { - // Get the current point. - var point = fingerprint[i]; - - // Append the current sample's timecode to the collection for this point. - invIndex[point] = i; - } - - InvertedIndexCache[(id, mode)] = invIndex; - - return invIndex; - } - /// /// Detect ranges of silence in the provided episode. /// diff --git a/IntroSkipper/IntroSkipper.csproj b/IntroSkipper/IntroSkipper.csproj index 12f6615..72c1b97 100644 --- a/IntroSkipper/IntroSkipper.csproj +++ b/IntroSkipper/IntroSkipper.csproj @@ -13,7 +13,8 @@ - + + diff --git a/IntroSkipper/Manager/QueueManager.cs b/IntroSkipper/Manager/QueueManager.cs index 066512c..028587f 100644 --- a/IntroSkipper/Manager/QueueManager.cs +++ b/IntroSkipper/Manager/QueueManager.cs @@ -152,9 +152,12 @@ namespace IntroSkipper.Manager { QueueEpisode(episode); } - else if (_analyzeMovies && item is Movie movie) + else if (item is Movie movie) { - QueueMovie(movie); + if (_analyzeMovies) + { + QueueMovie(movie); + } } else { @@ -219,6 +222,7 @@ namespace IntroSkipper.Manager SeriesName = episode.SeriesName, SeasonNumber = episode.AiredSeasonNumber ?? 0, SeriesId = episode.SeriesId, + SeasonId = episode.SeasonId, EpisodeId = episode.Id, Name = episode.Name, IsAnime = isAnime, @@ -253,10 +257,12 @@ namespace IntroSkipper.Manager { SeriesName = movie.Name, SeriesId = movie.Id, + SeasonId = movie.Id, EpisodeId = movie.Id, Name = movie.Name, Path = movie.Path, Duration = Convert.ToInt32(duration), + CreditsFingerprintStart = Convert.ToInt32(duration - pluginInstance.Configuration.MaximumMovieCreditsDuration), IsMovie = true }); @@ -288,11 +294,13 @@ namespace IntroSkipper.Manager /// Queued media items. /// Analysis mode. /// Media items that have been verified to exist in Jellyfin and in storage. - public (IReadOnlyList VerifiedItems, IReadOnlyCollection RequiredModes) + internal (IReadOnlyList QueuedEpisodes, IReadOnlyCollection RequiredModes) VerifyQueue(IReadOnlyList candidates, IReadOnlyCollection modes) { var verified = new List(); - var reqModes = new HashSet(); + var requiredModes = new HashSet(); + + var episodeIds = Plugin.Instance!.GetEpisodeIds(candidates[0].SeasonId); foreach (var candidate in candidates) { @@ -305,20 +313,12 @@ namespace IntroSkipper.Manager } verified.Add(candidate); - var segments = Plugin.Instance!.GetSegmentsById(candidate.EpisodeId); foreach (var mode in modes) { - if (segments.TryGetValue(mode, out var segment)) + if (!episodeIds.TryGetValue(mode, out var ids) || !ids.Contains(candidate.EpisodeId) || Plugin.Instance!.AnalyzeAgain) { - if (segment.Valid) - { - candidate.SetAnalyzed(mode, true); - } - } - else - { - reqModes.Add(mode); + requiredModes.Add(mode); } } } @@ -332,7 +332,7 @@ namespace IntroSkipper.Manager } } - return (verified, reqModes); + return (verified, requiredModes); } } } diff --git a/IntroSkipper/Migrations/20241116153434_InitialCreate.Designer.cs b/IntroSkipper/Migrations/20241116153434_InitialCreate.Designer.cs new file mode 100644 index 0000000..467d8ad --- /dev/null +++ b/IntroSkipper/Migrations/20241116153434_InitialCreate.Designer.cs @@ -0,0 +1,73 @@ +// +using System; +using IntroSkipper.Db; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IntroSkipper.Migrations +{ + [DbContext(typeof(IntroSkipperDbContext))] + [Migration("20241116153434_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("IntroSkipper.Db.DbSeasonInfo", b => + { + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Action") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("EpisodeIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("SeasonId", "Type"); + + b.HasIndex("SeasonId"); + + b.ToTable("DbSeasonInfo", (string)null); + }); + + modelBuilder.Entity("IntroSkipper.Db.DbSegment", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("End") + .ValueGeneratedOnAdd() + .HasColumnType("REAL") + .HasDefaultValue(0.0); + + b.Property("Start") + .ValueGeneratedOnAdd() + .HasColumnType("REAL") + .HasDefaultValue(0.0); + + b.HasKey("ItemId", "Type"); + + b.HasIndex("ItemId"); + + b.ToTable("DbSegment", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/IntroSkipper/Migrations/20241116153434_InitialCreate.cs b/IntroSkipper/Migrations/20241116153434_InitialCreate.cs new file mode 100644 index 0000000..10a867e --- /dev/null +++ b/IntroSkipper/Migrations/20241116153434_InitialCreate.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IntroSkipper.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DbSeasonInfo", + columns: table => new + { + SeasonId = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + Action = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + EpisodeIds = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DbSeasonInfo", x => new { x.SeasonId, x.Type }); + }); + + migrationBuilder.CreateTable( + name: "DbSegment", + columns: table => new + { + ItemId = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + Start = table.Column(type: "REAL", nullable: false, defaultValue: 0.0), + End = table.Column(type: "REAL", nullable: false, defaultValue: 0.0) + }, + constraints: table => + { + table.PrimaryKey("PK_DbSegment", x => new { x.ItemId, x.Type }); + }); + + migrationBuilder.CreateIndex( + name: "IX_DbSeasonInfo_SeasonId", + table: "DbSeasonInfo", + column: "SeasonId"); + + migrationBuilder.CreateIndex( + name: "IX_DbSegment_ItemId", + table: "DbSegment", + column: "ItemId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DbSeasonInfo"); + + migrationBuilder.DropTable( + name: "DbSegment"); + } + } +} diff --git a/IntroSkipper/Migrations/IntroSkipperDbContextModelSnapshot.cs b/IntroSkipper/Migrations/IntroSkipperDbContextModelSnapshot.cs new file mode 100644 index 0000000..db60c7f --- /dev/null +++ b/IntroSkipper/Migrations/IntroSkipperDbContextModelSnapshot.cs @@ -0,0 +1,70 @@ +// +using System; +using IntroSkipper.Db; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +#nullable disable + +namespace IntroSkipper.Migrations +{ + [DbContext(typeof(IntroSkipperDbContext))] + partial class IntroSkipperDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("IntroSkipper.Db.DbSeasonInfo", b => + { + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Action") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("EpisodeIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("SeasonId", "Type"); + + b.HasIndex("SeasonId"); + + b.ToTable("DbSeasonInfo", (string)null); + }); + + modelBuilder.Entity("IntroSkipper.Db.DbSegment", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("End") + .ValueGeneratedOnAdd() + .HasColumnType("REAL") + .HasDefaultValue(0.0); + + b.Property("Start") + .ValueGeneratedOnAdd() + .HasColumnType("REAL") + .HasDefaultValue(0.0); + + b.HasKey("ItemId", "Type"); + + b.HasIndex("ItemId"); + + b.ToTable("DbSegment", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/IntroSkipper/Plugin.cs b/IntroSkipper/Plugin.cs index d3c3f46..e133336 100644 --- a/IntroSkipper/Plugin.cs +++ b/IntroSkipper/Plugin.cs @@ -14,7 +14,6 @@ using IntroSkipper.Configuration; using IntroSkipper.Data; using IntroSkipper.Db; using IntroSkipper.Helper; -using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Configuration; @@ -37,7 +36,6 @@ public class Plugin : BasePlugin, IHasWebPages { private readonly ILibraryManager _libraryManager; private readonly IItemRepository _itemRepository; - private readonly IApplicationHost _applicationHost; private readonly ILogger _logger; private readonly string _introPath; private readonly string _creditsPath; @@ -46,7 +44,6 @@ public class Plugin : BasePlugin, IHasWebPages /// /// Initializes a new instance of the class. /// - /// Application host. /// Instance of the interface. /// Instance of the interface. /// Server configuration manager. @@ -54,7 +51,6 @@ public class Plugin : BasePlugin, IHasWebPages /// Item repository. /// Logger. public Plugin( - IApplicationHost applicationHost, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IServerConfigurationManager serverConfiguration, @@ -65,7 +61,6 @@ public class Plugin : BasePlugin, IHasWebPages { Instance = this; - _applicationHost = applicationHost; _libraryManager = libraryManager; _itemRepository = itemRepository; _logger = logger; @@ -128,11 +123,10 @@ public class Plugin : BasePlugin, IHasWebPages try { using var db = new IntroSkipperDbContext(_dbPath); - db.Database.EnsureCreated(); db.ApplyMigrations(); if (File.Exists(_introPath) || File.Exists(_creditsPath)) { - RestoreTimestampsAsync(db).GetAwaiter().GetResult(); + RestoreTimestamps(); } } catch (Exception ex) @@ -160,6 +154,11 @@ public class Plugin : BasePlugin, IHasWebPages /// public string DbPath => _dbPath; + /// + /// Gets or sets a value indicating whether to analyze again. + /// + public bool AnalyzeAgain { get; set; } + /// /// Gets the most recent media item queue. /// @@ -199,18 +198,16 @@ public class Plugin : BasePlugin, IHasWebPages /// /// Restore previous analysis results from disk. /// - /// IntroSkipperDbContext. - /// A representing the asynchronous operation. - public async Task RestoreTimestampsAsync(IntroSkipperDbContext db) + public void RestoreTimestamps() { + using var db = new IntroSkipperDbContext(_dbPath); // Import intros if (File.Exists(_introPath)) { var introList = XmlSerializationHelper.DeserializeFromXml(_introPath); foreach (var intro in introList) { - var dbSegment = new DbSegment(intro, AnalysisMode.Introduction); - db.DbSegment.Add(dbSegment); + db.DbSegment.Add(new DbSegment(intro, AnalysisMode.Introduction)); } } @@ -220,12 +217,11 @@ public class Plugin : BasePlugin, IHasWebPages var creditList = XmlSerializationHelper.DeserializeFromXml(_creditsPath); foreach (var credit in creditList) { - var dbSegment = new DbSegment(credit, AnalysisMode.Credits); - db.DbSegment.Add(dbSegment); + db.DbSegment.Add(new DbSegment(credit, AnalysisMode.Credits)); } } - await db.SaveChangesAsync().ConfigureAwait(false); + db.SaveChanges(); File.Delete(_introPath); File.Delete(_creditsPath); @@ -259,7 +255,7 @@ public class Plugin : BasePlugin, IHasWebPages return id != Guid.Empty ? _libraryManager.GetItemById(id) : null; } - internal IReadOnlyList GetCollectionFolders(Guid id) + internal ICollection GetCollectionFolders(Guid id) { var item = GetItem(id); return item is not null ? _libraryManager.GetCollectionFolders(item) : []; @@ -301,48 +297,43 @@ public class Plugin : BasePlugin, IHasWebPages return _itemRepository.GetChapters(item); } - internal async Task UpdateTimestamps(IReadOnlyList newTimestamps, AnalysisMode mode) + internal async Task UpdateTimestampAsync(Segment segment, AnalysisMode mode) { - if (newTimestamps.Count == 0) - { - return; - } - - _logger.LogDebug("Starting UpdateTimestamps with {Count} segments for mode {Mode}", newTimestamps.Count, mode); - using var db = new IntroSkipperDbContext(_dbPath); - var segments = newTimestamps.Select(s => new DbSegment(s, mode)).ToList(); - - var newItemIds = segments.Select(s => s.ItemId).ToHashSet(); - var existingIds = db.DbSegment - .Where(s => s.Type == mode && newItemIds.Contains(s.ItemId)) - .Select(s => s.ItemId) - .ToHashSet(); - - foreach (var segment in segments) + try { - if (existingIds.Contains(segment.ItemId)) + var existing = await db.DbSegment + .FirstOrDefaultAsync(s => s.ItemId == segment.EpisodeId && s.Type == mode) + .ConfigureAwait(false); + + var dbSegment = new DbSegment(segment, mode); + if (existing is not null) { - db.DbSegment.Update(segment); + db.Entry(existing).CurrentValues.SetValues(dbSegment); } else { - db.DbSegment.Add(segment); + db.DbSegment.Add(dbSegment); } - } - await db.SaveChangesAsync().ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update timestamp for episode {EpisodeId}", segment.EpisodeId); + throw; + } } - internal async Task ClearInvalidSegments() + internal IReadOnlyDictionary GetTimestamps(Guid id) { using var db = new IntroSkipperDbContext(_dbPath); - db.DbSegment.RemoveRange(db.DbSegment.Where(s => s.End == 0)); - await db.SaveChangesAsync().ConfigureAwait(false); + return db.DbSegment.Where(s => s.ItemId == id) + .ToDictionary(s => s.Type, s => s.ToSegment()); } - internal async Task CleanTimestamps(HashSet episodeIds) + internal async Task CleanTimestamps(IEnumerable episodeIds) { using var db = new IntroSkipperDbContext(_dbPath); db.DbSegment.RemoveRange(db.DbSegment @@ -350,35 +341,7 @@ public class Plugin : BasePlugin, IHasWebPages await db.SaveChangesAsync().ConfigureAwait(false); } - internal IReadOnlyDictionary GetSegmentsById(Guid id) - { - using var db = new IntroSkipperDbContext(_dbPath); - return db.DbSegment - .Where(s => s.ItemId == id) - .ToDictionary( - s => s.Type, - s => new Segment - { - EpisodeId = s.ItemId, - Start = s.Start, - End = s.End - }); - } - - internal Segment GetSegmentByMode(Guid id, AnalysisMode mode) - { - using var db = new IntroSkipperDbContext(_dbPath); - return db.DbSegment - .Where(s => s.ItemId == id && s.Type == mode) - .Select(s => new Segment - { - EpisodeId = s.ItemId, - Start = s.Start, - End = s.End - }).FirstOrDefault() ?? new Segment(id); - } - - internal async Task UpdateAnalyzerActionAsync(Guid id, IReadOnlyDictionary analyzerActions) + internal async Task SetAnalyzerActionAsync(Guid id, IReadOnlyDictionary analyzerActions) { using var db = new IntroSkipperDbContext(_dbPath); var existingEntries = await db.DbSeasonInfo @@ -388,24 +351,42 @@ public class Plugin : BasePlugin, IHasWebPages foreach (var (mode, action) in analyzerActions) { - var dbSeasonInfo = new DbSeasonInfo(id, mode, action); if (existingEntries.TryGetValue(mode, out var existing)) { - db.Entry(existing).CurrentValues.SetValues(dbSeasonInfo); + db.Entry(existing).Property(s => s.Action).CurrentValue = action; } else { - db.DbSeasonInfo.Add(dbSeasonInfo); + db.DbSeasonInfo.Add(new DbSeasonInfo(id, mode, action)); } } await db.SaveChangesAsync().ConfigureAwait(false); } - internal IReadOnlyDictionary GetAnalyzerAction(Guid id) + internal async Task SetEpisodeIdsAsync(Guid id, AnalysisMode mode, IEnumerable episodeIds) { using var db = new IntroSkipperDbContext(_dbPath); - return db.DbSeasonInfo.Where(s => s.SeasonId == id).ToDictionary(s => s.Type, s => s.Action); + var seasonInfo = db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode); + + if (seasonInfo is null) + { + seasonInfo = new DbSeasonInfo(id, mode, AnalyzerAction.Default, episodeIds); + db.DbSeasonInfo.Add(seasonInfo); + } + else + { + db.Entry(seasonInfo).Property(s => s.EpisodeIds).CurrentValue = episodeIds; + } + + await db.SaveChangesAsync().ConfigureAwait(false); + } + + internal IReadOnlyDictionary> GetEpisodeIds(Guid id) + { + using var db = new IntroSkipperDbContext(_dbPath); + return db.DbSeasonInfo.Where(s => s.SeasonId == id) + .ToDictionary(s => s.Type, s => s.EpisodeIds); } internal AnalyzerAction GetAnalyzerAction(Guid id, AnalysisMode mode) @@ -414,11 +395,11 @@ public class Plugin : BasePlugin, IHasWebPages return db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode)?.Action ?? AnalyzerAction.Default; } - internal async Task CleanSeasonInfoAsync() + internal async Task CleanSeasonInfoAsync(IEnumerable ids) { using var db = new IntroSkipperDbContext(_dbPath); var obsoleteSeasons = await db.DbSeasonInfo - .Where(s => !Instance!.QueuedMediaItems.Keys.Contains(s.SeasonId)) + .Where(s => !ids.Contains(s.SeasonId)) .ToListAsync().ConfigureAwait(false); db.DbSeasonInfo.RemoveRange(obsoleteSeasons); await db.SaveChangesAsync().ConfigureAwait(false); diff --git a/IntroSkipper/PluginServiceRegistrator.cs b/IntroSkipper/PluginServiceRegistrator.cs index ec0c586..60d96f9 100644 --- a/IntroSkipper/PluginServiceRegistrator.cs +++ b/IntroSkipper/PluginServiceRegistrator.cs @@ -19,7 +19,6 @@ namespace IntroSkipper public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) { serviceCollection.AddHostedService(); - serviceCollection.AddHostedService(); serviceCollection.AddHostedService(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/IntroSkipper/Providers/SegmentProvider.cs b/IntroSkipper/Providers/SegmentProvider.cs index da7860c..c41eb53 100644 --- a/IntroSkipper/Providers/SegmentProvider.cs +++ b/IntroSkipper/Providers/SegmentProvider.cs @@ -32,40 +32,58 @@ namespace IntroSkipper.Providers var segments = new List(); var remainingTicks = Plugin.Instance.Configuration.RemainingSecondsOfIntro * TimeSpan.TicksPerSecond; - var itemSegments = Plugin.Instance.GetSegmentsById(request.ItemId); + var itemSegments = Plugin.Instance.GetTimestamps(request.ItemId); + var runTimeTicks = Plugin.Instance.GetItem(request.ItemId)?.RunTimeTicks ?? 0; - // Add intro segment if found - if (itemSegments.TryGetValue(AnalysisMode.Introduction, out var introSegment) && introSegment.Valid) + // Define mappings between AnalysisMode and MediaSegmentType + var segmentMappings = new List<(AnalysisMode Mode, MediaSegmentType Type)> { - segments.Add(new MediaSegmentDto - { - StartTicks = (long)(introSegment.Start * TimeSpan.TicksPerSecond), - EndTicks = (long)(introSegment.End * TimeSpan.TicksPerSecond) - remainingTicks, - ItemId = request.ItemId, - Type = MediaSegmentType.Intro - }); - } + (AnalysisMode.Introduction, MediaSegmentType.Intro), + (AnalysisMode.Recap, MediaSegmentType.Recap), + (AnalysisMode.Preview, MediaSegmentType.Preview), + (AnalysisMode.Credits, MediaSegmentType.Outro) + }; - // Add outro/credits segment if found - if (itemSegments.TryGetValue(AnalysisMode.Credits, out var creditSegment) && creditSegment.Valid) + foreach (var (mode, type) in segmentMappings) { - var creditEndTicks = (long)(creditSegment.End * TimeSpan.TicksPerSecond); - var runTimeTicks = Plugin.Instance.GetItem(request.ItemId)?.RunTimeTicks ?? long.MaxValue; - - segments.Add(new MediaSegmentDto + if (itemSegments.TryGetValue(mode, out var segment) && segment.Valid) { - StartTicks = (long)(creditSegment.Start * TimeSpan.TicksPerSecond), - EndTicks = runTimeTicks > creditEndTicks + TimeSpan.TicksPerSecond - ? creditEndTicks - remainingTicks - : runTimeTicks, - ItemId = request.ItemId, - Type = MediaSegmentType.Outro - }); + long startTicks = (long)(segment.Start * TimeSpan.TicksPerSecond); + long endTicks = CalculateEndTicks(mode, segment, runTimeTicks, remainingTicks); + + segments.Add(new MediaSegmentDto + { + StartTicks = startTicks, + EndTicks = endTicks, + ItemId = request.ItemId, + Type = type + }); + } } return Task.FromResult>(segments); } + /// + /// Calculates the end ticks based on the segment type and runtime. + /// + private static long CalculateEndTicks(AnalysisMode mode, Segment segment, long runTimeTicks, long remainingTicks) + { + long endTicks = (long)(segment.End * TimeSpan.TicksPerSecond); + + if (mode is AnalysisMode.Preview or AnalysisMode.Credits) + { + if (runTimeTicks > 0 && runTimeTicks < endTicks + TimeSpan.TicksPerSecond) + { + return Math.Max(runTimeTicks, endTicks); + } + + return endTicks - remainingTicks; + } + + return endTicks - remainingTicks; + } + /// public ValueTask Supports(BaseItem item) => ValueTask.FromResult(item is Episode or Movie); } diff --git a/IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs b/IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs index fca8335..393aaa0 100644 --- a/IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs +++ b/IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs @@ -3,12 +3,13 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; using IntroSkipper.Analyzers; +using IntroSkipper.Configuration; using IntroSkipper.Data; +using IntroSkipper.Db; using IntroSkipper.Manager; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; @@ -18,158 +19,136 @@ namespace IntroSkipper.ScheduledTasks; /// /// Common code shared by all media item analyzer tasks. /// -public class BaseItemAnalyzerTask +/// +/// Initializes a new instance of the class. +/// +/// Task logger. +/// Logger factory. +/// Library manager. +/// MediaSegmentUpdateManager. +public class BaseItemAnalyzerTask( + ILogger logger, + ILoggerFactory loggerFactory, + ILibraryManager libraryManager, + MediaSegmentUpdateManager mediaSegmentUpdateManager) { - private readonly IReadOnlyCollection _analysisModes; - private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; - private readonly ILibraryManager _libraryManager; - private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager; - - /// - /// Initializes a new instance of the class. - /// - /// Analysis mode. - /// Task logger. - /// Logger factory. - /// Library manager. - /// MediaSegmentUpdateManager. - public BaseItemAnalyzerTask( - IReadOnlyCollection modes, - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager, - MediaSegmentUpdateManager mediaSegmentUpdateManager) - { - _analysisModes = modes; - _logger = logger; - _loggerFactory = loggerFactory; - _libraryManager = libraryManager; - _mediaSegmentUpdateManager = mediaSegmentUpdateManager; - } + private readonly ILogger _logger = logger; + private readonly ILoggerFactory _loggerFactory = loggerFactory; + private readonly ILibraryManager _libraryManager = libraryManager; + private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager; + private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); /// /// Analyze all media items on the server. /// - /// Progress. + /// Progress reporter. /// Cancellation token. - /// Season Ids to analyze. - /// A representing the asynchronous operation. - public async Task AnalyzeItems( + /// Season IDs to analyze. + /// A task representing the asynchronous operation. + public async Task AnalyzeItemsAsync( IProgress progress, CancellationToken cancellationToken, IReadOnlyCollection? seasonsToAnalyze = null) { - var ffmpegValid = FFmpegWrapper.CheckFFmpegVersion(); // Assert that ffmpeg with chromaprint is installed - if (Plugin.Instance!.Configuration.WithChromaprint && !ffmpegValid) + if (_config.WithChromaprint && !FFmpegWrapper.CheckFFmpegVersion()) { throw new FingerprintException( - "Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg5. If Jellyfin is running in a container, upgrade to version 10.8.0 or newer."); + "Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg7. If Jellyfin is running in a container, upgrade to version 10.10.0 or newer."); } + HashSet modes = [ + .. _config.ScanIntroduction ? [AnalysisMode.Introduction] : Array.Empty(), + .. _config.ScanCredits ? [AnalysisMode.Credits] : Array.Empty(), + .. _config.ScanRecap ? [AnalysisMode.Recap] : Array.Empty(), + .. _config.ScanPreview ? [AnalysisMode.Preview] : Array.Empty() + ]; + var queueManager = new QueueManager( _loggerFactory.CreateLogger(), _libraryManager); var queue = queueManager.GetMediaItems(); - // Filter the queue based on seasonsToAnalyze - if (seasonsToAnalyze is { Count: > 0 }) + if (seasonsToAnalyze?.Count > 0) { - queue = queue.Where(kvp => seasonsToAnalyze.Contains(kvp.Key)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + queue = queue.Where(kvp => seasonsToAnalyze.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); } - int totalQueued = queue.Sum(kvp => kvp.Value.Count) * _analysisModes.Count; + int totalQueued = queue.Sum(kvp => kvp.Value.Count) * modes.Count; if (totalQueued == 0) { throw new FingerprintException( "No libraries selected for analysis. Please visit the plugin settings to configure."); } - var totalProcessed = 0; + int totalProcessed = 0; var options = new ParallelOptions { - MaxDegreeOfParallelism = Plugin.Instance.Configuration.MaxParallelism, + MaxDegreeOfParallelism = Math.Max(1, _config.MaxParallelism), CancellationToken = cancellationToken }; await Parallel.ForEachAsync(queue, options, async (season, ct) => { - var updateManagers = false; - - // Since the first run of the task can run for multiple hours, ensure that none - // of the current media items were deleted from Jellyfin since the task was started. - var (episodes, requiredModes) = queueManager.VerifyQueue( - season.Value, - _analysisModes); + var updateMediaSegments = false; + var (episodes, requiredModes) = queueManager.VerifyQueue(season.Value, modes); if (episodes.Count == 0) { return; } - var first = episodes[0]; - if (requiredModes.Count == 0) - { - _logger.LogDebug( - "All episodes in {Name} season {Season} have already been analyzed", - first.SeriesName, - first.SeasonNumber); - - Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly - progress.Report(totalProcessed * 100 / totalQueued); - } - else if (_analysisModes.Count != requiredModes.Count) - { - Interlocked.Add(ref totalProcessed, episodes.Count); - progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed - } - try { - ct.ThrowIfCancellationRequested(); - - foreach (AnalysisMode mode in requiredModes) + var firstEpisode = episodes[0]; + if (modes.Count != requiredModes.Count) { - var action = Plugin.Instance!.GetAnalyzerAction(season.Key, mode); - var analyzed = await AnalyzeItems(episodes, mode, action, ct).ConfigureAwait(false); + Interlocked.Add(ref totalProcessed, episodes.Count * (modes.Count - requiredModes.Count)); + progress.Report((double)totalProcessed / totalQueued * 100); + } + + foreach (var mode in requiredModes) + { + ct.ThrowIfCancellationRequested(); + int analyzed = await AnalyzeItemsAsync( + episodes, + mode, + ct).ConfigureAwait(false); Interlocked.Add(ref totalProcessed, analyzed); - updateManagers = analyzed > 0 || updateManagers; - - progress.Report(totalProcessed * 100 / totalQueued); + updateMediaSegments = analyzed > 0 || updateMediaSegments; + progress.Report((double)totalProcessed / totalQueued * 100); } } - catch (OperationCanceledException ex) + catch (OperationCanceledException) { - _logger.LogDebug(ex, "Analysis cancelled"); + _logger.LogInformation("Analysis was canceled."); } catch (FingerprintException ex) { - _logger.LogWarning( - "Unable to analyze {Series} season {Season}: unable to fingerprint: {Ex}", - first.SeriesName, - first.SeasonNumber, - ex); + _logger.LogWarning(ex, "Fingerprint exception during analysis."); } catch (Exception ex) { - _logger.LogError(ex, "An unexpected error occurred during analysis"); + _logger.LogError(ex, "An unexpected error occurred during analysis."); throw; } - if (Plugin.Instance.Configuration.RebuildMediaSegments || (updateManagers && Plugin.Instance.Configuration.UpdateMediaSegments)) + if (_config.RebuildMediaSegments || (updateMediaSegments && _config.UpdateMediaSegments)) { await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, ct).ConfigureAwait(false); } }).ConfigureAwait(false); - if (Plugin.Instance.Configuration.RebuildMediaSegments) + Plugin.Instance!.AnalyzeAgain = false; + + if (_config.RebuildMediaSegments) { - _logger.LogInformation("Turning Mediasegment"); - Plugin.Instance.Configuration.RebuildMediaSegments = false; - Plugin.Instance.SaveConfiguration(); + _logger.LogInformation("Regenerated media segments."); + _config.RebuildMediaSegments = false; + Plugin.Instance!.SaveConfiguration(); } } @@ -178,24 +157,28 @@ public class BaseItemAnalyzerTask /// /// Media items to analyze. /// Analysis mode. - /// Analyzer action. /// Cancellation token. - /// Number of items that were successfully analyzed. - private async Task AnalyzeItems( + /// Number of items successfully analyzed. + private async Task AnalyzeItemsAsync( IReadOnlyList items, AnalysisMode mode, - AnalyzerAction action, CancellationToken cancellationToken) { - var totalItems = items.Count(e => !e.GetAnalyzed(mode)); - - // Only analyze specials (season 0) if the user has opted in. var first = items[0]; - if (!first.IsMovie && first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero) + if (!first.IsMovie && first.SeasonNumber == 0 && !_config.AnalyzeSeasonZero) { return 0; } + // Reset the IsAnalyzed flag for all items + foreach (var item in items) + { + item.IsAnalyzed = false; + } + + // Get the analyzer action for the current mode + var action = Plugin.Instance!.GetAnalyzerAction(first.SeasonId, mode); + _logger.LogInformation( "[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}", mode, @@ -203,24 +186,30 @@ public class BaseItemAnalyzerTask first.SeriesName, first.SeasonNumber); - var analyzers = new Collection(); + // Create a list of analyzers to use for the current mode + var analyzers = new List(); if (action is AnalyzerAction.Chapter or AnalyzerAction.Default) { analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger())); } - if (first.IsAnime && !first.IsMovie && action is AnalyzerAction.Chromaprint or AnalyzerAction.Default) + if (first.IsAnime && _config.WithChromaprint && + mode is not (AnalysisMode.Recap or AnalysisMode.Preview) && + action is AnalyzerAction.Default or AnalyzerAction.Chromaprint) { analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger())); } - if (mode is AnalysisMode.Credits && action is AnalyzerAction.BlackFrame or AnalyzerAction.Default) + if (mode is AnalysisMode.Credits && + action is AnalyzerAction.Default or AnalyzerAction.BlackFrame) { analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger())); } - if (!first.IsAnime && !first.IsMovie && action is AnalyzerAction.Chromaprint or AnalyzerAction.Default) + if (!first.IsAnime && !first.IsMovie && + mode is not (AnalysisMode.Recap or AnalysisMode.Preview) && + action is AnalyzerAction.Default or AnalyzerAction.Chromaprint) { analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger())); } @@ -229,16 +218,13 @@ public class BaseItemAnalyzerTask // analyzed items from the queue. foreach (var analyzer in analyzers) { - items = await analyzer.AnalyzeMediaFiles(items, mode, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); + items = await analyzer.AnalyzeMediaFiles(items, mode, cancellationToken).ConfigureAwait(false); } - // Add items without intros/credits to blacklist. - var blacklisted = new List(items.Where(e => !e.GetAnalyzed(mode)).Select(e => new Segment(e.EpisodeId))); - _logger.LogDebug("Blacklisting {Count} items for mode {Mode}", blacklisted.Count, mode); - await Plugin.Instance!.UpdateTimestamps(blacklisted, mode).ConfigureAwait(false); - totalItems -= blacklisted.Count; + // Set the episode IDs for the analyzed items + await Plugin.Instance!.SetEpisodeIdsAsync(first.SeasonId, mode, items.Select(i => i.EpisodeId)).ConfigureAwait(false); - return totalItems; + return items.Where(i => i.IsAnalyzed).Count(); } } diff --git a/IntroSkipper/ScheduledTasks/CleanCacheTask.cs b/IntroSkipper/ScheduledTasks/CleanCacheTask.cs index 2115b62..8f078c7 100644 --- a/IntroSkipper/ScheduledTasks/CleanCacheTask.cs +++ b/IntroSkipper/ScheduledTasks/CleanCacheTask.cs @@ -86,8 +86,6 @@ public class CleanCacheTask : IScheduledTask .SelectMany(episodes => episodes.Select(e => e.EpisodeId)) .ToHashSet(); - await Plugin.Instance!.ClearInvalidSegments().ConfigureAwait(false); - await Plugin.Instance!.CleanTimestamps(validEpisodeIds).ConfigureAwait(false); // Identify invalid episode IDs @@ -105,7 +103,9 @@ public class CleanCacheTask : IScheduledTask } // Clean up Season information by removing items that are no longer exist. - await Plugin.Instance!.CleanSeasonInfoAsync().ConfigureAwait(false); + await Plugin.Instance!.CleanSeasonInfoAsync(queue.Keys).ConfigureAwait(false); + + Plugin.Instance!.AnalyzeAgain = true; progress.Report(100); } diff --git a/IntroSkipper/ScheduledTasks/DetectCreditsTask.cs b/IntroSkipper/ScheduledTasks/DetectCreditsTask.cs deleted file mode 100644 index 830b848..0000000 --- a/IntroSkipper/ScheduledTasks/DetectCreditsTask.cs +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (C) 2024 Intro-Skipper contributors -// SPDX-License-Identifier: GPL-3.0-only. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using IntroSkipper.Data; -using IntroSkipper.Manager; -using IntroSkipper.Services; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Logging; - -namespace IntroSkipper.ScheduledTasks; - -/// -/// Analyze all television episodes for credits. -/// TODO: analyze all media files. -/// -/// -/// Initializes a new instance of the class. -/// -/// Logger factory. -/// Library manager. -/// Logger. -/// MediaSegment Update Manager. -public class DetectCreditsTask( - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager, - MediaSegmentUpdateManager mediaSegmentUpdateManager) : IScheduledTask -{ - private readonly ILogger _logger = logger; - - private readonly ILoggerFactory _loggerFactory = loggerFactory; - - private readonly ILibraryManager _libraryManager = libraryManager; - - private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager; - - /// - /// Gets the task name. - /// - public string Name => "Detect Credits"; - - /// - /// Gets the task category. - /// - public string Category => "Intro Skipper"; - - /// - /// Gets the task description. - /// - public string Description => "Analyzes media to determine the timestamp and length of credits"; - - /// - /// Gets the task key. - /// - public string Key => "CPBIntroSkipperDetectCredits"; - - /// - /// Analyze all episodes in the queue. Only one instance of this task should be run at a time. - /// - /// Task progress. - /// Cancellation token. - /// Task. - public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) - { - if (_libraryManager is null) - { - throw new InvalidOperationException("Library manager was null"); - } - - // abort automatic analyzer if running - if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling) - { - _logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState); - await Entrypoint.CancelAutomaticTaskAsync(cancellationToken).ConfigureAwait(false); - } - - using (await ScheduledTaskSemaphore.AcquireAsync(cancellationToken).ConfigureAwait(false)) - { - _logger.LogInformation("Scheduled Task is starting"); - - var modes = new List { AnalysisMode.Credits }; - - var baseCreditAnalyzer = new BaseItemAnalyzerTask( - modes, - _loggerFactory.CreateLogger(), - _loggerFactory, - _libraryManager, - _mediaSegmentUpdateManager); - - await baseCreditAnalyzer.AnalyzeItems(progress, cancellationToken).ConfigureAwait(false); - } - } - - /// - /// Get task triggers. - /// - /// Task triggers. - public IEnumerable GetDefaultTriggers() - { - return []; - } -} diff --git a/IntroSkipper/ScheduledTasks/DetectIntrosTask.cs b/IntroSkipper/ScheduledTasks/DetectIntrosTask.cs deleted file mode 100644 index 8c6d3bd..0000000 --- a/IntroSkipper/ScheduledTasks/DetectIntrosTask.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (C) 2024 Intro-Skipper contributors -// SPDX-License-Identifier: GPL-3.0-only. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using IntroSkipper.Data; -using IntroSkipper.Manager; -using IntroSkipper.Services; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Logging; - -namespace IntroSkipper.ScheduledTasks; - -/// -/// Analyze all television episodes for introduction sequences. -/// -/// -/// Initializes a new instance of the class. -/// -/// Logger factory. -/// Library manager. -/// Logger. -/// MediaSegment Update Manager. -public class DetectIntrosTask( - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager, - MediaSegmentUpdateManager mediaSegmentUpdateManager) : IScheduledTask -{ - private readonly ILogger _logger = logger; - - private readonly ILoggerFactory _loggerFactory = loggerFactory; - - private readonly ILibraryManager _libraryManager = libraryManager; - - private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager; - - /// - /// Gets the task name. - /// - public string Name => "Detect Intros"; - - /// - /// Gets the task category. - /// - public string Category => "Intro Skipper"; - - /// - /// Gets the task description. - /// - public string Description => "Analyzes media to determine the timestamp and length of intros."; - - /// - /// Gets the task key. - /// - public string Key => "CPBIntroSkipperDetectIntroductions"; - - /// - /// Analyze all episodes in the queue. Only one instance of this task should be run at a time. - /// - /// Task progress. - /// Cancellation token. - /// Task. - public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) - { - if (_libraryManager is null) - { - throw new InvalidOperationException("Library manager was null"); - } - - // abort automatic analyzer if running - if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling) - { - _logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState); - await Entrypoint.CancelAutomaticTaskAsync(cancellationToken).ConfigureAwait(false); - } - - using (await ScheduledTaskSemaphore.AcquireAsync(cancellationToken).ConfigureAwait(false)) - { - _logger.LogInformation("Scheduled Task is starting"); - - var modes = new List { AnalysisMode.Introduction }; - - var baseIntroAnalyzer = new BaseItemAnalyzerTask( - modes, - _loggerFactory.CreateLogger(), - _loggerFactory, - _libraryManager, - _mediaSegmentUpdateManager); - - await baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken).ConfigureAwait(false); - } - } - - /// - /// Get task triggers. - /// - /// Task triggers. - public IEnumerable GetDefaultTriggers() - { - return []; - } -} diff --git a/IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs b/IntroSkipper/ScheduledTasks/DetectSegmentsTask.cs similarity index 78% rename from IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs rename to IntroSkipper/ScheduledTasks/DetectSegmentsTask.cs index ffe7c3e..4b454fc 100644 --- a/IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs +++ b/IntroSkipper/ScheduledTasks/DetectSegmentsTask.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using IntroSkipper.Data; using IntroSkipper.Manager; using IntroSkipper.Services; using MediaBrowser.Controller.Library; @@ -15,22 +14,22 @@ using Microsoft.Extensions.Logging; namespace IntroSkipper.ScheduledTasks; /// -/// Analyze all television episodes for introduction sequences. +/// Analyze all television episodes for media segments. /// /// -/// Initializes a new instance of the class. +/// Initializes a new instance of the class. /// /// Logger factory. /// Library manager. /// Logger. /// MediaSegment Update Manager. -public class DetectIntrosCreditsTask( - ILogger logger, +public class DetectSegmentsTask( + ILogger logger, ILoggerFactory loggerFactory, ILibraryManager libraryManager, MediaSegmentUpdateManager mediaSegmentUpdateManager) : IScheduledTask { - private readonly ILogger _logger = logger; + private readonly ILogger _logger = logger; private readonly ILoggerFactory _loggerFactory = loggerFactory; @@ -41,7 +40,7 @@ public class DetectIntrosCreditsTask( /// /// Gets the task name. /// - public string Name => "Detect Intros and Credits"; + public string Name => "Detect and Analyze Media Segments"; /// /// Gets the task category. @@ -56,7 +55,7 @@ public class DetectIntrosCreditsTask( /// /// Gets the task key. /// - public string Key => "CPBIntroSkipperDetectIntrosCredits"; + public string Key => "IntroSkipperDetectSegmentsTask"; /// /// Analyze all episodes in the queue. Only one instance of this task should be run at a time. @@ -74,7 +73,7 @@ public class DetectIntrosCreditsTask( // abort automatic analyzer if running if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling) { - _logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState); + _logger.LogInformation("Automatic Task is {TaskState} and will be canceled.", Entrypoint.AutomaticTaskState); await Entrypoint.CancelAutomaticTaskAsync(cancellationToken).ConfigureAwait(false); } @@ -82,16 +81,13 @@ public class DetectIntrosCreditsTask( { _logger.LogInformation("Scheduled Task is starting"); - var modes = new List { AnalysisMode.Introduction, AnalysisMode.Credits }; - var baseIntroAnalyzer = new BaseItemAnalyzerTask( - modes, - _loggerFactory.CreateLogger(), + _loggerFactory.CreateLogger(), _loggerFactory, _libraryManager, _mediaSegmentUpdateManager); - await baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken).ConfigureAwait(false); + await baseIntroAnalyzer.AnalyzeItemsAsync(progress, cancellationToken).ConfigureAwait(false); } } diff --git a/IntroSkipper/Services/AutoSkip.cs b/IntroSkipper/Services/AutoSkip.cs index b851d44..78e4cd7 100644 --- a/IntroSkipper/Services/AutoSkip.cs +++ b/IntroSkipper/Services/AutoSkip.cs @@ -2,15 +2,15 @@ // SPDX-License-Identifier: GPL-3.0-only. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Timers; using IntroSkipper.Configuration; +using IntroSkipper.Controllers; using IntroSkipper.Data; -using IntroSkipper.Db; -using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Entities; @@ -18,7 +18,6 @@ using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Session; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Timer = System.Timers.Timer; namespace IntroSkipper.Services { @@ -32,124 +31,116 @@ namespace IntroSkipper.Services /// User data manager. /// Session manager. /// Logger. - public class AutoSkip( + public sealed class AutoSkip( IUserDataManager userDataManager, ISessionManager sessionManager, ILogger logger) : IHostedService, IDisposable { - private readonly object _sentSeekCommandLock = new(); - - private ILogger _logger = logger; - private IUserDataManager _userDataManager = userDataManager; - private ISessionManager _sessionManager = sessionManager; - private Timer _playbackTimer = new(1000); - private Dictionary _sentSeekCommand = []; + private readonly IUserDataManager _userDataManager = userDataManager; + private readonly ISessionManager _sessionManager = sessionManager; + private readonly ILogger _logger = logger; + private readonly System.Timers.Timer _playbackTimer = new(1000); + private readonly ConcurrentDictionary> _sentSeekCommand = []; + private PluginConfiguration _config = new(); private HashSet _clientList = []; + private HashSet _segmentTypes = []; + private bool _autoSkipEnabled; private void AutoSkipChanged(object? sender, BasePluginConfiguration e) { - var configuration = (PluginConfiguration)e; - _clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; - var newState = configuration.AutoSkip || _clientList.Count > 0; - _logger.LogDebug("Setting playback timer enabled to {NewState}", newState); - _playbackTimer.Enabled = newState; + _config = (PluginConfiguration)e; + _clientList = [.. _config.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + _segmentTypes = [.. _config.TypeList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(Enum.Parse)]; + _autoSkipEnabled = (_config.AutoSkip || _clientList.Count > 0) && _segmentTypes.Count > 0; + _logger.LogDebug("Setting playback timer enabled to {AutoSkipEnabled}", _autoSkipEnabled); + _playbackTimer.Enabled = _autoSkipEnabled; } private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) { - var itemId = e.Item.Id; - var newState = false; - var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); - // Ignore all events except playback start & end - if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) + if (e.SaveReason is not (UserDataSaveReason.PlaybackStart or UserDataSaveReason.PlaybackFinished) || !_autoSkipEnabled) { return; } - // Lookup the session for this item. - SessionInfo? session = null; + var itemId = e.Item.Id; + var session = _sessionManager.Sessions + .FirstOrDefault(s => s.UserId == e.UserId && s.NowPlayingItem?.Id == itemId); - try + if (session is null) { - foreach (var needle in _sessionManager.Sessions) + // Clean up orphaned sessions + if (!_sessionManager.Sessions + .Where(s => s.UserId == e.UserId && s.NowPlayingItem is null) + .Any(s => _sentSeekCommand.TryRemove(s.DeviceId, out _))) { - if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) - { - session = needle; - break; - } + _logger.LogInformation("Unable to find active session for item {ItemId}", itemId); } - if (session == null) - { - _logger.LogInformation("Unable to find session for {Item}", itemId); - return; - } - } - catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) - { return; } - // If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session. - if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) - { - newState = true; - } - // Reset the seek command state for this device. - lock (_sentSeekCommandLock) - { - var device = session.DeviceId; + var device = session.DeviceId; + _logger.LogDebug("Getting intros for session {Session}", device); - _logger.LogDebug("Resetting seek command state for session {Session}", device); - _sentSeekCommand[device] = newState; - } + bool firstEpisode = _config.SkipFirstEpisode && e.Item.IndexNumber.GetValueOrDefault(-1) == 1; + var intros = SkipIntroController.GetIntros(itemId) + .Where(i => _segmentTypes.Contains(i.Key) && (!firstEpisode || i.Key != AnalysisMode.Introduction)) + .Select(i => i.Value) + .ToList(); + + _sentSeekCommand.AddOrUpdate(device, intros, (_, _) => intros); } private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) { - foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkip || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase))) + foreach (var session in _sessionManager.Sessions.Where(s => _config.AutoSkip || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase))) { var deviceId = session.DeviceId; - var itemId = session.NowPlayingItem.Id; - var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond; // Don't send the seek command more than once in the same session. - lock (_sentSeekCommandLock) - { - if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) - { - _logger.LogTrace("Already sent seek command for session {Session}", deviceId); - continue; - } - } - - // Assert that an intro was detected for this item. - var intro = Plugin.Instance!.GetSegmentByMode(itemId, AnalysisMode.Introduction); - if (!intro.Valid) + if (!_sentSeekCommand.TryGetValue(deviceId, out var intros)) { continue; } - // Seek is unreliable if called at the very start of an episode. - var adjustedStart = Math.Max(1, intro.Start + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay); - var adjustedEnd = intro.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro; + var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond; + + var currentIntro = intros.FirstOrDefault(i => + position >= Math.Max(1, i.IntroStart + _config.SecondsOfIntroStartToPlay) && + position < i.IntroEnd - 3.0); // 3 seconds before the end of the intro + + if (currentIntro is null) + { + continue; + } + + var introEnd = currentIntro.IntroEnd; + + intros.Remove(currentIntro); + + // Check if adjacent segment is within the maximum skip range. + var maxTimeSkip = _config.MaximumTimeSkip + _config.RemainingSecondsOfIntro; + var nextIntro = intros.FirstOrDefault(i => introEnd + maxTimeSkip >= i.IntroStart && + introEnd < i.IntroEnd); + + if (nextIntro is not null) + { + introEnd = nextIntro.IntroEnd; + intros.Remove(nextIntro); + } + + _logger.LogDebug("Found segment for session {Session}, removing from list, {Intros} segments remaining", deviceId, intros.Count); _logger.LogTrace( - "Playback position is {Position}, intro runs from {Start} to {End}", - position, - adjustedStart, - adjustedEnd); - - if (position < adjustedStart || position > adjustedEnd) - { - continue; - } + "Playback position is {Position}", + position); // Notify the user that an introduction is being skipped for them. - var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText; + var notificationText = _config.AutoSkipNotificationText; + if (!string.IsNullOrWhiteSpace(notificationText)) { _sessionManager.SendMessageCommand( @@ -173,40 +164,20 @@ namespace IntroSkipper.Services { Command = PlaystateCommand.Seek, ControllingUserId = session.UserId.ToString(), - SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond, + SeekPositionTicks = (long)introEnd * TimeSpan.TicksPerSecond, }, CancellationToken.None); // Flag that we've sent the seek command so that it's not sent repeatedly - lock (_sentSeekCommandLock) - { - _logger.LogTrace("Setting seek command state for session {Session}", deviceId); - _sentSeekCommand[deviceId] = true; - } + _logger.LogTrace("Setting seek command state for session {Session}", deviceId); } } /// - /// Dispose. + /// Dispose resources. /// public void Dispose() { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Protected dispose. - /// - /// Dispose. - protected virtual void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - _playbackTimer.Stop(); _playbackTimer.Dispose(); } @@ -231,6 +202,8 @@ namespace IntroSkipper.Services public Task StopAsync(CancellationToken cancellationToken) { _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; + Plugin.Instance!.ConfigurationChanged -= AutoSkipChanged; + _playbackTimer.Stop(); return Task.CompletedTask; } } diff --git a/IntroSkipper/Services/AutoSkipCredits.cs b/IntroSkipper/Services/AutoSkipCredits.cs deleted file mode 100644 index 3eafb2f..0000000 --- a/IntroSkipper/Services/AutoSkipCredits.cs +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (C) 2024 Intro-Skipper contributors -// SPDX-License-Identifier: GPL-3.0-only. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using System.Timers; -using IntroSkipper.Configuration; -using IntroSkipper.Data; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Plugins; -using MediaBrowser.Model.Session; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Timer = System.Timers.Timer; - -namespace IntroSkipper.Services -{ - /// - /// Automatically skip past credit sequences. - /// Commands clients to seek to the end of the credits as soon as they start playing it. - /// - /// - /// Initializes a new instance of the class. - /// - /// User data manager. - /// Session manager. - /// Logger. - public class AutoSkipCredits( - IUserDataManager userDataManager, - ISessionManager sessionManager, - ILogger logger) : IHostedService, IDisposable - { - private readonly object _sentSeekCommandLock = new(); - - private ILogger _logger = logger; - private IUserDataManager _userDataManager = userDataManager; - private ISessionManager _sessionManager = sessionManager; - private Timer _playbackTimer = new(1000); - private Dictionary _sentSeekCommand = []; - private HashSet _clientList = []; - - private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e) - { - var configuration = (PluginConfiguration)e; - _clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; - var newState = configuration.AutoSkipCredits || _clientList.Count > 0; - _logger.LogDebug("Setting playback timer enabled to {NewState}", newState); - _playbackTimer.Enabled = newState; - } - - private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) - { - var itemId = e.Item.Id; - var newState = false; - var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); - - // Ignore all events except playback start & end - if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) - { - return; - } - - // Lookup the session for this item. - SessionInfo? session = null; - - try - { - foreach (var needle in _sessionManager.Sessions) - { - if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) - { - session = needle; - break; - } - } - - if (session == null) - { - _logger.LogInformation("Unable to find session for {Item}", itemId); - return; - } - } - catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) - { - return; - } - - // If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session. - if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) - { - newState = true; - } - - // Reset the seek command state for this device. - lock (_sentSeekCommandLock) - { - var device = session.DeviceId; - - _logger.LogDebug("Resetting seek command state for session {Session}", device); - _sentSeekCommand[device] = newState; - } - } - - private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) - { - foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkipCredits || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase))) - { - var deviceId = session.DeviceId; - var itemId = session.NowPlayingItem.Id; - var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond; - - // Don't send the seek command more than once in the same session. - lock (_sentSeekCommandLock) - { - if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) - { - _logger.LogTrace("Already sent seek command for session {Session}", deviceId); - continue; - } - } - - // Assert that credits were detected for this item. - var credit = Plugin.Instance!.GetSegmentByMode(itemId, AnalysisMode.Credits); - if (!credit.Valid) - { - continue; - } - - // Seek is unreliable if called at the very end of an episode. - var adjustedStart = credit.Start + Plugin.Instance.Configuration.SecondsOfCreditsStartToPlay; - var adjustedEnd = credit.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro; - - _logger.LogTrace( - "Playback position is {Position}, credits run from {Start} to {End}", - position, - adjustedStart, - adjustedEnd); - - if (position < adjustedStart || position > adjustedEnd) - { - continue; - } - - // Notify the user that credits are being skipped for them. - var notificationText = Plugin.Instance!.Configuration.AutoSkipCreditsNotificationText; - if (!string.IsNullOrWhiteSpace(notificationText)) - { - _sessionManager.SendMessageCommand( - session.Id, - session.Id, - new MessageCommand - { - Header = string.Empty, // some clients require header to be a string instead of null - Text = notificationText, - TimeoutMs = 2000, - }, - CancellationToken.None); - } - - _logger.LogDebug("Sending seek command to {Session}", deviceId); - - _sessionManager.SendPlaystateCommand( - session.Id, - session.Id, - new PlaystateRequest - { - Command = PlaystateCommand.Seek, - ControllingUserId = session.UserId.ToString(), - SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond, - }, - CancellationToken.None); - - // Flag that we've sent the seek command so that it's not sent repeatedly - lock (_sentSeekCommandLock) - { - _logger.LogTrace("Setting seek command state for session {Session}", deviceId); - _sentSeekCommand[deviceId] = true; - } - } - } - - /// - /// Dispose. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Protected dispose. - /// - /// Dispose. - protected virtual void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - _playbackTimer.Stop(); - _playbackTimer.Dispose(); - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogDebug("Setting up automatic credit skipping"); - - _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; - Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged; - - // Make the timer restart automatically and set enabled to match the configuration value. - _playbackTimer.AutoReset = true; - _playbackTimer.Elapsed += PlaybackTimer_Elapsed; - - AutoSkipCreditChanged(null, Plugin.Instance.Configuration); - - return Task.CompletedTask; - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; - return Task.CompletedTask; - } - } -} diff --git a/IntroSkipper/Services/Entrypoint.cs b/IntroSkipper/Services/Entrypoint.cs index 79b33d7..063b91a 100644 --- a/IntroSkipper/Services/Entrypoint.cs +++ b/IntroSkipper/Services/Entrypoint.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using IntroSkipper.Configuration; -using IntroSkipper.Data; using IntroSkipper.Manager; using IntroSkipper.ScheduledTasks; using MediaBrowser.Controller.Entities.Movies; @@ -112,7 +111,7 @@ namespace IntroSkipper.Services /// The . private void OnItemChanged(object? sender, ItemChangeEventArgs itemChangeEventArgs) { - if ((_config.AutoDetectIntros || _config.AutoDetectCredits) && + if (_config.AutoDetectIntros && itemChangeEventArgs.Item is { LocationType: not LocationType.Virtual } item) { Guid? id = item is Episode episode ? episode.SeasonId : (item is Movie movie ? movie.Id : null); @@ -132,7 +131,7 @@ namespace IntroSkipper.Services /// The . private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs) { - if ((_config.AutoDetectIntros || _config.AutoDetectCredits) && + if (_config.AutoDetectIntros && eventArgs.Result is { Key: "RefreshLibrary", Status: TaskCompletionStatus.Completed } && AutomaticTaskState != TaskState.Running) { @@ -143,7 +142,7 @@ namespace IntroSkipper.Services private void OnSettingsChanged(object? sender, BasePluginConfiguration e) { _config = (PluginConfiguration)e; - _ = Plugin.Instance!.ClearInvalidSegments(); + Plugin.Instance!.AnalyzeAgain = true; } /// @@ -196,20 +195,8 @@ namespace IntroSkipper.Services _seasonsToAnalyze.Clear(); _analyzeAgain = false; - var modes = new List(); - - if (_config.AutoDetectIntros) - { - modes.Add(AnalysisMode.Introduction); - } - - if (_config.AutoDetectCredits) - { - modes.Add(AnalysisMode.Credits); - } - - var analyzer = new BaseItemAnalyzerTask(modes, _loggerFactory.CreateLogger(), _loggerFactory, _libraryManager, _mediaSegmentUpdateManager); - await analyzer.AnalyzeItems(new Progress(), _cancellationTokenSource.Token, seasonIds).ConfigureAwait(false); + var analyzer = new BaseItemAnalyzerTask(_loggerFactory.CreateLogger(), _loggerFactory, _libraryManager, _mediaSegmentUpdateManager); + await analyzer.AnalyzeItemsAsync(new Progress(), _cancellationTokenSource.Token, seasonIds).ConfigureAwait(false); if (_analyzeAgain && !_cancellationTokenSource.IsCancellationRequested) {