Move from fpcalc to ffmpeg

This commit is contained in:
ConfusedPolarBear 2022-06-09 14:07:40 -05:00
parent db1fc45993
commit 3459e3ce4b
12 changed files with 115 additions and 64 deletions

View File

@ -1,14 +1,20 @@
/* 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 Xunit;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
// TODO: rename
public class TestFPCalc public class TestFPCalc
{ {
[Fact] [Fact]
public void TestInstallationCheck() public void TestInstallationCheck()
{ {
Assert.True(FPCalc.CheckFPCalcInstalled()); Assert.True(FPCalc.CheckFFmpegVersion());
} }
[Theory] [Theory]
@ -43,7 +49,7 @@ public class TestFPCalc
3416816715, 3404331257, 3395844345, 3395836155, 3408464089, 3374975369, 1282036360, 1290457736, 3416816715, 3404331257, 3395844345, 3395836155, 3408464089, 3374975369, 1282036360, 1290457736,
1290400440, 1290314408, 1281925800, 1277727404, 1277792932, 1278785460, 1561962388, 1426698196, 1290400440, 1290314408, 1281925800, 1277727404, 1277792932, 1278785460, 1561962388, 1426698196,
3607924711, 4131892839, 4140215815, 4292259591, 3218515717, 3209938229, 3171964197, 3171956013, 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, 1278748677, 1278748935, 1144662786, 1148854786, 1090388802, 1090388962, 1086260130, 1085940098,
1102709122, 45811586, 44634002, 44596656, 44592544, 1122527648, 1109944736, 1109977504, 1111030243, 1102709122, 45811586, 44634002, 44596656, 44592544, 1122527648, 1109944736, 1109977504, 1111030243,
1111017762, 1109969186, 1126721826, 1101556002, 1084844322, 1084979506, 1084914450, 1084914449, 1111017762, 1109969186, 1126721826, 1101556002, 1084844322, 1084979506, 1084914450, 1084914449,

View File

@ -15,7 +15,7 @@ public class PluginConfiguration : BasePluginConfiguration
} }
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
public bool CacheFingerprints { get; set; } = true; public bool CacheFingerprints { get; set; } = true;

View File

@ -33,8 +33,8 @@
<div class="fieldDescription"> <div class="fieldDescription">
If checked, will store the fingerprints for all subsequently scanned files to disk. If checked, will store the fingerprints for all subsequently scanned files to disk.
Caching fingerprints avoids having to re-run fpcalc on each file, at the expense of disk Caching fingerprints avoids having to re-run chromaprint on each file, at the expense of
usage. disk usage.
</div> </div>
</div> </div>
@ -66,7 +66,8 @@
<p> <p>
Erasing introduction timestamps is only necessary after upgrading the plugin if specifically Erasing introduction timestamps is only necessary after upgrading the plugin if specifically
requested to do so in the plugin's changelog. After the timestamps are erased, run the Analyze episodes scheduled task to re-analyze all media on the server. requested to do so in the plugin's changelog. After the timestamps are erased, run the
Analyze episodes scheduled task to re-analyze all media on the server.
</p> </p>
</div> </div>
</form> </form>

View File

@ -3,8 +3,8 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace> <RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace>
<AssemblyVersion>0.1.0.0</AssemblyVersion> <AssemblyVersion>0.1.5.0</AssemblyVersion>
<FileVersion>0.1.0.0</FileVersion> <FileVersion>0.1.5.0</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>

View File

@ -51,10 +51,10 @@ public class Entrypoint : IServerEntryPoint
{ {
FPCalc.Logger = _logger; FPCalc.Logger = _logger;
// Assert that fpcalc is installed // Assert that ffmpeg with chromaprint is installed
if (!FPCalc.CheckFPCalcInstalled()) if (!FPCalc.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; return Task.CompletedTask;
} }

View File

@ -7,10 +7,12 @@ using System.IO;
using System.Text; using System.Text;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
// TODO: rename
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary> /// <summary>
/// Wrapper for the fpcalc utility. /// Wrapper for libchromaprint.
/// </summary> /// </summary>
public static class FPCalc public static class FPCalc
{ {
@ -20,16 +22,16 @@ public static class FPCalc
public static ILogger? Logger { get; set; } public static ILogger? Logger { get; set; }
/// <summary> /// <summary>
/// Check that the fpcalc utility is installed. /// Check that the installed version of ffmpeg supports chromaprint.
/// </summary> /// </summary>
/// <returns>true if fpcalc is installed, false on any error.</returns> /// <returns>true if a compatible version of ffmpeg is installed, false on any error.</returns>
public static bool CheckFPCalcInstalled() public static bool CheckFFmpegVersion()
{ {
try try
{ {
var version = GetOutput("-version", 2000).TrimEnd(); var version = Encoding.UTF8.GetString(GetOutput("-version", 2000));
Logger?.LogInformation("fpcalc -version: {Version}", version); Logger?.LogDebug("ffmpeg version: {Version}", version);
return version.StartsWith("fpcalc version", StringComparison.OrdinalIgnoreCase); return version.Contains("--enable-chromaprint", StringComparison.OrdinalIgnoreCase);
} }
catch catch
{ {
@ -44,7 +46,7 @@ public static class FPCalc
/// <returns>Numerical fingerprint points.</returns> /// <returns>Numerical fingerprint points.</returns>
public static ReadOnlyCollection<uint> Fingerprint(QueuedEpisode episode) public static ReadOnlyCollection<uint> 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<uint> cachedFingerprint)) if (LoadCachedFingerprint(episode, out ReadOnlyCollection<uint> cachedFingerprint))
{ {
Logger?.LogDebug("Fingerprint cache hit on {File}", episode.Path); Logger?.LogDebug("Fingerprint cache hit on {File}", episode.Path);
@ -53,32 +55,24 @@ public static class FPCalc
Logger?.LogDebug("Fingerprinting {Duration} seconds from {File}", episode.FingerprintDuration, episode.Path); Logger?.LogDebug("Fingerprinting {Duration} seconds from {File}", episode.FingerprintDuration, episode.Path);
// FIXME: revisit escaping var args = string.Format(
var path = "\"" + episode.Path + "\""; CultureInfo.InvariantCulture,
var duration = episode.FingerprintDuration.ToString(CultureInfo.InvariantCulture); "-i \"{0}\" -to {1} -ac 2 -f chromaprint -fp_format raw -",
var args = " -raw -length " + duration + " " + path; episode.Path,
episode.FingerprintDuration);
/* Returns output similar to the following: // Returns all fingerprint points as raw 32 bit unsigned integers (little endian).
* DURATION=123 var rawPoints = GetOutput(args);
* FINGERPRINT=123456789,987654321,123456789,987654321,123456789,987654321 if (rawPoints.Length == 0 || rawPoints.Length % 4 != 0)
*/
var raw = GetOutput(args);
var lines = raw.Split("\n");
if (lines.Length < 2)
{ {
Logger?.LogTrace("fpcalc output is {Raw}", raw); throw new FingerprintException("chromaprint output was malformed");
throw new FingerprintException("fpcalc output was malformed");
} }
// Remove the "FINGERPRINT=" prefix and split into an array of numbers.
var fingerprint = lines[1].Substring(12).Split(",");
var results = new List<uint>(); var results = new List<uint>();
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. // Try to cache this fingerprint.
@ -88,23 +82,43 @@ public static class FPCalc
} }
/// <summary> /// <summary>
/// Runs fpcalc and returns standard output. /// Runs ffmpeg and returns standard output.
/// </summary> /// </summary>
/// <param name="args">Arguments to pass to fpcalc.</param> /// <param name="args">Arguments to pass to ffmpeg.</param>
/// <param name="timeout">Timeout (in seconds) to wait for fpcalc to exit.</param> /// <param name="timeout">Timeout (in seconds) to wait for ffmpeg to exit.</param>
private static string GetOutput(string args, int timeout = 60 * 1000) private static ReadOnlySpan<byte> GetOutput(string args, int timeout = 60 * 1000)
{ {
var info = new ProcessStartInfo("fpcalc", args); var ffmpegPath = Plugin.Instance?.FFmpegPath ?? "ffmpeg";
info.CreateNoWindow = true;
info.RedirectStandardOutput = true;
var fpcalc = new Process(); var info = new ProcessStartInfo(ffmpegPath, args)
fpcalc.StartInfo = info; {
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
fpcalc.Start(); var ffmpeg = new Process
fpcalc.WaitForExit(timeout); {
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();
}
} }
/// <summary> /// <summary>

View File

@ -5,6 +5,7 @@ using System.IO;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
@ -23,7 +24,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary> /// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param> /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) /// <param name="serverConfiguration">Server configuration manager.</param>
public Plugin(
IApplicationPaths applicationPaths,
IXmlSerializer xmlSerializer,
IServerConfigurationManager serverConfiguration)
: base(applicationPaths, xmlSerializer) : base(applicationPaths, xmlSerializer)
{ {
_xmlSerializer = xmlSerializer; _xmlSerializer = xmlSerializer;
@ -37,6 +42,9 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
_introPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml"); _introPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml");
// Get the path to FFmpeg.
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
Intros = new Dictionary<Guid, Intro>(); Intros = new Dictionary<Guid, Intro>();
AnalysisQueue = new Dictionary<Guid, List<QueuedEpisode>>(); AnalysisQueue = new Dictionary<Guid, List<QueuedEpisode>>();
Instance = this; Instance = this;
@ -64,6 +72,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary> /// </summary>
public string FingerprintCachePath { get; private set; } public string FingerprintCachePath { get; private set; }
/// <summary>
/// Gets the full path to FFmpeg.
/// </summary>
public string FFmpegPath { get; private set; }
/// <inheritdoc /> /// <inheritdoc />
public override string Name => "Intro Skipper"; public override string Name => "Intro Skipper";

View File

@ -104,6 +104,12 @@ public class FingerprinterTask : IScheduledTask
var queue = Plugin.Instance!.AnalysisQueue; var queue = Plugin.Instance!.AnalysisQueue;
var totalProcessed = 0; var totalProcessed = 0;
if (queue.Count == 0)
{
throw new FingerprintException(
"No episodes to analyze: either no show libraries are defined or ffmpeg is not properly installed");
}
// TODO: make configurable // TODO: make configurable
var options = new ParallelOptions(); var options = new ParallelOptions();
options.MaxDegreeOfParallelism = 2; options.MaxDegreeOfParallelism = 2;

View File

@ -1,18 +1,16 @@
# Intro Skipper (ALPHA) # Intro Skipper (beta)
<div align="center"> <div align="center">
<img alt="Plugin Banner" src="https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/master/images/logo.png" /> <img alt="Plugin Banner" src="https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/master/images/logo.png" />
</div> </div>
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) ![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. 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.
This plugin **will not work** without installing `fpcalc`. The easiest way to do this is to follow the steps below.
## Introduction requirements ## 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 * Located within the first 25% of an episode, or the first 10 minutes, whichever is smaller
* At least 20 seconds long * 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. 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**. 1. If you reuse the configuration data from another container, **make sure to create a backup first**.
2. Follow the plugin installation steps below 2. Follow the plugin installation steps below

10
docker/Dockerfile.ffmpeg5 Normal file
View File

@ -0,0 +1,10 @@
FROM jellyfin/jellyfin:10.8.0-beta3
RUN curl -Lo jellyfin-ffmpeg5.deb \
https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v5.0.1-5/jellyfin-ffmpeg5_5.0.1-5-bullseye_amd64.deb
RUN apt update
RUN apt install -y libxcb-randr0
RUN dpkg -i jellyfin-ffmpeg5.deb
COPY dist/ /jellyfin/jellyfin-web/

View File

@ -4,3 +4,8 @@ Build instructions for the `ghcr.io/confusedpolarbear/jellyfin-intro-skipper` co
2. Run `npm run build:production` 2. Run `npm run build:production`
3. Copy the `dist` folder into this folder 3. Copy the `dist` folder into this folder
4. Run `docker build .` 4. Run `docker build .`
## `Dockerfile.ffmpeg5` testing instructions
1. Follow steps 1 - 3 above (only needed if you don't use the automatic skip feature)
2. Run `docker build . -t jellyfin-ffmpeg5 -f Dockerfile.ffmpeg5`

View File

@ -2,15 +2,12 @@
## Requirements ## 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 * 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 ## Instructions
1. Install the `fpcalc` program 1. Install the latest version of ffmpeg from https://github.com/jellyfin/jellyfin-ffmpeg/releases
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)
2. Download the latest modified web interface from the releases tab and either: 2. Download the latest modified web interface from the releases tab and either:
1. Serve the web interface directly from your Jellyfin server, or 1. Serve the web interface directly from your Jellyfin server, or
2. Serve the web interface using an external web server 2. Serve the web interface using an external web server