2024-10-25 14:31:50 -04:00
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
2024-10-25 14:15:12 -04:00
2022-06-22 22:03:34 -05:00
using System ;
using System.Collections.Generic ;
2022-12-05 22:35:01 -06:00
using System.IO ;
2022-06-27 00:21:30 -05:00
using System.Linq ;
2024-10-19 23:50:41 +02:00
using IntroSkipper.Data ;
2022-06-22 22:03:34 -05:00
using Jellyfin.Data.Enums ;
2024-10-02 14:10:42 +02:00
using Jellyfin.Extensions ;
2022-06-22 22:03:34 -05:00
using MediaBrowser.Controller.Entities ;
2024-10-18 14:15:09 +02:00
using MediaBrowser.Controller.Entities.Movies ;
2022-06-22 22:03:34 -05:00
using MediaBrowser.Controller.Entities.TV ;
using MediaBrowser.Controller.Library ;
using Microsoft.Extensions.Logging ;
2024-10-19 23:50:41 +02:00
namespace IntroSkipper.Manager
2022-06-22 22:03:34 -05:00
{
/// <summary>
2024-10-16 16:20:21 +02:00
/// Manages enqueuing library items for analysis.
2022-06-22 22:03:34 -05:00
/// </summary>
2024-10-16 16:20:21 +02:00
/// <remarks>
/// Initializes a new instance of the <see cref="QueueManager"/> class.
/// </remarks>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
public class QueueManager ( ILogger < QueueManager > logger , ILibraryManager libraryManager )
2022-06-22 22:03:34 -05:00
{
2024-10-16 16:20:21 +02:00
private readonly ILibraryManager _libraryManager = libraryManager ;
private readonly ILogger < QueueManager > _logger = logger ;
private readonly Dictionary < Guid , List < QueuedEpisode > > _queuedEpisodes = [ ] ;
private double _analysisPercent ;
2024-10-18 14:15:09 +02:00
private bool _analyzeMovies ;
2024-10-16 16:20:21 +02:00
/// <summary>
/// Gets all media items on the server.
/// </summary>
/// <returns>Queued media items.</returns>
public IReadOnlyDictionary < Guid , List < QueuedEpisode > > GetMediaItems ( )
{
Plugin . Instance ! . TotalQueued = 0 ;
2022-06-22 22:03:34 -05:00
2024-10-16 16:20:21 +02:00
LoadAnalysisSettings ( ) ;
2022-06-27 00:21:30 -05:00
2024-10-16 16:20:21 +02:00
// For all selected libraries, enqueue all contained episodes.
foreach ( var folder in _libraryManager . GetVirtualFolders ( ) )
2022-06-27 00:21:30 -05:00
{
2024-10-16 16:20:21 +02:00
// If libraries have been selected for analysis, ensure this library was selected.
2024-11-24 17:53:07 +01:00
if ( folder . LibraryOptions . DisabledMediaSegmentProviders . Contains ( Plugin . Instance . Name ) )
2024-10-16 16:20:21 +02:00
{
2024-11-24 17:53:07 +01:00
_logger . LogDebug ( "Not analyzing library \"{Name}\": Intro Skipper is disabled in library settings. To enable, check library configuration > Media Segment Providers" , folder . Name ) ;
2024-10-16 16:20:21 +02:00
continue ;
}
2022-06-22 22:03:34 -05:00
2024-10-16 16:20:21 +02:00
_logger . LogInformation ( "Running enqueue of items in library {Name}" , folder . Name ) ;
2024-05-01 13:45:57 +02:00
2024-10-16 16:20:21 +02:00
// Some virtual folders don't have a proper item id.
if ( ! Guid . TryParse ( folder . ItemId , out var folderId ) )
{
continue ;
}
2024-10-16 14:47:20 +02:00
2024-10-16 16:20:21 +02:00
try
{
QueueLibraryContents ( folderId ) ;
}
catch ( Exception ex )
{
_logger . LogError ( "Failed to enqueue items from library {Name}: {Exception}" , folder . Name , ex ) ;
}
2024-10-16 16:05:59 +02:00
}
2024-10-16 16:20:21 +02:00
Plugin . Instance . TotalSeasons = _queuedEpisodes . Count ;
Plugin . Instance . QueuedMediaItems . Clear ( ) ;
foreach ( var kvp in _queuedEpisodes )
2024-10-16 16:05:59 +02:00
{
2024-10-16 16:20:21 +02:00
Plugin . Instance . QueuedMediaItems . TryAdd ( kvp . Key , kvp . Value ) ;
2022-06-22 22:03:34 -05:00
}
2022-11-23 02:34:28 -06:00
2024-10-16 16:20:21 +02:00
return _queuedEpisodes ;
2024-10-16 16:05:59 +02:00
}
2024-10-16 16:20:21 +02:00
/// <summary>
/// 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.
/// </summary>
private void LoadAnalysisSettings ( )
{
var config = Plugin . Instance ! . Configuration ;
2022-07-08 00:57:12 -05:00
2024-10-16 16:20:21 +02:00
// Store the analysis percent
_analysisPercent = Convert . ToDouble ( config . AnalysisPercent ) / 100 ;
2022-07-08 00:57:12 -05:00
2024-10-18 14:15:09 +02:00
_analyzeMovies = config . AnalyzeMovies ;
2024-10-16 16:20:21 +02:00
// 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 ) ;
}
2022-07-08 00:57:12 -05:00
}
2022-06-22 22:03:34 -05:00
2024-10-16 16:20:21 +02:00
private void QueueLibraryContents ( Guid id )
2022-06-22 22:03:34 -05:00
{
2024-10-16 16:20:21 +02:00
_logger . LogDebug ( "Constructing anonymous internal query" ) ;
2022-06-22 22:03:34 -05:00
2024-10-16 16:20:21 +02:00
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 ) , ] ,
2024-10-18 14:15:09 +02:00
IncludeItemTypes = [ BaseItemKind . Episode , BaseItemKind . Movie ] ,
2024-10-16 16:20:21 +02:00
Recursive = true ,
IsVirtualItem = false
} ;
2022-06-22 22:03:34 -05:00
2024-10-16 16:20:21 +02:00
var items = _libraryManager . GetItemList ( query , false ) ;
2024-10-16 16:05:59 +02:00
2024-10-16 16:20:21 +02:00
if ( items is null )
{
_logger . LogError ( "Library query result is null" ) ;
return ;
}
2022-06-22 22:03:34 -05:00
2024-10-16 16:20:21 +02:00
// Queue all episodes on the server for fingerprinting.
_logger . LogDebug ( "Iterating through library items" ) ;
2022-06-22 22:03:34 -05:00
2024-10-16 16:20:21 +02:00
foreach ( var item in items )
2024-10-16 14:47:20 +02:00
{
2024-10-18 14:15:09 +02:00
if ( item is Episode episode )
2024-10-16 16:20:21 +02:00
{
2024-10-18 14:15:09 +02:00
QueueEpisode ( episode ) ;
}
2024-11-21 15:42:55 +01:00
else if ( item is Movie movie )
2024-10-18 14:15:09 +02:00
{
2024-11-21 15:42:55 +01:00
if ( _analyzeMovies )
{
QueueMovie ( movie ) ;
}
2024-10-18 14:15:09 +02:00
}
else
{
_logger . LogDebug ( "Item {Name} is not an episode or movie" , item . Name ) ;
2024-10-16 16:20:21 +02:00
}
2024-10-16 14:47:20 +02:00
}
2022-06-22 22:03:34 -05:00
2024-10-16 16:20:21 +02:00
_logger . LogDebug ( "Queued {Count} episodes" , items . Count ) ;
2024-06-15 13:16:47 +02:00
}
2024-05-08 16:42:56 +02:00
2024-10-16 16:20:21 +02:00
private void QueueEpisode ( Episode episode )
{
var pluginInstance = Plugin . Instance ? ? throw new InvalidOperationException ( "Plugin instance was null" ) ;
2024-05-08 16:42:56 +02:00
2024-10-16 16:20:21 +02:00
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 ;
}
2022-12-05 22:35:01 -06:00
2024-10-16 16:20:21 +02:00
// Allocate a new list for each new season
var seasonId = GetSeasonId ( episode ) ;
if ( ! _queuedEpisodes . TryGetValue ( seasonId , out var seasonEpisodes ) )
{
seasonEpisodes = [ ] ;
_queuedEpisodes [ seasonId ] = seasonEpisodes ;
}
2024-09-29 21:46:46 +02:00
2024-10-16 16:20:21 +02:00
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 ;
}
2024-09-29 21:46:46 +02:00
2024-10-16 16:20:21 +02:00
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 ) ;
2024-10-18 14:15:09 +02:00
var maxCreditsDuration = Math . Min (
duration > = 5 * 60 ? duration * _analysisPercent : duration ,
60 * pluginInstance . Configuration . MaximumCreditsDuration ) ;
2024-10-16 16:20:21 +02:00
// Queue the episode for analysis
seasonEpisodes . Add ( new QueuedEpisode
{
SeriesName = episode . SeriesName ,
SeasonNumber = episode . AiredSeasonNumber ? ? 0 ,
SeriesId = episode . SeriesId ,
2024-11-21 15:42:55 +01:00
SeasonId = episode . SeasonId ,
2024-10-16 16:20:21 +02:00
EpisodeId = episode . Id ,
Name = episode . Name ,
IsAnime = isAnime ,
Path = episode . Path ,
Duration = Convert . ToInt32 ( duration ) ,
IntroFingerprintEnd = Convert . ToInt32 ( fingerprintDuration ) ,
CreditsFingerprintStart = Convert . ToInt32 ( duration - maxCreditsDuration ) ,
} ) ;
2024-10-18 14:15:09 +02:00
pluginInstance . TotalQueued + + ;
}
private void QueueMovie ( Movie movie )
{
var pluginInstance = Plugin . Instance ? ? throw new InvalidOperationException ( "Plugin instance was null" ) ;
if ( string . IsNullOrEmpty ( movie . Path ) )
{
_logger . LogWarning (
"Not queuing movie \"{Name}\" ({Id}) as no path was provided by Jellyfin" ,
movie . Name ,
movie . Id ) ;
return ;
}
// Allocate a new list for each Movie
_queuedEpisodes . TryAdd ( movie . Id , [ ] ) ;
var duration = TimeSpan . FromTicks ( movie . RunTimeTicks ? ? 0 ) . TotalSeconds ;
_queuedEpisodes [ movie . Id ] . Add ( new QueuedEpisode
{
SeriesName = movie . Name ,
SeriesId = movie . Id ,
2024-11-21 15:42:55 +01:00
SeasonId = movie . Id ,
2024-10-18 14:15:09 +02:00
EpisodeId = movie . Id ,
Name = movie . Name ,
Path = movie . Path ,
Duration = Convert . ToInt32 ( duration ) ,
2024-11-21 15:42:55 +01:00
CreditsFingerprintStart = Convert . ToInt32 ( duration - pluginInstance . Configuration . MaximumMovieCreditsDuration ) ,
2024-10-18 14:15:09 +02:00
IsMovie = true
} ) ;
2024-10-16 16:20:21 +02:00
pluginInstance . TotalQueued + + ;
2024-10-16 14:47:20 +02:00
}
2022-12-05 22:35:01 -06:00
2024-10-16 16:20:21 +02:00
private Guid GetSeasonId ( Episode episode )
2024-10-16 16:05:59 +02:00
{
2024-10-16 16:20:21 +02:00
if ( episode . ParentIndexNumber = = 0 & & episode . AiredSeasonNumber ! = 0 ) // In-season special
2022-12-05 22:35:01 -06:00
{
2024-10-16 16:20:21 +02:00
foreach ( var kvp in _queuedEpisodes )
2022-12-05 22:35:01 -06:00
{
2024-10-16 16:20:21 +02:00
var first = kvp . Value . FirstOrDefault ( ) ;
if ( first ? . SeriesId = = episode . SeriesId & &
first . SeasonNumber = = episode . AiredSeasonNumber )
{
return kvp . Key ;
}
2024-06-15 13:16:47 +02:00
}
2024-10-16 14:47:20 +02:00
}
2022-12-05 22:35:01 -06:00
2024-10-16 16:20:21 +02:00
return episode . SeasonId ;
}
2024-10-16 16:05:59 +02:00
2024-10-16 16:20:21 +02:00
/// <summary>
/// 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.
/// </summary>
/// <param name="candidates">Queued media items.</param>
/// <param name="modes">Analysis mode.</param>
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
2024-11-21 15:42:55 +01:00
internal ( IReadOnlyList < QueuedEpisode > QueuedEpisodes , IReadOnlyCollection < AnalysisMode > RequiredModes )
2024-10-16 16:20:21 +02:00
VerifyQueue ( IReadOnlyList < QueuedEpisode > candidates , IReadOnlyCollection < AnalysisMode > modes )
2024-10-16 16:05:59 +02:00
{
2024-10-16 16:20:21 +02:00
var verified = new List < QueuedEpisode > ( ) ;
2024-11-21 15:42:55 +01:00
var requiredModes = new HashSet < AnalysisMode > ( ) ;
var episodeIds = Plugin . Instance ! . GetEpisodeIds ( candidates [ 0 ] . SeasonId ) ;
2024-10-16 16:05:59 +02:00
2024-10-16 16:20:21 +02:00
foreach ( var candidate in candidates )
{
try
2024-06-15 13:16:47 +02:00
{
2024-10-16 16:20:21 +02:00
var path = Plugin . Instance ! . GetItemPath ( candidate . EpisodeId ) ;
if ( ! File . Exists ( path ) )
2024-04-20 21:12:04 +02:00
{
2024-10-16 16:05:59 +02:00
continue ;
2024-10-16 14:47:20 +02:00
}
2024-10-16 16:20:21 +02:00
verified . Add ( candidate ) ;
2024-10-16 16:05:59 +02:00
2024-10-16 16:20:21 +02:00
foreach ( var mode in modes )
2024-10-16 14:47:20 +02:00
{
2024-11-21 15:42:55 +01:00
if ( ! episodeIds . TryGetValue ( mode , out var ids ) | | ! ids . Contains ( candidate . EpisodeId ) | | Plugin . Instance ! . AnalyzeAgain )
2024-11-06 10:30:00 +01:00
{
2024-11-21 15:42:55 +01:00
requiredModes . Add ( mode ) ;
2024-10-16 16:20:21 +02:00
}
2024-04-20 21:12:04 +02:00
}
2022-12-05 22:35:01 -06:00
}
2024-10-16 16:20:21 +02:00
catch ( Exception ex )
{
_logger . LogDebug (
"Skipping analysis of {Name} ({Id}): {Exception}" ,
candidate . Name ,
candidate . EpisodeId ,
ex ) ;
}
2022-12-05 22:35:01 -06:00
}
2024-10-16 16:05:59 +02:00
2024-11-21 15:42:55 +01:00
return ( verified , requiredModes ) ;
2024-10-16 16:20:21 +02:00
}
2022-12-05 22:35:01 -06:00
}
2022-06-22 22:03:34 -05:00
}