// Copyright (C) 2024 Intro-Skipper contributors // SPDX-License-Identifier: GPL-3.0-only. using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using IntroSkipper.Analyzers; using IntroSkipper.Configuration; using IntroSkipper.Data; using IntroSkipper.Db; using IntroSkipper.Manager; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; namespace IntroSkipper.ScheduledTasks; /// /// Common code shared by all media item analyzer tasks. /// /// /// Initializes a new instance of the class. /// /// Task logger. /// Logger factory. /// Library manager. /// MediaSegmentUpdateManager. public class BaseItemAnalyzerTask( ILogger logger, ILoggerFactory loggerFactory, ILibraryManager libraryManager, MediaSegmentUpdateManager mediaSegmentUpdateManager) { private readonly ILogger _logger = logger; private readonly ILoggerFactory _loggerFactory = loggerFactory; private readonly ILibraryManager _libraryManager = libraryManager; private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager; private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); /// /// Analyze all media items on the server. /// /// Progress reporter. /// Cancellation token. /// Season IDs to analyze. /// A task representing the asynchronous operation. public async Task AnalyzeItemsAsync( IProgress progress, CancellationToken cancellationToken, IReadOnlyCollection? seasonsToAnalyze = null) { // Assert that ffmpeg with chromaprint is installed if (_config.WithChromaprint && !FFmpegWrapper.CheckFFmpegVersion()) { throw new FingerprintException( "Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg7. If Jellyfin is running in a container, upgrade to version 10.10.0 or newer."); } HashSet modes = [ .. _config.ScanIntroduction ? [AnalysisMode.Introduction] : Array.Empty(), .. _config.ScanCredits ? [AnalysisMode.Credits] : Array.Empty(), .. _config.ScanRecap ? [AnalysisMode.Recap] : Array.Empty(), .. _config.ScanPreview ? [AnalysisMode.Preview] : Array.Empty() ]; var queueManager = new QueueManager( _loggerFactory.CreateLogger(), _libraryManager); var queue = queueManager.GetMediaItems(); if (seasonsToAnalyze?.Count > 0) { 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; if (totalQueued == 0) { throw new FingerprintException( "No libraries selected for analysis. Please visit the plugin settings to configure."); } int totalProcessed = 0; var options = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, _config.MaxParallelism), CancellationToken = cancellationToken }; await Parallel.ForEachAsync(queue, options, async (season, ct) => { var updateMediaSegments = false; var (episodes, requiredModes) = queueManager.VerifyQueue(season.Value, modes); if (episodes.Count == 0) { return; } try { var firstEpisode = episodes[0]; if (modes.Count != requiredModes.Count) { Interlocked.Add(ref totalProcessed, episodes.Count * (modes.Count - requiredModes.Count)); progress.Report((double)totalProcessed / totalQueued * 100); } foreach (var mode in requiredModes) { ct.ThrowIfCancellationRequested(); int analyzed = await AnalyzeItemsAsync( episodes, mode, ct).ConfigureAwait(false); Interlocked.Add(ref totalProcessed, analyzed); updateMediaSegments = analyzed > 0 || updateMediaSegments; progress.Report((double)totalProcessed / totalQueued * 100); } } catch (OperationCanceledException) { _logger.LogInformation("Analysis was canceled."); } catch (FingerprintException ex) { _logger.LogWarning(ex, "Fingerprint exception during analysis."); } catch (Exception ex) { _logger.LogError(ex, "An unexpected error occurred during analysis."); throw; } if (_config.RebuildMediaSegments || (updateMediaSegments && _config.UpdateMediaSegments)) { await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, ct).ConfigureAwait(false); } }).ConfigureAwait(false); Plugin.Instance!.AnalyzeAgain = false; if (_config.RebuildMediaSegments) { _logger.LogInformation("Regenerated media segments."); _config.RebuildMediaSegments = false; Plugin.Instance!.SaveConfiguration(); } } /// /// Analyze a group of media items for skippable segments. /// /// Media items to analyze. /// Analysis mode. /// Cancellation token. /// Number of items successfully analyzed. private async Task AnalyzeItemsAsync( IReadOnlyList items, AnalysisMode mode, CancellationToken cancellationToken) { var first = items[0]; if (!first.IsMovie && first.SeasonNumber == 0 && !_config.AnalyzeSeasonZero) { return 0; } // Reset the IsAnalyzed flag for all items foreach (var item in items) { item.IsAnalyzed = false; } // Get the analyzer action for the current mode var action = Plugin.Instance!.GetAnalyzerAction(first.SeasonId, mode); _logger.LogInformation( "[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}", mode, items.Count, first.SeriesName, first.SeasonNumber); // Create a list of analyzers to use for the current mode var analyzers = new List(); if (action is AnalyzerAction.Chapter or AnalyzerAction.Default) { analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger())); } if (first.IsAnime && _config.WithChromaprint && mode is not (AnalysisMode.Recap or AnalysisMode.Preview) && action is AnalyzerAction.Default or AnalyzerAction.Chromaprint) { analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger())); } if (mode is AnalysisMode.Credits && action is AnalyzerAction.Default or AnalyzerAction.BlackFrame) { analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger())); } if (!first.IsAnime && !first.IsMovie && mode is not (AnalysisMode.Recap or AnalysisMode.Preview) && action is AnalyzerAction.Default or AnalyzerAction.Chromaprint) { analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger())); } // Use each analyzer to find skippable ranges in all media files, removing successfully // analyzed items from the queue. foreach (var analyzer in analyzers) { cancellationToken.ThrowIfCancellationRequested(); items = await analyzer.AnalyzeMediaFiles(items, mode, cancellationToken).ConfigureAwait(false); } // Set the episode IDs for the analyzed items await Plugin.Instance!.SetEpisodeIdsAsync(first.SeasonId, mode, items.Select(i => i.EpisodeId)).ConfigureAwait(false); return items.Where(i => i.IsAnalyzed).Count(); } }