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)) 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
103pub 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
112fn 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 .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 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
183fn 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
221fn 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
234fn 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
280fn 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 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}