1use 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
23const 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 pub bio_url: Option<String>,
41 pub image_url: Option<String>,
42 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
53pub 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
72pub 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
101pub 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
110fn 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 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
179fn 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
217fn 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
230fn 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
276fn 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 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}