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;