From 29ee3e0bc861d128f4f691d7eb8d159da28eab43 Mon Sep 17 00:00:00 2001 From: rlauuzo <46294892+rlauuzo@users.noreply.github.com> Date: Wed, 16 Oct 2024 14:47:20 +0200 Subject: [PATCH] Use Jellyfins MediaSegmentType (#344) * Use Jellyfins MediaSegmentType * Use primary constructor * fix autoskip * fix skip button * fix episodestate class * Update configPage.html * Update QueueManager.cs --------- Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com> Co-authored-by: Kilian von Pflugk --- .../TestAudioFingerprinting.cs | 9 +- .../TestChapterAnalyzer.cs | 19 +- .../TestEdl.cs | 1 + .../Analyzers/AnalyzerHelper.cs | 182 +++---- .../Analyzers/BlackFrameAnalyzer.cs | 5 +- .../Analyzers/ChapterAnalyzer.cs | 10 +- .../Analyzers/ChromaprintAnalyzer.cs | 21 +- .../Analyzers/IMediaFileAnalyzer.cs | 3 +- .../Analyzers/SegmentAnalyzer.cs | 15 +- .../Configuration/configPage.html | 28 +- .../Configuration/inject.js | 30 +- ...nfusedPolarBear.Plugin.IntroSkipper.csproj | 4 - .../Controllers/SkipIntroController.cs | 27 +- .../Controllers/VisualizationController.cs | 11 +- .../Data/AnalysisMode.cs | 17 - .../Data/EpisodeState.cs | 56 +- .../Data/IgnoreListItem.cs | 13 +- .../FFmpegWrapper.cs | 27 +- .../Helper/XmlSerializationHelper.cs | 2 +- .../Manager/EdlManager.cs | 169 +++--- .../Manager/QueueManager.cs | 500 +++++++++--------- .../Plugin.cs | 24 +- .../PluginServiceRegistrator.cs | 1 + .../ScheduledTasks/BaseItemAnalyzerTask.cs | 22 +- .../ScheduledTasks/CleanCacheTask.cs | 34 +- .../ScheduledTasks/DetectCreditsTask.cs | 38 +- .../ScheduledTasks/DetectIntrosCreditsTask.cs | 38 +- .../ScheduledTasks/DetectIntrosTask.cs | 38 +- .../Services/AutoSkip.cs | 348 ++++++------ .../Services/AutoSkipCredits.cs | 348 ++++++------ .../Services/Entrypoint.cs | 9 +- 31 files changed, 1010 insertions(+), 1039 deletions(-) delete mode 100644 ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisMode.cs diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs index b3c354a..43e3f36 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using Microsoft.Extensions.Logging; using Xunit; @@ -63,7 +64,7 @@ public class TestAudioFingerprinting var actual = FFmpegWrapper.Fingerprint( QueueEpisode("audio/big_buck_bunny_intro.mp3"), - AnalysisMode.Introduction); + MediaSegmentType.Intro); Assert.Equal(expected, actual); } @@ -83,7 +84,7 @@ public class TestAudioFingerprinting {77, 5}, }; - var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction); + var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, MediaSegmentType.Intro); Assert.Equal(expected, actual); } @@ -95,8 +96,8 @@ public class TestAudioFingerprinting var lhsEpisode = QueueEpisode("audio/big_buck_bunny_intro.mp3"); var rhsEpisode = QueueEpisode("audio/big_buck_bunny_clip.mp3"); - var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction); - var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction); + var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, MediaSegmentType.Intro); + var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, MediaSegmentType.Intro); var (lhs, rhs) = chromaprint.CompareEpisodes( lhsEpisode.EpisodeId, diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs index afdb811..2204f3b 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; using Xunit; @@ -19,8 +20,8 @@ public class TestChapterAnalyzer [InlineData("Introduction")] public void TestIntroductionExpression(string chapterName) { - var chapters = CreateChapters(chapterName, AnalysisMode.Introduction); - var introChapter = FindChapter(chapters, AnalysisMode.Introduction); + var chapters = CreateChapters(chapterName, MediaSegmentType.Intro); + var introChapter = FindChapter(chapters, MediaSegmentType.Intro); Assert.NotNull(introChapter); Assert.Equal(60, introChapter.Start); @@ -35,34 +36,34 @@ public class TestChapterAnalyzer [InlineData("Credits")] public void TestEndCreditsExpression(string chapterName) { - var chapters = CreateChapters(chapterName, AnalysisMode.Credits); - var creditsChapter = FindChapter(chapters, AnalysisMode.Credits); + var chapters = CreateChapters(chapterName, MediaSegmentType.Outro); + var creditsChapter = FindChapter(chapters, MediaSegmentType.Outro); Assert.NotNull(creditsChapter); Assert.Equal(1890, creditsChapter.Start); Assert.Equal(2000, creditsChapter.End); } - private Segment? FindChapter(Collection chapters, AnalysisMode mode) + private Segment? FindChapter(Collection chapters, MediaSegmentType mode) { var logger = new LoggerFactory().CreateLogger(); var analyzer = new ChapterAnalyzer(logger); var config = new Configuration.PluginConfiguration(); - var expression = mode == AnalysisMode.Introduction ? + var expression = mode == MediaSegmentType.Intro ? config.ChapterAnalyzerIntroductionPattern : config.ChapterAnalyzerEndCreditsPattern; return analyzer.FindMatchingChapter(new() { Duration = 2000 }, chapters, expression, mode); } - private Collection CreateChapters(string name, AnalysisMode mode) + private Collection CreateChapters(string name, MediaSegmentType mode) { var chapters = new[]{ CreateChapter("Cold Open", 0), - CreateChapter(mode == AnalysisMode.Introduction ? name : "Introduction", 60), + CreateChapter(mode == MediaSegmentType.Intro ? name : "Introduction", 60), CreateChapter("Main Episode", 90), - CreateChapter(mode == AnalysisMode.Credits ? name : "Credits", 1890) + CreateChapter(mode == MediaSegmentType.Outro ? name : "Credits", 1890) }; return new(new List(chapters)); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs index fe86c00..6106b2c 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs @@ -1,5 +1,6 @@ using System; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using ConfusedPolarBear.Plugin.IntroSkipper.Manager; using Xunit; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/AnalyzerHelper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/AnalyzerHelper.cs index d7829de..09e13c4 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/AnalyzerHelper.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/AnalyzerHelper.cs @@ -3,114 +3,116 @@ using System.Collections.Generic; using System.Linq; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using Microsoft.Extensions.Logging; -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Analyzer Helper. -/// -public class AnalyzerHelper +namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers { - private readonly ILogger _logger; - private readonly double _silenceDetectionMinimumDuration; - /// - /// Initializes a new instance of the class. + /// Analyzer Helper. /// - /// Logger. - public AnalyzerHelper(ILogger logger) + public class AnalyzerHelper { - var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); - _silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration; - _logger = logger; - } + private readonly ILogger _logger; + private readonly double _silenceDetectionMinimumDuration; - /// - /// Adjusts the end timestamps of all intros so that they end at silence. - /// - /// QueuedEpisodes to adjust. - /// Original introductions. - /// Analysis mode. - /// Modified Intro Timestamps. - public Dictionary AdjustIntroTimes( - IReadOnlyList episodes, - IReadOnlyDictionary originalIntros, - AnalysisMode mode) - { - return episodes - .Where(episode => originalIntros.TryGetValue(episode.EpisodeId, out var _)) - .ToDictionary( - episode => episode.EpisodeId, - episode => AdjustIntroForEpisode(episode, originalIntros[episode.EpisodeId], mode)); - } - - private Segment AdjustIntroForEpisode(QueuedEpisode episode, Segment originalIntro, AnalysisMode mode) - { - _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) + /// + /// Initializes a new instance of the class. + /// + /// Logger. + public AnalyzerHelper(ILogger logger) { - AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd); + var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); + _silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration; + _logger = logger; } - 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++) + /// + /// Adjusts the end timestamps of all intros so that they end at silence. + /// + /// QueuedEpisodes to adjust. + /// Original introductions. + /// Analysis mode. + /// Modified Intro Timestamps. + public Dictionary AdjustIntroTimes( + IReadOnlyList episodes, + IReadOnlyDictionary originalIntros, + MediaSegmentType mode) { - 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 episodes + .Where(episode => originalIntros.TryGetValue(episode.EpisodeId, out var _)) + .ToDictionary( + episode => episode.EpisodeId, + episode => AdjustIntroForEpisode(episode, originalIntros[episode.EpisodeId], mode)); } - return false; - } - - private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroEnd) - { - var silence = FFmpegWrapper.DetectSilence(episode, originalIntroEnd); - - foreach (var currentRange in silence) + private Segment AdjustIntroForEpisode(QueuedEpisode episode, Segment originalIntro, MediaSegmentType mode) { - _logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, currentRange.Start, currentRange.End); + _logger.LogTrace("{Name} original intro: {Start} - {End}", episode.Name, originalIntro.Start, originalIntro.End); - if (IsValidSilenceForIntroAdjustment(currentRange, originalIntroEnd, adjustedIntro)) + 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 == MediaSegmentType.Intro) { - adjustedIntro.End = currentRange.Start; - break; + 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; + private bool IsValidSilenceForIntroAdjustment(TimeRange silenceRange, TimeRange originalIntroEnd, Segment adjustedIntro) + { + return originalIntroEnd.Intersects(silenceRange) && + silenceRange.Duration >= _silenceDetectionMinimumDuration && + silenceRange.Start >= adjustedIntro.Start; + } } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs index 8027450..6b47910 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using Microsoft.Extensions.Logging; namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; @@ -41,10 +42,10 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer /// public IReadOnlyList AnalyzeMediaFiles( IReadOnlyList analysisQueue, - AnalysisMode mode, + MediaSegmentType mode, CancellationToken cancellationToken) { - if (mode != AnalysisMode.Credits) + if (mode != MediaSegmentType.Outro) { throw new NotImplementedException("mode must equal Credits"); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs index 88a121c..67a5770 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; @@ -26,7 +26,7 @@ public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyz /// public IReadOnlyList AnalyzeMediaFiles( IReadOnlyList analysisQueue, - AnalysisMode mode, + MediaSegmentType mode, CancellationToken cancellationToken) { var skippableRanges = new Dictionary(); @@ -34,7 +34,7 @@ public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyz // Episode analysis queue. var episodeAnalysisQueue = new List(analysisQueue); - var expression = mode == AnalysisMode.Introduction ? + var expression = mode == MediaSegmentType.Intro ? Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern : Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern; @@ -83,7 +83,7 @@ public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyz QueuedEpisode episode, IReadOnlyList chapters, string expression, - AnalysisMode mode) + MediaSegmentType mode) { var count = chapters.Count; if (count == 0) @@ -92,7 +92,7 @@ public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyz } var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); - var reversed = mode != AnalysisMode.Introduction; + var reversed = mode != MediaSegmentType.Intro; var (minDuration, maxDuration) = reversed ? (config.MinimumCreditsDuration, config.MaximumCreditsDuration) : (config.MinimumIntroDuration, config.MaximumIntroDuration); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs index cc4e1a9..ae46aec 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs @@ -6,6 +6,7 @@ using System.Numerics; using System.Threading; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using Microsoft.Extensions.Logging; namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; @@ -31,7 +32,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer private readonly ILogger _logger; - private AnalysisMode _analysisMode; + private MediaSegmentType _mediaSegmentType; /// /// Initializes a new instance of the class. @@ -51,7 +52,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer /// public IReadOnlyList AnalyzeMediaFiles( IReadOnlyList analysisQueue, - AnalysisMode mode, + MediaSegmentType mode, CancellationToken cancellationToken) { // All intros for this season. @@ -66,7 +67,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer // Episodes that were analyzed and do not have an introduction. var episodesWithoutIntros = episodeAnalysisQueue.Where(e => !e.State.IsAnalyzed(mode)).ToList(); - _analysisMode = mode; + _mediaSegmentType = mode; if (episodesWithoutIntros.Count == 0 || episodeAnalysisQueue.Count <= 1) { @@ -96,7 +97,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode); // Use reversed fingerprints for credits - if (_analysisMode == AnalysisMode.Credits) + if (_mediaSegmentType == MediaSegmentType.Outro) { Array.Reverse(fingerprintCache[episode.EpisodeId]); } @@ -139,7 +140,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer // - the introduction exceeds the configured limit if ( !remainingIntro.Valid || - (_analysisMode == AnalysisMode.Introduction && remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration)) + (_mediaSegmentType == MediaSegmentType.Intro && remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration)) { continue; } @@ -153,7 +154,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer * To fix this, the starting and ending times need to be switched, as they were previously reversed * and subtracted from the episode duration to get the reported time range. */ - if (_analysisMode == AnalysisMode.Credits) + if (_mediaSegmentType == MediaSegmentType.Outro) { // Calculate new values for the current intro double currentOriginalIntroStart = currentIntro.Start; @@ -202,9 +203,9 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer // Adjust all introduction times. var analyzerHelper = new AnalyzerHelper(_logger); - seasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, seasonIntros, _analysisMode); + seasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, seasonIntros, _mediaSegmentType); - Plugin.Instance!.UpdateTimestamps(seasonIntros, _analysisMode); + Plugin.Instance!.UpdateTimestamps(seasonIntros, _mediaSegmentType); return episodeAnalysisQueue; } @@ -296,8 +297,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 = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, _mediaSegmentType); + var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, _mediaSegmentType); var indexShifts = new HashSet(); // For all audio points in the left episode, check if the right episode has a point which matches exactly. diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/IMediaFileAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/IMediaFileAnalyzer.cs index 9c97c4f..782c544 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/IMediaFileAnalyzer.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/IMediaFileAnalyzer.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; @@ -18,6 +19,6 @@ public interface IMediaFileAnalyzer /// Collection of media files that were **unsuccessfully analyzed**. public IReadOnlyList AnalyzeMediaFiles( IReadOnlyList analysisQueue, - AnalysisMode mode, + MediaSegmentType mode, CancellationToken cancellationToken); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/SegmentAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/SegmentAnalyzer.cs index 0b20f25..8de1a3c 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/SegmentAnalyzer.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/SegmentAnalyzer.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Threading; using ConfusedPolarBear.Plugin.IntroSkipper.Data; -using Microsoft.Extensions.Logging; +using Jellyfin.Data.Enums; namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; @@ -10,21 +10,10 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; /// public class SegmentAnalyzer : IMediaFileAnalyzer { - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// Logger. - public SegmentAnalyzer(ILogger logger) - { - _logger = logger; - } - /// public IReadOnlyList AnalyzeMediaFiles( IReadOnlyList analysisQueue, - AnalysisMode mode, + MediaSegmentType mode, CancellationToken cancellationToken) { return analysisQueue; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html index 0ec85b0..f9851ac 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html @@ -1035,16 +1035,16 @@ // Update the editor for the first and second episodes timestampEditor.style.display = "unset"; document.querySelector("#editLeftEpisodeTitle").textContent = leftEpisode.text; - 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("#editLeftIntroEpisodeStartEdit").value = leftEpisodeJson.Intro.Start; + document.querySelector("#editLeftIntroEpisodeEndEdit").value = leftEpisodeJson.Intro.End; + document.querySelector("#editLeftCreditEpisodeStartEdit").value = leftEpisodeJson.Outro.Start; + document.querySelector("#editLeftCreditEpisodeEndEdit").value = leftEpisodeJson.Outro.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("#editRightIntroEpisodeStartEdit").value = rightEpisodeJson.Intro.Start; + document.querySelector("#editRightIntroEpisodeEndEdit").value = rightEpisodeJson.Intro.End; + document.querySelector("#editRightCreditEpisodeStartEdit").value = rightEpisodeJson.Outro.Start; + document.querySelector("#editRightCreditEpisodeEndEdit").value = rightEpisodeJson.Outro.End; // Update display inputs const inputs = document.querySelectorAll('#timestampEditor input[type="number"]'); @@ -1259,11 +1259,11 @@ selectEpisode1.addEventListener("change", episodeChanged); selectEpisode2.addEventListener("change", episodeChanged); btnEraseIntroTimestamps.addEventListener("click", (e) => { - eraseTimestamps("Introduction"); + eraseTimestamps("Intro"); e.preventDefault(); }); btnEraseCreditTimestamps.addEventListener("click", (e) => { - eraseTimestamps("Credits"); + eraseTimestamps("Outro"); e.preventDefault(); }); btnSeasonEraseTimestamps.addEventListener("click", () => { @@ -1317,11 +1317,11 @@ const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value; const newLhs = { - Introduction: { + Intro: { Start: getEditValue("editLeftIntroEpisodeStartEdit"), End: getEditValue("editLeftIntroEpisodeEndEdit"), }, - Credits: { + Outro: { Start: getEditValue("editLeftCreditEpisodeStartEdit"), End: getEditValue("editLeftCreditEpisodeEndEdit"), }, @@ -1329,11 +1329,11 @@ const rhsId = selectEpisode2.options[selectEpisode2.selectedIndex].value; const newRhs = { - Introduction: { + Intro: { Start: getEditValue("editRightIntroEpisodeStartEdit"), End: getEditValue("editRightIntroEpisodeEndEdit"), }, - Credits: { + Outro: { Start: getEditValue("editRightCreditEpisodeStartEdit"), End: getEditValue("editRightCreditEpisodeEndEdit"), }, diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js index abbb75e..26d210c 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js @@ -186,8 +186,8 @@ const introSkipper = { `; - this.skipButton.dataset.Introduction = config.SkipButtonIntroText; - this.skipButton.dataset.Credits = config.SkipButtonEndCreditsText; + this.skipButton.dataset.Intro = config.SkipButtonIntroText; + this.skipButton.dataset.Outro = config.SkipButtonEndCreditsText; const controls = document.querySelector("div#videoOsdPage"); controls.appendChild(this.skipButton); }, @@ -270,7 +270,7 @@ const introSkipper = { }, 500); }; this.videoPlayer.addEventListener('seeked', seekedHandler); - this.videoPlayer.currentTime = segment.SegmentType === "Credits" && this.videoPlayer.duration - segment.IntroEnd < 3 + this.videoPlayer.currentTime = segment.SegmentType === "Outro" && this.videoPlayer.duration - segment.IntroEnd < 3 ? this.videoPlayer.duration + 10 : segment.IntroEnd; }, @@ -391,11 +391,11 @@ const introSkipper = { this.setTimeInputs(skipperFields); }, updateSkipperFields(skipperFields) { - const { Introduction = {}, Credits = {} } = this.skipperData; - skipperFields.querySelector('#introStartEdit').value = Introduction.Start || 0; - skipperFields.querySelector('#introEndEdit').value = Introduction.End || 0; - skipperFields.querySelector('#creditsStartEdit').value = Credits.Start || 0; - skipperFields.querySelector('#creditsEndEdit').value = Credits.End || 0; + const { Intro = {}, Outro = {} } = this.skipperData; + skipperFields.querySelector('#introStartEdit').value = Intro.Start || 0; + skipperFields.querySelector('#introEndEdit').value = Intro.End || 0; + skipperFields.querySelector('#creditsStartEdit').value = Outro.Start || 0; + skipperFields.querySelector('#creditsEndEdit').value = Outro.End || 0; }, attachSaveListener(metadataFormFields) { const saveButton = metadataFormFields.querySelector('.formDialogFooter .btnSave'); @@ -441,20 +441,20 @@ const introSkipper = { }, async saveSkipperData() { const newTimestamps = { - Introduction: { + Intro: { Start: parseFloat(document.getElementById('introStartEdit').value || 0), End: parseFloat(document.getElementById('introEndEdit').value || 0) }, - Credits: { + Outro: { Start: parseFloat(document.getElementById('creditsStartEdit').value || 0), End: parseFloat(document.getElementById('creditsEndEdit').value || 0) } }; - const { Introduction = {}, Credits = {} } = this.skipperData; - if (newTimestamps.Introduction.Start !== (Introduction.Start || 0) || - newTimestamps.Introduction.End !== (Introduction.End || 0) || - newTimestamps.Credits.Start !== (Credits.Start || 0) || - newTimestamps.Credits.End !== (Credits.End || 0)) { + const { Intro = {}, Outro = {} } = this.skipperData; + if (newTimestamps.Intro.Start !== (Intro.Start || 0) || + newTimestamps.Intro.End !== (Intro.End || 0) || + newTimestamps.Outro.Start !== (Outro.Start || 0) || + newTimestamps.Outro.End !== (Outro.End || 0)) { const response = await this.secureFetch(`Episode/${this.currentEpisodeId}/Timestamps`, "POST", JSON.stringify(newTimestamps)); this.d(response.ok ? 'Timestamps updated successfully' : 'Failed to update timestamps:', response.status); } else { diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj index 0a5c4a9..ff595c2 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj @@ -23,8 +23,4 @@ - - - - diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs index 03a5c20..18913b5 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Net.Mime; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Entities.TV; using Microsoft.AspNetCore.Authorization; @@ -37,7 +38,7 @@ public class SkipIntroController : ControllerBase [HttpGet("Episode/{id}/IntroTimestamps/v1")] public ActionResult GetIntroTimestamps( [FromRoute] Guid id, - [FromQuery] AnalysisMode mode = AnalysisMode.Introduction) + [FromQuery] MediaSegmentType mode = MediaSegmentType.Intro) { var intro = GetIntro(id, mode); @@ -80,8 +81,8 @@ public class SkipIntroController : ControllerBase Plugin.Instance!.Credits[id] = new Segment(id, cr); } - Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction); - Plugin.Instance!.SaveTimestamps(AnalysisMode.Credits); + Plugin.Instance!.SaveTimestamps(MediaSegmentType.Intro); + Plugin.Instance!.SaveTimestamps(MediaSegmentType.Outro); return NoContent(); } @@ -125,18 +126,18 @@ public class SkipIntroController : ControllerBase /// Skippable segments dictionary. /// Dictionary of skippable segments. [HttpGet("Episode/{id}/IntroSkipperSegments")] - public ActionResult> GetSkippableSegments([FromRoute] Guid id) + public ActionResult> GetSkippableSegments([FromRoute] Guid id) { - var segments = new Dictionary(); + var segments = new Dictionary(); - if (GetIntro(id, AnalysisMode.Introduction) is Intro intro) + if (GetIntro(id, MediaSegmentType.Intro) is Intro intro) { - segments[AnalysisMode.Introduction] = intro; + segments[MediaSegmentType.Intro] = intro; } - if (GetIntro(id, AnalysisMode.Credits) is Intro credits) + if (GetIntro(id, MediaSegmentType.Outro) is Intro credits) { - segments[AnalysisMode.Credits] = credits; + segments[MediaSegmentType.Outro] = credits; } return segments; @@ -146,7 +147,7 @@ public class SkipIntroController : ControllerBase /// Unique identifier of this episode. /// Mode. /// Intro object if the provided item has an intro, null otherwise. - private static Intro? GetIntro(Guid id, AnalysisMode mode) + private static Intro? GetIntro(Guid id, MediaSegmentType mode) { try { @@ -187,13 +188,13 @@ public class SkipIntroController : ControllerBase /// No content. [Authorize(Policy = Policies.RequiresElevation)] [HttpPost("Intros/EraseTimestamps")] - public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false) + public ActionResult ResetIntroTimestamps([FromQuery] MediaSegmentType mode, [FromQuery] bool eraseCache = false) { - if (mode == AnalysisMode.Introduction) + if (mode == MediaSegmentType.Intro) { Plugin.Instance!.Intros.Clear(); } - else if (mode == AnalysisMode.Credits) + else if (mode == MediaSegmentType.Outro) { Plugin.Instance!.Credits.Clear(); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs index 5d3ae9f..139b42a 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using System.Net.Mime; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Api; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -114,8 +115,8 @@ public class VisualizationController(ILogger logger) : return new IgnoreListItem(Guid.Empty) { - IgnoreIntro = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, AnalysisMode.Introduction)), - IgnoreCredits = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, AnalysisMode.Credits)) + IgnoreIntro = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, MediaSegmentType.Intro)), + IgnoreCredits = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, MediaSegmentType.Outro)) }; } @@ -158,7 +159,7 @@ public class VisualizationController(ILogger logger) : { if (needle.EpisodeId == id) { - return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction); + return FFmpegWrapper.Fingerprint(needle, MediaSegmentType.Intro); } } } @@ -201,7 +202,7 @@ public class VisualizationController(ILogger logger) : } } - Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction | AnalysisMode.Credits); + Plugin.Instance!.SaveTimestamps(MediaSegmentType.Intro | MediaSegmentType.Outro); return NoContent(); } @@ -281,7 +282,7 @@ public class VisualizationController(ILogger logger) : { var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd); Plugin.Instance!.Intros[id] = new Segment(id, tr); - Plugin.Instance.SaveTimestamps(AnalysisMode.Introduction); + Plugin.Instance.SaveTimestamps(MediaSegmentType.Intro); } return NoContent(); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisMode.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisMode.cs deleted file mode 100644 index 7da010a..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisMode.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; - -/// -/// Type of media file analysis to perform. -/// -public enum AnalysisMode -{ - /// - /// Detect introduction sequences. - /// - Introduction, - - /// - /// Detect credits. - /// - Credits, -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/EpisodeState.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/EpisodeState.cs index b5fd801..8f9b53d 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/EpisodeState.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/EpisodeState.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Data.Enums; namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; @@ -7,44 +10,61 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; /// public class EpisodeState { - private readonly bool[] _analyzedStates = new bool[2]; + private readonly Dictionary _states = []; - private readonly bool[] _blacklistedStates = new bool[2]; + /// + /// Initializes a new instance of the class. + /// + public EpisodeState() => + Array.ForEach(Enum.GetValues(), mode => _states[mode] = default); /// /// Checks if the specified analysis mode has been analyzed. /// /// The analysis mode to check. /// True if the mode has been analyzed, false otherwise. - public bool IsAnalyzed(AnalysisMode mode) => _analyzedStates[(int)mode]; - - /// - /// Sets the analyzed state for the specified analysis mode. - /// - /// The analysis mode to set. - /// The analyzed state to set. - public void SetAnalyzed(AnalysisMode mode, bool value) => _analyzedStates[(int)mode] = value; + public bool IsAnalyzed(MediaSegmentType mode) => _states[mode].Analyzed; /// /// Checks if the specified analysis mode has been blacklisted. /// /// The analysis mode to check. /// True if the mode has been blacklisted, false otherwise. - public bool IsBlacklisted(AnalysisMode mode) => _blacklistedStates[(int)mode]; + public bool IsBlacklisted(MediaSegmentType mode) => _states[mode].Blacklisted; + + /// + /// Sets the analyzed state for the specified analysis mode. + /// + /// The analysis mode to set. + /// The analyzed state to set. + public void SetAnalyzed(MediaSegmentType mode, bool value) => + _states[mode] = (value, _states[mode].Blacklisted); /// /// Sets the blacklisted state for the specified analysis mode. /// /// The analysis mode to set. /// The blacklisted state to set. - public void SetBlacklisted(AnalysisMode mode, bool value) => _blacklistedStates[(int)mode] = value; + public void SetBlacklisted(MediaSegmentType mode, bool value) => + _states[mode] = (_states[mode].Analyzed, value); /// - /// Resets the analyzed states. + /// Resets all states to their default values. /// - public void ResetStates() - { - Array.Clear(_analyzedStates); - Array.Clear(_blacklistedStates); - } + public void ResetStates() => + Array.ForEach(Enum.GetValues(), mode => _states[mode] = default); + + /// + /// Gets all modes that have been analyzed. + /// + /// An IEnumerable of analyzed MediaSegmentTypes. + public IEnumerable GetAnalyzedModes() => + _states.Where(kvp => kvp.Value.Analyzed).Select(kvp => kvp.Key); + + /// + /// Gets all modes that have been blacklisted. + /// + /// An IEnumerable of blacklisted MediaSegmentTypes. + public IEnumerable GetBlacklistedModes() => + _states.Where(kvp => kvp.Value.Blacklisted).Select(kvp => kvp.Key); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/IgnoreListItem.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/IgnoreListItem.cs index 933bd3d..23273c3 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/IgnoreListItem.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/IgnoreListItem.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.Serialization; +using Jellyfin.Data.Enums; namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; @@ -59,14 +60,14 @@ public class IgnoreListItem /// /// Analysis mode. /// Value to set. - public void Toggle(AnalysisMode mode, bool value) + public void Toggle(MediaSegmentType mode, bool value) { switch (mode) { - case AnalysisMode.Introduction: + case MediaSegmentType.Intro: IgnoreIntro = value; break; - case AnalysisMode.Credits: + case MediaSegmentType.Outro: IgnoreCredits = value; break; } @@ -77,12 +78,12 @@ public class IgnoreListItem /// /// Analysis mode. /// True if ignored, false otherwise. - public bool IsIgnored(AnalysisMode mode) + public bool IsIgnored(MediaSegmentType mode) { return mode switch { - AnalysisMode.Introduction => IgnoreIntro, - AnalysisMode.Credits => IgnoreCredits, + MediaSegmentType.Intro => IgnoreIntro, + MediaSegmentType.Outro => IgnoreCredits, _ => false, }; } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs index 789be1d..744c2c9 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs @@ -7,6 +7,7 @@ using System.IO; using System.Text; using System.Text.RegularExpressions; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using Microsoft.Extensions.Logging; namespace ConfusedPolarBear.Plugin.IntroSkipper; @@ -33,7 +34,7 @@ public static partial class FFmpegWrapper private static Dictionary ChromaprintLogs { get; set; } = []; - private static ConcurrentDictionary<(Guid Id, AnalysisMode Mode), Dictionary> InvertedIndexCache { get; set; } = new(); + private static ConcurrentDictionary<(Guid Id, MediaSegmentType Mode), Dictionary> InvertedIndexCache { get; set; } = new(); /// /// Check that the installed version of ffmpeg supports chromaprint. @@ -109,16 +110,16 @@ public static partial class FFmpegWrapper /// Queued episode to fingerprint. /// Portion of media file to fingerprint. Introduction = first 25% / 10 minutes and Credits = last 4 minutes. /// Numerical fingerprint points. - public static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode) + public static uint[] Fingerprint(QueuedEpisode episode, MediaSegmentType mode) { int start, end; - if (mode == AnalysisMode.Introduction) + if (mode == MediaSegmentType.Intro) { start = 0; end = episode.IntroFingerprintEnd; } - else if (mode == AnalysisMode.Credits) + else if (mode == MediaSegmentType.Outro) { start = episode.CreditsFingerprintStart; end = episode.Duration; @@ -138,7 +139,7 @@ public static partial class FFmpegWrapper /// Chromaprint fingerprint. /// Mode. /// Inverted index. - public static Dictionary CreateInvertedIndex(Guid id, uint[] fingerprint, AnalysisMode mode) + public static Dictionary CreateInvertedIndex(Guid id, uint[] fingerprint, MediaSegmentType mode) { if (InvertedIndexCache.TryGetValue((id, mode), out var cached)) { @@ -468,7 +469,7 @@ public static partial class FFmpegWrapper /// Time (in seconds) relative to the start of the file to start fingerprinting from. /// Time (in seconds) relative to the start of the file to stop fingerprinting at. /// Numerical fingerprint points. - private static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode, int start, int end) + private static uint[] Fingerprint(QueuedEpisode episode, MediaSegmentType mode, int start, int end) { // Try to load this episode from cache before running ffmpeg. if (LoadCachedFingerprint(episode, mode, out uint[] cachedFingerprint)) @@ -522,7 +523,7 @@ public static partial class FFmpegWrapper /// true if the episode was successfully loaded from cache, false on any other error. private static bool LoadCachedFingerprint( QueuedEpisode episode, - AnalysisMode mode, + MediaSegmentType mode, out uint[] fingerprint) { fingerprint = Array.Empty(); @@ -578,7 +579,7 @@ public static partial class FFmpegWrapper /// Fingerprint of the episode to store. private static void CacheFingerprint( QueuedEpisode episode, - AnalysisMode mode, + MediaSegmentType mode, List fingerprint) { // Bail out if caching isn't enabled. @@ -627,11 +628,11 @@ public static partial class FFmpegWrapper /// Remove cached fingerprints from disk by mode. /// /// Analysis mode. - public static void DeleteCacheFiles(AnalysisMode mode) + public static void DeleteCacheFiles(MediaSegmentType mode) { foreach (var filePath in Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath)) { - var shouldDelete = (mode == AnalysisMode.Introduction) + var shouldDelete = (mode == MediaSegmentType.Intro) ? !filePath.Contains("credit", StringComparison.OrdinalIgnoreCase) && !filePath.Contains("blackframes", StringComparison.OrdinalIgnoreCase) : filePath.Contains("credit", StringComparison.OrdinalIgnoreCase) @@ -651,18 +652,18 @@ public static partial class FFmpegWrapper /// Episode. /// Analysis mode. /// Path. - public static string GetFingerprintCachePath(QueuedEpisode episode, AnalysisMode mode) + public static string GetFingerprintCachePath(QueuedEpisode episode, MediaSegmentType mode) { var basePath = Path.Join( Plugin.Instance!.FingerprintCachePath, episode.EpisodeId.ToString("N")); - if (mode == AnalysisMode.Introduction) + if (mode == MediaSegmentType.Intro) { return basePath; } - if (mode == AnalysisMode.Credits) + if (mode == MediaSegmentType.Outro) { return basePath + "-credits"; } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Helper/XmlSerializationHelper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Helper/XmlSerializationHelper.cs index f02d126..db5f929 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Helper/XmlSerializationHelper.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Helper/XmlSerializationHelper.cs @@ -6,7 +6,7 @@ using System.Runtime.Serialization; using System.Xml; using ConfusedPolarBear.Plugin.IntroSkipper.Data; -namespace ConfusedPolarBear.Plugin.IntroSkipper +namespace ConfusedPolarBear.Plugin.IntroSkipper.Helper { internal sealed class XmlSerializationHelper { diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs index dd8fc4b..a21bf15 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs @@ -4,113 +4,114 @@ using System.IO; using ConfusedPolarBear.Plugin.IntroSkipper.Data; using Microsoft.Extensions.Logging; -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Update EDL files associated with a list of episodes. -/// -public static class EdlManager +namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager { - private static ILogger? _logger; - /// - /// Initialize EDLManager with a logger. + /// Update EDL files associated with a list of episodes. /// - /// ILogger. - public static void Initialize(ILogger logger) + public static class EdlManager { - _logger = logger; - } + private static ILogger? _logger; - /// - /// Logs the configuration that will be used during EDL file creation. - /// - public static void LogConfiguration() - { - if (_logger is null) + /// + /// Initialize EDLManager with a logger. + /// + /// ILogger. + public static void Initialize(ILogger logger) { - throw new InvalidOperationException("Logger must not be null"); + _logger = logger; } - var config = Plugin.Instance!.Configuration; - - if (config.EdlAction == EdlAction.None) + /// + /// Logs the configuration that will be used during EDL file creation. + /// + public static void LogConfiguration() { - _logger.LogDebug("EDL action: None - taking no further action"); - return; + if (_logger is null) + { + throw new InvalidOperationException("Logger must not be null"); + } + + var config = Plugin.Instance!.Configuration; + + if (config.EdlAction == EdlAction.None) + { + _logger.LogDebug("EDL action: None - taking no further action"); + return; + } + + _logger.LogDebug("EDL action: {Action}", config.EdlAction); + _logger.LogDebug("Regenerate EDL files: {Regenerate}", config.RegenerateEdlFiles); } - _logger.LogDebug("EDL action: {Action}", config.EdlAction); - _logger.LogDebug("Regenerate EDL files: {Regenerate}", config.RegenerateEdlFiles); - } - - /// - /// If the EDL action is set to a value other than None, update EDL files for the provided episodes. - /// - /// Episodes to update EDL files for. - public static void UpdateEDLFiles(IReadOnlyList episodes) - { - var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles; - var action = Plugin.Instance.Configuration.EdlAction; - if (action == EdlAction.None) + /// + /// If the EDL action is set to a value other than None, update EDL files for the provided episodes. + /// + /// Episodes to update EDL files for. + public static void UpdateEDLFiles(IReadOnlyList episodes) { - _logger?.LogDebug("EDL action is set to none, not updating EDL files"); - return; - } - - _logger?.LogDebug("Updating EDL files with action {Action}", action); - - foreach (var episode in episodes) - { - var id = episode.EpisodeId; - - bool hasIntro = Plugin.Instance!.Intros.TryGetValue(id, out var intro) && intro.Valid; - bool hasCredit = Plugin.Instance!.Credits.TryGetValue(id, out var credit) && credit.Valid; - - if (!hasIntro && !hasCredit) + var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles; + var action = Plugin.Instance.Configuration.EdlAction; + if (action == EdlAction.None) { - _logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id); - continue; + _logger?.LogDebug("EDL action is set to none, not updating EDL files"); + return; } - var edlPath = GetEdlPath(Plugin.Instance.GetItemPath(id)); + _logger?.LogDebug("Updating EDL files with action {Action}", action); - _logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath); - - if (!regenerate && File.Exists(edlPath)) + foreach (var episode in episodes) { - _logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath); - continue; - } + var id = episode.EpisodeId; - var edlContent = string.Empty; + bool hasIntro = Plugin.Instance!.Intros.TryGetValue(id, out var intro) && intro.Valid; + bool hasCredit = Plugin.Instance!.Credits.TryGetValue(id, out var credit) && credit.Valid; - if (hasIntro) - { - edlContent += intro?.ToEdl(action); - } - - if (hasCredit) - { - if (edlContent.Length > 0) + if (!hasIntro && !hasCredit) { - edlContent += Environment.NewLine; + _logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id); + continue; } - edlContent += credit?.ToEdl(action); - } + var edlPath = GetEdlPath(Plugin.Instance.GetItemPath(id)); - File.WriteAllText(edlPath, edlContent); + _logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath); + + if (!regenerate && File.Exists(edlPath)) + { + _logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath); + continue; + } + + var edlContent = string.Empty; + + if (hasIntro) + { + edlContent += intro?.ToEdl(action); + } + + if (hasCredit) + { + if (edlContent.Length > 0) + { + edlContent += Environment.NewLine; + } + + edlContent += credit?.ToEdl(action); + } + + File.WriteAllText(edlPath, edlContent); + } + } + + /// + /// Given the path to an episode, return the path to the associated EDL file. + /// + /// Full path to episode. + /// Full path to EDL file. + public static string GetEdlPath(string mediaPath) + { + return Path.ChangeExtension(mediaPath, "edl"); } } - - /// - /// Given the path to an episode, return the path to the associated EDL file. - /// - /// Full path to episode. - /// Full path to EDL file. - public static string GetEdlPath(string mediaPath) - { - return Path.ChangeExtension(mediaPath, "edl"); - } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs index 13e981c..924d82d 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs @@ -10,287 +10,281 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Manages enqueuing library items for analysis. -/// -/// -/// Initializes a new instance of the class. -/// -/// Logger. -/// Library manager. -public class QueueManager(ILogger logger, ILibraryManager libraryManager) +namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager { - private readonly ILibraryManager _libraryManager = libraryManager; - private readonly ILogger _logger = logger; - private readonly Dictionary> _queuedEpisodes = []; - private double _analysisPercent; - private List _selectedLibraries = []; - private bool _selectAllLibraries; - /// - /// Gets all media items on the server. + /// Manages enqueuing library items for analysis. /// - /// Queued media items. - public IReadOnlyDictionary> GetMediaItems() + /// + /// Initializes a new instance of the class. + /// + /// Logger. + /// Library manager. + public class QueueManager(ILogger logger, ILibraryManager libraryManager) { - Plugin.Instance!.TotalQueued = 0; + private readonly ILibraryManager _libraryManager = libraryManager; + private readonly ILogger _logger = logger; + private readonly Dictionary> _queuedEpisodes = []; + private double _analysisPercent; + private List _selectedLibraries = []; + private bool _selectAllLibraries; - LoadAnalysisSettings(); - - // For all selected libraries, enqueue all contained episodes. - foreach (var folder in _libraryManager.GetVirtualFolders()) + /// + /// Gets all media items on the server. + /// + /// Queued media items. + public IReadOnlyDictionary> GetMediaItems() { - // If libraries have been selected for analysis, ensure this library was selected. - if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name)) + Plugin.Instance!.TotalQueued = 0; + + LoadAnalysisSettings(); + + // For all selected libraries, enqueue all contained episodes. + foreach (var folder in _libraryManager.GetVirtualFolders()) { - _logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name); - continue; - } - - _logger.LogInformation("Running enqueue of items in library {Name}", folder.Name); - - // Some virtual folders don't have a proper item id. - if (!Guid.TryParse(folder.ItemId, out var folderId)) - { - continue; - } - - try - { - QueueLibraryContents(folderId); - } - catch (Exception ex) - { - _logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex); - } - } - - Plugin.Instance.TotalSeasons = _queuedEpisodes.Count; - Plugin.Instance.QueuedMediaItems.Clear(); - foreach (var kvp in _queuedEpisodes) - { - Plugin.Instance.QueuedMediaItems.TryAdd(kvp.Key, kvp.Value); - } - - return _queuedEpisodes; - } - - /// - /// Loads the list of libraries which have been selected for analysis and the minimum intro duration. - /// Settings which have been modified from the defaults are logged. - /// - private void LoadAnalysisSettings() - { - var config = Plugin.Instance!.Configuration; - - // Store the analysis percent - _analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100; - - _selectAllLibraries = config.SelectAllLibraries; - - if (!_selectAllLibraries) - { - // Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries. - _selectedLibraries = [.. config.SelectedLibraries.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; - - // If any libraries have been selected for analysis, log their names. - _logger.LogInformation("Limiting analysis to the following libraries: {Selected}", _selectedLibraries); - } - else - { - _logger.LogDebug("Not limiting analysis by library name"); - } - - // If analysis settings have been changed from the default, log the modified settings. - if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15) - { - _logger.LogInformation( - "Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s", - config.AnalysisPercent, - config.AnalysisLengthLimit, - config.MinimumIntroDuration); - } - } - - private void QueueLibraryContents(Guid id) - { - _logger.LogDebug("Constructing anonymous internal query"); - - var query = new InternalItemsQuery - { - // Order by series name, season, and then episode number so that status updates are logged in order - ParentId = id, - OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),], - IncludeItemTypes = [BaseItemKind.Episode], - Recursive = true, - IsVirtualItem = false - }; - - var items = _libraryManager.GetItemList(query, false); - - if (items is null) - { - _logger.LogError("Library query result is null"); - return; - } - - // Queue all episodes on the server for fingerprinting. - _logger.LogDebug("Iterating through library items"); - - foreach (var item in items) - { - if (item is not Episode episode) - { - _logger.LogDebug("Item {Name} is not an episode", item.Name); - continue; - } - - QueueEpisode(episode); - } - - _logger.LogDebug("Queued {Count} episodes", items.Count); - } - - private void QueueEpisode(Episode episode) - { - var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null"); - - if (string.IsNullOrEmpty(episode.Path)) - { - _logger.LogWarning( - "Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin", - episode.Name, - episode.SeriesName, - episode.Id); - return; - } - - // Allocate a new list for each new season - var seasonId = GetSeasonId(episode); - if (!_queuedEpisodes.TryGetValue(seasonId, out var seasonEpisodes)) - { - seasonEpisodes = []; - _queuedEpisodes[seasonId] = seasonEpisodes; - } - - if (seasonEpisodes.Any(e => e.EpisodeId == episode.Id)) - { - _logger.LogDebug( - "\"{Name}\" from series \"{Series}\" ({Id}) is already queued", - episode.Name, - episode.SeriesName, - episode.Id); - return; - } - - var isAnime = seasonEpisodes.FirstOrDefault()?.IsAnime ?? - (pluginInstance.GetItem(episode.SeriesId) is Series series && - (series.Tags.Contains("anime", StringComparison.OrdinalIgnoreCase) || - series.Genres.Contains("anime", StringComparison.OrdinalIgnoreCase))); - - // Limit analysis to the first X% of the episode and at most Y minutes. - // X and Y default to 25% and 10 minutes. - var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds; - var fingerprintDuration = Math.Min( - duration >= 5 * 60 ? duration * _analysisPercent : duration, - 60 * pluginInstance.Configuration.AnalysisLengthLimit); - - // Queue the episode for analysis - var maxCreditsDuration = pluginInstance.Configuration.MaximumCreditsDuration; - seasonEpisodes.Add(new QueuedEpisode - { - SeriesName = episode.SeriesName, - SeasonNumber = episode.AiredSeasonNumber ?? 0, - SeriesId = episode.SeriesId, - EpisodeId = episode.Id, - Name = episode.Name, - IsAnime = isAnime, - Path = episode.Path, - Duration = Convert.ToInt32(duration), - IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration), - CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration), - }); - - pluginInstance.TotalQueued++; - } - - private Guid GetSeasonId(Episode episode) - { - if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special - { - foreach (var kvp in _queuedEpisodes) - { - var first = kvp.Value.FirstOrDefault(); - if (first?.SeriesId == episode.SeriesId && - first.SeasonNumber == episode.AiredSeasonNumber) + // If libraries have been selected for analysis, ensure this library was selected. + if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name)) { - return kvp.Key; + _logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name); + continue; } - } - } - return episode.SeasonId; - } + _logger.LogInformation("Running enqueue of items in library {Name}", folder.Name); - /// - /// Verify that a collection of queued media items still exist in Jellyfin and in storage. - /// This is done to ensure that we don't analyze items that were deleted between the call to GetMediaItems() and popping them from the queue. - /// - /// Queued media items. - /// Analysis mode. - /// Media items that have been verified to exist in Jellyfin and in storage. - public (IReadOnlyList VerifiedItems, IReadOnlyCollection RequiredModes) - VerifyQueue(IReadOnlyList candidates, IReadOnlyCollection modes) - { - var verified = new List(); - var reqModes = new HashSet(); - - foreach (var candidate in candidates) - { - try - { - var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId); - - if (!File.Exists(path)) + // Some virtual folders don't have a proper item id. + if (!Guid.TryParse(folder.ItemId, out var folderId)) { continue; } - verified.Add(candidate); - - foreach (var mode in modes) + try { - if (candidate.State.IsAnalyzed(mode) || candidate.State.IsBlacklisted(mode)) + QueueLibraryContents(folderId); + } + catch (Exception ex) + { + _logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex); + } + } + + Plugin.Instance.TotalSeasons = _queuedEpisodes.Count; + Plugin.Instance.QueuedMediaItems.Clear(); + foreach (var kvp in _queuedEpisodes) + { + Plugin.Instance.QueuedMediaItems.TryAdd(kvp.Key, kvp.Value); + } + + return _queuedEpisodes; + } + + /// + /// Loads the list of libraries which have been selected for analysis and the minimum intro duration. + /// Settings which have been modified from the defaults are logged. + /// + private void LoadAnalysisSettings() + { + var config = Plugin.Instance!.Configuration; + + // Store the analysis percent + _analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100; + + _selectAllLibraries = config.SelectAllLibraries; + + if (!_selectAllLibraries) + { + // Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries. + _selectedLibraries = [.. config.SelectedLibraries.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + + // If any libraries have been selected for analysis, log their names. + _logger.LogInformation("Limiting analysis to the following libraries: {Selected}", _selectedLibraries); + } + else + { + _logger.LogDebug("Not limiting analysis by library name"); + } + + // If analysis settings have been changed from the default, log the modified settings. + if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15) + { + _logger.LogInformation( + "Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s", + config.AnalysisPercent, + config.AnalysisLengthLimit, + config.MinimumIntroDuration); + } + } + + private void QueueLibraryContents(Guid id) + { + _logger.LogDebug("Constructing anonymous internal query"); + + var query = new InternalItemsQuery + { + // Order by series name, season, and then episode number so that status updates are logged in order + ParentId = id, + OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),], + IncludeItemTypes = [BaseItemKind.Episode], + Recursive = true, + IsVirtualItem = false + }; + + var items = _libraryManager.GetItemList(query, false); + + if (items is null) + { + _logger.LogError("Library query result is null"); + return; + } + + // Queue all episodes on the server for fingerprinting. + _logger.LogDebug("Iterating through library items"); + + foreach (var item in items) + { + if (item is not Episode episode) + { + _logger.LogDebug("Item {Name} is not an episode", item.Name); + continue; + } + + QueueEpisode(episode); + } + + _logger.LogDebug("Queued {Count} episodes", items.Count); + } + + private void QueueEpisode(Episode episode) + { + var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null"); + + if (string.IsNullOrEmpty(episode.Path)) + { + _logger.LogWarning( + "Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin", + episode.Name, + episode.SeriesName, + episode.Id); + return; + } + + // Allocate a new list for each new season + var seasonId = GetSeasonId(episode); + if (!_queuedEpisodes.TryGetValue(seasonId, out var seasonEpisodes)) + { + seasonEpisodes = []; + _queuedEpisodes[seasonId] = seasonEpisodes; + } + + if (seasonEpisodes.Any(e => e.EpisodeId == episode.Id)) + { + _logger.LogDebug( + "\"{Name}\" from series \"{Series}\" ({Id}) is already queued", + episode.Name, + episode.SeriesName, + episode.Id); + return; + } + + var isAnime = seasonEpisodes.FirstOrDefault()?.IsAnime ?? + (pluginInstance.GetItem(episode.SeriesId) is Series series && + (series.Tags.Contains("anime", StringComparison.OrdinalIgnoreCase) || + series.Genres.Contains("anime", StringComparison.OrdinalIgnoreCase))); + + // Limit analysis to the first X% of the episode and at most Y minutes. + // X and Y default to 25% and 10 minutes. + var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds; + var fingerprintDuration = Math.Min( + duration >= 5 * 60 ? duration * _analysisPercent : duration, + 60 * pluginInstance.Configuration.AnalysisLengthLimit); + + // Queue the episode for analysis + var maxCreditsDuration = pluginInstance.Configuration.MaximumCreditsDuration; + seasonEpisodes.Add(new QueuedEpisode + { + SeriesName = episode.SeriesName, + SeasonNumber = episode.AiredSeasonNumber ?? 0, + SeriesId = episode.SeriesId, + EpisodeId = episode.Id, + Name = episode.Name, + IsAnime = isAnime, + Path = episode.Path, + Duration = Convert.ToInt32(duration), + IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration), + CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration), + }); + + pluginInstance.TotalQueued++; + } + + private Guid GetSeasonId(Episode episode) + { + if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special + { + foreach (var kvp in _queuedEpisodes) + { + var first = kvp.Value.FirstOrDefault(); + if (first?.SeriesId == episode.SeriesId && + first.SeasonNumber == episode.AiredSeasonNumber) + { + return kvp.Key; + } + } + } + + return episode.SeasonId; + } + + /// + /// Verify that a collection of queued media items still exist in Jellyfin and in storage. + /// This is done to ensure that we don't analyze items that were deleted between the call to GetMediaItems() and popping them from the queue. + /// + /// Queued media items. + /// Analysis mode. + /// Media items that have been verified to exist in Jellyfin and in storage. + public (IReadOnlyList VerifiedItems, IReadOnlyCollection RequiredModes) + VerifyQueue(IReadOnlyList candidates, IReadOnlyCollection modes) + { + var verified = new List(); + var reqModes = new HashSet(modes); + + foreach (var candidate in candidates) + { + try + { + if (!File.Exists(Plugin.Instance!.GetItemPath(candidate.EpisodeId))) { continue; } - bool isAnalyzed = mode == AnalysisMode.Introduction - ? Plugin.Instance!.Intros.ContainsKey(candidate.EpisodeId) - : Plugin.Instance!.Credits.ContainsKey(candidate.EpisodeId); + verified.Add(candidate); + reqModes.ExceptWith(candidate.State.GetAnalyzedModes()); + reqModes.ExceptWith(candidate.State.GetBlacklistedModes()); - if (isAnalyzed) + if (reqModes.Remove(MediaSegmentType.Intro) && Plugin.Instance.Intros.ContainsKey(candidate.EpisodeId)) { - candidate.State.SetAnalyzed(mode, true); + candidate.State.SetAnalyzed(MediaSegmentType.Intro, true); } else { - reqModes.Add(mode); + reqModes.Add(MediaSegmentType.Intro); + } + + if (reqModes.Remove(MediaSegmentType.Outro) && Plugin.Instance.Credits.ContainsKey(candidate.EpisodeId)) + { + candidate.State.SetAnalyzed(MediaSegmentType.Outro, true); + } + else + { + reqModes.Add(MediaSegmentType.Outro); } } + catch (Exception ex) + { + _logger.LogDebug("Skipping analysis of {Name} ({Id}): {Exception}", candidate.Name, candidate.EpisodeId, ex); + } } - catch (Exception ex) - { - _logger.LogDebug( - "Skipping analysis of {Name} ({Id}): {Exception}", - candidate.Name, - candidate.EpisodeId, - ex); - } - } - return (verified, reqModes); + return (verified, reqModes); + } } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs index 7050830..335665b 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs @@ -6,6 +6,8 @@ using System.Linq; using System.Text.RegularExpressions; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using ConfusedPolarBear.Plugin.IntroSkipper.Helper; +using Jellyfin.Data.Enums; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; @@ -182,16 +184,16 @@ public class Plugin : BasePlugin, IHasWebPages /// Save timestamps to disk. /// /// Mode. - public void SaveTimestamps(AnalysisMode mode) + public void SaveTimestamps(MediaSegmentType mode) { List introList = []; - var filePath = mode == AnalysisMode.Introduction + var filePath = mode == MediaSegmentType.Intro ? _introPath : _creditsPath; lock (_introsLock) { - introList.AddRange(mode == AnalysisMode.Introduction + introList.AddRange(mode == MediaSegmentType.Intro ? Instance!.Intros.Values : Instance!.Credits.Values); } @@ -235,7 +237,7 @@ public class Plugin : BasePlugin, IHasWebPages /// Item id. /// Mode. /// True if ignored, false otherwise. - public bool IsIgnored(Guid id, AnalysisMode mode) + public bool IsIgnored(Guid id, MediaSegmentType mode) { return Instance!.IgnoreList.TryGetValue(id, out var item) && item.IsIgnored(mode); } @@ -312,9 +314,9 @@ public class Plugin : BasePlugin, IHasWebPages /// Item id. /// Mode. /// Intro. - internal static Segment GetIntroByMode(Guid id, AnalysisMode mode) + internal static Segment GetIntroByMode(Guid id, MediaSegmentType mode) { - return mode == AnalysisMode.Introduction + return mode == MediaSegmentType.Intro ? Instance!.Intros[id] : Instance!.Credits[id]; } @@ -373,15 +375,15 @@ public class Plugin : BasePlugin, IHasWebPages /// State of this item. internal EpisodeState GetState(Guid id) => EpisodeStates.GetOrAdd(id, _ => new EpisodeState()); - internal void UpdateTimestamps(IReadOnlyDictionary newTimestamps, AnalysisMode mode) + internal void UpdateTimestamps(IReadOnlyDictionary newTimestamps, MediaSegmentType mode) { foreach (var intro in newTimestamps) { - if (mode == AnalysisMode.Introduction) + if (mode == MediaSegmentType.Intro) { Instance!.Intros.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value); } - else if (mode == AnalysisMode.Credits) + else if (mode == MediaSegmentType.Outro) { Instance!.Credits.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value); } @@ -404,8 +406,8 @@ public class Plugin : BasePlugin, IHasWebPages } } - SaveTimestamps(AnalysisMode.Introduction); - SaveTimestamps(AnalysisMode.Credits); + SaveTimestamps(MediaSegmentType.Intro); + SaveTimestamps(MediaSegmentType.Outro); } private void MigrateRepoUrl(IServerConfigurationManager serverConfiguration) diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs b/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs index e5f6c42..89a856d 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs @@ -1,4 +1,5 @@ using ConfusedPolarBear.Plugin.IntroSkipper.Providers; +using ConfusedPolarBear.Plugin.IntroSkipper.Services; using MediaBrowser.Controller; using MediaBrowser.Controller.Plugins; using Microsoft.Extensions.DependencyInjection; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs index 7f8c49f..78c196d 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs @@ -6,6 +6,8 @@ using System.Threading; using System.Threading.Tasks; using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using ConfusedPolarBear.Plugin.IntroSkipper.Manager; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; @@ -16,7 +18,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; /// public class BaseItemAnalyzerTask { - private readonly IReadOnlyCollection _analysisModes; + private readonly IReadOnlyCollection _modes; private readonly ILogger _logger; @@ -32,12 +34,12 @@ public class BaseItemAnalyzerTask /// Logger factory. /// Library manager. public BaseItemAnalyzerTask( - IReadOnlyCollection modes, + IReadOnlyCollection modes, ILogger logger, ILoggerFactory loggerFactory, ILibraryManager libraryManager) { - _analysisModes = modes; + _modes = modes; _logger = logger; _loggerFactory = loggerFactory; _libraryManager = libraryManager; @@ -79,7 +81,7 @@ public class BaseItemAnalyzerTask 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( @@ -105,7 +107,7 @@ public class BaseItemAnalyzerTask // of the current media items were deleted from Jellyfin since the task was started. var (episodes, requiredModes) = queueManager.VerifyQueue( season.Value, - _analysisModes.Where(m => !Plugin.Instance!.IsIgnored(season.Key, m)).ToList()); + _modes.Where(m => !Plugin.Instance!.IsIgnored(season.Key, m)).ToList()); if (episodes.Count == 0) { @@ -120,13 +122,13 @@ public class BaseItemAnalyzerTask first.SeriesName, first.SeasonNumber); - Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly + Interlocked.Add(ref totalProcessed, episodes.Count * _modes.Count); // Update total Processed directly progress.Report(totalProcessed * 100 / totalQueued); return; } - if (_analysisModes.Count != requiredModes.Count) + if (_modes.Count != requiredModes.Count) { Interlocked.Add(ref totalProcessed, episodes.Count); progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed @@ -139,7 +141,7 @@ public class BaseItemAnalyzerTask return; } - foreach (AnalysisMode mode in requiredModes) + foreach (MediaSegmentType mode in requiredModes) { var analyzed = AnalyzeItems(episodes, mode, cancellationToken); Interlocked.Add(ref totalProcessed, analyzed); @@ -181,7 +183,7 @@ public class BaseItemAnalyzerTask /// Number of items that were successfully analyzed. private int AnalyzeItems( IReadOnlyList items, - AnalysisMode mode, + MediaSegmentType mode, CancellationToken cancellationToken) { var totalItems = items.Count; @@ -216,7 +218,7 @@ public class BaseItemAnalyzerTask analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger())); } - if (mode == AnalysisMode.Credits) + if (mode == MediaSegmentType.Outro) { analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger())); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs index f644db1..568fae7 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using ConfusedPolarBear.Plugin.IntroSkipper.Manager; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -13,29 +14,22 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; /// /// Analyze all television episodes for introduction sequences. /// -public class CleanCacheTask : IScheduledTask +/// +/// Initializes a new instance of the class. +/// +/// Logger factory. +/// Library manager. +/// Logger. +public class CleanCacheTask( + ILogger logger, + ILoggerFactory loggerFactory, + ILibraryManager libraryManager) : IScheduledTask { - private readonly ILogger _logger; + private readonly ILogger _logger = logger; - private readonly ILoggerFactory _loggerFactory; + private readonly ILoggerFactory _loggerFactory = loggerFactory; - private readonly ILibraryManager _libraryManager; - - /// - /// Initializes a new instance of the class. - /// - /// Logger factory. - /// Library manager. - /// Logger. - public CleanCacheTask( - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager) - { - _logger = logger; - _loggerFactory = loggerFactory; - _libraryManager = libraryManager; - } + private readonly ILibraryManager _libraryManager = libraryManager; /// /// Gets the task name. diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs index 7706da4..5ae41f2 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; -using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -14,29 +13,22 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; /// Analyze all television episodes for credits. /// TODO: analyze all media files. /// -public class DetectCreditsTask : IScheduledTask +/// +/// Initializes a new instance of the class. +/// +/// Logger factory. +/// Library manager. +/// Logger. +public class DetectCreditsTask( + ILogger logger, + ILoggerFactory loggerFactory, + ILibraryManager libraryManager) : IScheduledTask { - private readonly ILogger _logger; + private readonly ILogger _logger = logger; - private readonly ILoggerFactory _loggerFactory; + private readonly ILoggerFactory _loggerFactory = loggerFactory; - private readonly ILibraryManager _libraryManager; - - /// - /// Initializes a new instance of the class. - /// - /// Logger factory. - /// Library manager. - /// Logger. - public DetectCreditsTask( - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager) - { - _logger = logger; - _loggerFactory = loggerFactory; - _libraryManager = libraryManager; - } + private readonly ILibraryManager _libraryManager = libraryManager; /// /// Gets the task name. @@ -82,7 +74,7 @@ public class DetectCreditsTask : IScheduledTask { _logger.LogInformation("Scheduled Task is starting"); - var modes = new List { AnalysisMode.Credits }; + var modes = new List { MediaSegmentType.Outro }; var baseCreditAnalyzer = new BaseItemAnalyzerTask( modes, diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs index 72e8fee..6ba00dc 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; -using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -13,29 +12,22 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; /// /// Analyze all television episodes for introduction sequences. /// -public class DetectIntrosCreditsTask : IScheduledTask +/// +/// Initializes a new instance of the class. +/// +/// Logger factory. +/// Library manager. +/// Logger. +public class DetectIntrosCreditsTask( + ILogger logger, + ILoggerFactory loggerFactory, + ILibraryManager libraryManager) : IScheduledTask { - private readonly ILogger _logger; + private readonly ILogger _logger = logger; - private readonly ILoggerFactory _loggerFactory; + private readonly ILoggerFactory _loggerFactory = loggerFactory; - private readonly ILibraryManager _libraryManager; - - /// - /// Initializes a new instance of the class. - /// - /// Logger factory. - /// Library manager. - /// Logger. - public DetectIntrosCreditsTask( - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager) - { - _logger = logger; - _loggerFactory = loggerFactory; - _libraryManager = libraryManager; - } + private readonly ILibraryManager _libraryManager = libraryManager; /// /// Gets the task name. @@ -81,7 +73,7 @@ public class DetectIntrosCreditsTask : IScheduledTask { _logger.LogInformation("Scheduled Task is starting"); - var modes = new List { AnalysisMode.Introduction, AnalysisMode.Credits }; + var modes = new List { MediaSegmentType.Intro, MediaSegmentType.Outro }; var baseIntroAnalyzer = new BaseItemAnalyzerTask( modes, diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs index d7b17b1..2d485f4 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; -using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -13,29 +12,22 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; /// /// Analyze all television episodes for introduction sequences. /// -public class DetectIntrosTask : IScheduledTask +/// +/// Initializes a new instance of the class. +/// +/// Logger factory. +/// Library manager. +/// Logger. +public class DetectIntrosTask( + ILogger logger, + ILoggerFactory loggerFactory, + ILibraryManager libraryManager) : IScheduledTask { - private readonly ILogger _logger; + private readonly ILogger _logger = logger; - private readonly ILoggerFactory _loggerFactory; + private readonly ILoggerFactory _loggerFactory = loggerFactory; - private readonly ILibraryManager _libraryManager; - - /// - /// Initializes a new instance of the class. - /// - /// Logger factory. - /// Library manager. - /// Logger. - public DetectIntrosTask( - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager) - { - _logger = logger; - _loggerFactory = loggerFactory; - _libraryManager = libraryManager; - } + private readonly ILibraryManager _libraryManager = libraryManager; /// /// Gets the task name. @@ -81,7 +73,7 @@ public class DetectIntrosTask : IScheduledTask { _logger.LogInformation("Scheduled Task is starting"); - var modes = new List { AnalysisMode.Introduction }; + var modes = new List { MediaSegmentType.Intro }; var baseIntroAnalyzer = new BaseItemAnalyzerTask( modes, diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkip.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkip.cs index f019056..8b236bb 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkip.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkip.cs @@ -15,216 +15,216 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Timer = System.Timers.Timer; -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Automatically skip past introduction sequences. -/// Commands clients to seek to the end of the intro as soon as they start playing it. -/// -/// -/// Initializes a new instance of the class. -/// -/// User data manager. -/// Session manager. -/// Logger. -public class AutoSkip( - IUserDataManager userDataManager, - ISessionManager sessionManager, - ILogger logger) : IHostedService, IDisposable +namespace ConfusedPolarBear.Plugin.IntroSkipper.Services { - 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 AutoSkipChanged(object? sender, BasePluginConfiguration e) + /// + /// Automatically skip past introduction sequences. + /// Commands clients to seek to the end of the intro as soon as they start playing it. + /// + /// + /// Initializes a new instance of the class. + /// + /// User data manager. + /// Session manager. + /// Logger. + public class AutoSkip( + IUserDataManager userDataManager, + ISessionManager sessionManager, + ILogger logger) : IHostedService, IDisposable { - 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; - } + 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 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) + private void AutoSkipChanged(object? sender, BasePluginConfiguration e) { - return; + 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; } - // Lookup the session for this item. - SessionInfo? session = null; - - try + private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) { - foreach (var needle in _sessionManager.Sessions) - { - if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) - { - session = needle; - break; - } - } + var itemId = e.Item.Id; + var newState = false; + var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); - if (session == null) + // Ignore all events except playback start & end + if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) { - _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; - } + // Lookup the session for this item. + SessionInfo? session = null; - // 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.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) + try { - if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) + foreach (var needle in _sessionManager.Sessions) { - _logger.LogTrace("Already sent seek command for session {Session}", deviceId); - continue; + 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; } } - - // Assert that an intro was detected for this item. - if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid) + catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) { - continue; + return; } - // 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; - - _logger.LogTrace( - "Playback position is {Position}, intro runs from {Start} to {End}", - position, - adjustedStart, - adjustedEnd); - - if (position < adjustedStart || position > adjustedEnd) + // 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) { - continue; + newState = true; } - // Notify the user that an introduction is being skipped for them. - var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText; - 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 + // Reset the seek command state for this device. lock (_sentSeekCommandLock) { - _logger.LogTrace("Setting seek command state for session {Session}", deviceId); - _sentSeekCommand[deviceId] = true; + var device = session.DeviceId; + + _logger.LogDebug("Resetting seek command state for session {Session}", device); + _sentSeekCommand[device] = newState; } } - } - /// - /// Dispose. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Protected dispose. - /// - /// Dispose. - protected virtual void Dispose(bool disposing) - { - if (!disposing) + private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) { - return; + foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.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. + if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid) + { + 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; + + _logger.LogTrace( + "Playback position is {Position}, intro runs from {Start} to {End}", + position, + adjustedStart, + adjustedEnd); + + if (position < adjustedStart || position > adjustedEnd) + { + continue; + } + + // Notify the user that an introduction is being skipped for them. + var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText; + 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; + } + } } - _playbackTimer.Stop(); - _playbackTimer.Dispose(); - } + /// + /// Dispose. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogDebug("Setting up automatic skipping"); + /// + /// Protected dispose. + /// + /// Dispose. + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } - _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; - Plugin.Instance!.ConfigurationChanged += AutoSkipChanged; + _playbackTimer.Stop(); + _playbackTimer.Dispose(); + } - // Make the timer restart automatically and set enabled to match the configuration value. - _playbackTimer.AutoReset = true; - _playbackTimer.Elapsed += PlaybackTimer_Elapsed; + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("Setting up automatic skipping"); - AutoSkipChanged(null, Plugin.Instance.Configuration); + _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; + Plugin.Instance!.ConfigurationChanged += AutoSkipChanged; - return Task.CompletedTask; - } + // Make the timer restart automatically and set enabled to match the configuration value. + _playbackTimer.AutoReset = true; + _playbackTimer.Elapsed += PlaybackTimer_Elapsed; - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; - return Task.CompletedTask; + AutoSkipChanged(null, Plugin.Instance.Configuration); + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; + return Task.CompletedTask; + } } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkipCredits.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkipCredits.cs index 6898417..03ae43d 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkipCredits.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkipCredits.cs @@ -15,216 +15,216 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Timer = System.Timers.Timer; -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// 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 +namespace ConfusedPolarBear.Plugin.IntroSkipper.Services { - 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) + /// + /// 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 { - 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 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 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) + private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e) { - return; + 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; } - // Lookup the session for this item. - SessionInfo? session = null; - - try + private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) { - foreach (var needle in _sessionManager.Sessions) - { - if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) - { - session = needle; - break; - } - } + var itemId = e.Item.Id; + var newState = false; + var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); - if (session == null) + // Ignore all events except playback start & end + if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) { - _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; - } + // Lookup the session for this item. + SessionInfo? session = null; - // 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) + try { - if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) + foreach (var needle in _sessionManager.Sessions) { - _logger.LogTrace("Already sent seek command for session {Session}", deviceId); - continue; + 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; } } - - // Assert that credits were detected for this item. - if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var credit) || !credit.Valid) + catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) { - continue; + return; } - // 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) + // 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) { - continue; + newState = true; } - // 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 + // Reset the seek command state for this device. lock (_sentSeekCommandLock) { - _logger.LogTrace("Setting seek command state for session {Session}", deviceId); - _sentSeekCommand[deviceId] = true; + var device = session.DeviceId; + + _logger.LogDebug("Resetting seek command state for session {Session}", device); + _sentSeekCommand[device] = newState; } } - } - /// - /// Dispose. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Protected dispose. - /// - /// Dispose. - protected virtual void Dispose(bool disposing) - { - if (!disposing) + private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) { - return; + 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. + if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var credit) || !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; + } + } } - _playbackTimer.Stop(); - _playbackTimer.Dispose(); - } + /// + /// Dispose. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogDebug("Setting up automatic credit skipping"); + /// + /// Protected dispose. + /// + /// Dispose. + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } - _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; - Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged; + _playbackTimer.Stop(); + _playbackTimer.Dispose(); + } - // Make the timer restart automatically and set enabled to match the configuration value. - _playbackTimer.AutoReset = true; - _playbackTimer.Elapsed += PlaybackTimer_Elapsed; + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("Setting up automatic credit skipping"); - AutoSkipCreditChanged(null, Plugin.Instance.Configuration); + _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; + Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged; - return Task.CompletedTask; - } + // Make the timer restart automatically and set enabled to match the configuration value. + _playbackTimer.AutoReset = true; + _playbackTimer.Elapsed += PlaybackTimer_Elapsed; - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; - return Task.CompletedTask; + AutoSkipCreditChanged(null, Plugin.Instance.Configuration); + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; + return Task.CompletedTask; + } } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs index 5fc196a..193a649 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs @@ -3,8 +3,9 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; -using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using ConfusedPolarBear.Plugin.IntroSkipper.Manager; using ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; @@ -261,18 +262,18 @@ public sealed class Entrypoint : IHostedService, IDisposable _analyzeAgain = false; var progress = new Progress(); - var modes = new List(); + var modes = new List(); var tasklogger = _loggerFactory.CreateLogger("DefaultLogger"); if (_config.AutoDetectIntros) { - modes.Add(AnalysisMode.Introduction); + modes.Add(MediaSegmentType.Intro); tasklogger = _loggerFactory.CreateLogger(); } if (_config.AutoDetectCredits) { - modes.Add(AnalysisMode.Credits); + modes.Add(MediaSegmentType.Outro); tasklogger = modes.Count == 2 ? _loggerFactory.CreateLogger() : _loggerFactory.CreateLogger();