aede_core/
contributors.rs

1//! Source-backed artist identities for contributors absent from local tags.
2//!
3//! These are read-only views over trusted credit assertions, not new catalog
4//! artists. Grouping by MusicBrainz ID prevents two namesakes from becoming
5//! one person and prevents a reissue from inventing another identity.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use crate::model::{Catalog, Id};
10use crate::sources::Sources;
11use crate::text;
12
13/// An artist credited on local recordings but not identified in local tags.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct SourcedContributor {
16    /// MusicBrainz artist identity from the credited relationship.
17    pub mbid: String,
18    /// Canonical name reported by the most recent trusted credit.
19    pub name: String,
20    /// Other canonical or credited-as spellings carried by those credits.
21    pub names: Vec<String>,
22    /// Local recordings on which this person has a trusted credit.
23    pub recording_ids: Vec<Id>,
24    /// Local editions on which the artist has a direct edition credit.
25    pub release_ids: Vec<Id>,
26    /// Explicit work identities on which this person has a trusted credit.
27    pub work_mbids: Vec<String>,
28}
29
30struct Builder {
31    name: String,
32    fetched_at: u64,
33    names: BTreeSet<String>,
34    recording_ids: BTreeSet<Id>,
35    release_ids: BTreeSet<Id>,
36    work_mbids: BTreeSet<String>,
37}
38
39/// Group trusted source credits into stable, non-local artist identities.
40pub fn sourced(catalog: &Catalog, sources: &Sources) -> Vec<SourcedContributor> {
41    let local_mbids: BTreeSet<&str> = catalog
42        .artists
43        .iter()
44        .filter_map(|artist| artist.mbid.as_deref())
45        .collect();
46    let mut grouped: BTreeMap<String, Builder> = BTreeMap::new();
47    for link in sources
48        .credit_links(catalog)
49        .into_iter()
50        .filter(|link| link.trusted)
51    {
52        let mbid = link.credit.artist_mbid.trim();
53        if mbid.is_empty() || local_mbids.contains(mbid) {
54            continue;
55        }
56        let name = link.credit.artist_name.trim();
57        let builder = grouped.entry(mbid.to_string()).or_insert_with(|| Builder {
58            name: name.to_string(),
59            fetched_at: link.fetched_at,
60            names: BTreeSet::new(),
61            recording_ids: BTreeSet::new(),
62            release_ids: BTreeSet::new(),
63            work_mbids: BTreeSet::new(),
64        });
65        if !name.is_empty() {
66            builder.names.insert(name.to_string());
67            if builder.name.is_empty()
68                || link.fetched_at > builder.fetched_at
69                || (link.fetched_at == builder.fetched_at && name < builder.name.as_str())
70            {
71                builder.name = name.to_string();
72                builder.fetched_at = link.fetched_at;
73            }
74        }
75        if let Some(credited_as) = link.credit.credited_as.as_deref()
76            && !credited_as.trim().is_empty()
77        {
78            builder.names.insert(credited_as.trim().to_string());
79        }
80        builder
81            .recording_ids
82            .extend(link.applicable_recording_ids.iter().copied());
83        if let Some(work) = &link.work {
84            builder.work_mbids.insert(work.mbid.clone());
85        }
86    }
87    for link in sources
88        .edition_credit_links(catalog)
89        .into_iter()
90        .filter(|link| link.trusted)
91    {
92        let mbid = link.credit.artist_mbid.trim();
93        if mbid.is_empty() || local_mbids.contains(mbid) {
94            continue;
95        }
96        let name = link.credit.artist_name.trim();
97        let builder = grouped.entry(mbid.to_string()).or_insert_with(|| Builder {
98            name: name.to_string(),
99            fetched_at: link.fetched_at,
100            names: BTreeSet::new(),
101            recording_ids: BTreeSet::new(),
102            release_ids: BTreeSet::new(),
103            work_mbids: BTreeSet::new(),
104        });
105        if !name.is_empty() {
106            builder.names.insert(name.to_string());
107            if builder.name.is_empty()
108                || link.fetched_at > builder.fetched_at
109                || (link.fetched_at == builder.fetched_at && name < builder.name.as_str())
110            {
111                builder.name = name.to_string();
112                builder.fetched_at = link.fetched_at;
113            }
114        }
115        if let Some(credited_as) = link.credit.credited_as.as_deref()
116            && !credited_as.trim().is_empty()
117        {
118            builder.names.insert(credited_as.trim().to_string());
119        }
120        builder.release_ids.insert(link.release_id);
121    }
122    grouped
123        .into_iter()
124        .map(|(mbid, builder)| SourcedContributor {
125            name: if builder.name.is_empty() {
126                mbid.clone()
127            } else {
128                builder.name
129            },
130            mbid,
131            names: builder.names.into_iter().collect(),
132            recording_ids: builder.recording_ids.into_iter().collect(),
133            release_ids: builder.release_ids.into_iter().collect(),
134            work_mbids: builder.work_mbids.into_iter().collect(),
135        })
136        .collect()
137}
138
139/// Match a sourced contributor by canonical or credited-as spelling.
140/// Callers that select by MusicBrainz identity compare [`SourcedContributor::mbid`] directly.
141pub fn matches_name(contributor: &SourcedContributor, query: &str, exact: bool) -> bool {
142    let wanted = text::normalize(query);
143    !wanted.is_empty()
144        && contributor.names.iter().any(|name| {
145            let key = text::normalize(name);
146            if exact {
147                key == wanted
148            } else {
149                key.contains(&wanted)
150            }
151        })
152}
153
154#[cfg(test)]
155#[path = "contributors_tests.rs"]
156mod tests;