Implement automatic intro skipping
Requires that the client playing media supports remote commands. Closes #9.
This commit is contained in:
parent
d771b6529f
commit
4ca0511f30
197
ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs
Normal file
197
ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user