1use rusqlite::{Connection, OptionalExtension, params};
2
3use crate::db::connection::DbError;
4
5use super::AlbumRow;
6
7fn 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#[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: 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 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
141pub(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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
202pub enum AlbumOrder {
203 #[default]
205 ArtistThenDate,
206 Date,
208 RecentlyAdded,
211 Title,
212 YearDesc,
214 Random(i64),
217 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 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
243pub const LOSSLESS_CODECS: [&str; 5] = ["FLAC", "ALAC", "WAV", "AIFF", "PCM"];
245
246#[derive(Debug, Clone, Copy, Default)]
249pub struct AlbumFilter<'a> {
250 pub lossless: bool,
252 pub codec: Option<&'a str>,
254 pub year_from: Option<i32>,
257 pub year_to: Option<i32>,
258 pub genre: Option<&'a str>,
260}
261
262impl AlbumFilter<'_> {
263 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#[derive(Debug, Clone, Copy, Default)]
306pub struct AlbumQuery<'a> {
307 pub ids: Option<&'a [i64]>,
309 pub artist_id: Option<i64>,
310 pub search: Option<&'a str>,
312 pub order: AlbumOrder,
313 pub favourites_of: Option<i64>,
315 pub filter: AlbumFilter<'a>,
316 pub limit: Option<u32>,
318 pub offset: u32,
319}
320
321pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
390pub enum PlayedOrder {
391 Recent,
393 Frequent,
395}
396
397pub 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
433pub 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
447pub 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
457pub 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
469pub 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
485pub 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
497pub 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
508pub fn all_albums(conn: &Connection) -> Result<Vec<AlbumRow>, DbError> {
510 list_albums(conn, &AlbumQuery::default())
511}
512
513pub 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 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 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 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 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 get_or_create_album(
828 &db.conn,
829 "MHTRTC",
830 artist,
831 Some("1998"),
832 None,
833 None,
834 None, None, 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 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 #[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}