1use rusqlite::{Connection, OptionalExtension, params};
2
3use crate::db::connection::DbError;
4
5use super::ArtistRow;
6
7pub(super) fn escape_like(s: &str) -> String {
9 s.replace('\\', "\\\\")
10 .replace('%', "\\%")
11 .replace('_', "\\_")
12}
13
14pub fn get_or_create_artist(
20 conn: &Connection,
21 name: &str,
22 remote_id: Option<&str>,
23) -> Result<i64, DbError> {
24 let key = super::sources::fold(name);
25 let existing: Option<(i64, Option<String>)> = conn
26 .prepare_cached("SELECT id, remote_id FROM artists WHERE name_key = ?1")?
27 .query_row(params![key], |row| Ok((row.get(0)?, row.get(1)?)))
28 .optional()?;
29
30 if let Some((id, stored)) = existing {
31 if let Some(rid) = remote_id {
34 if stored.as_deref() != Some(rid) {
35 conn.prepare_cached("UPDATE artists SET remote_id = ?1 WHERE id = ?2")?
36 .execute(params![rid, id])?;
37 }
38 super::adopt_uid(conn, super::UidKind::Artist, id, rid)?;
39 }
40 return Ok(id);
41 }
42
43 let uid = super::free_uid(conn, super::UidKind::Artist, remote_id)?;
44 conn.prepare_cached(
45 "INSERT INTO artists (name, name_key, remote_id, uid) VALUES (?1, ?2, ?3, ?4)",
46 )?
47 .execute(params![name, key, remote_id, uid])?;
48 let id = conn.last_insert_rowid();
49 if let Some(rid) = remote_id {
50 super::adopt_uid(conn, super::UidKind::Artist, id, rid)?;
51 }
52 Ok(id)
53}
54
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
57pub enum ArtistOrder {
58 #[default]
60 Name,
61 AlbumCount,
63 RecentlyAdded,
65 Id,
67 LastPlayed,
70 Relevance,
74}
75
76impl ArtistOrder {
77 fn clause(self) -> &'static str {
78 match self {
79 Self::Name | Self::Relevance => "a.name COLLATE LIBRARY",
80 Self::AlbumCount => "COUNT(DISTINCT al.id) DESC, a.name COLLATE LIBRARY",
81 Self::RecentlyAdded => "COALESCE(MAX(al.added_at), '') DESC, a.name COLLATE LIBRARY",
82 Self::Id => "a.id",
83 Self::LastPlayed => "MAX(p.last) DESC, MAX(p.last_id) DESC",
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, Default)]
91pub struct ArtistQuery<'a> {
92 pub ids: Option<&'a [i64]>,
94 pub search: Option<&'a str>,
97 pub favourites_of: Option<i64>,
99 pub played: Option<super::history::PlayedSince>,
101 pub filter: super::albums::AlbumFilter<'a>,
104 pub order: ArtistOrder,
105 pub without_track_counts: bool,
108 pub limit: Option<u32>,
110 pub offset: u32,
111}
112
113pub fn list_artists(conn: &Connection, q: &ArtistQuery) -> Result<Vec<ArtistRow>, DbError> {
116 if let Some(ids) = fuzzy_fallback(conn, q)? {
117 return list_artists(
118 conn,
119 &ArtistQuery {
120 search: None,
121 ids: Some(&ids),
122 ..*q
123 },
124 );
125 }
126 let columns = if q.without_track_counts {
127 "a.id, a.name, a.sort_name, a.remote_id, COUNT(al.id), 0"
128 } else {
129 "a.id, a.name, a.sort_name, a.remote_id, COUNT(DISTINCT al.id), COUNT(t.id)"
130 };
131 let (body, mut params) = artist_body(conn, q)?;
132 let order = match q.order {
133 ArtistOrder::LastPlayed if q.played.is_none() => ArtistOrder::Name,
134 order => order,
135 };
136 let order_by = match (order, q.search, q.ids) {
137 (ArtistOrder::Relevance, Some(query), _) => {
138 params.extend(
139 super::search::match_rank_binds(query)
140 .map(|b| Box::new(b) as Box<dyn rusqlite::ToSql>),
141 );
142 format!(
143 "{}, a.name COLLATE LIBRARY",
144 super::search::match_rank("a.name")
145 )
146 }
147 (ArtistOrder::Relevance, None, Some(_)) => "MIN(r.key)".into(),
148 (order, ..) => order.clause().into(),
149 };
150 let mut sql = format!("SELECT {columns} {body} ORDER BY {order_by}");
151 if let Some(limit) = q.limit {
152 params.push(Box::new(limit as i64));
153 params.push(Box::new(q.offset as i64));
154 sql.push_str(" LIMIT ? OFFSET ?");
155 }
156
157 let mut stmt = conn.prepare(&sql)?;
158 let rows = stmt
159 .query_map(rusqlite::params_from_iter(params.iter()), artist_row)?
160 .collect::<Result<Vec<_>, _>>()?;
161 Ok(rows)
162}
163
164pub fn count_artists(conn: &Connection, q: &ArtistQuery) -> Result<u64, DbError> {
166 if let Some(ids) = fuzzy_fallback(conn, q)? {
167 return count_artists(
168 conn,
169 &ArtistQuery {
170 search: None,
171 ids: Some(&ids),
172 ..*q
173 },
174 );
175 }
176 let (body, params) = artist_body(conn, q)?;
177 let n: i64 = conn.query_row(
178 &format!("SELECT COUNT(*) FROM (SELECT a.id {body})"),
179 rusqlite::params_from_iter(params.iter()),
180 |r| r.get(0),
181 )?;
182 Ok(n as u64)
183}
184
185fn ranked_by_ids(q: &ArtistQuery) -> bool {
187 q.order == ArtistOrder::Relevance && q.search.is_none()
188}
189
190fn fuzzy_fallback(conn: &Connection, q: &ArtistQuery) -> Result<Option<Vec<i64>>, DbError> {
193 let Some(query) = q.search else {
194 return Ok(None);
195 };
196 let (body, params) = artist_body(conn, q)?;
197 let found: bool = conn.query_row(
198 &format!("SELECT EXISTS (SELECT 1 {body})"),
199 rusqlite::params_from_iter(params.iter()),
200 |r| r.get(0),
201 )?;
202 if found {
203 return Ok(None);
204 }
205 let mut ids = super::search::fuzzy_ids(conn, super::search::CorpusKind::Artist, query)?;
206 if let Some(only) = q.ids {
207 ids.retain(|id| only.contains(id));
208 }
209 Ok(Some(ids))
210}
211
212fn artist_body(
215 conn: &Connection,
216 q: &ArtistQuery,
217) -> Result<(String, Vec<Box<dyn rusqlite::ToSql>>), DbError> {
218 let listed = if q.favourites_of.is_some() || q.played.is_some() {
222 ""
223 } else {
224 " AND l.listed"
225 };
226 let mut sql = format!(
227 "FROM artists a
228 INNER JOIN album_artist_links l ON l.artist_id = a.id{listed}
229 INNER JOIN albums al ON al.id = l.album_id{}",
230 if q.without_track_counts {
231 ""
232 } else {
233 "
234 LEFT JOIN tracks t ON t.album_id = al.id"
235 }
236 );
237 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
238 if let Some(user) = q.favourites_of {
239 params.push(Box::new(super::auth::resolve_user(conn, user)?));
240 sql.push_str(" JOIN favourite_artists f ON f.artist_id = a.id AND f.user_id = ?");
241 }
242 if let Some(played) = q.played {
243 params.push(Box::new(super::auth::resolve_user(conn, played.user)?));
244 params.push(Box::new(played.since));
245 sql.push_str(
246 " JOIN (SELECT pal.artist_id AS id, MAX(h.played_at) AS last, MAX(h.id) AS last_id
247 FROM play_history h
248 JOIN tracks pt ON pt.id = h.track_id
249 JOIN albums pal ON pal.id = pt.album_id
250 WHERE h.user_id = ? AND h.played_at >= ?
251 GROUP BY pal.artist_id) p ON p.id = a.id",
252 );
253 }
254 let mut wheres: Vec<String> = Vec::new();
255 match q.ids {
256 Some(ids) if ranked_by_ids(q) => {
259 params.push(Box::new(super::json_list(ids)));
260 sql.push_str(" JOIN json_each(?) r ON r.value = a.id");
261 }
262 Some(ids) => {
263 params.push(Box::new(super::json_list(ids)));
264 wheres.push("a.id IN (SELECT value FROM json_each(?))".into());
265 }
266 None => {}
267 }
268 if let Some(query) = q.search {
269 params.push(Box::new(format!("%{}%", escape_like(query))));
270 wheres.push("a.name LIKE ? COLLATE NOCASE ESCAPE '\\'".into());
271 }
272 q.filter.push(&mut wheres, &mut params);
273 if !wheres.is_empty() {
274 sql.push_str(" WHERE ");
275 sql.push_str(&wheres.join(" AND "));
276 }
277 sql.push_str(" GROUP BY a.id");
278 Ok((sql, params))
279}
280
281fn artist_row(row: &rusqlite::Row) -> rusqlite::Result<ArtistRow> {
282 Ok(ArtistRow {
283 id: row.get(0)?,
284 name: row.get(1)?,
285 sort_name: row.get(2)?,
286 remote_id: row.get(3)?,
287 album_count: row.get(4)?,
288 track_count: row.get(5)?,
289 })
290}
291
292pub fn get_artist(conn: &Connection, artist_id: i64) -> Result<Option<ArtistRow>, DbError> {
296 Ok(conn
297 .query_row(
298 "SELECT a.id, a.name, a.sort_name, a.remote_id,
299 (SELECT COUNT(DISTINCT album_id) FROM album_artist_links
300 WHERE artist_id = a.id),
301 (SELECT COUNT(*) FROM tracks
302 WHERE artist_id = a.id
303 OR id IN (SELECT track_id FROM track_credits WHERE artist_id = a.id)
304 OR album_id IN (SELECT album_id FROM album_artist_links
305 WHERE artist_id = a.id))
306 FROM artists a
307 WHERE a.id = ?1",
308 params![artist_id],
309 artist_row,
310 )
311 .ok())
312}
313
314pub fn find_artists(conn: &Connection, query: &str) -> Result<Vec<ArtistRow>, DbError> {
316 list_artists(
317 conn,
318 &ArtistQuery {
319 search: Some(query),
320 ..Default::default()
321 },
322 )
323}
324
325pub fn all_artists(conn: &Connection) -> Result<Vec<ArtistRow>, DbError> {
327 list_artists(conn, &ArtistQuery::default())
328}
329
330pub fn enrich_remote_artist(
336 conn: &Connection,
337 remote_id: &str,
338 mbid: Option<&str>,
339 sort_name: Option<&str>,
340) -> Result<(), DbError> {
341 conn.execute(
342 "UPDATE artists SET
343 mbid = COALESCE(mbid, ?2),
344 sort_name = COALESCE(sort_name, ?3)
345 WHERE remote_id = ?1",
346 params![remote_id, mbid, sort_name],
347 )?;
348 Ok(())
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::db::connection::Database;
355
356 fn test_db() -> Database {
357 let conn = rusqlite::Connection::open_in_memory().unwrap();
358 conn.pragma_update(None, "foreign_keys", "on").unwrap();
359 crate::db::schema::create_tables(&conn).unwrap();
360 Database { conn }
361 }
362
363 fn stocked_db() -> Database {
366 use crate::db::queries::{sample_meta, upsert_track};
367 let db = test_db();
368 for (i, artist) in ["Autechre", "Boards of Canada", "Coil", "Dopplereffekt"]
369 .iter()
370 .enumerate()
371 {
372 let mut m = sample_meta("t", artist, "Album");
373 m.path = Some(format!("/music/{i}/t.flac"));
374 upsert_track(&db.conn, &m).unwrap();
375 }
376 db
377 }
378
379 const DUCHAMP: &str = "Orchestre Tout Puissant Marcel Duchamp";
380 const ATLAS: &str = "Frànçois & the Atlas Mountains";
381
382 fn collaboration_db(credited: bool) -> Database {
386 use crate::db::queries::{Credit, Credits, sample_meta, upsert_track};
387 let db = test_db();
388 for (i, artist) in [DUCHAMP, ATLAS].iter().enumerate() {
389 let mut m = sample_meta("t", artist, &format!("Own {i}"));
390 m.path = Some(format!("/music/own{i}.flac"));
391 upsert_track(&db.conn, &m).unwrap();
392 }
393 let credit = format!("{DUCHAMP} × {ATLAS}");
394 let both = vec![
395 Credit {
396 name: DUCHAMP.into(),
397 mbid: Some("177de814-d272-43ef-a42d-fb2ed79a0f78".into()),
398 remote_id: None,
399 },
400 Credit {
401 name: ATLAS.into(),
402 mbid: Some("63862e0f-4e06-458e-88d7-2baabbf27398".into()),
403 remote_id: None,
404 },
405 ];
406 for (n, title) in [(1, "Revenant du nord"), (2, "Siilent")] {
407 let mut m = sample_meta(title, &credit, "Revenant du nord / Siilent");
408 m.track_number = Some(n);
409 m.path = Some(format!("/music/ep/{n}.mp3"));
410 if credited {
411 m.credits = Credits {
412 artists: both.clone(),
413 album_artists: both.clone(),
414 };
415 }
416 upsert_track(&db.conn, &m).unwrap();
417 }
418 db
419 }
420
421 fn artist_id(db: &Database, name: &str) -> i64 {
422 db.conn
423 .query_row("SELECT id FROM artists WHERE name = ?1", [name], |r| {
424 r.get(0)
425 })
426 .unwrap()
427 }
428
429 fn album_titles(db: &Database, artist: i64) -> Vec<String> {
430 crate::db::queries::list_albums(
431 &db.conn,
432 &crate::db::queries::AlbumQuery {
433 artist_id: Some(artist),
434 ..Default::default()
435 },
436 )
437 .unwrap()
438 .into_iter()
439 .map(|a| a.title)
440 .collect()
441 }
442
443 #[test]
444 fn a_collaboration_is_listed_under_each_artist_it_credits() {
445 let db = collaboration_db(true);
446 let ep = "Revenant du nord / Siilent".to_string();
447 for name in [DUCHAMP, ATLAS] {
448 let id = artist_id(&db, name);
449 assert!(album_titles(&db, id).contains(&ep), "{name}");
450 let tracks = crate::db::queries::tracks_for_artist(&db.conn, id).unwrap();
451 assert_eq!(tracks.len(), 3, "{name}: own track and the EP's two");
452 assert_eq!(get_artist(&db.conn, id).unwrap().unwrap().album_count, 2);
453 }
454 let names: Vec<_> = all_artists(&db.conn)
455 .unwrap()
456 .into_iter()
457 .map(|a| a.name)
458 .collect();
459 assert_eq!(
460 names,
461 [ATLAS, DUCHAMP],
462 "the credit string is not an artist"
463 );
464 let credit = artist_id(&db, &format!("{DUCHAMP} × {ATLAS}"));
465 assert_eq!(
466 album_titles(&db, credit),
467 [ep],
468 "its page still holds the EP"
469 );
470 let mbid: Option<String> = db
471 .conn
472 .query_row(
473 "SELECT mbid FROM artists WHERE id = ?1",
474 [artist_id(&db, ATLAS)],
475 |r| r.get(0),
476 )
477 .unwrap();
478 assert_eq!(
479 mbid.as_deref(),
480 Some("63862e0f-4e06-458e-88d7-2baabbf27398")
481 );
482 }
483
484 #[test]
485 fn retagging_without_the_lists_takes_the_credits_away() {
486 use crate::db::queries::{sample_meta, upsert_track};
487 let db = collaboration_db(true);
488 let credit = format!("{DUCHAMP} × {ATLAS}");
489 for (n, title) in [(1, "Revenant du nord"), (2, "Siilent")] {
490 let mut m = sample_meta(title, &credit, "Revenant du nord / Siilent");
491 m.track_number = Some(n);
492 m.path = Some(format!("/music/ep/{n}.mp3"));
493 upsert_track(&db.conn, &m).unwrap();
494 }
495 assert_eq!(album_titles(&db, artist_id(&db, ATLAS)), ["Own 1"]);
496 let links: i64 = db
497 .conn
498 .query_row(
499 "SELECT (SELECT COUNT(*) FROM track_credits) + (SELECT COUNT(*) FROM album_credits)",
500 [],
501 |r| r.get(0),
502 )
503 .unwrap();
504 assert_eq!(links, 0);
505 assert!(
506 all_artists(&db.conn)
507 .unwrap()
508 .iter()
509 .any(|a| a.name == credit),
510 "the credit is an artist again"
511 );
512 }
513
514 #[test]
515 fn a_credit_named_only_as_a_string_is_left_as_it_was() {
516 let db = collaboration_db(false);
517 let credit = format!("{DUCHAMP} × {ATLAS}");
518 let names: Vec<_> = all_artists(&db.conn)
519 .unwrap()
520 .into_iter()
521 .map(|a| a.name)
522 .collect();
523 assert_eq!(names, [ATLAS, DUCHAMP, credit.as_str()]);
524 assert_eq!(album_titles(&db, artist_id(&db, ATLAS)), ["Own 1"]);
525 let links: i64 = db
526 .conn
527 .query_row(
528 "SELECT (SELECT COUNT(*) FROM track_credits) + (SELECT COUNT(*) FROM album_credits)",
529 [],
530 |r| r.get(0),
531 )
532 .unwrap();
533 assert_eq!(links, 0);
534 }
535
536 #[test]
537 fn paging_walks_the_listing_without_repeating() {
538 let db = stocked_db();
539 let page = |offset| {
540 list_artists(
541 &db.conn,
542 &ArtistQuery {
543 limit: Some(2),
544 offset,
545 ..Default::default()
546 },
547 )
548 .unwrap()
549 .into_iter()
550 .map(|a| a.name)
551 .collect::<Vec<_>>()
552 };
553 assert_eq!(page(0), ["Autechre", "Boards of Canada"]);
554 assert_eq!(page(2), ["Coil", "Dopplereffekt"]);
555 assert!(page(4).is_empty());
556 }
557
558 #[test]
559 fn favourites_only_lists_what_was_hearted() {
560 use crate::db::queries::toggle_favourite_artist;
561 let db = stocked_db();
562 let coil: i64 = db
563 .conn
564 .query_row("SELECT id FROM artists WHERE name = 'Coil'", [], |r| {
565 r.get(0)
566 })
567 .unwrap();
568 toggle_favourite_artist(&db.conn, crate::db::queries::LOCAL_USER, coil).unwrap();
569 let rows = list_artists(
570 &db.conn,
571 &ArtistQuery {
572 favourites_of: Some(crate::db::queries::LOCAL_USER),
573 ..Default::default()
574 },
575 )
576 .unwrap();
577 assert_eq!(
578 rows.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
579 ["Coil"]
580 );
581 }
582
583 #[test]
584 fn spellings_that_differ_only_in_case_are_one_artist() {
585 let db = stocked_db();
586 let a = get_or_create_artist(&db.conn, "The Squire of Gothos", None).unwrap();
587 let b = get_or_create_artist(&db.conn, "The Squire Of Gothos", None).unwrap();
588 assert_eq!(a, b);
589 }
590
591 #[test]
592 fn an_artist_with_tracks_but_no_albums_is_still_an_artist() {
593 let db = stocked_db();
594 let guest = get_or_create_artist(&db.conn, "A Guest", None).unwrap();
595 let album: i64 = db
596 .conn
597 .query_row("SELECT id FROM albums LIMIT 1", [], |r| r.get(0))
598 .unwrap();
599 db.conn
600 .execute(
601 "INSERT INTO tracks (title, album_id, artist_id, path) VALUES ('Feature', ?1, ?2, '/f.flac')",
602 params![album, guest],
603 )
604 .unwrap();
605 let artist = get_artist(&db.conn, guest)
606 .unwrap()
607 .expect("credited on a track");
608 assert_eq!((artist.album_count, artist.track_count), (0, 1));
609 }
610
611 #[test]
612 fn one_artist_carries_its_counts() {
613 let db = stocked_db();
614 let id = find_artists(&db.conn, "Coil").unwrap()[0].id;
615 let artist = get_artist(&db.conn, id)
616 .unwrap()
617 .expect("Coil owns an album");
618 assert_eq!(artist.name, "Coil");
619 assert_eq!(artist.album_count, 1);
620 assert_eq!(artist.track_count, 1);
621 assert!(get_artist(&db.conn, 9999).unwrap().is_none());
622 }
623
624 #[test]
625 fn test_artist_create_and_dedup() {
626 let db = test_db();
627 let id1 = get_or_create_artist(&db.conn, "Aphex Twin", None).unwrap();
628 let id2 = get_or_create_artist(&db.conn, "Aphex Twin", None).unwrap();
629 assert_eq!(id1, id2);
630
631 let id3 = get_or_create_artist(&db.conn, "Squarepusher", None).unwrap();
632 assert_ne!(id1, id3);
633 }
634}