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;