Skip to main content

koan_core/
artist_info.rs

1//! An artist's biography and photograph.
2//!
3//! Resolved by identity rather than by name: the artist's MusicBrainz id, the
4//! Wikidata item MusicBrainz links it to, and from there the Wikipedia article
5//! and the Commons image. A name search picks the wrong one of several bands
6//! sharing a name, and a biography of the wrong band is worse than none.
7//!
8//! Cached per artist, misses included, so a page draws from the database and
9//! never waits on the network, and an artist with no article is not asked
10//! about again on every visit. What was found is refreshed after a month.
11
12use std::sync::LazyLock;
13use std::time::{Duration, Instant};
14
15use rusqlite::{Connection, OptionalExtension, params};
16use thiserror::Error;
17
18use crate::remote::musicbrainz::{self, MusicBrainzError};
19use crate::remote::wikimedia::{self, WikimediaError};
20
21const REFRESH_AFTER_SECS: i64 = 30 * 24 * 60 * 60;
22
23/// Wide enough for a header at 2×; Commons serves the nearest standard size.
24const IMAGE_WIDTH: u32 = 800;
25
26#[derive(Debug, Error)]
27pub enum ArtistInfoError {
28    #[error("database error: {0}")]
29    Db(#[from] rusqlite::Error),
30    #[error("musicbrainz: {0}")]
31    MusicBrainz(#[from] MusicBrainzError),
32    #[error("wikimedia: {0}")]
33    Wikimedia(#[from] WikimediaError),
34}
35
36#[derive(Debug, Clone, Default, PartialEq)]
37pub struct ArtistInfo {
38    pub bio: Option<String>,
39    /// The article the biography is the opening of.
40    pub bio_url: Option<String>,
41    pub image_url: Option<String>,
42    /// Photographer and licence, as the image's licence asks.
43    pub image_credit: Option<String>,
44    pub fetched_at: i64,
45}
46
47impl ArtistInfo {
48    fn is_stale(&self, now: i64) -> bool {
49        now - self.fetched_at > REFRESH_AFTER_SECS
50    }
51}
52
53/// What the database holds, without touching the network.
54pub fn cached(conn: &Connection, artist_id: i64) -> rusqlite::Result<Option<ArtistInfo>> {
55    conn.query_row(
56        "SELECT bio, bio_url, image_url, image_credit, fetched_at
57           FROM artist_info WHERE artist_id = ?1",
58        params![artist_id],
59        |row| {
60            Ok(ArtistInfo {
61                bio: row.get(0)?,
62                bio_url: row.get(1)?,
63                image_url: row.get(2)?,
64                image_credit: row.get(3)?,
65                fetched_at: row.get(4)?,
66            })
67        },
68    )
69    .optional()
70}
71
72/// The cache while it is fresh, otherwise the network, stored for next time.
73///
74/// A network failure is not remembered as a miss — it says nothing about the
75/// artist — so a stale answer is served instead, if there is one.
76pub fn fetch(conn: &Connection, artist_id: i64) -> Result<Option<ArtistInfo>, ArtistInfoError> {
77    let now = now();
78    let held = cached(conn, artist_id)?;
79    if let Some(held) = &held
80        && !held.is_stale(now)
81    {
82        return Ok(Some(held.clone()));
83    }
84
85    match look_up(conn, artist_id, now) {
86        Ok(Some(info)) => {
87            store(conn, artist_id, &info)?;
88            Ok(Some(info))
89        }
90        Ok(None) => Ok(held),
91        Err(e) => {
92            log::warn!("artist info for {artist_id}: {e}");
93            match held {
94                Some(held) => Ok(Some(held)),
95                None => Err(e),
96            }
97        }
98    }
99}
100
101/// The photograph's bytes. Network, every time: the front end caches images,
102/// and the database is no place for them.
103pub fn image(conn: &Connection, artist_id: i64) -> Result<Option<Vec<u8>>, ArtistInfoError> {
104    let Some(url) = cached(conn, artist_id)?.and_then(|info| info.image_url) else {
105        return Ok(None);
106    };
107    Ok(Some(wikimedia::download(&wikimedia::client(), &url)?))
108}
109
110/// `None` when the artist is not one this can look up at all; a found-nothing
111/// answer is `Some` with every field empty, and is cached.
112fn look_up(
113    conn: &Connection,
114    artist_id: i64,
115    now: i64,
116) -> Result<Option<ArtistInfo>, ArtistInfoError> {
117    let Some((name, mbid)) = conn
118        .query_row(
119            "SELECT name, mbid FROM artists WHERE id = ?1",
120            params![artist_id],
121            |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
122        )
123        .optional()?
124    else {
125        return Ok(None);
126    };
127    if is_placeholder_name(&name) {
128        return Ok(None);
129    }
130
131    let mb = musicbrainz::default_client();
132    let mbid = match mbid {
133        Some(mbid) => Some(mbid),
134        None => {
135            let found = resolve_mbid(conn, &mb, artist_id, &name)?;
136            if let Some(found) = &found {
137                conn.execute(
138                    "UPDATE artists SET mbid = COALESCE(mbid, ?1) WHERE id = ?2",
139                    params![found, artist_id],
140                )?;
141            }
142            found
143        }
144    };
145
146    let mut info = ArtistInfo {
147        fetched_at: now,
148        ..Default::default()
149    };
150    let Some(mbid) = mbid else {
151        return Ok(Some(info));
152    };
153    throttle();
154    let Some(qid) = musicbrainz::wikidata_id(&mb, &mbid)? else {
155        return Ok(Some(info));
156    };
157
158    let wm = wikimedia::client();
159    let entity = wikimedia::entity(&wm, &qid)?;
160    if let Some(article) = &entity.article
161        && let Some(intro) = wikimedia::intro(&wm, article)?
162    {
163        info.bio = Some(intro.text);
164        info.bio_url = Some(intro.url).filter(|url| !url.is_empty());
165    }
166    // A vector file is a logo or a diagram, never a photograph.
167    if let Some(file) = entity
168        .image
169        .as_ref()
170        .filter(|f| !f.to_lowercase().ends_with(".svg"))
171        && let Some(image) = wikimedia::image(&wm, file, IMAGE_WIDTH)?
172    {
173        info.image_url = Some(image.url);
174        info.image_credit = image.credit;
175    }
176    Ok(Some(info))
177}
178
179/// Find the artist's MusicBrainz id without one on the row.
180///
181/// Through one of their releases first: an album synced from a server, or
182/// scanned from tags that carry `MUSICBRAINZ_ALBUMID`, names its artists by id,
183/// so there is nothing to guess. A name search is the last resort, and only an
184/// exact, unique match is taken.
185fn resolve_mbid(
186    conn: &Connection,
187    mb: &reqwest::blocking::Client,
188    artist_id: i64,
189    name: &str,
190) -> Result<Option<String>, ArtistInfoError> {
191    let release: Option<String> = conn
192        .query_row(
193            "SELECT mbid FROM albums WHERE artist_id = ?1 AND mbid IS NOT NULL LIMIT 1",
194            params![artist_id],
195            |row| row.get(0),
196        )
197        .optional()?;
198    if let Some(release) = release {
199        throttle();
200        let credits = musicbrainz::release_artists(mb, &release)?;
201        if let Some(mbid) = credited(&credits, name) {
202            return Ok(Some(mbid));
203        }
204    }
205
206    throttle();
207    let results = musicbrainz::search_artist(mb, name, 5)?;
208    let mut exact = results
209        .into_iter()
210        .filter(|r| r.score >= 95 && same_name(&r.name, name));
211    Ok(match (exact.next(), exact.next()) {
212        (Some(only), None) => Some(only.mbid),
213        _ => None,
214    })
215}
216
217/// The credit on a release that is this artist: by name, or the only one.
218fn credited(credits: &[(String, String)], name: &str) -> Option<String> {
219    let only = match credits {
220        [only] => Some(only),
221        _ => None,
222    };
223    credits
224        .iter()
225        .find(|(credit, _)| same_name(credit, name))
226        .or(only)
227        .map(|(_, mbid)| mbid.clone())
228}
229
230/// Names as the sources spell them. MusicBrainz writes typographic hyphens and
231/// apostrophes where tags almost always carry the ASCII ones: "At the Drive‐In".
232fn same_name(a: &str, b: &str) -> bool {
233    fn fold(name: &str) -> String {
234        name.chars()
235            .map(|c| match c {
236                '\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
237                '\u{2018}' | '\u{2019}' | '\u{02BC}' => '\'',
238                '\u{201C}' | '\u{201D}' => '"',
239                c => c,
240            })
241            .flat_map(char::to_lowercase)
242            .collect::<String>()
243            .split_whitespace()
244            .collect::<Vec<_>>()
245            .join(" ")
246    }
247    fold(a) == fold(b)
248}
249
250fn is_placeholder_name(name: &str) -> bool {
251    ["various artists", "unknown artist", "[unknown]"]
252        .iter()
253        .any(|p| name.eq_ignore_ascii_case(p))
254}
255
256fn store(conn: &Connection, artist_id: i64, info: &ArtistInfo) -> rusqlite::Result<()> {
257    conn.execute(
258        "INSERT INTO artist_info (artist_id, bio, bio_url, image_url, image_credit, fetched_at)
259         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
260         ON CONFLICT(artist_id) DO UPDATE SET
261             bio = excluded.bio, bio_url = excluded.bio_url,
262             image_url = excluded.image_url, image_credit = excluded.image_credit,
263             fetched_at = excluded.fetched_at",
264        params![
265            artist_id,
266            info.bio,
267            info.bio_url,
268            info.image_url,
269            info.image_credit,
270            info.fetched_at
271        ],
272    )?;
273    Ok(())
274}
275
276/// MusicBrainz allows one request a second per client, and paging through
277/// artists quickly would otherwise exceed it.
278fn throttle() {
279    static LAST: LazyLock<parking_lot::Mutex<Option<Instant>>> =
280        LazyLock::new(|| parking_lot::Mutex::new(None));
281    let mut last = LAST.lock();
282    if let Some(at) = *last {
283        let wait = Duration::from_secs(1).saturating_sub(at.elapsed());
284        if !wait.is_zero() {
285            std::thread::sleep(wait);
286        }
287    }
288    *last = Some(Instant::now());
289}
290
291fn now() -> i64 {
292    std::time::SystemTime::now()
293        .duration_since(std::time::UNIX_EPOCH)
294        .map(|d| d.as_secs() as i64)
295        .unwrap_or(0)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::db::queries::get_or_create_artist;
302
303    fn test_db() -> Connection {
304        let conn = Connection::open_in_memory().unwrap();
305        conn.pragma_update(None, "foreign_keys", "on").unwrap();
306        crate::db::schema::create_tables(&conn).unwrap();
307        conn
308    }
309
310    #[test]
311    fn a_fresh_answer_is_served_from_the_cache() {
312        let conn = test_db();
313        let id = get_or_create_artist(&conn, "Glass Candy", None).unwrap();
314        let info = ArtistInfo {
315            bio: Some("An American electronic music duo.".into()),
316            fetched_at: now(),
317            ..Default::default()
318        };
319        store(&conn, id, &info).unwrap();
320
321        // No network in a test: a fresh row must be answered without one.
322        assert_eq!(fetch(&conn, id).unwrap(), Some(info));
323    }
324
325    #[test]
326    fn a_cached_miss_is_an_answer_too() {
327        let conn = test_db();
328        let id = get_or_create_artist(&conn, "Nobody In Particular", None).unwrap();
329        let miss = ArtistInfo {
330            fetched_at: now(),
331            ..Default::default()
332        };
333        store(&conn, id, &miss).unwrap();
334        assert_eq!(fetch(&conn, id).unwrap(), Some(miss));
335    }
336
337    #[test]
338    fn placeholder_artists_are_never_looked_up() {
339        let conn = test_db();
340        let id = get_or_create_artist(&conn, "Various Artists", None).unwrap();
341        assert_eq!(fetch(&conn, id).unwrap(), None);
342    }
343
344    #[test]
345    fn the_credit_is_found_by_name_or_by_being_the_only_one() {
346        let credits = vec![
347            ("Ida No".to_string(), "a".to_string()),
348            ("Johnny Jewel".to_string(), "b".to_string()),
349        ];
350        assert_eq!(credited(&credits, "johnny jewel").as_deref(), Some("b"));
351        assert_eq!(credited(&credits, "Glass Candy"), None);
352        let solo = vec![(
353            "Glass Candy & The Shattered Theatre".to_string(),
354            "c".to_string(),
355        )];
356        assert_eq!(credited(&solo, "Glass Candy").as_deref(), Some("c"));
357    }
358
359    #[test]
360    fn names_match_across_typographic_punctuation() {
361        assert!(same_name("At the Drive\u{2010}In", "At the Drive-In"));
362        assert!(same_name("Can\u{2019}t Maintain", "can't  maintain"));
363        assert!(!same_name("Azure Ray", "Ray Charles"));
364    }
365
366    #[test]
367    fn staleness_is_a_month() {
368        let info = ArtistInfo {
369            fetched_at: 0,
370            ..Default::default()
371        };
372        assert!(!info.is_stale(REFRESH_AFTER_SECS));
373        assert!(info.is_stale(REFRESH_AFTER_SECS + 1));
374    }
375
376    #[test]
377    fn an_artist_leaving_the_library_takes_its_info() {
378        let conn = test_db();
379        let id = get_or_create_artist(&conn, "Glass Candy", None).unwrap();
380        store(
381            &conn,
382            id,
383            &ArtistInfo {
384                fetched_at: now(),
385                ..Default::default()
386            },
387        )
388        .unwrap();
389        conn.execute("DELETE FROM artists WHERE id = ?1", params![id])
390            .unwrap();
391        assert_eq!(cached(&conn, id).unwrap(), None);
392    }
393}