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;