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;
|
|
|
|
private List<string> _selectedLibraries = [];
|
|
|
|
private bool _selectAllLibraries;
|
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.
|
|
|
|
if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name))
|
|
|
|
{
|
|
|
|
_logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name);
|
|
|
|
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-16 16:20:21 +02:00
|
|
|
_selectAllLibraries = config.SelectAllLibraries;
|
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 (!_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)];
|
2024-09-17 08:41:56 +02:00
|
|
|
|
2024-10-16 16:20:21 +02:00
|
|
|
// 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");
|
|
|
|
}
|
2022-07-08 00:57:12 -05:00
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
else if (_analyzeMovies && item is Movie movie)
|
|
|
|
{
|
|
|
|
QueueMovie(movie);
|
|
|
|
}
|
|
|
|
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,
|
|
|
|
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,
|
|
|
|
EpisodeId = movie.Id,
|
|
|
|
Name = movie.Name,
|
|
|
|
Path = movie.Path,
|
|
|
|
Duration = Convert.ToInt32(duration),
|
|
|
|
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>
|
|
|
|
public (IReadOnlyList<QueuedEpisode> VerifiedItems, IReadOnlyCollection<AnalysisMode> RequiredModes)
|
|
|
|
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>();
|
|
|
|
var reqModes = new HashSet<AnalysisMode>();
|
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);
|
2024-04-20 21:12:04 +02:00
|
|
|
|
2024-10-16 16:20:21 +02:00
|
|
|
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-10-16 16:20:21 +02:00
|
|
|
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);
|
|
|
|
}
|
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-10-16 16:20:21 +02:00
|
|
|
return (verified, reqModes);
|
|
|
|
}
|
2022-12-05 22:35:01 -06:00
|
|
|
}
|
2022-06-22 22:03:34 -05:00
|
|
|
}
|