diff --git a/CHANGELOG.md b/CHANGELOG.md index ffca671..42b40ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.1.5.0 (unreleased) +* Use `ffmpeg` to generate audio fingerprints instead of `fpcalc` + * Requires that the installed version of `ffmpeg`: + * Was compiled with the `--enable-chromaprint` option + * Understands the `-fp_format raw` flag + * `jellyfin-ffmpeg 5.0.1-5` meets both of these requirements + ## v0.1.0.0 (2022-06-09) * Add option to automatically skip intros * Cache audio fingerprints by default diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs index 72279ac..adf5835 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs @@ -1,14 +1,18 @@ +/* These tests require that the host system has a version of FFmpeg installed + * which supports both chromaprint and the "-fp_format raw" flag. + */ + using Xunit; using Microsoft.Extensions.Logging; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; -public class TestFPCalc +public class TestAudioFingerprinting { [Fact] public void TestInstallationCheck() { - Assert.True(FPCalc.CheckFPCalcInstalled()); + Assert.True(Chromaprint.CheckFFmpegVersion()); } [Theory] @@ -43,7 +47,7 @@ public class TestFPCalc 3416816715, 3404331257, 3395844345, 3395836155, 3408464089, 3374975369, 1282036360, 1290457736, 1290400440, 1290314408, 1281925800, 1277727404, 1277792932, 1278785460, 1561962388, 1426698196, 3607924711, 4131892839, 4140215815, 4292259591, 3218515717, 3209938229, 3171964197, 3171956013, - 4229117295, 4229312879, 4242407935, 4238016959, 4239987133, 4239990013, 3703060732, 1547188252, + 4229117295, 4229312879, 4242407935, 4240114111, 4239987133, 4239990013, 3703060732, 1547188252, 1278748677, 1278748935, 1144662786, 1148854786, 1090388802, 1090388962, 1086260130, 1085940098, 1102709122, 45811586, 44634002, 44596656, 44592544, 1122527648, 1109944736, 1109977504, 1111030243, 1111017762, 1109969186, 1126721826, 1101556002, 1084844322, 1084979506, 1084914450, 1084914449, @@ -52,7 +56,7 @@ public class TestFPCalc 3472417825, 3395841056, 3458735136, 3341420624, 1076496560, 1076501168, 1076501136, 1076497024 }; - var actual = FPCalc.Fingerprint(queueEpisode("audio/big_buck_bunny_intro.mp3")); + var actual = Chromaprint.Fingerprint(queueEpisode("audio/big_buck_bunny_intro.mp3")); Assert.Equal(expected, actual); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/FPCalc.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Chromaprint.cs similarity index 64% rename from ConfusedPolarBear.Plugin.IntroSkipper/FPCalc.cs rename to ConfusedPolarBear.Plugin.IntroSkipper/Chromaprint.cs index 77cdfc4..1ad4594 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/FPCalc.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Chromaprint.cs @@ -10,9 +10,9 @@ using Microsoft.Extensions.Logging; namespace ConfusedPolarBear.Plugin.IntroSkipper; /// -/// Wrapper for the fpcalc utility. +/// Wrapper for libchromaprint. /// -public static class FPCalc +public static class Chromaprint { /// /// Gets or sets the logger. @@ -20,16 +20,16 @@ public static class FPCalc public static ILogger? Logger { get; set; } /// - /// Check that the fpcalc utility is installed. + /// Check that the installed version of ffmpeg supports chromaprint. /// - /// true if fpcalc is installed, false on any error. - public static bool CheckFPCalcInstalled() + /// true if a compatible version of ffmpeg is installed, false on any error. + public static bool CheckFFmpegVersion() { try { - var version = GetOutput("-version", 2000).TrimEnd(); - Logger?.LogInformation("fpcalc -version: {Version}", version); - return version.StartsWith("fpcalc version", StringComparison.OrdinalIgnoreCase); + var version = Encoding.UTF8.GetString(GetOutput("-version", 2000)); + Logger?.LogDebug("ffmpeg version: {Version}", version); + return version.Contains("--enable-chromaprint", StringComparison.OrdinalIgnoreCase); } catch { @@ -44,7 +44,7 @@ public static class FPCalc /// Numerical fingerprint points. public static ReadOnlyCollection Fingerprint(QueuedEpisode episode) { - // Try to load this episode from cache before running fpcalc. + // Try to load this episode from cache before running ffmpeg. if (LoadCachedFingerprint(episode, out ReadOnlyCollection cachedFingerprint)) { Logger?.LogDebug("Fingerprint cache hit on {File}", episode.Path); @@ -53,32 +53,24 @@ public static class FPCalc Logger?.LogDebug("Fingerprinting {Duration} seconds from {File}", episode.FingerprintDuration, episode.Path); - // FIXME: revisit escaping - var path = "\"" + episode.Path + "\""; - var duration = episode.FingerprintDuration.ToString(CultureInfo.InvariantCulture); - var args = " -raw -length " + duration + " " + path; + var args = string.Format( + CultureInfo.InvariantCulture, + "-i \"{0}\" -to {1} -ac 2 -f chromaprint -fp_format raw -", + episode.Path, + episode.FingerprintDuration); - /* Returns output similar to the following: - * DURATION=123 - * FINGERPRINT=123456789,987654321,123456789,987654321,123456789,987654321 - */ - - var raw = GetOutput(args); - var lines = raw.Split("\n"); - - if (lines.Length < 2) + // Returns all fingerprint points as raw 32 bit unsigned integers (little endian). + var rawPoints = GetOutput(args); + if (rawPoints.Length == 0 || rawPoints.Length % 4 != 0) { - Logger?.LogTrace("fpcalc output is {Raw}", raw); - throw new FingerprintException("fpcalc output for " + episode.Path + " was malformed"); + throw new FingerprintException("chromaprint output for " + episode.Path + " was malformed"); } - // Remove the "FINGERPRINT=" prefix and split into an array of numbers. - var fingerprint = lines[1].Substring(12).Split(","); - var results = new List(); - foreach (var rawNumber in fingerprint) + for (var i = 0; i < rawPoints.Length; i += 4) { - results.Add(Convert.ToUInt32(rawNumber, CultureInfo.InvariantCulture)); + var rawPoint = rawPoints.Slice(i, 4); + results.Add(BitConverter.ToUInt32(rawPoint)); } // Try to cache this fingerprint. @@ -88,23 +80,43 @@ public static class FPCalc } /// - /// Runs fpcalc and returns standard output. + /// Runs ffmpeg and returns standard output. /// - /// Arguments to pass to fpcalc. - /// Timeout (in seconds) to wait for fpcalc to exit. - private static string GetOutput(string args, int timeout = 60 * 1000) + /// Arguments to pass to ffmpeg. + /// Timeout (in seconds) to wait for ffmpeg to exit. + private static ReadOnlySpan GetOutput(string args, int timeout = 60 * 1000) { - var info = new ProcessStartInfo("fpcalc", args); - info.CreateNoWindow = true; - info.RedirectStandardOutput = true; + var ffmpegPath = Plugin.Instance?.FFmpegPath ?? "ffmpeg"; - var fpcalc = new Process(); - fpcalc.StartInfo = info; + var info = new ProcessStartInfo(ffmpegPath, args) + { + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; - fpcalc.Start(); - fpcalc.WaitForExit(timeout); + var ffmpeg = new Process + { + StartInfo = info + }; - return fpcalc.StandardOutput.ReadToEnd(); + ffmpeg.Start(); + ffmpeg.WaitForExit(timeout); + + using (MemoryStream ms = new MemoryStream()) + { + var buf = new byte[4096]; + var bytesRead = 0; + + do + { + bytesRead = ffmpeg.StandardOutput.BaseStream.Read(buf, 0, buf.Length); + ms.Write(buf, 0, bytesRead); + } + while (bytesRead > 0); + + return ms.ToArray().AsSpan(); + } } /// diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs index 72b4917..5a31357 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs @@ -15,7 +15,7 @@ public class PluginConfiguration : BasePluginConfiguration } /// - /// Gets or sets a value indicating whether the output of fpcalc should be cached to the filesystem. + /// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem. /// public bool CacheFingerprints { get; set; } = true; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj index 5a7b391..16990a2 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj @@ -3,8 +3,8 @@ net6.0 ConfusedPolarBear.Plugin.IntroSkipper - 0.1.0.0 - 0.1.0.0 + 0.1.5.0 + 0.1.5.0 true true enable diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs index c92b919..c179c54 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs @@ -105,7 +105,7 @@ public class VisualizationController : ControllerBase { if (needle.EpisodeId == id) { - return FPCalc.Fingerprint(needle); + return Chromaprint.Fingerprint(needle); } } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs index 952cc46..a1f2cc4 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs @@ -49,12 +49,12 @@ public class Entrypoint : IServerEntryPoint /// Task. public Task RunAsync() { - FPCalc.Logger = _logger; + Chromaprint.Logger = _logger; - // Assert that fpcalc is installed - if (!FPCalc.CheckFPCalcInstalled()) + // Assert that ffmpeg with chromaprint is installed + if (!Chromaprint.CheckFFmpegVersion()) { - _logger.LogError("fpcalc is not installed on this system - episodes will not be analyzed"); + _logger.LogError("ffmpeg with chromaprint is not installed on this system - episodes will not be analyzed"); return Task.CompletedTask; } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs index 6dde104..e069363 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs @@ -5,6 +5,7 @@ using System.IO; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; @@ -23,7 +24,11 @@ public class Plugin : BasePlugin, IHasWebPages /// /// Instance of the interface. /// Instance of the interface. - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + /// Server configuration manager. + public Plugin( + IApplicationPaths applicationPaths, + IXmlSerializer xmlSerializer, + IServerConfigurationManager serverConfiguration) : base(applicationPaths, xmlSerializer) { _xmlSerializer = xmlSerializer; @@ -37,6 +42,9 @@ public class Plugin : BasePlugin, IHasWebPages _introPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml"); + // Get the path to FFmpeg. + FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay; + Intros = new Dictionary(); AnalysisQueue = new Dictionary>(); Instance = this; @@ -64,6 +72,11 @@ public class Plugin : BasePlugin, IHasWebPages /// public string FingerprintCachePath { get; private set; } + /// + /// Gets the full path to FFmpeg. + /// + public string FFmpegPath { get; private set; } + /// public override string Name => "Intro Skipper"; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs index 1e634fd..7701378 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs @@ -106,7 +106,7 @@ public class FingerprinterTask : IScheduledTask if (queue.Count == 0) { throw new FingerprintException( - "No episodes to analyze: either no show libraries are defined or fpcalc is not properly installed"); + "No episodes to analyze: either no show libraries are defined or ffmpeg could not be found"); } var totalProcessed = 0; @@ -274,8 +274,8 @@ public class FingerprinterTask : IScheduledTask /// Intros for the first and second episodes. public (Intro Lhs, Intro Rhs) FingerprintEpisodes(QueuedEpisode lhsEpisode, QueuedEpisode rhsEpisode) { - var lhsFingerprint = FPCalc.Fingerprint(lhsEpisode); - var rhsFingerprint = FPCalc.Fingerprint(rhsEpisode); + var lhsFingerprint = Chromaprint.Fingerprint(lhsEpisode); + var rhsFingerprint = Chromaprint.Fingerprint(rhsEpisode); // Cache the fingerprints for quicker recall in the second pass (if one is needed). lock (_fingerprintCacheLock) diff --git a/README.md b/README.md index b8b7a0e..6dc62cc 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,16 @@ -# Intro Skipper (ALPHA) +# Intro Skipper (beta)
Plugin Banner
-Analyzes the audio of television episodes to detect and skip over intros. Currently in alpha. +Analyzes the audio of television episodes to detect and skip over intros. Jellyfin must use a version of `ffmpeg` that has been compiled with `--enable-chromaprint` (`jellyfin-ffmpeg` versions 5.0.1-5 and later will work). -Installing this plugin (along with a modified web interface and `fpcalc`) will result in a skip intro button displaying in the video player, like this: +If you use the custom web interface on your server, you will be able to click a skip button to skip intros, like this: ![Skip intro button](images/skip-button.png) -If you use Jellyfin clients that do not use the web interface provided by the server, the plugin can be configured to automatically skip intros. - -This plugin **will not work** without installing `fpcalc`. The easiest way to do this is to follow the steps below. +However, if you use Jellyfin clients that do not use the web interface provided by the server, the plugin can be configured to automatically skip intros. ## Introduction requirements @@ -21,7 +19,8 @@ Show introductions will only be detected if they are: * Located within the first 25% of an episode, or the first 10 minutes, whichever is smaller * At least 20 seconds long -## Step 1: Install the modified web interface + fpcalc +## Step 1: Install the modified web interface (optional) +This step is only necessary if you do not use the automatic skip feature. 1. Run the `ghcr.io/confusedpolarbear/jellyfin-intro-skipper` container just as you would any other Jellyfin container 1. If you reuse the configuration data from another container, **make sure to create a backup first**. 2. Follow the plugin installation steps below diff --git a/docker/Dockerfile b/docker/Dockerfile index a5774f4..b188eb8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,3 @@ -FROM jellyfin/jellyfin:10.8.0-beta2 - -RUN apt update && apt install -y libchromaprint-tools +FROM jellyfin/jellyfin:10.8.0 COPY dist/ /jellyfin/jellyfin-web/ diff --git a/docs/native.md b/docs/native.md index ae09f4b..07307fd 100644 --- a/docs/native.md +++ b/docs/native.md @@ -2,15 +2,12 @@ ## Requirements -* Jellyfin 10.8.0 beta 2 (beta 3 may also work, untested) +* Jellyfin 10.8.0 beta 3 * Compiled [jellyfin-web](https://github.com/ConfusedPolarBear/jellyfin-web/tree/intros) interface with intro skip button -* [chromaprint](https://github.com/acoustid/chromaprint) (only versions 1.4.3 and later have been verified to work) ## Instructions -1. Install the `fpcalc` program - 1. On Debian based distributions, this is provided by the `libchromaprint-tools` package - 2. Compiled binaries can also be downloaded from the [GitHub repository](https://github.com/acoustid/chromaprint/releases/tag/v1.5.1) +1. Install the latest version of ffmpeg from https://github.com/jellyfin/jellyfin-ffmpeg/releases 2. Download the latest modified web interface from the releases tab and either: 1. Serve the web interface directly from your Jellyfin server, or 2. Serve the web interface using an external web server