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