aede_core/musicbrainz.rs
1//! Reading what MusicBrainz answers, and deciding whether it is about us.
2//!
3//! Deliberately **no network**. This module turns a response that somebody
4//! else fetched into [`crate::sources`] records, and decides how firmly one of
5//! them is attached to an entity of the catalog. Those two jobs are where the
6//! mistakes live — a misread field, a wrong album picked confidently — and
7//! neither of them has any reason to need a socket to be tested.
8//!
9//! The field spellings come from the MusicBrainz documentation and are quoted
10//! exactly: `sort-name`, `life-span.begin`, `primary-type`, `secondary-types`,
11//! `first-release-date`, `label-info[].label.name`. Hyphens, not underscores —
12//! guessing one of those wrong produces a record that is silently empty.
13
14use crate::json::Json;
15use crate::model::CreditAttribute;
16use crate::sources::{
17 ArtistFacts, Confidence, CreditLink, LabelFacts, Membership, ReleaseFacts, Side, TrackFacts,
18 WorkLink,
19};
20use crate::text;
21
22/// Base address of the web service, kept here so the client has nothing to
23/// decide.
24pub const WEB_SERVICE: &str = "https://musicbrainz.org/ws/2";
25
26/// Admit an identifier as one URL path component; no name, query or external authority is accepted.
27pub fn valid_id(id: &str) -> bool {
28 id.len() == 36
29 && id.bytes().enumerate().all(|(index, byte)| {
30 if [8, 13, 18, 23].contains(&index) {
31 byte == b'-'
32 } else {
33 byte.is_ascii_hexdigit()
34 }
35 })
36}
37
38/// The gap the service requires between two requests, with a small margin.
39///
40/// One request per second per address is the documented limit, and going over
41/// it does not slow a run down — it makes the service answer `503` to
42/// *everything* from that address until the rate drops, for every program
43/// sharing it. The margin is there because the clock that decides is the
44/// server's, not ours.
45///
46/// It lives here rather than in the client because it is a property of
47/// MusicBrainz, not of how we talk to it: a build without the network feature
48/// still needs it to say how long a run would take.
49pub const REQUEST_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1100);
50
51/// What to ask for alongside an artist, in one request.
52///
53/// The whole point of a lookup over a search: these ride on the request
54/// already being made, so genres, other names and the links an artist has —
55/// Wikidata among them — cost nothing extra at a service that allows one
56/// request per second.
57///
58/// `artist-rels` carries the dated memberships — who played in a band and
59/// between which years — read by `memberships` below. It rides on the same request
60/// as the rest, so a line-up costs nothing beyond the lookup already being
61/// made, and the dates are what let an album page name the band as it stood
62/// the year that record came out.
63pub const ARTIST_INCLUDES: &str = "genres+tags+aliases+url-rels+artist-rels";
64
65/// URL relationships include the Wikidata link used by label summaries.
66pub const LABEL_INCLUDES: &str = "url-rels";
67
68/// What a recording lookup needs for performance, production and composition
69/// credits in a single request.
70///
71/// `work-level-rels` is a switch: `work-rels` first attaches each work and
72/// `artist-rels` then asks for artist relationships both on the recording and
73/// on those linked works. `work-rels` also exposes explicit parent-work
74/// relationships on linked works, including classical movements.
75pub const RECORDING_INCLUDES: &str = "artist-rels+work-rels+work-level-rels";
76
77/// What to ask for alongside a *release*, in one request.
78///
79/// A release is one edition; a release group is the album every edition of it
80/// belongs to. The two facts a library wants sit on opposite sides of that
81/// line — the **label** is the edition's, the **type and first release date**
82/// are the album's — and asking for them separately would be two requests per
83/// album at one request per second.
84///
85/// `release-groups` folds the album into the edition's answer, so a library
86/// tagged by Picard, which writes the edition identifier, gets the whole
87/// record for the price of one lookup.
88pub const RELEASE_INCLUDES: &str = "labels+release-groups+artist-rels";
89
90/// One answer among the several a search returns.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Candidate<F> {
93 /// The MusicBrainz identifier, which is what makes a later fetch an update
94 /// rather than a second opinion.
95 pub mbid: String,
96 /// Name or title as MusicBrainz spells it, for the reader to judge.
97 pub name: String,
98 /// Whatever the search decided about relevance, 0 to 100.
99 ///
100 /// **Not a probability, and not a confidence.** It says how well the query
101 /// matched the index, so a library holding one album by an obscure band
102 /// gets a 100 for the only thing that answered. It is one input to
103 /// [`best_match`], never the verdict.
104 pub score: u8,
105 /// What was said, ready to be stored.
106 pub facts: F,
107}
108
109/// Why nothing was attached, which is worth saying rather than returning
110/// `None` and letting the caller invent a reason.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum NoMatch {
113 /// The search came back with nothing at all.
114 Nothing,
115 /// Several answers are equally good, and choosing between them would be
116 /// arbitrary. Names are carried for display, with identifiers when names
117 /// alone cannot distinguish the candidates.
118 Ambiguous(Vec<String>),
119 /// The best answer is not close enough to the name asked about.
120 TooWeak {
121 /// The best candidate's name, so the reader can see what was rejected.
122 best: String,
123 /// Its search score.
124 score: u8,
125 },
126}
127
128/// Below this, an answer is not worth attaching to anything.
129///
130/// A floor rather than a preference: MusicBrainz answers *something* for
131/// almost any query, and an unfiltered "best" result is how a library ends up
132/// with a Beatles record filed under a bar band of the same initials.
133const FLOOR: u8 = 70;
134
135/// Picks the answer that describes the thing asked about, or says why none does.
136///
137/// Two rules, both taken from behaviour this codebase already has:
138///
139/// - **An exact name match wins, and only widens when nothing matches
140/// exactly** — the rule `Catalog::find_releases` follows. The comparison is
141/// [`text::normalize`], the same function that decides two spellings are one
142/// name everywhere else.
143/// - **Several equally good answers are refused, not arbitrated.** Returning
144/// the first of them is an arbitrary answer given without saying so, which
145/// is the fault `find_releases` and `moved_to` were both fixed for.
146///
147/// A name is not an identity: equally ranked candidates with different MBIDs
148/// remain ambiguous even when their names normalize alike. Repeated spellings
149/// of the same MBID are one candidate.
150///
151/// This search helper always returns [`Confidence::Matched`], however sure
152/// the answer looks. Identifier lookups are handled separately.
153pub fn best_match<F: Clone>(
154 candidates: &[Candidate<F>],
155 wanted: &str,
156) -> Result<(Candidate<F>, Confidence), NoMatch> {
157 if candidates.is_empty() {
158 return Err(NoMatch::Nothing);
159 }
160 let wanted = text::normalize(wanted);
161
162 let exact: Vec<&Candidate<F>> = candidates
163 .iter()
164 .filter(|c| text::normalize(&c.name) == wanted)
165 .collect();
166
167 let pool: Vec<&Candidate<F>> = match exact.is_empty() {
168 false => exact,
169 true => candidates.iter().collect(),
170 };
171
172 let Some(best) = pool.iter().max_by_key(|c| c.score) else {
173 return Err(NoMatch::Nothing);
174 };
175 if best.score < FLOOR {
176 return Err(NoMatch::TooWeak {
177 best: best.name.clone(),
178 score: best.score,
179 });
180 }
181
182 // An equal name cannot disambiguate two distinct people or release
183 // groups. Only another spelling of the same identity is not a tie.
184 let tied: Vec<&&Candidate<F>> = pool
185 .iter()
186 .filter(|c| c.score == best.score && c.mbid != best.mbid)
187 .collect();
188 if !tied.is_empty() {
189 let ambiguous: Vec<&Candidate<F>> = std::iter::once(*best)
190 .chain(tied.iter().map(|candidate| **candidate))
191 .collect();
192 let mut identities_by_name = std::collections::BTreeMap::new();
193 for candidate in &ambiguous {
194 identities_by_name
195 .entry(text::normalize(&candidate.name))
196 .or_insert_with(std::collections::BTreeSet::new)
197 .insert(candidate.mbid.as_str());
198 }
199 let mut names: Vec<String> = ambiguous
200 .iter()
201 .map(|candidate| {
202 if identities_by_name
203 .get(&text::normalize(&candidate.name))
204 .is_some_and(|identities| identities.len() > 1)
205 {
206 format!("{} ({})", candidate.name, candidate.mbid)
207 } else {
208 candidate.name.clone()
209 }
210 })
211 .collect();
212 names.sort();
213 return Err(NoMatch::Ambiguous(names));
214 }
215
216 // The score MusicBrainz gives is about its index, not about us. A name
217 // that matches exactly deserves more than one that merely ranked well, and
218 // neither is ever a certainty.
219 let confidence = match text::normalize(&best.name) == wanted {
220 true => Confidence::matched(best.score.min(95)),
221 false => Confidence::matched(best.score.saturating_sub(25)),
222 };
223 Ok(((*best).clone(), confidence))
224}
225
226/// Escapes the characters Lucene reads as syntax.
227///
228/// The search server parses the query, so a name carrying `/`, `:`, `(` or `-`
229/// is not a name to it but an expression — and an expression that does not
230/// parse is not a polite "nothing found": the request fails. MusicBrainz
231/// documents this and gives the same example, `ac/dc` sent as `ac\/dc`, and
232/// the escaping happens **before** URL-encoding, never instead of it.
233///
234/// This is what a whole-library run trips over on its first awkward name,
235/// while a run naming one well-behaved artist works perfectly — which is
236/// exactly how it was found.
237pub fn escape_query(name: &str) -> String {
238 const SPECIAL: [char; 22] = [
239 '\\', '+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':',
240 '/', '<', '>', '=',
241 ];
242 let mut out = String::with_capacity(name.len() + 8);
243 for c in name.chars() {
244 if SPECIAL.contains(&c) {
245 out.push('\\');
246 }
247 out.push(c);
248 }
249 out
250}
251
252// --------------------------------------------------------------------------
253// Reading the answers
254// --------------------------------------------------------------------------
255
256/// A string field, absent rather than empty.
257fn field(value: &Json, key: &str) -> Option<String> {
258 value
259 .field_str(key)
260 .map(|s| s.trim().to_string())
261 .filter(|s| !s.is_empty())
262}
263
264/// The search score, which MusicBrainz gives as a number 0–100.
265fn score_of(value: &Json) -> u8 {
266 value
267 .field_u32("score")
268 .unwrap_or(0)
269 .min(100)
270 .try_into()
271 .unwrap_or(100)
272}
273
274/// Artists, as `/ws/2/artist/?query=…&fmt=json` returns them.
275///
276/// An entry with no identifier is skipped: without it a later fetch could not
277/// tell an update from a second opinion, which is the one thing the identifier
278/// is stored for.
279/// The entity, read the same way whether it came from a search or a lookup.
280///
281/// One extractor rather than two: the two answers carry the same artist, and
282/// reading it in two places is how one of them quietly stops keeping a field.
283fn artist_facts(row: &Json) -> ArtistFacts {
284 let life = row.get("life-span");
285 ArtistFacts {
286 lookup_mbid: None,
287 relationships_complete: false,
288 // `country` is a code, `area.name` is the name a reader wants. Both
289 // are kept: the name is what a listing shows, the code is what
290 // somebody types. Preferring one and discarding the other threw away
291 // a fact the source had stated — and it was the short one, which is
292 // the half a reader reaches for.
293 area: row
294 .get("area")
295 .and_then(|a| field(a, "name"))
296 .or_else(|| field(row, "country")),
297 country_code: field(row, "country"),
298 began: life.and_then(|l| field(l, "begin")),
299 // An artist that has not ended has no end date, and a `life-span` may
300 // carry `ended: false` with no `end`.
301 ended: life.and_then(|l| field(l, "end")),
302 // `ended: false` is an answer — "still going" — and it is the one a
303 // reader looking at a band wants most.
304 active: life
305 .and_then(|l| l.field_optional_bool("ended"))
306 .map(|e| !e),
307 kind: field(row, "type"),
308 disambiguation: field(row, "disambiguation"),
309 // `genres` and `tags` have the same shape; genres are the curated
310 // half, so they come first and tags fill in only when there are none.
311 genres: voted(row, "genres")
312 .or_else(|| voted(row, "tags"))
313 .unwrap_or_default(),
314 aliases: row
315 .get("aliases")
316 .and_then(Json::as_arr)
317 .map(|a| a.iter().filter_map(|x| field(x, "name")).collect())
318 .unwrap_or_default(),
319 wikidata: linked(row, "wikidata"),
320 discogs: linked(row, "discogs"),
321 // MusicBrainz spells this relationship "official homepage".
322 homepage: linked(row, "official homepage"),
323 // Filled by its own pass, never by this one: a discography is a
324 // browse over release groups, not a field of an artist lookup, and
325 // reading it here would quietly empty it on every ordinary fetch.
326 discography: Vec::new(),
327 discography_fetched_at: None,
328 members: memberships(row),
329 // MusicBrainz holds no prose about an artist: an annotation there is
330 // an editorial note about the data, not a description of the
331 // musician. The summary comes from Wikipedia, reached through the
332 // `wikidata` link above, and carries its own licence with it.
333 summary: None,
334 // Likewise filled by its own pass: MusicBrainz holds no picture of an
335 // artist either, and `fetch --portraits` reaches one the same way
336 // Wikipedia is reached, through the `wikidata` link above.
337 portrait: None,
338 // Same story again: `fetch --logos` is the only pass that fills this.
339 logo: None,
340 }
341}
342
343/// Band memberships, from the `artist-rels` a lookup was asked for.
344///
345/// # The direction, measured rather than assumed
346///
347/// MusicBrainz states **one** relation between two artists and returns it on
348/// both, distinguished by `direction`. Reading that field wrongly does not lose
349/// data, it inverts it — Black Sabbath would appear in the list of Ozzy
350/// Osbourne's members — so it was checked against live answers for a person and
351/// for a band rather than reasoned about.
352///
353/// Both answers carry `"direction": "backward"`, and that settles it: these
354/// relationships are defined **from the musician towards the group**, so a
355/// backward one is being read *from the group's end* and the artist it names is
356/// the player. The first version of this function had it the other way round,
357/// on the reasonable-sounding assumption that a person's own record would read
358/// forward. It does not.
359///
360/// # Which relationships count
361///
362/// `member of band` is the obvious one and it is not enough. Ozzy Osbourne's
363/// own record holds no members at all — a solo artist is not a band — and every
364/// musician who played on his records is there as an
365/// `instrumental supporting musician`: Randy Rhoads on guitar, Bob Daisley on
366/// bass. A line-up that showed nothing for him while MusicBrainz plainly holds
367/// his band would be a worse answer than no feature.
368///
369/// The two are kept apart rather than merged, because a founding member and a
370/// guitarist hired for one tour are both on the record and only one of them was
371/// in the band. [`Membership::kind`] carries the source's own phrase.
372///
373/// **The list is what has been seen in a real answer**, and nothing else: a
374/// relationship type nobody has checked is a guess with a `const` around it.
375fn memberships(row: &Json) -> Vec<Membership> {
376 let Some(relations) = row.get("relations").and_then(Json::as_arr) else {
377 return Vec::new();
378 };
379 relations
380 .iter()
381 .filter(|r| {
382 r.field_str("type")
383 .is_some_and(|t| MEMBER_RELATIONS.contains(&t.as_str()))
384 })
385 .filter_map(|r| {
386 let artist = r.get("artist")?;
387 Some(Membership {
388 relation_id: field(r, "id"),
389 role_id: field(r, "type-id"),
390 direction: field(r, "direction"),
391 // No identifier, no membership: a name alone cannot be
392 // followed to the artist it names, and the whole value of this
393 // list is that each row leads somewhere.
394 mbid: field(artist, "id")?,
395 name: field(artist, "name").unwrap_or_default(),
396 credited_as: field(r, "target-credit").filter(|value| !value.is_empty()),
397 kind: field(r, "type").unwrap_or_default(),
398 side: match r.field_str("direction").as_deref() {
399 Some("backward") => Side::Player,
400 _ => Side::Group,
401 },
402 attributes: r
403 .get("attributes")
404 .and_then(Json::as_arr)
405 .map(|a| a.iter().filter_map(Json::as_string).collect())
406 .unwrap_or_default(),
407 // `begin` may be a year, `1979-11`, or a full date, and every
408 // one of those is answered by taking the first four characters
409 // where a year is wanted.
410 began: field(r, "begin"),
411 ended: field(r, "end"),
412 // `ended: false` is an answer — "still in the band" — and it is
413 // the one a reader looking at a line-up wants most. It arrives
414 // beside `"end": null`, so the two must be read separately.
415 over: r.field_optional_bool("ended"),
416 })
417 })
418 .collect()
419}
420
421/// The relationships this reads, spelt as MusicBrainz spells them.
422///
423/// One place, because the strings are the whole filter: a typo here empties
424/// every line-up in the program and nothing else goes wrong. Each was seen in
425/// a live answer — `member of band` on Judas Priest, `instrumental supporting
426/// musician` on Ozzy Osbourne — and a type nobody has checked does not belong
427/// on this list, however plausible its name.
428const MEMBER_RELATIONS: [&str; 2] = ["member of band", "instrumental supporting musician"];
429
430/// Names from a `genres` or `tags` list, most agreed first.
431///
432/// `None` when the list is absent — which is not the same as an artist nobody
433/// tagged, and only the first of those means "we did not ask for it".
434fn voted(row: &Json, key: &str) -> Option<Vec<String>> {
435 let rows = row.get(key)?.as_arr()?;
436 let mut scored: Vec<(u32, String)> = rows
437 .iter()
438 .filter_map(|t| Some((t.field_u32("count").unwrap_or(0), field(t, "name")?)))
439 .collect();
440 // Most agreed first, then alphabetically so two runs give the same order.
441 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
442 Some(scored.into_iter().map(|(_, name)| name).collect())
443}
444
445/// The URL behind a relationship of the given type.
446///
447/// `relations[].url.resource` is the shape a URL relationship takes, and the
448/// one spelling here I could not confirm against a live answer — so it is
449/// written to come back empty rather than to break, and the tests below pin
450/// what it expects.
451fn linked(row: &Json, relation: &str) -> Option<String> {
452 row.get("relations")?.as_arr()?.iter().find_map(|r| {
453 (r.field_str("type").as_deref() == Some(relation))
454 .then(|| r.get("url").and_then(|u| field(u, "resource")))
455 .flatten()
456 })
457}
458
459/// One artist, as `/ws/2/artist/{mbid}?fmt=json` returns it.
460///
461/// A **lookup**, not a search, and the difference is the whole reason this
462/// exists beside [`artists`]. A search answers with an abbreviated entry and a
463/// relevance score, because it has to guess which artist was meant. A lookup
464/// is asked about one identifier and answers with the entity itself —
465/// `life-span.ended` and `disambiguation` among the fields a search result
466/// leaves out.
467///
468/// Which matters because a library tagged with Picard already carries those
469/// identifiers. Searching by name for an artist whose MBID sits in the tags is
470/// asking a question that has already been answered, and accepting a guess in
471/// place of a certainty.
472pub fn artist(response: &Json) -> Option<Candidate<ArtistFacts>> {
473 relationship_rows(response)?;
474 let mbid = field(response, "id")?;
475 let mut facts = artist_facts(response);
476 facts.relationships_complete = true;
477 Some(Candidate {
478 mbid,
479 name: field(response, "name").unwrap_or_default(),
480 // Nothing was ranked: the service was asked about this one thing.
481 score: 100,
482 facts,
483 })
484}
485
486/// One recording, as a lookup with [`RECORDING_INCLUDES`] returns it.
487///
488/// A lookup is evidence about the identifier we asked for, never a title
489/// match. Its recording credits, work relationships and work credits remain
490/// source facts: reconciliation decides later whether and how they join the
491/// local canonical graph.
492pub fn recording(response: &Json) -> Option<Candidate<TrackFacts>> {
493 let mbid = field(response, "id")?;
494 for relation in credit_relationship_rows(response)? {
495 if relation.field_str("target-type").as_deref() == Some("work") {
496 let work = relation.get("work")?;
497 if !has_nonblank_string(work, "id") {
498 return None;
499 }
500 for nested in credit_relationship_rows(work)? {
501 if is_parent_work_relation(nested)
502 && !nested
503 .get("work")
504 .is_some_and(|parent| has_nonblank_string(parent, "id"))
505 {
506 return None;
507 }
508 }
509 }
510 }
511 let credits = relationship_credits(response);
512 let works = response
513 .get("relations")
514 .and_then(Json::as_arr)
515 .map(|relations| {
516 relations
517 .iter()
518 .filter(|relation| relation.field_str("target-type").as_deref() == Some("work"))
519 .filter_map(|relation| {
520 let work = relation.get("work")?;
521 Some(WorkLink {
522 mbid: field(work, "id")?,
523 title: field(work, "title").unwrap_or_default(),
524 relation_id: field(relation, "id"),
525 relation_type: field(relation, "type"),
526 relation_type_id: field(relation, "type-id"),
527 direction: field(relation, "direction"),
528 attributes: relationship_attributes(relation),
529 credits: relationship_credits(work),
530 parents: work_parent_links(work),
531 })
532 })
533 .collect()
534 })
535 .unwrap_or_default();
536 Some(Candidate {
537 mbid: mbid.clone(),
538 name: field(response, "title").unwrap_or_default(),
539 score: 100,
540 facts: TrackFacts {
541 recording: Some(mbid),
542 title: field(response, "title"),
543 works,
544 credits,
545 relationships_complete: true,
546 ..Default::default()
547 },
548 })
549}
550
551/// A child work may have several containing works. Only the backward
552/// MusicBrainz "parts" relationship says the queried work is a part; a
553/// forward relationship names one of its children, and a derivative is not a
554/// container at all.
555fn work_parent_links(work: &Json) -> Vec<crate::sources::WorkParentLink> {
556 work.get("relations")
557 .and_then(Json::as_arr)
558 .into_iter()
559 .flatten()
560 .filter(|relation| is_parent_work_relation(relation))
561 .filter_map(|relation| {
562 let parent = relation.get("work")?;
563 Some(crate::sources::WorkParentLink {
564 mbid: field(parent, "id")?,
565 title: field(parent, "title").unwrap_or_default(),
566 relation_id: field(relation, "id"),
567 relation_type_id: field(relation, "type-id"),
568 direction: field(relation, "direction"),
569 attributes: relationship_attributes(relation),
570 order: relation.field_u32("ordering-key"),
571 })
572 })
573 .collect()
574}
575
576fn is_parent_work_relation(relation: &Json) -> bool {
577 const PARTS_TYPE_ID: &str = "ca8d3642-ce5f-49f8-91f2-125d72524e6a";
578 relation.field_str("target-type").as_deref() == Some("work")
579 && relation.field_str("direction").as_deref() == Some("backward")
580 && match relation.field_str("type-id") {
581 Some(id) => id == PARTS_TYPE_ID,
582 None => relation.field_str("type").as_deref() == Some("parts"),
583 }
584}
585
586/// Do not turn a discarded, unreadable credit into completed empty evidence.
587/// Unknown relationship families and optional display metadata stay optional.
588fn credit_relationship_rows(entity: &Json) -> Option<&[Json]> {
589 let rows = relationship_rows(entity)?;
590 rows.iter()
591 .all(|relation| {
592 relation.field_str("target-type").as_deref() != Some("artist")
593 || (has_nonblank_string(relation, "type")
594 && relation
595 .get("artist")
596 .is_some_and(|artist| has_nonblank_string(artist, "id")))
597 })
598 .then_some(rows)
599}
600
601fn has_nonblank_string(entity: &Json, key: &str) -> bool {
602 matches!(entity.get(key), Some(Json::Str(text)) if !text.trim().is_empty())
603}
604
605/// A malformed present table cannot establish a completed empty lookup.
606/// Missing optional relationships retain their existing empty meaning.
607fn relationship_rows(entity: &Json) -> Option<&[Json]> {
608 match entity.get("relations") {
609 None => Some(&[]),
610 Some(Json::Arr(rows)) if rows.iter().all(|row| matches!(row, Json::Obj(_))) => Some(rows),
611 _ => None,
612 }
613}
614
615/// Artist relationships carried by one recording or work response.
616fn relationship_credits(entity: &Json) -> Vec<CreditLink> {
617 entity
618 .get("relations")
619 .and_then(Json::as_arr)
620 .map(|relations| {
621 relations
622 .iter()
623 .filter(|relation| relation.field_str("target-type").as_deref() == Some("artist"))
624 .filter_map(|relation| {
625 let artist = relation.get("artist")?;
626 let credited_as = field(relation, "target-credit").filter(|s| !s.is_empty());
627 Some(CreditLink {
628 relation_id: field(relation, "id"),
629 role_id: field(relation, "type-id"),
630 role: field(relation, "type")?,
631 direction: field(relation, "direction"),
632 artist_mbid: field(artist, "id")?,
633 artist_name: field(artist, "name").unwrap_or_default(),
634 credited_as,
635 attributes: relationship_attributes(relation),
636 began: field(relation, "begin"),
637 ended: field(relation, "end"),
638 over: relation.field_optional_bool("ended"),
639 order: relation.field_u32("ordering-key"),
640 })
641 })
642 .collect()
643 })
644 .unwrap_or_default()
645}
646
647/// Attributes of one relationship, retaining values and credited spellings.
648fn relationship_attributes(relation: &Json) -> Vec<CreditAttribute> {
649 relation
650 .get("attributes")
651 .and_then(Json::as_arr)
652 .map(|attributes| {
653 attributes
654 .iter()
655 .filter_map(Json::as_string)
656 .map(|name| CreditAttribute {
657 id: relation
658 .get("attribute-ids")
659 .and_then(|ids| ids.get(&name))
660 .and_then(Json::as_string),
661 value: relation
662 .get("attribute-values")
663 .and_then(|values| values.get(&name))
664 .and_then(Json::as_string),
665 credited_as: relation
666 .get("attribute-credits")
667 .and_then(|credits| credits.get(&name))
668 .and_then(Json::as_string)
669 .filter(|value| !value.is_empty()),
670 name,
671 })
672 .collect()
673 })
674 .unwrap_or_default()
675}
676
677/// Artists, as `/ws/2/artist/?query=…&fmt=json` returns them.
678///
679/// A **search**: the answers are ranked guesses at what was meant, and each
680/// entry is abbreviated. Prefer [`artist`] whenever an identifier is known.
681///
682/// An entry with no identifier is skipped: without it a later fetch could not
683/// tell an update from a second opinion, which is the one thing the identifier
684/// is stored for.
685pub fn artists(response: &Json) -> Vec<Candidate<ArtistFacts>> {
686 let rows = response
687 .get("artists")
688 .and_then(Json::as_arr)
689 .unwrap_or(&[]);
690 rows.iter()
691 .filter_map(|row| {
692 relationship_rows(row)?;
693 let mbid = field(row, "id")?;
694 Some(Candidate {
695 mbid,
696 name: field(row, "name").unwrap_or_default(),
697 score: score_of(row),
698 facts: artist_facts(row),
699 })
700 })
701 .collect()
702}
703
704/// Release groups, as `/ws/2/release-group/?query=…&fmt=json` returns them.
705///
706/// Groups rather than releases, deliberately: a release group is *the album*,
707/// and every remaster and reissue of it is a release inside that group. Asking
708/// about releases would answer with one edition among twenty and call it the
709/// album's date.
710pub fn release_groups(response: &Json) -> Vec<Candidate<ReleaseFacts>> {
711 let rows = response
712 .get("release-groups")
713 .and_then(Json::as_arr)
714 .unwrap_or(&[]);
715 rows.iter()
716 .filter_map(|row| {
717 let mbid = field(row, "id")?;
718 Some(Candidate {
719 mbid,
720 name: field(row, "title").unwrap_or_default(),
721 score: score_of(row),
722 facts: release_facts(row),
723 })
724 })
725 .collect()
726}
727
728/// The album, read the same way wherever the group came from.
729///
730/// One extractor for the search result, the group lookup and the group folded
731/// into a release lookup — for the reason [`artist_facts`] is one function:
732/// reading the same entity in three places is how two of them quietly stop
733/// keeping a field.
734///
735/// The label is deliberately **not** read here. A group has none; a release
736/// does. Filling it from whichever edition answered first would attribute one
737/// pressing's label to the album itself.
738fn release_facts(group: &Json) -> ReleaseFacts {
739 ReleaseFacts {
740 lookup_mbid: None,
741 primary_type: field(group, "primary-type"),
742 secondary_types: group
743 .get("secondary-types")
744 .and_then(Json::as_arr)
745 .map(|a| a.iter().filter_map(Json::as_string).collect())
746 .unwrap_or_default(),
747 first_released: field(group, "first-release-date"),
748 label: None,
749 label_mbid: None,
750 // MusicBrainz holds no artwork: the images live at the Cover Art
751 // Archive, which is a different service with its own answer — see
752 // [`crate::coverart`].
753 cover_art: None,
754 edition_mbid: None,
755 edition_lookup_mbid: None,
756 credits: Vec::new(),
757 relationships_complete: false,
758 }
759}
760
761/// One release group, as a lookup answers: `/ws/2/release-group/<mbid>`.
762///
763/// The counterpart of [`artist`]: an answer about the identifier that was
764/// asked for is a certainty, where a search result is a guess with a score.
765/// Used when the tags carry `MUSICBRAINZ_RELEASEGROUPID` but no edition.
766pub fn release_group(response: &Json) -> Option<Candidate<ReleaseFacts>> {
767 let mbid = field(response, "id")?;
768 Some(Candidate {
769 mbid,
770 name: field(response, "title").unwrap_or_default(),
771 score: 100,
772 facts: release_facts(response),
773 })
774}
775
776/// One release, as a lookup with [`RELEASE_INCLUDES`] answers.
777///
778/// The richest answer this program can get about an album, and the cheapest:
779/// the edition supplies the label, the folded release group supplies the type
780/// and the date the album first appeared — which is the fact a reissue's `DATE`
781/// tag most often contradicts.
782///
783/// The identifier kept is the **release group's**, not the edition's. Two
784/// pressings of one album are one album, and keying the record on the edition
785/// would file a second copy of the same answer the day the user replaces their
786/// CD rip with a vinyl one. The edition is what was asked; the album is what
787/// was learnt.
788pub fn release(response: &Json) -> Option<Candidate<ReleaseFacts>> {
789 credit_relationship_rows(response)?;
790 let group = response.get("release-group");
791 let mut facts = group.map(release_facts).unwrap_or_default();
792 facts.edition_mbid = field(response, "id");
793 facts.credits = relationship_credits(response);
794 facts.relationships_complete = true;
795 if let Some(label) = label_of_release(response) {
796 facts.label = Some(label.name);
797 facts.label_mbid = label.mbid;
798 }
799 // Falling back to the edition's own identifier: an answer with no group is
800 // not one this program has seen, but storing it under the edition is
801 // better than dropping a lookup that succeeded.
802 let mbid = group
803 .and_then(|g| field(g, "id"))
804 .or_else(|| field(response, "id"))?;
805 Some(Candidate {
806 mbid,
807 name: group
808 .and_then(|g| field(g, "title"))
809 .or_else(|| field(response, "title"))
810 .unwrap_or_default(),
811 score: 100,
812 facts,
813 })
814}
815
816/// How many release groups one browse request may return.
817///
818/// The service's own ceiling. A prolific artist has more than this, so the
819/// caller pages — which is the one place in this program where a single entity
820/// can cost more than one request, and the reason [`discography`] hands back
821/// the total alongside the page.
822pub const BROWSE_LIMIT: usize = 100;
823
824/// Where to ask for everything credited to an artist.
825///
826/// A **browse**, not a search: browse answers "what does this identifier have",
827/// which is a question with one right answer, where a search answers "what
828/// resembles these words". Nothing here is scored or matched, because nothing
829/// here is guessed.
830///
831/// `type=album` narrows what travels. It is a saving and not the filter that
832/// matters — [`crate::sources::KnownRelease::is_studio_album`] decides what a
833/// reader is shown, so a parameter the service ignores costs bandwidth and
834/// never correctness.
835pub fn discography_url(artist_mbid: &str, offset: usize) -> String {
836 format!(
837 "{WEB_SERVICE}/release-group?artist={artist_mbid}&type=album\
838 &fmt=json&limit={BROWSE_LIMIT}&offset={offset}"
839 )
840}
841
842/// One page of an artist's discography, and how many there are in all.
843///
844/// The total comes back as `release-group-count` and is what tells the caller
845/// whether to ask for another page. Absent, it is taken to be what arrived:
846/// stopping is the safe reading, since paging forever on a field that was never
847/// there would be a request per second with no end.
848pub fn discography(response: &Json) -> (Vec<crate::sources::KnownRelease>, usize) {
849 use crate::sources::KnownRelease;
850 let rows = response
851 .get("release-groups")
852 .and_then(Json::as_arr)
853 .unwrap_or(&[]);
854 let page: Vec<KnownRelease> = rows
855 .iter()
856 .filter_map(|row| {
857 // No identifier, no row: the only way to tell "you own this one"
858 // from "you are missing it" without an identifier is the title, and
859 // two records share a title often enough that a wish list built on
860 // titles alone is wrong.
861 Some(KnownRelease {
862 mbid: field(row, "id")?,
863 title: field(row, "title").unwrap_or_default(),
864 first_released: field(row, "first-release-date"),
865 primary_type: field(row, "primary-type"),
866 secondary_types: row
867 .get("secondary-types")
868 .and_then(Json::as_arr)
869 .map(|a| a.iter().filter_map(Json::as_string).collect())
870 .unwrap_or_default(),
871 })
872 })
873 .collect();
874 let total = response
875 .field_u32("release-group-count")
876 .map(|n| n as usize)
877 .unwrap_or(page.len());
878 (page, total)
879}
880
881/// Read a complete browse page without confusing missing data with an empty
882/// discography. Network callers must reject `None` and keep their prior answer.
883///
884/// Unlike [`discography`], this requires an array, an integral total, and an
885/// identifier on every row. Skipping a malformed row would make the caller's
886/// pagination offset and completeness conclusion unreliable.
887pub fn discography_page(response: &Json) -> Option<(Vec<crate::sources::KnownRelease>, usize)> {
888 let rows = response.get("release-groups")?.as_arr()?;
889 let total = response.field_u32("release-group-count")? as usize;
890 let (page, _) = discography(response);
891 (page.len() == rows.len()).then_some((page, total))
892}
893
894/// A label credited on a release: its name and, when the same entry carried
895/// one, its MusicBrainz identifier.
896pub struct LabelOfRelease {
897 /// The name, as the release lookup spells it.
898 pub name: String,
899 /// The label's own MusicBrainz identifier, when the same entry named
900 /// one.
901 pub mbid: Option<String>,
902}
903
904/// The label of a release, as a release lookup returns it.
905///
906/// Separate from [`release_groups`] because it comes from a different request,
907/// and because it describes an edition rather than the album.
908///
909/// The name and the identifier are read from **the same** `label-info` entry
910/// — the first one that names a label — never a name from one row and an
911/// identifier from another: a release crediting several labels would
912/// otherwise risk pairing the name of one with the address of a different
913/// one. This supplies edition evidence independently of any explicit label
914/// identifier already carried by local tags.
915pub fn label_of_release(response: &Json) -> Option<LabelOfRelease> {
916 response
917 .get("label-info")
918 .and_then(Json::as_arr)?
919 .iter()
920 .find_map(|info| {
921 let label = info.get("label")?;
922 let name = field(label, "name")?;
923 Some(LabelOfRelease {
924 name,
925 mbid: field(label, "id"),
926 })
927 })
928}
929
930/// One label, as `/ws/2/label/{mbid}?fmt=json` returns it.
931///
932/// A **lookup**, asked only once an identifier is already known — see
933/// `crate::sources::ReleaseFacts::label_mbid` and the `fetch --labels`
934/// module doc for where that comes from: a release this catalog looked up
935/// sometimes already names its label's own identifier, and a lookup then
936/// turns that into a certainty instead of guessing by name all over again.
937/// No tag ever carries it directly, so unlike [`artist`] this is never asked
938/// with a value read out of the files.
939pub fn label(response: &Json) -> Option<Candidate<LabelFacts>> {
940 relationship_rows(response)?;
941 let mbid = field(response, "id")?;
942 Some(Candidate {
943 mbid,
944 name: field(response, "name").unwrap_or_default(),
945 // Nothing was ranked: the service was asked about this one thing.
946 score: 100,
947 facts: LabelFacts {
948 wikidata: linked(response, "wikidata"),
949 discogs: linked(response, "discogs"),
950 ..Default::default()
951 },
952 })
953}
954
955/// Labels, as `/ws/2/label/?query=…&fmt=json` returns them.
956///
957/// A **search**: ranked guesses, asked when no identifier is known for this
958/// label yet. Prefer [`label`] whenever one is.
959pub fn labels(response: &Json) -> Vec<Candidate<LabelFacts>> {
960 let rows = response.get("labels").and_then(Json::as_arr).unwrap_or(&[]);
961 rows.iter()
962 .filter_map(|row| {
963 let mbid = field(row, "id")?;
964 Some(Candidate {
965 mbid,
966 name: field(row, "name").unwrap_or_default(),
967 score: score_of(row),
968 facts: LabelFacts::default(),
969 })
970 })
971 .collect()
972}
973
974#[cfg(test)]
975#[path = "musicbrainz_tests.rs"]
976mod tests;