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 = @"