Use primary constructor everywhere

This commit is contained in:
rlauu 2024-10-16 16:20:21 +02:00
parent ca9a167ad5
commit fc830a5e6f
16 changed files with 1081 additions and 1070 deletions

View File

@ -1,5 +1,6 @@
using System; using System;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Manager;
using Xunit; using Xunit;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;

View File

@ -5,112 +5,113 @@ using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers
/// <summary>
/// Analyzer Helper.
/// </summary>
public class AnalyzerHelper
{ {
private readonly ILogger _logger;
private readonly double _silenceDetectionMinimumDuration;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="AnalyzerHelper"/> class. /// Analyzer Helper.
/// </summary> /// </summary>
/// <param name="logger">Logger.</param> public class AnalyzerHelper
public AnalyzerHelper(ILogger logger)
{ {
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); private readonly ILogger _logger;
_silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration; private readonly double _silenceDetectionMinimumDuration;
_logger = logger;
}
/// <summary> /// <summary>
/// Adjusts the end timestamps of all intros so that they end at silence. /// Initializes a new instance of the <see cref="AnalyzerHelper"/> class.
/// </summary> /// </summary>
/// <param name="episodes">QueuedEpisodes to adjust.</param> /// <param name="logger">Logger.</param>
/// <param name="originalIntros">Original introductions.</param> public AnalyzerHelper(ILogger logger)
/// <param name="mode">Analysis mode.</param>
/// <returns>Modified Intro Timestamps.</returns>
public Dictionary<Guid, Segment> AdjustIntroTimes(
IReadOnlyList<QueuedEpisode> episodes,
IReadOnlyDictionary<Guid, Segment> 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)
{ {
AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd); var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
_silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration;
_logger = logger;
} }
return adjustedIntro; /// <summary>
} /// Adjusts the end timestamps of all intros so that they end at silence.
/// </summary>
private bool AdjustIntroBasedOnChapters(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroStart, TimeRange originalIntroEnd) /// <param name="episodes">QueuedEpisodes to adjust.</param>
{ /// <param name="originalIntros">Original introductions.</param>
var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? []; /// <param name="mode">Analysis mode.</param>
double previousTime = 0; /// <returns>Modified Intro Timestamps.</returns>
public Dictionary<Guid, Segment> AdjustIntroTimes(
for (int i = 0; i <= chapters.Count; i++) IReadOnlyList<QueuedEpisode> episodes,
IReadOnlyDictionary<Guid, Segment> originalIntros,
AnalysisMode mode)
{ {
double currentTime = i < chapters.Count return episodes
? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds .Where(episode => originalIntros.TryGetValue(episode.EpisodeId, out var _))
: episode.Duration; .ToDictionary(
episode => episode.EpisodeId,
if (originalIntroStart.Start < previousTime && previousTime < originalIntroStart.End) episode => AdjustIntroForEpisode(episode, originalIntros[episode.EpisodeId], mode));
{
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 Segment AdjustIntroForEpisode(QueuedEpisode episode, Segment originalIntro, AnalysisMode mode)
}
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); _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; AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd);
break; }
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) private bool IsValidSilenceForIntroAdjustment(TimeRange silenceRange, TimeRange originalIntroEnd, Segment adjustedIntro)
{ {
return originalIntroEnd.Intersects(silenceRange) && return originalIntroEnd.Intersects(silenceRange) &&
silenceRange.Duration >= _silenceDetectionMinimumDuration && silenceRange.Duration >= _silenceDetectionMinimumDuration &&
silenceRange.Start >= adjustedIntro.Start; silenceRange.Start >= adjustedIntro.Start;
}
} }
} }

View File

@ -23,8 +23,4 @@
<EmbeddedResource Include="Configuration\visualizer.js" /> <EmbeddedResource Include="Configuration\visualizer.js" />
<EmbeddedResource Include="Configuration\inject.js" /> <EmbeddedResource Include="Configuration\inject.js" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Manager\" />
<Folder Include="Services\" />
</ItemGroup>
</Project> </Project>

View File

