From fc830a5e6f81849515cd0aa237433870e1c8f506 Mon Sep 17 00:00:00 2001 From: rlauu <46294892+rlauu@users.noreply.github.com> Date: Wed, 16 Oct 2024 16:20:21 +0200 Subject: [PATCH] Use primary constructor everywhere --- .../TestEdl.cs | 1 + .../Analyzers/AnalyzerHelper.cs | 181 +++--- ...nfusedPolarBear.Plugin.IntroSkipper.csproj | 4 - .../Helper/XmlSerializationHelper.cs | 2 +- .../Manager/EdlManager.cs | 169 ++--- .../Manager/QueueManager.cs | 513 ++++++++-------- .../Plugin.cs | 1 + .../PluginServiceRegistrator.cs | 1 + .../ScheduledTasks/BaseItemAnalyzerTask.cs | 1 + .../ScheduledTasks/CleanCacheTask.cs | 1 + .../ScheduledTasks/DetectCreditsTask.cs | 1 + .../ScheduledTasks/DetectIntrosCreditsTask.cs | 1 + .../ScheduledTasks/DetectIntrosTask.cs | 1 + .../Services/AutoSkip.cs | 347 +++++------ .../Services/AutoSkipCredits.cs | 347 +++++------ .../Services/Entrypoint.cs | 580 +++++++++--------- 16 files changed, 1081 insertions(+), 1070 deletions(-) diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs index fe86c00..6106b2c 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs @@ -1,5 +1,6 @@ using System; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using ConfusedPolarBear.Plugin.IntroSkipper.Manager; using Xunit; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/AnalyzerHelper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/AnalyzerHelper.cs index d7829de..3170689 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/AnalyzerHelper.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/AnalyzerHelper.cs @@ -5,112 +5,113 @@ using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Data; using Microsoft.Extensions.Logging; -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Analyzer Helper. -/// -public class AnalyzerHelper +namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers { - private readonly ILogger _logger; - private readonly double _silenceDetectionMinimumDuration; - /// - /// Initializes a new instance of the class. + /// Analyzer Helper. /// - /// Logger. - public AnalyzerHelper(ILogger logger) + public class AnalyzerHelper { - var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); - _silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration; - _logger = logger; - } + private readonly ILogger _logger; + private readonly double _silenceDetectionMinimumDuration; - /// - /// Adjusts the end timestamps of all intros so that they end at silence. - /// - /// QueuedEpisodes to adjust. - /// Original introductions. - /// Analysis mode. - /// Modified Intro Timestamps. - public Dictionary AdjustIntroTimes( - IReadOnlyList episodes, - IReadOnlyDictionary originalIntros, - AnalysisMode mode) - { - return episodes - .Where(episode => originalIntros.TryGetValue(episode.EpisodeId, out var _)) - .ToDictionary( - episode => episode.EpisodeId, - episode => AdjustIntroForEpisode(episode, originalIntros[episode.EpisodeId], mode)); - } - - private Segment AdjustIntroForEpisode(QueuedEpisode episode, Segment originalIntro, AnalysisMode mode) - { - _logger.LogTrace("{Name} original intro: {Start} - {End}", episode.Name, originalIntro.Start, originalIntro.End); - - var adjustedIntro = new Segment(originalIntro); - var originalIntroStart = new TimeRange(Math.Max(0, (int)originalIntro.Start - 5), (int)originalIntro.Start + 10); - var originalIntroEnd = new TimeRange((int)originalIntro.End - 10, Math.Min(episode.Duration, (int)originalIntro.End + 5)); - - if (!AdjustIntroBasedOnChapters(episode, adjustedIntro, originalIntroStart, originalIntroEnd) && mode == AnalysisMode.Introduction) + /// + /// Initializes a new instance of the class. + /// + /// Logger. + public AnalyzerHelper(ILogger logger) { - AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd); + var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); + _silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration; + _logger = logger; } - return adjustedIntro; - } - - private bool AdjustIntroBasedOnChapters(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroStart, TimeRange originalIntroEnd) - { - var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? []; - double previousTime = 0; - - for (int i = 0; i <= chapters.Count; i++) + /// + /// Adjusts the end timestamps of all intros so that they end at silence. + /// + /// QueuedEpisodes to adjust. + /// Original introductions. + /// Analysis mode. + /// Modified Intro Timestamps. + public Dictionary AdjustIntroTimes( + IReadOnlyList episodes, + IReadOnlyDictionary originalIntros, + AnalysisMode mode) { - double currentTime = i < chapters.Count - ? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds - : episode.Duration; - - if (originalIntroStart.Start < previousTime && previousTime < originalIntroStart.End) - { - adjustedIntro.Start = previousTime; - _logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime); - } - - if (originalIntroEnd.Start < currentTime && currentTime < originalIntroEnd.End) - { - adjustedIntro.End = currentTime; - _logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime); - return true; - } - - previousTime = currentTime; + return episodes + .Where(episode => originalIntros.TryGetValue(episode.EpisodeId, out var _)) + .ToDictionary( + episode => episode.EpisodeId, + episode => AdjustIntroForEpisode(episode, originalIntros[episode.EpisodeId], mode)); } - return false; - } - - private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroEnd) - { - var silence = FFmpegWrapper.DetectSilence(episode, originalIntroEnd); - - foreach (var currentRange in silence) + private Segment AdjustIntroForEpisode(QueuedEpisode episode, Segment originalIntro, AnalysisMode mode) { - _logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, currentRange.Start, currentRange.End); + _logger.LogTrace("{Name} original intro: {Start} - {End}", episode.Name, originalIntro.Start, originalIntro.End); - if (IsValidSilenceForIntroAdjustment(currentRange, originalIntroEnd, adjustedIntro)) + var adjustedIntro = new Segment(originalIntro); + var originalIntroStart = new TimeRange(Math.Max(0, (int)originalIntro.Start - 5), (int)originalIntro.Start + 10); + var originalIntroEnd = new TimeRange((int)originalIntro.End - 10, Math.Min(episode.Duration, (int)originalIntro.End + 5)); + + if (!AdjustIntroBasedOnChapters(episode, adjustedIntro, originalIntroStart, originalIntroEnd) && mode == AnalysisMode.Introduction) { - adjustedIntro.End = currentRange.Start; - break; + AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd); + } + + return adjustedIntro; + } + + private bool AdjustIntroBasedOnChapters(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroStart, TimeRange originalIntroEnd) + { + var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? []; + double previousTime = 0; + + for (int i = 0; i <= chapters.Count; i++) + { + double currentTime = i < chapters.Count + ? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds + : episode.Duration; + + if (originalIntroStart.Start < previousTime && previousTime < originalIntroStart.End) + { + adjustedIntro.Start = previousTime; + _logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime); + } + + if (originalIntroEnd.Start < currentTime && currentTime < originalIntroEnd.End) + { + adjustedIntro.End = currentTime; + _logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime); + return true; + } + + previousTime = currentTime; + } + + return false; + } + + private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroEnd) + { + var silence = FFmpegWrapper.DetectSilence(episode, originalIntroEnd); + + foreach (var currentRange in silence) + { + _logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, currentRange.Start, currentRange.End); + + if (IsValidSilenceForIntroAdjustment(currentRange, originalIntroEnd, adjustedIntro)) + { + adjustedIntro.End = currentRange.Start; + break; + } } } - } - private bool IsValidSilenceForIntroAdjustment(TimeRange silenceRange, TimeRange originalIntroEnd, Segment adjustedIntro) - { - return originalIntroEnd.Intersects(silenceRange) && - silenceRange.Duration >= _silenceDetectionMinimumDuration && - silenceRange.Start >= adjustedIntro.Start; + private bool IsValidSilenceForIntroAdjustment(TimeRange silenceRange, TimeRange originalIntroEnd, Segment adjustedIntro) + { + return originalIntroEnd.Intersects(silenceRange) && + silenceRange.Duration >= _silenceDetectionMinimumDuration && + silenceRange.Start >= adjustedIntro.Start; + } } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj index 0a5c4a9..ff595c2 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj @@ -23,8 +23,4 @@ - - - - diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Helper/XmlSerializationHelper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Helper/XmlSerializationHelper.cs index f02d126..db5f929 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Helper/XmlSerializationHelper.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Helper/XmlSerializationHelper.cs @@ -6,7 +6,7 @@ using System.Runtime.Serialization; using System.Xml; using ConfusedPolarBear.Plugin.IntroSkipper.Data; -namespace ConfusedPolarBear.Plugin.IntroSkipper +namespace ConfusedPolarBear.Plugin.IntroSkipper.Helper { internal sealed class XmlSerializationHelper { diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs index dd8fc4b..a21bf15 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/EdlManager.cs @@ -4,113 +4,114 @@ using System.IO; using ConfusedPolarBear.Plugin.IntroSkipper.Data; using Microsoft.Extensions.Logging; -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Update EDL files associated with a list of episodes. -/// -public static class EdlManager +namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager { - private static ILogger? _logger; - /// - /// Initialize EDLManager with a logger. + /// Update EDL files associated with a list of episodes. /// - /// ILogger. - public static void Initialize(ILogger logger) + public static class EdlManager { - _logger = logger; - } + private static ILogger? _logger; - /// - /// Logs the configuration that will be used during EDL file creation. - /// - public static void LogConfiguration() - { - if (_logger is null) + /// + /// Initialize EDLManager with a logger. + /// + /// ILogger. + public static void Initialize(ILogger logger) { - throw new InvalidOperationException("Logger must not be null"); + _logger = logger; } - var config = Plugin.Instance!.Configuration; - - if (config.EdlAction == EdlAction.None) + /// + /// Logs the configuration that will be used during EDL file creation. + /// + public static void LogConfiguration() { - _logger.LogDebug("EDL action: None - taking no further action"); - return; + if (_logger is null) + { + throw new InvalidOperationException("Logger must not be null"); + } + + var config = Plugin.Instance!.Configuration; + + if (config.EdlAction == EdlAction.None) + { + _logger.LogDebug("EDL action: None - taking no further action"); + return; + } + + _logger.LogDebug("EDL action: {Action}", config.EdlAction); + _logger.LogDebug("Regenerate EDL files: {Regenerate}", config.RegenerateEdlFiles); } - _logger.LogDebug("EDL action: {Action}", config.EdlAction); - _logger.LogDebug("Regenerate EDL files: {Regenerate}", config.RegenerateEdlFiles); - } - - /// - /// If the EDL action is set to a value other than None, update EDL files for the provided episodes. - /// - /// Episodes to update EDL files for. - public static void UpdateEDLFiles(IReadOnlyList episodes) - { - var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles; - var action = Plugin.Instance.Configuration.EdlAction; - if (action == EdlAction.None) + /// + /// If the EDL action is set to a value other than None, update EDL files for the provided episodes. + /// + /// Episodes to update EDL files for. + public static void UpdateEDLFiles(IReadOnlyList episodes) { - _logger?.LogDebug("EDL action is set to none, not updating EDL files"); - return; - } - - _logger?.LogDebug("Updating EDL files with action {Action}", action); - - foreach (var episode in episodes) - { - var id = episode.EpisodeId; - - bool hasIntro = Plugin.Instance!.Intros.TryGetValue(id, out var intro) && intro.Valid; - bool hasCredit = Plugin.Instance!.Credits.TryGetValue(id, out var credit) && credit.Valid; - - if (!hasIntro && !hasCredit) + var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles; + var action = Plugin.Instance.Configuration.EdlAction; + if (action == EdlAction.None) { - _logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id); - continue; + _logger?.LogDebug("EDL action is set to none, not updating EDL files"); + return; } - var edlPath = GetEdlPath(Plugin.Instance.GetItemPath(id)); + _logger?.LogDebug("Updating EDL files with action {Action}", action); - _logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath); - - if (!regenerate && File.Exists(edlPath)) + foreach (var episode in episodes) { - _logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath); - continue; - } + var id = episode.EpisodeId; - var edlContent = string.Empty; + bool hasIntro = Plugin.Instance!.Intros.TryGetValue(id, out var intro) && intro.Valid; + bool hasCredit = Plugin.Instance!.Credits.TryGetValue(id, out var credit) && credit.Valid; - if (hasIntro) - { - edlContent += intro?.ToEdl(action); - } - - if (hasCredit) - { - if (edlContent.Length > 0) + if (!hasIntro && !hasCredit) { - edlContent += Environment.NewLine; + _logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id); + continue; } - edlContent += credit?.ToEdl(action); - } + var edlPath = GetEdlPath(Plugin.Instance.GetItemPath(id)); - File.WriteAllText(edlPath, edlContent); + _logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath); + + if (!regenerate && File.Exists(edlPath)) + { + _logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath); + continue; + } + + var edlContent = string.Empty; + + if (hasIntro) + { + edlContent += intro?.ToEdl(action); + } + + if (hasCredit) + { + if (edlContent.Length > 0) + { + edlContent += Environment.NewLine; + } + + edlContent += credit?.ToEdl(action); + } + + File.WriteAllText(edlPath, edlContent); + } + } + + /// + /// Given the path to an episode, return the path to the associated EDL file. + /// + /// Full path to episode. + /// Full path to EDL file. + public static string GetEdlPath(string mediaPath) + { + return Path.ChangeExtension(mediaPath, "edl"); } } - - /// - /// Given the path to an episode, return the path to the associated EDL file. - /// - /// Full path to episode. - /// Full path to EDL file. - public static string GetEdlPath(string mediaPath) - { - return Path.ChangeExtension(mediaPath, "edl"); - } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs index 13e981c..e324d76 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Manager/QueueManager.cs @@ -10,287 +10,288 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Manages enqueuing library items for analysis. -/// -/// -/// Initializes a new instance of the class. -/// -/// Logger. -/// Library manager. -public class QueueManager(ILogger logger, ILibraryManager libraryManager) +namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager { - private readonly ILibraryManager _libraryManager = libraryManager; - private readonly ILogger _logger = logger; - private readonly Dictionary> _queuedEpisodes = []; - private double _analysisPercent; - private List _selectedLibraries = []; - private bool _selectAllLibraries; - /// - /// Gets all media items on the server. + /// Manages enqueuing library items for analysis. /// - /// Queued media items. - public IReadOnlyDictionary> GetMediaItems() + /// + /// Initializes a new instance of the class. + /// + /// Logger. + /// Library manager. + public class QueueManager(ILogger logger, ILibraryManager libraryManager) { - Plugin.Instance!.TotalQueued = 0; + private readonly ILibraryManager _libraryManager = libraryManager; + private readonly ILogger _logger = logger; + private readonly Dictionary> _queuedEpisodes = []; + private double _analysisPercent; + private List _selectedLibraries = []; + private bool _selectAllLibraries; - LoadAnalysisSettings(); - - // For all selected libraries, enqueue all contained episodes. - foreach (var folder in _libraryManager.GetVirtualFolders()) + /// + /// Gets all media items on the server. + /// + /// Queued media items. + public IReadOnlyDictionary> GetMediaItems() { - // If libraries have been selected for analysis, ensure this library was selected. - if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name)) + Plugin.Instance!.TotalQueued = 0; + + LoadAnalysisSettings(); + + // For all selected libraries, enqueue all contained episodes. + foreach (var folder in _libraryManager.GetVirtualFolders()) { - _logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name); - continue; - } - - _logger.LogInformation("Running enqueue of items in library {Name}", folder.Name); - - // Some virtual folders don't have a proper item id. - if (!Guid.TryParse(folder.ItemId, out var folderId)) - { - continue; - } - - try - { - QueueLibraryContents(folderId); - } - catch (Exception ex) - { - _logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex); - } - } - - Plugin.Instance.TotalSeasons = _queuedEpisodes.Count; - Plugin.Instance.QueuedMediaItems.Clear(); - foreach (var kvp in _queuedEpisodes) - { - Plugin.Instance.QueuedMediaItems.TryAdd(kvp.Key, kvp.Value); - } - - return _queuedEpisodes; - } - - /// - /// Loads the list of libraries which have been selected for analysis and the minimum intro duration. - /// Settings which have been modified from the defaults are logged. - /// - private void LoadAnalysisSettings() - { - var config = Plugin.Instance!.Configuration; - - // Store the analysis percent - _analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100; - - _selectAllLibraries = config.SelectAllLibraries; - - if (!_selectAllLibraries) - { - // Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries. - _selectedLibraries = [.. config.SelectedLibraries.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; - - // If any libraries have been selected for analysis, log their names. - _logger.LogInformation("Limiting analysis to the following libraries: {Selected}", _selectedLibraries); - } - else - { - _logger.LogDebug("Not limiting analysis by library name"); - } - - // If analysis settings have been changed from the default, log the modified settings. - if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15) - { - _logger.LogInformation( - "Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s", - config.AnalysisPercent, - config.AnalysisLengthLimit, - config.MinimumIntroDuration); - } - } - - private void QueueLibraryContents(Guid id) - { - _logger.LogDebug("Constructing anonymous internal query"); - - var query = new InternalItemsQuery - { - // Order by series name, season, and then episode number so that status updates are logged in order - ParentId = id, - OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),], - IncludeItemTypes = [BaseItemKind.Episode], - Recursive = true, - IsVirtualItem = false - }; - - var items = _libraryManager.GetItemList(query, false); - - if (items is null) - { - _logger.LogError("Library query result is null"); - return; - } - - // Queue all episodes on the server for fingerprinting. - _logger.LogDebug("Iterating through library items"); - - foreach (var item in items) - { - if (item is not Episode episode) - { - _logger.LogDebug("Item {Name} is not an episode", item.Name); - continue; - } - - QueueEpisode(episode); - } - - _logger.LogDebug("Queued {Count} episodes", items.Count); - } - - private void QueueEpisode(Episode episode) - { - var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null"); - - if (string.IsNullOrEmpty(episode.Path)) - { - _logger.LogWarning( - "Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin", - episode.Name, - episode.SeriesName, - episode.Id); - return; - } - - // Allocate a new list for each new season - var seasonId = GetSeasonId(episode); - if (!_queuedEpisodes.TryGetValue(seasonId, out var seasonEpisodes)) - { - seasonEpisodes = []; - _queuedEpisodes[seasonId] = seasonEpisodes; - } - - if (seasonEpisodes.Any(e => e.EpisodeId == episode.Id)) - { - _logger.LogDebug( - "\"{Name}\" from series \"{Series}\" ({Id}) is already queued", - episode.Name, - episode.SeriesName, - episode.Id); - return; - } - - var isAnime = seasonEpisodes.FirstOrDefault()?.IsAnime ?? - (pluginInstance.GetItem(episode.SeriesId) is Series series && - (series.Tags.Contains("anime", StringComparison.OrdinalIgnoreCase) || - series.Genres.Contains("anime", StringComparison.OrdinalIgnoreCase))); - - // Limit analysis to the first X% of the episode and at most Y minutes. - // X and Y default to 25% and 10 minutes. - var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds; - var fingerprintDuration = Math.Min( - duration >= 5 * 60 ? duration * _analysisPercent : duration, - 60 * pluginInstance.Configuration.AnalysisLengthLimit); - - // Queue the episode for analysis - var maxCreditsDuration = pluginInstance.Configuration.MaximumCreditsDuration; - seasonEpisodes.Add(new QueuedEpisode - { - SeriesName = episode.SeriesName, - SeasonNumber = episode.AiredSeasonNumber ?? 0, - SeriesId = episode.SeriesId, - EpisodeId = episode.Id, - Name = episode.Name, - IsAnime = isAnime, - Path = episode.Path, - Duration = Convert.ToInt32(duration), - IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration), - CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration), - }); - - pluginInstance.TotalQueued++; - } - - private Guid GetSeasonId(Episode episode) - { - if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special - { - foreach (var kvp in _queuedEpisodes) - { - var first = kvp.Value.FirstOrDefault(); - if (first?.SeriesId == episode.SeriesId && - first.SeasonNumber == episode.AiredSeasonNumber) + // If libraries have been selected for analysis, ensure this library was selected. + if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name)) { - return kvp.Key; + _logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name); + continue; } - } - } - return episode.SeasonId; - } + _logger.LogInformation("Running enqueue of items in library {Name}", folder.Name); - /// - /// Verify that a collection of queued media items still exist in Jellyfin and in storage. - /// This is done to ensure that we don't analyze items that were deleted between the call to GetMediaItems() and popping them from the queue. - /// - /// Queued media items. - /// Analysis mode. - /// Media items that have been verified to exist in Jellyfin and in storage. - public (IReadOnlyList VerifiedItems, IReadOnlyCollection RequiredModes) - VerifyQueue(IReadOnlyList candidates, IReadOnlyCollection modes) - { - var verified = new List(); - var reqModes = new HashSet(); - - foreach (var candidate in candidates) - { - try - { - var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId); - - if (!File.Exists(path)) + // Some virtual folders don't have a proper item id. + if (!Guid.TryParse(folder.ItemId, out var folderId)) { continue; } - verified.Add(candidate); - - foreach (var mode in modes) + try { - if (candidate.State.IsAnalyzed(mode) || candidate.State.IsBlacklisted(mode)) + QueueLibraryContents(folderId); + } + catch (Exception ex) + { + _logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex); + } + } + + Plugin.Instance.TotalSeasons = _queuedEpisodes.Count; + Plugin.Instance.QueuedMediaItems.Clear(); + foreach (var kvp in _queuedEpisodes) + { + Plugin.Instance.QueuedMediaItems.TryAdd(kvp.Key, kvp.Value); + } + + return _queuedEpisodes; + } + + /// + /// Loads the list of libraries which have been selected for analysis and the minimum intro duration. + /// Settings which have been modified from the defaults are logged. + /// + private void LoadAnalysisSettings() + { + var config = Plugin.Instance!.Configuration; + + // Store the analysis percent + _analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100; + + _selectAllLibraries = config.SelectAllLibraries; + + if (!_selectAllLibraries) + { + // Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries. + _selectedLibraries = [.. config.SelectedLibraries.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + + // If any libraries have been selected for analysis, log their names. + _logger.LogInformation("Limiting analysis to the following libraries: {Selected}", _selectedLibraries); + } + else + { + _logger.LogDebug("Not limiting analysis by library name"); + } + + // If analysis settings have been changed from the default, log the modified settings. + if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15) + { + _logger.LogInformation( + "Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s", + config.AnalysisPercent, + config.AnalysisLengthLimit, + config.MinimumIntroDuration); + } + } + + private void QueueLibraryContents(Guid id) + { + _logger.LogDebug("Constructing anonymous internal query"); + + var query = new InternalItemsQuery + { + // Order by series name, season, and then episode number so that status updates are logged in order + ParentId = id, + OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),], + IncludeItemTypes = [BaseItemKind.Episode], + Recursive = true, + IsVirtualItem = false + }; + + var items = _libraryManager.GetItemList(query, false); + + if (items is null) + { + _logger.LogError("Library query result is null"); + return; + } + + // Queue all episodes on the server for fingerprinting. + _logger.LogDebug("Iterating through library items"); + + foreach (var item in items) + { + if (item is not Episode episode) + { + _logger.LogDebug("Item {Name} is not an episode", item.Name); + continue; + } + + QueueEpisode(episode); + } + + _logger.LogDebug("Queued {Count} episodes", items.Count); + } + + private void QueueEpisode(Episode episode) + { + var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null"); + + if (string.IsNullOrEmpty(episode.Path)) + { + _logger.LogWarning( + "Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin", + episode.Name, + episode.SeriesName, + episode.Id); + return; + } + + // Allocate a new list for each new season + var seasonId = GetSeasonId(episode); + if (!_queuedEpisodes.TryGetValue(seasonId, out var seasonEpisodes)) + { + seasonEpisodes = []; + _queuedEpisodes[seasonId] = seasonEpisodes; + } + + if (seasonEpisodes.Any(e => e.EpisodeId == episode.Id)) + { + _logger.LogDebug( + "\"{Name}\" from series \"{Series}\" ({Id}) is already queued", + episode.Name, + episode.SeriesName, + episode.Id); + return; + } + + var isAnime = seasonEpisodes.FirstOrDefault()?.IsAnime ?? + (pluginInstance.GetItem(episode.SeriesId) is Series series && + (series.Tags.Contains("anime", StringComparison.OrdinalIgnoreCase) || + series.Genres.Contains("anime", StringComparison.OrdinalIgnoreCase))); + + // Limit analysis to the first X% of the episode and at most Y minutes. + // X and Y default to 25% and 10 minutes. + var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds; + var fingerprintDuration = Math.Min( + duration >= 5 * 60 ? duration * _analysisPercent : duration, + 60 * pluginInstance.Configuration.AnalysisLengthLimit); + + // Queue the episode for analysis + var maxCreditsDuration = pluginInstance.Configuration.MaximumCreditsDuration; + seasonEpisodes.Add(new QueuedEpisode + { + SeriesName = episode.SeriesName, + SeasonNumber = episode.AiredSeasonNumber ?? 0, + SeriesId = episode.SeriesId, + EpisodeId = episode.Id, + Name = episode.Name, + IsAnime = isAnime, + Path = episode.Path, + Duration = Convert.ToInt32(duration), + IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration), + CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration), + }); + + pluginInstance.TotalQueued++; + } + + private Guid GetSeasonId(Episode episode) + { + if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special + { + foreach (var kvp in _queuedEpisodes) + { + var first = kvp.Value.FirstOrDefault(); + if (first?.SeriesId == episode.SeriesId && + first.SeasonNumber == episode.AiredSeasonNumber) + { + return kvp.Key; + } + } + } + + return episode.SeasonId; + } + + /// + /// Verify that a collection of queued media items still exist in Jellyfin and in storage. + /// This is done to ensure that we don't analyze items that were deleted between the call to GetMediaItems() and popping them from the queue. + /// + /// Queued media items. + /// Analysis mode. + /// Media items that have been verified to exist in Jellyfin and in storage. + public (IReadOnlyList VerifiedItems, IReadOnlyCollection RequiredModes) + VerifyQueue(IReadOnlyList candidates, IReadOnlyCollection modes) + { + var verified = new List(); + var reqModes = new HashSet(); + + foreach (var candidate in candidates) + { + try + { + var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId); + + if (!File.Exists(path)) { continue; } - bool isAnalyzed = mode == AnalysisMode.Introduction - ? Plugin.Instance!.Intros.ContainsKey(candidate.EpisodeId) - : Plugin.Instance!.Credits.ContainsKey(candidate.EpisodeId); + verified.Add(candidate); - if (isAnalyzed) + foreach (var mode in modes) { - candidate.State.SetAnalyzed(mode, true); - } - else - { - reqModes.Add(mode); + if (candidate.State.IsAnalyzed(mode) || candidate.State.IsBlacklisted(mode)) + { + continue; + } + + bool isAnalyzed = mode == AnalysisMode.Introduction + ? Plugin.Instance!.Intros.ContainsKey(candidate.EpisodeId) + : Plugin.Instance!.Credits.ContainsKey(candidate.EpisodeId); + + if (isAnalyzed) + { + candidate.State.SetAnalyzed(mode, true); + } + else + { + reqModes.Add(mode); + } } } + catch (Exception ex) + { + _logger.LogDebug( + "Skipping analysis of {Name} ({Id}): {Exception}", + candidate.Name, + candidate.EpisodeId, + ex); + } } - catch (Exception ex) - { - _logger.LogDebug( - "Skipping analysis of {Name} ({Id}): {Exception}", - candidate.Name, - candidate.EpisodeId, - ex); - } - } - return (verified, reqModes); + return (verified, reqModes); + } } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs index 7050830..1e32252 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text.RegularExpressions; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using ConfusedPolarBear.Plugin.IntroSkipper.Helper; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs b/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs index e5f6c42..89a856d 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs @@ -1,4 +1,5 @@ using ConfusedPolarBear.Plugin.IntroSkipper.Providers; +using ConfusedPolarBear.Plugin.IntroSkipper.Services; using MediaBrowser.Controller; using MediaBrowser.Controller.Plugins; using Microsoft.Extensions.DependencyInjection; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs index 7f8c49f..f2e05e5 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using ConfusedPolarBear.Plugin.IntroSkipper.Manager; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs index f644db1..6f096de 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using ConfusedPolarBear.Plugin.IntroSkipper.Manager; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs index 7706da4..394e86c 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using ConfusedPolarBear.Plugin.IntroSkipper.Services; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs index 72e8fee..aeca1a0 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosCreditsTask.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using ConfusedPolarBear.Plugin.IntroSkipper.Services; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs index d7b17b1..894c3b0 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntrosTask.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using ConfusedPolarBear.Plugin.IntroSkipper.Services; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkip.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkip.cs index f019056..e733d31 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkip.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkip.cs @@ -15,216 +15,217 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Timer = System.Timers.Timer; -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Automatically skip past introduction sequences. -/// Commands clients to seek to the end of the intro as soon as they start playing it. -/// -/// -/// Initializes a new instance of the class. -/// -/// User data manager. -/// Session manager. -/// Logger. -public class AutoSkip( - IUserDataManager userDataManager, - ISessionManager sessionManager, - ILogger logger) : IHostedService, IDisposable +namespace ConfusedPolarBear.Plugin.IntroSkipper.Services { - private readonly object _sentSeekCommandLock = new(); - - private ILogger _logger = logger; - private IUserDataManager _userDataManager = userDataManager; - private ISessionManager _sessionManager = sessionManager; - private Timer _playbackTimer = new(1000); - private Dictionary _sentSeekCommand = []; - private HashSet _clientList = []; - - private void AutoSkipChanged(object? sender, BasePluginConfiguration e) + /// + /// Automatically skip past introduction sequences. + /// Commands clients to seek to the end of the intro as soon as they start playing it. + /// + /// + /// Initializes a new instance of the class. + /// + /// User data manager. + /// Session manager. + /// Logger. + public class AutoSkip( + IUserDataManager userDataManager, + ISessionManager sessionManager, + ILogger logger) : IHostedService, IDisposable { - var configuration = (PluginConfiguration)e; - _clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; - var newState = configuration.AutoSkip || _clientList.Count > 0; - _logger.LogDebug("Setting playback timer enabled to {NewState}", newState); - _playbackTimer.Enabled = newState; - } + private readonly object _sentSeekCommandLock = new(); - private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) - { - var itemId = e.Item.Id; - var newState = false; - var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); + private ILogger _logger = logger; + private IUserDataManager _userDataManager = userDataManager; + private ISessionManager _sessionManager = sessionManager; + private Timer _playbackTimer = new(1000); + private Dictionary _sentSeekCommand = []; + private HashSet _clientList = []; - // Ignore all events except playback start & end - if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) + private void AutoSkipChanged(object? sender, BasePluginConfiguration e) { - return; + var configuration = (PluginConfiguration)e; + _clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + var newState = configuration.AutoSkip || _clientList.Count > 0; + _logger.LogDebug("Setting playback timer enabled to {NewState}", newState); + _playbackTimer.Enabled = newState; } - // Lookup the session for this item. - SessionInfo? session = null; - - try + private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) { - foreach (var needle in _sessionManager.Sessions) - { - if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) - { - session = needle; - break; - } - } + var itemId = e.Item.Id; + var newState = false; + var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); - if (session == null) + // Ignore all events except playback start & end + if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) { - _logger.LogInformation("Unable to find session for {Item}", itemId); return; } - } - catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) - { - return; - } - // If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session. - if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) - { - newState = true; - } + // Lookup the session for this item. + SessionInfo? session = null; - // Reset the seek command state for this device. - lock (_sentSeekCommandLock) - { - var device = session.DeviceId; - - _logger.LogDebug("Resetting seek command state for session {Session}", device); - _sentSeekCommand[device] = newState; - } - } - - private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) - { - foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkip || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase))) - { - var deviceId = session.DeviceId; - var itemId = session.NowPlayingItem.Id; - var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond; - - // Don't send the seek command more than once in the same session. - lock (_sentSeekCommandLock) + try { - if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) + foreach (var needle in _sessionManager.Sessions) { - _logger.LogTrace("Already sent seek command for session {Session}", deviceId); - continue; + if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) + { + session = needle; + break; + } + } + + if (session == null) + { + _logger.LogInformation("Unable to find session for {Item}", itemId); + return; } } - - // Assert that an intro was detected for this item. - if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid) + catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) { - continue; + return; } - // Seek is unreliable if called at the very start of an episode. - var adjustedStart = Math.Max(1, intro.Start + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay); - var adjustedEnd = intro.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro; - - _logger.LogTrace( - "Playback position is {Position}, intro runs from {Start} to {End}", - position, - adjustedStart, - adjustedEnd); - - if (position < adjustedStart || position > adjustedEnd) + // If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session. + if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) { - continue; + newState = true; } - // Notify the user that an introduction is being skipped for them. - var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText; - if (!string.IsNullOrWhiteSpace(notificationText)) - { - _sessionManager.SendMessageCommand( - session.Id, - session.Id, - new MessageCommand - { - Header = string.Empty, // some clients require header to be a string instead of null - Text = notificationText, - TimeoutMs = 2000, - }, - CancellationToken.None); - } - - _logger.LogDebug("Sending seek command to {Session}", deviceId); - - _sessionManager.SendPlaystateCommand( - session.Id, - session.Id, - new PlaystateRequest - { - Command = PlaystateCommand.Seek, - ControllingUserId = session.UserId.ToString(), - SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond, - }, - CancellationToken.None); - - // Flag that we've sent the seek command so that it's not sent repeatedly + // Reset the seek command state for this device. lock (_sentSeekCommandLock) { - _logger.LogTrace("Setting seek command state for session {Session}", deviceId); - _sentSeekCommand[deviceId] = true; + var device = session.DeviceId; + + _logger.LogDebug("Resetting seek command state for session {Session}", device); + _sentSeekCommand[device] = newState; } } - } - /// - /// Dispose. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Protected dispose. - /// - /// Dispose. - protected virtual void Dispose(bool disposing) - { - if (!disposing) + private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) { - return; + foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkip || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase))) + { + var deviceId = session.DeviceId; + var itemId = session.NowPlayingItem.Id; + var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond; + + // Don't send the seek command more than once in the same session. + lock (_sentSeekCommandLock) + { + if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) + { + _logger.LogTrace("Already sent seek command for session {Session}", deviceId); + continue; + } + } + + // Assert that an intro was detected for this item. + if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid) + { + continue; + } + + // Seek is unreliable if called at the very start of an episode. + var adjustedStart = Math.Max(1, intro.Start + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay); + var adjustedEnd = intro.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro; + + _logger.LogTrace( + "Playback position is {Position}, intro runs from {Start} to {End}", + position, + adjustedStart, + adjustedEnd); + + if (position < adjustedStart || position > adjustedEnd) + { + continue; + } + + // Notify the user that an introduction is being skipped for them. + var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText; + if (!string.IsNullOrWhiteSpace(notificationText)) + { + _sessionManager.SendMessageCommand( + session.Id, + session.Id, + new MessageCommand + { + Header = string.Empty, // some clients require header to be a string instead of null + Text = notificationText, + TimeoutMs = 2000, + }, + CancellationToken.None); + } + + _logger.LogDebug("Sending seek command to {Session}", deviceId); + + _sessionManager.SendPlaystateCommand( + session.Id, + session.Id, + new PlaystateRequest + { + Command = PlaystateCommand.Seek, + ControllingUserId = session.UserId.ToString(), + SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond, + }, + CancellationToken.None); + + // Flag that we've sent the seek command so that it's not sent repeatedly + lock (_sentSeekCommandLock) + { + _logger.LogTrace("Setting seek command state for session {Session}", deviceId); + _sentSeekCommand[deviceId] = true; + } + } } - _playbackTimer.Stop(); - _playbackTimer.Dispose(); - } + /// + /// Dispose. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogDebug("Setting up automatic skipping"); + /// + /// Protected dispose. + /// + /// Dispose. + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } - _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; - Plugin.Instance!.ConfigurationChanged += AutoSkipChanged; + _playbackTimer.Stop(); + _playbackTimer.Dispose(); + } - // Make the timer restart automatically and set enabled to match the configuration value. - _playbackTimer.AutoReset = true; - _playbackTimer.Elapsed += PlaybackTimer_Elapsed; + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("Setting up automatic skipping"); - AutoSkipChanged(null, Plugin.Instance.Configuration); + _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; + Plugin.Instance!.ConfigurationChanged += AutoSkipChanged; - return Task.CompletedTask; - } + // Make the timer restart automatically and set enabled to match the configuration value. + _playbackTimer.AutoReset = true; + _playbackTimer.Elapsed += PlaybackTimer_Elapsed; - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; - return Task.CompletedTask; + AutoSkipChanged(null, Plugin.Instance.Configuration); + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; + return Task.CompletedTask; + } } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkipCredits.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkipCredits.cs index 6898417..b0e6068 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkipCredits.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Services/AutoSkipCredits.cs @@ -15,216 +15,217 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Timer = System.Timers.Timer; -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Automatically skip past credit sequences. -/// Commands clients to seek to the end of the credits as soon as they start playing it. -/// -/// -/// Initializes a new instance of the class. -/// -/// User data manager. -/// Session manager. -/// Logger. -public class AutoSkipCredits( - IUserDataManager userDataManager, - ISessionManager sessionManager, - ILogger logger) : IHostedService, IDisposable +namespace ConfusedPolarBear.Plugin.IntroSkipper.Services { - private readonly object _sentSeekCommandLock = new(); - - private ILogger _logger = logger; - private IUserDataManager _userDataManager = userDataManager; - private ISessionManager _sessionManager = sessionManager; - private Timer _playbackTimer = new(1000); - private Dictionary _sentSeekCommand = []; - private HashSet _clientList = []; - - private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e) + /// + /// Automatically skip past credit sequences. + /// Commands clients to seek to the end of the credits as soon as they start playing it. + /// + /// + /// Initializes a new instance of the class. + /// + /// User data manager. + /// Session manager. + /// Logger. + public class AutoSkipCredits( + IUserDataManager userDataManager, + ISessionManager sessionManager, + ILogger logger) : IHostedService, IDisposable { - var configuration = (PluginConfiguration)e; - _clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; - var newState = configuration.AutoSkipCredits || _clientList.Count > 0; - _logger.LogDebug("Setting playback timer enabled to {NewState}", newState); - _playbackTimer.Enabled = newState; - } + private readonly object _sentSeekCommandLock = new(); - private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) - { - var itemId = e.Item.Id; - var newState = false; - var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); + private ILogger _logger = logger; + private IUserDataManager _userDataManager = userDataManager; + private ISessionManager _sessionManager = sessionManager; + private Timer _playbackTimer = new(1000); + private Dictionary _sentSeekCommand = []; + private HashSet _clientList = []; - // Ignore all events except playback start & end - if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) + private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e) { - return; + var configuration = (PluginConfiguration)e; + _clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + var newState = configuration.AutoSkipCredits || _clientList.Count > 0; + _logger.LogDebug("Setting playback timer enabled to {NewState}", newState); + _playbackTimer.Enabled = newState; } - // Lookup the session for this item. - SessionInfo? session = null; - - try + private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) { - foreach (var needle in _sessionManager.Sessions) - { - if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) - { - session = needle; - break; - } - } + var itemId = e.Item.Id; + var newState = false; + var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); - if (session == null) + // Ignore all events except playback start & end + if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) { - _logger.LogInformation("Unable to find session for {Item}", itemId); return; } - } - catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) - { - return; - } - // If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session. - if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) - { - newState = true; - } + // Lookup the session for this item. + SessionInfo? session = null; - // Reset the seek command state for this device. - lock (_sentSeekCommandLock) - { - var device = session.DeviceId; - - _logger.LogDebug("Resetting seek command state for session {Session}", device); - _sentSeekCommand[device] = newState; - } - } - - private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) - { - foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkipCredits || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase))) - { - var deviceId = session.DeviceId; - var itemId = session.NowPlayingItem.Id; - var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond; - - // Don't send the seek command more than once in the same session. - lock (_sentSeekCommandLock) + try { - if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) + foreach (var needle in _sessionManager.Sessions) { - _logger.LogTrace("Already sent seek command for session {Session}", deviceId); - continue; + if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) + { + session = needle; + break; + } + } + + if (session == null) + { + _logger.LogInformation("Unable to find session for {Item}", itemId); + return; } } - - // Assert that credits were detected for this item. - if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var credit) || !credit.Valid) + catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) { - continue; + return; } - // Seek is unreliable if called at the very end of an episode. - var adjustedStart = credit.Start + Plugin.Instance.Configuration.SecondsOfCreditsStartToPlay; - var adjustedEnd = credit.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro; - - _logger.LogTrace( - "Playback position is {Position}, credits run from {Start} to {End}", - position, - adjustedStart, - adjustedEnd); - - if (position < adjustedStart || position > adjustedEnd) + // If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session. + if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) { - continue; + newState = true; } - // Notify the user that credits are being skipped for them. - var notificationText = Plugin.Instance!.Configuration.AutoSkipCreditsNotificationText; - if (!string.IsNullOrWhiteSpace(notificationText)) - { - _sessionManager.SendMessageCommand( - session.Id, - session.Id, - new MessageCommand - { - Header = string.Empty, // some clients require header to be a string instead of null - Text = notificationText, - TimeoutMs = 2000, - }, - CancellationToken.None); - } - - _logger.LogDebug("Sending seek command to {Session}", deviceId); - - _sessionManager.SendPlaystateCommand( - session.Id, - session.Id, - new PlaystateRequest - { - Command = PlaystateCommand.Seek, - ControllingUserId = session.UserId.ToString(), - SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond, - }, - CancellationToken.None); - - // Flag that we've sent the seek command so that it's not sent repeatedly + // Reset the seek command state for this device. lock (_sentSeekCommandLock) { - _logger.LogTrace("Setting seek command state for session {Session}", deviceId); - _sentSeekCommand[deviceId] = true; + var device = session.DeviceId; + + _logger.LogDebug("Resetting seek command state for session {Session}", device); + _sentSeekCommand[device] = newState; } } - } - /// - /// Dispose. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Protected dispose. - /// - /// Dispose. - protected virtual void Dispose(bool disposing) - { - if (!disposing) + private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) { - return; + foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkipCredits || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase))) + { + var deviceId = session.DeviceId; + var itemId = session.NowPlayingItem.Id; + var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond; + + // Don't send the seek command more than once in the same session. + lock (_sentSeekCommandLock) + { + if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) + { + _logger.LogTrace("Already sent seek command for session {Session}", deviceId); + continue; + } + } + + // Assert that credits were detected for this item. + if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var credit) || !credit.Valid) + { + continue; + } + + // Seek is unreliable if called at the very end of an episode. + var adjustedStart = credit.Start + Plugin.Instance.Configuration.SecondsOfCreditsStartToPlay; + var adjustedEnd = credit.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro; + + _logger.LogTrace( + "Playback position is {Position}, credits run from {Start} to {End}", + position, + adjustedStart, + adjustedEnd); + + if (position < adjustedStart || position > adjustedEnd) + { + continue; + } + + // Notify the user that credits are being skipped for them. + var notificationText = Plugin.Instance!.Configuration.AutoSkipCreditsNotificationText; + if (!string.IsNullOrWhiteSpace(notificationText)) + { + _sessionManager.SendMessageCommand( + session.Id, + session.Id, + new MessageCommand + { + Header = string.Empty, // some clients require header to be a string instead of null + Text = notificationText, + TimeoutMs = 2000, + }, + CancellationToken.None); + } + + _logger.LogDebug("Sending seek command to {Session}", deviceId); + + _sessionManager.SendPlaystateCommand( + session.Id, + session.Id, + new PlaystateRequest + { + Command = PlaystateCommand.Seek, + ControllingUserId = session.UserId.ToString(), + SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond, + }, + CancellationToken.None); + + // Flag that we've sent the seek command so that it's not sent repeatedly + lock (_sentSeekCommandLock) + { + _logger.LogTrace("Setting seek command state for session {Session}", deviceId); + _sentSeekCommand[deviceId] = true; + } + } } - _playbackTimer.Stop(); - _playbackTimer.Dispose(); - } + /// + /// Dispose. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogDebug("Setting up automatic credit skipping"); + /// + /// Protected dispose. + /// + /// Dispose. + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } - _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; - Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged; + _playbackTimer.Stop(); + _playbackTimer.Dispose(); + } - // Make the timer restart automatically and set enabled to match the configuration value. - _playbackTimer.AutoReset = true; - _playbackTimer.Elapsed += PlaybackTimer_Elapsed; + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("Setting up automatic credit skipping"); - AutoSkipCreditChanged(null, Plugin.Instance.Configuration); + _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; + Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged; - return Task.CompletedTask; - } + // Make the timer restart automatically and set enabled to match the configuration value. + _playbackTimer.AutoReset = true; + _playbackTimer.Elapsed += PlaybackTimer_Elapsed; - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; - return Task.CompletedTask; + AutoSkipCreditChanged(null, Plugin.Instance.Configuration); + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; + return Task.CompletedTask; + } } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs index 5fc196a..d6735c1 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Services/Entrypoint.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Data; +using ConfusedPolarBear.Plugin.IntroSkipper.Manager; using ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -13,314 +14,315 @@ using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Server entrypoint. -/// -public sealed class Entrypoint : IHostedService, IDisposable +namespace ConfusedPolarBear.Plugin.IntroSkipper.Services { - private readonly ITaskManager _taskManager; - private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; - private readonly HashSet _seasonsToAnalyze = []; - private readonly Timer _queueTimer; - private static readonly ManualResetEventSlim _autoTaskCompletEvent = new(false); - private PluginConfiguration _config; - private bool _analyzeAgain; - private static CancellationTokenSource? _cancellationTokenSource; - /// - /// Initializes a new instance of the class. + /// Server entrypoint. /// - /// Library manager. - /// Task manager. - /// Logger. - /// Logger factory. - public Entrypoint( - ILibraryManager libraryManager, - ITaskManager taskManager, - ILogger logger, - ILoggerFactory loggerFactory) + public sealed class Entrypoint : IHostedService, IDisposable { - _libraryManager = libraryManager; - _taskManager = taskManager; - _logger = logger; - _loggerFactory = loggerFactory; + private readonly ITaskManager _taskManager; + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + private readonly HashSet _seasonsToAnalyze = []; + private readonly Timer _queueTimer; + private static readonly ManualResetEventSlim _autoTaskCompletEvent = new(false); + private PluginConfiguration _config; + private bool _analyzeAgain; + private static CancellationTokenSource? _cancellationTokenSource; - _config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); - _queueTimer = new Timer( - OnTimerCallback, - null, - Timeout.InfiniteTimeSpan, - Timeout.InfiniteTimeSpan); - } - - /// - /// Gets State of the automatic task. - /// - public static TaskState AutomaticTaskState - { - get + /// + /// Initializes a new instance of the class. + /// + /// Library manager. + /// Task manager. + /// Logger. + /// Logger factory. + public Entrypoint( + ILibraryManager libraryManager, + ITaskManager taskManager, + ILogger logger, + ILoggerFactory loggerFactory) { - if (_cancellationTokenSource is not null) + _libraryManager = libraryManager; + _taskManager = taskManager; + _logger = logger; + _loggerFactory = loggerFactory; + + _config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); + _queueTimer = new Timer( + OnTimerCallback, + null, + Timeout.InfiniteTimeSpan, + Timeout.InfiniteTimeSpan); + } + + /// + /// Gets State of the automatic task. + /// + public static TaskState AutomaticTaskState + { + get { - return _cancellationTokenSource.IsCancellationRequested - ? TaskState.Cancelling - : TaskState.Running; - } + if (_cancellationTokenSource is not null) + { + return _cancellationTokenSource.IsCancellationRequested + ? TaskState.Cancelling + : TaskState.Running; + } - return TaskState.Idle; - } - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _libraryManager.ItemAdded += OnItemAdded; - _libraryManager.ItemUpdated += OnItemModified; - _taskManager.TaskCompleted += OnLibraryRefresh; - Plugin.Instance!.ConfigurationChanged += OnSettingsChanged; - - FFmpegWrapper.Logger = _logger; - - try - { - // Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible - _logger.LogInformation("Running startup enqueue"); - var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager); - queueManager?.GetMediaItems(); - } - catch (Exception ex) - { - _logger.LogError("Unable to run startup enqueue: {Exception}", ex); - } - - return Task.CompletedTask; - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _libraryManager.ItemAdded -= OnItemAdded; - _libraryManager.ItemUpdated -= OnItemModified; - _taskManager.TaskCompleted -= OnLibraryRefresh; - - // Stop the timer - _queueTimer.Change(Timeout.Infinite, 0); - return Task.CompletedTask; - } - - // Disclose source for inspiration - // Implementation based on the principles of jellyfin-plugin-media-analyzer: - // https://github.com/endrl/jellyfin-plugin-media-analyzer - - /// - /// Library item was added. - /// - /// The sending entity. - /// The . - private void OnItemAdded(object? sender, ItemChangeEventArgs itemChangeEventArgs) - { - // Don't do anything if auto detection is disabled - if (!_config.AutoDetectIntros && !_config.AutoDetectCredits) - { - return; - } - - // Don't do anything if it's not a supported media type - if (itemChangeEventArgs.Item is not Episode episode) - { - return; - } - - if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual) - { - return; - } - - _seasonsToAnalyze.Add(episode.SeasonId); - - StartTimer(); - } - - /// - /// Library item was modified. - /// - /// The sending entity. - /// The . - private void OnItemModified(object? sender, ItemChangeEventArgs itemChangeEventArgs) - { - // Don't do anything if auto detection is disabled - if (!_config.AutoDetectIntros && !_config.AutoDetectCredits) - { - return; - } - - // Don't do anything if it's not a supported media type - if (itemChangeEventArgs.Item is not Episode episode) - { - return; - } - - if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual) - { - return; - } - - _seasonsToAnalyze.Add(episode.SeasonId); - - StartTimer(); - } - - /// - /// TaskManager task ended. - /// - /// The sending entity. - /// The . - private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs) - { - // Don't do anything if auto detection is disabled - if (!_config.AutoDetectIntros && !_config.AutoDetectCredits) - { - return; - } - - var result = eventArgs.Result; - - if (result.Key != "RefreshLibrary") - { - return; - } - - if (result.Status != TaskCompletionStatus.Completed) - { - return; - } - - // Unless user initiated, this is likely an overlap - if (AutomaticTaskState == TaskState.Running) - { - return; - } - - StartTimer(); - } - - private void OnSettingsChanged(object? sender, BasePluginConfiguration e) => _config = (PluginConfiguration)e; - - /// - /// Start timer to debounce analyzing. - /// - private void StartTimer() - { - if (AutomaticTaskState == TaskState.Running) - { - _analyzeAgain = true; - } - else if (AutomaticTaskState == TaskState.Idle) - { - _logger.LogDebug("Media Library changed, analyzis will start soon!"); - _queueTimer.Change(TimeSpan.FromMilliseconds(20000), Timeout.InfiniteTimeSpan); - } - } - - /// - /// Wait for timer callback to be completed. - /// - private void OnTimerCallback(object? state) - { - try - { - PerformAnalysis(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in PerformAnalysis"); - } - - // Clean up - _cancellationTokenSource = null; - _autoTaskCompletEvent.Set(); - } - - /// - /// Wait for timer to be completed. - /// - private void PerformAnalysis() - { - _logger.LogInformation("Initiate automatic analysis task."); - _autoTaskCompletEvent.Reset(); - - using (_cancellationTokenSource = new CancellationTokenSource()) - using (ScheduledTaskSemaphore.Acquire(_cancellationTokenSource.Token)) - { - var seasonIds = new HashSet(_seasonsToAnalyze); - _seasonsToAnalyze.Clear(); - - _analyzeAgain = false; - var progress = new Progress(); - var modes = new List(); - var tasklogger = _loggerFactory.CreateLogger("DefaultLogger"); - - if (_config.AutoDetectIntros) - { - modes.Add(AnalysisMode.Introduction); - tasklogger = _loggerFactory.CreateLogger(); - } - - if (_config.AutoDetectCredits) - { - modes.Add(AnalysisMode.Credits); - tasklogger = modes.Count == 2 - ? _loggerFactory.CreateLogger() - : _loggerFactory.CreateLogger(); - } - - var baseCreditAnalyzer = new BaseItemAnalyzerTask( - modes, - tasklogger, - _loggerFactory, - _libraryManager); - - baseCreditAnalyzer.AnalyzeItems(progress, _cancellationTokenSource.Token, seasonIds); - - // New item detected, start timer again - if (_analyzeAgain && !_cancellationTokenSource.IsCancellationRequested) - { - _logger.LogInformation("Analyzing ended, but we need to analyze again!"); - StartTimer(); + return TaskState.Idle; } } - } - /// - /// Method to cancel the automatic task. - /// - /// Cancellation token. - public static void CancelAutomaticTask(CancellationToken cancellationToken) - { - if (_cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested) + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _libraryManager.ItemAdded += OnItemAdded; + _libraryManager.ItemUpdated += OnItemModified; + _taskManager.TaskCompleted += OnLibraryRefresh; + Plugin.Instance!.ConfigurationChanged += OnSettingsChanged; + + FFmpegWrapper.Logger = _logger; + + try + { + // Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible + _logger.LogInformation("Running startup enqueue"); + var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager); + queueManager?.GetMediaItems(); + } + catch (Exception ex) + { + _logger.LogError("Unable to run startup enqueue: {Exception}", ex); + } + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _libraryManager.ItemAdded -= OnItemAdded; + _libraryManager.ItemUpdated -= OnItemModified; + _taskManager.TaskCompleted -= OnLibraryRefresh; + + // Stop the timer + _queueTimer.Change(Timeout.Infinite, 0); + return Task.CompletedTask; + } + + // Disclose source for inspiration + // Implementation based on the principles of jellyfin-plugin-media-analyzer: + // https://github.com/endrl/jellyfin-plugin-media-analyzer + + /// + /// Library item was added. + /// + /// The sending entity. + /// The . + private void OnItemAdded(object? sender, ItemChangeEventArgs itemChangeEventArgs) + { + // Don't do anything if auto detection is disabled + if (!_config.AutoDetectIntros && !_config.AutoDetectCredits) + { + return; + } + + // Don't do anything if it's not a supported media type + if (itemChangeEventArgs.Item is not Episode episode) + { + return; + } + + if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual) + { + return; + } + + _seasonsToAnalyze.Add(episode.SeasonId); + + StartTimer(); + } + + /// + /// Library item was modified. + /// + /// The sending entity. + /// The . + private void OnItemModified(object? sender, ItemChangeEventArgs itemChangeEventArgs) + { + // Don't do anything if auto detection is disabled + if (!_config.AutoDetectIntros && !_config.AutoDetectCredits) + { + return; + } + + // Don't do anything if it's not a supported media type + if (itemChangeEventArgs.Item is not Episode episode) + { + return; + } + + if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual) + { + return; + } + + _seasonsToAnalyze.Add(episode.SeasonId); + + StartTimer(); + } + + /// + /// TaskManager task ended. + /// + /// The sending entity. + /// The . + private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs) + { + // Don't do anything if auto detection is disabled + if (!_config.AutoDetectIntros && !_config.AutoDetectCredits) + { + return; + } + + var result = eventArgs.Result; + + if (result.Key != "RefreshLibrary") + { + return; + } + + if (result.Status != TaskCompletionStatus.Completed) + { + return; + } + + // Unless user initiated, this is likely an overlap + if (AutomaticTaskState == TaskState.Running) + { + return; + } + + StartTimer(); + } + + private void OnSettingsChanged(object? sender, BasePluginConfiguration e) => _config = (PluginConfiguration)e; + + /// + /// Start timer to debounce analyzing. + /// + private void StartTimer() + { + if (AutomaticTaskState == TaskState.Running) + { + _analyzeAgain = true; + } + else if (AutomaticTaskState == TaskState.Idle) + { + _logger.LogDebug("Media Library changed, analyzis will start soon!"); + _queueTimer.Change(TimeSpan.FromMilliseconds(20000), Timeout.InfiniteTimeSpan); + } + } + + /// + /// Wait for timer callback to be completed. + /// + private void OnTimerCallback(object? state) { try { - _cancellationTokenSource.Cancel(); + PerformAnalysis(); } - catch (ObjectDisposedException) + catch (Exception ex) { - _cancellationTokenSource = null; + _logger.LogError(ex, "Error in PerformAnalysis"); + } + + // Clean up + _cancellationTokenSource = null; + _autoTaskCompletEvent.Set(); + } + + /// + /// Wait for timer to be completed. + /// + private void PerformAnalysis() + { + _logger.LogInformation("Initiate automatic analysis task."); + _autoTaskCompletEvent.Reset(); + + using (_cancellationTokenSource = new CancellationTokenSource()) + using (ScheduledTaskSemaphore.Acquire(_cancellationTokenSource.Token)) + { + var seasonIds = new HashSet(_seasonsToAnalyze); + _seasonsToAnalyze.Clear(); + + _analyzeAgain = false; + var progress = new Progress(); + var modes = new List(); + var tasklogger = _loggerFactory.CreateLogger("DefaultLogger"); + + if (_config.AutoDetectIntros) + { + modes.Add(AnalysisMode.Introduction); + tasklogger = _loggerFactory.CreateLogger(); + } + + if (_config.AutoDetectCredits) + { + modes.Add(AnalysisMode.Credits); + tasklogger = modes.Count == 2 + ? _loggerFactory.CreateLogger() + : _loggerFactory.CreateLogger(); + } + + var baseCreditAnalyzer = new BaseItemAnalyzerTask( + modes, + tasklogger, + _loggerFactory, + _libraryManager); + + baseCreditAnalyzer.AnalyzeItems(progress, _cancellationTokenSource.Token, seasonIds); + + // New item detected, start timer again + if (_analyzeAgain && !_cancellationTokenSource.IsCancellationRequested) + { + _logger.LogInformation("Analyzing ended, but we need to analyze again!"); + StartTimer(); + } } } - _autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal - } + /// + /// Method to cancel the automatic task. + /// + /// Cancellation token. + public static void CancelAutomaticTask(CancellationToken cancellationToken) + { + if (_cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested) + { + try + { + _cancellationTokenSource.Cancel(); + } + catch (ObjectDisposedException) + { + _cancellationTokenSource = null; + } + } - /// - public void Dispose() - { - _queueTimer.Dispose(); - _cancellationTokenSource?.Dispose(); - _autoTaskCompletEvent.Dispose(); + _autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal + } + + /// + public void Dispose() + { + _queueTimer.Dispose(); + _cancellationTokenSource?.Dispose(); + _autoTaskCompletEvent.Dispose(); + } } }