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;