aede_core/fingerprint.rs
1//! The acoustic fingerprint of a file: what the audio *is*, not what it says.
2//!
3//! # Why this exists
4//!
5//! Every other identifier in this program comes out of the tags, and a badly
6//! tagged file therefore cannot be identified at all: `track03.mp3` with no
7//! title and no artist is invisible to MusicBrainz, because there is nothing
8//! to ask about. A fingerprint is computed from the **decoded audio**, so it
9//! answers for a file whose tags say nothing, and it disagrees with a file
10//! whose tags say the wrong thing.
11//!
12//! # It is computed, not fetched — and stored beside the checksum
13//!
14//! A fingerprint is Aède's own conclusion about the bytes, like
15//! [`crate::audit::integrity`] and unlike anything in `sources.json`: nobody
16//! told us, we worked it out. So it lives on [`crate::model::AudioFile`] and
17//! is carried across a rescan by the same size-and-mtime rule the integrity
18//! verdict uses. What **AcoustID** answers when shown one is a different
19//! thing entirely, and goes in the attributed layer where every other
20//! source's claim goes.
21//!
22//! # Two ways to compute one, and why ffmpeg comes first
23//!
24//! Chromaprint is the algorithm; there are two ordinary ways to run it.
25//!
26//! - **ffmpeg**, *when* it was built with `--enable-chromaprint`. Debian and
27//! Ubuntu build it that way, so on those machines this is no new dependency
28//! at all — Aède already requires ffmpeg for `spectrum` and
29//! `copy --compress`.
30//! - **`fpcalc`**, the tool Chromaprint itself ships. Canonical, and the one
31//! AcoustID's own documentation names.
32//!
33//! **Homebrew's ffmpeg does not have chromaprint**, which this module's first
34//! version claimed it did — a guess written as a fact, corrected by a reader
35//! pasting the `configuration:` line of a fresh `brew install ffmpeg`. On
36//! macOS the answer is `brew install chromaprint`, and it is a small package.
37//! The lesson is not about ffmpeg: **a build option is a property of somebody
38//! else's packaging decision, and this program has no business asserting one
39//! it has not seen.** So the availability check asks the muxer list rather
40//! than reading a version string, and the message names both ways out with
41//! the right one first for each platform.
42//!
43//! **The two agree on the whole input, not just a preview.** `fpcalc` normally
44//! processes only the first 120 seconds; `-length 0` removes that limit, matching
45//! ffmpeg's complete-input path. On a short file its ordinary default agrees
46//! with `ffmpeg -algorithm 1`, byte for byte. `fpcalc -algorithm 1` is a
47//! *different* algorithm and answers something else — the measurement is beside
48//! the `ALGORITHM` constant below.
49//!
50//! ffmpeg is tried first because it is more likely to be there already; the
51//! fallback exists because "built with chromaprint" is not a promise anyone
52//! made. **Neither is linked or vendored**, exactly as ffmpeg is not: a
53//! checkout with neither installed builds, passes its tests, and says plainly
54//! what is missing.
55//!
56//! # The duration is part of the question
57//!
58//! AcoustID is asked for a fingerprint *and* a length, and refuses without
59//! both — the length is how it tells a full track from an excerpt that
60//! fingerprints alike. `fpcalc` prints it; ffmpeg's muxer does not, so the
61//! caller supplies it from the catalog, which read it out of the file's own
62//! header at scan time.
63
64use std::process::Command;
65
66/// What one file's audio amounts to, for a service that identifies audio.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Fingerprint {
69 /// The base64 compressed fingerprint, as both tools emit it.
70 pub data: String,
71 /// The track's length in whole seconds, which the lookup also needs.
72 pub seconds: u32,
73}
74
75/// Which program produced a fingerprint, for a message to name.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum By {
78 /// ffmpeg's `chromaprint` muxer.
79 Ffmpeg,
80 /// Chromaprint's own `fpcalc`.
81 Fpcalc,
82}
83
84impl By {
85 /// The program's name, as somebody would type it.
86 pub fn program(self) -> &'static str {
87 match self {
88 By::Ffmpeg => "ffmpeg",
89 By::Fpcalc => "fpcalc",
90 }
91 }
92}
93
94/// The algorithm AcoustID's index is built on.
95///
96/// Chromaprint has three, and a fingerprint computed with the wrong one is not
97/// wrong-looking — it is a valid fingerprint that matches nothing, which is
98/// indistinguishable from a recording nobody has ever submitted. `fpcalc`
99/// defaults to it; ffmpeg's muxer numbers the same list from zero, so the
100/// second entry is the one, and it is passed explicitly rather than trusted to
101/// stay the default.
102///
103/// **The two programs number the list differently, and it is measured rather
104/// than assumed.** On one file, ffmpeg 1.5.1 and fpcalc 1.5.1:
105///
106/// ```text
107/// ffmpeg -algorithm 1 AQAAS0mUaEkSRZEAAA… ← what Aède computes
108/// fpcalc AQAAS0mUaEkSRZEAAA… ← identical, its default
109/// fpcalc -algorithm 1 AAAAS4kUSUqSJEkWAA… ← a different algorithm
110/// ffmpeg -algorithm 2 AgAADUmUaEkSRZEAAA…
111/// ```
112///
113/// So ffmpeg's number *is* the version byte the fingerprint begins with, and
114/// fpcalc's is that plus one. Aède's ffmpeg path passes `1` and its fpcalc path
115/// leaves the algorithm at its default, while `-length 0` processes the whole
116/// input. Both choices are needed for the paths to agree on long files too.
117/// Anything that tells a reader to compare with `fpcalc` must name the complete
118/// input and must not tell them to add `-algorithm 1`.
119const ALGORITHM: &str = "1";
120
121/// Whether either program is available, and which.
122///
123/// Looked for once per run by the caller, never once per file: a thousand
124/// tracks would otherwise mean a thousand failed lookups before the first
125/// fingerprint. The same reasoning, and the same shape, as
126/// [`crate::ffmpeg::find`].
127pub fn find() -> Option<By> {
128 if has_chromaprint() {
129 return Some(By::Ffmpeg);
130 }
131 has_fpcalc().then_some(By::Fpcalc)
132}
133
134/// `true` when Chromaprint's own `fpcalc` is on the path.
135///
136/// Split out of [`find`] so that a test can ask the question without being told
137/// the answer `find` prefers: it returns ffmpeg whenever ffmpeg will do, which
138/// is right for a run and useless to a test whose whole subject is whether the
139/// two agree.
140fn has_fpcalc() -> bool {
141 Command::new("fpcalc")
142 .arg("-version")
143 .stdout(std::process::Stdio::null())
144 .stderr(std::process::Stdio::null())
145 .status()
146 .is_ok_and(|status| status.success())
147}
148
149/// `true` when the ffmpeg on this machine can produce a fingerprint.
150///
151/// Asked of its muxer list rather than of its version string: a build says
152/// `--enable-chromaprint` in its configuration line only when it was compiled
153/// from source with that flag showing, while the muxer list is what it can
154/// actually do.
155fn has_chromaprint() -> bool {
156 let Some(ffmpeg) = crate::ffmpeg::find() else {
157 return false;
158 };
159 Command::new(ffmpeg)
160 .args(["-hide_banner", "-muxers"])
161 .output()
162 .is_ok_and(|out| String::from_utf8_lossy(&out.stdout).contains("chromaprint"))
163}
164
165/// Computes the fingerprint of one file.
166///
167/// `seconds` is the length the catalog holds, used only on the ffmpeg path,
168/// which emits no duration of its own. `fpcalc` states one and its answer
169/// wins: it measured what it actually decoded, and a header that disagrees
170/// with the stream is exactly the kind of file this whole feature exists for.
171///
172/// The path must resolve to a regular local file. It is canonicalized before
173/// invoking either helper, so relative names cannot become options or URLs.
174/// Directories, devices and other special files are refused before a helper
175/// can block reading them.
176pub fn of(by: By, path: &std::path::Path, seconds: u32) -> Result<Fingerprint, String> {
177 let path = local_file(path)?;
178 match by {
179 By::Ffmpeg => {
180 let ffmpeg = crate::ffmpeg::find().ok_or_else(|| "ffmpeg is gone".to_string())?;
181 let out = Command::new(ffmpeg)
182 .args(["-hide_banner", "-loglevel", "error", "-i"])
183 .arg(&path)
184 .args(["-f", "chromaprint", "-fp_format", "base64"])
185 .args(["-algorithm", ALGORITHM])
186 .arg("-")
187 .output()
188 .map_err(|e| format!("ffmpeg could not be run: {e}"))?;
189 if !out.status.success() {
190 return Err(said(&out.stderr));
191 }
192 read_ffmpeg(&String::from_utf8_lossy(&out.stdout), seconds)
193 }
194 By::Fpcalc => {
195 let out = Command::new("fpcalc")
196 .args(["-length", "0"])
197 .arg(&path)
198 .output()
199 .map_err(|e| format!("fpcalc could not be run: {e}"))?;
200 if !out.status.success() {
201 return Err(said(&out.stderr));
202 }
203 read_fpcalc(&String::from_utf8_lossy(&out.stdout))
204 }
205 }
206}
207
208fn local_file(path: &std::path::Path) -> Result<std::path::PathBuf, String> {
209 let fail = |detail: &dyn std::fmt::Display| {
210 format!(
211 "a local regular file is required for fingerprinting: {}: {detail}",
212 path.display()
213 )
214 };
215 let resolved = std::fs::canonicalize(path).map_err(|error| fail(&error))?;
216 crate::copy::validate_source(&resolved).map_err(|error| fail(&error))?;
217 Ok(resolved)
218}
219
220/// The last thing a failed program said, for a message worth reading.
221fn said(stderr: &[u8]) -> String {
222 let text = String::from_utf8_lossy(stderr);
223 text.lines()
224 .rfind(|line| !line.trim().is_empty())
225 .unwrap_or("it failed without saying why")
226 .to_string()
227}
228
229/// Reads what ffmpeg's chromaprint muxer wrote to its output.
230///
231/// Split out so the parsing is tested without running anything: the muxer
232/// writes the base64 fingerprint and nothing else, and the empty answer — a
233/// file too short to fingerprint, or one that decoded to silence — has to be
234/// a refusal rather than an empty fingerprint sent to a service that would
235/// match it against everything.
236pub fn read_ffmpeg(stdout: &str, seconds: u32) -> Result<Fingerprint, String> {
237 let data = stdout.trim().to_string();
238 usable(data, seconds)
239}
240
241/// Reads `fpcalc`'s two lines: `DURATION=` and `FINGERPRINT=`.
242///
243/// The duration it states is preferred over the caller's, because it is the
244/// length of what was decoded rather than the length a header claims.
245/// Decimal fractions are truncated to whole seconds; malformed fractions,
246/// signs and values outside `u32` are refused instead of accepting a prefix.
247pub fn read_fpcalc(stdout: &str) -> Result<Fingerprint, String> {
248 let mut data = String::new();
249 let mut seconds = 0u32;
250 for line in stdout.lines() {
251 match line.split_once('=') {
252 Some(("FINGERPRINT", value)) => data = value.trim().to_string(),
253 Some(("DURATION", value)) => {
254 seconds = parse_seconds(value.trim())?;
255 }
256 _ => {}
257 }
258 }
259 usable(data, seconds)
260}
261
262fn parse_seconds(value: &str) -> Result<u32, String> {
263 let (whole, fraction) = value
264 .split_once('.')
265 .map_or((value, None), |(whole, fraction)| (whole, Some(fraction)));
266 if whole.is_empty()
267 || !whole.bytes().all(|byte| byte.is_ascii_digit())
268 || fraction
269 .is_some_and(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
270 {
271 return Err("fpcalc returned an invalid duration".to_string());
272 }
273 whole
274 .parse()
275 .map_err(|_| "fpcalc returned a duration outside the supported range".to_string())
276}
277
278/// The one gate both readers pass through.
279///
280/// An empty fingerprint and a zero length are each refused, because either
281/// makes a lookup that cannot mean anything: AcoustID needs both, and sending
282/// a blank one asks the service to match silence against its whole index.
283fn usable(data: String, seconds: u32) -> Result<Fingerprint, String> {
284 if data.is_empty() {
285 return Err("no fingerprint came back: the audio may be too short".to_string());
286 }
287 if seconds == 0 {
288 return Err("no duration is known for this file, and a lookup needs one".to_string());
289 }
290 Ok(Fingerprint { data, seconds })
291}
292
293/// What to tell somebody who has neither program, worded so they can act.
294///
295/// Both ways out are named, and which one is likelier is said, because "no
296/// fingerprinting tool was found" leaves a reader to guess that ffmpeg — which
297/// they already have for the spectrograms — might be the answer.
298pub fn missing() -> String {
299 "\
300Fingerprinting needs Chromaprint, and no way to run it was found.
301
302Two programs can do it, and Aède ships neither.
303
304 macOS brew install chromaprint
305 Homebrew's ffmpeg is built without chromaprint, so the ffmpeg
306 you already have for the spectrograms cannot do this one.
307
308 Debian apt install ffmpeg
309 Ubuntu — theirs is built with chromaprint, so this is enough. Failing
310 that, apt install libchromaprint-tools.
311
312Everything else in Aède works without either. Run \"aede fingerprint\" again
313once one of them is on your PATH."
314 .to_string()
315}
316
317#[cfg(test)]
318#[path = "fingerprint_tests.rs"]
319mod tests;