@ -6,7 +6,7 @@ using System.Runtime.Serialization;
using System.Xml; using System.Xml;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using ConfusedPolarBear.Plugin.IntroSkipper.Data;
namespace ConfusedPolarBear.Plugin.IntroSkipper namespace ConfusedPolarBear.Plugin.IntroSkipper.Helper
{ {
internal sealed class XmlSerializationHelper internal sealed class XmlSerializationHelper
{ {

View File

@ -4,113 +4,114 @@ using System.IO;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager
/// <summary>
/// Update EDL files associated with a list of episodes.
/// </summary>
public static class EdlManager
{ {
private static ILogger? _logger;
/// <summary> /// <summary>
/// Initialize EDLManager with a logger. /// Update EDL files associated with a list of episodes.
/// </summary> /// </summary>
/// <param name="logger">ILogger.</param> public static class EdlManager
public static void Initialize(ILogger logger)
{ {
_logger = logger; private static ILogger? _logger;
}
/// <summary> /// <summary>
/// Logs the configuration that will be used during EDL file creation. /// Initialize EDLManager with a logger.
/// </summary> /// </summary>
public static void LogConfiguration() /// <param name="logger">ILogger.</param>
{ public static void Initialize(ILogger logger)
if (_logger is null)
{ {
throw new InvalidOperationException("Logger must not be null"); _logger = logger;
} }
var config = Plugin.Instance!.Configuration; /// <summary>
/// Logs the configuration that will be used during EDL file creation.
if (config.EdlAction == EdlAction.None) /// </summary>
public static void LogConfiguration()
{ {
_logger.LogDebug("EDL action: None - taking no further action"); if (_logger is null)
return; {
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); /// <summary>
_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.
} /// </summary>
/// <param name="episodes">Episodes to update EDL files for.</param>
/// <summary> public static void UpdateEDLFiles(IReadOnlyList<QueuedEpisode> episodes)
/// If the EDL action is set to a value other than None, update EDL files for the provided episodes.
/// </summary>
/// <param name="episodes">Episodes to update EDL files for.</param>
public static void UpdateEDLFiles(IReadOnlyList<QueuedEpisode> episodes)
{
var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles;
var action = Plugin.Instance.Configuration.EdlAction;
if (action == EdlAction.None)
{ {
_logger?.LogDebug("EDL action is set to none, not updating EDL files"); var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles;
return; var action = Plugin.Instance.Configuration.EdlAction;
} if (action == EdlAction.None)
_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)
{ {
_logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id); _logger?.LogDebug("EDL action is set to none, not updating EDL files");
continue; 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); foreach (var episode in episodes)
if (!regenerate && File.Exists(edlPath))
{ {
_logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath); var id = episode.EpisodeId;
continue;
}
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) if (!hasIntro && !hasCredit)
{
edlContent += intro?.ToEdl(action);
}
if (hasCredit)
{
if (edlContent.Length > 0)
{ {
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);
}
}
/// <summary>
/// Given the path to an episode, return the path to the associated EDL file.
/// </summary>
/// <param name="mediaPath">Full path to episode.</param>
/// <returns>Full path to EDL file.</returns>
public static string GetEdlPath(string mediaPath)
{
return Path.ChangeExtension(mediaPath, "edl");
} }
} }
/// <summary>
/// Given the path to an episode, return the path to the associated EDL file.
/// </summary>
/// <param name="mediaPath">Full path to episode.</param>
/// <returns>Full path to EDL file.</returns>
public static string GetEdlPath(string mediaPath)
{
return Path.ChangeExtension(mediaPath, "edl");
}
} }

View File

@ -10,287 +10,288 @@ using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager
/// <summary>
/// Manages enqueuing library items for analysis.
/// </summary>
/// <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)
{ {
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;
/// <summary> /// <summary>
/// Gets all media items on the server. /// Manages enqueuing library items for analysis.
/// </summary> /// </summary>
/// <returns>Queued media items.</returns> /// <remarks>
public IReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems() /// 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)
{ {
Plugin.Instance!.TotalQueued = 0; 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;
LoadAnalysisSettings(); /// <summary>
/// Gets all media items on the server.
// For all selected libraries, enqueue all contained episodes. /// </summary>
foreach (var folder in _libraryManager.GetVirtualFolders()) /// <returns>Queued media items.</returns>
public IReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems()
{ {
// If libraries have been selected for analysis, ensure this library was selected. Plugin.Instance!.TotalQueued = 0;
if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name))
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); // If libraries have been selected for analysis, ensure this library was selected.
continue; if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name))
}
_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;
}
/// <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;
// 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; _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);
}
/// <summary> // Some virtual folders don't have a proper item id.
/// Verify that a collection of queued media items still exist in Jellyfin and in storage. if (!Guid.TryParse(folder.ItemId, out var folderId))
/// 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)
{
var verified = new List<QueuedEpisode>();
var reqModes = new HashSet<AnalysisMode>();
foreach (var candidate in candidates)
{
try
{
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
if (!File.Exists(path))
{ {
continue; continue;
} }
verified.Add(candidate); try
foreach (var mode in modes)
{ {
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;
}
/// <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;
// 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;
}
/// <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)
{
var verified = new List<QueuedEpisode>();
var reqModes = new HashSet<AnalysisMode>();
foreach (var candidate in candidates)
{
try
{
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
if (!File.Exists(path))
{ {
continue; continue;
} }
bool isAnalyzed = mode == AnalysisMode.Introduction verified.Add(candidate);
? Plugin.Instance!.Intros.ContainsKey(candidate.EpisodeId)
: Plugin.Instance!.Credits.ContainsKey(candidate.EpisodeId);
if (isAnalyzed) foreach (var mode in modes)
{ {
candidate.State.SetAnalyzed(mode, true); if (candidate.State.IsAnalyzed(mode) || candidate.State.IsBlacklisted(mode))
} {
else continue;
{ }
reqModes.Add(mode);
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);
}
} }
} }

View File

@ -6,6 +6,7 @@ using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Helper;
using MediaBrowser.Common; using MediaBrowser.Common;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;

View File

@ -1,4 +1,5 @@
using ConfusedPolarBear.Plugin.IntroSkipper.Providers; using ConfusedPolarBear.Plugin.IntroSkipper.Providers;
using ConfusedPolarBear.Plugin.IntroSkipper.Services;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;

View File

@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Manager;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;

View File

@ -4,6 +4,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Manager;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;

View File

@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Services;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;

View File

@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Services;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;

View File

@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Services;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;

View File

@ -15,216 +15,217 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer; using Timer = System.Timers.Timer;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper.Services
/// <summary>
/// Automatically skip past introduction sequences.
/// Commands clients to seek to the end of the intro as soon as they start playing it.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
/// </remarks>
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public class AutoSkip(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkip> logger) : IHostedService, IDisposable
{ {
private readonly object _sentSeekCommandLock = new(); /// <summary>
/// Automatically skip past introduction sequences.
private ILogger<AutoSkip> _logger = logger; /// Commands clients to seek to the end of the intro as soon as they start playing it.
private IUserDataManager _userDataManager = userDataManager; /// </summary>
private ISessionManager _sessionManager = sessionManager; /// <remarks>
private Timer _playbackTimer = new(1000); /// Initializes a new instance of the <see cref="AutoSkip"/> class.
private Dictionary<string, bool> _sentSeekCommand = []; /// </remarks>
private HashSet<string> _clientList = []; /// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
private void AutoSkipChanged(object? sender, BasePluginConfiguration e) /// <param name="logger">Logger.</param>
public class AutoSkip(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkip> logger) : IHostedService, IDisposable
{ {
var configuration = (PluginConfiguration)e; private readonly object _sentSeekCommandLock = new();
_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 void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) private ILogger<AutoSkip> _logger = logger;
{ private IUserDataManager _userDataManager = userDataManager;
var itemId = e.Item.Id; private ISessionManager _sessionManager = sessionManager;
var newState = false; private Timer _playbackTimer = new(1000);
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); private Dictionary<string, bool> _sentSeekCommand = [];
private HashSet<string> _clientList = [];
// Ignore all events except playback start & end private void AutoSkipChanged(object? sender, BasePluginConfiguration e)
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
{ {
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. private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
SessionInfo? session = null;
try
{ {
foreach (var needle in _sessionManager.Sessions) var itemId = e.Item.Id;
{ var newState = false;
if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
{
session = needle;
break;
}
}
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; 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. // Lookup the session for this item.
if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) SessionInfo? session = null;
{
newState = true;
}
// Reset the seek command state for this device. try
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)
{ {
if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) foreach (var needle in _sessionManager.Sessions)
{ {
_logger.LogTrace("Already sent seek command for session {Session}", deviceId); if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
continue; {
session = needle;
break;
}
}
if (session == null)
{
_logger.LogInformation("Unable to find session for {Item}", itemId);
return;
} }
} }
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
// Assert that an intro was detected for this item.
if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid)
{ {
continue; return;
} }
// Seek is unreliable if called at the very start of an episode. // 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.
var adjustedStart = Math.Max(1, intro.Start + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay); if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
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; newState = true;
} }
// Notify the user that an introduction is being skipped for them. // Reset the seek command state for this device.
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) lock (_sentSeekCommandLock)
{ {
_logger.LogTrace("Setting seek command state for session {Session}", deviceId); var device = session.DeviceId;
_sentSeekCommand[deviceId] = true;
_logger.LogDebug("Resetting seek command state for session {Session}", device);
_sentSeekCommand[device] = newState;
} }
} }
}
/// <summary> private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="disposing">Dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{ {
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(); /// <summary>
_playbackTimer.Dispose(); /// Dispose.
} /// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <inheritdoc /> /// <summary>
public Task StartAsync(CancellationToken cancellationToken) /// Protected dispose.
{ /// </summary>
_logger.LogDebug("Setting up automatic skipping"); /// <param name="disposing">Dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved; _playbackTimer.Stop();
Plugin.Instance!.ConfigurationChanged += AutoSkipChanged; _playbackTimer.Dispose();
}
// Make the timer restart automatically and set enabled to match the configuration value. /// <inheritdoc />
_playbackTimer.AutoReset = true; public Task StartAsync(CancellationToken cancellationToken)
_playbackTimer.Elapsed += PlaybackTimer_Elapsed; {
_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;
/// <inheritdoc /> AutoSkipChanged(null, Plugin.Instance.Configuration);
public Task StopAsync(CancellationToken cancellationToken)
{ return Task.CompletedTask;
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; }
return Task.CompletedTask;
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
return Task.CompletedTask;
}
} }
} }

