aede_core/
credit_coverage.rs

1//! Read-only coverage of MusicBrainz recording, work and edition credits.
2//!
3//! A completed relationship lookup with no credits is an answer, not a gap.
4//! Count canonical recordings rather than file placements so reissues do not
5//! inflate the recording figures. Each local edition has its own lookup;
6//! manual edition corrections are counted without pretending it was queried.
7
8use std::collections::BTreeMap;
9
10use crate::model::{Catalog, EntityKind, Id};
11use crate::sources::{Facts, MUSICBRAINZ, ReviewDecision, Sources, trusted_with_identity};
12use crate::user::EntityRef;
13
14/// What is known about a recording or edition's external credits.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum CreditStatus {
17    /// No usable local MusicBrainz identifier to ask about.
18    Unidentified,
19    /// No completed MusicBrainz relationship lookup is attached.
20    Waiting,
21    /// A completed answer exists, but its source identity is not trusted.
22    Untrusted,
23    /// A trusted lookup completed and returned no credits in its scope.
24    Empty,
25    /// A trusted lookup returned at least one credit in its scope.
26    Credited,
27}
28
29impl CreditStatus {
30    /// Stable machine-readable spelling.
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Self::Unidentified => "unidentified",
34            Self::Waiting => "waiting",
35            Self::Untrusted => "untrusted",
36            Self::Empty => "empty",
37            Self::Credited => "credited",
38        }
39    }
40}
41
42/// One canonical recording's coverage and the scope of its sourced credits.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct RecordingCreditCoverage {
45    /// Canonical recording in this catalog.
46    pub recording_id: Id,
47    /// Current external-credit state.
48    pub status: CreditStatus,
49    /// Direct recording relationships in the trusted answer.
50    pub recording_credits: usize,
51    /// Work-level relationships in the trusted answer.
52    pub work_credits: usize,
53}
54
55/// One local edition's external lookup and separately supplied corrections.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct EditionCreditCoverage {
58    /// Local release edition in this catalog.
59    pub release_id: Id,
60    /// MusicBrainz lookup state; manual corrections do not complete a lookup.
61    pub status: CreditStatus,
62    /// Edition relationships in the trusted completed MusicBrainz answer.
63    pub edition_credits: usize,
64    /// Trusted manual edition corrections, independent of lookup completion.
65    pub manual_credits: usize,
66}
67
68/// Classify local editions separately from their canonical recordings.
69///
70/// Only an exact, completed and trusted MusicBrainz answer supplies the lookup
71/// status. A manual correction uses its explicit local scope, never completes
72/// an external lookup, and cannot supply recording or work coverage. Counts
73/// describe the retained answers; excluding an individual relationship does
74/// not erase the evidence that an external lookup was completed.
75pub fn editions(catalog: &Catalog, sources: &Sources) -> Vec<EditionCreditCoverage> {
76    let records: BTreeMap<_, _> = sources
77        .records
78        .iter()
79        .filter(|record| matches!(&record.facts, Facts::Release(_)))
80        .map(|record| ((record.key.as_str(), record.source.as_str()), record))
81        .collect();
82    let reviews = reviews_by_claim(sources, EntityKind::Release);
83    catalog
84        .releases
85        .iter()
86        .map(|release| {
87            let local_id = usable_id(release.mbid.as_deref());
88            let mut coverage = EditionCreditCoverage {
89                release_id: release.id,
90                status: if local_id.is_some() {
91                    CreditStatus::Waiting
92                } else {
93                    CreditStatus::Unidentified
94                },
95                edition_credits: 0,
96                manual_credits: 0,
97            };
98            let Some(entity) = EntityRef::of(catalog, EntityKind::Release, release.id) else {
99                return coverage;
100            };
101            for source in [MUSICBRAINZ, "manual"] {
102                let Some(record) = records.get(&(entity.key.as_str(), source)) else {
103                    continue;
104                };
105                let Facts::Release(facts) = &record.facts else {
106                    continue;
107                };
108                let exact_edition = local_id.is_some_and(|id| record.matches_edition(id));
109                let review = reviews
110                    .get(&(
111                        record.key.as_str(),
112                        record.source.as_str(),
113                        record.source_id.as_deref(),
114                    ))
115                    .copied();
116                let local_identity = record
117                    .musicbrainz_release_identity()
118                    .and_then(|identity| identity.local_id(catalog, release));
119                let trusted = trusted_with_identity(record, local_identity, review);
120                if source == "manual" {
121                    if trusted && (facts.edition_mbid.is_none() || exact_edition) {
122                        coverage.manual_credits = facts.credits.len();
123                    }
124                } else if exact_edition && facts.relationships_complete {
125                    if !trusted {
126                        coverage.status = CreditStatus::Untrusted;
127                    } else {
128                        coverage.edition_credits = facts.credits.len();
129                        coverage.status = if facts.credits.is_empty() {
130                            CreditStatus::Empty
131                        } else {
132                            CreditStatus::Credited
133                        };
134                    }
135                }
136            }
137            coverage
138        })
139        .collect()
140}
141
142fn usable_id(id: Option<&str>) -> Option<&str> {
143    id.filter(|id| !id.trim().is_empty())
144}
145
146fn reviews_by_claim(
147    sources: &Sources,
148    kind: EntityKind,
149) -> BTreeMap<(&str, &str, Option<&str>), ReviewDecision> {
150    let mut result = BTreeMap::new();
151    for review in sources
152        .reviews
153        .iter()
154        .filter(|review| review.entity.kind == kind)
155    {
156        // Keep the first matching decision, exactly as Sources::review_for.
157        result
158            .entry((
159                review.entity.key.as_str(),
160                review.source.as_str(),
161                review.source_id.as_deref(),
162            ))
163            .or_insert(review.decision);
164    }
165    result
166}
167
168/// Classify every canonical recording, including those without an MBID.
169pub fn recordings(catalog: &Catalog, sources: &Sources) -> Vec<RecordingCreditCoverage> {
170    let records_by_path: BTreeMap<&str, &crate::sources::SourceRecord> = sources
171        .records
172        .iter()
173        .filter(|record| record.source == MUSICBRAINZ && matches!(&record.facts, Facts::Track(_)))
174        .map(|record| (record.key.as_str(), record))
175        .collect();
176    let reviews = reviews_by_claim(sources, EntityKind::Track);
177    catalog
178        .recordings
179        .iter()
180        .map(|recording| {
181            let local_id = usable_id(recording.mbid.as_deref());
182            let mut coverage = RecordingCreditCoverage {
183                recording_id: recording.id,
184                status: if local_id.is_some() {
185                    CreditStatus::Waiting
186                } else {
187                    CreditStatus::Unidentified
188                },
189                recording_credits: 0,
190                work_credits: 0,
191            };
192            if local_id.is_none() {
193                return coverage;
194            }
195            for &track_id in &recording.track_ids {
196                let Some(entity) = EntityRef::of(catalog, EntityKind::Track, track_id) else {
197                    continue;
198                };
199                let Some(record) = records_by_path.get(entity.key.as_str()) else {
200                    continue;
201                };
202                let Facts::Track(facts) = &record.facts else {
203                    continue;
204                };
205                if !facts.relationships_complete
206                    || record.musicbrainz_recording_identity().is_none()
207                {
208                    continue;
209                }
210                let review = reviews
211                    .get(&(
212                        record.key.as_str(),
213                        record.source.as_str(),
214                        record.source_id.as_deref(),
215                    ))
216                    .copied();
217                if !trusted_with_identity(record, local_id, review) {
218                    if coverage.status == CreditStatus::Waiting {
219                        coverage.status = CreditStatus::Untrusted;
220                    }
221                    continue;
222                }
223                let work_credits = facts.works.iter().map(|work| work.credits.len()).sum();
224                let direct_credits = facts.credits.len();
225                // Several local placements can carry the same recording. A
226                // source answer is a snapshot, not an additive list of rows.
227                // Prefer the most informative trusted completed snapshot.
228                if coverage.status != CreditStatus::Credited
229                    || direct_credits + work_credits
230                        > coverage.recording_credits + coverage.work_credits
231                {
232                    coverage.recording_credits = direct_credits;
233                    coverage.work_credits = work_credits;
234                    coverage.status = if direct_credits + work_credits == 0 {
235                        CreditStatus::Empty
236                    } else {
237                        CreditStatus::Credited
238                    };
239                }
240            }
241            coverage
242        })
243        .collect()
244}
245
246#[cfg(test)]
247#[path = "credit_coverage_tests.rs"]
248mod tests;