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