2019-02-21 01:57:43 -08:00
using System ;
2024-05-08 16:27:16 +02:00
using System.Collections.Concurrent ;
2019-02-21 01:57:43 -08:00
using System.Collections.Generic ;
2022-05-05 18:10:34 -05:00
using System.IO ;
2022-11-06 21:20:52 -06:00
using System.Text.RegularExpressions ;
2022-04-29 23:52:50 -05:00
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration ;
2024-08-31 18:56:48 +02:00
using ConfusedPolarBear.Plugin.IntroSkipper.Data ;
2019-02-21 01:57:43 -08:00
using MediaBrowser.Common.Configuration ;
using MediaBrowser.Common.Plugins ;
2022-06-09 14:07:40 -05:00
using MediaBrowser.Controller.Configuration ;
2022-07-29 03:34:55 -05:00
using MediaBrowser.Controller.Entities ;
2022-06-15 01:00:03 -05:00
using MediaBrowser.Controller.Library ;
2022-11-24 00:43:23 -06:00
using MediaBrowser.Controller.Persistence ;
using MediaBrowser.Model.Entities ;
2019-02-21 01:57:43 -08:00
using MediaBrowser.Model.Plugins ;
using MediaBrowser.Model.Serialization ;
2022-09-27 20:31:18 -05:00
using Microsoft.Extensions.Logging ;
2019-02-21 01:57:43 -08:00
2022-04-29 23:52:50 -05:00
namespace ConfusedPolarBear.Plugin.IntroSkipper ;
2021-12-13 16:58:05 -07:00
/// <summary>
2022-05-01 01:24:57 -05:00
/// Intro skipper plugin. Uses audio analysis to find common sequences of audio shared between episodes.
2021-12-13 16:58:05 -07:00
/// </summary>
2024-09-10 18:08:42 +02:00
public partial class Plugin : BasePlugin < PluginConfiguration > , IHasWebPages
2019-02-21 01:57:43 -08:00
{
2022-10-28 02:25:57 -05:00
private readonly object _serializationLock = new ( ) ;
private readonly object _introsLock = new ( ) ;
2024-09-10 18:08:42 +02:00
private readonly ILibraryManager _libraryManager ;
private readonly IItemRepository _itemRepository ;
private readonly ILogger < Plugin > _logger ;
private readonly string _introPath ;
private readonly string _creditsPath ;
2022-05-05 18:10:34 -05:00
2022-05-01 01:24:57 -05:00
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
2022-06-09 14:07:40 -05:00
/// <param name="serverConfiguration">Server configuration manager.</param>
2022-06-15 01:00:03 -05:00
/// <param name="libraryManager">Library manager.</param>
2022-11-24 00:43:23 -06:00
/// <param name="itemRepository">Item repository.</param>
2022-09-27 20:31:18 -05:00
/// <param name="logger">Logger.</param>
2022-06-09 14:07:40 -05:00
public Plugin (
IApplicationPaths applicationPaths ,
IXmlSerializer xmlSerializer ,
2022-06-15 01:00:03 -05:00
IServerConfigurationManager serverConfiguration ,
2022-09-27 20:31:18 -05:00
ILibraryManager libraryManager ,
2022-11-24 00:43:23 -06:00
IItemRepository itemRepository ,
2022-09-27 20:31:18 -05:00
ILogger < Plugin > logger )
2022-05-01 01:24:57 -05:00
: base ( applicationPaths , xmlSerializer )
{
2022-09-27 21:03:27 -05:00
Instance = this ;
2022-06-15 01:00:03 -05:00
_libraryManager = libraryManager ;
2022-11-24 00:43:23 -06:00
_itemRepository = itemRepository ;
2022-09-27 20:31:18 -05:00
_logger = logger ;
2022-05-05 18:10:34 -05:00
2022-09-27 21:03:27 -05:00
FFmpegPath = serverConfiguration . GetEncodingOptions ( ) . EncoderAppPathDisplay ;
2022-11-06 21:20:52 -06:00
2024-03-29 15:32:23 +01:00
ArgumentNullException . ThrowIfNull ( applicationPaths ) ;
2024-05-16 18:24:36 +02:00
var pluginDirName = "introskipper" ;
var pluginCachePath = "chromaprints" ;
2024-03-18 19:52:57 +01:00
2024-05-16 18:24:36 +02:00
var introsDirectory = Path . Join ( applicationPaths . DataPath , pluginDirName ) ;
FingerprintCachePath = Path . Join ( introsDirectory , pluginCachePath ) ;
_introPath = Path . Join ( applicationPaths . DataPath , pluginDirName , "intros.xml" ) ;
_creditsPath = Path . Join ( applicationPaths . DataPath , pluginDirName , "credits.xml" ) ;
var cacheRoot = applicationPaths . CachePath ;
var oldIntrosDirectory = Path . Join ( cacheRoot , pluginDirName ) ;
if ( ! Directory . Exists ( oldIntrosDirectory ) )
{
pluginDirName = "intros" ;
pluginCachePath = "cache" ;
cacheRoot = applicationPaths . PluginConfigurationsPath ;
oldIntrosDirectory = Path . Join ( cacheRoot , pluginDirName ) ;
}
var oldFingerprintCachePath = Path . Join ( oldIntrosDirectory , pluginCachePath ) ;
var oldIntroPath = Path . Join ( cacheRoot , pluginDirName , "intros.xml" ) ;
var oldCreditsPath = Path . Join ( cacheRoot , pluginDirName , "credits.xml" ) ;
2022-09-27 21:03:27 -05:00
// Create the base & cache directories (if needed).
2022-05-05 18:10:34 -05:00
if ( ! Directory . Exists ( FingerprintCachePath ) )
{
Directory . CreateDirectory ( FingerprintCachePath ) ;
2024-03-18 19:52:57 +01:00
// Check if the old cache directory exists
2024-04-20 13:36:04 +02:00
if ( Directory . Exists ( oldFingerprintCachePath ) )
2024-03-18 19:52:57 +01:00
{
2024-05-12 20:31:35 +02:00
// move intro.xml if exists
if ( File . Exists ( oldIntroPath ) )
{
File . Move ( oldIntroPath , _introPath ) ;
}
// move credits.xml if exists
if ( File . Exists ( oldCreditsPath ) )
{
File . Move ( oldCreditsPath , _creditsPath ) ;
}
2024-03-18 19:52:57 +01:00
// Move the contents from old directory to new directory
2024-04-20 13:36:04 +02:00
string [ ] files = Directory . GetFiles ( oldFingerprintCachePath ) ;
2024-03-18 19:52:57 +01:00
foreach ( string file in files )
{
string fileName = Path . GetFileName ( file ) ;
string destFile = Path . Combine ( FingerprintCachePath , fileName ) ;
File . Move ( file , destFile ) ;
}
// Optionally, you may delete the old directory after moving its contents
2024-04-20 13:36:04 +02:00
Directory . Delete ( oldIntrosDirectory , true ) ;
2024-03-18 19:52:57 +01:00
}
2022-05-05 18:10:34 -05:00
}
2024-03-29 15:32:23 +01:00
// migrate from XMLSchema to DataContract
XmlSerializationHelper . MigrateXML ( _introPath ) ;
XmlSerializationHelper . MigrateXML ( _creditsPath ) ;
2022-09-27 21:03:27 -05:00
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
2022-09-27 20:31:18 -05:00
try
{
RestoreTimestamps ( ) ;
}
catch ( Exception ex )
{
_logger . LogWarning ( "Unable to load introduction timestamps: {Exception}" , ex ) ;
}
2022-11-06 21:20:52 -06:00
// Inject the skip intro button code into the web interface.
try
{
2024-08-31 17:08:42 +00:00
InjectSkipButton ( applicationPaths . WebPath ) ;
2022-11-06 21:20:52 -06:00
}
catch ( Exception ex )
{
2022-11-06 21:25:23 -06:00
WarningManager . SetFlag ( PluginWarning . UnableToAddSkipButton ) ;
2022-11-06 21:20:52 -06:00
2024-09-19 13:06:37 +02:00
_logger . LogError ( "Failed to add skip button to web interface. See https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible for the most common issues. Error: {Error}" , ex ) ;
2022-11-06 21:20:52 -06:00
}
2024-03-07 17:08:15 -05:00
FFmpegWrapper . CheckFFmpegVersion ( ) ;
2022-05-05 18:10:34 -05:00
}
2022-05-09 22:56:03 -05:00
/// <summary>
/// Gets the results of fingerprinting all episodes.
/// </summary>
2024-09-12 08:37:47 +00:00
public ConcurrentDictionary < Guid , Segment > Intros { get ; } = new ( ) ;
2022-05-09 22:56:03 -05:00
/// <summary>
2022-11-24 00:43:23 -06:00
/// Gets all discovered ending credits.
2022-05-09 22:56:03 -05:00
/// </summary>
2024-09-12 08:37:47 +00:00
public ConcurrentDictionary < Guid , Segment > Credits { get ; } = new ( ) ;
2022-11-24 00:43:23 -06:00
2022-05-09 22:56:03 -05:00
/// <summary>
2022-11-23 02:34:28 -06:00
/// Gets the most recent media item queue.
2022-05-09 22:56:03 -05:00
/// </summary>
2024-05-08 16:27:16 +02:00
public ConcurrentDictionary < Guid , List < QueuedEpisode > > QueuedMediaItems { get ; } = new ( ) ;
2022-05-09 22:56:03 -05:00
2024-06-15 13:16:47 +02:00
/// <summary>
/// Gets all episode states.
/// </summary>
public ConcurrentDictionary < Guid , EpisodeState > EpisodeStates { get ; } = new ( ) ;
2022-05-09 22:56:03 -05:00
/// <summary>
/// Gets or sets the total number of episodes in the queue.
/// </summary>
public int TotalQueued { get ; set ; }
2023-06-08 00:51:18 -05:00
/// <summary>
/// Gets or sets the number of seasons in the queue.
/// </summary>
public int TotalSeasons { get ; set ; }
2022-05-09 22:56:03 -05:00
/// <summary>
/// Gets the directory to cache fingerprints in.
/// </summary>
public string FingerprintCachePath { get ; private set ; }
2022-06-09 14:07:40 -05:00
/// <summary>
/// Gets the full path to FFmpeg.
/// </summary>
public string FFmpegPath { get ; private set ; }
2022-05-09 22:56:03 -05:00
/// <inheritdoc />
public override string Name = > "Intro Skipper" ;
/// <inheritdoc />
public override Guid Id = > Guid . Parse ( "c83d86bb-a1e0-4c35-a113-e2101cf4ee6b" ) ;
/// <summary>
/// Gets the plugin instance.
/// </summary>
public static Plugin ? Instance { get ; private set ; }
2022-05-05 18:10:34 -05:00
/// <summary>
/// Save timestamps to disk.
/// </summary>
2024-05-08 16:27:16 +02:00
/// <param name="mode">Mode.</param>
public void SaveTimestamps ( AnalysisMode mode )
2022-05-05 18:10:34 -05:00
{
2024-09-12 08:37:47 +00:00
List < Segment > introList = [ ] ;
2024-05-08 16:27:16 +02:00
var filePath = mode = = AnalysisMode . Introduction
? _introPath
: _creditsPath ;
2022-11-24 00:43:23 -06:00
2024-05-08 16:27:16 +02:00
lock ( _introsLock )
{
introList . AddRange ( mode = = AnalysisMode . Introduction
? Instance ! . Intros . Values
: Instance ! . Credits . Values ) ;
2022-07-17 01:54:05 -05:00
}
2022-05-05 18:10:34 -05:00
2024-04-20 16:21:20 +02:00
lock ( _serializationLock )
{
2024-05-08 16:27:16 +02:00
try
2024-04-20 16:21:20 +02:00
{
2024-05-08 16:27:16 +02:00
XmlSerializationHelper . SerializeToXml ( introList , filePath ) ;
2024-04-20 16:21:20 +02:00
}
2024-05-08 16:27:16 +02:00
catch ( Exception e )
2024-04-20 16:21:20 +02:00
{
2024-05-08 16:27:16 +02:00
_logger . LogError ( "SaveTimestamps {Message}" , e . Message ) ;
2024-04-20 16:21:20 +02:00
}
}
}
2022-05-05 18:10:34 -05:00
/// <summary>
/// Restore previous analysis results from disk.
/// </summary>
public void RestoreTimestamps ( )
{
2022-11-24 00:43:23 -06:00
if ( File . Exists ( _introPath ) )
2022-05-05 18:10:34 -05:00
{
2022-11-24 00:43:23 -06:00
// Since dictionaries can't be easily serialized, analysis results are stored on disk as a list.
2024-03-29 15:32:23 +01:00
var introList = XmlSerializationHelper . DeserializeFromXml ( _introPath ) ;
2022-05-05 18:10:34 -05:00
2022-11-24 00:43:23 -06:00
foreach ( var intro in introList )
{
2024-05-08 16:27:16 +02:00
Instance ! . Intros . TryAdd ( intro . EpisodeId , intro ) ;
2022-11-24 00:43:23 -06:00
}
}
2022-05-05 18:10:34 -05:00
2022-11-24 00:43:23 -06:00
if ( File . Exists ( _creditsPath ) )
2022-05-05 18:10:34 -05:00
{
2024-03-29 15:32:23 +01:00
var creditList = XmlSerializationHelper . DeserializeFromXml ( _creditsPath ) ;
2022-11-24 00:43:23 -06:00
foreach ( var credit in creditList )
{
2024-05-08 16:27:16 +02:00
Instance ! . Credits . TryAdd ( credit . EpisodeId , credit ) ;
2022-11-24 00:43:23 -06:00
}
2022-05-05 18:10:34 -05:00
}
}
2022-11-06 21:20:52 -06:00
/// <inheritdoc />
public IEnumerable < PluginPageInfo > GetPages ( )
{
2024-09-10 18:08:42 +02:00
return
[
2022-11-06 21:20:52 -06:00
new PluginPageInfo
{
2024-09-10 18:08:42 +02:00
Name = Name ,
2022-11-06 21:20:52 -06:00
EmbeddedResourcePath = GetType ( ) . Namespace + ".Configuration.configPage.html"
} ,
new PluginPageInfo
{
Name = "visualizer.js" ,
EmbeddedResourcePath = GetType ( ) . Namespace + ".Configuration.visualizer.js"
} ,
new PluginPageInfo
{
Name = "skip-intro-button.js" ,
EmbeddedResourcePath = GetType ( ) . Namespace + ".Configuration.inject.js"
}
2024-09-10 18:08:42 +02:00
] ;
2022-11-06 21:20:52 -06:00
}
2022-11-23 01:20:48 -06:00
/// <summary>
/// Gets the commit used to build the plugin.
/// </summary>
/// <returns>Commit.</returns>
public string GetCommit ( )
{
var commit = string . Empty ;
var path = GetType ( ) . Namespace + ".Configuration.version.txt" ;
using var stream = GetType ( ) . Assembly . GetManifestResourceStream ( path ) ;
if ( stream is null )
{
_logger . LogWarning ( "Unable to read embedded version information" ) ;
return commit ;
}
using var reader = new StreamReader ( stream ) ;
commit = reader . ReadToEnd ( ) . TrimEnd ( ) ;
if ( commit = = "unknown" )
{
_logger . LogTrace ( "Embedded version information was not valid, ignoring" ) ;
return string . Empty ;
}
_logger . LogInformation ( "Unstable plugin version built from commit {Commit}" , commit ) ;
return commit ;
}
2024-06-15 13:16:47 +02:00
/// <summary>
/// Gets the Intro for this item.
/// </summary>
/// <param name="id">Item id.</param>
/// <param name="mode">Mode.</param>
/// <returns>Intro.</returns>
2024-09-12 08:37:47 +00:00
internal static Segment GetIntroByMode ( Guid id , AnalysisMode mode )
2024-06-15 13:16:47 +02:00
{
return mode = = AnalysisMode . Introduction
? Instance ! . Intros [ id ]
: Instance ! . Credits [ id ] ;
}
2024-05-01 13:45:57 +02:00
internal BaseItem ? GetItem ( Guid id )
2022-07-29 03:34:55 -05:00
{
return _libraryManager . GetItemById ( id ) ;
}
2022-06-15 01:00:03 -05:00
/// <summary>
/// Gets the full path for an item.
/// </summary>
/// <param name="id">Item id.</param>
/// <returns>Full path to item.</returns>
internal string GetItemPath ( Guid id )
{
2024-05-01 13:45:57 +02:00
var item = GetItem ( id ) ;
if ( item = = null )
{
// Handle the case where the item is not found
_logger . LogWarning ( "Item with ID {Id} not found." , id ) ;
return string . Empty ;
}
return item . Path ;
2022-06-15 01:00:03 -05:00
}
2022-11-24 00:43:23 -06:00
/// <summary>
/// Gets all chapters for this item.
/// </summary>
/// <param name="id">Item id.</param>
/// <returns>List of chapters.</returns>
internal List < ChapterInfo > GetChapters ( Guid id )
2022-10-28 02:25:57 -05:00
{
2024-05-01 13:45:57 +02:00
var item = GetItem ( id ) ;
if ( item = = null )
{
// Handle the case where the item is not found
_logger . LogWarning ( "Item with ID {Id} not found." , id ) ;
2024-09-10 18:08:42 +02:00
return [ ] ;
2024-05-01 13:45:57 +02:00
}
return _itemRepository . GetChapters ( item ) ;
2022-11-24 00:43:23 -06:00
}
2024-06-15 13:16:47 +02:00
/// <summary>
/// Gets the state for this item.
/// </summary>
/// <param name="id">Item ID.</param>
/// <returns>State of this item.</returns>
internal EpisodeState GetState ( Guid id ) = > EpisodeStates . GetOrAdd ( id , _ = > new EpisodeState ( ) ) ;
2024-09-12 08:37:47 +00:00
internal void UpdateTimestamps ( Dictionary < Guid , Segment > newTimestamps , AnalysisMode mode )
2022-10-28 02:25:57 -05:00
{
2024-05-08 16:27:16 +02:00
foreach ( var intro in newTimestamps )
2022-10-28 02:25:57 -05:00
{
2024-05-08 16:27:16 +02:00
if ( mode = = AnalysisMode . Introduction )
2022-10-28 02:25:57 -05:00
{
2024-05-08 16:27:16 +02:00
Instance ! . Intros . AddOrUpdate ( intro . Key , intro . Value , ( key , oldValue ) = > intro . Value ) ;
}
else if ( mode = = AnalysisMode . Credits )
{
Instance ! . Credits . AddOrUpdate ( intro . Key , intro . Value , ( key , oldValue ) = > intro . Value ) ;
2022-10-28 02:25:57 -05:00
}
}
2024-05-08 16:27:16 +02:00
SaveTimestamps ( mode ) ;
2022-10-28 02:25:57 -05:00
}
2024-06-15 10:57:20 +02:00
internal void CleanTimestamps ( HashSet < Guid > validEpisodeIds )
{
var allKeys = new HashSet < Guid > ( Instance ! . Intros . Keys ) ;
allKeys . UnionWith ( Instance ! . Credits . Keys ) ;
foreach ( var key in allKeys )
{
if ( ! validEpisodeIds . Contains ( key ) )
{
Instance ! . Intros . TryRemove ( key , out _ ) ;
Instance ! . Credits . TryRemove ( key , out _ ) ;
}
}
SaveTimestamps ( AnalysisMode . Introduction ) ;
SaveTimestamps ( AnalysisMode . Credits ) ;
}
2022-11-06 21:20:52 -06:00
/// <summary>
/// Inject the skip button script into the web interface.
/// </summary>
2024-08-31 17:08:42 +00:00
/// <param name="webPath">Full path to index.html.</param>
private void InjectSkipButton ( string webPath )
2022-06-14 14:36:05 -05:00
{
2024-08-31 17:08:42 +00:00
// search for controllers/playback/video/index.html
string searchPattern = "playback-video-index-html.*.chunk.js" ;
string [ ] filePaths = Directory . GetFiles ( webPath , searchPattern , SearchOption . TopDirectoryOnly ) ;
// should be only one file but this safer
foreach ( var file in filePaths )
{
// search for class btnSkipIntro
if ( File . ReadAllText ( file ) . Contains ( "btnSkipIntro" , StringComparison . OrdinalIgnoreCase ) )
{
_logger . LogInformation ( "jellyfin has build-in skip button" ) ;
return ;
}
}
// Inject the skip intro button code into the web interface.
string indexPath = Path . Join ( webPath , "index.html" ) ;
2022-11-06 21:20:52 -06:00
// Parts of this code are based off of JellyScrub's script injection code.
2024-03-03 21:46:52 -05:00
// https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/JellyscrubPlugin.cs#L38
2022-11-06 21:20:52 -06:00
_logger . LogDebug ( "Reading index.html from {Path}" , indexPath ) ;
2024-08-31 17:08:42 +00:00
string contents = File . ReadAllText ( indexPath ) ;
2022-11-06 21:20:52 -06:00
2024-07-30 21:22:09 +02:00
// change URL with every relase to prevent the Browers from caching
2024-08-31 17:08:42 +00:00
string scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js&release=" + GetType ( ) . Assembly . GetName ( ) . Version + "\"></script>" ;
2022-11-06 21:20:52 -06:00
// Only inject the script tag once
if ( contents . Contains ( scriptTag , StringComparison . OrdinalIgnoreCase ) )
{
_logger . LogInformation ( "Skip button already added" ) ;
return ;
}
2024-07-30 21:22:09 +02:00
// remove old version if necessary
string pattern = @"<script src=""configurationpage\?name=skip-intro-button\.js.*<\/script>" ;
contents = Regex . Replace ( contents , pattern , string . Empty , RegexOptions . IgnoreCase ) ;
2022-11-06 21:20:52 -06:00
// Inject a link to the script at the end of the <head> section.
// A regex is used here to ensure the replacement is only done once.
2024-09-10 18:08:42 +02:00
Regex headEnd = HeadRegex ( ) ;
2022-11-06 21:20:52 -06:00
contents = headEnd . Replace ( contents , scriptTag + "</head>" , 1 ) ;
// Write the modified file contents
File . WriteAllText ( indexPath , contents ) ;
2024-03-02 11:53:34 -05:00
_logger . LogInformation ( "Skip intro button successfully added" ) ;
2022-06-14 14:36:05 -05:00
}
2024-09-10 18:08:42 +02:00
[GeneratedRegex("</head>", RegexOptions.IgnoreCase)]
private static partial Regex HeadRegex ( ) ;
2019-03-10 08:53:30 +09:00
}