// Copyright (C) 2024 Intro-Skipper contributors // SPDX-License-Identifier: GPL-3.0-only. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Xml; using System.Xml.Serialization; using IntroSkipper.Configuration; using IntroSkipper.Data; using IntroSkipper.Db; using IntroSkipper.Helper; using MediaBrowser.Common; 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 MediaBrowser.Model.Updates; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace IntroSkipper; /// /// Intro skipper plugin. Uses audio analysis to find common sequences of audio shared between episodes. /// public class Plugin : BasePlugin, IHasWebPages { private readonly ILibraryManager _libraryManager; private readonly IItemRepository _itemRepository; private readonly IApplicationHost _applicationHost; private readonly ILogger _logger; private readonly string _introPath; private readonly string _creditsPath; private readonly string _dbPath; /// /// Initializes a new instance of the class. /// /// Application host. /// Instance of the interface. /// Instance of the interface. /// Server configuration manager. /// Library manager. /// Item repository. /// Logger. public Plugin( IApplicationHost applicationHost, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IServerConfigurationManager serverConfiguration, ILibraryManager libraryManager, IItemRepository itemRepository, ILogger logger) : base(applicationPaths, xmlSerializer) { Instance = this; _applicationHost = applicationHost; _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"); _dbPath = Path.Join(applicationPaths.DataPath, pluginDirName, "introskipper.db"); // Create the base & cache directories (if needed). if (!Directory.Exists(FingerprintCachePath)) { Directory.CreateDirectory(FingerprintCachePath); } // migrate from XMLSchema to DataContract XmlSerializationHelper.MigrateXML(_introPath); XmlSerializationHelper.MigrateXML(_creditsPath); var oldConfigFile = Path.Join(applicationPaths.PluginConfigurationsPath, "ConfusedPolarBear.Plugin.IntroSkipper.xml"); if (File.Exists(oldConfigFile)) { try { XmlSerializer serializer = new XmlSerializer(typeof(PluginConfiguration)); using FileStream fileStream = new FileStream(oldConfigFile, FileMode.Open); var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, // Disable DTD processing XmlResolver = null // Disable the XmlResolver }; using var reader = XmlReader.Create(fileStream, settings); if (serializer.Deserialize(reader) is PluginConfiguration oldConfig) { Instance.UpdateConfiguration(oldConfig); fileStream.Close(); File.Delete(oldConfigFile); } } catch (Exception ex) { // Handle exceptions, such as file not found, deserialization errors, etc. _logger.LogWarning("Something stupid happened: {Exception}", ex); } } MigrateRepoUrl(serverConfiguration); // Initialize database, restore timestamps if available. try { using var db = new IntroSkipperDbContext(_dbPath); db.Database.EnsureCreated(); db.ApplyMigrations(); if (File.Exists(_introPath) || File.Exists(_creditsPath)) { RestoreTimestampsAsync(db).GetAwaiter().GetResult(); } } catch (Exception ex) { _logger.LogWarning("Error initializing database: {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 path to the database. /// public string DbPath => _dbPath; /// /// Gets the most recent media item queue. /// public ConcurrentDictionary> 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; } /// /// Restore previous analysis results from disk. /// /// IntroSkipperDbContext. /// A representing the asynchronous operation. public async Task RestoreTimestampsAsync(IntroSkipperDbContext db) { // Import intros if (File.Exists(_introPath)) { var introList = XmlSerializationHelper.DeserializeFromXml(_introPath); foreach (var intro in introList) { var dbSegment = new DbSegment(intro, AnalysisMode.Introduction); db.DbSegment.Add(dbSegment); } } // Import credits if (File.Exists(_creditsPath)) { var creditList = XmlSerializationHelper.DeserializeFromXml(_creditsPath); foreach (var credit in creditList) { var dbSegment = new DbSegment(credit, AnalysisMode.Credits); db.DbSegment.Add(dbSegment); } } await db.SaveChangesAsync().ConfigureAwait(false); File.Delete(_introPath); File.Delete(_creditsPath); } /// 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" } ]; } internal BaseItem? GetItem(Guid id) { return id != Guid.Empty ? _libraryManager.GetItemById(id) : null; } internal IReadOnlyList GetCollectionFolders(Guid id) { var item = GetItem(id); return item is not null ? _libraryManager.GetCollectionFolders(item) : []; } /// /// 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 IReadOnlyList 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); } internal async Task UpdateTimestamps(IReadOnlyDictionary newTimestamps, AnalysisMode mode) { using var db = new IntroSkipperDbContext(_dbPath); // Get all existing segments in a single query var existingSegments = db.DbSegment .Where(s => newTimestamps.Keys.Contains(s.ItemId) && s.Type == mode) .ToDictionary(s => s.ItemId); // Batch updates and inserts var segmentsToAdd = new List(); foreach (var (itemId, segment) in newTimestamps) { var dbSegment = new DbSegment(segment, mode); if (existingSegments.TryGetValue(itemId, out var existing)) { db.Entry(existing).CurrentValues.SetValues(dbSegment); } else { segmentsToAdd.Add(dbSegment); } } if (segmentsToAdd.Count > 0) { await db.DbSegment.AddRangeAsync(segmentsToAdd).ConfigureAwait(false); } await db.SaveChangesAsync().ConfigureAwait(false); } internal async Task ClearInvalidSegments() { using var db = new IntroSkipperDbContext(_dbPath); db.DbSegment.RemoveRange(db.DbSegment.Where(s => s.End == 0)); await db.SaveChangesAsync().ConfigureAwait(false); } internal async Task CleanTimestamps(HashSet episodeIds) { using var db = new IntroSkipperDbContext(_dbPath); db.DbSegment.RemoveRange(db.DbSegment .Where(s => !episodeIds.Contains(s.ItemId))); await db.SaveChangesAsync().ConfigureAwait(false); } internal IReadOnlyDictionary GetSegmentsById(Guid id) { using var db = new IntroSkipperDbContext(_dbPath); return db.DbSegment .Where(s => s.ItemId == id) .ToDictionary( s => s.Type, s => new Segment { EpisodeId = s.ItemId, Start = s.Start, End = s.End }); } internal Segment GetSegmentByMode(Guid id, AnalysisMode mode) { using var db = new IntroSkipperDbContext(_dbPath); return db.DbSegment .Where(s => s.ItemId == id && s.Type == mode) .Select(s => new Segment { EpisodeId = s.ItemId, Start = s.Start, End = s.End }).FirstOrDefault() ?? new Segment(id); } internal async Task UpdateAnalyzerActionAsync(Guid id, IReadOnlyDictionary analyzerActions) { using var db = new IntroSkipperDbContext(_dbPath); var existingEntries = await db.DbSeasonInfo .Where(s => s.SeasonId == id) .ToDictionaryAsync(s => s.Type) .ConfigureAwait(false); foreach (var (mode, action) in analyzerActions) { var dbSeasonInfo = new DbSeasonInfo(id, mode, action); if (existingEntries.TryGetValue(mode, out var existing)) { db.Entry(existing).CurrentValues.SetValues(dbSeasonInfo); } else { db.DbSeasonInfo.Add(dbSeasonInfo); } } await db.SaveChangesAsync().ConfigureAwait(false); } internal IReadOnlyDictionary GetAnalyzerAction(Guid id) { using var db = new IntroSkipperDbContext(_dbPath); return db.DbSeasonInfo.Where(s => s.SeasonId == id).ToDictionary(s => s.Type, s => s.Action); } internal AnalyzerAction GetAnalyzerAction(Guid id, AnalysisMode mode) { using var db = new IntroSkipperDbContext(_dbPath); return db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode)?.Action ?? AnalyzerAction.Default; } internal async Task CleanSeasonInfoAsync() { using var db = new IntroSkipperDbContext(_dbPath); var obsoleteSeasons = await db.DbSeasonInfo .Where(s => !Instance!.QueuedMediaItems.Keys.Contains(s.SeasonId)) .ToListAsync().ConfigureAwait(false); db.DbSeasonInfo.RemoveRange(obsoleteSeasons); await db.SaveChangesAsync().ConfigureAwait(false); } private void MigrateRepoUrl(IServerConfigurationManager serverConfiguration) { try { List oldRepos = [ "https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/manifest.json", "https://raw.githubusercontent.com/jumoog/intro-skipper/master/manifest.json", "https://manifest.intro-skipper.workers.dev/manifest.json" ]; // Access the current server configuration var config = serverConfiguration.Configuration; // Get the list of current plugin repositories var pluginRepositories = config.PluginRepositories.ToList(); // check if old plugins exits if (pluginRepositories.Exists(repo => repo.Url != null && oldRepos.Contains(repo.Url))) { // remove all old plugins pluginRepositories.RemoveAll(repo => repo.Url != null && oldRepos.Contains(repo.Url)); // Add repository only if it does not exit and the OverideManifestUrl Option is activated if (!pluginRepositories.Exists(repo => repo.Url == "https://manifest.intro-skipper.org/manifest.json") && Instance!.Configuration.OverrideManifestUrl) { // Add the new repository to the list pluginRepositories.Add(new RepositoryInfo { Name = "intro skipper (automatically migrated by plugin)", Url = "https://manifest.intro-skipper.org/manifest.json", Enabled = true, }); } // Update the configuration with the new repository list config.PluginRepositories = [.. pluginRepositories]; // Save the updated configuration serverConfiguration.SaveConfiguration(); } } catch (Exception ex) { _logger.LogError(ex, "Error occurred while migrating repo URL"); } } /// /// Inject the skip button script into the web interface. /// /// Full path to index.html. private void InjectSkipButton(string webPath) { string searchPattern = "dashboard-dashboard.*.chunk.js"; string[] filePaths = Directory.GetFiles(webPath, searchPattern, SearchOption.TopDirectoryOnly); string pattern = @"buildVersion""\)\.innerText=""(?\d+\.\d+\.\d+)"",.*?webVersion""\)\.innerText=""(?\d+\.\d+\.\d+)"; string webVersionString = "unknown"; // Create a Regex object Regex regex = new Regex(pattern); // should be only one file but this safer foreach (var file in filePaths) { string dashBoardText = File.ReadAllText(file); // Perform the match Match match = regex.Match(dashBoardText); // search for buildVersion and webVersion if (match.Success) { webVersionString = match.Groups["webVersion"].Value; _logger.LogInformation("Found jellyfin-web <{WebVersion}>", webVersionString); break; } } if (webVersionString != "unknown") { // append Revision webVersionString += ".0"; if (Version.TryParse(webVersionString, out var webversion)) { if (_applicationHost.ApplicationVersion != webversion) { _logger.LogWarning("The jellyfin-web <{WebVersion}> NOT compatible with Jellyfin <{JellyfinVersion}>", webVersionString, _applicationHost.ApplicationVersion); } else { _logger.LogInformation("The jellyfin-web <{WebVersion}> compatible with Jellyfin <{JellyfinVersion}>", webVersionString, _applicationHost.ApplicationVersion); } } } // 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); if (!Instance!.Configuration.SkipButtonEnabled) { pattern = @""; // Only inject the script tag once if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("The skip button has already been injected."); return; } // remove old version if necessary pattern = @"