aede_core/
acoustid.rs

1//! AcoustID: what a recording is, asked of its sound.
2//!
3//! Deliberately **no network**, like [`crate::musicbrainz`] and
4//! [`crate::coverart`]: this module builds the address to ask and reads the
5//! answer somebody else fetched. Reaching it is the client's job.
6//!
7//! # What it answers, and what it cannot
8//!
9//! Shown a fingerprint and a length, AcoustID answers with **recordings** —
10//! MusicBrainz recording identifiers, with a score saying how sure it is. A
11//! recording is a performance, not a release: the same recording appears on an
12//! album, a compilation and a deluxe reissue, so the answer identifies *what
13//! is playing* and not *which pressing this file was ripped from*.
14//!
15//! That is the right answer for the question this feature exists to ask —
16//! "what is this untagged file?" — and the wrong one for "which edition do I
17//! own", which the tags answer better than any fingerprint could.
18//!
19//! # The score is kept, and it is not a certainty
20//!
21//! Everything here arrives as [`crate::sources::Confidence::Matched`], never
22//! `Identified`. A fingerprint match is a strong guess about a performance and
23//! is wrong in ways that are easy to picture: two masterings of one recording
24//! fingerprint alike, a live version and its studio original sometimes do, and
25//! a silent or very short track matches a great deal. The score comes back
26//! with the record so that a reader can see what they are being told.
27//!
28//! # It identifies; it never corrects
29//!
30//! Nothing here writes a tag, because nothing in Aède writes into an audio
31//! file. What the service says is filed **beside** the tags, in the attributed
32//! layer, and shown next to them — so a file whose audio and tags disagree is
33//! reported rather than quietly rewritten. Retagging is the user's tagger's
34//! job, and this is not one.
35
36use crate::json::Json;
37use crate::url::encode_component;
38
39/// The name records from this service carry in `sources.json`.
40pub const SOURCE: &str = "acoustid";
41
42/// Base address of the lookup endpoint.
43pub const WEB_SERVICE: &str = "https://api.acoustid.org/v2/lookup";
44
45/// The environment variable holding the application key.
46///
47/// AcoustID requires one and Aède ships none, for a reason worth stating:
48/// a key baked into an open-source binary is a key every copy of the program
49/// shares, and the quota with it. Registering takes a minute and the key is
50/// free — [`no_key`] says where.
51pub const KEY_VARIABLE: &str = "AEDE_ACOUSTID_KEY";
52
53/// The key this machine holds, when it holds one.
54///
55/// **Read once, at the edge, and carried in.** It used to be read inside the
56/// pass, from the process environment — which made the pass untestable by
57/// construction: the environment is one variable shared by every thread, and
58/// `cargo test` runs tests on threads. Four tests set and unset it around each
59/// other, and which one won depended on the interleaving: green on a machine
60/// with no key, red on a machine that had one. A value a function reads from
61/// the process rather than from its caller cannot be given to it.
62///
63/// The trimming and the empty check live here rather than at the call site, so
64/// that `AEDE_ACOUSTID_KEY=""` and no variable at all mean the same thing
65/// wherever the question is asked.
66pub fn key() -> Option<String> {
67    std::env::var(KEY_VARIABLE)
68        .ok()
69        .map(|value| value.trim().to_string())
70        .filter(|value| !value.is_empty())
71}
72
73/// The minimum recommended wait between two AcoustID requests.
74///
75/// The service asks for no more than three per second. A 350 ms interval stays
76/// under that limit; the CLI currently uses the slower shared MusicBrainz
77/// throttle so a combined fetch remains safe for every service it reaches.
78pub const REQUEST_INTERVAL: std::time::Duration = std::time::Duration::from_millis(350);
79
80/// What the answer must contain to be worth parsing.
81///
82/// Without `meta` the service answers with its own track identifiers and
83/// nothing else — true, useless, and indistinguishable at a glance from a
84/// full answer. `recordings` brings the MusicBrainz identifier, the title and
85/// the artists; `releasegroups` brings the album, which is what makes the
86/// answer readable next to a tag.
87const META: &str = "recordings+releasegroups";
88
89/// Where to ask about one fingerprint.
90///
91/// The fingerprint is base64 and carries `+` and `/`, both of which mean
92/// something else in a query string, so it is escaped — a fingerprint sent raw
93/// comes back as "not found" for a file the service knows perfectly well.
94pub fn lookup_url(key: &str, fingerprint: &str, seconds: u32) -> String {
95    format!(
96        "{WEB_SERVICE}?client={}&meta={META}&duration={seconds}&fingerprint={}",
97        encode_component(key),
98        encode_component(fingerprint)
99    )
100}
101
102/// What the service says one file is.
103#[derive(Debug, Clone, PartialEq)]
104pub struct Heard {
105    /// The MusicBrainz **recording** identifier — a performance, not a release.
106    pub recording: String,
107    /// How sure the service is, from 0 to 1, as it stated it.
108    pub score: f64,
109    /// The recording's title, when the answer carries one.
110    pub title: Option<String>,
111    /// The artists credited, in the order given.
112    pub artists: Vec<String>,
113    /// A release group the recording appears on, when one is named.
114    ///
115    /// One of possibly many, and it is not "the album this file came from":
116    /// a recording sits on the original, the compilation and the reissue
117    /// alike. It is here to make the answer readable, not to be believed
118    /// over a tag.
119    pub album: Option<String>,
120}
121
122/// The best thing the service said, or nothing.
123///
124/// **Highest score wins, and a result with no recording is not an answer.**
125/// The service replies with its own track identifiers even when it knows
126/// nothing else about them, and a caller that took the first result would file
127/// a record naming a track nobody can look up.
128///
129/// Ties are broken by the recording identifier so that two runs over an
130/// unchanged file answer the same thing — a match that changes between runs
131/// cannot be compared with the one stored last time.
132/// Malformed statuses and absent, non-finite or out-of-range scores cannot
133/// identify a recording. No service failure is returned as a match.
134pub fn best(response: &Json) -> Option<Heard> {
135    if response.get("status").and_then(Json::as_str) != Some("ok") {
136        return None;
137    }
138    let mut found: Option<Heard> = None;
139    for result in response.get("results").and_then(Json::as_arr)? {
140        let Some(score) = result
141            .field_f64("score")
142            .filter(|score| score.is_finite() && (0.0..=1.0).contains(score))
143        else {
144            continue;
145        };
146        let Some(recordings) = result.get("recordings").and_then(Json::as_arr) else {
147            continue;
148        };
149        for recording in recordings {
150            let Some(id) = recording.field_str("id").filter(|id| !id.trim().is_empty()) else {
151                continue;
152            };
153            if found.as_ref().is_some_and(|current| {
154                current.score > score || (current.score == score && current.recording <= id)
155            }) {
156                continue;
157            }
158            found = Some(Heard {
159                recording: id,
160                score,
161                title: recording.field_str("title").filter(|t| !t.is_empty()),
162                artists: recording
163                    .get("artists")
164                    .and_then(Json::as_arr)
165                    .map(|list| {
166                        list.iter()
167                            .filter_map(|a| a.field_str("name").filter(|name| !name.is_empty()))
168                            .collect()
169                    })
170                    .unwrap_or_default(),
171                album: recording
172                    .get("releasegroups")
173                    .and_then(Json::as_arr)
174                    .and_then(|list| list.first())
175                    .and_then(|group| group.field_str("title")),
176            });
177        }
178    }
179    found
180}
181
182/// `true` when the service reported a failure rather than an empty answer.
183///
184/// `status` is `ok` or `error`, and an error carries a message worth showing —
185/// a bad key answers `200 OK` with `"status": "error"`, which a client
186/// reading only the HTTP code would file as "this file is unknown" for every
187/// file in the library.
188/// Only `ok` with a result array is a successful lookup; an empty array is a
189/// genuine miss, while a missing or malformed array is a failed answer.
190/// Recording metadata is optional, but a present recording table must contain
191/// identified recordings. An unreadable table is not a negative identification.
192pub fn refused(response: &Json) -> Option<String> {
193    match response.field_str("status").as_deref() {
194        Some("ok") => {
195            let Some(results) = response.get("results").and_then(Json::as_arr) else {
196                return Some("the service returned no valid results array".to_string());
197            };
198            if results.iter().any(|result| {
199                result
200                    .get("id")
201                    .and_then(Json::as_str)
202                    .is_none_or(|id| id.trim().is_empty())
203                    || result
204                        .field_f64("score")
205                        .is_none_or(|score| !score.is_finite() || !(0.0..=1.0).contains(&score))
206            }) {
207                Some(
208                    "the service returned a result without a valid track identifier or score"
209                        .to_string(),
210                )
211            } else if results.iter().any(|result| {
212                result.get("recordings").is_some_and(|recordings| {
213                    recordings.as_arr().is_none_or(|recordings| {
214                        recordings.iter().any(|recording| {
215                            recording
216                                .get("id")
217                                .and_then(Json::as_str)
218                                .is_none_or(|id| id.trim().is_empty())
219                        })
220                    })
221                })
222            }) {
223                Some("the service returned invalid recording metadata".to_string())
224            } else {
225                None
226            }
227        }
228        _ => Some(
229            response
230                .get("error")
231                .and_then(|e| e.field_str("message"))
232                .unwrap_or_else(|| "the service did not say it was ok".to_string()),
233        ),
234    }
235}
236
237/// What to tell somebody who has no application key.
238///
239/// The address is given, and so is the reason there is no key in the program:
240/// a reader who is told to "set AEDE_ACOUSTID_KEY" and nothing else has been
241/// handed a chore with no explanation.
242pub fn no_key() -> String {
243    format!(
244        "\
245Identifying by sound needs an AcoustID application key, and none is set.
246
247AcoustID is free and asks every program using it to register, so that a
248misbehaving one can be stopped without stopping everyone. Aède ships no key
249of its own on purpose: a key inside an open-source program is a key every
250copy shares, and the quota with it.
251
252  1. Register at https://acoustid.org/new-application
253  2. export {KEY_VARIABLE}=<the key it gives you>
254
255Nothing else in Aède needs it."
256    )
257}
258
259#[cfg(test)]
260#[path = "acoustid_tests.rs"]
261mod tests;