Skip to main content

koan_core/db/queries/
albums.rs

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