using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Globalization; using System.IO; using System.Text; using Microsoft.Extensions.Logging; namespace ConfusedPolarBear.Plugin.IntroSkipper; /// /// Wrapper for the fpcalc utility. /// public static class FPCalc { /// /// Gets or sets the logger. /// public static ILogger? Logger { get; set; } /// /// Check that the fpcalc utility is installed. /// /// true if fpcalc is installed, false on any error. public static bool CheckFPCalcInstalled() { try { var version = GetOutput("-version", 2000).TrimEnd(); Logger?.LogInformation("fpcalc -version: {Version}", version); return version.StartsWith("fpcalc version", StringComparison.OrdinalIgnoreCase); } catch { return false; } } /// /// Fingerprint a queued episode. /// /// Queued episode to fingerprint. /// Numerical fingerprint points. public static ReadOnlyCollection Fingerprint(QueuedEpisode episode) { // Try to load this episode from cache before running fpcalc. if (LoadCachedFingerprint(episode, out ReadOnlyCollection cachedFingerprint)) { Logger?.LogDebug("Fingerprint cache hit on {File}", episode.Path); return cachedFingerprint; } 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; /* 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) { Logger?.LogTrace("fpcalc output is {Raw}", raw); 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(); foreach (var rawNumber in fingerprint) { results.Add(Convert.ToUInt32(rawNumber, CultureInfo.InvariantCulture)); } // Try to cache this fingerprint. CacheFingerprint(episode, results); return results.AsReadOnly(); } /// /// Runs fpcalc 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) { var info = new ProcessStartInfo("fpcalc", args); info.CreateNoWindow = true; info.RedirectStandardOutput = true; var fpcalc = new Process(); fpcalc.StartInfo = info; fpcalc.Start(); fpcalc.WaitForExit(timeout); return fpcalc.StandardOutput.ReadToEnd(); } /// /// 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. /// ReadOnlyCollection 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 ReadOnlyCollection fingerprint) { fingerprint = new List().AsReadOnly(); // 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); foreach (var rawNumber in raw) { result.Add(Convert.ToUInt32(rawNumber, CultureInfo.InvariantCulture)); } fingerprint = result.AsReadOnly(); 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")); } }