From bbfc9e71a3e14f80661b5102ab64f18209f3a4be Mon Sep 17 00:00:00 2001 From: Kilian von Pflugk Date: Mon, 13 May 2024 14:25:52 +0200 Subject: [PATCH] port AutoSkipCredits forward --- .../AutoSkipCredits.cs | 236 ++++++++++++++++++ .../Configuration/PluginConfiguration.cs | 10 + .../Configuration/configPage.html | 95 ++++--- .../PluginServiceRegistrator.cs | 1 + 4 files changed, 314 insertions(+), 28 deletions(-) create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs new file mode 100644 index 0000000..35b463d --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Timers; +using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Timer = System.Timers.Timer; + +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +/// +/// Automatically skip past credit sequences. +/// Commands clients to seek to the end of the credits as soon as they start playing it. +/// +public class AutoSkipCredits : IHostedService, IDisposable +{ + private readonly object _sentSeekCommandLock = new(); + + private ILogger _logger; + private IUserDataManager _userDataManager; + private ISessionManager _sessionManager; + private 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(); + } + + private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e) + { + var configuration = (PluginConfiguration)e; + var newState = 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; + } + + // 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. + if (!Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) + { + newState = true; + } + + // 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 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 start of an episode. + var adjustedStart = Math.Max(5, credit.IntroStart); + + _logger.LogTrace( + "Playback position is {Position}, credits run from {Start} to {End}", + position, + adjustedStart, + credit.IntroEnd); + + if (position < adjustedStart || position > credit.IntroEnd) + { + 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); + + var creditEnd = (long)credit.IntroEnd; + + _sessionManager.SendPlaystateCommand( + session.Id, + session.Id, + new PlaystateRequest + { + Command = PlaystateCommand.Seek, + ControllingUserId = session.UserId.ToString("N"), + SeekPositionTicks = creditEnd * 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; + } + + _playbackTimer.Stop(); + _playbackTimer.Dispose(); + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("Setting up automatic credit skipping"); + + _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; + Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged; + + // Make the timer restart automatically and set enabled to match the configuration value. + _playbackTimer.AutoReset = true; + _playbackTimer.Elapsed += PlaybackTimer_Elapsed; + + AutoSkipCreditChanged(null, Plugin.Instance.Configuration); + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; + return Task.CompletedTask; + } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs index e7d5114..43fd7b0 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs @@ -133,6 +133,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. /// @@ -204,6 +209,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; } = "Credits 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 4fb0642..ad04e45 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html @@ -7,19 +7,19 @@
+ data-require="emby-input,emby-button,emby-select,emby-checkbox,emby-linkbutton">
@@ -356,11 +356,25 @@
- If checked, auto skip will ignore introduction in the first episode of a season.
+ If checked, auto skip will play the introduction of the first episode in a season.
+
+
+
+ +
+ + +
+ If checked, credits will be automatically skipped. If you access Jellyfin through a + reverse proxy, it must be configured to proxy web + sockets.
@@ -455,6 +469,16 @@ Message shown after automatically skipping an introduction. Leave blank to disable notification.
+ +
+ + +
+ Message shown after automatically skipping credits. Leave blank to disable notification. +
+
@@ -511,9 +535,9 @@

Fingerprint Visualizer

@@ -524,8 +548,8 @@

- - + + @@ -631,7 +655,8 @@ // UI customization "SkipButtonIntroText", "SkipButtonEndCreditsText", - "AutoSkipNotificationText" + "AutoSkipNotificationText", + "AutoSkipCreditsNotificationText" ] var booleanConfigurationFields = [ @@ -642,6 +667,7 @@ "UseChromaprint", "CacheFingerprints", "AutoSkip", + "AutoSkipCredits", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonVisible" @@ -666,6 +692,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) { @@ -679,6 +707,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"); @@ -879,16 +917,16 @@ // make an authenticated GET to the server and parse the response as JSON async function getJson(url) { return await fetchWithAuth(url, "GET") - .then(r => { - if (r.ok) { - return r.json(); - } else { - return null; - } - }) - .catch(err => { - console.debug(err); - }); + .then(r => { + if (r.ok) { + return r.json(); + } else { + return null; + } + }) + .catch(err => { + console.debug(err); + }); } // make an authenticated fetch to the server @@ -1018,6 +1056,7 @@ } autoSkipChanged(); + autoSkipCreditsChanged(); persistSkipChanged(); Dashboard.hideLoadingMsg(); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs b/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs index 2f3459a..d3e45d5 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/PluginServiceRegistrator.cs @@ -13,6 +13,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) { serviceCollection.AddHostedService(); + serviceCollection.AddHostedService(); serviceCollection.AddHostedService(); } }
KeyFunctionKeyFunction