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