Implement automatic intro skipping

Requires that the client playing media supports remote commands.
Closes #9.
This commit is contained in:
ConfusedPolarBear 2022-06-07 18:33:59 -05:00
parent d771b6529f
commit 4ca0511f30
4 changed files with 222 additions and 3 deletions

View File

@ -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;
/// <summary>
/// Automatically skip past introduction sequences.
/// Commands clients to seek to the end of the intro as soon as they start playing it.
/// </summary>
public class AutoSkip : IServerEntryPoint
{
private readonly object _sentSeekCommandLock = new();
private ILogger<AutoSkip> _logger;
private IUserDataManager _userDataManager;
private ISessionManager _sessionManager;
private System.Timers.Timer _playbackTimer = new(1000);
private Dictionary<string, bool> _sentSeekCommand;
/// <summary>
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
/// </summary>
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public AutoSkip(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkip> logger)
{
_userDataManager = userDataManager;
_sessionManager = sessionManager;
_logger = logger;
_sentSeekCommand = new Dictionary<string, bool>();
}
/// <summary>
/// If introduction auto skipping is enabled, set it up.
/// </summary>
/// <returns>Task.</returns>
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;
}
}
}
/// <summary>
/// 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;
}
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
_playbackTimer.Stop();
_playbackTimer.Dispose();
}
}

View File

@ -19,6 +19,11 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public bool CacheFingerprints { get; set; } public bool CacheFingerprints { get; set; }
/// <summary>
/// Gets or sets a value indicating whether introductions should be automatically skipped.
/// </summary>
public bool AutoSkip { get; set; }
/// <summary> /// <summary>
/// Gets or sets the seconds before the intro starts to show the skip prompt at. /// Gets or sets the seconds before the intro starts to show the skip prompt at.
/// </summary> /// </summary>

View File

@ -12,6 +12,19 @@
<div data-role="content"> <div data-role="content">
<div class="content-primary"> <div class="content-primary">
<form id="FingerprintConfigForm"> <form id="FingerprintConfigForm">
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoSkip" type="checkbox" is="emby-checkbox" />
<span>Automatically skip intros</span>
</label>
<div class="fieldDescription">
If checked, intros will be automatically skipped. Will only work if web
sockets are configured correctly.<br />
<strong>Jellyfin must be restarted after changing this setting.</strong>
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="CacheFingerprints" type="checkbox" is="emby-checkbox" /> <input id="CacheFingerprints" type="checkbox" is="emby-checkbox" />
@ -34,7 +47,7 @@
</div> </div>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="AnInteger">Hide skip prompt at</label> <label class="inputLabel inputLabelUnfocused" for="AnInteger">Hide skip prompt after</label>
<input id="HidePromptAdjustment" type="number" is="emby-input" min="2" /> <input id="HidePromptAdjustment" type="number" is="emby-input" min="2" />
<div class="fieldDescription"> <div class="fieldDescription">
Seconds after the introduction starts to hide the skip prompt at. Seconds after the introduction starts to hide the skip prompt at.
@ -53,7 +66,7 @@
<p> <p>
Erasing introduction timestamps is only necessary after upgrading the plugin if specifically 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.
</p> </p>
</div> </div>
</form> </form>
@ -401,6 +414,7 @@
.addEventListener('pageshow', function () { .addEventListener('pageshow', function () {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) {
document.querySelector('#AutoSkip').checked = config.AutoSkip;
document.querySelector('#CacheFingerprints').checked = config.CacheFingerprints; document.querySelector('#CacheFingerprints').checked = config.CacheFingerprints;
document.querySelector('#ShowPromptAdjustment').value = config.ShowPromptAdjustment; document.querySelector('#ShowPromptAdjustment').value = config.ShowPromptAdjustment;
@ -414,6 +428,7 @@
.addEventListener('submit', function (e) { .addEventListener('submit', function (e) {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) {
config.AutoSkip = document.querySelector('#AutoSkip').checked;
config.CacheFingerprints = document.querySelector('#CacheFingerprints').checked; config.CacheFingerprints = document.querySelector('#CacheFingerprints').checked;
config.ShowPromptAdjustment = document.querySelector("#ShowPromptAdjustment").value; config.ShowPromptAdjustment = document.querySelector("#ShowPromptAdjustment").value;

View File

@ -10,7 +10,9 @@ Installing this plugin (along with a modified web interface and `fpcalc`) will r
![Skip intro button](images/skip-button.png) ![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 ## Introduction requirements