Skip to main content

koan_core/helpers/
credits.rs

1//! A credit as it reads, with each artist it names linked.
2
3use std::collections::HashMap;
4
5use rusqlite::Connection;
6use unicode_normalization::UnicodeNormalization;
7
8use crate::db::connection::DbError;
9use crate::db::queries::batch::{CreditsOf, credited_artists};
10
11/// One stretch of a credit: an artist's name, linked to them, or the plain
12/// text joining two names.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct CreditPart {
15    pub text: String,
16    pub artist_id: Option<i64>,
17}
18
19/// `credit` cut into parts, each credited artist's name linked to that artist
20/// and the joining text ("×", ", ", " feat. ") plain.
21///
22/// The names are looked for in credit order, as whole words, ignoring case,
23/// whitespace and Unicode normalisation, so a link reads as the credit
24/// spells it. When one is not there, as when MusicBrainz spells an artist
25/// differently from the credit, the artists are listed joined by commas
26/// instead. With fewer than two credited artists the credit is one part,
27/// linked to `filed`, the artist the record or track is filed under.
28pub fn credit_parts<S: AsRef<str>>(
29    credit: &str,
30    filed: Option<i64>,
31    artists: &[(i64, S)],
32) -> Vec<CreditPart> {
33    if artists.len() < 2 {
34        return vec![CreditPart {
35            text: credit.to_string(),
36            artist_id: filed,
37        }];
38    }
39    let credit: String = credit.nfc().collect();
40    let folded = Folded::new(&credit);
41    let mut parts = Vec::with_capacity(artists.len() * 2 + 1);
42    let mut at = 0;
43    let mut byte = 0;
44    for (id, name) in artists {
45        let name = Folded::name(name.as_ref());
46        let Some(found) = folded.find(&name, at) else {
47            return comma_joined(artists);
48        };
49        let (start, end) = folded.bytes(found, name.len());
50        if start > byte {
51            parts.push(CreditPart {
52                text: credit[byte..start].to_string(),
53                artist_id: None,
54            });
55        }
56        parts.push(CreditPart {
57            text: credit[start..end].to_string(),
58            artist_id: Some(*id),
59        });
60        at = found + name.len();
61        byte = end;
62    }
63    if byte < credit.len() {
64        parts.push(CreditPart {
65            text: credit[byte..].to_string(),
66            artist_id: None,
67        });
68    }
69    parts
70}
71
72/// A credit folded as names are matched: NFC, lowercased a character at a
73/// time, whitespace collapsed, final sigma read as sigma. Each folded
74/// character keeps the bytes of the credit it came from. Names are folded the
75/// same way: a word-final Σ lowers to σ a character at a time and to ς in a
76/// whole string, and a name may be spelt either way.
77struct Folded {
78    chars: Vec<char>,
79    spans: Vec<(usize, usize)>,
80}
81
82impl Folded {
83    fn new(credit: &str) -> Self {
84        let mut chars = Vec::with_capacity(credit.len());
85        let mut spans: Vec<(usize, usize)> = Vec::with_capacity(credit.len());
86        for (at, c) in credit.char_indices() {
87            let span = (at, at + c.len_utf8());
88            if c.is_whitespace() {
89                if chars.last() == Some(&' ') {
90                    spans.last_mut().expect("a space was pushed").1 = span.1;
91                } else {
92                    chars.push(' ');
93                    spans.push(span);
94                }
95            } else {
96                for lower in c.to_lowercase() {
97                    chars.push(if lower == 'ς' { 'σ' } else { lower });
98                    spans.push(span);
99                }
100            }
101        }
102        Self { chars, spans }
103    }
104
105    fn name(name: &str) -> Vec<char> {
106        let name: String = name.nfc().collect();
107        let mut chars = Self::new(&name).chars;
108        while chars.last() == Some(&' ') {
109            chars.pop();
110        }
111        let lead = chars.iter().take_while(|c| **c == ' ').count();
112        chars.split_off(lead)
113    }
114
115    /// Where `name` next occurs from `from` as a whole word: not run into a
116    /// letter or digit on either side.
117    fn find(&self, name: &[char], from: usize) -> Option<usize> {
118        if name.is_empty() || name.len() > self.chars.len() {
119            return None;
120        }
121        let word = |i: usize| self.chars.get(i).is_some_and(|c| c.is_alphanumeric());
122        (from..=self.chars.len() - name.len()).find(|&i| {
123            self.chars[i..i + name.len()] == *name
124                && !(name[0].is_alphanumeric() && i > 0 && word(i - 1))
125                && !(name[name.len() - 1].is_alphanumeric() && word(i + name.len()))
126        })
127    }
128
129    /// The credit's bytes for `len` folded characters from `at`.
130    fn bytes(&self, at: usize, len: usize) -> (usize, usize) {
131        (self.spans[at].0, self.spans[at + len - 1].1)
132    }
133}
134
135fn comma_joined<S: AsRef<str>>(artists: &[(i64, S)]) -> Vec<CreditPart> {
136    let mut parts = Vec::with_capacity(artists.len() * 2 - 1);
137    for (i, (id, name)) in artists.iter().enumerate() {
138        if i > 0 {
139            parts.push(CreditPart {
140                text: ", ".to_string(),
141                artist_id: None,
142            });
143        }
144        parts.push(CreditPart {
145            text: name.as_ref().to_string(),
146            artist_id: Some(*id),
147        });
148    }
149    parts
150}
151
152/// The credited artists of these tracks or albums, as `credit_parts` takes
153/// them: id and name in credit order, keyed by track or album.
154pub fn credits_by_id(
155    conn: &Connection,
156    of: CreditsOf,
157    ids: &[i64],
158) -> Result<HashMap<i64, Vec<(i64, String)>>, DbError> {
159    Ok(credited_artists(conn, of, ids)?
160        .into_iter()
161        .map(|(owner, artists)| {
162            let named = artists
163                .into_iter()
164                .map(|c| (c.artist.id, c.artist.name))
165                .collect();
166            (owner, named)
167        })
168        .collect())
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn read(parts: &[CreditPart]) -> Vec<(&str, Option<i64>)> {
176        parts
177            .iter()
178            .map(|p| (p.text.as_str(), p.artist_id))
179            .collect()
180    }
181
182    #[test]
183    fn each_name_in_the_credit_links_its_artist() {
184        let credit = "Orchestre Tout Puissant Marcel Duchamp × Frànçois & the Atlas Mountains";
185        let parts = credit_parts(
186            credit,
187            Some(9),
188            &[
189                (1, "Orchestre Tout Puissant Marcel Duchamp"),
190                (2, "Frànçois & the Atlas Mountains"),
191            ],
192        );
193        assert_eq!(
194            read(&parts),
195            [
196                ("Orchestre Tout Puissant Marcel Duchamp", Some(1)),
197                (" × ", None),
198                ("Frànçois & the Atlas Mountains", Some(2)),
199            ]
200        );
201    }
202
203    #[test]
204    fn text_around_the_names_is_kept_plain() {
205        let parts = credit_parts("A feat. B (live)", None, &[(1, "A"), (2, "B")]);
206        assert_eq!(
207            read(&parts),
208            [
209                ("A", Some(1)),
210                (" feat. ", None),
211                ("B", Some(2)),
212                (" (live)", None)
213            ]
214        );
215    }
216
217    #[test]
218    fn a_name_not_in_the_credit_lists_the_artists_by_commas() {
219        let parts = credit_parts("Bjork & Friend", None, &[(1, "Björk"), (2, "Friend")]);
220        assert_eq!(
221            read(&parts),
222            [("Björk", Some(1)), (", ", None), ("Friend", Some(2))]
223        );
224    }
225
226    #[test]
227    fn a_name_is_found_whatever_its_case_and_reads_as_the_credit_spells_it() {
228        let parts = credit_parts(
229            "Boards Of Canada  &  Hood",
230            None,
231            &[(1, "Boards of Canada"), (2, "Hood")],
232        );
233        assert_eq!(
234            read(&parts),
235            [
236                ("Boards Of Canada", Some(1)),
237                ("  &  ", None),
238                ("Hood", Some(2))
239            ]
240        );
241    }
242
243    #[test]
244    fn a_name_is_matched_only_as_a_whole_word() {
245        let parts = credit_parts("Sonsie & Sons", None, &[(1, "Sons"), (2, "Sonsie")]);
246        assert_eq!(
247            read(&parts),
248            [("Sons", Some(1)), (", ", None), ("Sonsie", Some(2))]
249        );
250        let parts = credit_parts("Ann & Annabel", None, &[(1, "Ann"), (2, "Annabel")]);
251        assert_eq!(
252            read(&parts),
253            [("Ann", Some(1)), (" & ", None), ("Annabel", Some(2))]
254        );
255    }
256
257    #[test]
258    fn a_name_ending_in_sigma_is_found() {
259        let parts = credit_parts("ΧΆΟΣ & Βήμα", None, &[(1, "Χάος"), (2, "Βήμα")]);
260        assert_eq!(
261            read(&parts),
262            [("ΧΆΟΣ", Some(1)), (" & ", None), ("Βήμα", Some(2))]
263        );
264    }
265
266    #[test]
267    fn names_are_found_in_credit_order() {
268        let parts = credit_parts("B & A", None, &[(1, "A"), (2, "B")]);
269        assert_eq!(read(&parts), [("A", Some(1)), (", ", None), ("B", Some(2))]);
270    }
271
272    #[test]
273    fn a_credit_naming_no_artists_links_where_it_is_filed() {
274        assert_eq!(
275            read(&credit_parts::<&str>("Solo", Some(4), &[])),
276            [("Solo", Some(4))]
277        );
278        assert_eq!(read(&credit_parts::<&str>("", None, &[])), [("", None)]);
279    }
280}