aede_core/
sources.rs

1//! What other sources say about the library.
2//!
3//! The catalog holds what the files say. [`crate::user`] holds what the user
4//! says. This holds what somebody else says — MusicBrainz first, a plugin
5//! later — and it is a third store rather than a column in either of the other
6//! two, for reasons written out in `docs/design/attribution.md`.
7//!
8//! The rule the whole module exists to enforce: **a fetched value sits beside
9//! the tag, never on top of it.** Writing a fetched genre into the catalog's
10//! `genre` would cost three things at once — saying where the value came from,
11//! noticing that the source and the tag differ, and undoing it — and it would
12//! not even survive, because a scan rebuilds the catalog from the files.
13//!
14//! Nothing here touches the network. This is the receptacle; filling it from
15//! MusicBrainz is a later step, and the layer is testable without a single
16//! request.
17
18use crate::graph::RelationRef;
19use crate::json::Json;
20use crate::model::{CreditAttribute, EntityKind, Id};
21use crate::user::EntityRef;
22use std::collections::{BTreeMap, BTreeSet};
23use std::path::Path;
24use std::sync::Arc;
25
26/// Version of the `sources.json` document this build writes. Version 1 is
27/// accepted as the review-free predecessor and migrated on the next save.
28pub const SOURCES_FORMAT_VERSION: u32 = 2;
29
30/// Name of the file, inside the data folder that holds the catalog.
31pub const SOURCES_FILE: &str = "sources.json";
32
33/// The source name MusicBrainz records carry.
34pub const MUSICBRAINZ: &str = "musicbrainz";
35
36// --------------------------------------------------------------------------
37// How firmly a record is attached
38// --------------------------------------------------------------------------
39
40/// How the record was attached to the entity it describes.
41///
42/// Kept because the roadmap's rule for M1 is that a file matched to a release
43/// approximately is never treated as certain. A value reached by asking for a
44/// known identifier and a value reached by guessing from a name are different
45/// claims, and a layer that stored them identically could not offer the
46/// review that makes an approximate match acceptable in the first place.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Confidence {
49    /// The source was asked about this exact thing, by identifier.
50    Identified,
51    /// Reached by matching names and metadata, scored from 0 to 100.
52    Matched(u8),
53}
54
55impl Confidence {
56    /// A matched confidence, with the score bounded to 0–100.
57    ///
58    /// Bounded here rather than trusted from the caller: a score above 100
59    /// would print as one and sort above a certainty, which is exactly the
60    /// confusion this type exists to prevent.
61    pub fn matched(score: u8) -> Confidence {
62        Confidence::Matched(score.min(100))
63    }
64
65    /// `true` when the record was reached by identifier rather than by guess.
66    pub fn is_certain(self) -> bool {
67        matches!(self, Confidence::Identified)
68    }
69}
70
71/// A user's explicit decision about one external identity claim.
72///
73/// This is deliberately separate from [`Confidence`]. Confidence says how a
74/// service found the candidate; a review says whether the library owner chose
75/// to trust that candidate. Accepting a 92% name match must not rewrite history
76/// and pretend it was an identifier lookup, while rejecting an exact lookup
77/// that conflicts with a local tag must remain possible too.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ReviewDecision {
80    /// The source claim may participate in graph traversal.
81    Accepted,
82    /// The source claim remains visible evidence but must not become a link.
83    Rejected,
84}
85
86/// One durable review decision, bound to the exact claim that was reviewed.
87///
88/// `source_id` is part of the identity on purpose. If a later fetch proposes a
89/// different MusicBrainz object, the old answer cannot silently approve it.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct SourceReview {
92    /// Stable entity reference, independent of catalog vector positions.
93    pub entity: EntityRef,
94    /// Service that made the claim.
95    pub source: String,
96    /// Identifier that was accepted or rejected.
97    pub source_id: Option<String>,
98    /// The explicit choice.
99    pub decision: ReviewDecision,
100    /// Time at which the choice was made.
101    pub reviewed_at: u64,
102}
103
104/// Why a current source record needs a human decision.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum ReviewReason {
107    /// A name, metadata or fingerprint match rather than an identifier lookup.
108    Approximate {
109        /// Score supplied by the matching process.
110        score: u8,
111    },
112    /// An exact MusicBrainz lookup contradicts the identifier in local tags.
113    IdentityConflict {
114        /// Identifier explicitly carried by the local catalog.
115        local_id: String,
116        /// Different identifier asserted by MusicBrainz.
117        sourced_id: String,
118    },
119    /// A previous decision is retained although a newer fetch no longer needs
120    /// human help. It remains listed with `--all` so it can still be undone.
121    PriorDecision,
122}
123
124/// One reviewable claim as presented by the quality-resolution workflow.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct ReviewItem {
127    /// Stable short selector accepted by `aede review`.
128    pub id: String,
129    /// Local entity the claim is attached to.
130    pub entity: EntityRef,
131    /// Service that made the claim.
132    pub source: String,
133    /// Identifier under review.
134    pub source_id: Option<String>,
135    /// Why automatic traversal is unsafe.
136    pub reason: ReviewReason,
137    /// Existing decision, absent while the item is pending.
138    pub decision: Option<ReviewDecision>,
139    /// When the existing decision was made.
140    pub reviewed_at: Option<u64>,
141}
142
143// --------------------------------------------------------------------------
144// What a source says
145// --------------------------------------------------------------------------
146
147/// A piece of prose taken from somewhere, with what its licence obliges.
148///
149/// **One value, not four fields.** Wikipedia is CC BY-SA: the text may be
150/// reused, and attribution has to travel with it. A `text` field beside a
151/// separate optional `url` would make it possible — and therefore eventually
152/// certain — to hold the words without the credit: one code path that fills
153/// the first and forgets the second, one export that copies one and not the
154/// other, and the project is quietly out of compliance.
155///
156/// So they are the same value. There is no way to store the sentence without
157/// storing where it came from, in which language, and under what terms,
158/// because the type does not offer one.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct Prose {
161    /// The words themselves.
162    pub text: String,
163    /// The page they came from, which is the attribution a reader can follow.
164    pub url: String,
165    /// Language code of that page, so a reader knows what they are getting.
166    pub lang: String,
167    /// The licence, named as the source names it — `CC BY-SA 4.0`.
168    ///
169    /// **Stored per record, not looked up from a constant.** It is the same
170    /// string for every Wikipedia article today, so carrying a copy on each row
171    /// looks like waste — about twenty-four bytes, ten kilobytes over a large
172    /// library. It buys two things that a constant read at display time cannot.
173    ///
174    /// It is a **fact about the fetch**, like [`SourceRecord::fetched_at`]: it
175    /// says what these words were taken under, not what the current build
176    /// believes they would be taken under now. Wikimedia has already moved once
177    /// — CC BY-SA 3.0 to 4.0 — and with a constant that upgrade silently
178    /// relabels every paragraph fetched before it.
179    ///
180    /// And `Prose` is not Wikipedia's. It is prose with its terms, so the next
181    /// source of a biography — a plugin, a label, a discography site under
182    /// quite different terms — files it in this same field, and the record says
183    /// which. Moving the licence out to the source that usually supplies it
184    /// would make the second source a special case of the first.
185    pub licence: String,
186}
187
188impl Prose {
189    /// The line that has to appear wherever the text does.
190    ///
191    /// The **language is named**, and that is not decoration. This text is
192    /// fetched in one language and kept in one language, so a reader who wanted
193    /// French and is reading English has no way to tell whether their
194    /// preference was ignored, whether the article does not exist in French, or
195    /// whether the prose was fetched before they expressed a preference at all.
196    /// The field was stored from the first version and displayed nowhere — the
197    /// same dead end `source_id` and the fingerprints had. Now the screen says
198    /// which language it is, and the way to ask for another is one line below.
199    pub fn credit(&self) -> String {
200        format!("{} — in {} — {}", self.url, self.lang, self.licence)
201    }
202}
203
204/// A picture of an artist, as a source answered it.
205///
206/// Just the address, deliberately as thin as [`Prose`] is thick. `Prose`
207/// cannot exist without stating the page, the language and the licence
208/// because the words cannot be shown without them; a picture is written to
209/// disk as a file and shown as one, and the file itself is what a viewer
210/// looks at — the record here exists only so that `fetch --portraits` does
211/// not ask the same question of the same artist on every run.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct Picture {
214    /// Where the image was downloaded from.
215    pub url: String,
216}
217
218/// What a source says about one artist.
219///
220/// Every field is optional: a source may not hold it, and an empty answer is
221/// not an error. None of these have a tag counterpart — there is no widely
222/// used tag for an artist's country — so this is knowledge the files simply
223/// do not carry, rather than a second opinion about something they do.
224#[derive(Debug, Clone, Default, PartialEq, Eq)]
225pub struct ArtistFacts {
226    /// An artist MBID whose exact lookup confirmed this canonical source ID.
227    ///
228    /// Set by the lookup orchestrator when MusicBrainz redirects a merged ID.
229    /// It preserves that verified alias without replacing the canonical ID in
230    /// `SourceRecord::source_id`. Absent on legacy snapshots; name searches
231    /// never establish this evidence.
232    pub lookup_mbid: Option<String>,
233    /// A full MusicBrainz lookup checked relationships, including a confirmed absence of URLs.
234    pub relationships_complete: bool,
235    /// Country or area of origin, as the source names it.
236    pub area: Option<String>,
237    /// The same country as a two-letter code, when the source gives one.
238    ///
239    /// Kept **beside** the name rather than instead of it, because the source
240    /// states both and they answer different needs: `area` is what a reader
241    /// sees, the code is what they can type. It used to be read only as a
242    /// fallback for a missing name and otherwise dropped — a fact the source
243    /// had stated, thrown away because another field happened to be filled.
244    ///
245    /// ISO 3166-1 alpha-2, so the United Kingdom is `GB` and not `UK`.
246    pub country_code: Option<String>,
247    /// Formation date: a year, or a fuller date when the source has one.
248    pub began: Option<String>,
249    /// When it ended, for a group that has.
250    pub ended: Option<String>,
251    /// Whether it is still going, when the source says so.
252    ///
253    /// Distinct from [`ArtistFacts::ended`] being absent, and the distinction
254    /// is the whole point: a band with no end date may be one that never
255    /// stopped, or one nobody has filled in. `Some(true)` is an answer,
256    /// `None` is a silence, and a reader shown the same thing for both learns
257    /// nothing.
258    pub active: Option<bool>,
259    /// `person`, `group`, `orchestra`, `choir`… as the source classifies it.
260    pub kind: Option<String>,
261    /// The short phrase a source uses to tell two same-named artists apart —
262    /// "US industrial metal band", "UK folk singer", and sometimes something
263    /// as unhelpful as "the band".
264    ///
265    /// **Not a description**, and it must not be labelled as one: it exists to
266    /// separate two entries that share a name, so it is written against
267    /// whatever the other entry is. Shown as a note for that reason.
268    pub disambiguation: Option<String>,
269    /// Genres, as the source's editors voted them, most agreed first.
270    ///
271    /// Beside the genre tag rather than over it: what a crowd calls a record
272    /// and what its tags call it are two answers, and the second is the one
273    /// the user chose to write.
274    pub genres: Vec<String>,
275    /// Other names the same artist is known by.
276    pub aliases: Vec<String>,
277    /// Wikidata entity page, when the source links to one.
278    ///
279    /// The one link that leads somewhere else: Wikidata is how a real article
280    /// is reached in the reader's own language, so it is stored under its own
281    /// name rather than lost in a list of URLs.
282    pub wikidata: Option<String>,
283    /// Discogs page, when the source links to one.
284    pub discogs: Option<String>,
285    /// The artist's own site, when they have one the source knows about.
286    pub homepage: Option<String>,
287    /// A few sentences about the artist, with the attribution its licence
288    /// requires — see [`Prose`].
289    ///
290    /// MusicBrainz never fills this: it holds identifiers and relationships,
291    /// not prose. It comes from an encyclopaedia, and it is stored as one
292    /// inseparable value for the reason written on the type.
293    pub summary: Option<Prose>,
294    /// Every record the source credits to this artist — see [`KnownRelease`].
295    ///
296    /// **On the artist, and deliberately not on the releases.** A release key
297    /// is `artist|title|folder`, and an album you do not own has no folder, so
298    /// a record you are missing cannot be an entity of this layer at all. It is
299    /// instead a fact *about the artist*: "MusicBrainz credits fourteen albums
300    /// to this name".
301    ///
302    /// Which of them are missing from the shelf is then **derived on read**, by
303    /// comparing that list with the catalog — the same rule the whole layer
304    /// follows for agreement with a tag. Storing "missing" would go stale the
305    /// moment the album is bought, and the catalog would hold a claim it had
306    /// stopped being able to justify.
307    pub discography: Vec<KnownRelease>,
308    /// When a complete discography browse succeeded, including an empty answer.
309    ///
310    /// Unix seconds, set only after every page was received and validated.
311    /// Absent on older documents and artist lookups that never browsed release
312    /// groups. A legacy nonempty list remains reusable without this optional
313    /// field; an empty list needs it to distinguish an answer from no request.
314    /// An artist lookup must preserve it only while the artist ID stays the same.
315    pub discography_fetched_at: Option<u64>,
316    /// Who played in this band, or which bands this person played in — see
317    /// [`Membership`].
318    ///
319    /// One list, not two, because MusicBrainz states **one** relation and the
320    /// two readings are the same fact seen from either end. Splitting it here
321    /// would mean deciding at write time which question a reader was going to
322    /// ask, and getting it wrong for a person who is both — a soloist with a
323    /// backing band under their own name is exactly that.
324    pub members: Vec<Membership>,
325    /// A picture of the artist, when a source holds an address for one — see
326    /// [`Picture`].
327    ///
328    /// The address, never the bytes. The image itself is written to disk —
329    /// beside the music when one folder holds every album of theirs, an
330    /// `assets/` folder otherwise — and rediscovered from there, the same way
331    /// `cover.jpg` is never registered anywhere either. What is kept here is
332    /// only the fact that a source was asked and what it answered, so a later
333    /// run does not ask again for nothing: see `fetch --portraits`.
334    pub portrait: Option<Picture>,
335    /// The artist's logo, when a source holds an address for one — see
336    /// [`Picture`].
337    ///
338    /// Same shape and same reason as [`ArtistFacts::portrait`]: the address
339    /// only, written to disk and rediscovered from there, kept here so a
340    /// later run does not ask again for nothing: see `fetch --logos`.
341    pub logo: Option<Picture>,
342}
343
344impl ArtistFacts {
345    /// Whether a completed discography answer is cached, even when it is empty.
346    ///
347    /// Nonempty legacy lists remain cached without a completion timestamp.
348    pub fn discography_is_cached(&self) -> bool {
349        self.discography_fetched_at.is_some() || !self.discography.is_empty()
350    }
351
352    /// The line-up: everyone the source says played in **this** artist.
353    pub fn line_up(&self) -> impl Iterator<Item = &Membership> {
354        self.members.iter().filter(|m| m.side == Side::Player)
355    }
356
357    /// The other way round: the bands **this** artist played in.
358    pub fn bands(&self) -> impl Iterator<Item = &Membership> {
359        self.members.iter().filter(|m| m.side == Side::Group)
360    }
361
362    /// Who the source places in this band in a given year, and how many
363    /// members it cannot place at all.
364    ///
365    /// **This is the whole reason the dates are worth fetching**: a line-up is
366    /// a fact about an artist, an album is a fact about a year, and crossing
367    /// them answers the question a listener actually asks — who was in the band
368    /// when this record came out.
369    ///
370    /// Derived when read, never stored. A stored line-up would be a claim the
371    /// catalog had stopped being able to justify the moment either side
372    /// changed, and it is the same rule the missing-albums list already
373    /// follows.
374    ///
375    /// A row is placed only when the source's own dates put it there: an
376    /// undated membership, or one the source says has ended without saying
377    /// when, is **counted rather than shown**. Including it would put a
378    /// musician on a record they may not be on, which is the one mistake here
379    /// that matters; dropping it in silence would be a filter the reader
380    /// cannot see.
381    pub fn line_up_in(&self, year: u32) -> (Vec<&Membership>, usize) {
382        let mut placed = Vec::new();
383        let mut undated = 0;
384        for member in self.line_up() {
385            match member.covers(year) {
386                Some(true) => placed.push(member),
387                Some(false) => {}
388                None => undated += 1,
389            }
390        }
391        (placed, undated)
392    }
393}
394
395/// Which end of a membership the named artist is.
396///
397/// A membership is one relation between two artists, and MusicBrainz returns
398/// it on both of them with a direction. Losing that direction is not a cosmetic
399/// mistake: it puts Judas Priest in the list of Ozzy Osbourne's members.
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum Side {
402    /// The named artist played **in** the artist this record describes, which
403    /// therefore is a band.
404    Player,
405    /// The named artist **is** the band, and the artist this record describes
406    /// played in it.
407    Group,
408}
409
410/// One spell in a band, as a source dates it.
411///
412/// **A person can appear twice**, and the second row is not a duplicate: Ozzy
413/// Osbourne was in Black Sabbath from 1968 to 1979 and again from 1997, and a
414/// list that folded the two into one span would claim he was there in 1985.
415/// MusicBrainz states them as two relations and they are kept as two rows.
416#[derive(Debug, Clone, PartialEq, Eq)]
417pub struct Membership {
418    /// MusicBrainz relationship row identifier, when exposed.
419    pub relation_id: Option<String>,
420    /// Stable MusicBrainz relationship type identifier.
421    pub role_id: Option<String>,
422    /// Direction exactly as returned by MusicBrainz.
423    pub direction: Option<String>,
424    /// The other artist's MusicBrainz identifier, which is what makes a later
425    /// fetch about them an update rather than a second opinion.
426    pub mbid: String,
427    /// The other artist's name, as the source spells it.
428    pub name: String,
429    /// Exact spelling used for the related artist in this relationship.
430    pub credited_as: Option<String>,
431    /// Which end of the relation that name is — see [`Side`].
432    pub side: Side,
433    /// How the source names the relationship, in its own words: `member of
434    /// band`, `instrumental supporting musician`.
435    ///
436    /// Kept verbatim and shown verbatim, the same rule
437    /// [`KnownRelease::stated_type`] follows. The two are not the same fact — a
438    /// founding member and a guitarist hired for one tour are both on a record
439    /// and only one of them was in the band — and paraphrasing them into one
440    /// word would throw away the distinction MusicBrainz took the trouble to
441    /// draw.
442    pub kind: String,
443    /// What the source lists against the relationship.
444    ///
445    /// **Not "instruments", although most of them are.** Measured on a real
446    /// answer: Judas Priest's line-up carries `["guitar family", "original"]`
447    /// on one row, where `original` marks an original member and is not an
448    /// instrument at all. Calling this field `instruments` would put a lie in
449    /// a column header, so it carries the source's own word and its own
450    /// values.
451    pub attributes: Vec<String>,
452    /// When it started: a year, or a fuller date when the source has one.
453    pub began: Option<String>,
454    /// When it stopped.
455    pub ended: Option<String>,
456    /// Whether the source says it is over.
457    ///
458    /// The same distinction [`ArtistFacts::active`] draws, and it matters more
459    /// here: a membership with no end date may be a current one or one nobody
460    /// has filled in, and printing "1968–" for both tells the reader something
461    /// the source never said. `Some(false)` is "still in the band".
462    pub over: Option<bool>,
463}
464
465impl Membership {
466    /// Whether this membership was running in a given year: `Some(true)`,
467    /// `Some(false)`, or `None` where the source's dates cannot say.
468    ///
469    /// The three-way answer is the point. A membership with no start date
470    /// places nobody, and one the source says has **ended without saying
471    /// when** places nobody either — the end is somewhere, and "somewhere"
472    /// cannot be compared with a year. Both come back `None`, so a caller has
473    /// to decide what to do about them rather than being handed a `false` that
474    /// looks like knowledge.
475    pub fn covers(&self, year: u32) -> Option<bool> {
476        let started = self.year(self.began.as_deref())?;
477        if started > year {
478            return Some(false);
479        }
480        match (self.year(self.ended.as_deref()), self.over) {
481            (Some(stopped), _) => Some(stopped >= year),
482            // Still in the band, said so by the source.
483            (None, Some(false)) => Some(true),
484            // Over, with no date for it, or nothing said at all.
485            (None, _) => None,
486        }
487    }
488
489    /// The year out of a MusicBrainz date, which is `1968` or `1968-02-13`.
490    fn year(&self, date: Option<&str>) -> Option<u32> {
491        date?.get(..4)?.parse().ok()
492    }
493
494    /// The years as a reader recognises them: `1968–1979`, `1968–` for a
495    /// current member, `1968` where the source only knows a start and does not
496    /// say whether it ended.
497    ///
498    /// **Never invents an end.** A dash trailing into nothing is a claim that
499    /// the membership is still going, and it is one MusicBrainz makes with
500    /// `ended: false` rather than with a missing date.
501    pub fn years(&self) -> String {
502        let began = self.began.as_deref().unwrap_or("");
503        match (&self.ended, self.over) {
504            (Some(end), _) => format!("{began}–{end}"),
505            (None, Some(false)) => format!("{began}–"),
506            // Said to be over, with no date for it: the reader is told that
507            // much rather than being shown an open range that would be wrong.
508            (None, Some(true)) => format!("{began}–?"),
509            (None, None) => began.to_string(),
510        }
511    }
512}
513
514/// One record a source credits to an artist, whether or not you own it.
515///
516/// Enough to recognise it and to show it: the identifier that makes a second
517/// fetch an update, the title, when it first appeared, and what kind of record
518/// it is. Not a [`ReleaseFacts`], which is what a source says about an album
519/// *in the catalog* — this is a name on a list, and the difference matters:
520/// one is attached to files, the other is precisely the one that is not.
521#[derive(Debug, Clone, PartialEq, Eq)]
522pub struct KnownRelease {
523    /// Release-group identifier, which is what the catalog's own
524    /// `release_group_mbid` can be compared against without guessing.
525    pub mbid: String,
526    /// Title as the source spells it.
527    pub title: String,
528    /// When the album first appeared — the year a reader recognises it by.
529    pub first_released: Option<String>,
530    /// Album, Single, EP, Broadcast, Other.
531    pub primary_type: Option<String>,
532    /// Compilation, Soundtrack, Live, Remix, Demo…
533    pub secondary_types: Vec<String>,
534}
535
536impl KnownRelease {
537    /// `true` for a studio album: primary type Album, and nothing else.
538    ///
539    /// The filter that makes a completeness report readable rather than a wall.
540    /// A discography holds every single, every live recording, every
541    /// compilation somebody assembled, and reporting all of them as *missing*
542    /// would be true and useless — nobody's shelf holds every single ever
543    /// pressed, and a report that is mostly noise is a report nobody opens.
544    ///
545    /// Applied here, on what came back, rather than trusted to the request: a
546    /// server-side filter narrows what travels, but only this decides what is
547    /// shown, so a parameter the service ignores costs bandwidth and not
548    /// correctness.
549    pub fn is_studio_album(&self) -> bool {
550        self.primary_type.as_deref() == Some("Album") && self.secondary_types.is_empty()
551    }
552
553    /// What the source calls this record, in the source's own words.
554    ///
555    /// For a report that has left something out to be able to say **why**. The
556    /// words are MusicBrainz's — `Album · Live`, `Single`, `Album · Compilation`
557    /// — rather than a vocabulary of our own, because the reader's next move is
558    /// often to go and correct the type on the page where it is set, and a
559    /// paraphrase would send them looking for a word that is not written there.
560    ///
561    /// A record nobody has typed at all says so: it is not a studio album
562    /// either, and having no type is exactly why it was left out.
563    pub fn stated_type(&self) -> String {
564        let mut parts: Vec<&str> = Vec::new();
565        if let Some(primary) = &self.primary_type {
566            parts.push(primary);
567        }
568        parts.extend(self.secondary_types.iter().map(String::as_str));
569        match parts.is_empty() {
570            true => "no type".to_string(),
571            false => parts.join(" · "),
572        }
573    }
574
575    /// The year, for a date that may be a year, a month or a full date.
576    pub fn year(&self) -> Option<&str> {
577        self.first_released
578            .as_deref()
579            .and_then(|d| d.get(..4.min(d.len())))
580    }
581}
582
583/// What a source says about one release.
584///
585/// Unlike the artist fields, these do have tag counterparts — Picard writes
586/// `RELEASETYPE`, `DATE` and `LABEL` — which is what makes a release the place
587/// where agreement and disagreement can actually be observed.
588#[derive(Debug, Clone, Default, PartialEq, Eq)]
589pub struct ReleaseFacts {
590    /// Release-group MBID whose exact lookup confirmed the canonical source ID.
591    /// Edition lookups never establish this alias. Optional on legacy snapshots;
592    /// it preserves identity through a merge without replacing the local tag.
593    pub lookup_mbid: Option<String>,
594    /// Album, Single, EP, Broadcast, Other — the release group's primary type.
595    pub primary_type: Option<String>,
596    /// Compilation, Soundtrack, Live, Remix, Demo… any number of them.
597    pub secondary_types: Vec<String>,
598    /// First release date of the group, which is not the date of this edition:
599    /// a 2011 remaster of a 1973 album was first released in 1973.
600    pub first_released: Option<String>,
601    /// Label, as the source names it.
602    pub label: Option<String>,
603    /// The label's own MusicBrainz identifier, when the same answer carried
604    /// one.
605    ///
606    /// Kept beside the name rather than instead of it, the same choice
607    /// [`ArtistFacts::country_code`] makes beside [`ArtistFacts::area`]: the
608    /// source states both, and a reader who only wants to see the label reads
609    /// the name, while `fetch --labels` reads the identifier. Read from the
610    /// very `label-info` entry the name came from — never from a different
611    /// one — so a release crediting several labels is never misattributed.
612    /// Never from a tag: no widely used tag carries a label's MusicBrainz
613    /// identifier, so this can only ever come from a release lookup, unlike
614    /// every other `mbid` in [`crate::model`].
615    pub label_mbid: Option<String>,
616    /// Address of the front image, when a source holds one.
617    ///
618    /// The address rather than the picture: a catalog is a description of a
619    /// library, and megabytes of artwork inside `sources.json` would make every
620    /// load of it slower for something that belongs beside the music. The image
621    /// is written into the album's folder, where the next scan discovers it
622    /// exactly as it would one the user put there.
623    ///
624    /// Kept so that "asked, and there is a cover" can be told from "asked, and
625    /// there is none" — the distinction this whole layer exists for — and so
626    /// that a second run costs nothing.
627    pub cover_art: Option<String>,
628    /// Canonical edition returned for these relationships, absent for a group search.
629    pub edition_mbid: Option<String>,
630    /// Exact edition selector confirmed by a redirected lookup. This is separate
631    /// from the release-group alias and absent from legacy snapshots.
632    pub edition_lookup_mbid: Option<String>,
633    /// Artist relationships on this edition, never on the release group.
634    pub credits: Vec<CreditLink>,
635    /// Distinguishes an empty lookup from an edition never queried for credits.
636    pub relationships_complete: bool,
637}
638
639/// What a source says, for one kind of entity.
640///
641/// An enum rather than a flat bag of `(field, value)` strings: the display,
642/// the query grammar and `doctor` all have to know what a field *means*, and a
643/// generic bag pushes that knowledge into string literals scattered through
644/// the program. [`crate::analysis::FileAnalysis`] made the same choice.
645///
646/// # The two variants are not the same size, on purpose
647///
648/// An artist carries twelve fields, one of them a paragraph; a release carries
649/// four. Clippy points out that a `Facts` is therefore as large as its larger
650/// variant, and the usual remedy is to box that one. It is the wrong remedy
651/// here: artist records are the majority of this layer, so boxing them puts a
652/// heap allocation and a pointer chase on the common path in order to shrink
653/// the rare one. The whole store is a few hundred rows of a few hundred bytes
654/// — under a hundred kilobytes — and it is read once at start-up.
655#[allow(clippy::large_enum_variant)]
656#[derive(Debug, Clone, PartialEq, Eq)]
657pub enum Facts {
658    /// About an artist.
659    Artist(ArtistFacts),
660    /// About a release.
661    Release(ReleaseFacts),
662    /// About one recorded performance, identified by its sound.
663    Track(TrackFacts),
664    /// About a record label, including attributed prose when available.
665    Label(LabelFacts),
666}
667
668impl Facts {
669    /// The kind of entity these facts can describe.
670    ///
671    /// This is what makes a record's entity kind and its contents impossible
672    /// to contradict each other: the kind is derived from the facts rather
673    /// than stored beside them.
674    pub fn kind(&self) -> EntityKind {
675        match self {
676            Facts::Artist(_) => EntityKind::Artist,
677            Facts::Release(_) => EntityKind::Release,
678            Facts::Track(_) => EntityKind::Track,
679            Facts::Label(_) => EntityKind::Label,
680        }
681    }
682
683    /// `true` when the source answered but said nothing at all.
684    ///
685    /// Distinct from having no record: "asked, and it holds nothing about
686    /// this" and "never asked" are two different states, and the whole layer
687    /// exists to keep them apart.
688    pub fn is_empty(&self) -> bool {
689        match self {
690            Facts::Artist(a) => a == &ArtistFacts::default(),
691            Facts::Release(r) => r == &ReleaseFacts::default(),
692            Facts::Track(t) => t == &TrackFacts::default(),
693            Facts::Label(l) => l == &LabelFacts::default(),
694        }
695    }
696}
697
698/// What external sources say about a label already known by name.
699///
700/// MusicBrainz supplies the identifier and may link to Wikidata, which can
701/// lead to a credited Wikipedia summary. The identifier itself lives in
702/// [`SourceRecord::source_id`], the same place every other MusicBrainz
703/// identifier in this layer lives. An optional verified lookup alias can be
704/// retained in its facts without replacing the canonical identity. A record
705/// with no fields still means "asked, and this MBID answered".
706#[derive(Debug, Clone, Default, PartialEq, Eq)]
707pub struct LabelFacts {
708    /// Label MBID whose exact lookup confirmed this canonical source ID.
709    /// Optional on older snapshots; it never upgrades a name-search score.
710    pub lookup_mbid: Option<String>,
711    /// Wikidata link supplied by a MusicBrainz label lookup.
712    pub wikidata: Option<String>,
713    /// Discogs page supplied by a MusicBrainz label lookup.
714    pub discogs: Option<String>,
715    /// Credited prose reached through that Wikidata link.
716    pub summary: Option<Prose>,
717    /// Original Discogs markup, so an unchanged profile can reuse resolved
718    /// label names without repeating every linked-label lookup.
719    pub summary_markup: Option<String>,
720    /// The label's logo, when Fanart.tv supplied one.
721    pub logo: Option<Picture>,
722}
723
724/// What a source says about one recorded performance.
725///
726/// **A recording, not a release.** AcoustID answers what is *playing*, and the
727/// same performance sits on the original album, the compilation and the
728/// deluxe reissue alike — so this identifies the track and says nothing about
729/// which pressing the file was ripped from, which the tags answer better.
730///
731/// AcoustID records arrive as [`Confidence::Matched`]: a fingerprint match is
732/// a strong guess and is wrong in ways that are easy to picture — two
733/// masterings of one recording fingerprint alike, and a very short or silent
734/// track matches a great deal. A later MusicBrainz identifier lookup is
735/// [`Confidence::Identified`] and can add relationship evidence to the same
736/// source record without turning it into a local tag.
737#[derive(Debug, Clone, Default, PartialEq, Eq)]
738pub struct TrackFacts {
739    /// The MusicBrainz recording identifier the source named.
740    pub recording: Option<String>,
741    /// How sure the source said it was, as a percentage.
742    ///
743    /// Rounded on the way in, from the fraction the service states. A
744    /// stored float compares badly — two records that are the same answer
745    /// would not be equal — and the difference between 98.12 % and 98 % is
746    /// one nobody acts on. What a reader wants is a number to put beside a
747    /// tag.
748    pub score: Option<u8>,
749    /// The title, as the source spells it.
750    pub title: Option<String>,
751    /// The artists credited, in the order given.
752    pub artists: Vec<String>,
753    /// One release group the recording appears on, when the source named one.
754    pub album: Option<String>,
755    /// Compositions MusicBrainz explicitly says this recording realizes.
756    ///
757    /// Kept as source evidence rather than copied into the catalog: the same
758    /// recording may be linked to several works (for example a medley), and a
759    /// later reconciliation must be able to show exactly who made each claim.
760    pub works: Vec<WorkLink>,
761    /// Credits attached directly to the recorded performance.
762    pub credits: Vec<CreditLink>,
763    /// Whether the recording relationship lookup completed, including when it
764    /// returned no work or credit. This distinguishes “nothing there” from an
765    /// older record that has never been asked for rich relationships.
766    pub relationships_complete: bool,
767    /// Recording selector whose completed lookup confirmed this identity.
768    ///
769    /// A merged MusicBrainz ID can return a different canonical `recording`.
770    /// Keeping the question separately lets a corrected local ID reopen the
771    /// lookup without repeatedly requesting unchanged aliases. Refreshing the
772    /// same canonical recording retains an earlier verified alias. Legacy
773    /// receipts use their source identifier until the next successful lookup.
774    /// A completed MusicBrainz snapshot with a consistent canonical recording
775    /// can also establish this alias during identity checks, without overriding
776    /// its confidence or explicit review.
777    pub relationships_for: Option<String>,
778}
779
780/// One artist relationship asserted by MusicBrainz.
781#[derive(Debug, Clone, PartialEq, Eq)]
782pub struct CreditLink {
783    /// Relationship row identifier, when the web service exposes it.
784    pub relation_id: Option<String>,
785    /// Stable identifier of the relationship type.
786    pub role_id: Option<String>,
787    /// Human-readable relationship type: performer, producer, composer…
788    pub role: String,
789    /// Direction of the MusicBrainz relationship from the queried entity.
790    pub direction: Option<String>,
791    /// MusicBrainz artist identifier.
792    pub artist_mbid: String,
793    /// Canonical artist name returned by MusicBrainz.
794    pub artist_name: String,
795    /// Name under which the artist was actually credited.
796    pub credited_as: Option<String>,
797    /// Instruments, qualifiers and other relationship attributes.
798    pub attributes: Vec<CreditAttribute>,
799    /// Optional beginning of the relationship's validity period.
800    pub began: Option<String>,
801    /// Optional end of the relationship's validity period.
802    pub ended: Option<String>,
803    /// Whether MusicBrainz explicitly marks the relationship as ended.
804    pub over: Option<bool>,
805    /// Ordering key supplied by MusicBrainz.
806    pub order: Option<u32>,
807}
808
809/// One work relationship asserted by a source about a recording.
810///
811/// The identifier is mandatory. A title is useful for a reader, but it is not
812/// identity and must never be used to join two compositions.
813#[derive(Debug, Clone, Default, PartialEq, Eq)]
814pub struct WorkLink {
815    /// MusicBrainz work identifier.
816    pub mbid: String,
817    /// Title as the source spells it.
818    pub title: String,
819    /// Identifier of the recording-to-work relationship, when exposed.
820    pub relation_id: Option<String>,
821    /// Relationship type, normally `performance`.
822    pub relation_type: Option<String>,
823    /// Stable identifier of the recording-to-work relationship type.
824    pub relation_type_id: Option<String>,
825    /// Direction of the relationship from the queried recording.
826    pub direction: Option<String>,
827    /// Attributes such as live, cover, instrumental, partial or medley.
828    pub attributes: Vec<CreditAttribute>,
829    /// Artist relationships attached to the composition itself.
830    pub credits: Vec<CreditLink>,
831    /// Explicitly identified containing works, such as a symphony for one movement.
832    pub parents: Vec<WorkParentLink>,
833}
834
835/// One source-asserted part-of-work relationship, directed child → parent.
836#[derive(Debug, Clone, Default, PartialEq, Eq)]
837pub struct WorkParentLink {
838    /// Exact identity of the containing work; a matching title is insufficient.
839    pub mbid: String,
840    /// Title supplied for the containing work.
841    pub title: String,
842    /// Identifier of this exact MusicBrainz relationship.
843    pub relation_id: Option<String>,
844    /// Stable MusicBrainz relationship type identifier.
845    pub relation_type_id: Option<String>,
846    /// Direction as returned for the child work.
847    pub direction: Option<String>,
848    /// Qualifiers such as movement, act or part of collection.
849    pub attributes: Vec<CreditAttribute>,
850    /// MusicBrainz ordering key when available.
851    pub order: Option<u32>,
852}
853
854/// A work relationship placed on a local recording, while retaining its source.
855///
856/// This is a read-only reconciliation view. It deliberately does not alter
857/// [`crate::model::Catalog`]: a scan rebuilds that catalog from local files,
858/// whereas a source claim must remain attributable, reviewable and removable.
859#[derive(Debug, Clone, PartialEq, Eq)]
860pub struct SourcedWorkLink {
861    /// The local recording the source record is attached to through its track.
862    pub recording_id: Id,
863    /// The composition MusicBrainz linked to that recording.
864    pub work: WorkLink,
865    /// The service making the assertion.
866    pub source: String,
867    /// How firmly the source record was attached to the local file.
868    pub confidence: Confidence,
869    /// Explicit review decision, when the owner made one.
870    pub review: Option<ReviewDecision>,
871    /// Whether this claim is safe to traverse after confidence, conflicts and
872    /// the explicit review have all been considered.
873    pub trusted: bool,
874    /// When the assertion was fetched.
875    pub fetched_at: u64,
876}
877
878/// A sourced child → parent work assertion attached to a local recording.
879#[derive(Debug, Clone, PartialEq, Eq)]
880pub struct SourcedWorkParentLink {
881    /// The local recording through which the child work was discovered.
882    pub recording_id: Id,
883    /// Performances of the child work to which this relationship applies.
884    pub applicable_recording_ids: Arc<[Id]>,
885    /// Exact identity of the child work.
886    pub child_mbid: String,
887    /// Title supplied for the child work.
888    pub child_title: String,
889    /// The containing work and the source relationship's details.
890    pub parent: WorkParentLink,
891    /// Service making the assertion.
892    pub source: String,
893    /// Firmness of the attachment to the local recording.
894    pub confidence: Confidence,
895    /// Explicit review decision, if one exists.
896    pub review: Option<ReviewDecision>,
897    /// Whether this relationship may be traversed.
898    pub trusted: bool,
899    /// Time at which the assertion was fetched.
900    pub fetched_at: u64,
901}
902
903/// One externally described composition, grouped without losing its evidence.
904///
905/// A work fetched from MusicBrainz is useful for navigation before the audio
906/// files carry a `MUSICBRAINZ_WORKID`, but it does not thereby become a local
907/// fact. Every relationship remains available in `links`, with its source,
908/// confidence and fetch date.
909#[derive(Debug, Clone, PartialEq, Eq)]
910pub struct SourcedWork {
911    /// MusicBrainz work identifier; the only identity used for grouping.
912    pub mbid: String,
913    /// Best non-empty title supplied by the source.
914    pub title: String,
915    /// Recording relationships that justify this external work view.
916    pub links: Vec<SourcedWorkLink>,
917}
918
919/// A rich external credit placed on a local recording or one of its works.
920#[derive(Debug, Clone, PartialEq, Eq)]
921pub struct SourcedCreditLink {
922    /// Local observation host reached through the source record's track.
923    pub recording_id: Id,
924    /// Performances to which this credit applies, without duplicating evidence.
925    pub applicable_recording_ids: Arc<[Id]>,
926    /// Work carrying the credit; absent for recording-level credits.
927    pub work: Option<WorkLink>,
928    /// Who did what, with the relationship's own details.
929    pub credit: CreditLink,
930    /// Service making the assertion.
931    pub source: String,
932    /// Firmness of the source record's attachment to the local track.
933    pub confidence: Confidence,
934    /// Explicit review decision, when the owner made one.
935    pub review: Option<ReviewDecision>,
936    /// Whether this relationship may participate in traversal and queries.
937    pub trusted: bool,
938    /// The owner excluded this precise credit, without deleting the assertion.
939    pub excluded: bool,
940    /// Time at which this assertion was fetched.
941    pub fetched_at: u64,
942}
943
944/// A credit asserted about one precise edition, not every recording on it.
945#[derive(Debug, Clone, PartialEq, Eq)]
946pub struct SourcedEditionCreditLink {
947    /// Local edition these credits describe.
948    pub release_id: Id,
949    /// Artist relationship and all of its original qualifiers.
950    pub credit: CreditLink,
951    /// Service making the assertion.
952    pub source: String,
953    /// Firmness of the source record's attachment.
954    pub confidence: Confidence,
955    /// Explicit review of the source identity, if any.
956    pub review: Option<ReviewDecision>,
957    /// Whether the credit may participate in navigation and queries.
958    pub trusted: bool,
959    /// The owner excluded this precise credit, without deleting the assertion.
960    pub excluded: bool,
961    /// Time at which this assertion was fetched.
962    pub fetched_at: u64,
963}
964
965/// A reversible choice about one credit relationship, not its parent record.
966#[derive(Debug, Clone, PartialEq, Eq)]
967pub struct CreditExclusion {
968    /// Stable relationship identity, including source and source row ID.
969    pub relation: RelationRef,
970    /// Time of the owner's decision.
971    pub excluded_at: u64,
972}
973
974/// A dated artist-to-artist membership placed beside the canonical catalog.
975#[derive(Debug, Clone, PartialEq, Eq)]
976pub struct SourcedMembershipLink {
977    /// Local artist whose source record carried the relationship.
978    pub artist_id: Id,
979    /// The other endpoint when its MusicBrainz identity is also local.
980    pub related_artist_id: Option<Id>,
981    /// The complete source relationship, including side, dates and attributes.
982    pub membership: Membership,
983    /// Service making the assertion.
984    pub source: String,
985    /// Firmness of the source record's attachment to the local artist.
986    pub confidence: Confidence,
987    /// Explicit review decision, when the owner made one.
988    pub review: Option<ReviewDecision>,
989    /// Whether this relationship may participate in traversal.
990    pub trusted: bool,
991    /// Time at which this assertion was fetched.
992    pub fetched_at: u64,
993}
994
995/// A MusicBrainz identity evidence record placed on its local label.
996///
997/// Unlike a name-search match, this view contains only identifier lookups.
998/// It is therefore safe for callers to use as an external identity link while
999/// still keeping the source record that justifies it.
1000#[derive(Debug, Clone, PartialEq, Eq)]
1001pub struct SourcedLabelIdentity {
1002    /// The local label identified by the source record.
1003    pub label_id: Id,
1004    /// MusicBrainz label identifier.
1005    pub mbid: String,
1006    /// When MusicBrainz confirmed this identity.
1007    pub fetched_at: u64,
1008}
1009
1010/// Reconciliation of a label's tag identity with MusicBrainz evidence.
1011///
1012/// Nothing in this enum changes the catalog. It makes the decision explicit:
1013/// an identifier lookup may confirm a tag or supply a missing identity, a
1014/// name search is only a proposal, and two different identifiers are a
1015/// conflict to review rather than a winner chosen in silence.
1016#[derive(Debug, Clone, PartialEq, Eq)]
1017pub enum LabelIdentityResolution {
1018    /// Only the audio tags carry an identifier.
1019    Local {
1020        /// Identifier explicitly read from the file tags.
1021        mbid: String,
1022    },
1023    /// MusicBrainz identified the label and local tags had no identifier.
1024    Confirmed {
1025        /// Identifier asserted by the source.
1026        mbid: String,
1027        /// Service making the assertion.
1028        source: String,
1029        /// Time of the lookup.
1030        fetched_at: u64,
1031    },
1032    /// Local tags and the identified source name the same verified identity.
1033    Agrees {
1034        /// Canonical identifier, including when the tag names a verified alias.
1035        mbid: String,
1036        /// Service confirming the local value.
1037        source: String,
1038        /// Time of the lookup.
1039        fetched_at: u64,
1040    },
1041    /// A name search returned a candidate; it is not an identity.
1042    Suggested {
1043        /// Candidate identifier.
1044        mbid: String,
1045        /// Service returning the candidate.
1046        source: String,
1047        /// Approximate attachment score.
1048        confidence: Confidence,
1049        /// Time of the search.
1050        fetched_at: u64,
1051    },
1052    /// A certain source lookup contradicts the explicit local identifier.
1053    Conflict {
1054        /// Identifier read from the file tags, which remains authoritative.
1055        local_mbid: String,
1056        /// Different identifier asserted by the source.
1057        sourced_mbid: String,
1058        /// Service making the contradictory assertion.
1059        source: String,
1060        /// Time of the lookup.
1061        fetched_at: u64,
1062    },
1063    /// An approximate proposal or conflicting source identity was explicitly
1064    /// accepted without rewriting the local tag.
1065    Accepted {
1066        /// Identifier chosen for source-backed traversal.
1067        mbid: String,
1068        /// Identifier still carried by local tags, when this resolved a
1069        /// conflict rather than an approximate proposal.
1070        local_mbid: Option<String>,
1071        /// Service whose claim was accepted.
1072        source: String,
1073        /// Time at which the underlying source claim was fetched.
1074        fetched_at: u64,
1075        /// Time of the review.
1076        reviewed_at: u64,
1077    },
1078    /// A proposal or conflict was explicitly rejected; the local identifier,
1079    /// when present, remains the usable one.
1080    Rejected {
1081        /// Identifier retained from local tags, if one exists.
1082        local_mbid: Option<String>,
1083        /// Source identifier that was rejected.
1084        sourced_mbid: String,
1085        /// Service whose claim was rejected.
1086        source: String,
1087        /// Time of the review.
1088        reviewed_at: u64,
1089    },
1090}
1091
1092/// One entity, as one source describes it.
1093///
1094/// The key is the entity's [`EntityRef`] key rather than a catalog id, for the
1095/// reason annotations use the same scheme: ids are positions that a scan
1096/// renumbers, and a record may describe an entity the catalog does not hold
1097/// yet.
1098#[derive(Debug, Clone, PartialEq, Eq)]
1099pub struct SourceRecord {
1100    /// The [`EntityRef`] key of the entity described.
1101    pub key: String,
1102    /// Who says so: [`MUSICBRAINZ`], or the name of a plugin.
1103    pub source: String,
1104    /// The identifier that source uses — an MBID — which is what turns a
1105    /// second fetch into an update rather than a duplicate.
1106    pub source_id: Option<String>,
1107    /// When it was fetched, in seconds since the Unix epoch, so a value can be
1108    /// shown as old rather than silently trusted forever.
1109    pub fetched_at: u64,
1110    /// How firmly this is attached to the entity.
1111    pub confidence: Confidence,
1112    /// What was said.
1113    pub facts: Facts,
1114}
1115
1116/// The MusicBrainz entity actually named by an album source snapshot.
1117///
1118/// Ordinary release lookups retain the release-group ID. A lookup without a
1119/// group retains the edition ID instead; these namespaces are not interchangeable.
1120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1121pub enum MusicBrainzReleaseIdentity<'a> {
1122    /// A release edition, retained when the lookup supplied no group identity.
1123    Edition(&'a str),
1124    /// The release group shared by the album's editions.
1125    ReleaseGroup(&'a str),
1126}
1127
1128impl MusicBrainzReleaseIdentity<'_> {
1129    /// Resolve only the corresponding local namespace, without deciding trust.
1130    pub(crate) fn local_id<'a>(
1131        self,
1132        catalog: &'a crate::model::Catalog,
1133        release: &'a crate::model::Release,
1134    ) -> Option<&'a str> {
1135        match self {
1136            Self::Edition(_) => release.mbid.as_deref(),
1137            Self::ReleaseGroup(_) => release
1138                .release_group_id
1139                .and_then(|group| catalog.release_group(group))
1140                .map(|group| group.mbid.as_str()),
1141        }
1142    }
1143}
1144
1145impl SourceRecord {
1146    /// Retained MusicBrainz recording identity when its two stored selectors agree.
1147    /// Legacy snapshots may carry only the source ID or only the recording fact;
1148    /// two contradictory present IDs establish neither as a reusable identity.
1149    /// This neither proves lookup completion nor decides confidence/review trust.
1150    pub fn musicbrainz_recording_identity(&self) -> Option<&str> {
1151        if self.source != MUSICBRAINZ {
1152            return None;
1153        }
1154        let Facts::Track(facts) = &self.facts else {
1155            return None;
1156        };
1157        match (self.source_id.as_deref(), facts.recording.as_deref()) {
1158            (Some(source), Some(recording)) if source != recording => None,
1159            (source, recording) => source.or(recording),
1160        }
1161        .filter(|id| !id.trim().is_empty())
1162    }
1163
1164    /// A valid exact-lookup selector confirmed by this canonical MusicBrainz claim.
1165    /// Legacy snapshots without query provenance establish no alias. Recording
1166    /// receipts must also retain a completed, consistent canonical recording.
1167    pub fn musicbrainz_lookup_alias(&self) -> Option<&str> {
1168        if self.source != MUSICBRAINZ {
1169            return None;
1170        }
1171        let canonical = self
1172            .source_id
1173            .as_deref()
1174            .filter(|id| crate::musicbrainz::valid_id(id))?;
1175        let alias = match &self.facts {
1176            Facts::Artist(facts) => facts.lookup_mbid.as_deref(),
1177            Facts::Label(facts) => facts.lookup_mbid.as_deref(),
1178            Facts::Release(facts)
1179                if matches!(
1180                    self.musicbrainz_release_identity(),
1181                    Some(MusicBrainzReleaseIdentity::ReleaseGroup(_))
1182                ) =>
1183            {
1184                facts.lookup_mbid.as_deref()
1185            }
1186            Facts::Release(facts)
1187                if matches!(
1188                    self.musicbrainz_release_identity(),
1189                    Some(MusicBrainzReleaseIdentity::Edition(_))
1190                ) && facts.relationships_complete =>
1191            {
1192                facts.edition_lookup_mbid.as_deref()
1193            }
1194            Facts::Track(facts)
1195                if facts.relationships_complete
1196                    && facts.recording.is_some()
1197                    && self.musicbrainz_recording_identity() == Some(canonical) =>
1198            {
1199                facts.relationships_for.as_deref()
1200            }
1201            _ => None,
1202        };
1203        alias.filter(|id| crate::musicbrainz::valid_id(id))
1204    }
1205
1206    /// Whether retained edition facts describe this exact local edition.
1207    /// MusicBrainz may additionally prove one queried alias of the canonical
1208    /// edition; manual and other sources keep their literal scope. Neither a
1209    /// group alias nor missing/malformed identifiers establishes an edition.
1210    pub fn matches_edition(&self, id: &str) -> bool {
1211        let Facts::Release(facts) = &self.facts else {
1212            return false;
1213        };
1214        let Some(canonical) = facts
1215            .edition_mbid
1216            .as_deref()
1217            .filter(|id| !id.trim().is_empty())
1218        else {
1219            return false;
1220        };
1221        if id == canonical {
1222            return true;
1223        }
1224        self.source == MUSICBRAINZ
1225            && facts.relationships_complete
1226            && self
1227                .source_id
1228                .as_deref()
1229                .is_some_and(crate::musicbrainz::valid_id)
1230            && crate::musicbrainz::valid_id(canonical)
1231            && crate::musicbrainz::valid_id(id)
1232            && facts.edition_lookup_mbid.as_deref() == Some(id)
1233    }
1234
1235    /// The entity this record describes.
1236    pub fn entity(&self) -> EntityRef {
1237        EntityRef {
1238            kind: self.facts.kind(),
1239            key: self.key.clone(),
1240        }
1241    }
1242
1243    /// Local identifier in this claim's MusicBrainz namespace, without deciding trust.
1244    /// Release editions and release groups must never be compared across namespaces.
1245    pub fn local_musicbrainz_id<'a>(&self, catalog: &'a crate::model::Catalog) -> Option<&'a str> {
1246        if self.source != MUSICBRAINZ {
1247            return None;
1248        }
1249        local_musicbrainz_id(catalog, self)
1250    }
1251
1252    /// The retained MusicBrainz album identifier with its original namespace.
1253    ///
1254    /// An edition fallback is identified by the same ID in `source_id` and
1255    /// [`ReleaseFacts::edition_mbid`], as stored by [`crate::musicbrainz::release`].
1256    /// Older snapshots without an edition ID retain their established group
1257    /// interpretation. This classifies the stored claim; it neither establishes
1258    /// trust nor invents a missing group identifier.
1259    pub fn musicbrainz_release_identity(&self) -> Option<MusicBrainzReleaseIdentity<'_>> {
1260        if self.source != MUSICBRAINZ {
1261            return None;
1262        }
1263        let Facts::Release(facts) = &self.facts else {
1264            return None;
1265        };
1266        let id = self.source_id.as_deref()?;
1267        Some(if facts.edition_mbid.as_deref() == Some(id) {
1268            MusicBrainzReleaseIdentity::Edition(id)
1269        } else {
1270            MusicBrainzReleaseIdentity::ReleaseGroup(id)
1271        })
1272    }
1273}
1274
1275// --------------------------------------------------------------------------
1276// The store
1277// --------------------------------------------------------------------------
1278
1279/// Everything other sources have said, as it sits in `sources.json`.
1280#[derive(Debug, Clone, Default, PartialEq, Eq)]
1281pub struct Sources {
1282    /// One row per entity and source.
1283    pub records: Vec<SourceRecord>,
1284    /// Explicit answers to claims that could not safely resolve themselves.
1285    pub reviews: Vec<SourceReview>,
1286    /// User decisions about individual source credits.
1287    pub credit_exclusions: Vec<CreditExclusion>,
1288}
1289
1290impl Sources {
1291    /// What one source says about one entity, if it has said anything.
1292    pub fn get(&self, entity: &EntityRef, source: &str) -> Option<&SourceRecord> {
1293        self.records
1294            .iter()
1295            .find(|r| r.source == source && r.key == entity.key && r.facts.kind() == entity.kind)
1296    }
1297
1298    /// Everything every source says about one entity.
1299    ///
1300    /// Several rows are the point rather than a defect: two sources that
1301    /// disagree are two rows, and the disagreement is reported instead of
1302    /// being resolved behind the user's back.
1303    pub fn about<'a>(&'a self, entity: &'a EntityRef) -> impl Iterator<Item = &'a SourceRecord> {
1304        self.records
1305            .iter()
1306            .filter(move |r| r.key == entity.key && r.facts.kind() == entity.kind)
1307    }
1308
1309    /// The current decision for this exact source claim.
1310    pub fn review_for(&self, record: &SourceRecord) -> Option<&SourceReview> {
1311        let entity = record.entity();
1312        self.reviews.iter().find(|review| {
1313            review.entity == entity
1314                && review.source == record.source
1315                && review.source_id == record.source_id
1316        })
1317    }
1318
1319    /// Whether a source record is safe to use as a graph relationship.
1320    ///
1321    /// A decision wins over the mechanical confidence. With no decision, only
1322    /// exact attachments that do not contradict a local MusicBrainz identity
1323    /// are trusted.
1324    pub fn is_trusted(&self, catalog: &crate::model::Catalog, record: &SourceRecord) -> bool {
1325        if let Some(review) = self.review_for(record) {
1326            return trusted_with_identity(record, None, Some(review.decision));
1327        }
1328        if self.contradicts_linked_identity(catalog, record) {
1329            return false;
1330        }
1331        let local_id = if record.source == MUSICBRAINZ && record.confidence.is_certain() {
1332            local_musicbrainz_id(catalog, record)
1333        } else {
1334            None
1335        };
1336        trusted_with_identity(record, local_id, None)
1337    }
1338
1339    /// Whether a biography cache belongs to a different known provider identity.
1340    ///
1341    /// Only a trusted MusicBrainz relationship can establish a contradiction.
1342    /// Missing relationships and pending proposals do not invalidate independent
1343    /// evidence. This is a cache identity check, not a review decision: explicit
1344    /// reviews of the biography itself still take priority in `is_trusted`.
1345    pub fn contradicts_linked_identity(
1346        &self,
1347        catalog: &crate::model::Catalog,
1348        record: &SourceRecord,
1349    ) -> bool {
1350        if !matches!(
1351            record.source.as_str(),
1352            crate::wikipedia::SOURCE | crate::discogs::SOURCE
1353        ) {
1354            return false;
1355        }
1356        let Some(identity) = self.get(&record.entity(), MUSICBRAINZ) else {
1357            return false;
1358        };
1359        if !self.is_trusted(catalog, identity) {
1360            return false;
1361        }
1362        let expected = match (record.source.as_str(), &identity.facts) {
1363            (crate::wikipedia::SOURCE, Facts::Artist(facts)) => facts
1364                .wikidata
1365                .as_deref()
1366                .and_then(crate::wikipedia::entity_id),
1367            (crate::wikipedia::SOURCE, Facts::Label(facts)) => facts
1368                .wikidata
1369                .as_deref()
1370                .and_then(crate::wikipedia::entity_id),
1371            (crate::discogs::SOURCE, Facts::Label(facts)) => facts
1372                .discogs
1373                .as_deref()
1374                .and_then(crate::discogs::label_id)
1375                .map(|id| id.to_string()),
1376            _ => None,
1377        };
1378        expected.is_some_and(|id| record.source_id.as_deref() != Some(id.as_str()))
1379    }
1380
1381    /// Trust a track claim whose canonical recording is already resolved.
1382    /// Reusing that endpoint avoids searching every local path for each claim.
1383    pub(crate) fn is_trusted_recording(
1384        &self,
1385        catalog: &crate::model::Catalog,
1386        record: &SourceRecord,
1387        recording_id: Id,
1388    ) -> bool {
1389        if let Some(review) = self.review_for(record) {
1390            return trusted_with_identity(record, None, Some(review.decision));
1391        }
1392        let local_id = catalog
1393            .recording(recording_id)
1394            .and_then(|recording| recording.mbid.as_deref());
1395        trusted_with_identity(record, local_id, None)
1396    }
1397
1398    /// Every current claim requiring or carrying an explicit review.
1399    pub fn review_items(&self, catalog: &crate::model::Catalog) -> Vec<ReviewItem> {
1400        let mut items = Vec::new();
1401        for record in &self.records {
1402            let review = self.review_for(record);
1403            let reason = match record.confidence {
1404                Confidence::Matched(score) => ReviewReason::Approximate { score },
1405                Confidence::Identified => match identity_conflict(catalog, record) {
1406                    Some((local_id, sourced_id)) => ReviewReason::IdentityConflict {
1407                        local_id,
1408                        sourced_id,
1409                    },
1410                    None if review.is_some() => ReviewReason::PriorDecision,
1411                    None => continue,
1412                },
1413            };
1414            items.push(ReviewItem {
1415                id: review_id(record),
1416                entity: record.entity(),
1417                source: record.source.clone(),
1418                source_id: record.source_id.clone(),
1419                reason,
1420                decision: review.map(|review| review.decision),
1421                reviewed_at: review.map(|review| review.reviewed_at),
1422            });
1423        }
1424        items.sort_by(|left, right| {
1425            left.entity
1426                .kind
1427                .as_str()
1428                .cmp(right.entity.kind.as_str())
1429                .then_with(|| left.entity.key.cmp(&right.entity.key))
1430                .then_with(|| left.source.cmp(&right.source))
1431                .then_with(|| left.source_id.cmp(&right.source_id))
1432        });
1433        items
1434    }
1435
1436    /// Accepts or rejects one review item selected by its full ID or an
1437    /// unambiguous prefix. Returns the reviewed item as it now stands.
1438    pub fn decide(
1439        &mut self,
1440        catalog: &crate::model::Catalog,
1441        selector: &str,
1442        decision: ReviewDecision,
1443        reviewed_at: u64,
1444    ) -> Result<ReviewItem, String> {
1445        let item = select_review_item(self.review_items(catalog), selector)?;
1446        self.set_review(SourceReview {
1447            entity: item.entity.clone(),
1448            source: item.source.clone(),
1449            source_id: item.source_id.clone(),
1450            decision,
1451            reviewed_at,
1452        });
1453        let mut decided = item;
1454        decided.decision = Some(decision);
1455        decided.reviewed_at = Some(reviewed_at);
1456        Ok(decided)
1457    }
1458
1459    /// Removes one explicit decision, returning the item to pending state.
1460    pub fn clear_review(
1461        &mut self,
1462        catalog: &crate::model::Catalog,
1463        selector: &str,
1464    ) -> Result<ReviewItem, String> {
1465        let item = select_review_item(self.review_items(catalog), selector)?;
1466        let before = self.reviews.len();
1467        self.reviews.retain(|review| {
1468            !(review.entity == item.entity
1469                && review.source == item.source
1470                && review.source_id == item.source_id)
1471        });
1472        if self.reviews.len() == before {
1473            return Err(format!("review {} has no decision to undo", item.id));
1474        }
1475        let mut cleared = item;
1476        cleared.decision = None;
1477        cleared.reviewed_at = None;
1478        Ok(cleared)
1479    }
1480
1481    /// Stores one review, replacing the previous decision about the same
1482    /// exact claim.
1483    pub fn set_review(&mut self, review: SourceReview) -> bool {
1484        let same = |held: &SourceReview| {
1485            held.entity == review.entity
1486                && held.source == review.source
1487                && held.source_id == review.source_id
1488        };
1489        match self.reviews.iter().position(same) {
1490            Some(index) => {
1491                self.reviews[index] = review;
1492                true
1493            }
1494            None => {
1495                self.reviews.push(review);
1496                false
1497            }
1498        }
1499    }
1500
1501    /// Files a record, replacing whatever that same source said before.
1502    ///
1503    /// Returns `true` when it replaced an existing row. A second fetch is an
1504    /// update, not a duplicate — but only for the same source: what
1505    /// MusicBrainz says never overwrites what a plugin said, which is the
1506    /// whole reason the source is part of the key.
1507    pub fn set(&mut self, record: SourceRecord) -> bool {
1508        let same = |r: &SourceRecord| {
1509            r.source == record.source
1510                && r.key == record.key
1511                && r.facts.kind() == record.facts.kind()
1512        };
1513        match self.records.iter().position(same) {
1514            Some(i) => {
1515                if self.records[i].source_id != record.source_id {
1516                    let old = self.records[i].entity();
1517                    self.reviews
1518                        .retain(|review| !(review.entity == old && review.source == record.source));
1519                }
1520                self.records[i] = record;
1521                true
1522            }
1523            None => {
1524                self.records.push(record);
1525                false
1526            }
1527        }
1528    }
1529
1530    /// Source-asserted recording-to-work links that this catalog can place.
1531    ///
1532    /// The source record is keyed by a local track path, while the graph's
1533    /// relationship belongs to the canonical recording carrying that track.
1534    /// Resolving this only when read keeps rescans and source updates
1535    /// independent. Conflicting sources are intentionally all returned.
1536    pub fn work_links(&self, catalog: &crate::model::Catalog) -> Vec<SourcedWorkLink> {
1537        if !self
1538            .records
1539            .iter()
1540            .any(|record| matches!(record.facts, Facts::Track(_)))
1541        {
1542            return Vec::new();
1543        }
1544        let recordings = recordings_by_path(catalog);
1545        self.records
1546            .iter()
1547            .filter_map(|record| {
1548                let Facts::Track(facts) = &record.facts else {
1549                    return None;
1550                };
1551                let recording_id = *recordings.get(record.key.as_str())?;
1552                let review = self.review_for(record).map(|review| review.decision);
1553                let trusted = self.is_trusted_recording(catalog, record, recording_id);
1554                Some(
1555                    facts
1556                        .works
1557                        .iter()
1558                        .cloned()
1559                        .map(move |work| SourcedWorkLink {
1560                            recording_id,
1561                            work,
1562                            source: record.source.clone(),
1563                            confidence: record.confidence,
1564                            review,
1565                            trusted,
1566                            fetched_at: record.fetched_at,
1567                        }),
1568                )
1569            })
1570            .flatten()
1571            .collect()
1572    }
1573
1574    /// Explicit part-of-work assertions, retaining the attachment's trust.
1575    pub fn work_parent_links(&self, catalog: &crate::model::Catalog) -> Vec<SourcedWorkParentLink> {
1576        let links = self.work_links(catalog);
1577        if !links.iter().any(|link| !link.work.parents.is_empty()) {
1578            return Vec::new();
1579        }
1580        let work_recordings = recordings_by_work(
1581            catalog,
1582            links
1583                .iter()
1584                .filter(|link| link.trusted)
1585                .map(|link| (link.recording_id, link.work.mbid.as_str())),
1586        );
1587        links
1588            .iter()
1589            .flat_map(|link| {
1590                let applicable_recording_ids = match work_recordings.get(link.work.mbid.as_str()) {
1591                    Some(recordings) if link.trusted => Arc::clone(recordings),
1592                    _ => vec![link.recording_id].into(),
1593                };
1594                link.work
1595                    .parents
1596                    .iter()
1597                    .cloned()
1598                    .map(move |parent| SourcedWorkParentLink {
1599                        recording_id: link.recording_id,
1600                        applicable_recording_ids: Arc::clone(&applicable_recording_ids),
1601                        child_mbid: link.work.mbid.clone(),
1602                        child_title: link.work.title.clone(),
1603                        parent,
1604                        source: link.source.clone(),
1605                        confidence: link.confidence,
1606                        review: link.review,
1607                        trusted: link.trusted,
1608                        fetched_at: link.fetched_at,
1609                    })
1610                    .collect::<Vec<_>>()
1611            })
1612            .collect()
1613    }
1614
1615    /// Rich recording and work credits that can be placed on this catalog.
1616    ///
1617    /// The relationship remains external evidence. Mapping the source record's
1618    /// track to its canonical recording makes it traversable without copying
1619    /// the claim into the tag-built catalog.
1620    pub fn credit_links(&self, catalog: &crate::model::Catalog) -> Vec<SourcedCreditLink> {
1621        let mut links = Vec::new();
1622        if !self
1623            .records
1624            .iter()
1625            .any(|record| matches!(record.facts, Facts::Track(_)))
1626        {
1627            return links;
1628        }
1629        let recordings = recordings_by_path(catalog);
1630        let mut sourced_works = Vec::new();
1631        for record in &self.records {
1632            let Facts::Track(facts) = &record.facts else {
1633                continue;
1634            };
1635            let Some(&recording_id) = recordings.get(record.key.as_str()) else {
1636                continue;
1637            };
1638            let review = self.review_for(record).map(|review| review.decision);
1639            let trusted = self.is_trusted_recording(catalog, record, recording_id);
1640            // A known manual performance must not follow its observation
1641            // file onto a different recording. Legacy local-only snapshots
1642            // still attach by path, and composition claims stay independent.
1643            let direct_scope_matches = record.source != "manual"
1644                || facts.recording.as_deref().is_none_or(|held| {
1645                    catalog
1646                        .recording(recording_id)
1647                        .and_then(|recording| recording.mbid.as_deref())
1648                        == Some(held)
1649                });
1650            for credit in facts.credits.iter().filter(|_| direct_scope_matches) {
1651                links.push(SourcedCreditLink {
1652                    recording_id,
1653                    applicable_recording_ids: vec![recording_id].into(),
1654                    work: None,
1655                    credit: credit.clone(),
1656                    source: record.source.clone(),
1657                    confidence: record.confidence,
1658                    review,
1659                    trusted,
1660                    excluded: false,
1661                    fetched_at: record.fetched_at,
1662                });
1663            }
1664            for work in &facts.works {
1665                if trusted && !work.mbid.is_empty() {
1666                    sourced_works.push((recording_id, work.mbid.as_str()));
1667                }
1668                for credit in &work.credits {
1669                    links.push(SourcedCreditLink {
1670                        recording_id,
1671                        applicable_recording_ids: vec![recording_id].into(),
1672                        work: Some(work.clone()),
1673                        credit: credit.clone(),
1674                        source: record.source.clone(),
1675                        confidence: record.confidence,
1676                        review,
1677                        trusted,
1678                        excluded: false,
1679                        fetched_at: record.fetched_at,
1680                    });
1681                }
1682            }
1683        }
1684        let work_recordings = recordings_by_work(catalog, sourced_works);
1685        for link in &mut links {
1686            if !self.credit_exclusions.is_empty() {
1687                link.excluded =
1688                    crate::graph::credit_reference(catalog, link).is_some_and(|reference| {
1689                        self.credit_exclusions.iter().any(|decision| {
1690                            crate::graph::same_credit_relation(
1691                                catalog,
1692                                &decision.relation,
1693                                &reference,
1694                            )
1695                        })
1696                    });
1697                link.trusted &= !link.excluded;
1698            }
1699            if link.trusted
1700                && let Some(recordings) = link
1701                    .work
1702                    .as_ref()
1703                    .and_then(|work| work_recordings.get(work.mbid.as_str()))
1704            {
1705                link.applicable_recording_ids = Arc::clone(recordings);
1706            }
1707        }
1708        links
1709    }
1710
1711    /// Credits anchored to an exact fetched edition or an explicit local
1712    /// manual correction. A release-group-only answer cannot supply these.
1713    pub fn edition_credit_links(
1714        &self,
1715        catalog: &crate::model::Catalog,
1716    ) -> Vec<SourcedEditionCreditLink> {
1717        let mut links = Vec::new();
1718        for record in &self.records {
1719            let Facts::Release(facts) = &record.facts else {
1720                continue;
1721            };
1722            let Some(release_id) = record.entity().resolve(catalog) else {
1723                continue;
1724            };
1725            let Some(release) = catalog.release(release_id) else {
1726                continue;
1727            };
1728            let local_manual = record.source == "manual" && facts.edition_mbid.is_none();
1729            let identified_edition = release
1730                .mbid
1731                .as_deref()
1732                .is_some_and(|id| record.matches_edition(id));
1733            // The user's explicit local scope is independent of whether its
1734            // tags carry an MBID. A fetched missing ID proves no edition.
1735            if !local_manual && !identified_edition {
1736                continue;
1737            }
1738            let review = self.review_for(record).map(|review| review.decision);
1739            let trusted = self.is_trusted(catalog, record);
1740            for credit in &facts.credits {
1741                links.push(SourcedEditionCreditLink {
1742                    release_id,
1743                    credit: credit.clone(),
1744                    source: record.source.clone(),
1745                    confidence: record.confidence,
1746                    review,
1747                    trusted,
1748                    excluded: false,
1749                    fetched_at: record.fetched_at,
1750                });
1751            }
1752        }
1753        if self.credit_exclusions.is_empty() {
1754            return links;
1755        }
1756        for link in &mut links {
1757            link.excluded =
1758                crate::graph::edition_credit_reference(catalog, link).is_some_and(|reference| {
1759                    self.credit_exclusions.iter().any(|decision| {
1760                        crate::graph::same_credit_relation(catalog, &decision.relation, &reference)
1761                    })
1762                });
1763            link.trusted &= !link.excluded;
1764        }
1765        links
1766    }
1767
1768    /// Exclude one relationship without losing the underlying fetched claim.
1769    pub fn exclude_credit(&mut self, relation: RelationRef, at: u64) {
1770        if let Some(existing) = self
1771            .credit_exclusions
1772            .iter_mut()
1773            .find(|row| row.relation == relation)
1774        {
1775            existing.excluded_at = at;
1776        } else {
1777            self.credit_exclusions.push(CreditExclusion {
1778                relation,
1779                excluded_at: at,
1780            });
1781        }
1782    }
1783
1784    /// Exclude a current credit while retaining its artist's exact identity.
1785    /// Keeping a known MBID makes the decision survive the artist entering or
1786    /// leaving local tags without changing displayed relationship selectors.
1787    pub fn exclude_credit_in(
1788        &mut self,
1789        catalog: &crate::model::Catalog,
1790        relation: RelationRef,
1791        at: u64,
1792    ) {
1793        self.exclude_credit(
1794            crate::graph::canonical_credit_relation(catalog, relation),
1795            at,
1796        );
1797    }
1798
1799    /// Take back one exclusion and make the original claim eligible again.
1800    pub fn restore_credit(&mut self, relation: &RelationRef) -> bool {
1801        let before = self.credit_exclusions.len();
1802        self.credit_exclusions
1803            .retain(|row| &row.relation != relation);
1804        self.credit_exclusions.len() < before
1805    }
1806
1807    /// Restore a credit selected from the current catalog, including an older
1808    /// exclusion whose named endpoint now has an exact MusicBrainz identity.
1809    pub fn restore_credit_in(
1810        &mut self,
1811        catalog: &crate::model::Catalog,
1812        relation: &RelationRef,
1813    ) -> bool {
1814        let before = self.credit_exclusions.len();
1815        self.credit_exclusions
1816            .retain(|row| !crate::graph::same_credit_relation(catalog, &row.relation, relation));
1817        self.credit_exclusions.len() < before
1818    }
1819
1820    /// Dated group memberships, retaining external endpoints when the related
1821    /// artist is not part of the local catalog.
1822    pub fn membership_links(&self, catalog: &crate::model::Catalog) -> Vec<SourcedMembershipLink> {
1823        let mut links = Vec::new();
1824        for record in &self.records {
1825            let Facts::Artist(facts) = &record.facts else {
1826                continue;
1827            };
1828            let Some(artist_id) = record.entity().resolve(catalog) else {
1829                continue;
1830            };
1831            let review = self.review_for(record).map(|review| review.decision);
1832            let trusted = self.is_trusted(catalog, record);
1833            for membership in &facts.members {
1834                let related_artist_id = catalog
1835                    .artists
1836                    .iter()
1837                    .find(|artist| artist.mbid.as_deref() == Some(&membership.mbid))
1838                    .map(|artist| artist.id);
1839                links.push(SourcedMembershipLink {
1840                    artist_id,
1841                    related_artist_id,
1842                    membership: membership.clone(),
1843                    source: record.source.clone(),
1844                    confidence: record.confidence,
1845                    review,
1846                    trusted,
1847                    fetched_at: record.fetched_at,
1848                });
1849            }
1850        }
1851        links
1852    }
1853
1854    /// Certain source-backed works matching a title or MusicBrainz ID.
1855    ///
1856    /// Approximate attachment records stay visible through [`Sources::work_links`]
1857    /// but do not become navigation targets. This is the boundary between
1858    /// evidence and a relationship safe enough to traverse.
1859    pub fn find_sourced_works(
1860        &self,
1861        catalog: &crate::model::Catalog,
1862        query: &str,
1863    ) -> Vec<SourcedWork> {
1864        let key = crate::text::normalize(query);
1865        let mut grouped: BTreeMap<String, SourcedWork> = BTreeMap::new();
1866        for link in self
1867            .work_links(catalog)
1868            .into_iter()
1869            .filter(|link| link.trusted)
1870            .filter(|link| {
1871                let title = crate::text::normalize(&link.work.title);
1872                link.work.mbid == query || title == key || title.contains(&key)
1873            })
1874        {
1875            let entry = grouped
1876                .entry(link.work.mbid.clone())
1877                .or_insert_with(|| SourcedWork {
1878                    mbid: link.work.mbid.clone(),
1879                    title: link.work.title.clone(),
1880                    links: Vec::new(),
1881                });
1882            if entry.title.is_empty() && !link.work.title.is_empty() {
1883                entry.title = link.work.title.clone();
1884            }
1885            if !entry.links.contains(&link) {
1886                entry.links.push(link);
1887            }
1888        }
1889        grouped.into_values().collect()
1890    }
1891
1892    /// Reconciles one label's explicit tag identity with its source record.
1893    pub fn label_identity(
1894        &self,
1895        catalog: &crate::model::Catalog,
1896        label_id: Id,
1897    ) -> Option<LabelIdentityResolution> {
1898        let label = catalog.label(label_id)?;
1899        let entity = EntityRef::of(catalog, EntityKind::Label, label_id)?;
1900        let source = self.get(&entity, MUSICBRAINZ);
1901        let local = label
1902            .mbid
1903            .as_deref()
1904            .map(str::trim)
1905            .filter(|id| !id.is_empty());
1906        let Some(record) = source else {
1907            return local.map(|mbid| LabelIdentityResolution::Local {
1908                mbid: mbid.to_string(),
1909            });
1910        };
1911        let sourced = record
1912            .source_id
1913            .as_deref()
1914            .map(str::trim)
1915            .filter(|id| !id.is_empty());
1916        let Some(sourced) = sourced else {
1917            return local.map(|mbid| LabelIdentityResolution::Local {
1918                mbid: mbid.to_string(),
1919            });
1920        };
1921
1922        if let Some(review) = self.review_for(record) {
1923            return Some(match review.decision {
1924                ReviewDecision::Accepted => LabelIdentityResolution::Accepted {
1925                    mbid: sourced.to_string(),
1926                    local_mbid: local.map(str::to_string),
1927                    source: record.source.clone(),
1928                    fetched_at: record.fetched_at,
1929                    reviewed_at: review.reviewed_at,
1930                },
1931                ReviewDecision::Rejected => LabelIdentityResolution::Rejected {
1932                    local_mbid: local.map(str::to_string),
1933                    sourced_mbid: sourced.to_string(),
1934                    source: record.source.clone(),
1935                    reviewed_at: review.reviewed_at,
1936                },
1937            });
1938        }
1939
1940        if !record.confidence.is_certain() {
1941            return Some(LabelIdentityResolution::Suggested {
1942                mbid: sourced.to_string(),
1943                source: record.source.clone(),
1944                confidence: record.confidence,
1945                fetched_at: record.fetched_at,
1946            });
1947        }
1948        match local {
1949            None => Some(LabelIdentityResolution::Confirmed {
1950                mbid: sourced.to_string(),
1951                source: record.source.clone(),
1952                fetched_at: record.fetched_at,
1953            }),
1954            Some(local) if local == sourced || record.musicbrainz_lookup_alias() == Some(local) => {
1955                Some(LabelIdentityResolution::Agrees {
1956                    mbid: sourced.to_string(),
1957                    source: record.source.clone(),
1958                    fetched_at: record.fetched_at,
1959                })
1960            }
1961            Some(local) => Some(LabelIdentityResolution::Conflict {
1962                local_mbid: local.to_string(),
1963                sourced_mbid: sourced.to_string(),
1964                source: record.source.clone(),
1965                fetched_at: record.fetched_at,
1966            }),
1967        }
1968    }
1969
1970    /// MusicBrainz label identities that this catalog can place.
1971    ///
1972    /// Searches are deliberately absent: an equally named label is not an
1973    /// identity. The `Identified` confidence records are identifier lookups.
1974    pub fn label_identities(&self, catalog: &crate::model::Catalog) -> Vec<SourcedLabelIdentity> {
1975        catalog
1976            .labels
1977            .iter()
1978            .filter_map(|label| match self.label_identity(catalog, label.id)? {
1979                LabelIdentityResolution::Confirmed {
1980                    mbid, fetched_at, ..
1981                }
1982                | LabelIdentityResolution::Agrees {
1983                    mbid, fetched_at, ..
1984                }
1985                | LabelIdentityResolution::Accepted {
1986                    mbid, fetched_at, ..
1987                } => Some(SourcedLabelIdentity {
1988                    label_id: label.id,
1989                    mbid,
1990                    fetched_at,
1991                }),
1992                LabelIdentityResolution::Local { .. }
1993                | LabelIdentityResolution::Suggested { .. }
1994                | LabelIdentityResolution::Conflict { .. }
1995                | LabelIdentityResolution::Rejected { .. } => None,
1996            })
1997            .collect()
1998    }
1999
2000    /// Drops everything one source ever said, and reports how much that was.
2001    ///
2002    /// The counterpart of an attributed layer: a value that can be traced to a
2003    /// source can also be removed by naming that source, without touching what
2004    /// anybody else said.
2005    pub fn forget(&mut self, source: &str) -> usize {
2006        let before = self.records.len();
2007        self.records.retain(|r| r.source != source);
2008        self.reviews.retain(|review| review.source != source);
2009        self.credit_exclusions
2010            .retain(|decision| decision.relation.provenance != source);
2011        before - self.records.len()
2012    }
2013
2014    /// Drops everything said about one entity, whoever said it.
2015    pub fn forget_entity(&mut self, entity: &EntityRef) -> usize {
2016        let before = self.records.len();
2017        self.records
2018            .retain(|r| !(r.key == entity.key && r.facts.kind() == entity.kind));
2019        self.reviews.retain(|review| review.entity != *entity);
2020        before - self.records.len()
2021    }
2022}
2023
2024fn local_musicbrainz_id<'a>(
2025    catalog: &'a crate::model::Catalog,
2026    record: &SourceRecord,
2027) -> Option<&'a str> {
2028    let entity = record.entity();
2029    let id = entity.resolve(catalog)?;
2030    match entity.kind {
2031        EntityKind::Artist => catalog.artist(id)?.mbid.as_deref(),
2032        EntityKind::Release => record
2033            .musicbrainz_release_identity()?
2034            .local_id(catalog, catalog.release(id)?),
2035        EntityKind::Track => catalog
2036            .track(id)
2037            .and_then(|track| catalog.recording(track.recording_id))
2038            .and_then(|recording| recording.mbid.as_deref()),
2039        EntityKind::Label => catalog.label(id)?.mbid.as_deref(),
2040        _ => None,
2041    }
2042}
2043
2044fn recordings_by_path(catalog: &crate::model::Catalog) -> BTreeMap<&str, Id> {
2045    catalog
2046        .tracks
2047        .iter()
2048        .filter_map(|track| {
2049            Some((
2050                catalog.file(track.file_id)?.path.as_str(),
2051                track.recording_id,
2052            ))
2053        })
2054        .collect()
2055}
2056
2057fn recordings_by_work<'a>(
2058    catalog: &'a crate::model::Catalog,
2059    sourced: impl IntoIterator<Item = (Id, &'a str)>,
2060) -> BTreeMap<&'a str, Arc<[Id]>> {
2061    let mut recordings: BTreeMap<&str, BTreeSet<Id>> = catalog
2062        .works
2063        .iter()
2064        .filter(|work| !work.mbid.is_empty())
2065        .map(|work| {
2066            (
2067                work.mbid.as_str(),
2068                work.recording_ids.iter().copied().collect(),
2069            )
2070        })
2071        .collect();
2072    for (recording_id, mbid) in sourced {
2073        if !mbid.is_empty() {
2074            recordings.entry(mbid).or_default().insert(recording_id);
2075        }
2076    }
2077    recordings
2078        .into_iter()
2079        .map(|(mbid, ids)| (mbid, ids.into_iter().collect::<Vec<_>>().into()))
2080        .collect()
2081}
2082
2083fn identity_conflict(
2084    catalog: &crate::model::Catalog,
2085    record: &SourceRecord,
2086) -> Option<(String, String)> {
2087    if record.source != MUSICBRAINZ {
2088        return None;
2089    }
2090    let local = local_musicbrainz_id(catalog, record);
2091    identity_conflict_with_id(record, local)
2092}
2093
2094fn identity_conflict_with_id(
2095    record: &SourceRecord,
2096    local_id: Option<&str>,
2097) -> Option<(String, String)> {
2098    if record.source != MUSICBRAINZ {
2099        return None;
2100    }
2101    let sourced = record
2102        .source_id
2103        .as_deref()
2104        .or_else(|| record.musicbrainz_recording_identity())?
2105        .trim();
2106    if sourced.is_empty() {
2107        return None;
2108    }
2109    let local = local_id?.trim();
2110    let verified_alias = record.musicbrainz_lookup_alias() == Some(local);
2111    (local != sourced && !verified_alias).then(|| (local.to_string(), sourced.to_string()))
2112}
2113
2114/// Apply the ordinary trust rule after a caller has resolved the identity
2115/// and indexed its exact review, without rescanning either table per claim.
2116pub(crate) fn trusted_with_identity(
2117    record: &SourceRecord,
2118    local_musicbrainz_id: Option<&str>,
2119    review: Option<ReviewDecision>,
2120) -> bool {
2121    if let Some(decision) = review {
2122        return decision == ReviewDecision::Accepted;
2123    }
2124    record.confidence.is_certain()
2125        && identity_conflict_with_id(record, local_musicbrainz_id).is_none()
2126}
2127
2128fn review_id(record: &SourceRecord) -> String {
2129    // FNV-1a is used only as a stable compact selector, never as identity or
2130    // security. The command still rejects an ambiguous prefix.
2131    let mut hash = 0xcbf29ce484222325u64;
2132    for part in [
2133        record.facts.kind().as_str(),
2134        record.key.as_str(),
2135        record.source.as_str(),
2136        record.source_id.as_deref().unwrap_or(""),
2137    ] {
2138        for byte in part.as_bytes().iter().copied().chain(std::iter::once(0)) {
2139            hash ^= u64::from(byte);
2140            hash = hash.wrapping_mul(0x100000001b3);
2141        }
2142    }
2143    format!("{hash:016x}")
2144}
2145
2146fn select_review_item(items: Vec<ReviewItem>, selector: &str) -> Result<ReviewItem, String> {
2147    let selector = selector.trim().to_ascii_lowercase();
2148    if selector.is_empty() {
2149        return Err("a review ID is required; run aede review to list them".to_string());
2150    }
2151    let mut matched = items
2152        .into_iter()
2153        .filter(|item| item.id.starts_with(&selector));
2154    let Some(item) = matched.next() else {
2155        return Err(format!(
2156            "no review starts with \"{selector}\"; run aede review --all for the current list"
2157        ));
2158    };
2159    if matched.next().is_some() {
2160        return Err(format!(
2161            "review ID \"{selector}\" is ambiguous; copy more characters from aede review --all"
2162        ));
2163    }
2164    Ok(item)
2165}
2166
2167// --------------------------------------------------------------------------
2168// How much of it the catalog can currently place
2169// --------------------------------------------------------------------------
2170
2171/// How many records the catalog can place, and how many are waiting.
2172#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2173pub struct Attachment {
2174    /// Records whose entity is in the catalog.
2175    pub attached: usize,
2176    /// Records whose entity is not, and which are kept exactly as they are.
2177    pub waiting: usize,
2178}
2179
2180/// Counts what this catalog can currently place.
2181///
2182/// Nothing is rewritten here, and that is the difference with
2183/// [`crate::user::reconcile`]. Annotations are reattached in place because a
2184/// note whose file moved is **lost** otherwise, so it is worth guessing from a
2185/// name and a size when exactly one file matches.
2186///
2187/// A fetched value is not in that position: it is re-fetchable. A release key
2188/// carries the folder the album sits in, so moving an album does break the
2189/// attachment — and the right answer there is to ask the source again, not to
2190/// guess. Guessing would risk filing what MusicBrainz said about one album
2191/// onto another to save a network call, which is a poor trade in a layer whose
2192/// whole promise is that every value can be traced to what it describes.
2193///
2194/// So a record that does not resolve simply waits, exactly as an imported
2195/// analysis waits for the file it describes to be scanned.
2196pub fn attachment(sources: &Sources, catalog: &crate::model::Catalog) -> Attachment {
2197    let mut report = Attachment::default();
2198    for record in &sources.records {
2199        match record.entity().resolve(catalog).is_some() {
2200            true => report.attached += 1,
2201            false => report.waiting += 1,
2202        }
2203    }
2204    report
2205}
2206
2207// --------------------------------------------------------------------------
2208// The verdict, which is derived and never stored
2209// --------------------------------------------------------------------------
2210
2211/// What a source's value amounts to, next to the tag it can be compared with.
2212///
2213/// **Derived on read, never stored.** A stored "agrees" goes stale the moment
2214/// the file is re-tagged, and the catalog would then hold a claim it has
2215/// stopped being able to justify. What is stored is the answer itself, whole;
2216/// this is computed from it. That is also what makes "does my tag still
2217/// match?" an offline question.
2218#[derive(Debug, Clone, PartialEq, Eq)]
2219pub enum Verdict {
2220    /// The source and the tag say the same thing.
2221    Agrees,
2222    /// They do not, and both are named so the reader can judge.
2223    Differs {
2224        /// What the source says.
2225        theirs: String,
2226        /// What the tag says.
2227        yours: String,
2228    },
2229    /// There is no tag to compare against, so the source adds rather than
2230    /// contradicts.
2231    NothingToCompare,
2232}
2233
2234/// Compares what a source says with what the tags say.
2235///
2236/// The comparison goes through [`crate::text::normalize`], the same
2237/// normalisation the catalog uses to decide that two spellings are one name —
2238/// case, diacritics, punctuation and a leading article. `Album` and `album`,
2239/// `Björk` and `Bjork`, `The Beatles` and `Beatles, The` are agreement, and
2240/// reporting them as disagreement would train the reader to skip the report.
2241///
2242/// What it does **not** fold is worth stating, because it is the case that
2243/// will produce the first false alarm: `&` and `and` are different words to
2244/// `normalize`, so `Rock & Roll` against `Rock and Roll` is reported as a
2245/// difference. Widening the normalisation is the wrong fix — it is the
2246/// catalog's identity function, and loosening it there to quiet a report here
2247/// would start merging artists. If the noise becomes real, the answer is a
2248/// comparison of its own, not a change to what counts as one name.
2249pub fn verdict(theirs: &str, yours: Option<&str>) -> Verdict {
2250    let Some(yours) = yours.map(str::trim).filter(|y| !y.is_empty()) else {
2251        return Verdict::NothingToCompare;
2252    };
2253    match crate::text::normalize(theirs) == crate::text::normalize(yours) {
2254        true => Verdict::Agrees,
2255        false => Verdict::Differs {
2256            theirs: theirs.trim().to_string(),
2257            yours: yours.to_string(),
2258        },
2259    }
2260}
2261
2262/// The verdict for a field that holds a **set**, not a value: genres.
2263///
2264/// Comparing sets as strings is what produced the first false alarm of this
2265/// layer: MusicBrainz said `pop, dance-pop, electropop, europop`, the files said
2266/// `Rock, Pop`, and the report called that a disagreement. It is not. The tags
2267/// say the record is pop *and* rock; MusicBrainz says pop and three finer words
2268/// for it. Nobody is contradicting anybody.
2269///
2270/// So the rule is **overlap, not equality**. Genres are not exclusive and the
2271/// two sides are not even trying to answer with the same granularity — one is
2272/// what a crowd voted, the other is what one person typed. A shared name is
2273/// agreement; only two sets with nothing at all in common are a difference
2274/// worth showing.
2275///
2276/// Splitting is done here rather than by the caller because a genre tag is
2277/// written in every shape: several tag values, or one value holding
2278/// `Rock, Pop`, `Rock; Pop` or `Rock / Pop`. All of them mean a list.
2279pub fn verdict_set(theirs: &[String], yours: &[String]) -> Verdict {
2280    let split = |values: &[String]| -> Vec<String> {
2281        values
2282            .iter()
2283            .flat_map(|value| value.split([',', ';', '/']))
2284            .map(crate::text::normalize)
2285            .filter(|name| !name.is_empty())
2286            .collect()
2287    };
2288    let (mine, other) = (split(theirs), split(yours));
2289    if other.is_empty() {
2290        return Verdict::NothingToCompare;
2291    }
2292    if mine.iter().any(|name| other.contains(name)) {
2293        return Verdict::Agrees;
2294    }
2295    Verdict::Differs {
2296        theirs: theirs.join(", "),
2297        yours: yours.join(", "),
2298    }
2299}
2300
2301/// Compares two dates written at different precisions.
2302///
2303/// The plain [`verdict`] is wrong for dates and would be wrong on nearly every
2304/// album: MusicBrainz answers `1973-03-01` where a tag almost always holds
2305/// `1973`, and reporting that as a disagreement would fill the report with
2306/// noise on the first run and teach the reader to stop looking.
2307///
2308/// So when either side gives only a year, only the years are compared. When
2309/// both are precise, they are compared as they are — two full dates that
2310/// differ really are a disagreement.
2311pub fn verdict_date(theirs: &str, yours: Option<&str>) -> Verdict {
2312    let Some(yours) = yours.map(str::trim).filter(|y| !y.is_empty()) else {
2313        return Verdict::NothingToCompare;
2314    };
2315    let theirs = theirs.trim();
2316
2317    fn year(text: &str) -> Option<&str> {
2318        let head = text.get(..4)?;
2319        head.chars().all(|c| c.is_ascii_digit()).then_some(head)
2320    }
2321    let (Some(mine), Some(other)) = (year(theirs), year(yours)) else {
2322        return verdict(theirs, Some(yours));
2323    };
2324
2325    // One side is a bare year: that is the precision of the comparison, not a
2326    // difference of opinion about the date.
2327    let bare = theirs.len() == 4 || yours.len() == 4;
2328    let same = match bare {
2329        true => mine == other,
2330        false => crate::text::normalize(theirs) == crate::text::normalize(yours),
2331    };
2332    match same {
2333        true => Verdict::Agrees,
2334        false => Verdict::Differs {
2335            theirs: theirs.to_string(),
2336            yours: yours.to_string(),
2337        },
2338    }
2339}
2340
2341// --------------------------------------------------------------------------
2342// Persistence
2343// --------------------------------------------------------------------------
2344
2345/// Where `sources.json` sits inside a data folder.
2346pub fn sources_path(data_dir: &Path) -> std::path::PathBuf {
2347    data_dir.join(SOURCES_FILE)
2348}
2349
2350/// Writes the layer, atomically and pretty.
2351///
2352/// Pretty like `user.json` and unlike the catalog: this file is small, and it
2353/// is one a user may want to open to see what was fetched and from where.
2354pub fn save(sources: &Sources, path: &Path) -> Result<(), crate::store::StoreError> {
2355    crate::atomic_file::write(path, to_json(sources).to_string_pretty().as_bytes())?;
2356    Ok(())
2357}
2358
2359/// Reads the layer; `Ok(None)` when nothing has ever been fetched.
2360pub fn load(path: &Path) -> Result<Option<Sources>, crate::store::StoreError> {
2361    let Some(mut sources) = load_all(path)? else {
2362        return Ok(None);
2363    };
2364    let now = crate::clock::now_seconds();
2365    // Discogs' API terms prohibit displaying an old snapshot. Readers and
2366    // exports see only current profiles; writers use load_all so an unrelated
2367    // change does not erase the local copy before it can be refreshed.
2368    sources.records.retain(|record| {
2369        record.source != crate::discogs::SOURCE
2370            || !matches!(&record.facts, Facts::Label(_))
2371            || crate::discogs::fresh(record.fetched_at, now)
2372    });
2373    Ok(Some(sources))
2374}
2375
2376/// Read every stored claim, including Discogs profiles awaiting revalidation.
2377/// Only use this for a read/modify/write operation or a backup, not display.
2378pub fn load_all(path: &Path) -> Result<Option<Sources>, crate::store::StoreError> {
2379    let text = match std::fs::read_to_string(path) {
2380        Ok(text) => text,
2381        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2382        Err(error) => return Err(error.into()),
2383    };
2384    let value = crate::json::parse(&text).map_err(crate::store::StoreError::Parse)?;
2385    from_json(&value).map(Some)
2386}
2387
2388/// A list of strings, as the document carries them.
2389fn strings(values: &[String]) -> Json {
2390    Json::Arr(values.iter().map(|v| Json::Str(v.clone())).collect())
2391}
2392
2393/// Reads back what [`strings`] wrote; an absent field is an empty list, which
2394/// is what an older document carries for a field this build added.
2395fn read_strings(value: &Json, key: &str) -> Vec<String> {
2396    value
2397        .get(key)
2398        .and_then(Json::as_arr)
2399        .map(|a| a.iter().filter_map(Json::as_string).collect())
2400        .unwrap_or_default()
2401}
2402
2403fn opt_str(value: &Option<String>) -> Json {
2404    match value {
2405        Some(s) => Json::Str(s.clone()),
2406        None => Json::Null,
2407    }
2408}
2409
2410// Retaining a readable subset would make an incomplete list look like a
2411// completed legacy browse, whose nonempty list is reused without a timestamp.
2412fn readable_discography_rows(facts: &Json) -> Option<&[Json]> {
2413    let rows = facts.get("discography")?.as_arr()?;
2414    rows.iter()
2415        .all(|row| {
2416            row.get("mbid")
2417                .and_then(Json::as_str)
2418                .is_some_and(|id| !id.trim().is_empty())
2419        })
2420        .then_some(rows)
2421}
2422
2423fn credit_attribute_to_json(attribute: &CreditAttribute) -> Json {
2424    let mut row = Json::obj();
2425    row.set("id", opt_str(&attribute.id));
2426    row.set("name", attribute.name.clone().into());
2427    row.set("value", opt_str(&attribute.value));
2428    row.set("credited_as", opt_str(&attribute.credited_as));
2429    row
2430}
2431
2432fn credit_link_to_json(credit: &CreditLink) -> Json {
2433    let mut row = Json::obj();
2434    row.set("relation_id", opt_str(&credit.relation_id));
2435    row.set("role_id", opt_str(&credit.role_id));
2436    row.set("role", credit.role.clone().into());
2437    row.set("direction", opt_str(&credit.direction));
2438    row.set("artist_mbid", credit.artist_mbid.clone().into());
2439    row.set("artist_name", credit.artist_name.clone().into());
2440    row.set("credited_as", opt_str(&credit.credited_as));
2441    row.set(
2442        "attributes",
2443        Json::Arr(
2444            credit
2445                .attributes
2446                .iter()
2447                .map(credit_attribute_to_json)
2448                .collect(),
2449        ),
2450    );
2451    row.set("began", opt_str(&credit.began));
2452    row.set("ended", opt_str(&credit.ended));
2453    row.set("over", credit.over.map(Json::Bool).unwrap_or(Json::Null));
2454    row.set(
2455        "order",
2456        credit
2457            .order
2458            .map(|value| Json::Num(value as f64))
2459            .unwrap_or(Json::Null),
2460    );
2461    row
2462}
2463
2464fn credit_attribute_from_json(row: &Json) -> Option<CreditAttribute> {
2465    Some(CreditAttribute {
2466        id: row.field_str("id"),
2467        name: row.field_str("name")?,
2468        value: row.field_str("value"),
2469        credited_as: row.field_str("credited_as"),
2470    })
2471}
2472
2473fn credit_required_fields(row: &Json) -> Option<(&str, &str)> {
2474    Some((
2475        row.get("role")?.as_str()?,
2476        row.get("artist_mbid")?.as_str()?,
2477    ))
2478}
2479
2480fn credit_link_from_json(row: &Json) -> Option<CreditLink> {
2481    let (role, artist_mbid) = credit_required_fields(row)?;
2482    Some(CreditLink {
2483        relation_id: row.field_str("relation_id"),
2484        role_id: row.field_str("role_id"),
2485        role: role.to_string(),
2486        direction: row.field_str("direction"),
2487        artist_mbid: artist_mbid.to_string(),
2488        artist_name: row.field_str("artist_name").unwrap_or_default(),
2489        credited_as: row.field_str("credited_as"),
2490        attributes: row
2491            .get("attributes")
2492            .and_then(Json::as_arr)
2493            .map(|rows| rows.iter().filter_map(credit_attribute_from_json).collect())
2494            .unwrap_or_default(),
2495        began: row.field_str("began"),
2496        ended: row.field_str("ended"),
2497        over: row.field_optional_bool("over"),
2498        order: row.field_u32("order"),
2499    })
2500}
2501
2502fn readable_credit_snapshot(facts: &Json, kind: EntityKind) -> bool {
2503    let readable_credits = |value: &Json| {
2504        value.get("credits").is_none_or(|credits| {
2505            credits
2506                .as_arr()
2507                .is_some_and(|rows| rows.iter().all(|row| credit_required_fields(row).is_some()))
2508        })
2509    };
2510    readable_credits(facts)
2511        && (kind != EntityKind::Track
2512            || facts.get("works").is_none_or(|works| {
2513                works.as_arr().is_some_and(|rows| {
2514                    rows.iter().all(|work| {
2515                        work.get("mbid").and_then(Json::as_str).is_some() && readable_credits(work)
2516                    })
2517                })
2518            }))
2519}
2520
2521/// The document: a version, and one array of records.
2522pub fn to_json(sources: &Sources) -> Json {
2523    let mut root = Json::obj();
2524    root.set("format_version", SOURCES_FORMAT_VERSION.into());
2525
2526    let records: Vec<Json> = sources
2527        .records
2528        .iter()
2529        .map(|r| {
2530            let mut o = Json::obj();
2531            o.set("entity", r.entity().to_token().into());
2532            o.set("source", r.source.clone().into());
2533            o.set("source_id", opt_str(&r.source_id));
2534            o.set("fetched_at", r.fetched_at.into());
2535            match r.confidence {
2536                Confidence::Identified => o.set("confidence", "identified".into()),
2537                Confidence::Matched(score) => {
2538                    o.set("confidence", "matched".into());
2539                    o.set("score", u32::from(score).into());
2540                }
2541            }
2542            let mut facts = Json::obj();
2543            match &r.facts {
2544                Facts::Track(t) => {
2545                    facts.set("recording", opt_str(&t.recording));
2546                    facts.set(
2547                        "score",
2548                        match t.score {
2549                            Some(per_cent) => u32::from(per_cent).into(),
2550                            None => Json::Null,
2551                        },
2552                    );
2553                    facts.set("title", opt_str(&t.title));
2554                    facts.set("artists", strings(&t.artists));
2555                    facts.set("album", opt_str(&t.album));
2556                    facts.set("relationships_complete", t.relationships_complete.into());
2557                    if let Some(id) = &t.relationships_for {
2558                        facts.set("relationships_for", id.clone().into());
2559                    }
2560                    facts.set(
2561                        "credits",
2562                        Json::Arr(t.credits.iter().map(credit_link_to_json).collect()),
2563                    );
2564                    facts.set(
2565                        "works",
2566                        Json::Arr(
2567                            t.works
2568                                .iter()
2569                                .map(|work| {
2570                                    let mut row = Json::obj();
2571                                    row.set("mbid", work.mbid.clone().into());
2572                                    row.set("title", work.title.clone().into());
2573                                    row.set("relation_id", opt_str(&work.relation_id));
2574                                    row.set("relation_type", opt_str(&work.relation_type));
2575                                    row.set("relation_type_id", opt_str(&work.relation_type_id));
2576                                    row.set("direction", opt_str(&work.direction));
2577                                    row.set(
2578                                        "attributes",
2579                                        Json::Arr(
2580                                            work.attributes
2581                                                .iter()
2582                                                .map(credit_attribute_to_json)
2583                                                .collect(),
2584                                        ),
2585                                    );
2586                                    row.set(
2587                                        "credits",
2588                                        Json::Arr(
2589                                            work.credits.iter().map(credit_link_to_json).collect(),
2590                                        ),
2591                                    );
2592                                    row.set(
2593                                        "parents",
2594                                        Json::Arr(
2595                                            work.parents
2596                                                .iter()
2597                                                .map(|parent| {
2598                                                    let mut part = Json::obj();
2599                                                    part.set("mbid", parent.mbid.clone().into());
2600                                                    part.set("title", parent.title.clone().into());
2601                                                    part.set(
2602                                                        "relation_id",
2603                                                        opt_str(&parent.relation_id),
2604                                                    );
2605                                                    part.set(
2606                                                        "relation_type_id",
2607                                                        opt_str(&parent.relation_type_id),
2608                                                    );
2609                                                    part.set(
2610                                                        "direction",
2611                                                        opt_str(&parent.direction),
2612                                                    );
2613                                                    part.set(
2614                                                        "attributes",
2615                                                        Json::Arr(
2616                                                            parent
2617                                                                .attributes
2618                                                                .iter()
2619                                                                .map(credit_attribute_to_json)
2620                                                                .collect(),
2621                                                        ),
2622                                                    );
2623                                                    part.set("order", parent.order.into());
2624                                                    part
2625                                                })
2626                                                .collect(),
2627                                        ),
2628                                    );
2629                                    row
2630                                })
2631                                .collect(),
2632                        ),
2633                    );
2634                }
2635                Facts::Artist(a) => {
2636                    if let Some(id) = &a.lookup_mbid {
2637                        facts.set("lookup_mbid", id.clone().into());
2638                    }
2639                    facts.set("relationships_complete", a.relationships_complete.into());
2640                    facts.set("area", opt_str(&a.area));
2641                    facts.set("country_code", opt_str(&a.country_code));
2642                    facts.set("began", opt_str(&a.began));
2643                    facts.set("ended", opt_str(&a.ended));
2644                    facts.set(
2645                        "active",
2646                        match a.active {
2647                            Some(active) => Json::Bool(active),
2648                            None => Json::Null,
2649                        },
2650                    );
2651                    facts.set("kind", opt_str(&a.kind));
2652                    facts.set("disambiguation", opt_str(&a.disambiguation));
2653                    facts.set("genres", strings(&a.genres));
2654                    facts.set("aliases", strings(&a.aliases));
2655                    facts.set("wikidata", opt_str(&a.wikidata));
2656                    facts.set("discogs", opt_str(&a.discogs));
2657                    facts.set("homepage", opt_str(&a.homepage));
2658                    // Written as an array of objects rather than a flat list
2659                    // of identifiers: a reader shown "you are missing three
2660                    // albums" needs their titles and years, and re-fetching
2661                    // them from the identifiers would be a request per album
2662                    // for something already known.
2663                    facts.set(
2664                        "discography",
2665                        Json::Arr(
2666                            a.discography
2667                                .iter()
2668                                .map(|known| {
2669                                    let mut o = Json::obj();
2670                                    o.set("mbid", known.mbid.clone().into());
2671                                    o.set("title", known.title.clone().into());
2672                                    o.set("first_released", opt_str(&known.first_released));
2673                                    o.set("primary_type", opt_str(&known.primary_type));
2674                                    o.set("secondary_types", strings(&known.secondary_types));
2675                                    o
2676                                })
2677                                .collect(),
2678                        ),
2679                    );
2680                    if let Some(at) = a.discography_fetched_at {
2681                        facts.set("discography_fetched_at", at.into());
2682                    }
2683                    facts.set(
2684                        "members",
2685                        Json::Arr(
2686                            a.members
2687                                .iter()
2688                                .map(|m| {
2689                                    let mut o = Json::obj();
2690                                    o.set("relation_id", opt_str(&m.relation_id));
2691                                    o.set("role_id", opt_str(&m.role_id));
2692                                    o.set("direction", opt_str(&m.direction));
2693                                    o.set("mbid", m.mbid.clone().into());
2694                                    o.set("name", m.name.clone().into());
2695                                    o.set("credited_as", opt_str(&m.credited_as));
2696                                    // Spelt out rather than written as a flag:
2697                                    // a hand-editable file where the direction
2698                                    // is `true` is a file nobody can correct.
2699                                    o.set(
2700                                        "side",
2701                                        match m.side {
2702                                            Side::Player => "player",
2703                                            Side::Group => "group",
2704                                        }
2705                                        .into(),
2706                                    );
2707                                    o.set("kind", m.kind.clone().into());
2708                                    o.set("attributes", strings(&m.attributes));
2709                                    o.set("began", opt_str(&m.began));
2710                                    o.set("ended", opt_str(&m.ended));
2711                                    o.set(
2712                                        "over",
2713                                        match m.over {
2714                                            Some(over) => Json::Bool(over),
2715                                            None => Json::Null,
2716                                        },
2717                                    );
2718                                    o
2719                                })
2720                                .collect(),
2721                        ),
2722                    );
2723                    facts.set(
2724                        "summary",
2725                        match &a.summary {
2726                            // Written as one object for the same reason it is
2727                            // read as one: a document cannot carry the words
2728                            // without the credit either.
2729                            Some(prose) => {
2730                                let mut o = Json::obj();
2731                                o.set("text", prose.text.clone().into());
2732                                o.set("url", prose.url.clone().into());
2733                                o.set("lang", prose.lang.clone().into());
2734                                o.set("licence", prose.licence.clone().into());
2735                                o
2736                            }
2737                            None => Json::Null,
2738                        },
2739                    );
2740                    facts.set(
2741                        "portrait",
2742                        match &a.portrait {
2743                            Some(picture) => {
2744                                let mut o = Json::obj();
2745                                o.set("url", picture.url.clone().into());
2746                                o
2747                            }
2748                            None => Json::Null,
2749                        },
2750                    );
2751                    facts.set(
2752                        "logo",
2753                        match &a.logo {
2754                            Some(picture) => {
2755                                let mut o = Json::obj();
2756                                o.set("url", picture.url.clone().into());
2757                                o
2758                            }
2759                            None => Json::Null,
2760                        },
2761                    );
2762                }
2763                Facts::Release(rel) => {
2764                    if let Some(id) = &rel.lookup_mbid {
2765                        facts.set("lookup_mbid", id.clone().into());
2766                    }
2767                    facts.set("primary_type", opt_str(&rel.primary_type));
2768                    facts.set(
2769                        "secondary_types",
2770                        Json::Arr(
2771                            rel.secondary_types
2772                                .iter()
2773                                .map(|t| Json::Str(t.clone()))
2774                                .collect(),
2775                        ),
2776                    );
2777                    facts.set("first_released", opt_str(&rel.first_released));
2778                    facts.set("label", opt_str(&rel.label));
2779                    facts.set("label_mbid", opt_str(&rel.label_mbid));
2780                    facts.set("cover_art", opt_str(&rel.cover_art));
2781                    facts.set("edition_mbid", opt_str(&rel.edition_mbid));
2782                    if let Some(id) = &rel.edition_lookup_mbid {
2783                        facts.set("edition_lookup_mbid", id.clone().into());
2784                    }
2785                    facts.set(
2786                        "credits",
2787                        Json::Arr(rel.credits.iter().map(credit_link_to_json).collect()),
2788                    );
2789                    facts.set("relationships_complete", rel.relationships_complete.into());
2790                }
2791                Facts::Label(label) => {
2792                    if let Some(id) = &label.lookup_mbid {
2793                        facts.set("lookup_mbid", id.clone().into());
2794                    }
2795                    facts.set("wikidata", opt_str(&label.wikidata));
2796                    facts.set("discogs", opt_str(&label.discogs));
2797                    facts.set("summary_markup", opt_str(&label.summary_markup));
2798                    facts.set(
2799                        "summary",
2800                        match &label.summary {
2801                            Some(prose) => {
2802                                let mut o = Json::obj();
2803                                o.set("text", prose.text.clone().into());
2804                                o.set("url", prose.url.clone().into());
2805                                o.set("lang", prose.lang.clone().into());
2806                                o.set("licence", prose.licence.clone().into());
2807                                o
2808                            }
2809                            None => Json::Null,
2810                        },
2811                    );
2812                    facts.set(
2813                        "logo",
2814                        match &label.logo {
2815                            Some(picture) => {
2816                                let mut o = Json::obj();
2817                                o.set("url", picture.url.clone().into());
2818                                o
2819                            }
2820                            None => Json::Null,
2821                        },
2822                    );
2823                }
2824            }
2825            o.set("facts", facts);
2826            o
2827        })
2828        .collect();
2829    root.set("records", Json::Arr(records));
2830    root.set(
2831        "reviews",
2832        Json::Arr(
2833            sources
2834                .reviews
2835                .iter()
2836                .map(|review| {
2837                    let mut row = Json::obj();
2838                    row.set("entity", review.entity.to_token().into());
2839                    row.set("source", review.source.clone().into());
2840                    row.set("source_id", opt_str(&review.source_id));
2841                    row.set(
2842                        "decision",
2843                        match review.decision {
2844                            ReviewDecision::Accepted => "accepted",
2845                            ReviewDecision::Rejected => "rejected",
2846                        }
2847                        .into(),
2848                    );
2849                    row.set("reviewed_at", review.reviewed_at.into());
2850                    row
2851                })
2852                .collect(),
2853        ),
2854    );
2855    root.set(
2856        "credit_exclusions",
2857        Json::Arr(
2858            sources
2859                .credit_exclusions
2860                .iter()
2861                .map(|decision| {
2862                    let mut row = Json::obj();
2863                    row.set("source", decision.relation.source.to_token().into());
2864                    row.set("relation", decision.relation.kind.clone().into());
2865                    row.set("target", decision.relation.target.to_token().into());
2866                    row.set("provenance", decision.relation.provenance.clone().into());
2867                    row.set("source_id", opt_str(&decision.relation.source_id));
2868                    row.set("excluded_at", decision.excluded_at.into());
2869                    row
2870                })
2871                .collect(),
2872        ),
2873    );
2874    root
2875}
2876
2877/// Reads back what [`to_json`] wrote.
2878///
2879/// Unknown versions are refused rather than read approximately. Version 1 is
2880/// the one explicit migration: its record shape is unchanged and it simply
2881/// predates the `reviews` array.
2882///
2883/// Malformed tables, unreadable facts objects for supported entities and
2884/// duplicate entity/source snapshots are refused so a subsequent save cannot
2885/// discard data or preserve an arbitrary first answer.
2886/// An unreadable confidence remains evidence with a zero-score match, never
2887/// an inferred exact attachment.
2888pub fn from_json(value: &Json) -> Result<Sources, crate::store::StoreError> {
2889    use crate::store::StoreError;
2890    let found = value.field_u32("format_version").unwrap_or(0);
2891    // Version 1 had no review decisions. It migrates losslessly to an empty
2892    // review list; every other unknown shape is refused.
2893    if found != 1 && found != SOURCES_FORMAT_VERSION {
2894        return Err(StoreError::Version {
2895            found,
2896            expected: SOURCES_FORMAT_VERSION,
2897        });
2898    }
2899
2900    if value.get("records").and_then(Json::as_arr).is_none() {
2901        return Err(StoreError::Invalid("source records must be an array"));
2902    }
2903    for table in ["reviews", "credit_exclusions"] {
2904        if value.get(table).is_some_and(|rows| rows.as_arr().is_none()) {
2905            return Err(StoreError::Invalid("source tables must be arrays"));
2906        }
2907    }
2908
2909    let mut sources = Sources::default();
2910    let mut record_keys = std::collections::BTreeSet::new();
2911    let rows = value.get("records").and_then(Json::as_arr).unwrap_or(&[]);
2912    for row in rows {
2913        // Unknown and unsupported entity kinds stay outside this reader.
2914        // A supported snapshot must retain its facts object: manufacturing
2915        // defaults from an unreadable container would erase an earlier claim.
2916        let Some(entity) = row
2917            .field_str("entity")
2918            .and_then(|t| EntityRef::parse_token(&t))
2919        else {
2920            continue;
2921        };
2922        let facts = row.get("facts");
2923        if matches!(
2924            entity.kind,
2925            EntityKind::Artist | EntityKind::Release | EntityKind::Track | EntityKind::Label
2926        ) && !matches!(facts, Some(Json::Obj(_)))
2927        {
2928            return Err(StoreError::Invalid("source facts must be an object"));
2929        }
2930        if matches!(entity.kind, EntityKind::Track | EntityKind::Release)
2931            && facts.is_some_and(|facts| {
2932                facts.field_optional_bool("relationships_complete") == Some(true)
2933                    && !readable_credit_snapshot(facts, entity.kind)
2934            })
2935        {
2936            return Err(StoreError::Invalid(
2937                "completed credit collections must be readable",
2938            ));
2939        }
2940        let facts = match entity.kind {
2941            EntityKind::Artist => Facts::Artist(ArtistFacts {
2942                lookup_mbid: facts.and_then(|f| f.field_str("lookup_mbid")),
2943                relationships_complete: match facts.and_then(|f| f.get("relationships_complete")) {
2944                    None => false,
2945                    Some(Json::Bool(value)) => *value,
2946                    _ => {
2947                        return Err(StoreError::Invalid(
2948                            "artist relationship marker must be boolean",
2949                        ));
2950                    }
2951                },
2952                area: facts.and_then(|f| f.field_str("area")),
2953                // Absent from every record written before this field existed,
2954                // which is exactly what `Option` is for: those artists keep
2955                // their country and simply cannot be reached by its code
2956                // until the next fetch.
2957                country_code: facts.and_then(|f| f.field_str("country_code")),
2958                began: facts.and_then(|f| f.field_str("began")),
2959                ended: facts.and_then(|f| f.field_str("ended")),
2960                active: facts.and_then(|f| f.field_optional_bool("active")),
2961                kind: facts.and_then(|f| f.field_str("kind")),
2962                disambiguation: facts.and_then(|f| f.field_str("disambiguation")),
2963                genres: facts.map(|f| read_strings(f, "genres")).unwrap_or_default(),
2964                aliases: facts
2965                    .map(|f| read_strings(f, "aliases"))
2966                    .unwrap_or_default(),
2967                wikidata: facts.and_then(|f| f.field_str("wikidata")),
2968                discogs: facts.and_then(|f| f.field_str("discogs")),
2969                homepage: facts.and_then(|f| f.field_str("homepage")),
2970                // An unreadable list is not a complete answer: retaining only
2971                // its valid rows would skip a retry through legacy caching.
2972                discography: facts
2973                    .and_then(readable_discography_rows)
2974                    .map(|rows| {
2975                        rows.iter()
2976                            .filter_map(|row| {
2977                                Some(KnownRelease {
2978                                    mbid: row.field_str("mbid")?,
2979                                    title: row.field_str("title").unwrap_or_default(),
2980                                    first_released: row.field_str("first_released"),
2981                                    primary_type: row.field_str("primary_type"),
2982                                    secondary_types: read_strings(row, "secondary_types"),
2983                                })
2984                            })
2985                            .collect()
2986                    })
2987                    .unwrap_or_default(),
2988                // A timestamp alone cannot turn missing or unreadable rows
2989                // into a successful empty answer. Older lists need no marker.
2990                discography_fetched_at: facts
2991                    .and_then(|f| f.get("discography_fetched_at"))
2992                    .and_then(Json::as_u64)
2993                    .filter(|_| facts.and_then(readable_discography_rows).is_some()),
2994                members: facts
2995                    .and_then(|f| f.get("members"))
2996                    .and_then(Json::as_arr)
2997                    .map(|rows| {
2998                        rows.iter()
2999                            .filter_map(|row| {
3000                                // No identifier and no direction, no
3001                                // membership: a name alone cannot say which
3002                                // end of the relation it is, and guessing
3003                                // would put a band among a person's members.
3004                                Some(Membership {
3005                                    relation_id: row.field_str("relation_id"),
3006                                    role_id: row.field_str("role_id"),
3007                                    direction: row.field_str("direction"),
3008                                    mbid: row.field_str("mbid")?,
3009                                    name: row.field_str("name").unwrap_or_default(),
3010                                    credited_as: row.field_str("credited_as"),
3011                                    side: match row.field_str("side")?.as_str() {
3012                                        "player" => Side::Player,
3013                                        "group" => Side::Group,
3014                                        _ => return None,
3015                                    },
3016                                    kind: row.field_str("kind").unwrap_or_default(),
3017                                    attributes: read_strings(row, "attributes"),
3018                                    began: row.field_str("began"),
3019                                    ended: row.field_str("ended"),
3020                                    over: row.get("over").and_then(Json::as_bool),
3021                                })
3022                            })
3023                            .collect()
3024                    })
3025                    .unwrap_or_default(),
3026                // All four or none: a row that lost its attribution somewhere
3027                // is a row this build will not repeat.
3028                summary: facts.and_then(|f| f.get("summary")).and_then(|p| {
3029                    Some(Prose {
3030                        text: p.field_str("text")?,
3031                        url: p.field_str("url")?,
3032                        lang: p.field_str("lang")?,
3033                        licence: p.field_str("licence")?,
3034                    })
3035                }),
3036                // Absent from every record written before this field existed,
3037                // the same as `country_code` above: those artists simply have
3038                // not been asked for a portrait yet.
3039                portrait: facts.and_then(|f| f.get("portrait")).and_then(|p| {
3040                    Some(Picture {
3041                        url: p.field_str("url")?,
3042                    })
3043                }),
3044                // Same absence, same reason: not asked for a logo yet.
3045                logo: facts.and_then(|f| f.get("logo")).and_then(|p| {
3046                    Some(Picture {
3047                        url: p.field_str("url")?,
3048                    })
3049                }),
3050            }),
3051            EntityKind::Release => Facts::Release(ReleaseFacts {
3052                lookup_mbid: facts.and_then(|f| f.field_str("lookup_mbid")),
3053                primary_type: facts.and_then(|f| f.field_str("primary_type")),
3054                secondary_types: facts
3055                    .and_then(|f| f.get("secondary_types"))
3056                    .and_then(Json::as_arr)
3057                    .map(|a| a.iter().filter_map(Json::as_string).collect())
3058                    .unwrap_or_default(),
3059                first_released: facts.and_then(|f| f.field_str("first_released")),
3060                label: facts.and_then(|f| f.field_str("label")),
3061                label_mbid: facts.and_then(|f| f.field_str("label_mbid")),
3062                cover_art: facts.and_then(|f| f.field_str("cover_art")),
3063                edition_mbid: facts.and_then(|f| f.field_str("edition_mbid")),
3064                edition_lookup_mbid: facts.and_then(|f| f.field_str("edition_lookup_mbid")),
3065                credits: facts
3066                    .and_then(|f| f.get("credits"))
3067                    .and_then(Json::as_arr)
3068                    .map(|rows| rows.iter().filter_map(credit_link_from_json).collect())
3069                    .unwrap_or_default(),
3070                relationships_complete: facts
3071                    .is_some_and(|f| f.field_bool("relationships_complete")),
3072            }),
3073            EntityKind::Track => Facts::Track(TrackFacts {
3074                recording: facts.and_then(|f| f.field_str("recording")),
3075                // Read back as a percentage and clamped, so a hand-edited
3076                // file cannot put 4000 % beside somebody's tags.
3077                score: facts
3078                    .and_then(|f| f.field_u32("score"))
3079                    .map(|value| value.min(100) as u8),
3080                title: facts.and_then(|f| f.field_str("title")),
3081                artists: facts
3082                    .map(|f| read_strings(f, "artists"))
3083                    .unwrap_or_default(),
3084                album: facts.and_then(|f| f.field_str("album")),
3085                credits: facts
3086                    .and_then(|f| f.get("credits"))
3087                    .and_then(Json::as_arr)
3088                    .map(|rows| rows.iter().filter_map(credit_link_from_json).collect())
3089                    .unwrap_or_default(),
3090                relationships_complete: facts
3091                    .and_then(|f| f.get("relationships_complete"))
3092                    .and_then(Json::as_bool)
3093                    .unwrap_or(false),
3094                relationships_for: facts
3095                    .and_then(|f| f.field_str("relationships_for"))
3096                    .filter(|id| !id.trim().is_empty()),
3097                works: facts
3098                    .and_then(|f| f.get("works"))
3099                    .and_then(Json::as_arr)
3100                    .map(|rows| {
3101                        rows.iter()
3102                            .filter_map(|work| {
3103                                Some(WorkLink {
3104                                    mbid: work.field_str("mbid")?,
3105                                    title: work.field_str("title").unwrap_or_default(),
3106                                    relation_id: work.field_str("relation_id"),
3107                                    relation_type: work.field_str("relation_type"),
3108                                    relation_type_id: work.field_str("relation_type_id"),
3109                                    direction: work.field_str("direction"),
3110                                    attributes: work
3111                                        .get("attributes")
3112                                        .and_then(Json::as_arr)
3113                                        .map(|rows| {
3114                                            rows.iter()
3115                                                .filter_map(credit_attribute_from_json)
3116                                                .collect()
3117                                        })
3118                                        .unwrap_or_default(),
3119                                    credits: work
3120                                        .get("credits")
3121                                        .and_then(Json::as_arr)
3122                                        .map(|rows| {
3123                                            rows.iter().filter_map(credit_link_from_json).collect()
3124                                        })
3125                                        .unwrap_or_default(),
3126                                    parents: work
3127                                        .get("parents")
3128                                        .and_then(Json::as_arr)
3129                                        .map(|rows| {
3130                                            rows.iter()
3131                                                .filter_map(|parent| {
3132                                                    Some(WorkParentLink {
3133                                                        mbid: parent.field_str("mbid")?,
3134                                                        title: parent
3135                                                            .field_str("title")
3136                                                            .unwrap_or_default(),
3137                                                        relation_id: parent
3138                                                            .field_str("relation_id"),
3139                                                        relation_type_id: parent
3140                                                            .field_str("relation_type_id"),
3141                                                        direction: parent.field_str("direction"),
3142                                                        attributes: parent
3143                                                            .get("attributes")
3144                                                            .and_then(Json::as_arr)
3145                                                            .map(|rows| {
3146                                                                rows.iter()
3147                                                                    .filter_map(
3148                                                                        credit_attribute_from_json,
3149                                                                    )
3150                                                                    .collect()
3151                                                            })
3152                                                            .unwrap_or_default(),
3153                                                        order: parent.field_u32("order"),
3154                                                    })
3155                                                })
3156                                                .collect()
3157                                        })
3158                                        .unwrap_or_default(),
3159                                })
3160                            })
3161                            .collect()
3162                    })
3163                    .unwrap_or_default(),
3164            }),
3165            EntityKind::Label => Facts::Label(LabelFacts {
3166                lookup_mbid: facts.and_then(|f| f.field_str("lookup_mbid")),
3167                wikidata: facts.and_then(|f| f.field_str("wikidata")),
3168                discogs: facts.and_then(|f| f.field_str("discogs")),
3169                summary_markup: facts.and_then(|f| f.field_str("summary_markup")),
3170                summary: facts.and_then(|f| f.get("summary")).and_then(|p| {
3171                    Some(Prose {
3172                        text: p.field_str("text")?,
3173                        url: p.field_str("url")?,
3174                        lang: p.field_str("lang")?,
3175                        licence: p.field_str("licence")?,
3176                    })
3177                }),
3178                logo: facts.and_then(|f| f.get("logo")).and_then(|p| {
3179                    Some(Picture {
3180                        url: p.field_str("url")?,
3181                    })
3182                }),
3183            }),
3184            _ => continue,
3185        };
3186
3187        let confidence = match row.field_str("confidence").as_deref() {
3188            Some("matched") => Confidence::matched(
3189                row.field_u32("score")
3190                    .unwrap_or(0)
3191                    .try_into()
3192                    .unwrap_or(u8::MAX),
3193            ),
3194            Some("identified") => Confidence::Identified,
3195            // Preserve the evidence, but a missing or unknown confidence
3196            // cannot prove the exact attachment required for navigation.
3197            _ => Confidence::matched(0),
3198        };
3199
3200        let source = row.field_str("source").unwrap_or_default();
3201        if !record_keys.insert((entity.kind, entity.key.clone(), source.clone())) {
3202            return Err(StoreError::Invalid("duplicate source records"));
3203        }
3204
3205        sources.records.push(SourceRecord {
3206            key: entity.key,
3207            source,
3208            source_id: row.field_str("source_id"),
3209            fetched_at: row.field_u64("fetched_at").unwrap_or(0),
3210            confidence,
3211            facts,
3212        });
3213    }
3214    if found >= 2 {
3215        for row in value.get("reviews").and_then(Json::as_arr).unwrap_or(&[]) {
3216            let Some(entity) = row
3217                .field_str("entity")
3218                .and_then(|token| EntityRef::parse_token(&token))
3219            else {
3220                continue;
3221            };
3222            let decision = match row.field_str("decision").as_deref() {
3223                Some("accepted") => ReviewDecision::Accepted,
3224                Some("rejected") => ReviewDecision::Rejected,
3225                _ => continue,
3226            };
3227            if sources.set_review(SourceReview {
3228                entity,
3229                source: row.field_str("source").unwrap_or_default(),
3230                source_id: row.field_str("source_id"),
3231                decision,
3232                reviewed_at: row.field_u64("reviewed_at").unwrap_or(0),
3233            }) {
3234                return Err(StoreError::Invalid("duplicate source reviews"));
3235            }
3236        }
3237        for row in value
3238            .get("credit_exclusions")
3239            .and_then(Json::as_arr)
3240            .unwrap_or(&[])
3241        {
3242            let (Some(source), Some(kind), Some(target), Some(provenance)) = (
3243                row.field_str("source")
3244                    .and_then(|value| EntityRef::parse_token(&value)),
3245                row.field_str("relation"),
3246                row.field_str("target")
3247                    .and_then(|value| EntityRef::parse_token(&value)),
3248                row.field_str("provenance"),
3249            ) else {
3250                continue;
3251            };
3252            if !kind.starts_with("credit:") {
3253                continue;
3254            }
3255            sources.exclude_credit(
3256                RelationRef {
3257                    source,
3258                    kind,
3259                    target,
3260                    provenance,
3261                    source_id: row.field_str("source_id"),
3262                },
3263                row.field_u64("excluded_at").unwrap_or(0),
3264            );
3265        }
3266    }
3267    Ok(sources)
3268}
3269
3270#[cfg(test)]
3271#[path = "sources_tests.rs"]
3272mod tests;