using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; using IntroSkipper.Analyzers; using IntroSkipper.Data; using IntroSkipper.Manager; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; namespace IntroSkipper.ScheduledTasks; /// /// Common code shared by all media item analyzer tasks. /// public class BaseItemAnalyzerTask { private readonly IReadOnlyCollection _analysisModes; private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; private readonly ILibraryManager _libraryManager; private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager; /// /// Initializes a new instance of the class. /// /// Analysis mode. /// Task logger. /// Logger factory. /// Library manager. /// MediaSegmentUpdateManager. public BaseItemAnalyzerTask( IReadOnlyCollection modes, ILogger logger, ILoggerFactory loggerFactory, ILibraryManager libraryManager, MediaSegmentUpdateManager mediaSegmentUpdateManager) { _analysisModes = modes; _logger = logger; _loggerFactory = loggerFactory; _libraryManager = libraryManager; _mediaSegmentUpdateManager = mediaSegmentUpdateManager; if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None) { EdlManager.Initialize(_logger); } } /// /// Analyze all media items on the server. /// /// Progress. /// Cancellation token. /// Season Ids to analyze. /// A representing the asynchronous operation. public async Task AnalyzeItems( IProgress progress, CancellationToken cancellationToken, IReadOnlyCollection? seasonsToAnalyze = null) { var ffmpegValid = FFmpegWrapper.CheckFFmpegVersion(); // Assert that ffmpeg with chromaprint is installed if (Plugin.Instance!.Configuration.WithChromaprint && !ffmpegValid) { throw new FingerprintException( "Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg5. If Jellyfin is running in a container, upgrade to version 10.8.0 or newer."); } var queueManager = new QueueManager( _loggerFactory.CreateLogger(), _libraryManager); var queue = queueManager.GetMediaItems(); // Filter the queue based on seasonsToAnalyze if (seasonsToAnalyze is { 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) * _analysisModes.Count; if (totalQueued == 0) { throw new FingerprintException( "No libraries selected for analysis. Please visit the plugin settings to configure."); } if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None) { EdlManager.LogConfiguration(); } var totalProcessed = 0; var options = new ParallelOptions { MaxDegreeOfParallelism = Plugin.Instance.Configuration.MaxParallelism, CancellationToken = cancellationToken }; await Parallel.ForEachAsync(queue, options, async (season, ct) => { var updateManagers = false; // Since the first run of the task can run for multiple hours, ensure that none // of the current media items were deleted from Jellyfin since the task was started. var (episodes, requiredModes) = queueManager.VerifyQueue( season.Value, _analysisModes.Where(m => !Plugin.Instance!.IsIgnored(season.Key, m)).ToList()); if (episodes.Count == 0) { return; } var first = episodes[0]; if (requiredModes.Count == 0) { _logger.LogDebug( "All episodes in {Name} season {Season} have already been analyzed", first.SeriesName, first.SeasonNumber); Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly progress.Report(totalProcessed * 100 / totalQueued); } else if (_analysisModes.Count != requiredModes.Count) { Interlocked.Add(ref totalProcessed, episodes.Count); progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed } try { ct.ThrowIfCancellationRequested(); foreach (AnalysisMode mode in requiredModes) { var analyzed = AnalyzeItems(episodes, mode, ct); Interlocked.Add(ref totalProcessed, analyzed); updateManagers = analyzed > 0 || updateManagers; progress.Report(totalProcessed * 100 / totalQueued); } } catch (OperationCanceledException ex) { _logger.LogDebug(ex, "Analysis cancelled"); } catch (FingerprintException ex) { _logger.LogWarning( "Unable to analyze {Series} season {Season}: unable to fingerprint: {Ex}", first.SeriesName, first.SeasonNumber, ex); } catch (Exception ex) { _logger.LogError(ex, "An unexpected error occurred during analysis"); throw; } if (Plugin.Instance.Configuration.RegenerateMediaSegments || (updateManagers && Plugin.Instance.Configuration.UpdateMediaSegments)) { await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, ct).ConfigureAwait(false); } if (Plugin.Instance.Configuration.RegenerateEdlFiles || (updateManagers && Plugin.Instance.Configuration.EdlAction != EdlAction.None)) { EdlManager.UpdateEDLFiles(episodes); } }).ConfigureAwait(false); if (Plugin.Instance.Configuration.RegenerateMediaSegments || Plugin.Instance.Configuration.RegenerateEdlFiles) { _logger.LogInformation("Turning Mediasegment/EDL file regeneration flag off"); Plugin.Instance.Configuration.RegenerateMediaSegments = false; Plugin.Instance.Configuration.RegenerateEdlFiles = false; Plugin.Instance.SaveConfiguration(); } } /// /// Analyze a group of media items for skippable segments. /// /// Media items to analyze. /// Analysis mode. /// Cancellation token. /// Number of items that were successfully analyzed. private int AnalyzeItems( IReadOnlyList items, AnalysisMode mode, CancellationToken cancellationToken) { var totalItems = items.Count(e => !e.State.IsAnalyzed(mode)); // Only analyze specials (season 0) if the user has opted in. var first = items[0]; if (!first.IsMovie && first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero) { return 0; } // Remove from Blacklist foreach (var item in items.Where(e => e.State.IsBlacklisted(mode))) { item.State.SetBlacklisted(mode, false); } _logger.LogInformation( "[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}", mode, items.Count, first.SeriesName, first.SeasonNumber); var analyzers = new Collection { new ChapterAnalyzer(_loggerFactory.CreateLogger()) }; if (first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint && !first.IsMovie) { analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger())); } if (mode == AnalysisMode.Credits) { analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger())); } if (!first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint && !first.IsMovie) { 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) { items = analyzer.AnalyzeMediaFiles(items, mode, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); } // Add items without intros/credits to blacklist. foreach (var item in items.Where(e => !e.State.IsAnalyzed(mode))) { item.State.SetBlacklisted(mode, true); totalItems -= 1; } return totalItems; } }