View File

@ -15,216 +15,217 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer; using Timer = System.Timers.Timer;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper.Services
/// <summary>
/// Automatically skip past credit sequences.
/// Commands clients to seek to the end of the credits as soon as they start playing it.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="AutoSkipCredits"/> class.
/// </remarks>
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public class AutoSkipCredits(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkipCredits> logger) : IHostedService, IDisposable
{ {
private readonly object _sentSeekCommandLock = new(); /// <summary>
/// Automatically skip past credit sequences.
private ILogger<AutoSkipCredits> _logger = logger; /// Commands clients to seek to the end of the credits as soon as they start playing it.
private IUserDataManager _userDataManager = userDataManager; /// </summary>
private ISessionManager _sessionManager = sessionManager; /// <remarks>
private Timer _playbackTimer = new(1000); /// Initializes a new instance of the <see cref="AutoSkipCredits"/> class.
private Dictionary<string, bool> _sentSeekCommand = []; /// </remarks>
private HashSet<string> _clientList = []; /// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e) /// <param name="logger">Logger.</param>
public class AutoSkipCredits(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkipCredits> logger) : IHostedService, IDisposable
{ {
var configuration = (PluginConfiguration)e; private readonly object _sentSeekCommandLock = new();
_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 void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) private ILogger<AutoSkipCredits> _logger = logger;
{ private IUserDataManager _userDataManager = userDataManager;
var itemId = e.Item.Id; private ISessionManager _sessionManager = sessionManager;
var newState = false; private Timer _playbackTimer = new(1000);
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); private Dictionary<string, bool> _sentSeekCommand = [];
private HashSet<string> _clientList = [];
// Ignore all events except playback start & end private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e)
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
{ {
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. private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
SessionInfo? session = null;
try
{ {
foreach (var needle in _sessionManager.Sessions) var itemId = e.Item.Id;
{ var newState = false;
if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
{
session = needle;
break;
}
}
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; 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. // Lookup the session for this item.
if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) SessionInfo? session = null;
{
newState = true;
}
// Reset the seek command state for this device. try
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)
{ {
if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) foreach (var needle in _sessionManager.Sessions)
{ {
_logger.LogTrace("Already sent seek command for session {Session}", deviceId); if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
continue; {
session = needle;
break;
}
}
if (session == null)
{
_logger.LogInformation("Unable to find session for {Item}", itemId);
return;
} }
} }
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
// Assert that credits were detected for this item.
if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var credit) || !credit.Valid)
{ {
continue; return;
} }
// Seek is unreliable if called at the very end of an episode. // 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.
var adjustedStart = credit.Start + Plugin.Instance.Configuration.SecondsOfCreditsStartToPlay; if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
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; newState = true;
} }
// Notify the user that credits are being skipped for them. // Reset the seek command state for this device.
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) lock (_sentSeekCommandLock)
{ {
_logger.LogTrace("Setting seek command state for session {Session}", deviceId); var device = session.DeviceId;
_sentSeekCommand[deviceId] = true;
_logger.LogDebug("Resetting seek command state for session {Session}", device);
_sentSeekCommand[device] = newState;
} }
} }
}
/// <summary> private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="disposing">Dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{ {
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(); /// <summary>
_playbackTimer.Dispose(); /// Dispose.
} /// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <inheritdoc /> /// <summary>
public Task StartAsync(CancellationToken cancellationToken) /// Protected dispose.
{ /// </summary>
_logger.LogDebug("Setting up automatic credit skipping"); /// <param name="disposing">Dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved; _playbackTimer.Stop();
Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged; _playbackTimer.Dispose();
}
// Make the timer restart automatically and set enabled to match the configuration value. /// <inheritdoc />
_playbackTimer.AutoReset = true; public Task StartAsync(CancellationToken cancellationToken)
_playbackTimer.Elapsed += PlaybackTimer_Elapsed; {
_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;
/// <inheritdoc /> AutoSkipCreditChanged(null, Plugin.Instance.Configuration);
public Task StopAsync(CancellationToken cancellationToken)
{ return Task.CompletedTask;
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; }
return Task.CompletedTask;
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
return Task.CompletedTask;
}
} }
} }

