Skip to main content

koan_core/db/queries/
albums.rs

1use rusqlite::{Connection, 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    })
22}
23
24/// Get or create an album by title + artist. Returns the album ID.
25#[allow(clippy::too_many_arguments)]
26pub fn get_or_create_album(
27    conn: &Connection,
28    title: &str,
29    artist_id: i64,
30    date: Option<&str>,
31    total_discs: Option<i32>,
32    total_tracks: Option<i32>,
33    codec: Option<&str>,
34    label: Option<&str>,
35    // `added_at`: remote sync passes the server's `created`, a local scan the
36    // earliest mtime among the album's files. Both ISO 8601 UTC, so the two
37    // sources sort against each other.
38    remote_id: Option<&str>,
39    added_at: Option<&str>,
40) -> Result<i64, DbError> {
41    let existing: Option<i64> = conn
42        .query_row(
43            "SELECT id FROM albums WHERE title = ?1 AND artist_id = ?2",
44            params![title, artist_id],
45            |row| row.get(0),
46        )
47        .ok();
48
49    if let Some(id) = existing {
50        // Update mutable fields so rescans pick up format upgrades (e.g. MP3→FLAC),
51        // corrected dates, or newly-added remote IDs.
52        conn.execute(
53            "UPDATE albums SET
54                codec      = COALESCE(?1, codec),
55                date       = COALESCE(?2, date),
56                label      = COALESCE(?3, label),
57                remote_id  = COALESCE(?4, remote_id),
58                -- Earliest wins. A record acquired over months should date
59                -- from its first file, not its last, and filling only would
60                -- freeze whichever file the first scan happened to reach.
61                added_at   = MIN(COALESCE(added_at, ?5), COALESCE(?5, added_at))
62             WHERE id = ?6",
63            params![codec, date, label, remote_id, added_at, id],
64        )?;
65        return Ok(id);
66    }
67
68    conn.execute(
69        "INSERT INTO albums (title, artist_id, date, total_discs, total_tracks, codec, label, remote_id, added_at)
70         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
71        params![title, artist_id, date, total_discs, total_tracks, codec, label, remote_id, added_at],
72    )?;
73    Ok(conn.last_insert_rowid())
74}
75
76/// How a listing of albums is ordered.
77///
78/// In SQL rather than over the returned rows, because a listing that is read a
79/// page at a time has to be ordered before it is cut.
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
81pub enum AlbumOrder {
82    /// Artist, then release date, then title — how a shelf reads.
83    #[default]
84    ArtistThenDate,
85    /// Release date, then title. A discography, in the order it happened.
86    Date,
87    /// Newest acquisition first. What a browser should open on: the record you
88    /// just added is the one you were looking for.
89    RecentlyAdded,
90    Title,
91    /// Newest release first.
92    YearDesc,
93    /// Seeded, so every page of one shuffle belongs to the same shuffle. A new
94    /// seed is a new order — that is what the reshuffle button asks for.
95    Random(i64),
96    /// Insertion order. The one order a new album cannot land in the middle
97    /// of, which is what makes an offset walk over the whole list exact.
98    Id,
99}
100
101impl AlbumOrder {
102    fn clause(self) -> &'static str {
103        match self {
104            Self::ArtistThenDate => "a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY",
105            Self::Date => "al.date, al.title COLLATE LIBRARY",
106            // Albums predating the added_at column sort last rather than first,
107            // which is what a NULL would do.
108            Self::RecentlyAdded => {
109                "COALESCE(al.added_at, '') DESC, a.name COLLATE LIBRARY, al.title COLLATE LIBRARY"
110            }
111            Self::Title => "al.title COLLATE LIBRARY, a.name COLLATE LIBRARY, al.date",
112            Self::YearDesc => {
113                "COALESCE(CAST(substr(al.date, 1, 4) AS INTEGER), 0) DESC, \
114                               a.name COLLATE LIBRARY, al.title COLLATE LIBRARY"
115            }
116            Self::Random(_) => "koan_shuffle(al.id, ?)",
117            Self::Id => "al.id",
118        }
119    }
120}
121
122/// Codecs that lose nothing, as the indexer names them.
123pub const LOSSLESS_CODECS: [&str; 5] = ["FLAC", "ALAC", "WAV", "AIFF", "PCM"];
124
125/// Narrowing by what the records are, shared by the album and artist listings:
126/// an artist passes when any of their albums does.
127#[derive(Debug, Clone, Copy, Default)]
128pub struct AlbumFilter<'a> {
129    /// Only records in a codec from `LOSSLESS_CODECS`.
130    pub lossless: bool,
131    /// Only records in this codec, matched as the indexer names it.
132    pub codec: Option<&'a str>,
133    /// Release year bounds, inclusive. Records without a date are left out
134    /// when either is set.
135    pub year_from: Option<i32>,
136    pub year_to: Option<i32>,
137    /// Records with at least one track tagged with this genre.
138    pub genre: Option<&'a str>,
139}
140
141impl AlbumFilter<'_> {
142    /// Conditions on the album aliased `al`.
143    pub(crate) fn push(
144        &self,
145        wheres: &mut Vec<String>,
146        params: &mut Vec<Box<dyn rusqlite::ToSql>>,
147    ) {
148        if self.lossless {
149            wheres.push(format!(
150                "al.codec IN ({})",
151                vec!["?"; LOSSLESS_CODECS.len()].join(",")
152            ));
153            params.extend(
154                LOSSLESS_CODECS
155                    .iter()
156                    .map(|c| Box::new(*c) as Box<dyn rusqlite::ToSql>),
157            );
158        }
159        if let Some(codec) = self.codec {
160            wheres.push("al.codec = ? COLLATE NOCASE".into());
161            params.push(Box::new(codec.to_owned()));
162        }
163        let year = "CAST(substr(al.date, 1, 4) AS INTEGER)";
164        if let Some(from) = self.year_from {
165            wheres.push(format!("{year} >= ?"));
166            params.push(Box::new(from));
167        }
168        if let Some(to) = self.year_to {
169            wheres.push(format!("al.date IS NOT NULL AND {year} <= ?"));
170            params.push(Box::new(to));
171        }
172        if let Some(genre) = self.genre {
173            wheres.push(
174                "EXISTS (SELECT 1 FROM tracks g WHERE g.album_id = al.id AND g.genre = ? COLLATE NOCASE)"
175                    .into(),
176            );
177            params.push(Box::new(genre.to_owned()));
178        }
179    }
180}
181
182/// What to list. Everything optional, so one query answers the browser, the
183/// search field, an artist's discography and the favourites page.
184#[derive(Debug, Clone, Copy, Default)]
185pub struct AlbumQuery<'a> {
186    pub artist_id: Option<i64>,
187    /// Case-insensitive substring over the album title and the artist name.
188    pub search: Option<&'a str>,
189    pub order: AlbumOrder,
190    /// Favourited records only.
191    pub favourites_only: bool,
192    pub filter: AlbumFilter<'a>,
193    /// `None` for the whole listing. A client that scrolls should page.
194    pub limit: Option<u32>,
195    pub offset: u32,
196}
197
198/// Albums, narrowed, ordered and paged by the database.
199///
200/// The narrowing belongs here rather than in each client: every front end wants
201/// the same answer, and the ones that filtered a fully-loaded list in their own
202/// language paid for reading the whole table to throw most of it away. Matching
203/// is ASCII case-insensitive, like `find_artists` — SQLite's `NOCASE` does not
204/// fold accented letters, so `MOTLEY` finds `Motley` but `MÖTLEY` does not find
205/// `Mötley`.
206pub fn list_albums(conn: &Connection, q: &AlbumQuery) -> Result<Vec<AlbumRow>, DbError> {
207    let mut sql = String::from(
208        "SELECT al.id, al.title, al.artist_id, a.name, al.date,
209                al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
210                al.added_at
211         FROM albums al
212         LEFT JOIN artists a ON al.artist_id = a.id",
213    );
214    if q.favourites_only {
215        sql.push_str(
216            " JOIN favourite_albums f
217                ON f.artist_name = a.name AND f.album_title = al.title",
218        );
219    }
220
221    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
222    let mut wheres: Vec<String> = Vec::new();
223    if let Some(id) = q.artist_id {
224        params.push(Box::new(id));
225        wheres.push("al.artist_id = ?".into());
226    }
227    if let Some(query) = q.search {
228        let pattern = format!("%{}%", super::artists::escape_like(query));
229        // Bound twice rather than once: positional parameters are cheaper to
230        // keep straight than named ones across an assembled query.
231        params.push(Box::new(pattern.clone()));
232        params.push(Box::new(pattern));
233        wheres.push(
234            "(al.title LIKE ? COLLATE NOCASE ESCAPE '\\'
235              OR a.name LIKE ? COLLATE NOCASE ESCAPE '\\')"
236                .into(),
237        );
238    }
239    q.filter.push(&mut wheres, &mut params);
240    if !wheres.is_empty() {
241        sql.push_str(" WHERE ");
242        sql.push_str(&wheres.join(" AND "));
243    }
244
245    sql.push_str(" ORDER BY ");
246    if let AlbumOrder::Random(seed) = q.order {
247        params.push(Box::new(seed));
248    }
249    sql.push_str(q.order.clause());
250
251    if let Some(limit) = q.limit {
252        params.push(Box::new(limit as i64));
253        params.push(Box::new(q.offset as i64));
254        sql.push_str(" LIMIT ? OFFSET ?");
255    }
256
257    let mut stmt = conn.prepare(&sql)?;
258    let rows = stmt
259        .query_map(rusqlite::params_from_iter(params.iter()), album_row)?
260        .collect::<Result<Vec<_>, _>>()?;
261    Ok(rows)
262}
263
264/// Genres by how many records carry them, most first: what a genre filter
265/// offers. Blank tags are left out.
266pub fn genres(conn: &Connection, limit: u32) -> Result<Vec<String>, DbError> {
267    let mut stmt = conn.prepare(
268        "SELECT genre FROM tracks
269         WHERE genre IS NOT NULL AND TRIM(genre) != '' AND album_id IS NOT NULL
270         GROUP BY genre COLLATE NOCASE
271         ORDER BY COUNT(DISTINCT album_id) DESC, genre COLLATE NOCASE
272         LIMIT ?1",
273    )?;
274    let rows = stmt.query_map([limit], |r| r.get(0))?;
275    Ok(rows.collect::<Result<_, _>>()?)
276}
277
278/// The codecs records are in, most common first.
279pub fn album_codecs(conn: &Connection) -> Result<Vec<String>, DbError> {
280    let mut stmt = conn.prepare(
281        "SELECT codec FROM albums WHERE codec IS NOT NULL AND codec != ''
282         GROUP BY codec ORDER BY COUNT(*) DESC, codec",
283    )?;
284    let rows = stmt.query_map([], |r| r.get(0))?;
285    Ok(rows.collect::<Result<_, _>>()?)
286}
287
288/// Get albums for a specific artist, ordered chronologically.
289pub fn albums_for_artist(conn: &Connection, artist_id: i64) -> Result<Vec<AlbumRow>, DbError> {
290    list_albums(
291        conn,
292        &AlbumQuery {
293            artist_id: Some(artist_id),
294            order: AlbumOrder::Date,
295            ..Default::default()
296        },
297    )
298}
299
300/// Get a single album by ID.
301pub fn get_album(conn: &Connection, album_id: i64) -> Result<Option<AlbumRow>, DbError> {
302    let result = conn
303        .query_row(
304            "SELECT al.id, al.title, al.artist_id, a.name, al.date,
305                    al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
306                al.added_at
307             FROM albums al
308             LEFT JOIN artists a ON al.artist_id = a.id
309             WHERE al.id = ?1",
310            params![album_id],
311            album_row,
312        )
313        .ok();
314    Ok(result)
315}
316
317/// Get the date string for an album by ID.
318pub fn album_date(conn: &Connection, album_id: i64) -> Result<Option<String>, DbError> {
319    Ok(conn
320        .query_row(
321            "SELECT date FROM albums WHERE id = ?1",
322            params![album_id],
323            |row| row.get(0),
324        )
325        .ok()
326        .flatten())
327}
328
329/// Albums whose title or artist matches, case-insensitive substring.
330pub fn find_albums(conn: &Connection, query: &str) -> Result<Vec<AlbumRow>, DbError> {
331    list_albums(
332        conn,
333        &AlbumQuery {
334            search: Some(query),
335            ..Default::default()
336        },
337    )
338}
339
340/// Get all albums with their artist name, sorted.
341pub fn all_albums(conn: &Connection) -> Result<Vec<AlbumRow>, DbError> {
342    list_albums(conn, &AlbumQuery::default())
343}
344
345/// Record what the server knows about an album beyond what a track carries.
346///
347/// `get_or_create_album` is reached through a track and only ever sees what a
348/// file's tags say. Track totals, the record label and the MusicBrainz id are
349/// properties of the release, and the server hands all three over in the same
350/// response the sync already paged through.
351///
352/// Fills blanks rather than overwriting, so a locally-scanned album keeps what
353/// its tags said.
354pub fn enrich_remote_album(
355    conn: &Connection,
356    remote_id: &str,
357    mbid: Option<&str>,
358    sort_name: Option<&str>,
359    total_tracks: Option<i32>,
360    label: Option<&str>,
361) -> Result<(), DbError> {
362    conn.execute(
363        "UPDATE albums SET
364             mbid         = COALESCE(mbid, ?2),
365             sort_name    = COALESCE(sort_name, ?3),
366             total_tracks = COALESCE(total_tracks, ?4),
367             label        = COALESCE(label, ?5)
368         WHERE remote_id = ?1",
369        params![remote_id, mbid, sort_name, total_tracks, label],
370    )?;
371    Ok(())
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::db::connection::Database;
378    use crate::db::queries::get_or_create_artist;
379
380    fn test_db() -> Database {
381        let conn = rusqlite::Connection::open_in_memory().unwrap();
382        conn.pragma_update(None, "foreign_keys", "on").unwrap();
383        crate::db::schema::create_tables(&conn).unwrap();
384        Database { conn }
385    }
386
387    /// Three records by two artists: a FLAC techno one from 1995, an MP3 rock
388    /// one from 2005 and an ALAC techno one from 2010.
389    fn filter_library() -> Database {
390        let db = test_db();
391        for (title, artist, album, codec, date, genre) in [
392            ("A", "Rrose", "Early", "FLAC", "1995", "Techno"),
393            ("B", "Band", "Middle", "MP3", "2005", "Rock"),
394            ("C", "Rrose", "Late", "ALAC", "2010-03-01", "techno"),
395        ] {
396            let mut meta = crate::db::queries::sample_meta(title, artist, album);
397            meta.codec = Some(codec.into());
398            meta.date = Some(date.into());
399            meta.genre = Some(genre.into());
400            crate::db::queries::upsert_track(&db.conn, &meta).unwrap();
401        }
402        db
403    }
404
405    fn titles(db: &Database, filter: AlbumFilter) -> Vec<String> {
406        list_albums(
407            &db.conn,
408            &AlbumQuery {
409                filter,
410                order: AlbumOrder::Date,
411                ..Default::default()
412            },
413        )
414        .unwrap()
415        .into_iter()
416        .map(|a| a.title)
417        .collect()
418    }
419
420    #[test]
421    fn albums_filter_by_codec_year_and_genre_in_sql() {
422        let db = filter_library();
423        let lossless = AlbumFilter {
424            lossless: true,
425            ..Default::default()
426        };
427        assert_eq!(titles(&db, lossless), ["Early", "Late"]);
428        let mp3 = AlbumFilter {
429            codec: Some("mp3"),
430            ..Default::default()
431        };
432        assert_eq!(titles(&db, mp3), ["Middle"]);
433        let years = AlbumFilter {
434            year_from: Some(2000),
435            year_to: Some(2010),
436            ..Default::default()
437        };
438        assert_eq!(titles(&db, years), ["Middle", "Late"]);
439        let techno = AlbumFilter {
440            genre: Some("TECHNO"),
441            ..Default::default()
442        };
443        assert_eq!(titles(&db, techno), ["Early", "Late"]);
444        let all = AlbumFilter {
445            lossless: true,
446            year_from: Some(2000),
447            genre: Some("techno"),
448            ..Default::default()
449        };
450        assert_eq!(titles(&db, all), ["Late"]);
451        assert_eq!(
452            genres(&db.conn, 10).unwrap().len(),
453            2,
454            "techno counted once"
455        );
456        assert_eq!(album_codecs(&db.conn).unwrap().len(), 3);
457    }
458
459    #[test]
460    fn artists_sort_and_count_what_the_filter_leaves() {
461        use crate::db::queries::{ArtistOrder, ArtistQuery, list_artists};
462        let db = filter_library();
463        let names = |q: ArtistQuery| {
464            list_artists(&db.conn, &q)
465                .unwrap()
466                .into_iter()
467                .map(|a| (a.name, a.album_count))
468                .collect::<Vec<_>>()
469        };
470        assert_eq!(
471            names(ArtistQuery {
472                order: ArtistOrder::AlbumCount,
473                ..Default::default()
474            }),
475            [("Rrose".to_string(), 2), ("Band".to_string(), 1)]
476        );
477        assert_eq!(
478            names(ArtistQuery {
479                filter: AlbumFilter {
480                    year_from: Some(2000),
481                    ..Default::default()
482                },
483                ..Default::default()
484            }),
485            [("Band".to_string(), 1), ("Rrose".to_string(), 1)]
486        );
487    }
488
489    #[test]
490    fn test_album_create_and_dedup() {
491        let db = test_db();
492        let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
493        let a1 = get_or_create_album(
494            &db.conn,
495            "Music Has the Right to Children",
496            artist,
497            Some("1998"),
498            None,
499            None,
500            Some("FLAC"),
501            Some("Warp"),
502            None,
503            None,
504        )
505        .unwrap();
506        let a2 = get_or_create_album(
507            &db.conn,
508            "Music Has the Right to Children",
509            artist,
510            Some("1998"),
511            None,
512            None,
513            Some("FLAC"),
514            Some("Warp"),
515            None,
516            None,
517        )
518        .unwrap();
519        assert_eq!(a1, a2);
520    }
521
522    #[test]
523    fn test_album_codec_updated_on_format_upgrade() {
524        let db = test_db();
525        let artist = get_or_create_artist(&db.conn, "WAGDUG FUTURISTIC UNITY", None).unwrap();
526
527        // First scan: album indexed as MP3.
528        let id1 = get_or_create_album(
529            &db.conn,
530            "HAKAI",
531            artist,
532            Some("2008"),
533            None,
534            None,
535            Some("MP3"),
536            None,
537            None,
538            None,
539        )
540        .unwrap();
541
542        let codec: Option<String> = db
543            .conn
544            .query_row(
545                "SELECT codec FROM albums WHERE id = ?1",
546                params![id1],
547                |r| r.get(0),
548            )
549            .unwrap();
550        assert_eq!(codec.as_deref(), Some("MP3"));
551
552        // Re-scan after upgrading MP3→FLAC: same album, new codec.
553        let id2 = get_or_create_album(
554            &db.conn,
555            "HAKAI",
556            artist,
557            Some("2008"),
558            None,
559            None,
560            Some("FLAC"),
561            None,
562            None,
563            None,
564        )
565        .unwrap();
566
567        assert_eq!(id1, id2, "should return the same album ID");
568
569        let codec: Option<String> = db
570            .conn
571            .query_row(
572                "SELECT codec FROM albums WHERE id = ?1",
573                params![id1],
574                |r| r.get(0),
575            )
576            .unwrap();
577        assert_eq!(
578            codec.as_deref(),
579            Some("FLAC"),
580            "album codec should be updated after format upgrade"
581        );
582    }
583
584    #[test]
585    fn test_album_codec_not_nulled_by_missing_codec() {
586        let db = test_db();
587        let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
588
589        // First scan with codec.
590        let id = get_or_create_album(
591            &db.conn,
592            "MHTRTC",
593            artist,
594            Some("1998"),
595            None,
596            None,
597            Some("FLAC"),
598            Some("Warp"),
599            None,
600            None,
601        )
602        .unwrap();
603
604        // Re-encounter with no codec (e.g. remote sync without codec info).
605        get_or_create_album(
606            &db.conn,
607            "MHTRTC",
608            artist,
609            Some("1998"),
610            None,
611            None,
612            None, // no codec
613            None, // no label
614            None,
615            None,
616        )
617        .unwrap();
618
619        let (codec, label): (Option<String>, Option<String>) = db
620            .conn
621            .query_row(
622                "SELECT codec, label FROM albums WHERE id = ?1",
623                params![id],
624                |r| Ok((r.get(0)?, r.get(1)?)),
625            )
626            .unwrap();
627        assert_eq!(
628            codec.as_deref(),
629            Some("FLAC"),
630            "codec should not be nulled by a None value"
631        );
632        assert_eq!(
633            label.as_deref(),
634            Some("Warp"),
635            "label should not be nulled by a None value"
636        );
637    }
638
639    /// Six albums across two artists, so a page is smaller than the listing.
640    fn stocked_db() -> Database {
641        use crate::db::queries::{sample_meta, upsert_track};
642        let db = test_db();
643        for (i, (artist, album)) in [
644            ("Autechre", "Amber"),
645            ("Autechre", "Tri Repetae"),
646            ("Autechre", "Confield"),
647            ("Boards of Canada", "Geogaddi"),
648            ("Boards of Canada", "Twoism"),
649            ("Coil", "Horse Rotorvator"),
650        ]
651        .iter()
652        .enumerate()
653        {
654            let mut m = sample_meta("t", artist, album);
655            m.path = Some(format!("/music/{album}/t.flac"));
656            m.date = Some(format!("199{i}"));
657            upsert_track(&db.conn, &m).unwrap();
658        }
659        db
660    }
661
662    #[test]
663    fn paging_walks_the_listing_without_repeating() {
664        let db = stocked_db();
665        let page = |offset| {
666            list_albums(
667                &db.conn,
668                &AlbumQuery {
669                    limit: Some(2),
670                    offset,
671                    ..Default::default()
672                },
673            )
674            .unwrap()
675            .into_iter()
676            .map(|a| a.title)
677            .collect::<Vec<_>>()
678        };
679        let whole = all_albums(&db.conn)
680            .unwrap()
681            .into_iter()
682            .map(|a| a.title)
683            .collect::<Vec<_>>();
684        assert_eq!([page(0), page(2), page(4)].concat(), whole);
685        assert!(
686            page(6).is_empty(),
687            "a page past the end is empty, not wrapped"
688        );
689    }
690
691    #[test]
692    fn search_narrows_on_title_or_artist() {
693        let db = stocked_db();
694        let titles = |q| {
695            find_albums(&db.conn, q)
696                .unwrap()
697                .into_iter()
698                .map(|a| a.title)
699                .collect::<Vec<_>>()
700        };
701        assert_eq!(titles("geogaddi"), ["Geogaddi"]);
702        assert_eq!(titles("autechre").len(), 3, "matched on the artist name");
703    }
704
705    /// The reason the seed exists: page two has to belong to the same shuffle
706    /// as page one, or scrolling repeats and drops records.
707    #[test]
708    fn a_seeded_shuffle_pages_consistently() {
709        let db = stocked_db();
710        let shuffled = |seed, limit, offset| {
711            list_albums(
712                &db.conn,
713                &AlbumQuery {
714                    order: AlbumOrder::Random(seed),
715                    limit,
716                    offset,
717                    ..Default::default()
718                },
719            )
720            .unwrap()
721            .into_iter()
722            .map(|a| a.id)
723            .collect::<Vec<_>>()
724        };
725
726        let whole = shuffled(42, None, 0);
727        assert_eq!(
728            [shuffled(42, Some(4), 0), shuffled(42, Some(4), 4)].concat(),
729            whole
730        );
731        assert_ne!(shuffled(43, None, 0), whole, "a new seed is a new order");
732        assert_eq!(whole.len(), 6, "a shuffle drops nothing");
733    }
734
735    #[test]
736    fn favourites_only_lists_what_was_hearted() {
737        use crate::db::queries::toggle_favourite_album;
738        let db = stocked_db();
739        toggle_favourite_album(&db.conn, "Coil", "Horse Rotorvator").unwrap();
740        let rows = list_albums(
741            &db.conn,
742            &AlbumQuery {
743                favourites_only: true,
744                ..Default::default()
745            },
746        )
747        .unwrap();
748        assert_eq!(
749            rows.iter().map(|a| a.title.as_str()).collect::<Vec<_>>(),
750            ["Horse Rotorvator"]
751        );
752    }
753
754    #[test]
755    fn test_all_albums_and_tracks() {
756        use crate::db::queries::{sample_meta, tracks_for_album, upsert_track};
757
758        let db = test_db();
759        let mut m1 = sample_meta("Track1", "Artist1", "Album1");
760        m1.track_number = Some(1);
761        let mut m2 = sample_meta("Track2", "Artist1", "Album1");
762        m2.track_number = Some(2);
763        m2.path = Some("/music/Album1/Track2.flac".into());
764        upsert_track(&db.conn, &m1).unwrap();
765        upsert_track(&db.conn, &m2).unwrap();
766
767        let albums = all_albums(&db.conn).unwrap();
768        assert_eq!(albums.len(), 1);
769        assert_eq!(albums[0].title, "Album1");
770
771        let tracks = tracks_for_album(&db.conn, albums[0].id).unwrap();
772        assert_eq!(tracks.len(), 2);
773        assert_eq!(tracks[0].track_number, Some(1));
774        assert_eq!(tracks[1].track_number, Some(2));
775    }
776}