aede_core/missing.rs
1//! Missing studio albums are derived from trusted discographies, never catalog entities.
2use crate::model::Catalog;
3use crate::sources::{self, Facts, KnownRelease};
4use crate::text;
5
6/// One record credited to an artist that this catalog does not hold.
7pub struct Absent<'a> {
8 /// The artist, as the catalog spells them.
9 pub artist: String,
10 /// Why this row is not normally on the report, and `None` when it is.
11 ///
12 /// Two quite different reasons, in one field because they answer one
13 /// question — *why am I not being shown this* — and because a record can
14 /// be both: a live album the reader also set aside. The words for the
15 /// first are MusicBrainz's own; see [`KnownRelease::stated_type`].
16 pub held_back: Option<String>,
17 /// `true` when the reader set this one aside and asked to see it anyway.
18 pub set_aside: bool,
19 /// Which artist, for a caller asking about one rather than about all.
20 pub artist_id: crate::model::Id,
21 /// The record MusicBrainz credits to them.
22 pub known: &'a KnownRelease,
23}
24
25/// What is credited to your artists and not on your shelf.
26///
27/// **Derived, never stored.** The comparison happens here, at the moment of
28/// reading, so the day an album is added to the library it stops being listed —
29/// with nothing to update and nothing to go stale.
30///
31/// Two ways of recognising a record you already have, in this order:
32///
33/// 1. the **release-group identifier**, when your tags carry one, including a
34/// trusted exact lookup's verified alias. Edition IDs never establish groups.
35/// 2. the **normalised title**, otherwise. `text::normalize` is the same
36/// function that decides two spellings are one name everywhere else in this
37/// program, so a shelf that says "Kind Of Blue" is not told it is missing
38/// "Kind of Blue".
39///
40/// Two things are held back, and `everything` lifts both.
41///
42/// Only studio albums are reported — see [`KnownRelease::is_studio_album`] for
43/// why a complete discography would be a true and useless answer — and only
44/// records the reader has not set aside. Those are the report's two filters,
45/// and `--all` is one word for "hold nothing back", so it lifts both and every
46/// row that came back this way carries the reason it would not normally be
47/// here. **A filter is only honest when the reader can turn it off**: saying
48/// what is left out and offering no way to see it is a locked door with a
49/// label on it.
50pub fn absent<'a>(
51 catalog: &Catalog,
52 held: &'a sources::Sources,
53 aside: &[crate::user::SetAside],
54 everything: bool,
55) -> Vec<Absent<'a>> {
56 let mut out: Vec<Absent<'a>> = Vec::new();
57 let mut ids: std::collections::BTreeSet<&str> = catalog
58 .releases
59 .iter()
60 .filter_map(|release| release.release_group_mbid.as_deref())
61 .filter(|id| !id.trim().is_empty())
62 .collect();
63 // A verified redirect describes the same group on either side of a retag.
64 // Keep literal tag identities; add only the confirmed equivalent of an
65 // owned group, never an edition fallback or a conflicting accepted claim.
66 for record in &held.records {
67 let (Some(sources::MusicBrainzReleaseIdentity::ReleaseGroup(canonical)), Some(alias)) = (
68 record.musicbrainz_release_identity(),
69 record.musicbrainz_lookup_alias(),
70 ) else {
71 continue;
72 };
73 let local = record.local_musicbrainz_id(catalog);
74 if (local == Some(canonical) || local == Some(alias)) && held.is_trusted(catalog, record) {
75 ids.insert(canonical);
76 ids.insert(alias);
77 }
78 }
79 for record in &held.records {
80 if record.source != sources::MUSICBRAINZ {
81 continue;
82 }
83 if !held.is_trusted(catalog, record) {
84 continue;
85 }
86 let Facts::Artist(facts) = &record.facts else {
87 continue;
88 };
89 if facts.discography.is_empty() {
90 continue;
91 }
92 let entity = record.entity();
93 // An artist the catalog cannot place has no shelf to be missing from.
94 let Some(artist_id) = entity.resolve(catalog) else {
95 continue;
96 };
97 let Some(artist) = catalog.artist(artist_id) else {
98 continue;
99 };
100
101 // Exact identities apply across the catalog; a title fallback belongs
102 // only to this artist's albums without a known release-group identity.
103 let mut has_shelf = false;
104 let mut titles: Vec<String> = Vec::new();
105 for release in &catalog.releases {
106 if release.album_artist_id != Some(artist_id) {
107 continue;
108 }
109 has_shelf = true;
110 if release
111 .release_group_mbid
112 .as_deref()
113 .is_none_or(|id| id.trim().is_empty())
114 {
115 titles.push(text::normalize(&release.title));
116 }
117 }
118
119 // **No album of their own, no shelf to have gaps in.** The catalog
120 // holds an artist for every credit it reads — a guest on one track, a
121 // composer, one name on a compilation — and being in the catalog is
122 // not the same as having a place in the library. The first version
123 // missed that distinction and answered a question nobody asked: one
124 // Rolling Stones track on a compilation produced their entire studio
125 // discography as "missing", which is true and worthless, and it did
126 // so for every passing credit at once until the report was mostly
127 // that. What this reports is an *incomplete* discography, which means
128 // one that was started.
129 //
130 // The same rule decides who is browsed at all — see the discography fetch selection.
131 if !has_shelf {
132 continue;
133 }
134
135 for known in &facts.discography {
136 // Set aside by hand. Filtered here rather than by dropping it from
137 // the layer, because the two are different claims and this program
138 // keeps them apart everywhere else: MusicBrainz still says this is
139 // an album, and the user still says it is not one they want listed.
140 // Deleting the record would lose the first to record the second.
141 let put_aside = aside.iter().any(|a| a.release_group == known.mbid);
142
143 // Both reasons, in the order a reader meets them: what it is, then
144 // what they decided about it. A record can be both, and naming only
145 // one would answer half the question.
146 let mut why: Vec<String> = Vec::new();
147 if !known.is_studio_album() {
148 why.push(known.stated_type());
149 }
150 if put_aside {
151 why.push("set aside".to_string());
152 }
153 let held_back = (!why.is_empty()).then(|| why.join(", "));
154 if held_back.is_some() && !everything {
155 continue;
156 }
157
158 let have = ids.contains(known.mbid.as_str())
159 || titles.contains(&text::normalize(&known.title));
160 if !have {
161 out.push(Absent {
162 artist: artist.name.clone(),
163 held_back,
164 set_aside: put_aside,
165 artist_id,
166 known,
167 });
168 }
169 }
170 }
171 // Oldest first within an artist, so a discography reads as one.
172 out.sort_by(|a, b| {
173 a.artist
174 .cmp(&b.artist)
175 .then_with(|| a.known.first_released.cmp(&b.known.first_released))
176 });
177 out
178}
179
180/// How many records one artist has that this shelf does not.
181///
182/// For a page to say so at the moment somebody is looking at that artist —
183/// which is the moment they wonder. `aede missing` was in the help, in the
184/// README and in the manual, and still could not be found, because it was
185/// named nowhere near the question it answers. The same fault `aede extract`
186/// had, and the same fix.
187///
188/// Derived like everything else here: nothing is stored, so the number falls
189/// the day an album is added.
190pub fn absent_for(
191 catalog: &Catalog,
192 held: &sources::Sources,
193 aside: &[crate::user::SetAside],
194 artist_id: crate::model::Id,
195) -> usize {
196 absent(catalog, held, aside, false)
197 .iter()
198 .filter(|record| record.artist_id == artist_id)
199 .count()
200}
201
202#[cfg(test)]
203#[path = "missing_tests.rs"]
204mod tests;