View File

@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Manager;
using ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; using ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
@ -13,314 +14,315 @@ using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper.Services
/// <summary>
/// Server entrypoint.
/// </summary>
public sealed class Entrypoint : IHostedService, IDisposable
{ {
private readonly ITaskManager _taskManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<Entrypoint> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly HashSet<Guid> _seasonsToAnalyze = [];
private readonly Timer _queueTimer;
private static readonly ManualResetEventSlim _autoTaskCompletEvent = new(false);
private PluginConfiguration _config;
private bool _analyzeAgain;
private static CancellationTokenSource? _cancellationTokenSource;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Entrypoint"/> class. /// Server entrypoint.
/// </summary> /// </summary>
/// <param name="libraryManager">Library manager.</param> public sealed class Entrypoint : IHostedService, IDisposable
/// <param name="taskManager">Task manager.</param>
/// <param name="logger">Logger.</param>
/// <param name="loggerFactory">Logger factory.</param>
public Entrypoint(
ILibraryManager libraryManager,
ITaskManager taskManager,
ILogger<Entrypoint> logger,
ILoggerFactory loggerFactory)
{ {
_libraryManager = libraryManager; private readonly ITaskManager _taskManager;
_taskManager = taskManager; private readonly ILibraryManager _libraryManager;
_logger = logger; private readonly ILogger<Entrypoint> _logger;
_loggerFactory = loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly HashSet<Guid> _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(); /// <summary>
_queueTimer = new Timer( /// Initializes a new instance of the <see cref="Entrypoint"/> class.
OnTimerCallback, /// </summary>
null, /// <param name="libraryManager">Library manager.</param>
Timeout.InfiniteTimeSpan, /// <param name="taskManager">Task manager.</param>
Timeout.InfiniteTimeSpan); /// <param name="logger">Logger.</param>
} /// <param name="loggerFactory">Logger factory.</param>
public Entrypoint(
/// <summary> ILibraryManager libraryManager,
/// Gets State of the automatic task. ITaskManager taskManager,
/// </summary> ILogger<Entrypoint> logger,
public static TaskState AutomaticTaskState ILoggerFactory loggerFactory)
{
get
{ {
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);
}
/// <summary>
/// Gets State of the automatic task.
/// </summary>
public static TaskState AutomaticTaskState
{
get
{ {
return _cancellationTokenSource.IsCancellationRequested if (_cancellationTokenSource is not null)
? TaskState.Cancelling {
: TaskState.Running; return _cancellationTokenSource.IsCancellationRequested
} ? TaskState.Cancelling
: TaskState.Running;
}
return TaskState.Idle; return TaskState.Idle;
}
}
/// <inheritdoc />
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<QueueManager>(), _libraryManager);
queueManager?.GetMediaItems();
}
catch (Exception ex)
{
_logger.LogError("Unable to run startup enqueue: {Exception}", ex);
}
return Task.CompletedTask;
}
/// <inheritdoc />
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
/// <summary>
/// Library item was added.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
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();
}
/// <summary>
/// Library item was modified.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
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();
}
/// <summary>
/// TaskManager task ended.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
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;
/// <summary>
/// Start timer to debounce analyzing.
/// </summary>
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);
}
}
/// <summary>
/// Wait for timer callback to be completed.
/// </summary>
private void OnTimerCallback(object? state)
{
try
{
PerformAnalysis();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in PerformAnalysis");
}
// Clean up
_cancellationTokenSource = null;
_autoTaskCompletEvent.Set();
}
/// <summary>
/// Wait for timer to be completed.
/// </summary>
private void PerformAnalysis()
{
_logger.LogInformation("Initiate automatic analysis task.");
_autoTaskCompletEvent.Reset();
using (_cancellationTokenSource = new CancellationTokenSource())
using (ScheduledTaskSemaphore.Acquire(_cancellationTokenSource.Token))
{
var seasonIds = new HashSet<Guid>(_seasonsToAnalyze);
_seasonsToAnalyze.Clear();
_analyzeAgain = false;
var progress = new Progress<double>();
var modes = new List<AnalysisMode>();
var tasklogger = _loggerFactory.CreateLogger("DefaultLogger");
if (_config.AutoDetectIntros)
{
modes.Add(AnalysisMode.Introduction);
tasklogger = _loggerFactory.CreateLogger<DetectIntrosTask>();
}
if (_config.AutoDetectCredits)
{
modes.Add(AnalysisMode.Credits);
tasklogger = modes.Count == 2
? _loggerFactory.CreateLogger<DetectIntrosCreditsTask>()
: _loggerFactory.CreateLogger<DetectCreditsTask>();
}
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();
} }
} }
}
/// <summary> /// <inheritdoc />
/// Method to cancel the automatic task. public Task StartAsync(CancellationToken cancellationToken)
/// </summary> {
/// <param name="cancellationToken">Cancellation token.</param> _libraryManager.ItemAdded += OnItemAdded;
public static void CancelAutomaticTask(CancellationToken cancellationToken) _libraryManager.ItemUpdated += OnItemModified;
{ _taskManager.TaskCompleted += OnLibraryRefresh;
if (_cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested) 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<QueueManager>(), _libraryManager);
queueManager?.GetMediaItems();
}
catch (Exception ex)
{
_logger.LogError("Unable to run startup enqueue: {Exception}", ex);
}
return Task.CompletedTask;
}
/// <inheritdoc />
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
/// <summary>
/// Library item was added.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
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();
}
/// <summary>
/// Library item was modified.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
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();
}
/// <summary>
/// TaskManager task ended.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
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;
/// <summary>
/// Start timer to debounce analyzing.
/// </summary>
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);
}
}
/// <summary>
/// Wait for timer callback to be completed.
/// </summary>
private void OnTimerCallback(object? state)
{ {
try try
{ {
_cancellationTokenSource.Cancel(); PerformAnalysis();
} }
catch (ObjectDisposedException) catch (Exception ex)
{ {
_cancellationTokenSource = null; _logger.LogError(ex, "Error in PerformAnalysis");
}
// Clean up
_cancellationTokenSource = null;
_autoTaskCompletEvent.Set();
}
/// <summary>
/// Wait for timer to be completed.
/// </summary>
private void PerformAnalysis()
{
_logger.LogInformation("Initiate automatic analysis task.");
_autoTaskCompletEvent.Reset();
using (_cancellationTokenSource = new CancellationTokenSource())
using (ScheduledTaskSemaphore.Acquire(_cancellationTokenSource.Token))
{
var seasonIds = new HashSet<Guid>(_seasonsToAnalyze);
_seasonsToAnalyze.Clear();
_analyzeAgain = false;
var progress = new Progress<double>();
var modes = new List<AnalysisMode>();
var tasklogger = _loggerFactory.CreateLogger("DefaultLogger");
if (_config.AutoDetectIntros)
{
modes.Add(AnalysisMode.Introduction);
tasklogger = _loggerFactory.CreateLogger<DetectIntrosTask>();
}
if (_config.AutoDetectCredits)
{
modes.Add(AnalysisMode.Credits);
tasklogger = modes.Count == 2
? _loggerFactory.CreateLogger<DetectIntrosCreditsTask>()
: _loggerFactory.CreateLogger<DetectCreditsTask>();
}
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 /// <summary>
} /// Method to cancel the automatic task.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public static void CancelAutomaticTask(CancellationToken cancellationToken)
{
if (_cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested)
{
try
{
_cancellationTokenSource.Cancel();
}
catch (ObjectDisposedException)
{
_cancellationTokenSource = null;
}
}
/// <inheritdoc/> _autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal
public void Dispose() }
{
_queueTimer.Dispose(); /// <inheritdoc/>
_cancellationTokenSource?.Dispose(); public void Dispose()
_autoTaskCompletEvent.Dispose(); {
_queueTimer.Dispose();
_cancellationTokenSource?.Dispose();
_autoTaskCompletEvent.Dispose();
}
} }
} }