Skip to main content

koan_core/db/queries/
artists.rs

1use rusqlite::{Connection, OptionalExtension, params};
2
3use crate::db::connection::DbError;
4
5use super::ArtistRow;
6
7/// Escape SQL LIKE wildcard characters in user input.
8pub(super) fn escape_like(s: &str) -> String {
9    s.replace('\\', "\\\\")
10        .replace('%', "\\%")
11        .replace('_', "\\_")
12}
13
14/// Get or create an artist by name. Returns the artist ID.
15///
16/// Names that differ only in letter case are one artist: tags spell the same
17/// act "The Squire Of Gothos" on one record and "of" on the next, and two rows
18/// split its albums from its tracks. An exact match wins over a case-folded one.
19pub 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        // The server's current id wins: one that renumbers its library would
32        // otherwise leave the artist under an id it no longer answers to.
33        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/// How to order the artist listing.
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
57pub enum ArtistOrder {
58    /// By name, as it reads. Sort names from tags are too erratic to order by.
59    #[default]
60    Name,
61    /// Most albums first.
62    AlbumCount,
63    /// The artist whose newest album arrived most recently first.
64    RecentlyAdded,
65    /// Insertion order, for an offset walk that must not skip or repeat.
66    Id,
67    /// Most recently played first. Only with `ArtistQuery::played`, which is
68    /// what knows when; without it, `Name`.
69    LastPlayed,
70    /// The closest match to `ArtistQuery::search` first (see
71    /// `search::match_rank`), then by name. Without a search, in the order of
72    /// `ArtistQuery::ids`; without either, `Name`.
73    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/// What to list. Artists are always album artists — a track-only credit (a
89/// featured guest) appears inline in the queue, not as a shelf of its own.
90#[derive(Debug, Clone, Copy, Default)]
91pub struct ArtistQuery<'a> {
92    /// Only these artists.
93    pub ids: Option<&'a [i64]>,
94    /// Case-insensitive substring over the name. When no artist's name holds
95    /// it, the closest fuzzy matches instead (`search::fuzzy_ids`).
96    pub search: Option<&'a str>,
97    /// Only artists this user has favourited.
98    pub favourites_of: Option<i64>,
99    /// Only artists with a record played since then, by its album artist.
100    pub played: Option<super::history::PlayedSince>,
101    /// Albums that count; an artist with none left is not listed, and the
102    /// counts are of what is left.
103    pub filter: super::albums::AlbumFilter<'a>,
104    pub order: ArtistOrder,
105    /// Leave `track_count` at zero rather than read every track in the library
106    /// to count them.
107    pub without_track_counts: bool,
108    /// `None` for the whole listing. A client that scrolls should page.
109    pub limit: Option<u32>,
110    pub offset: u32,
111}
112
113/// Artists with their album and track counts, narrowed, ordered and paged by
114/// the database.
115pub 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
164/// How many artists `list_artists` would list for `q`, ignoring its paging.
165pub 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
185/// Whether `q` lists its ids in their own order: `Relevance` with no search.
186fn ranked_by_ids(q: &ArtistQuery) -> bool {
187    q.order == ArtistOrder::Relevance && q.search.is_none()
188}
189
190/// What `q`'s search lists when no artist's name holds it: the fuzzy matches,
191/// best first. `None` when it has no search or the search finds something.
192fn 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
212/// The FROM, WHERE and GROUP BY that `list_artists` and `count_artists`
213/// share, and their parameters, in order.
214fn artist_body(
215    conn: &Connection,
216    q: &ArtistQuery,
217) -> Result<(String, Vec<Box<dyn rusqlite::ToSql>>), DbError> {
218    // An album is under each artist its credits name, and the artist list
219    // leaves out a credit string standing for artists credited one by one.
220    // A shelf of what someone hearted or played keeps it: they chose it.
221    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        // Joined once rather than looked up per row, so the order of the ids
257        // is `r.key`.
258        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
292/// One artist, with its counts. An artist credited only on other people's
293/// albums (a feature, a compilation track) owns none, and is still an artist:
294/// its track count is every track credited to it or on its albums.
295pub 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
314/// Find artists by name (case-insensitive substring match).
315pub 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
325/// Every album artist, sorted by name.
326pub fn all_artists(conn: &Connection) -> Result<Vec<ArtistRow>, DbError> {
327    list_artists(conn, &ArtistQuery::default())
328}
329
330/// Record what the server knows about an artist beyond its name.
331///
332/// Fills blanks rather than overwriting: a local scan may have set a sort name
333/// from tags, and the server's should not clobber it. Matched on `remote_id`,
334/// which the artist already has from the track upserts.
335pub 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    /// Artists only appear once they own an album, so the fixture goes in
364    /// through a track.
365    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    /// The Duchamp × Atlas Mountains EP as sift tags it: the credit as it
383    /// reads, and each artist with their MusicBrainz id beside it. Each
384    /// artist also has an album of their own.
385    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}