Skip to main content

koan_core/db/queries/
albums.rs

1use rusqlite::{Connection, OptionalExtension, params};
2
3use crate::db::connection::DbError;
4
5use super::AlbumRow;
6
7/// The album column list, read the same way by every query that selects it.
8fn album_row(row: &rusqlite::Row) -> rusqlite::Result<AlbumRow> {
9    Ok(AlbumRow {
10        id: row.get(0)?,
11        title: row.get(1)?,
12        artist_id: row.get(2)?,
13        artist_name: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
14        date: row.get(4)?,
15        total_discs: row.get(5)?,
16        total_tracks: row.get(6)?,
17        codec: row.get(7)?,
18        label: row.get(8)?,
19        remote_id: row.get(9)?,
20        added_at: row.get(10)?,
21        on_device: None,
22    })
23}
24
25/// The album a track with these names belongs to, made if there is none.
26///
27/// An album is its title and album artist, compared as matching compares
28/// names, and its release when one is known: two editions with the same title
29/// and their own MusicBrainz release ids are two albums. A track naming no
30/// release joins the album of its names that has none either, or else the
31/// oldest; one naming a release joins the album with that release, or one
32/// that has none yet, which takes it. The release id is never overwritten, so
33/// files of two editions cannot trade it back and forth.
34#[allow(clippy::too_many_arguments)]
35pub fn get_or_create_album(
36    conn: &Connection,
37    title: &str,
38    artist_id: i64,
39    date: Option<&str>,
40    total_discs: Option<i32>,
41    total_tracks: Option<i32>,
42    codec: Option<&str>,
43    label: Option<&str>,
44    release: Option<&str>,
45    // `added_at`: remote sync passes the server's `created`, a local scan the
46    // earliest mtime among the album's files. Both ISO 8601 UTC, so the two
47    // sources sort against each other.
48    added_at: Option<&str>,
49) -> Result<i64, DbError> {
50    let release = release.filter(|r| !r.is_empty());
51    let key = super::sources::fold(title);
52    type Stored = (
53        i64,
54        Option<String>,
55        Option<String>,
56        Option<String>,
57        Option<String>,
58        Option<String>,
59    );
60    let candidates: Vec<Stored> = conn
61        .prepare_cached(
62            "SELECT id, mbid, codec, date, label, added_at FROM albums
63             WHERE title_key = ?1 AND artist_id = ?2 ORDER BY id",
64        )?
65        .query_map(params![key, artist_id], |row| {
66            Ok((
67                row.get(0)?,
68                row.get(1)?,
69                row.get(2)?,
70                row.get(3)?,
71                row.get(4)?,
72                row.get(5)?,
73            ))
74        })?
75        .collect::<rusqlite::Result<_>>()?;
76    let unclaimed = || candidates.iter().find(|c| c.1.is_none());
77    let existing = match release {
78        Some(release) => candidates
79            .iter()
80            .find(|c| c.1.as_deref() == Some(release))
81            .or_else(unclaimed),
82        None => unclaimed().or(candidates.first()),
83    };
84
85    if let Some((id, s_mbid, s_codec, s_date, s_label, s_added_at)) = existing {
86        // Update mutable fields so rescans pick up format upgrades (e.g. MP3→FLAC)
87        // or corrected dates. Every track of the album passes through here, so
88        // the row is only written when one of them brings something new.
89        // Earliest wins. A record acquired over months should date from its
90        // first file, not its last, and filling only would freeze whichever
91        // file the first scan happened to reach.
92        let earliest = match (added_at, s_added_at.as_deref()) {
93            (Some(new), Some(stored)) => Some(new.min(stored)),
94            (new, stored) => new.or(stored),
95        };
96        let merged = (
97            codec.or(s_codec.as_deref()),
98            date.or(s_date.as_deref()),
99            label.or(s_label.as_deref()),
100            s_mbid.as_deref().or(release),
101            earliest,
102        );
103        let stored = (
104            s_codec.as_deref(),
105            s_date.as_deref(),
106            s_label.as_deref(),
107            s_mbid.as_deref(),
108            s_added_at.as_deref(),
109        );
110        if merged != stored {
111            conn.prepare_cached(
112                "UPDATE albums SET codec = ?1, date = ?2, label = ?3, mbid = ?4, added_at = ?5
113                 WHERE id = ?6",
114            )?
115            .execute(params![
116                merged.0, merged.1, merged.2, merged.3, merged.4, id
117            ])?;
118        }
119        return Ok(*id);
120    }
121
122    conn.prepare_cached(
123        "INSERT INTO albums (title, title_key, artist_id, date, total_discs, total_tracks, codec,
124                             label, mbid, added_at)
125         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
126    )?
127    .execute(params![
128        title,
129        key,
130        artist_id,
131        date,
132        total_discs,
133        total_tracks,
134        codec,
135        label,
136        release,
137        added_at
138    ])?;
139    Ok(conn.last_insert_rowid())
140}
141
142/// Fold album `gone` into `keep`: its tracks, favourites, ratings and
143/// shares move across, `keep` fills its gaps from it, and it is deleted. The koan server's
144/// uid goes with the server's id, so the album stays one id on every device.
145pub(crate) fn merge_albums(conn: &Connection, keep: i64, gone: i64) -> rusqlite::Result<()> {
146    let server_uid: Option<String> = conn
147        .query_row(
148            "SELECT g.uid FROM albums g, albums k WHERE g.id = ?1 AND k.id = ?2
149               AND g.uid = g.remote_id AND k.uid IS NOT k.remote_id",
150            params![gone, keep],
151            |r| r.get(0),
152        )
153        .optional()?;
154    conn.execute_batch("SAVEPOINT merge_albums")?;
155    let merged = (|| {
156        for sql in [
157            "UPDATE tracks SET album_id = ?1 WHERE album_id = ?2",
158            "UPDATE OR IGNORE favourite_albums SET album_id = ?1 WHERE album_id = ?2",
159            "UPDATE OR IGNORE album_ratings SET album_id = ?1 WHERE album_id = ?2",
160            "UPDATE shares SET subject_id = ?1 WHERE kind = 'album' AND subject_id = ?2",
161            "UPDATE albums SET
162                 remote_id = COALESCE(remote_id, (SELECT remote_id FROM albums WHERE id = ?2)),
163                 mbid = COALESCE(mbid, (SELECT mbid FROM albums WHERE id = ?2)),
164                 date = COALESCE(date, (SELECT date FROM albums WHERE id = ?2)),
165                 label = COALESCE(label, (SELECT label FROM albums WHERE id = ?2)),
166                 codec = COALESCE(codec, (SELECT codec FROM albums WHERE id = ?2)),
167                 sort_name = COALESCE(sort_name, (SELECT sort_name FROM albums WHERE id = ?2)),
168                 total_discs = COALESCE(total_discs, (SELECT total_discs FROM albums WHERE id = ?2)),
169                 total_tracks = COALESCE(total_tracks, (SELECT total_tracks FROM albums WHERE id = ?2)),
170                 added_at = MIN(COALESCE(added_at, (SELECT added_at FROM albums WHERE id = ?2)),
171                                COALESCE((SELECT added_at FROM albums WHERE id = ?2), added_at))
172               WHERE id = ?1",
173        ] {
174            conn.execute(sql, params![keep, gone])?;
175        }
176        for sql in [
177            "DELETE FROM favourite_albums WHERE album_id = ?1",
178            "DELETE FROM album_ratings WHERE album_id = ?1",
179            "DELETE FROM albums WHERE id = ?1",
180        ] {
181            conn.execute(sql, params![gone])?;
182        }
183        if let Some(uid) = &server_uid {
184            conn.execute(
185                "UPDATE albums SET uid = ?1 WHERE id = ?2",
186                params![uid, keep],
187            )?;
188        }
189        Ok(())
190    })();
191    match merged {
192        Ok(()) => conn.execute_batch("RELEASE merge_albums"),
193        Err(e) => {
194            conn.execute_batch("ROLLBACK TO merge_albums; RELEASE merge_albums")?;
195            Err(e)
196        }
197    }
198}
199
200/// How a listing of albums is ordered.
201///
202/// In SQL rather than over the returned rows, because a listing that is read a
203/// page at a time has to be ordered before it is cut.
204#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
205pub enum AlbumOrder {
206    /// Artist, then release date, then title — how a shelf reads.
207    #[default]
208    ArtistThenDate,
209    /// Release date, then title. A discography, in the order it happened.
210    Date,
211    /// Newest acquisition first. What a browser should open on: the record you
212    /// just added is the one you were looking for.
213    RecentlyAdded,
214    Title,
215    /// Newest release first.
216    YearDesc,
217    /// Seeded, so every page of one shuffle belongs to the same shuffle. A new
218    /// seed is a new order — that is what the reshuffle button asks for.
219    Random(i64),
220    /// Most recently played first. Only with `AlbumQuery::played`, which is
221    /// what knows when; without it, `RecentlyAdded`.
222    LastPlayed,
223    /// Insertion order. The one order a new album cannot land in the middle
224    /// of, which is what makes an offset walk over the whole list exact.
225    Id,
226    /// Fully on this device first, then by how much is, then the most
227    /// recently downloaded.
228    Downloaded,
229    /// The closest match to `AlbumQuery::search` first, by title or artist
230    /// (see `search::match_rank`), then as `ArtistThenDate`. Without a search,
231    /// in the order of `AlbumQuery::ids`; without either, `RecentlyAdded`.
232    Relevance,
233}
234
235impl AlbumOrder {
236    fn clause(self) -> &'static str {
237        match self {
238            Self::ArtistThenDate => "a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY",
239            Self::Date => "al.date, al.title COLLATE LIBRARY",
240            // Albums predating the added_at column sort last rather than first,
241            // which is what a NULL would do.
242            Self::RecentlyAdded | Self::Relevance => {
243                "COALESCE(al.added_at, '') DESC, a.name COLLATE LIBRARY, al.title COLLATE LIBRARY"
244            }
245            Self::Title => "al.title COLLATE LIBRARY, a.name COLLATE LIBRARY, al.date",
246            Self::YearDesc => {
247                "COALESCE(CAST(substr(al.date, 1, 4) AS INTEGER), 0) DESC, \
248                               a.name COLLATE LIBRARY, al.title COLLATE LIBRARY"
249            }
250            Self::Random(_) => "koan_shuffle(al.id, ?)",
251            Self::LastPlayed => "p.last DESC, p.last_id DESC",
252            Self::Id => "al.id",
253            Self::Downloaded => {
254                "have = total DESC, CAST(have AS REAL) / total DESC, fetched DESC, al.id"
255            }
256        }
257    }
258}
259
260/// Codecs that lose nothing, as the indexer names them.
261pub const LOSSLESS_CODECS: [&str; 5] = ["FLAC", "ALAC", "WAV", "AIFF", "PCM"];
262
263/// How much of a record is on this device.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub struct OnDevice {
266    /// Tracks with a file here: downloaded, or in the library.
267    pub have: u32,
268    pub total: u32,
269}
270
271/// What a listing narrowed to the device selects after the album columns, and
272/// what `AlbumOrder::Downloaded` sorts by. Correlated rather than joined, so
273/// only the records that pass the narrowing are counted.
274const ON_DEVICE_COLUMNS: &str = ",
275    (SELECT SUM(COALESCE(d.cached_path, d.path) IS NOT NULL) FROM tracks d WHERE d.album_id = al.id) AS have,
276    (SELECT COUNT(*) FROM tracks d WHERE d.album_id = al.id) AS total,
277    (SELECT MAX(COALESCE(d.cache_download_date, 0)) FROM tracks d WHERE d.album_id = al.id) AS fetched";
278
279/// Narrowing by what the records are, shared by the album and artist listings:
280/// an artist passes when any of their albums does.
281#[derive(Debug, Clone, Copy, Default)]
282pub struct AlbumFilter<'a> {
283    /// Only records in a codec from `LOSSLESS_CODECS`.
284    pub lossless: bool,
285    /// Only records in this codec, matched as the indexer names it.
286    pub codec: Option<&'a str>,
287    /// Release year bounds, inclusive. Records without a date are left out
288    /// when either is set.
289    pub year_from: Option<i32>,
290    pub year_to: Option<i32>,
291    /// Records with at least one track tagged with this genre.
292    pub genre: Option<&'a str>,
293    /// Records with at least one track that can play here: downloaded, or a
294    /// file in the library. What offline narrows to.
295    pub on_device: bool,
296}
297
298impl AlbumFilter<'_> {
299    /// Conditions on the album aliased `al`.
300    pub(crate) fn push(
301        &self,
302        wheres: &mut Vec<String>,
303        params: &mut Vec<Box<dyn rusqlite::ToSql>>,
304    ) {
305        if self.lossless {
306            wheres.push(format!(
307                "al.codec IN ({})",
308                vec!["?"; LOSSLESS_CODECS.len()].join(",")
309            ));
310            params.extend(
311                LOSSLESS_CODECS
312                    .iter()
313                    .map(|c| Box::new(*c) as Box<dyn rusqlite::ToSql>),
314            );
315        }
316        if let Some(codec) = self.codec {
317            wheres.push("al.codec = ? COLLATE NOCASE".into());
318            params.push(Box::new(codec.to_owned()));
319        }
320        let year = "CAST(substr(al.date, 1, 4) AS INTEGER)";
321        if let Some(from) = self.year_from {
322            wheres.push(format!("{year} >= ?"));
323            params.push(Box::new(from));
324        }
325        if let Some(to) = self.year_to {
326            wheres.push(format!("al.date IS NOT NULL AND {year} <= ?"));
327            params.push(Box::new(to));
328        }
329        if self.on_device {
330            wheres.push(
331                "EXISTS (SELECT 1 FROM tracks d WHERE d.album_id = al.id
332                         AND COALESCE(d.cached_path, d.path) IS NOT NULL)"
333                    .into(),
334            );
335        }
336        if let Some(genre) = self.genre {
337            wheres.push(
338                "EXISTS (SELECT 1 FROM tracks g WHERE g.album_id = al.id AND g.genre = ? COLLATE NOCASE)"
339                    .into(),
340            );
341            params.push(Box::new(genre.to_owned()));
342        }
343    }
344}
345
346/// What to list. Everything optional, so one query answers the browser, the
347/// search field, an artist's discography and the favourites page.
348#[derive(Debug, Clone, Copy, Default)]
349pub struct AlbumQuery<'a> {
350    /// Only these albums.
351    pub ids: Option<&'a [i64]>,
352    pub artist_id: Option<i64>,
353    /// Case-insensitive substring over the album title and the artist name.
354    /// When no album holds it, the closest fuzzy matches instead
355    /// (`search::fuzzy_ids`).
356    pub search: Option<&'a str>,
357    pub order: AlbumOrder,
358    /// Only records this user has favourited.
359    pub favourites_of: Option<i64>,
360    /// Only records with a track played since then.
361    pub played: Option<super::history::PlayedSince>,
362    pub filter: AlbumFilter<'a>,
363    /// `None` for the whole listing. A client that scrolls should page.
364    pub limit: Option<u32>,
365    pub offset: u32,
366}
367
368/// Albums, narrowed, ordered and paged by the database.
369///
370/// The narrowing belongs here rather than in each client: every front end wants
371/// the same answer, and one filtering a fully-loaded list in its own language
372/// pays for reading the whole table to throw most of it away. Matching
373/// is ASCII case-insensitive, like `find_artists` — SQLite's `NOCASE` does not
374/// fold accented letters, so `MOTLEY` finds `Motley` but `MÖTLEY` does not find
375/// `Mötley`.
376pub fn list_albums(conn: &Connection, q: &AlbumQuery) -> Result<Vec<AlbumRow>, DbError> {
377    if let Some(ids) = fuzzy_fallback(conn, q)? {
378        return list_albums(
379            conn,
380            &AlbumQuery {
381                search: None,
382                ids: Some(&ids),
383                ..*q
384            },
385        );
386    }
387    let (body, mut params) = album_body(conn, q)?;
388    let counted = q.filter.on_device || q.order == AlbumOrder::Downloaded;
389    let mut sql = format!(
390        "SELECT al.id, al.title, al.artist_id, a.name, al.date,
391                al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
392                al.added_at{}
393         {body} ORDER BY ",
394        if counted { ON_DEVICE_COLUMNS } else { "" }
395    );
396    let order = match q.order {
397        AlbumOrder::LastPlayed if q.played.is_none() => AlbumOrder::RecentlyAdded,
398        order => order,
399    };
400    match (order, q.search, q.ids) {
401        (AlbumOrder::Random(seed), ..) => {
402            params.push(Box::new(seed));
403            sql.push_str(order.clause());
404        }
405        (AlbumOrder::Relevance, Some(query), _) => {
406            let binds = super::search::match_rank_binds(query);
407            params.extend(
408                binds
409                    .iter()
410                    .chain(&binds)
411                    .map(|b| Box::new(b.clone()) as Box<dyn rusqlite::ToSql>),
412            );
413            sql.push_str(&format!(
414                "min({}, {}), {}",
415                super::search::match_rank("al.title"),
416                super::search::match_rank("a.name"),
417                AlbumOrder::ArtistThenDate.clause()
418            ));
419        }
420        (AlbumOrder::Relevance, None, Some(_)) => sql.push_str("r.key"),
421        _ => sql.push_str(order.clause()),
422    }
423
424    if let Some(limit) = q.limit {
425        params.push(Box::new(limit as i64));
426        params.push(Box::new(q.offset as i64));
427        sql.push_str(" LIMIT ? OFFSET ?");
428    }
429
430    let mut stmt = conn.prepare(&sql)?;
431    let rows = stmt
432        .query_map(rusqlite::params_from_iter(params.iter()), |row| {
433            let mut album = album_row(row)?;
434            if counted {
435                album.on_device = Some(OnDevice {
436                    have: row.get(11)?,
437                    total: row.get(12)?,
438                });
439            }
440            Ok(album)
441        })?
442        .collect::<Result<Vec<_>, _>>()?;
443    Ok(rows)
444}
445
446/// How many albums `list_albums` would list for `q`, ignoring its paging: what
447/// a "See all" says, from the same narrowing as the list it opens.
448pub fn count_albums(conn: &Connection, q: &AlbumQuery) -> Result<u64, DbError> {
449    if let Some(ids) = fuzzy_fallback(conn, q)? {
450        return count_albums(
451            conn,
452            &AlbumQuery {
453                search: None,
454                ids: Some(&ids),
455                ..*q
456            },
457        );
458    }
459    let (body, params) = album_body(conn, q)?;
460    let n: i64 = conn.query_row(
461        &format!("SELECT COUNT(*) {body}"),
462        rusqlite::params_from_iter(params.iter()),
463        |r| r.get(0),
464    )?;
465    Ok(n as u64)
466}
467
468/// What `q`'s search lists when no album holds it: the fuzzy matches, best
469/// first. `None` when it has no search or the search finds something.
470fn fuzzy_fallback(conn: &Connection, q: &AlbumQuery) -> Result<Option<Vec<i64>>, DbError> {
471    let Some(query) = q.search else {
472        return Ok(None);
473    };
474    let (body, params) = album_body(conn, q)?;
475    let found: bool = conn.query_row(
476        &format!("SELECT EXISTS (SELECT 1 {body})"),
477        rusqlite::params_from_iter(params.iter()),
478        |r| r.get(0),
479    )?;
480    if found {
481        return Ok(None);
482    }
483    let mut ids = super::search::fuzzy_ids(conn, super::search::CorpusKind::Album, query)?;
484    if let Some(only) = q.ids {
485        ids.retain(|id| only.contains(id));
486    }
487    Ok(Some(ids))
488}
489
490/// The FROM and WHERE that `list_albums` and `count_albums` share, and their
491/// parameters, in order.
492fn album_body(
493    conn: &Connection,
494    q: &AlbumQuery,
495) -> Result<(String, Vec<Box<dyn rusqlite::ToSql>>), DbError> {
496    let mut sql = String::from(
497        "FROM albums al
498         LEFT JOIN artists a ON al.artist_id = a.id",
499    );
500    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
501    if let Some(user) = q.favourites_of {
502        params.push(Box::new(super::auth::resolve_user(conn, user)?));
503        sql.push_str(" JOIN favourite_albums f ON f.album_id = al.id AND f.user_id = ?");
504    }
505    if let Some(played) = q.played {
506        params.push(Box::new(super::auth::resolve_user(conn, played.user)?));
507        params.push(Box::new(played.since));
508        sql.push_str(
509            " JOIN (SELECT t.album_id AS id, MAX(h.played_at) AS last, MAX(h.id) AS last_id
510                      FROM play_history h JOIN tracks t ON t.id = h.track_id
511                     WHERE h.user_id = ? AND h.played_at >= ? AND t.album_id IS NOT NULL
512                     GROUP BY t.album_id) p ON p.id = al.id",
513        );
514    }
515    let mut wheres: Vec<String> = Vec::new();
516    match q.ids {
517        // Joined once rather than looked up per row, so the order of the ids
518        // is `r.key`.
519        Some(ids) if q.order == AlbumOrder::Relevance && q.search.is_none() => {
520            params.push(Box::new(super::json_list(ids)));
521            sql.push_str(" JOIN json_each(?) r ON r.value = al.id");
522        }
523        Some(ids) => {
524            params.push(Box::new(super::json_list(ids)));
525            wheres.push("al.id IN (SELECT value FROM json_each(?))".into());
526        }
527        None => {}
528    }
529    if let Some(id) = q.artist_id {
530        params.push(Box::new(id));
531        wheres.push("al.artist_id = ?".into());
532    }
533    if let Some(query) = q.search {
534        let pattern = format!("%{}%", super::artists::escape_like(query));
535        // Bound twice rather than once: positional parameters are cheaper to
536        // keep straight than named ones across an assembled query.
537        params.push(Box::new(pattern.clone()));
538        params.push(Box::new(pattern));
539        wheres.push(
540            "(al.title LIKE ? COLLATE NOCASE ESCAPE '\\'
541              OR a.name LIKE ? COLLATE NOCASE ESCAPE '\\')"
542                .into(),
543        );
544    }
545    q.filter.push(&mut wheres, &mut params);
546    if !wheres.is_empty() {
547        sql.push_str(" WHERE ");
548        sql.push_str(&wheres.join(" AND "));
549    }
550    Ok((sql, params))
551}
552
553/// How `played_albums` orders a user's listening.
554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub enum PlayedOrder {
556    /// Most recently played first.
557    Recent,
558    /// Most plays first, ties by most recent.
559    Frequent,
560}
561
562/// Albums `user` has played, from play history, paged by the database.
563/// Albums never played are not listed.
564pub fn played_albums(
565    conn: &Connection,
566    user: i64,
567    order: PlayedOrder,
568    limit: u32,
569    offset: u32,
570) -> Result<Vec<AlbumRow>, DbError> {
571    let order = match order {
572        PlayedOrder::Recent => "p.last DESC",
573        PlayedOrder::Frequent => "p.plays DESC, p.last DESC",
574    };
575    let sql = format!(
576        "SELECT al.id, al.title, al.artist_id, a.name, al.date,
577                al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
578                al.added_at
579         FROM (SELECT t.album_id, MAX(h.played_at) AS last, COUNT(*) AS plays
580                 FROM play_history h JOIN tracks t ON t.id = h.track_id
581                WHERE h.user_id = ?1 AND t.album_id IS NOT NULL
582                GROUP BY t.album_id) p
583         JOIN albums al ON al.id = p.album_id
584         LEFT JOIN artists a ON al.artist_id = a.id
585         ORDER BY {order}, al.id
586         LIMIT ?2 OFFSET ?3"
587    );
588    let mut stmt = conn.prepare(&sql)?;
589    let rows = stmt
590        .query_map(
591            params![super::auth::resolve_user(conn, user)?, limit, offset],
592            album_row,
593        )?
594        .collect::<Result<Vec<_>, _>>()?;
595    Ok(rows)
596}
597
598/// `user`'s rated albums, highest first and, within a rating, most recently
599/// rated first: Subsonic's `highest` list.
600pub fn highest_rated_albums(
601    conn: &Connection,
602    user: i64,
603    limit: u32,
604    offset: u32,
605) -> Result<Vec<AlbumRow>, DbError> {
606    let mut stmt = conn.prepare_cached(
607        "SELECT al.id, al.title, al.artist_id, a.name, al.date,
608                al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
609                al.added_at
610         FROM album_ratings r
611         JOIN albums al ON al.id = r.album_id
612         LEFT JOIN artists a ON al.artist_id = a.id
613         WHERE r.user_id = ?1
614         ORDER BY r.rating DESC, r.changed_at DESC, al.id
615         LIMIT ?2 OFFSET ?3",
616    )?;
617    let rows = stmt
618        .query_map(
619            params![super::auth::resolve_user(conn, user)?, limit, offset],
620            album_row,
621        )?
622        .collect::<Result<Vec<_>, _>>()?;
623    Ok(rows)
624}
625
626/// Genres by how many records carry them, most first: what a genre filter
627/// offers. Blank tags are left out.
628pub fn genres(conn: &Connection, limit: u32) -> Result<Vec<String>, DbError> {
629    let mut stmt = conn.prepare(
630        "SELECT genre FROM tracks
631         WHERE genre IS NOT NULL AND TRIM(genre) != '' AND album_id IS NOT NULL
632         GROUP BY genre COLLATE NOCASE
633         ORDER BY COUNT(DISTINCT album_id) DESC, genre COLLATE NOCASE
634         LIMIT ?1",
635    )?;
636    let rows = stmt.query_map([limit], |r| r.get(0))?;
637    Ok(rows.collect::<Result<_, _>>()?)
638}
639
640/// The codecs records are in, most common first.
641pub fn album_codecs(conn: &Connection) -> Result<Vec<String>, DbError> {
642    let mut stmt = conn.prepare(
643        "SELECT codec FROM albums WHERE codec IS NOT NULL AND codec != ''
644         GROUP BY codec ORDER BY COUNT(*) DESC, codec",
645    )?;
646    let rows = stmt.query_map([], |r| r.get(0))?;
647    Ok(rows.collect::<Result<_, _>>()?)
648}
649
650/// Get albums for a specific artist, ordered chronologically.
651pub fn albums_for_artist(conn: &Connection, artist_id: i64) -> Result<Vec<AlbumRow>, DbError> {
652    list_albums(
653        conn,
654        &AlbumQuery {
655            artist_id: Some(artist_id),
656            order: AlbumOrder::Date,
657            ..Default::default()
658        },
659    )
660}
661
662/// Get a single album by ID.
663pub fn get_album(conn: &Connection, album_id: i64) -> Result<Option<AlbumRow>, DbError> {
664    let result = conn
665        .prepare_cached(
666            "SELECT al.id, al.title, al.artist_id, a.name, al.date,
667                    al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
668                al.added_at
669             FROM albums al
670             LEFT JOIN artists a ON al.artist_id = a.id
671             WHERE al.id = ?1",
672        )
673        .and_then(|mut stmt| stmt.query_row(params![album_id], album_row))
674        .ok();
675    Ok(result)
676}
677
678/// Get the date string for an album by ID.
679pub fn album_date(conn: &Connection, album_id: i64) -> Result<Option<String>, DbError> {
680    Ok(conn
681        .query_row(
682            "SELECT date FROM albums WHERE id = ?1",
683            params![album_id],
684            |row| row.get(0),
685        )
686        .ok()
687        .flatten())
688}
689
690/// Albums whose title or artist matches, case-insensitive substring.
691pub fn find_albums(conn: &Connection, query: &str) -> Result<Vec<AlbumRow>, DbError> {
692    list_albums(
693        conn,
694        &AlbumQuery {
695            search: Some(query),
696            ..Default::default()
697        },
698    )
699}
700
701/// Get all albums with their artist name, sorted.
702pub fn all_albums(conn: &Connection) -> Result<Vec<AlbumRow>, DbError> {
703    list_albums(conn, &AlbumQuery::default())
704}
705
706/// Record what the server knows about an album beyond what a track carries.
707///
708/// `get_or_create_album` is reached through a track and only ever sees what a
709/// file's tags say. Track totals, the record label and the MusicBrainz id are
710/// properties of the release, and the server hands all three over in the same
711/// response the sync already paged through.
712///
713/// Fills blanks rather than overwriting, so a locally-scanned album keeps what
714/// its tags said.
715pub fn enrich_remote_album(
716    conn: &Connection,
717    remote_id: &str,
718    mbid: Option<&str>,
719    sort_name: Option<&str>,
720    total_tracks: Option<i32>,
721    label: Option<&str>,
722) -> Result<(), DbError> {
723    conn.execute(
724        "UPDATE albums SET
725             mbid         = COALESCE(mbid, ?2),
726             sort_name    = COALESCE(sort_name, ?3),
727             total_tracks = COALESCE(total_tracks, ?4),
728             label        = COALESCE(label, ?5)
729         WHERE remote_id = ?1",
730        params![remote_id, mbid, sort_name, total_tracks, label],
731    )?;
732    Ok(())
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use crate::db::connection::Database;
739    use crate::db::queries::get_or_create_artist;
740
741    fn test_db() -> Database {
742        let conn = rusqlite::Connection::open_in_memory().unwrap();
743        conn.pragma_update(None, "foreign_keys", "on").unwrap();
744        crate::db::schema::create_tables(&conn).unwrap();
745        Database { conn }
746    }
747
748    /// Three records by two artists: a FLAC techno one from 1995, an MP3 rock
749    /// one from 2005 and an ALAC techno one from 2010.
750    fn filter_library() -> Database {
751        let db = test_db();
752        for (title, artist, album, codec, date, genre) in [
753            ("A", "Rrose", "Early", "FLAC", "1995", "Techno"),
754            ("B", "Band", "Middle", "MP3", "2005", "Rock"),
755            ("C", "Rrose", "Late", "ALAC", "2010-03-01", "techno"),
756        ] {
757            let mut meta = crate::db::queries::sample_meta(title, artist, album);
758            meta.codec = Some(codec.into());
759            meta.date = Some(date.into());
760            meta.genre = Some(genre.into());
761            crate::db::queries::upsert_track(&db.conn, &meta).unwrap();
762        }
763        db
764    }
765
766    /// A record fully downloaded comes before one half there, and a record
767    /// with nothing here is neither counted nor offered offline.
768    #[test]
769    fn on_device_counts_what_can_play_here() {
770        let db = test_db();
771        for (title, album) in [
772            ("X1", "Whole"),
773            ("X2", "Whole"),
774            ("Y1", "Half"),
775            ("Y2", "Half"),
776            ("Z1", "None"),
777        ] {
778            let mut meta = crate::db::queries::sample_meta(title, "Artist", album);
779            meta.path = Some(format!("/music/{title}.flac"));
780            crate::db::queries::upsert_track(&db.conn, &meta).unwrap();
781        }
782        // Remote tracks, as a phone has them, two records' worth downloaded.
783        db.conn
784            .execute("UPDATE tracks SET path = NULL", [])
785            .unwrap();
786        db.conn
787            .execute(
788                "UPDATE tracks SET cached_path = '/cache/' || title WHERE title IN ('X1', 'X2', 'Y1')",
789                [],
790            )
791            .unwrap();
792        let album = |title: &str| -> i64 {
793            db.conn
794                .query_row("SELECT id FROM albums WHERE title = ?1", [title], |r| {
795                    r.get(0)
796                })
797                .unwrap()
798        };
799
800        let downloaded = list_albums(
801            &db.conn,
802            &AlbumQuery {
803                filter: AlbumFilter {
804                    on_device: true,
805                    ..Default::default()
806                },
807                order: AlbumOrder::Downloaded,
808                ..Default::default()
809            },
810        )
811        .unwrap();
812        assert_eq!(
813            downloaded
814                .iter()
815                .map(|a| (a.id, a.on_device))
816                .collect::<Vec<_>>(),
817            vec![
818                (album("Whole"), Some(OnDevice { have: 2, total: 2 })),
819                (album("Half"), Some(OnDevice { have: 1, total: 2 })),
820            ]
821        );
822        let offline = AlbumFilter {
823            on_device: true,
824            ..Default::default()
825        };
826        let mut listed = titles(&db, offline);
827        listed.sort();
828        assert_eq!(listed, vec!["Half", "Whole"]);
829    }
830
831    fn titles(db: &Database, filter: AlbumFilter) -> Vec<String> {
832        list_albums(
833            &db.conn,
834            &AlbumQuery {
835                filter,
836                order: AlbumOrder::Date,
837                ..Default::default()
838            },
839        )
840        .unwrap()
841        .into_iter()
842        .map(|a| a.title)
843        .collect()
844    }
845
846    #[test]
847    fn albums_filter_by_codec_year_and_genre_in_sql() {
848        let db = filter_library();
849        let lossless = AlbumFilter {
850            lossless: true,
851            ..Default::default()
852        };
853        assert_eq!(titles(&db, lossless), ["Early", "Late"]);
854        let mp3 = AlbumFilter {
855            codec: Some("mp3"),
856            ..Default::default()
857        };
858        assert_eq!(titles(&db, mp3), ["Middle"]);
859        let years = AlbumFilter {
860            year_from: Some(2000),
861            year_to: Some(2010),
862            ..Default::default()
863        };
864        assert_eq!(titles(&db, years), ["Middle", "Late"]);
865        let techno = AlbumFilter {
866            genre: Some("TECHNO"),
867            ..Default::default()
868        };
869        assert_eq!(titles(&db, techno), ["Early", "Late"]);
870        let all = AlbumFilter {
871            lossless: true,
872            year_from: Some(2000),
873            genre: Some("techno"),
874            ..Default::default()
875        };
876        assert_eq!(titles(&db, all), ["Late"]);
877        assert_eq!(
878            genres(&db.conn, 10).unwrap().len(),
879            2,
880            "techno counted once"
881        );
882        assert_eq!(album_codecs(&db.conn).unwrap().len(), 3);
883    }
884
885    #[test]
886    fn played_albums_follow_one_users_history() {
887        use crate::db::queries::{LOCAL_USER, SOURCE_LOCAL, record_play_at};
888        let db = filter_library();
889        let track = |title: &str| -> i64 {
890            db.conn
891                .query_row("SELECT id FROM tracks WHERE title = ?1", [title], |r| {
892                    r.get(0)
893                })
894                .unwrap()
895        };
896        for (title, at) in [("A", 10), ("C", 30), ("A", 20)] {
897            record_play_at(&db.conn, LOCAL_USER, track(title), at, None, SOURCE_LOCAL).unwrap();
898        }
899        let played = |order, limit, offset| {
900            played_albums(&db.conn, LOCAL_USER, order, limit, offset)
901                .unwrap()
902                .into_iter()
903                .map(|a| a.title)
904                .collect::<Vec<_>>()
905        };
906        assert_eq!(played(PlayedOrder::Recent, 10, 0), ["Late", "Early"]);
907        assert_eq!(played(PlayedOrder::Frequent, 10, 0), ["Early", "Late"]);
908        assert_eq!(played(PlayedOrder::Frequent, 1, 1), ["Late"]);
909    }
910
911    #[test]
912    fn random_draws_narrow_in_sql() {
913        use crate::db::queries::{RandomFilter, random_tracks_where};
914        let db = filter_library();
915        let draw = |filter: RandomFilter, count| {
916            let mut titles: Vec<String> = random_tracks_where(&db.conn, count, &filter)
917                .unwrap()
918                .into_iter()
919                .map(|t| t.title)
920                .collect();
921            titles.sort();
922            titles
923        };
924        assert_eq!(draw(RandomFilter::default(), 10), ["A", "B", "C"]);
925        assert_eq!(draw(RandomFilter::default(), 2).len(), 2);
926        let techno = RandomFilter {
927            genre: Some("TECHNO"),
928            ..Default::default()
929        };
930        assert_eq!(draw(techno, 10), ["A", "C"]);
931        let nineties = RandomFilter {
932            year_from: Some(1990),
933            year_to: Some(1999),
934            ..Default::default()
935        };
936        assert_eq!(draw(nineties, 10), ["A"]);
937    }
938
939    #[test]
940    fn artists_sort_and_count_what_the_filter_leaves() {
941        use crate::db::queries::{ArtistOrder, ArtistQuery, list_artists};
942        let db = filter_library();
943        let names = |q: ArtistQuery| {
944            list_artists(&db.conn, &q)
945                .unwrap()
946                .into_iter()
947                .map(|a| (a.name, a.album_count))
948                .collect::<Vec<_>>()
949        };
950        assert_eq!(
951            names(ArtistQuery {
952                order: ArtistOrder::AlbumCount,
953                ..Default::default()
954            }),
955            [("Rrose".to_string(), 2), ("Band".to_string(), 1)]
956        );
957        assert_eq!(
958            names(ArtistQuery {
959                filter: AlbumFilter {
960                    year_from: Some(2000),
961                    ..Default::default()
962                },
963                ..Default::default()
964            }),
965            [("Band".to_string(), 1), ("Rrose".to_string(), 1)]
966        );
967    }
968
969    #[test]
970    fn test_album_create_and_dedup() {
971        let db = test_db();
972        let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
973        let a1 = get_or_create_album(
974            &db.conn,
975            "Music Has the Right to Children",
976            artist,
977            Some("1998"),
978            None,
979            None,
980            Some("FLAC"),
981            Some("Warp"),
982            None,
983            None,
984        )
985        .unwrap();
986        let a2 = get_or_create_album(
987            &db.conn,
988            "Music Has the Right to Children",
989            artist,
990            Some("1998"),
991            None,
992            None,
993            Some("FLAC"),
994            Some("Warp"),
995            None,
996            None,
997        )
998        .unwrap();
999        assert_eq!(a1, a2);
1000    }
1001
1002    #[test]
1003    fn test_album_codec_updated_on_format_upgrade() {
1004        let db = test_db();
1005        let artist = get_or_create_artist(&db.conn, "WAGDUG FUTURISTIC UNITY", None).unwrap();
1006
1007        // First scan: album indexed as MP3.
1008        let id1 = get_or_create_album(
1009            &db.conn,
1010            "HAKAI",
1011            artist,
1012            Some("2008"),
1013            None,
1014            None,
1015            Some("MP3"),
1016            None,
1017            None,
1018            None,
1019        )
1020        .unwrap();
1021
1022        let codec: Option<String> = db
1023            .conn
1024            .query_row(
1025                "SELECT codec FROM albums WHERE id = ?1",
1026                params![id1],
1027                |r| r.get(0),
1028            )
1029            .unwrap();
1030        assert_eq!(codec.as_deref(), Some("MP3"));
1031
1032        // Re-scan after upgrading MP3→FLAC: same album, new codec.
1033        let id2 = get_or_create_album(
1034            &db.conn,
1035            "HAKAI",
1036            artist,
1037            Some("2008"),
1038            None,
1039            None,
1040            Some("FLAC"),
1041            None,
1042            None,
1043            None,
1044        )
1045        .unwrap();
1046
1047        assert_eq!(id1, id2, "should return the same album ID");
1048
1049        let codec: Option<String> = db
1050            .conn
1051            .query_row(
1052                "SELECT codec FROM albums WHERE id = ?1",
1053                params![id1],
1054                |r| r.get(0),
1055            )
1056            .unwrap();
1057        assert_eq!(
1058            codec.as_deref(),
1059            Some("FLAC"),
1060            "album codec should be updated after format upgrade"
1061        );
1062    }
1063
1064    #[test]
1065    fn test_album_codec_not_nulled_by_missing_codec() {
1066        let db = test_db();
1067        let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
1068
1069        // First scan with codec.
1070        let id = get_or_create_album(
1071            &db.conn,
1072            "MHTRTC",
1073            artist,
1074            Some("1998"),
1075            None,
1076            None,
1077            Some("FLAC"),
1078            Some("Warp"),
1079            None,
1080            None,
1081        )
1082        .unwrap();
1083
1084        // Re-encounter with no codec (e.g. remote sync without codec info).
1085        get_or_create_album(
1086            &db.conn,
1087            "MHTRTC",
1088            artist,
1089            Some("1998"),
1090            None,
1091            None,
1092            None, // no codec
1093            None, // no label
1094            None,
1095            None,
1096        )
1097        .unwrap();
1098
1099        let (codec, label): (Option<String>, Option<String>) = db
1100            .conn
1101            .query_row(
1102                "SELECT codec, label FROM albums WHERE id = ?1",
1103                params![id],
1104                |r| Ok((r.get(0)?, r.get(1)?)),
1105            )
1106            .unwrap();
1107        assert_eq!(
1108            codec.as_deref(),
1109            Some("FLAC"),
1110            "codec should not be nulled by a None value"
1111        );
1112        assert_eq!(
1113            label.as_deref(),
1114            Some("Warp"),
1115            "label should not be nulled by a None value"
1116        );
1117    }
1118
1119    /// Six albums across two artists, so a page is smaller than the listing.
1120    fn stocked_db() -> Database {
1121        use crate::db::queries::{sample_meta, upsert_track};
1122        let db = test_db();
1123        for (i, (artist, album)) in [
1124            ("Autechre", "Amber"),
1125            ("Autechre", "Tri Repetae"),
1126            ("Autechre", "Confield"),
1127            ("Boards of Canada", "Geogaddi"),
1128            ("Boards of Canada", "Twoism"),
1129            ("Coil", "Horse Rotorvator"),
1130        ]
1131        .iter()
1132        .enumerate()
1133        {
1134            let mut m = sample_meta("t", artist, album);
1135            m.path = Some(format!("/music/{album}/t.flac"));
1136            m.date = Some(format!("199{i}"));
1137            upsert_track(&db.conn, &m).unwrap();
1138        }
1139        db
1140    }
1141
1142    #[test]
1143    fn paging_walks_the_listing_without_repeating() {
1144        let db = stocked_db();
1145        let page = |offset| {
1146            list_albums(
1147                &db.conn,
1148                &AlbumQuery {
1149                    limit: Some(2),
1150                    offset,
1151                    ..Default::default()
1152                },
1153            )
1154            .unwrap()
1155            .into_iter()
1156            .map(|a| a.title)
1157            .collect::<Vec<_>>()
1158        };
1159        let whole = all_albums(&db.conn)
1160            .unwrap()
1161            .into_iter()
1162            .map(|a| a.title)
1163            .collect::<Vec<_>>();
1164        assert_eq!([page(0), page(2), page(4)].concat(), whole);
1165        assert!(
1166            page(6).is_empty(),
1167            "a page past the end is empty, not wrapped"
1168        );
1169    }
1170
1171    #[test]
1172    fn search_narrows_on_title_or_artist() {
1173        let db = stocked_db();
1174        let titles = |q| {
1175            find_albums(&db.conn, q)
1176                .unwrap()
1177                .into_iter()
1178                .map(|a| a.title)
1179                .collect::<Vec<_>>()
1180        };
1181        assert_eq!(titles("geogaddi"), ["Geogaddi"]);
1182        assert_eq!(titles("autechre").len(), 3, "matched on the artist name");
1183    }
1184
1185    /// The reason the seed exists: page two has to belong to the same shuffle
1186    /// as page one, or scrolling repeats and drops records.
1187    #[test]
1188    fn a_seeded_shuffle_pages_consistently() {
1189        let db = stocked_db();
1190        let shuffled = |seed, limit, offset| {
1191            list_albums(
1192                &db.conn,
1193                &AlbumQuery {
1194                    order: AlbumOrder::Random(seed),
1195                    limit,
1196                    offset,
1197                    ..Default::default()
1198                },
1199            )
1200            .unwrap()
1201            .into_iter()
1202            .map(|a| a.id)
1203            .collect::<Vec<_>>()
1204        };
1205
1206        let whole = shuffled(42, None, 0);
1207        assert_eq!(
1208            [shuffled(42, Some(4), 0), shuffled(42, Some(4), 4)].concat(),
1209            whole
1210        );
1211        assert_ne!(shuffled(43, None, 0), whole, "a new seed is a new order");
1212        assert_eq!(whole.len(), 6, "a shuffle drops nothing");
1213    }
1214
1215    #[test]
1216    fn favourites_only_lists_what_was_hearted() {
1217        use crate::db::queries::toggle_favourite_album;
1218        let db = stocked_db();
1219        let album: i64 = db
1220            .conn
1221            .query_row(
1222                "SELECT id FROM albums WHERE title = 'Horse Rotorvator'",
1223                [],
1224                |r| r.get(0),
1225            )
1226            .unwrap();
1227        toggle_favourite_album(&db.conn, crate::db::queries::LOCAL_USER, album).unwrap();
1228        let rows = list_albums(
1229            &db.conn,
1230            &AlbumQuery {
1231                favourites_of: Some(crate::db::queries::LOCAL_USER),
1232                ..Default::default()
1233            },
1234        )
1235        .unwrap();
1236        assert_eq!(
1237            rows.iter().map(|a| a.title.as_str()).collect::<Vec<_>>(),
1238            ["Horse Rotorvator"]
1239        );
1240    }
1241
1242    #[test]
1243    fn test_all_albums_and_tracks() {
1244        use crate::db::queries::{sample_meta, tracks_for_album, upsert_track};
1245
1246        let db = test_db();
1247        let mut m1 = sample_meta("Track1", "Artist1", "Album1");
1248        m1.track_number = Some(1);
1249        let mut m2 = sample_meta("Track2", "Artist1", "Album1");
1250        m2.track_number = Some(2);
1251        m2.path = Some("/music/Album1/Track2.flac".into());
1252        upsert_track(&db.conn, &m1).unwrap();
1253        upsert_track(&db.conn, &m2).unwrap();
1254
1255        let albums = all_albums(&db.conn).unwrap();
1256        assert_eq!(albums.len(), 1);
1257        assert_eq!(albums[0].title, "Album1");
1258
1259        let tracks = tracks_for_album(&db.conn, albums[0].id).unwrap();
1260        assert_eq!(tracks.len(), 2);
1261        assert_eq!(tracks[0].track_number, Some(1));
1262        assert_eq!(tracks[1].track_number, Some(2));
1263    }
1264}