using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Data; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; 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 partial class Plugin : BasePlugin, IHasWebPages { private readonly object _serializationLock = new(); private readonly object _introsLock = new(); private readonly ILibraryManager _libraryManager; private readonly IItemRepository _itemRepository; private readonly ILogger _logger; private readonly string _introPath; private readonly string _creditsPath; private string _ignorelistPath; /// /// 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; _libraryManager = libraryManager; _itemRepository = itemRepository; _logger = logger; FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay; ArgumentNullException.ThrowIfNull(applicationPaths); var pluginDirName = "introskipper"; var pluginCachePath = "chromaprints"; 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"); _ignorelistPath = Path.Join(applicationPaths.DataPath, pluginDirName, "ignorelist.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"); // 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 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); } // Move the contents from old directory to new directory 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); } } // migrate from XMLSchema to DataContract XmlSerializationHelper.MigrateXML(_introPath); XmlSerializationHelper.MigrateXML(_creditsPath); // 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); } try { LoadIgnoreList(); } catch (Exception ex) { _logger.LogWarning("Unable to load ignore list: {Exception}", ex); } // Inject the skip intro button code into the web interface. try { InjectSkipButton(applicationPaths.WebPath); } catch (Exception ex) { WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton); _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); } FFmpegWrapper.CheckFFmpegVersion(); } /// /// Gets the results of fingerprinting all episodes. /// public ConcurrentDictionary Intros { get; } = new(); /// /// Gets all discovered ending credits. /// public ConcurrentDictionary Credits { get; } = new(); /// /// Gets the most recent media item queue. /// public ConcurrentDictionary> QueuedMediaItems { get; } = new(); /// /// Gets all episode states. /// public ConcurrentDictionary EpisodeStates { get; } = new(); /// /// Gets the ignore list. /// public ConcurrentDictionary IgnoreList { 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. /// /// Mode. public void SaveTimestamps(AnalysisMode mode) { List introList = []; var filePath = mode == AnalysisMode.Introduction ? _introPath : _creditsPath; lock (_introsLock) { introList.AddRange(mode == AnalysisMode.Introduction ? Instance!.Intros.Values : Instance!.Credits.Values); } lock (_serializationLock) { try { XmlSerializationHelper.SerializeToXml(introList, filePath); } catch (Exception e) { _logger.LogError("SaveTimestamps {Message}", e.Message); } } } /// /// Save IgnoreList to disk. /// public void SaveIgnoreList() { var ignorelist = Instance!.IgnoreList.Values.ToList(); lock (_serializationLock) { try { XmlSerializationHelper.SerializeToXml(ignorelist, _ignorelistPath); } catch (Exception e) { _logger.LogError("SaveIgnoreList {Message}", e.Message); } } } /// /// Check if an item is ignored. /// /// Item id. /// Mode. /// True if ignored, false otherwise. public bool IsIgnored(Guid id, AnalysisMode mode) { return Instance!.IgnoreList.TryGetValue(id, out var item) && item.IsIgnored(mode); } /// /// Load IgnoreList from disk. /// public void LoadIgnoreList() { if (File.Exists(_ignorelistPath)) { var ignorelist = XmlSerializationHelper.DeserializeFromXml(_ignorelistPath); foreach (var item in ignorelist) { Instance!.IgnoreList.TryAdd(item.SeasonId, item); } } } /// /// 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 = XmlSerializationHelper.DeserializeFromXml(_introPath); foreach (var intro in introList) { Instance!.Intros.TryAdd(intro.EpisodeId, intro); } } if (File.Exists(_creditsPath)) { var creditList = XmlSerializationHelper.DeserializeFromXml(_creditsPath); foreach (var credit in creditList) { Instance!.Credits.TryAdd(credit.EpisodeId, credit); } } } /// public IEnumerable GetPages() { return [ new PluginPageInfo { Name = 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 Intro for this item. /// /// Item id. /// Mode. /// Intro. internal static Segment GetIntroByMode(Guid id, AnalysisMode mode) { return mode == AnalysisMode.Introduction ? Instance!.Intros[id] : Instance!.Credits[id]; } 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) { 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; } /// /// Gets all chapters for this item. /// /// Item id. /// List of chapters. internal List GetChapters(Guid id) { 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 []; } return _itemRepository.GetChapters(item); } /// /// Gets the state for this item. /// /// Item ID. /// State of this item. internal EpisodeState GetState(Guid id) => EpisodeStates.GetOrAdd(id, _ => new EpisodeState()); internal void UpdateTimestamps(IReadOnlyDictionary newTimestamps, AnalysisMode mode) { foreach (var intro in newTimestamps) { if (mode == AnalysisMode.Introduction) { 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); } } SaveTimestamps(mode); } internal void CleanTimestamps(HashSet validEpisodeIds) { var allKeys = new HashSet(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); } /// /// Inject the skip button script into the web interface. /// /// Full path to index.html. private void InjectSkipButton(string webPath) { // 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"); // 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); string contents = File.ReadAllText(indexPath); // change URL with every relase to prevent the Browers from caching string scriptTag = ""; // Only inject the script tag once if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Skip button already added"); return; } // remove old version if necessary string pattern = @"