Use primary constructor everywhere
This commit is contained in:
parent
ca9a167ad5
commit
fc830a5e6f
@ -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;
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
{
|
{
|
||||||
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user