diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs new file mode 100644 index 0000000..486d4d6 --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Timers; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Logging; + +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +/// +/// Automatically skip past introduction sequences. +/// Commands clients to seek to the end of the intro as soon as they start playing it. +/// +public class AutoSkipCredits : IServerEntryPoint +{ + private readonly object _sentSeekCommandLock = new(); + + private ILogger _logger; + private IUserDataManager _userDataManager; + private ISessionManager _sessionManager; + private System.Timers.Timer _playbackTimer = new(1000); + private Dictionary _sentSeekCommand; + + /// + /// Initializes a new instance of the class. + /// + /// User data manager. + /// Session manager. + /// Logger. + public AutoSkipCredits( + IUserDataManager userDataManager, + ISessionManager sessionManager, + ILogger logger) + { + _userDataManager = userDataManager; + _sessionManager = sessionManager; + _logger = logger; + _sentSeekCommand = new Dictionary(); + } + + /// + /// If introduction auto skipping is enabled, set it up. + /// + /// Task. + public Task RunAsync() + { + _logger.LogDebug("Setting up automatic skipping"); + + _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; + Plugin.Instance!.AutoSkipCreditsChanged += AutoSkipCreditsChanged; + + // Make the timer restart automatically and set enabled to match the configuration value. + _playbackTimer.AutoReset = true; + _playbackTimer.Elapsed += PlaybackTimer_Elapsed; + + AutoSkipCreditsChanged(null, EventArgs.Empty); + + return Task.CompletedTask; + } + + private void AutoSkipCreditsChanged(object? sender, EventArgs e) + { + var newState = Plugin.Instance!.Configuration.AutoSkipCredits; + _logger.LogDebug("Setting playback timer enabled to {NewState}", newState); + _playbackTimer.Enabled = newState; + } + + private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) + { + var itemId = e.Item.Id; + var newState = false; + var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); + + // Ignore all events except playback start & end + if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) + { + return; + } + + // Lookup the session for this item. + SessionInfo? session = null; + + try + { + foreach (var needle in _sessionManager.Sessions) + { + if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) + { + 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) + { + return; + } + + // Reset the seek command state for this device. + 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) + { + 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 a credit was detected for this item. + if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var intro) || !intro.Valid) + { + continue; + } + + // Seek is unreliable if called at the very start of an episode. + var adjustedStart = Math.Max(5, intro.IntroStart); + + _logger.LogTrace( + "Playback position is {Position}, intro runs from {Start} to {End}", + position, + adjustedStart, + intro.IntroEnd); + + if (position < adjustedStart || position > intro.IntroEnd) + { + continue; + } + + // Notify the user that an introduction is 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); + + var introEnd = (long)intro.IntroEnd; + + _sessionManager.SendPlaystateCommand( + session.Id, + session.Id, + new PlaystateRequest + { + Command = PlaystateCommand.Seek, + ControllingUserId = session.UserId.ToString("N"), + SeekPositionTicks = introEnd * 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; + } + } + } + + /// + /// Dispose. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Protected dispose. + /// + /// Dispose. + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; + _playbackTimer.Stop(); + _playbackTimer.Dispose(); + } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs index 5f771d0..5549843 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs @@ -127,6 +127,11 @@ public class PluginConfiguration : BasePluginConfiguration /// public bool AutoSkip { get; set; } + /// + /// Gets or sets a value indicating whether credits should be automatically skipped. + /// + public bool AutoSkipCredits { get; set; } + /// /// Gets or sets the seconds before the intro starts to show the skip prompt at. /// @@ -198,6 +203,11 @@ public class PluginConfiguration : BasePluginConfiguration /// public string AutoSkipNotificationText { get; set; } = "Intro skipped"; + /// + /// Gets or sets the notification text sent after automatically skipping credits. + /// + public string AutoSkipCreditsNotificationText { get; set; } = "Intro skipped"; + /// /// Gets or sets the number of threads for an ffmpeg process. /// diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html index 7512a04..ccbb6b8 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html @@ -344,6 +344,19 @@ + + + + Automatically skip credits + + + + If checked, credits will be automatically skipped. If you access Jellyfin through a + reverse proxy, it must be configured to proxy web + sockets. + + + @@ -435,6 +448,16 @@ Message shown after automatically skipping an introduction. Leave blank to disable notification. + + + + Automatic skip notification message + + + + Message shown after automatically skipping credits. Leave blank to disable notification. + + @@ -609,7 +632,8 @@ // UI customization "SkipButtonIntroText", "SkipButtonEndCreditsText", - "AutoSkipNotificationText" + "AutoSkipNotificationText", + "AutoSkipCreditsNotificationText" ] var booleanConfigurationFields = [ @@ -620,6 +644,7 @@ "UseChromaprint", "CacheFingerprints", "AutoSkip", + "AutoSkipCredits", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonVisible" @@ -644,6 +669,8 @@ var autoSkip = document.querySelector("input#AutoSkip"); var skipFirstEpisode = document.querySelector("div#divSkipFirstEpisode"); var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText"); + var autoSkipCredits = document.querySelector("input#AutoSkipCredits"); + var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText"); async function autoSkipChanged() { if (autoSkip.checked) { @@ -657,6 +684,16 @@ autoSkip.addEventListener("change", autoSkipChanged); + async function autoSkipCreditsChanged() { + if (autoSkipCredits.checked) { + autoSkipCreditsNotificationText.style.display = 'unset'; + } else { + autoSkipCreditsNotificationText.style.display = 'none'; + } + } + + autoSkipCredits.addEventListener("change", autoSkipCreditsChanged); + var persistSkip = document.querySelector("input#PersistSkipButton"); var showAdjustment = document.querySelector("div#divShowPromptAdjustment"); var hideAdjustment = document.querySelector("div#divHidePromptAdjustment"); @@ -996,6 +1033,7 @@ } autoSkipChanged(); + autoSkipCreditsChanged(); persistSkipChanged(); Dashboard.hideLoadingMsg(); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs index 5aad59a..bd7d4ab 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs @@ -144,6 +144,11 @@ public class Plugin : BasePlugin, IHasWebPages /// public event EventHandler? AutoSkipChanged; + /// + /// Fired after configuration has been saved so the auto skip timer can be stopped or started. + /// + public event EventHandler? AutoSkipCreditsChanged; + /// /// Gets or sets a value indicating whether analysis is running. /// @@ -355,6 +360,7 @@ public class Plugin : BasePlugin, IHasWebPages private void OnConfigurationChanged(object? sender, BasePluginConfiguration e) { AutoSkipChanged?.Invoke(this, EventArgs.Empty); + AutoSkipCreditsChanged?.Invoke(this, EventArgs.Empty); } ///