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;