using System; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace ConfusedPolarBear.Plugin.IntroSkipper; /// /// Intro skipper plugin. Uses audio analysis to find common sequences of audio shared between episodes. /// public class Plugin : BasePlugin, IHasWebPages { private readonly object _serializationLock = new(); private readonly object _introsLock = new(); private IXmlSerializer _xmlSerializer; private ILibraryManager _libraryManager; private IItemRepository _itemRepository; private ILogger _logger; private string _introPath; private string _creditsPath; private string _oldintroPath; private string _oldcreditsPath; private string _oldFingerprintCachePath; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. /// Server configuration manager. /// Library manager. /// Item repository. /// Logger. public Plugin( IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IServerConfigurationManager serverConfiguration, ILibraryManager libraryManager, IItemRepository itemRepository, ILogger logger) : base(applicationPaths, xmlSerializer) { Instance = this; _xmlSerializer = xmlSerializer; _libraryManager = libraryManager; _itemRepository = itemRepository; _logger = logger; FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay; var introsDirectory = Path.Join(applicationPaths.CachePath, "introskipper"); FingerprintCachePath = Path.Join(introsDirectory, "chromaprints"); _introPath = Path.Join(applicationPaths.CachePath, "introskipper", "intros.xml"); _creditsPath = Path.Join(applicationPaths.CachePath, "introskipper", "credits.xml"); var oldintrosDirectory = Path.Join(applicationPaths.PluginConfigurationsPath, "intros"); _oldFingerprintCachePath = Path.Join(oldintrosDirectory, "cache"); _oldintroPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml"); _oldcreditsPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "credits.xml"); // Create the base & cache directories (if needed). if (!Directory.Exists(FingerprintCachePath)) { Directory.CreateDirectory(FingerprintCachePath); // Check if the old cache directory exists if (Directory.Exists(_oldFingerprintCachePath)) { // Move the contents from old directory to new directory File.Move(_oldintroPath, _introPath); File.Move(_oldcreditsPath, _creditsPath); string[] files = Directory.GetFiles(_oldFingerprintCachePath); 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 Directory.Delete(oldintrosDirectory, true); } } ConfigurationChanged += OnConfigurationChanged; // TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete try { RestoreTimestamps(); } catch (Exception ex) { _logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex); } // Inject the skip intro button code into the web interface. var indexPath = Path.Join(applicationPaths.WebPath, "index.html"); try { InjectSkipButton(indexPath); } catch (Exception ex) { WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton); if (ex is UnauthorizedAccessException) { var suggestion = OperatingSystem.IsLinux() ? "running `sudo chown jellyfin PATH` (if this is a native installation)" : "changing the permissions of PATH"; suggestion = suggestion.Replace("PATH", indexPath, StringComparison.Ordinal); _logger.LogError( "Failed to add skip button to web interface. Try {Suggestion} and restarting the server. Error: {Error}", suggestion, ex); } else { _logger.LogError("Unknown error encountered while adding skip button: {Error}", ex); } } FFmpegWrapper.CheckFFmpegVersion(); } /// /// Fired after configuration has been saved so the auto skip timer can be stopped or started. /// public event EventHandler? AutoSkipChanged; /// /// Gets the results of fingerprinting all episodes. /// public Dictionary Intros { get; } = new(); /// /// Gets all discovered ending credits. /// public Dictionary Credits { get; } = new(); /// /// Gets the most recent media item queue. /// public Dictionary> QueuedMediaItems { get; } = new(); /// /// Gets or sets the total number of episodes in the queue. /// public int TotalQueued { get; set; } /// /// Gets or sets the number of seasons in the queue. /// public int TotalSeasons { get; set; } /// /// Gets the directory to cache fingerprints in. /// public string FingerprintCachePath { get; private set; } /// /// Gets the full path to FFmpeg. /// public string FFmpegPath { get; private set; } /// public override string Name => "Intro Skipper"; /// public override Guid Id => Guid.Parse("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b"); /// /// Gets the plugin instance. /// public static Plugin? Instance { get; private set; } /// /// Save timestamps to disk. /// public void SaveTimestamps() { lock (_serializationLock) { var introList = new List(); // Serialize intros foreach (var intro in Plugin.Instance!.Intros) { introList.Add(intro.Value); } _xmlSerializer.SerializeToFile(introList, _introPath); // Serialize credits introList.Clear(); foreach (var intro in Plugin.Instance!.Credits) { introList.Add(intro.Value); } _xmlSerializer.SerializeToFile(introList, _creditsPath); } } /// /// Restore previous analysis results from disk. /// public void RestoreTimestamps() { if (File.Exists(_introPath)) { // Since dictionaries can't be easily serialized, analysis results are stored on disk as a list. var introList = (List)_xmlSerializer.DeserializeFromFile( typeof(List), _introPath); foreach (var intro in introList) { Plugin.Instance!.Intros[intro.EpisodeId] = intro; } } if (File.Exists(_creditsPath)) { var creditList = (List)_xmlSerializer.DeserializeFromFile( typeof(List), _creditsPath); foreach (var credit in creditList) { Plugin.Instance!.Credits[credit.EpisodeId] = credit; } } } /// public IEnumerable GetPages() { return new[] { new PluginPageInfo { Name = this.Name, 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" } }; } /// /// Gets the commit used to build the plugin. /// /// Commit. 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; } internal BaseItem GetItem(Guid id) { return _libraryManager.GetItemById(id); } /// /// Gets the full path for an item. /// /// Item id. /// Full path to item. internal string GetItemPath(Guid id) { return GetItem(id).Path; } /// /// Gets all chapters for this item. /// /// Item id. /// List of chapters. internal List GetChapters(Guid id) { return _itemRepository.GetChapters(GetItem(id)); } internal void UpdateTimestamps(Dictionary newTimestamps, AnalysisMode mode) { lock (_introsLock) { foreach (var intro in newTimestamps) { if (mode == AnalysisMode.Introduction) { Plugin.Instance!.Intros[intro.Key] = intro.Value; } else if (mode == AnalysisMode.Credits) { Plugin.Instance!.Credits[intro.Key] = intro.Value; } } Plugin.Instance!.SaveTimestamps(); } } private void OnConfigurationChanged(object? sender, BasePluginConfiguration e) { AutoSkipChanged?.Invoke(this, EventArgs.Empty); } /// /// Inject the skip button script into the web interface. /// /// Full path to index.html. private void InjectSkipButton(string indexPath) { // Parts of this code are based off of JellyScrub's script injection code. // https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/JellyscrubPlugin.cs#L38 _logger.LogDebug("Reading index.html from {Path}", indexPath); var contents = File.ReadAllText(indexPath); _logger.LogDebug("Successfully read index.html"); var scriptTag = ""; // Only inject the script tag once if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Skip button already added"); return; } // Inject a link to the script at the end of the section. // A regex is used here to ensure the replacement is only done once. _logger.LogDebug("Injecting script tag"); var headEnd = new Regex("", RegexOptions.IgnoreCase); contents = headEnd.Replace(contents, scriptTag + "", 1); // Write the modified file contents _logger.LogDebug("Saving modified file"); File.WriteAllText(indexPath, contents); _logger.LogInformation("Skip intro button successfully added"); } }