aede_core/
credit_coverage.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum CreditStatus {
17 Unidentified,
19 Waiting,
21 Untrusted,
23 Empty,
25 Credited,
27}
28
29impl CreditStatus {
30 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct RecordingCreditCoverage {
45 pub recording_id: Id,
47 pub status: CreditStatus,
49 pub recording_credits: usize,
51 pub work_credits: usize,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct EditionCreditCoverage {
58 pub release_id: Id,
60 pub status: CreditStatus,
62 pub edition_credits: usize,
64 pub manual_credits: usize,
66}
67
68pub 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 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
168pub 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 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;