From ca9a167ad503b2e3e658a2110c79b48ae5b96bda Mon Sep 17 00:00:00 2001 From: rlauu <46294892+rlauu@users.noreply.github.com> Date: Wed, 16 Oct 2024 16:05:59 +0200 Subject: [PATCH] Revert "Use Jellyfins MediaSegmentType (#344)" This reverts commit 29ee3e0bc861d128f4f691d7eb8d159da28eab43. --- .../TestAudioFingerprinting.cs | 9 +- .../TestChapterAnalyzer.cs | 19 +- .../TestEdl.cs | 1 - .../Analyzers/AnalyzerHelper.cs | 172 +++--- .../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 | 185 ++++--- .../Manager/QueueManager.cs | 498 +++++++++--------- .../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 | 386 +++++++------- .../Services/AutoSkipCredits.cs | 386 +++++++------- .../Services/Entrypoint.cs | 9 +- 31 files changed, 1079 insertions(+), 1050 deletions(-) create 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 43e3f36..b3c354a 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs @@ -6,7 +6,6 @@ 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; @@ -64,7 +63,7 @@ public class TestAudioFingerprinting var actual = FFmpegWrapper.Fingerprint( QueueEpisode("audio/big_buck_bunny_intro.mp3"), - MediaSegmentType.Intro); + AnalysisMode.Introduction); Assert.Equal(expected, actual); } @@ -84,7 +83,7 @@ public class TestAudioFingerprinting {77, 5}, }; - var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, MediaSegmentType.Intro); + var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction); Assert.Equal(expected, actual); } @@ -96,8 +95,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, MediaSegmentType.Intro); - var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, MediaSegmentType.Intro); + var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction); + var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction); var (lhs, rhs) = chromaprint.CompareEpisodes( lhsEpisode.EpisodeId, diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs index 2204f3b..afdb811 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs @@ -5,7 +5,6 @@ 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; @@ -20,8 +19,8 @@ public class TestChapterAnalyzer [InlineData("Introduction")] public void TestIntroductionExpression(string chapterName) { - var chapters = CreateChapters(chapterName, MediaSegmentType.Intro); - var introChapter = FindChapter(chapters, MediaSegmentType.Intro); + var chapters = CreateChapters(chapterName, AnalysisMode.Introduction); + var introChapter = FindChapter(chapters, AnalysisMode.Introduction); Assert.NotNull(introChapter); Assert.Equal(60, introChapter.Start); @@ -36,34 +35,34 @@ public class TestChapterAnalyzer [InlineData("Credits")] public void TestEndCreditsExpression(string chapterName) { - var chapters = CreateChapters(chapterName, MediaSegmentType.Outro); - var creditsChapter = FindChapter(chapters, MediaSegmentType.Outro); + var chapters = CreateChapters(chapterName, AnalysisMode.Credits); + var creditsChapter = FindChapter(chapters, AnalysisMode.Credits); Assert.NotNull(creditsChapter); Assert.Equal(1890, creditsChapter.Start); Assert.Equal(2000, creditsChapter.End); } - private Segment? FindChapter(Collection chapters, MediaSegmentType mode) + private Segment? FindChapter(Collection chapters, AnalysisMode mode) { var logger = new LoggerFactory().CreateLogger(); var analyzer = new ChapterAnalyzer(logger); var config = new Configuration.PluginConfiguration(); - var expression = mode == MediaSegmentType.Intro ? + var expression = mode == AnalysisMode.Introduction ? config.ChapterAnalyzerIntroductionPattern : config.ChapterAnalyzerEndCreditsPattern; return analyzer.FindMatchingChapter(new() { Duration = 2000 }, chapters, expression, mode); } - private Collection CreateChapters(string name, MediaSegmentType mode) + private Collection CreateChapters(string name, AnalysisMode mode) { var chapters = new[]{ CreateChapter("Cold Open", 0), - CreateChapter(mode == MediaSegmentType.Intro ? name : "Introduction", 60), + CreateChapter(mode == AnalysisMode.Introduction ? name : "Introduction", 60), CreateChapter("Main Episode", 90), - CreateChapter(mode == MediaSegmentType.Outro ? name : "Credits", 1890) + CreateChapter(mode == AnalysisMode.Credits ? 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 6106b2c..fe86c00 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs @@ -1,6 +1,5 @@ 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 09e13c4..d7829de 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/AnalyzerHelper.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/AnalyzerHelper.cs @@ -3,116 +3,114 @@ 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.Analyzers +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +/// +/// Analyzer Helper. +/// +public class AnalyzerHelper { + private readonly ILogger _logger; + private readonly double _silenceDetectionMinimumDuration; + /// - /// Analyzer Helper. + /// Initializes a new instance of the class. /// - public class AnalyzerHelper + /// Logger. + public AnalyzerHelper(ILogger logger) { - private readonly ILogger _logger; - private readonly double _silenceDetectionMinimumDuration; + var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); + _silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration; + _logger = logger; + } - /// - /// Initializes a new instance of the class. - /// - /// Logger. - public AnalyzerHelper(ILogger logger) + /// + /// Adjusts the end timestamps of all intros so that they end at silence. + /// + /// QueuedEpisodes to adjust. + /// Original introductions. + /// Analysis mode. + /// Modified Intro Timestamps. + public 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) { - var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); - _silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration; - _logger = logger; + AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd); } - /// - /// 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) + 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++) { - return episodes - .Where(episode => originalIntros.TryGetValue(episode.EpisodeId, out var _)) - .ToDictionary( - episode => episode.EpisodeId, - episode => AdjustIntroForEpisode(episode, originalIntros[episode.EpisodeId], mode)); - } + double currentTime = i < chapters.Count + ? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds + : episode.Duration; - private Segment AdjustIntroForEpisode(QueuedEpisode episode, Segment originalIntro, MediaSegmentType 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 == MediaSegmentType.Intro) + if (originalIntroStart.Start < previousTime && previousTime < originalIntroStart.End) { - AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd); + adjustedIntro.Start = previousTime; + _logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime); } - 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++) + if (originalIntroEnd.Start < currentTime && currentTime < originalIntroEnd.End) { - 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; + adjustedIntro.End = currentTime; + _logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime); + return true; } - return false; + previousTime = currentTime; } - private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroEnd) - { - var silence = FFmpegWrapper.DetectSilence(episode, originalIntroEnd); + return false; + } - foreach (var currentRange in silence) + 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)) { - _logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, currentRange.Start, currentRange.End); - - if (IsValidSilenceForIntroAdjustment(currentRange, originalIntroEnd, adjustedIntro)) - { - adjustedIntro.End = currentRange.Start; - break; - } + 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 6b47910..8027450 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs @@ -4,7 +4,6 @@ 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; @@ -42,10 +41,10 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer /// public IReadOnlyList AnalyzeMediaFiles( IReadOnlyList analysisQueue, - MediaSegmentType mode, + AnalysisMode mode, CancellationToken cancellationToken) { - if (mode != MediaSegmentType.Outro) + if (mode != AnalysisMode.Credits) { throw new NotImplementedException("mode must equal Credits"); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs index 67a5770..88a121c 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, - MediaSegmentType mode, + AnalysisMode 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 == MediaSegmentType.Intro ? + var expression = mode == AnalysisMode.Introduction ? Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern : Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern; @@ -83,7 +83,7 @@ public class ChapterAnalyzer(ILogger logger) : IMediaFileAnalyz QueuedEpisode episode, IReadOnlyList chapters, string expression, - MediaSegmentType mode) + AnalysisMode 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 != MediaSegmentType.Intro; + var reversed = mode != AnalysisMode.Introduction; 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 ae46aec..cc4e1a9 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs @@ -6,7 +6,6 @@ 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; @@ -32,7 +31,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer private readonly ILogger _logger; - private MediaSegmentType _mediaSegmentType; + private AnalysisMode _analysisMode; /// /// Initializes a new instance of the class. @@ -52,7 +51,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer /// public IReadOnlyList AnalyzeMediaFiles( IReadOnlyList analysisQueue, - MediaSegmentType mode, + AnalysisMode mode, CancellationToken cancellationToken) { // All intros for this season. @@ -67,7 +66,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(); - _mediaSegmentType = mode; + _analysisMode = mode; if (episodesWithoutIntros.Count == 0 || episodeAnalysisQueue.Count <= 1) { @@ -97,7 +96,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode); // Use reversed fingerprints for credits - if (_mediaSegmentType == MediaSegmentType.Outro) + if (_analysisMode == AnalysisMode.Credits) { Array.Reverse(fingerprintCache[episode.EpisodeId]); } @@ -140,7 +139,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer // - the introduction exceeds the configured limit if ( !remainingIntro.Valid || - (_mediaSegmentType == MediaSegmentType.Intro && remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration)) + (_analysisMode == AnalysisMode.Introduction && remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration)) { continue; } @@ -154,7 +153,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 (_mediaSegmentType == MediaSegmentType.Outro) + if (_analysisMode == AnalysisMode.Credits) { // Calculate new values for the current intro double currentOriginalIntroStart = currentIntro.Start; @@ -203,9 +202,9 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer // Adjust all introduction times. var analyzerHelper = new AnalyzerHelper(_logger); - seasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, seasonIntros, _mediaSegmentType); + seasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, seasonIntros, _analysisMode); - Plugin.Instance!.UpdateTimestamps(seasonIntros, _mediaSegmentType); + Plugin.Instance!.UpdateTimestamps(seasonIntros, _analysisMode); return episodeAnalysisQueue; } @@ -297,8 +296,8 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer var rhsRanges = new List(); // Generate inverted indexes for the left and right episodes. - var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, _mediaSegmentType); - var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, _mediaSegmentType); + var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, _analysisMode); + var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, _analysisMode); 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 782c544..9c97c4f 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/IMediaFileAnalyzer.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/IMediaFileAnalyzer.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Threading; using ConfusedPolarBear.Plugin.IntroSkipper.Data; -using Jellyfin.Data.Enums; namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; @@ -19,6 +18,6 @@ public interface IMediaFileAnalyzer /// Collection of media files that were **unsuccessfully analyzed**. public IReadOnlyList AnalyzeMediaFiles( IReadOnlyList analysisQueue, - MediaSegmentType mode, + AnalysisMode mode, CancellationToken cancellationToken); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/SegmentAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/SegmentAnalyzer.cs index 8de1a3c..0b20f25 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 Jellyfin.Data.Enums; +using Microsoft.Extensions.Logging; namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; @@ -10,10 +10,21 @@ 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, - MediaSegmentType mode, + AnalysisMode mode, CancellationToken cancellationToken) { return analysisQueue; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html index f9851ac..0ec85b0 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.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("#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("#editRightEpisodeTitle").textContent = rightEpisode.text; - 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; + 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; // 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("Intro"); + eraseTimestamps("Introduction"); e.preventDefault(); }); btnEraseCreditTimestamps.addEventListener("click", (e) => { - eraseTimestamps("Outro"); + eraseTimestamps("Credits"); e.preventDefault(); }); btnSeasonEraseTimestamps.addEventListener("click", () => { @@ -1317,11 +1317,11 @@ const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value; const newLhs = { - Intro: { + Introduction: { Start: getEditValue("editLeftIntroEpisodeStartEdit"), End: getEditValue("editLeftIntroEpisodeEndEdit"), }, - Outro: { + Credits: { Start: getEditValue("editLeftCreditEpisodeStartEdit"), End: getEditValue("editLeftCreditEpisodeEndEdit"), }, @@ -1329,11 +1329,11 @@ const rhsId = selectEpisode2.options[selectEpisode2.selectedIndex].value; const newRhs = { - Intro: { + Introduction: { Start: getEditValue("editRightIntroEpisodeStartEdit"), End: getEditValue("editRightIntroEpisodeEndEdit"), }, - Outro: { + Credits: { Start: getEditValue("editRightCreditEpisodeStartEdit"), End: getEditValue("editRightCreditEpisodeEndEdit"), }, diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js index 26d210c..abbb75e 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js @@ -186,8 +186,8 @@ const introSkipper = { `; - this.skipButton.dataset.Intro = config.SkipButtonIntroText; - this.skipButton.dataset.Outro = config.SkipButtonEndCreditsText; + this.skipButton.dataset.Introduction = config.SkipButtonIntroText; + this.skipButton.dataset.Credits = 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 === "Outro" && this.videoPlayer.duration - segment.IntroEnd < 3 + this.videoPlayer.currentTime = segment.SegmentType === "Credits" && this.videoPlayer.duration - segment.IntroEnd < 3 ? this.videoPlayer.duration + 10 : segment.IntroEnd; }, @@ -391,11 +391,11 @@ const introSkipper = { this.setTimeInputs(skipperFields); }, updateSkipperFields(skipperFields) { - 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; + 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; }, attachSaveListener(metadataFormFields) { const saveButton = metadataFormFields.querySelector('.formDialogFooter .btnSave'); @@ -441,20 +441,20 @@ const introSkipper = { }, async saveSkipperData() { const newTimestamps = { - Intro: { + Introduction: { Start: parseFloat(document.getElementById('introStartEdit').value || 0), End: parseFloat(document.getElementById('introEndEdit').value || 0) }, - Outro: { + Credits: { Start: parseFloat(document.getElementById('creditsStartEdit').value || 0), End: parseFloat(document.getElementById('creditsEndEdit').value || 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 { 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 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 ff595c2..0a5c4a9 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj @@ -23,4 +23,8 @@ + + + + diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs index 18913b5..03a5c20 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs @@ -3,7 +3,6 @@ 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; @@ -38,7 +37,7 @@ public class SkipIntroController : ControllerBase [HttpGet("Episode/{id}/IntroTimestamps/v1")] public ActionResult GetIntroTimestamps( [FromRoute] Guid id, - [FromQuery] MediaSegmentType mode = MediaSegmentType.Intro) + [FromQuery] AnalysisMode mode = AnalysisMode.Introduction) { var intro = GetIntro(id, mode); @@ -81,8 +80,8 @@ public class SkipIntroController : ControllerBase Plugin.Instance!.Credits[id] = new Segment(id, cr); } - Plugin.Instance!.SaveTimestamps(MediaSegmentType.Intro); - Plugin.Instance!.SaveTimestamps(MediaSegmentType.Outro); + Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction); + Plugin.Instance!.SaveTimestamps(AnalysisMode.Credits); return NoContent(); } @@ -126,18 +125,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, MediaSegmentType.Intro) is Intro intro) + if (GetIntro(id, AnalysisMode.Introduction) is Intro intro) { - segments[MediaSegmentType.Intro] = intro; + segments[AnalysisMode.Introduction] = intro; } - if (GetIntro(id, MediaSegmentType.Outro) is Intro credits) + if (GetIntro(id, AnalysisMode.Credits) is Intro credits) { - segments[MediaSegmentType.Outro] = credits; + segments[AnalysisMode.Credits] = credits; } return segments; @@ -147,7 +146,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, MediaSegmentType mode) + private static Intro? GetIntro(Guid id, AnalysisMode mode) { try { @@ -188,13 +187,13 @@ public class SkipIntroController : ControllerBase /// No content. [Authorize(Policy = Policies.RequiresElevation)] [HttpPost("Intros/EraseTimestamps")] - public ActionResult ResetIntroTimestamps([FromQuery] MediaSegmentType mode, [FromQuery] bool eraseCache = false) + public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false) { - if (mode == MediaSegmentType.Intro) + if (mode == AnalysisMode.Introduction) { Plugin.Instance!.Intros.Clear(); } - else if (mode == MediaSegmentType.Outro) + else if (mode == AnalysisMode.Credits) { Plugin.Instance!.Credits.Clear(); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs index 139b42a..5d3ae9f 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs @@ -4,7 +4,6 @@ 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; @@ -115,8 +114,8 @@ public class VisualizationController(ILogger logger) : return new IgnoreListItem(Guid.Empty) { - IgnoreIntro = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, MediaSegmentType.Intro)), - IgnoreCredits = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, MediaSegmentType.Outro)) + IgnoreIntro = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, AnalysisMode.Introduction)), + IgnoreCredits = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, AnalysisMode.Credits)) }; } @@ -159,7 +158,7 @@ public class VisualizationController(ILogger logger) : { if (needle.EpisodeId == id) { - return FFmpegWrapper.Fingerprint(needle, MediaSegmentType.Intro); + return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction); } } } @@ -202,7 +201,7 @@ public class VisualizationController(ILogger logger) : } } - Plugin.Instance!.SaveTimestamps(MediaSegmentType.Intro | MediaSegmentType.Outro); + Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction | AnalysisMode.Credits); return NoContent(); } @@ -282,7 +281,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(MediaSegmentType.Intro); + Plugin.Instance.SaveTimestamps(AnalysisMode.Introduction); } return NoContent(); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisMode.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisMode.cs new file mode 100644 index 0000000..7da010a --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisMode.cs @@ -0,0 +1,17 @@ +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 8f9b53d..b5fd801 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/EpisodeState.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/EpisodeState.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using Jellyfin.Data.Enums; namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; @@ -10,61 +7,44 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; /// public class EpisodeState { - private readonly Dictionary _states = []; + private readonly bool[] _analyzedStates = new bool[2]; - /// - /// Initializes a new instance of the class. - /// - public EpisodeState() => - Array.ForEach(Enum.GetValues(), mode => _states[mode] = default); + private readonly bool[] _blacklistedStates = new bool[2]; /// /// 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(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(MediaSegmentType mode) => _states[mode].Blacklisted; + 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(MediaSegmentType mode, bool value) => - _states[mode] = (value, _states[mode].Blacklisted); + public void SetAnalyzed(AnalysisMode mode, bool value) => _analyzedStates[(int)mode] = value; + + /// + /// 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]; /// /// Sets the blacklisted state for the specified analysis mode. /// /// The analysis mode to set. /// The blacklisted state to set. - public void SetBlacklisted(MediaSegmentType mode, bool value) => - _states[mode] = (_states[mode].Analyzed, value); + public void SetBlacklisted(AnalysisMode mode, bool value) => _blacklistedStates[(int)mode] = value; /// - /// Resets all states to their default values. + /// Resets the analyzed states. /// - 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); + public void ResetStates() + { + Array.Clear(_analyzedStates); + Array.Clear(_blacklistedStates); + } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/IgnoreListItem.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/IgnoreListItem.cs index 23273c3..933bd3d 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/IgnoreListItem.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/IgnoreListItem.cs @@ -1,6 +1,5 @@ using System; using System.Runtime.Serialization; -using Jellyfin.Data.Enums; namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; @@ -60,14 +59,14 @@ public class IgnoreListItem /// /// Analysis mode. /// Value to set. - public void Toggle(MediaSegmentType mode, bool value) + public void Toggle(AnalysisMode mode, bool value) { switch (mode) { - case MediaSegmentType.Intro: + case AnalysisMode.Introduction: IgnoreIntro = value; break; - case MediaSegmentType.Outro: + case AnalysisMode.Credits: IgnoreCredits = value; break; } @@ -78,12 +77,12 @@ public class IgnoreListItem /// /// Analysis mode. /// True if ignored, false otherwise. - public bool IsIgnored(MediaSegmentType mode) + public bool IsIgnored(AnalysisMode mode) { return mode switch { - MediaSegmentType.Intro => IgnoreIntro, - MediaSegmentType.Outro => IgnoreCredits, + AnalysisMode.Introduction => IgnoreIntro, + AnalysisMode.Credits => IgnoreCredits, _ => false, }; } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs index 744c2c9..789be1d 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs @@ -7,7 +7,6 @@ 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; @@ -34,7 +33,7 @@ public static partial class FFmpegWrapper private static Dictionary ChromaprintLogs { get; set; } = []; - private static ConcurrentDictionary<(Guid Id, MediaSegmentType Mode), Dictionary> InvertedIndexCache { get; set; } = new(); + private static ConcurrentDictionary<(Guid Id, AnalysisMode Mode), Dictionary> InvertedIndexCache { get; set; } = new(); /// /// Check that the installed version of ffmpeg supports chromaprint. @@ -110,16 +109,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, MediaSegmentType mode) + public static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode) { int start, end; - if (mode == MediaSegmentType.Intro) + if (mode == AnalysisMode.Introduction) { start = 0; end = episode.IntroFingerprintEnd; } - else if (mode == MediaSegmentType.Outro) + else if (mode == AnalysisMode.Credits) { start = episode.CreditsFingerprintStart; end = episode.Duration; @@ -139,7 +138,7 @@ public static partial class FFmpegWrapper /// Chromaprint fingerprint. /// Mode. /// Inverted index. - public static Dictionary CreateInvertedIndex(Guid id, uint[] fingerprint, MediaSegmentType mode) + public static Dictionary CreateInvertedIndex(Guid id, uint[] fingerprint, AnalysisMode mode) { if (InvertedIndexCache.TryGetValue((id, mode), out var cached)) { @@ -469,7 +468,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, MediaSegmentType mode, int start, int end) + private static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode, int start, int end) { // Try to load this episode from cache before running ffmpeg. if (LoadCachedFingerprint(episode, mode, out uint[] cachedFingerprint)) @@ -523,7 +522,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, - MediaSegmentType mode, + AnalysisMode mode, out uint[] fingerprint) { fingerprint = Array.Empty(); @@ -579,7 +578,7 @@ public static partial class FFmpegWrapper /// Fingerprint of the episode to store. private static void CacheFingerprint( QueuedEpisode episode, - MediaSegmentType mode, + AnalysisMode mode, List fingerprint) { // Bail out if caching isn't enabled. @@ -628,11 +627,11 @@ public static partial class FFmpegWrapper /// Remove cached fingerprints from disk by mode. /// /// Analysis mode. - public static void DeleteCacheFiles(MediaSegmentType mode) + public static void DeleteCacheFiles(AnalysisMode mode) { foreach (var filePath in Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath)) { - var shouldDelete = (mode == MediaSegmentType.Intro) + var shouldDelete = (mode == AnalysisMode.Introduction) ? !filePath.Contains("credit", StringComparison.OrdinalIgnoreCase) && !filePath.Contains("blackframes", StringComparison.OrdinalIgnoreCase) : filePath.Contains("credit", StringComparison.OrdinalIgnoreCase) @@ -652,18 +651,18 @@ public static partial class FFmpegWrapper /// Episode. /// Analysis mode. /// Path. - public static string GetFingerprintCachePath(QueuedEpisode episode, MediaSegmentType mode) + public static string GetFingerprintCachePath(QueuedEpisode episode, AnalysisMode mode) { var basePath = Path.Join( Plugin.Instance!.FingerprintCachePath, episode.EpisodeId.ToString("N")); - if (mode == MediaSegmentType.Intro) + if (mode == AnalysisMode.Introduction) { return basePath; } - if (mode == MediaSegmentType.Outro) + if (mode == AnalysisMode.Credits) { return basePath + "-credits"; } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Helper/XmlSerializationHelper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Helper/XmlSerializationHelper.cs index db5f929..f02d126 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.Helper +namespace ConfusedPolarBear.Plugin.IntroSkipper { internal sealed class XmlSerializationHelper { diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs index a21bf15..dd8fc4b 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs @@ -4,114 +4,113 @@ using System.IO; using ConfusedPolarBear.Plugin.IntroSkipper.Data; using Microsoft.Extensions.Logging; -namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +/// +/// Update EDL files associated with a list of episodes. +/// +public static class EdlManager { + private static ILogger? _logger; + /// - /// Update EDL files associated with a list of episodes. + /// Initialize EDLManager with a logger. /// - public static class EdlManager + /// ILogger. + public static void Initialize(ILogger logger) { - private static ILogger? _logger; + _logger = logger; + } - /// - /// Initialize EDLManager with a logger. - /// - /// ILogger. - public static void Initialize(ILogger logger) + /// + /// Logs the configuration that will be used during EDL file creation. + /// + public static void LogConfiguration() + { + if (_logger is null) { - _logger = logger; + throw new InvalidOperationException("Logger must not be null"); } - /// - /// Logs the configuration that will be used during EDL file creation. - /// - public static void LogConfiguration() + var config = Plugin.Instance!.Configuration; + + if (config.EdlAction == EdlAction.None) { - 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: None - taking no further action"); + return; } - /// - /// 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: {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) { - var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles; - var action = Plugin.Instance.Configuration.EdlAction; - if (action == EdlAction.None) - { - _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) - { - _logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id); - continue; - } - - var edlPath = GetEdlPath(Plugin.Instance.GetItemPath(id)); - - _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); - } + _logger?.LogDebug("EDL action is set to none, not updating EDL files"); + return; } - /// - /// 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) + _logger?.LogDebug("Updating EDL files with action {Action}", action); + + foreach (var episode in episodes) { - return Path.ChangeExtension(mediaPath, "edl"); + 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) + { + _logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id); + continue; + } + + var edlPath = GetEdlPath(Plugin.Instance.GetItemPath(id)); + + _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"); + } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs index 924d82d..13e981c 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs @@ -10,281 +10,287 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; -namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager +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) { + private readonly ILibraryManager _libraryManager = libraryManager; + private readonly ILogger _logger = logger; + private readonly Dictionary> _queuedEpisodes = []; + private double _analysisPercent; + private List _selectedLibraries = []; + private bool _selectAllLibraries; + /// - /// Manages enqueuing library items for analysis. + /// Gets all media items on the server. /// - /// - /// Initializes a new instance of the class. - /// - /// Logger. - /// Library manager. - public class QueueManager(ILogger logger, ILibraryManager libraryManager) + /// Queued media items. + public IReadOnlyDictionary> GetMediaItems() { - private readonly ILibraryManager _libraryManager = libraryManager; - private readonly ILogger _logger = logger; - private readonly Dictionary> _queuedEpisodes = []; - private double _analysisPercent; - private List _selectedLibraries = []; - private bool _selectAllLibraries; + Plugin.Instance!.TotalQueued = 0; - /// - /// Gets all media items on the server. - /// - /// Queued media items. - public IReadOnlyDictionary> GetMediaItems() + LoadAnalysisSettings(); + + // For all selected libraries, enqueue all contained episodes. + foreach (var folder in _libraryManager.GetVirtualFolders()) { - Plugin.Instance!.TotalQueued = 0; - - LoadAnalysisSettings(); - - // For all selected libraries, enqueue all contained episodes. - foreach (var folder in _libraryManager.GetVirtualFolders()) + // If libraries have been selected for analysis, ensure this library was selected. + if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name)) { - // If libraries have been selected for analysis, ensure this library was selected. - if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name)) - { - _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); - } + _logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name); + continue; } - Plugin.Instance.TotalSeasons = _queuedEpisodes.Count; - Plugin.Instance.QueuedMediaItems.Clear(); + _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) { - 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) + 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(); + + foreach (var candidate in candidates) + { + try + { + var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId); + + if (!File.Exists(path)) { - _logger.LogDebug("Item {Name} is not an episode", item.Name); continue; } - QueueEpisode(episode); - } + verified.Add(candidate); - _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) + foreach (var mode in modes) { - 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))) + if (candidate.State.IsAnalyzed(mode) || candidate.State.IsBlacklisted(mode)) { continue; } - verified.Add(candidate); - reqModes.ExceptWith(candidate.State.GetAnalyzedModes()); - reqModes.ExceptWith(candidate.State.GetBlacklistedModes()); + bool isAnalyzed = mode == AnalysisMode.Introduction + ? Plugin.Instance!.Intros.ContainsKey(candidate.EpisodeId) + : Plugin.Instance!.Credits.ContainsKey(candidate.EpisodeId); - if (reqModes.Remove(MediaSegmentType.Intro) && Plugin.Instance.Intros.ContainsKey(candidate.EpisodeId)) + if (isAnalyzed) { - candidate.State.SetAnalyzed(MediaSegmentType.Intro, true); + candidate.State.SetAnalyzed(mode, true); } else { - reqModes.Add(MediaSegmentType.Intro); + reqModes.Add(mode); } - - 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); } } - - return (verified, reqModes); + catch (Exception ex) + { + _logger.LogDebug( + "Skipping analysis of {Name} ({Id}): {Exception}", + candidate.Name, + candidate.EpisodeId, + ex); + } } + + return (verified, reqModes); } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs index 335665b..7050830 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs @@ -6,8 +6,6 @@ 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; @@ -184,16 +182,16 @@ public class Plugin : BasePlugin, IHasWebPages /// Save timestamps to disk. /// /// Mode. - public void SaveTimestamps(MediaSegmentType mode) + public void SaveTimestamps(AnalysisMode mode) { List introList = []; - var filePath = mode == MediaSegmentType.Intro + var filePath = mode == AnalysisMode.Introduction ? _introPath : _creditsPath; lock (_introsLock) { - introList.AddRange(mode == MediaSegmentType.Intro + introList.AddRange(mode == AnalysisMode.Introduction ? Instance!.Intros.Values : Instance!.Credits.Values); } @@ -237,7 +235,7 @@ public class Plugin : BasePlugin, IHasWebPages /// Item id. /// Mode. /// True if ignored, false otherwise. - public bool IsIgnored(Guid id, MediaSegmentType mode) + public bool IsIgnored(Guid id, AnalysisMode mode) { return Instance!.IgnoreList.TryGetValue(id, out var item) && item.IsIgnored(mode); } @@ -314,9 +312,9 @@ public class Plugin : BasePlugin, IHasWebPages /// Item id. /// Mode. /// Intro. - internal static Segment GetIntroByMode(Guid id, MediaSegmentType mode) + internal static Segment GetIntroByMode(Guid id, AnalysisMode mode) { - return mode == MediaSegmentType.Intro + return mode == AnalysisMode.Introduction ? Instance!.Intros[id] : Instance!.Credits[id]; } @@ -375,15 +373,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, MediaSegmentType mode) + internal void UpdateTimestamps(IReadOnlyDictionary newTimestamps, AnalysisMode mode) { foreach (var intro in newTimestamps) { - if (mode == MediaSegmentType.Intro) + if (mode == AnalysisMode.Introduction) { Instance!.Intros.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value); } - else if (mode == MediaSegmentType.Outro) + else if (mode == AnalysisMode.Credits) { Instance!.Credits.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value); } @@ -406,8 +404,8 @@ public class Plugin : BasePlugin, IHasWebPages } } - SaveTimestamps(MediaSegmentType.Intro); - SaveTimestamps(MediaSegmentType.Outro); + SaveTimestamps(AnalysisMode.Introduction); + SaveTimestamps(AnalysisMode.Credits); } private void MigrateRepoUrl(IServerConfigurationManager serverConfiguration) diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs b/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs index 89a856d..e5f6c42 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs @@ -1,5 +1,4 @@ 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 78c196d..7f8c49f 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs @@ -6,8 +6,6 @@ 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; @@ -18,7 +16,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; /// public class BaseItemAnalyzerTask { - private readonly IReadOnlyCollection _modes; + private readonly IReadOnlyCollection _analysisModes; private readonly ILogger _logger; @@ -34,12 +32,12 @@ public class BaseItemAnalyzerTask /// Logger factory. /// Library manager. public BaseItemAnalyzerTask( - IReadOnlyCollection modes, + IReadOnlyCollection modes, ILogger logger, ILoggerFactory loggerFactory, ILibraryManager libraryManager) { - _modes = modes; + _analysisModes = modes; _logger = logger; _loggerFactory = loggerFactory; _libraryManager = libraryManager; @@ -81,7 +79,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) * _modes.Count; + int totalQueued = queue.Sum(kvp => kvp.Value.Count) * _analysisModes.Count; if (totalQueued == 0) { throw new FingerprintException( @@ -107,7 +105,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, - _modes.Where(m => !Plugin.Instance!.IsIgnored(season.Key, m)).ToList()); + _analysisModes.Where(m => !Plugin.Instance!.IsIgnored(season.Key, m)).ToList()); if (episodes.Count == 0) { @@ -122,13 +120,13 @@ public class BaseItemAnalyzerTask first.SeriesName, first.SeasonNumber); - Interlocked.Add(ref totalProcessed, episodes.Count * _modes.Count); // Update total Processed directly + Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly progress.Report(totalProcessed * 100 / totalQueued); return; } - if (_modes.Count != requiredModes.Count) + if (_analysisModes.Count != requiredModes.Count) { Interlocked.Add(ref totalProcessed, episodes.Count); progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed @@ -141,7 +139,7 @@ public class BaseItemAnalyzerTask return; } - foreach (MediaSegmentType mode in requiredModes) + foreach (AnalysisMode mode in requiredModes) { var analyzed = AnalyzeItems(episodes, mode, cancellationToken); Interlocked.Add(ref totalProcessed, analyzed); @@ -183,7 +181,7 @@ public class BaseItemAnalyzerTask /// Number of items that were successfully analyzed. private int AnalyzeItems( IReadOnlyList items, - MediaSegmentType mode, + AnalysisMode mode, CancellationToken cancellationToken) { var totalItems = items.Count; @@ -218,7 +216,7 @@ public class BaseItemAnalyzerTask analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger())); } - if (mode == MediaSegmentType.Outro) + if (mode == AnalysisMode.Credits) { analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger())); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs index 568fae7..f644db1 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs @@ -4,7 +4,6 @@ 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; @@ -14,22 +13,29 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; /// /// Analyze all television episodes for introduction sequences. /// -/// -/// Initializes a new instance of the class. -/// -/// Logger factory. -/// Library manager. -/// Logger. -public class CleanCacheTask( - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager) : IScheduledTask +public class CleanCacheTask : IScheduledTask { - private readonly ILogger _logger = logger; + private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory = loggerFactory; + private readonly ILoggerFactory _loggerFactory; - private readonly ILibraryManager _libraryManager = libraryManager; + 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; + } /// /// Gets the task name. diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs index 5ae41f2..7706da4 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs @@ -1,8 +1,9 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Enums; +using ConfusedPolarBear.Plugin.IntroSkipper.Data; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -13,22 +14,29 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; /// Analyze all television episodes for credits. /// TODO: analyze all media files. /// -/// -/// Initializes a new instance of the class. -/// -/// Logger factory. -/// Library manager. -/// Logger. -public class DetectCreditsTask( - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager) : IScheduledTask +public class DetectCreditsTask : IScheduledTask { - private readonly ILogger _logger = logger; + private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory = loggerFactory; + private readonly ILoggerFactory _loggerFactory; - private readonly ILibraryManager _libraryManager = libraryManager; + 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; + } /// /// Gets the task name. @@ -74,7 +82,7 @@ public class DetectCreditsTask( { _logger.LogInformation("Scheduled Task is starting"); - var modes = new List { MediaSegmentType.Outro }; + var modes = new List { AnalysisMode.Credits }; var baseCreditAnalyzer = new BaseItemAnalyzerTask( modes, diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs index 6ba00dc..72e8fee 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs @@ -1,8 +1,9 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Enums; +using ConfusedPolarBear.Plugin.IntroSkipper.Data; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -12,22 +13,29 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; /// /// Analyze all television episodes for introduction sequences. /// -/// -/// Initializes a new instance of the class. -/// -/// Logger factory. -/// Library manager. -/// Logger. -public class DetectIntrosCreditsTask( - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager) : IScheduledTask +public class DetectIntrosCreditsTask : IScheduledTask { - private readonly ILogger _logger = logger; + private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory = loggerFactory; + private readonly ILoggerFactory _loggerFactory; - private readonly ILibraryManager _libraryManager = libraryManager; + 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; + } /// /// Gets the task name. @@ -73,7 +81,7 @@ public class DetectIntrosCreditsTask( { _logger.LogInformation("Scheduled Task is starting"); - var modes = new List { MediaSegmentType.Intro, MediaSegmentType.Outro }; + var modes = new List { AnalysisMode.Introduction, AnalysisMode.Credits }; var baseIntroAnalyzer = new BaseItemAnalyzerTask( modes, diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs index 2d485f4..d7b17b1 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs @@ -1,8 +1,9 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Enums; +using ConfusedPolarBear.Plugin.IntroSkipper.Data; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -12,22 +13,29 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; /// /// Analyze all television episodes for introduction sequences. /// -/// -/// Initializes a new instance of the class. -/// -/// Logger factory. -/// Library manager. -/// Logger. -public class DetectIntrosTask( - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager) : IScheduledTask +public class DetectIntrosTask : IScheduledTask { - private readonly ILogger _logger = logger; + private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory = loggerFactory; + private readonly ILoggerFactory _loggerFactory; - private readonly ILibraryManager _libraryManager = libraryManager; + 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; + } /// /// Gets the task name. @@ -73,7 +81,7 @@ public class DetectIntrosTask( { _logger.LogInformation("Scheduled Task is starting"); - var modes = new List { MediaSegmentType.Intro }; + var modes = new List { AnalysisMode.Introduction }; var baseIntroAnalyzer = new BaseItemAnalyzerTask( modes, diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkip.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkip.cs index 8b236bb..f019056 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.Services +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 { - /// - /// 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 + 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) { - 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 = []; + 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 void AutoSkipChanged(object? sender, BasePluginConfiguration e) + 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) { - 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; + return; } - private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) - { - var itemId = e.Item.Id; - var newState = false; - var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); + // Lookup the session for this item. + SessionInfo? session = null; - // Ignore all events except playback start & end - if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) + try + { + foreach (var needle in _sessionManager.Sessions) { + if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) + { + session = needle; + break; + } + } + + if (session == null) + { + _logger.LogInformation("Unable to find session for {Item}", itemId); return; } - - // Lookup the session for this item. - SessionInfo? session = null; - - try - { - foreach (var needle in _sessionManager.Sessions) - { - if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) - { - session = needle; - break; - } - } - - if (session == null) - { - _logger.LogInformation("Unable to find session for {Item}", itemId); - return; - } - } - catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) - { - return; - } - - // If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session. - if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) - { - newState = true; - } - - // Reset the seek command state for this device. - lock (_sentSeekCommandLock) - { - var device = session.DeviceId; - - _logger.LogDebug("Resetting seek command state for session {Session}", device); - _sentSeekCommand[device] = newState; - } + } + catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) + { + return; } - private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) + // 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) { - 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; - } - } + newState = true; } - /// - /// Dispose. - /// - public void Dispose() + // Reset the seek command state for this device. + lock (_sentSeekCommandLock) { - Dispose(true); - GC.SuppressFinalize(this); - } + var device = session.DeviceId; - /// - /// Protected dispose. - /// - /// Dispose. - protected virtual void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - _playbackTimer.Stop(); - _playbackTimer.Dispose(); - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogDebug("Setting up automatic skipping"); - - _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; - Plugin.Instance!.ConfigurationChanged += AutoSkipChanged; - - // Make the timer restart automatically and set enabled to match the configuration value. - _playbackTimer.AutoReset = true; - _playbackTimer.Elapsed += PlaybackTimer_Elapsed; - - AutoSkipChanged(null, Plugin.Instance.Configuration); - - return Task.CompletedTask; - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; - return Task.CompletedTask; + _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) + { + 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; + } + } + } + + /// + /// Dispose. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Protected dispose. + /// + /// Dispose. + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _playbackTimer.Stop(); + _playbackTimer.Dispose(); + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("Setting up automatic skipping"); + + _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; + Plugin.Instance!.ConfigurationChanged += AutoSkipChanged; + + // Make the timer restart automatically and set enabled to match the configuration value. + _playbackTimer.AutoReset = true; + _playbackTimer.Elapsed += PlaybackTimer_Elapsed; + + 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 03ae43d..6898417 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.Services +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 { - /// - /// Automatically skip past credit sequences. - /// Commands clients to seek to the end of the credits as soon as they start playing it. - /// - /// - /// Initializes a new instance of the class. - /// - /// User data manager. - /// Session manager. - /// Logger. - public class AutoSkipCredits( - IUserDataManager userDataManager, - ISessionManager sessionManager, - ILogger logger) : IHostedService, IDisposable + private readonly object _sentSeekCommandLock = new(); + + private ILogger _logger = logger; + private IUserDataManager _userDataManager = userDataManager; + private ISessionManager _sessionManager = sessionManager; + private Timer _playbackTimer = new(1000); + private Dictionary _sentSeekCommand = []; + private HashSet _clientList = []; + + private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e) { - 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 = []; + var configuration = (PluginConfiguration)e; + _clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + var newState = configuration.AutoSkipCredits || _clientList.Count > 0; + _logger.LogDebug("Setting playback timer enabled to {NewState}", newState); + _playbackTimer.Enabled = newState; + } - private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e) + 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) { - 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; + return; } - private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) - { - var itemId = e.Item.Id; - var newState = false; - var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); + // Lookup the session for this item. + SessionInfo? session = null; - // Ignore all events except playback start & end - if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) + try + { + foreach (var needle in _sessionManager.Sessions) { + if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) + { + session = needle; + break; + } + } + + if (session == null) + { + _logger.LogInformation("Unable to find session for {Item}", itemId); return; } - - // Lookup the session for this item. - SessionInfo? session = null; - - try - { - foreach (var needle in _sessionManager.Sessions) - { - if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) - { - session = needle; - break; - } - } - - if (session == null) - { - _logger.LogInformation("Unable to find session for {Item}", itemId); - return; - } - } - catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) - { - return; - } - - // If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session. - if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) - { - newState = true; - } - - // Reset the seek command state for this device. - lock (_sentSeekCommandLock) - { - var device = session.DeviceId; - - _logger.LogDebug("Resetting seek command state for session {Session}", device); - _sentSeekCommand[device] = newState; - } + } + catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) + { + return; } - private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) + // 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) { - 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; - } - } + newState = true; } - /// - /// Dispose. - /// - public void Dispose() + // Reset the seek command state for this device. + lock (_sentSeekCommandLock) { - Dispose(true); - GC.SuppressFinalize(this); - } + var device = session.DeviceId; - /// - /// Protected dispose. - /// - /// Dispose. - protected virtual void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - _playbackTimer.Stop(); - _playbackTimer.Dispose(); - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogDebug("Setting up automatic credit skipping"); - - _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; - Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged; - - // Make the timer restart automatically and set enabled to match the configuration value. - _playbackTimer.AutoReset = true; - _playbackTimer.Elapsed += PlaybackTimer_Elapsed; - - AutoSkipCreditChanged(null, Plugin.Instance.Configuration); - - return Task.CompletedTask; - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; - return Task.CompletedTask; + _logger.LogDebug("Resetting seek command state for session {Session}", device); + _sentSeekCommand[device] = newState; } } + + private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) + { + foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkipCredits || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase))) + { + var deviceId = session.DeviceId; + var itemId = session.NowPlayingItem.Id; + var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond; + + // Don't send the seek command more than once in the same session. + lock (_sentSeekCommandLock) + { + if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) + { + _logger.LogTrace("Already sent seek command for session {Session}", deviceId); + continue; + } + } + + // Assert that credits were detected for this item. + 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; + } + } + } + + /// + /// Dispose. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Protected dispose. + /// + /// Dispose. + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _playbackTimer.Stop(); + _playbackTimer.Dispose(); + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("Setting up automatic credit skipping"); + + _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; + Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged; + + // Make the timer restart automatically and set enabled to match the configuration value. + _playbackTimer.AutoReset = true; + _playbackTimer.Elapsed += PlaybackTimer_Elapsed; + + AutoSkipCreditChanged(null, Plugin.Instance.Configuration); + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; + return Task.CompletedTask; + } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs index 193a649..5fc196a 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs @@ -3,9 +3,8 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; -using ConfusedPolarBear.Plugin.IntroSkipper.Manager; +using ConfusedPolarBear.Plugin.IntroSkipper.Data; using ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; -using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; @@ -262,18 +261,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(MediaSegmentType.Intro); + modes.Add(AnalysisMode.Introduction); tasklogger = _loggerFactory.CreateLogger(); } if (_config.AutoDetectCredits) { - modes.Add(MediaSegmentType.Outro); + modes.Add(AnalysisMode.Credits); tasklogger = modes.Count == 2 ? _loggerFactory.CreateLogger() : _loggerFactory.CreateLogger();