using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Text; using Microsoft.Extensions.Logging; namespace ConfusedPolarBear.Plugin.IntroSkipper; /// /// Wrapper for libchromaprint. /// public static class Chromaprint { private static bool _loggedVersionInformation; /// /// Gets or sets the logger. /// public static ILogger? Logger { get; set; } /// /// Check that the installed version of ffmpeg supports chromaprint. /// /// true if a compatible version of ffmpeg is installed, false on any error. public static bool CheckFFmpegVersion() { try { // Log the output of "ffmpeg -version" at the first call to this function if (!_loggedVersionInformation) { _loggedVersionInformation = true; var version = Encoding.UTF8.GetString(GetOutput("-version", 2000)); Logger?.LogDebug("ffmpeg version information: {Version}", version); } // First, validate that the installed version of ffmpeg supports chromaprint at all. var muxers = Encoding.UTF8.GetString(GetOutput("-muxers", 2000)); Logger?.LogTrace("ffmpeg muxers: {Muxers}", muxers); if (!muxers.Contains("chromaprint", StringComparison.OrdinalIgnoreCase)) { Logger?.LogError("The installed version of ffmpeg does not support chromaprint"); return false; } // Second, validate that ffmpeg understands the "-fp_format raw" option. var muxerHelp = Encoding.UTF8.GetString(GetOutput("-h muxer=chromaprint", 2000)); Logger?.LogTrace("ffmpeg chromaprint help: {MuxerHelp}", muxerHelp); if (!muxerHelp.Contains("-fp_format", StringComparison.OrdinalIgnoreCase)) { Logger?.LogError("The installed version of ffmpeg does not support the -fp_format flag"); return false; } else if (!muxerHelp.Contains("binary raw fingerprint", StringComparison.OrdinalIgnoreCase)) { Logger?.LogError("The installed version of ffmpeg does not support raw binary fingerprints"); return false; } Logger?.LogDebug("Installed version of ffmpeg meets fingerprinting requirements"); return true; } catch { return false; } } /// /// Fingerprint a queued episode. /// /// Queued episode to fingerprint. /// Numerical fingerprint points. public static uint[] Fingerprint(QueuedEpisode episode) { // Try to load this episode from cache before running ffmpeg. if (LoadCachedFingerprint(episode, out uint[] cachedFingerprint)) { Logger?.LogTrace("Fingerprint cache hit on {File}", episode.Path); return cachedFingerprint; } Logger?.LogDebug( "Fingerprinting {Duration} seconds from \"{File}\" (length {Length}, id {Id})", episode.FingerprintDuration, episode.Path, episode.Path.Length, episode.EpisodeId); var args = string.Format( CultureInfo.InvariantCulture, "-i \"{0}\" -to {1} -ac 2 -f chromaprint -fp_format raw -", episode.Path, episode.FingerprintDuration); // 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?.LogWarning("Chromaprint returned {Count} points for \"{Path}\"", rawPoints.Length, episode.Path); throw new FingerprintException("chromaprint output for \"" + episode.Path + "\" was malformed"); } var results = new List(); for (var i = 0; i < rawPoints.Length; i += 4) { var rawPoint = rawPoints.Slice(i, 4); results.Add(BitConverter.ToUInt32(rawPoint)); } // Try to cache this fingerprint. CacheFingerprint(episode, results); return results.ToArray(); } /// /// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at. /// /// Chromaprint fingerprint. /// Inverted index. public static Dictionary CreateInvertedIndex(uint[] fingerprint) { var invIndex = new Dictionary(); for (int i = 0; i < fingerprint.Length; i++) { // Get the current point. var point = fingerprint[i]; // Append the current sample's timecode to the collection for this point. invIndex[point] = i; } return invIndex; } /// /// Runs ffmpeg and returns standard output. /// /// 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 ffmpegPath = Plugin.Instance?.FFmpegPath ?? "ffmpeg"; // Prepend some flags to prevent FFmpeg from logging it's banner and progress information // for each file that is fingerprinted. var info = new ProcessStartInfo(ffmpegPath, args.Insert(0, "-hide_banner -loglevel warning ")) { WindowStyle = ProcessWindowStyle.Hidden, CreateNoWindow = true, UseShellExecute = false, ErrorDialog = false, // We only consume standardOutput. RedirectStandardOutput = true, RedirectStandardError = false }; var ffmpeg = new Process { StartInfo = info }; Logger?.LogDebug("Starting ffmpeg with the following arguments: {Arguments}", ffmpeg.StartInfo.Arguments); ffmpeg.Start(); 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); ffmpeg.WaitForExit(timeout); return ms.ToArray().AsSpan(); } } /// /// Tries to load an episode's fingerprint from cache. If caching is not enabled, calling this function is a no-op. /// /// Episode to try to load from cache. /// Array to store the fingerprint in. /// true if the episode was successfully loaded from cache, false on any other error. private static bool LoadCachedFingerprint(QueuedEpisode episode, out uint[] fingerprint) { fingerprint = Array.Empty(); // If fingerprint caching isn't enabled, don't try to load anything. if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false)) { return false; } var path = GetFingerprintCachePath(episode); // If this episode isn't cached, bail out. if (!File.Exists(path)) { return false; } // TODO: make async var raw = File.ReadAllLines(path, Encoding.UTF8); var result = new List(); // Read each stringified uint. result.EnsureCapacity(raw.Length); try { foreach (var rawNumber in raw) { result.Add(Convert.ToUInt32(rawNumber, CultureInfo.InvariantCulture)); } } catch (FormatException) { // Occurs when the cached fingerprint is corrupt. Logger?.LogDebug( "Cached fingerprint for {Path} ({Id}) is corrupt, ignoring cache", episode.Path, episode.EpisodeId); return false; } fingerprint = result.ToArray(); return true; } /// /// Cache an episode's fingerprint to disk. If caching is not enabled, calling this function is a no-op. /// /// Episode to store in cache. /// Fingerprint of the episode to store. private static void CacheFingerprint(QueuedEpisode episode, List fingerprint) { // Bail out if caching isn't enabled. if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false)) { return; } // Stringify each data point. var lines = new List(); foreach (var number in fingerprint) { lines.Add(number.ToString(CultureInfo.InvariantCulture)); } // Cache the episode. File.WriteAllLinesAsync(GetFingerprintCachePath(episode), lines, Encoding.UTF8).ConfigureAwait(false); } /// /// Determines the path an episode should be cached at. /// /// Episode. private static string GetFingerprintCachePath(QueuedEpisode episode) { return Path.Join(Plugin.Instance!.FingerprintCachePath, episode.EpisodeId.ToString("N")); } }