From 4ca0511f307b5041775a9ef7ddad4c287f570b57 Mon Sep 17 00:00:00 2001 From: ConfusedPolarBear <33811686+ConfusedPolarBear@users.noreply.github.com> Date: Tue, 7 Jun 2022 18:33:59 -0500 Subject: [PATCH] Implement automatic intro skipping Requires that the client playing media supports remote commands. Closes #9. --- .../AutoSkip.cs | 197 ++++++++++++++++++ .../Configuration/PluginConfiguration.cs | 5 + .../Configuration/configPage.html | 19 +- README.md | 4 +- 4 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs new file mode 100644 index 0000000..c8542c6 --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Timers; +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 AutoSkip : 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 AutoSkip( + 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() + { + if (!Plugin.Instance!.Configuration.AutoSkip) + { + _logger.LogDebug("Not setting up automatic skipping"); + return Task.CompletedTask; + } + + _logger.LogDebug("Setting up automatic skipping"); + + _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; + + _playbackTimer.AutoReset = true; + _playbackTimer.Elapsed += PlaybackTimer_Elapsed; + _playbackTimer.Start(); + + return Task.CompletedTask; + } + + private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) + { + var itemId = e.Item.Id; + + // 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 (NullReferenceException) + { + 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] = false; + } + } + + 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[deviceId]) + { + _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(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; + } + + // Send the seek command + _logger.LogDebug("Sending seek command to {Session}", deviceId); + + var seekCommand = new PlaystateRequest + { + Command = PlaystateCommand.Seek, + ControllingUserId = session.UserId.ToString("N"), + SeekPositionTicks = (long)intro.IntroEnd * TimeSpan.TicksPerSecond, + }; + + _sessionManager.SendPlaystateCommand(session.Id, session.Id, seekCommand, 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 fb5660f..8efa355 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs @@ -19,6 +19,11 @@ public class PluginConfiguration : BasePluginConfiguration /// public bool CacheFingerprints { get; set; } + /// + /// Gets or sets a value indicating whether introductions should be automatically skipped. + /// + public bool AutoSkip { get; set; } + /// /// Gets or sets the seconds before the intro starts to show the skip prompt at. /// diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html index 7bd971e..7e00e3b 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html @@ -12,6 +12,19 @@
+
+ + +
+ If checked, intros will be automatically skipped. Will only work if web + sockets are configured correctly.
+ Jellyfin must be restarted after changing this setting. +
+
+
- +
Seconds after the introduction starts to hide the skip prompt at. @@ -53,7 +66,7 @@

Erasing introduction timestamps is only necessary after upgrading the plugin if specifically - requested to do so in the plugin's changelog. + requested to do so in the plugin's changelog. After the timestamps are erased, run the Analyze episodes scheduled task to re-analyze all media on the server.

@@ -401,6 +414,7 @@ .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { + document.querySelector('#AutoSkip').checked = config.AutoSkip; document.querySelector('#CacheFingerprints').checked = config.CacheFingerprints; document.querySelector('#ShowPromptAdjustment').value = config.ShowPromptAdjustment; @@ -414,6 +428,7 @@ .addEventListener('submit', function (e) { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { + config.AutoSkip = document.querySelector('#AutoSkip').checked; config.CacheFingerprints = document.querySelector('#CacheFingerprints').checked; config.ShowPromptAdjustment = document.querySelector("#ShowPromptAdjustment").value; diff --git a/README.md b/README.md index b4daf51..b8b7a0e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Installing this plugin (along with a modified web interface and `fpcalc`) will r ![Skip intro button](images/skip-button.png) -This plugin **will not work** until both the modified web interface and `fpcalc` are installed. The easiest way to do this is to follow the steps below. +If you use Jellyfin clients that do not use the web interface provided by the server, the plugin can be configured to automatically skip intros. + +This plugin **will not work** without installing `fpcalc`. The easiest way to do this is to follow the steps below. ## Introduction requirements