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