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)]
26pub fn get_or_create_album(
27 conn: &Connection,
28 title: &str,
29 artist_id: i64,
30 date: Option<&str>,
31 total_discs: Option<i32>,
32 total_tracks: Option<i32>,
33 codec: Option<&str>,
34 label: Option<&str>,
35 remote_id: Option<&str>,
39 added_at: Option<&str>,
40) -> Result<i64, DbError> {
41 type Stored = (
42 i64,
43 Option<String>,
44 Option<String>,
45 Option<String>,
46 Option<String>,
47 Option<String>,
48 );
49 let existing: Option<Stored> = conn
50 .prepare_cached(
51 "SELECT id, codec, date, label, remote_id, added_at FROM albums
52 WHERE title = ?1 AND artist_id = ?2",
53 )?
54 .query_row(params![title, artist_id], |row| {
55 Ok((
56 row.get(0)?,
57 row.get(1)?,
58 row.get(2)?,
59 row.get(3)?,
60 row.get(4)?,
61 row.get(5)?,
62 ))
63 })
64 .optional()?;
65
66 if let Some((id, s_codec, s_date, s_label, s_remote_id, s_added_at)) = existing {
67 let earliest = match (added_at, s_added_at.as_deref()) {
75 (Some(new), Some(stored)) => Some(new.min(stored)),
76 (new, stored) => new.or(stored),
77 };
78 let merged = (
79 codec.or(s_codec.as_deref()),
80 date.or(s_date.as_deref()),
81 label.or(s_label.as_deref()),
82 remote_id.or(s_remote_id.as_deref()),
83 earliest,
84 );
85 let stored = (
86 s_codec.as_deref(),
87 s_date.as_deref(),
88 s_label.as_deref(),
89 s_remote_id.as_deref(),
90 s_added_at.as_deref(),
91 );
92 if merged != stored {
93 conn.prepare_cached(
94 "UPDATE albums SET codec = ?1, date = ?2, label = ?3, remote_id = ?4, added_at = ?5
95 WHERE id = ?6",
96 )?
97 .execute(params![
98 merged.0, merged.1, merged.2, merged.3, merged.4, id
99 ])?;
100 }
101 if let Some(rid) = remote_id {
102 super::adopt_uid(conn, super::UidKind::Album, id, rid)?;
103 }
104 return Ok(id);
105 }
106
107 let uid = super::free_uid(conn, super::UidKind::Album, remote_id)?;
108 conn.prepare_cached(
109 "INSERT INTO albums (title, artist_id, date, total_discs, total_tracks, codec, label, remote_id, added_at, uid)
110 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
111 )?
112 .execute(params![title, artist_id, date, total_discs, total_tracks, codec, label, remote_id, added_at, uid])?;
113 let id = conn.last_insert_rowid();
114 if let Some(rid) = remote_id {
115 super::adopt_uid(conn, super::UidKind::Album, id, rid)?;
116 }
117 Ok(id)
118}
119
120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125pub enum AlbumOrder {
126 #[default]
128 ArtistThenDate,
129 Date,
131 RecentlyAdded,
134 Title,
135 YearDesc,
137 Random(i64),
140 Id,
143}
144
145impl AlbumOrder {
146 fn clause(self) -> &'static str {
147 match self {
148 Self::ArtistThenDate => "a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY",
149 Self::Date => "al.date, al.title COLLATE LIBRARY",
150 Self::RecentlyAdded => {
153 "COALESCE(al.added_at, '') DESC, a.name COLLATE LIBRARY, al.title COLLATE LIBRARY"
154 }
155 Self::Title => "al.title COLLATE LIBRARY, a.name COLLATE LIBRARY, al.date",
156 Self::YearDesc => {
157 "COALESCE(CAST(substr(al.date, 1, 4) AS INTEGER), 0) DESC, \
158 a.name COLLATE LIBRARY, al.title COLLATE LIBRARY"
159 }
160 Self::Random(_) => "koan_shuffle(al.id, ?)",
161 Self::Id => "al.id",
162 }
163 }
164}
165
166pub const LOSSLESS_CODECS: [&str; 5] = ["FLAC", "ALAC", "WAV", "AIFF", "PCM"];
168
169#[derive(Debug, Clone, Copy, Default)]
172pub struct AlbumFilter<'a> {
173 pub lossless: bool,
175 pub codec: Option<&'a str>,
177 pub year_from: Option<i32>,
180 pub year_to: Option<i32>,
181 pub genre: Option<&'a str>,
183}
184
185impl AlbumFilter<'_> {
186 pub(crate) fn push(
188 &self,
189 wheres: &mut Vec<String>,
190 params: &mut Vec<Box<dyn rusqlite::ToSql>>,
191 ) {
192 if self.lossless {
193 wheres.push(format!(
194 "al.codec IN ({})",
195 vec!["?"; LOSSLESS_CODECS.len()].join(",")
196 ));
197 params.extend(
198 LOSSLESS_CODECS
199 .iter()
200 .map(|c| Box::new(*c) as Box<dyn rusqlite::ToSql>),
201 );
202 }
203 if let Some(codec) = self.codec {
204 wheres.push("al.codec = ? COLLATE NOCASE".into());
205 params.push(Box::new(codec.to_owned()));
206 }
207 let year = "CAST(substr(al.date, 1, 4) AS INTEGER)";
208 if let Some(from) = self.year_from {
209 wheres.push(format!("{year} >= ?"));
210 params.push(Box::new(from));
211 }
212 if let Some(to) = self.year_to {
213 wheres.push(format!("al.date IS NOT NULL AND {year} <= ?"));
214 params.push(Box::new(to));
215 }
216 if let Some(genre) = self.genre {
217 wheres.push(
218 "EXISTS (SELECT 1 FROM tracks g WHERE g.album_id = al.id AND g.genre = ? COLLATE NOCASE)"
219 .into(),
220 );
221 params.push(Box::new(genre.to_owned()));
222 }
223 }
224}
225
226#[derive(Debug, Clone, Copy, Default)]
229pub struct AlbumQuery<'a> {
230 pub ids: Option<&'a [i64]>,
232 pub artist_id: Option<i64>,
233 pub search: Option<&'a str>,
235 pub order: AlbumOrder,
236 pub favourites_of: Option<i64>,
238 pub filter: AlbumFilter<'a>,
239 pub limit: Option<u32>,
241 pub offset: u32,
242}
243
244pub fn list_albums(conn: &Connection, q: &AlbumQuery) -> Result<Vec<AlbumRow>, DbError> {
253 let mut sql = String::from(
254 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
255 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
256 al.added_at
257 FROM albums al
258 LEFT JOIN artists a ON al.artist_id = a.id",
259 );
260 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
261 if let Some(user) = q.favourites_of {
262 params.push(Box::new(super::auth::resolve_user(conn, user)?));
263 sql.push_str(
264 " JOIN favourite_albums f
265 ON f.artist_name = a.name AND f.album_title = al.title AND f.user_id = ?",
266 );
267 }
268 let mut wheres: Vec<String> = Vec::new();
269 if let Some(ids) = q.ids {
270 params.push(Box::new(super::json_list(ids)));
271 wheres.push("al.id IN (SELECT value FROM json_each(?))".into());
272 }
273 if let Some(id) = q.artist_id {
274 params.push(Box::new(id));
275 wheres.push("al.artist_id = ?".into());
276 }
277 if let Some(query) = q.search {
278 let pattern = format!("%{}%", super::artists::escape_like(query));
279 params.push(Box::new(pattern.clone()));
282 params.push(Box::new(pattern));
283 wheres.push(
284 "(al.title LIKE ? COLLATE NOCASE ESCAPE '\\'
285 OR a.name LIKE ? COLLATE NOCASE ESCAPE '\\')"
286 .into(),
287 );
288 }
289 q.filter.push(&mut wheres, &mut params);
290 if !wheres.is_empty() {
291 sql.push_str(" WHERE ");
292 sql.push_str(&wheres.join(" AND "));
293 }
294
295 sql.push_str(" ORDER BY ");
296 if let AlbumOrder::Random(seed) = q.order {
297 params.push(Box::new(seed));
298 }
299 sql.push_str(q.order.clause());
300
301 if let Some(limit) = q.limit {
302 params.push(Box::new(limit as i64));
303 params.push(Box::new(q.offset as i64));
304 sql.push_str(" LIMIT ? OFFSET ?");
305 }
306
307 let mut stmt = conn.prepare(&sql)?;
308 let rows = stmt
309 .query_map(rusqlite::params_from_iter(params.iter()), album_row)?
310 .collect::<Result<Vec<_>, _>>()?;
311 Ok(rows)
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum PlayedOrder {
317 Recent,
319 Frequent,
321}
322
323pub fn played_albums(
326 conn: &Connection,
327 user: i64,
328 order: PlayedOrder,
329 limit: u32,
330 offset: u32,
331) -> Result<Vec<AlbumRow>, DbError> {
332 let order = match order {
333 PlayedOrder::Recent => "p.last DESC",
334 PlayedOrder::Frequent => "p.plays DESC, p.last DESC",
335 };
336 let sql = format!(
337 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
338 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
339 al.added_at
340 FROM (SELECT t.album_id, MAX(h.played_at) AS last, COUNT(*) AS plays
341 FROM play_history h JOIN tracks t ON t.id = h.track_id
342 WHERE h.user_id = ?1 AND t.album_id IS NOT NULL
343 GROUP BY t.album_id) p
344 JOIN albums al ON al.id = p.album_id
345 LEFT JOIN artists a ON al.artist_id = a.id
346 ORDER BY {order}, al.id
347 LIMIT ?2 OFFSET ?3"
348 );
349 let mut stmt = conn.prepare(&sql)?;
350 let rows = stmt
351 .query_map(
352 params![super::auth::resolve_user(conn, user)?, limit, offset],
353 album_row,
354 )?
355 .collect::<Result<Vec<_>, _>>()?;
356 Ok(rows)
357}
358
359pub fn genres(conn: &Connection, limit: u32) -> Result<Vec<String>, DbError> {
362 let mut stmt = conn.prepare(
363 "SELECT genre FROM tracks
364 WHERE genre IS NOT NULL AND TRIM(genre) != '' AND album_id IS NOT NULL
365 GROUP BY genre COLLATE NOCASE
366 ORDER BY COUNT(DISTINCT album_id) DESC, genre COLLATE NOCASE
367 LIMIT ?1",
368 )?;
369 let rows = stmt.query_map([limit], |r| r.get(0))?;
370 Ok(rows.collect::<Result<_, _>>()?)
371}
372
373pub fn album_codecs(conn: &Connection) -> Result<Vec<String>, DbError> {
375 let mut stmt = conn.prepare(
376 "SELECT codec FROM albums WHERE codec IS NOT NULL AND codec != ''
377 GROUP BY codec ORDER BY COUNT(*) DESC, codec",
378 )?;
379 let rows = stmt.query_map([], |r| r.get(0))?;
380 Ok(rows.collect::<Result<_, _>>()?)
381}
382
383pub fn albums_for_artist(conn: &Connection, artist_id: i64) -> Result<Vec<AlbumRow>, DbError> {
385 list_albums(
386 conn,
387 &AlbumQuery {
388 artist_id: Some(artist_id),
389 order: AlbumOrder::Date,
390 ..Default::default()
391 },
392 )
393}
394
395pub fn get_album(conn: &Connection, album_id: i64) -> Result<Option<AlbumRow>, DbError> {
397 let result = conn
398 .prepare_cached(
399 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
400 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
401 al.added_at
402 FROM albums al
403 LEFT JOIN artists a ON al.artist_id = a.id
404 WHERE al.id = ?1",
405 )
406 .and_then(|mut stmt| stmt.query_row(params![album_id], album_row))
407 .ok();
408 Ok(result)
409}
410
411pub fn album_date(conn: &Connection, album_id: i64) -> Result<Option<String>, DbError> {
413 Ok(conn
414 .query_row(
415 "SELECT date FROM albums WHERE id = ?1",
416 params![album_id],
417 |row| row.get(0),
418 )
419 .ok()
420 .flatten())
421}
422
423pub fn find_albums(conn: &Connection, query: &str) -> Result<Vec<AlbumRow>, DbError> {
425 list_albums(
426 conn,
427 &AlbumQuery {
428 search: Some(query),
429 ..Default::default()
430 },
431 )
432}
433
434pub fn all_albums(conn: &Connection) -> Result<Vec<AlbumRow>, DbError> {
436 list_albums(conn, &AlbumQuery::default())
437}
438
439pub fn enrich_remote_album(
449 conn: &Connection,
450 remote_id: &str,
451 mbid: Option<&str>,
452 sort_name: Option<&str>,
453 total_tracks: Option<i32>,
454 label: Option<&str>,
455) -> Result<(), DbError> {
456 conn.execute(
457 "UPDATE albums SET
458 mbid = COALESCE(mbid, ?2),
459 sort_name = COALESCE(sort_name, ?3),
460 total_tracks = COALESCE(total_tracks, ?4),
461 label = COALESCE(label, ?5)
462 WHERE remote_id = ?1",
463 params![remote_id, mbid, sort_name, total_tracks, label],
464 )?;
465 Ok(())
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::db::connection::Database;
472 use crate::db::queries::get_or_create_artist;
473
474 fn test_db() -> Database {
475 let conn = rusqlite::Connection::open_in_memory().unwrap();
476 conn.pragma_update(None, "foreign_keys", "on").unwrap();
477 crate::db::schema::create_tables(&conn).unwrap();
478 Database { conn }
479 }
480
481 fn filter_library() -> Database {
484 let db = test_db();
485 for (title, artist, album, codec, date, genre) in [
486 ("A", "Rrose", "Early", "FLAC", "1995", "Techno"),
487 ("B", "Band", "Middle", "MP3", "2005", "Rock"),
488 ("C", "Rrose", "Late", "ALAC", "2010-03-01", "techno"),
489 ] {
490 let mut meta = crate::db::queries::sample_meta(title, artist, album);
491 meta.codec = Some(codec.into());
492 meta.date = Some(date.into());
493 meta.genre = Some(genre.into());
494 crate::db::queries::upsert_track(&db.conn, &meta).unwrap();
495 }
496 db
497 }
498
499 fn titles(db: &Database, filter: AlbumFilter) -> Vec<String> {
500 list_albums(
501 &db.conn,
502 &AlbumQuery {
503 filter,
504 order: AlbumOrder::Date,
505 ..Default::default()
506 },
507 )
508 .unwrap()
509 .into_iter()
510 .map(|a| a.title)
511 .collect()
512 }
513
514 #[test]
515 fn albums_filter_by_codec_year_and_genre_in_sql() {
516 let db = filter_library();
517 let lossless = AlbumFilter {
518 lossless: true,
519 ..Default::default()
520 };
521 assert_eq!(titles(&db, lossless), ["Early", "Late"]);
522 let mp3 = AlbumFilter {
523 codec: Some("mp3"),
524 ..Default::default()
525 };
526 assert_eq!(titles(&db, mp3), ["Middle"]);
527 let years = AlbumFilter {
528 year_from: Some(2000),
529 year_to: Some(2010),
530 ..Default::default()
531 };
532 assert_eq!(titles(&db, years), ["Middle", "Late"]);
533 let techno = AlbumFilter {
534 genre: Some("TECHNO"),
535 ..Default::default()
536 };
537 assert_eq!(titles(&db, techno), ["Early", "Late"]);
538 let all = AlbumFilter {
539 lossless: true,
540 year_from: Some(2000),
541 genre: Some("techno"),
542 ..Default::default()
543 };
544 assert_eq!(titles(&db, all), ["Late"]);
545 assert_eq!(
546 genres(&db.conn, 10).unwrap().len(),
547 2,
548 "techno counted once"
549 );
550 assert_eq!(album_codecs(&db.conn).unwrap().len(), 3);
551 }
552
553 #[test]
554 fn played_albums_follow_one_users_history() {
555 use crate::db::queries::{LOCAL_USER, SOURCE_LOCAL, record_play_at};
556 let db = filter_library();
557 let track = |title: &str| -> i64 {
558 db.conn
559 .query_row("SELECT id FROM tracks WHERE title = ?1", [title], |r| {
560 r.get(0)
561 })
562 .unwrap()
563 };
564 for (title, at) in [("A", 10), ("C", 30), ("A", 20)] {
565 record_play_at(&db.conn, LOCAL_USER, track(title), at, None, SOURCE_LOCAL).unwrap();
566 }
567 let played = |order, limit, offset| {
568 played_albums(&db.conn, LOCAL_USER, order, limit, offset)
569 .unwrap()
570 .into_iter()
571 .map(|a| a.title)
572 .collect::<Vec<_>>()
573 };
574 assert_eq!(played(PlayedOrder::Recent, 10, 0), ["Late", "Early"]);
575 assert_eq!(played(PlayedOrder::Frequent, 10, 0), ["Early", "Late"]);
576 assert_eq!(played(PlayedOrder::Frequent, 1, 1), ["Late"]);
577 }
578
579 #[test]
580 fn random_draws_narrow_in_sql() {
581 use crate::db::queries::{RandomFilter, random_tracks_where};
582 let db = filter_library();
583 let draw = |filter: RandomFilter, count| {
584 let mut titles: Vec<String> = random_tracks_where(&db.conn, count, &filter)
585 .unwrap()
586 .into_iter()
587 .map(|t| t.title)
588 .collect();
589 titles.sort();
590 titles
591 };
592 assert_eq!(draw(RandomFilter::default(), 10), ["A", "B", "C"]);
593 assert_eq!(draw(RandomFilter::default(), 2).len(), 2);
594 let techno = RandomFilter {
595 genre: Some("TECHNO"),
596 ..Default::default()
597 };
598 assert_eq!(draw(techno, 10), ["A", "C"]);
599 let nineties = RandomFilter {
600 year_from: Some(1990),
601 year_to: Some(1999),
602 ..Default::default()
603 };
604 assert_eq!(draw(nineties, 10), ["A"]);
605 }
606
607 #[test]
608 fn artists_sort_and_count_what_the_filter_leaves() {
609 use crate::db::queries::{ArtistOrder, ArtistQuery, list_artists};
610 let db = filter_library();
611 let names = |q: ArtistQuery| {
612 list_artists(&db.conn, &q)
613 .unwrap()
614 .into_iter()
615 .map(|a| (a.name, a.album_count))
616 .collect::<Vec<_>>()
617 };
618 assert_eq!(
619 names(ArtistQuery {
620 order: ArtistOrder::AlbumCount,
621 ..Default::default()
622 }),
623 [("Rrose".to_string(), 2), ("Band".to_string(), 1)]
624 );
625 assert_eq!(
626 names(ArtistQuery {
627 filter: AlbumFilter {
628 year_from: Some(2000),
629 ..Default::default()
630 },
631 ..Default::default()
632 }),
633 [("Band".to_string(), 1), ("Rrose".to_string(), 1)]
634 );
635 }
636
637 #[test]
638 fn test_album_create_and_dedup() {
639 let db = test_db();
640 let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
641 let a1 = get_or_create_album(
642 &db.conn,
643 "Music Has the Right to Children",
644 artist,
645 Some("1998"),
646 None,
647 None,
648 Some("FLAC"),
649 Some("Warp"),
650 None,
651 None,
652 )
653 .unwrap();
654 let a2 = get_or_create_album(
655 &db.conn,
656 "Music Has the Right to Children",
657 artist,
658 Some("1998"),
659 None,
660 None,
661 Some("FLAC"),
662 Some("Warp"),
663 None,
664 None,
665 )
666 .unwrap();
667 assert_eq!(a1, a2);
668 }
669
670 #[test]
671 fn test_album_codec_updated_on_format_upgrade() {
672 let db = test_db();
673 let artist = get_or_create_artist(&db.conn, "WAGDUG FUTURISTIC UNITY", None).unwrap();
674
675 let id1 = get_or_create_album(
677 &db.conn,
678 "HAKAI",
679 artist,
680 Some("2008"),
681 None,
682 None,
683 Some("MP3"),
684 None,
685 None,
686 None,
687 )
688 .unwrap();
689
690 let codec: Option<String> = db
691 .conn
692 .query_row(
693 "SELECT codec FROM albums WHERE id = ?1",
694 params![id1],
695 |r| r.get(0),
696 )
697 .unwrap();
698 assert_eq!(codec.as_deref(), Some("MP3"));
699
700 let id2 = get_or_create_album(
702 &db.conn,
703 "HAKAI",
704 artist,
705 Some("2008"),
706 None,
707 None,
708 Some("FLAC"),
709 None,
710 None,
711 None,
712 )
713 .unwrap();
714
715 assert_eq!(id1, id2, "should return the same album ID");
716
717 let codec: Option<String> = db
718 .conn
719 .query_row(
720 "SELECT codec FROM albums WHERE id = ?1",
721 params![id1],
722 |r| r.get(0),
723 )
724 .unwrap();
725 assert_eq!(
726 codec.as_deref(),
727 Some("FLAC"),
728 "album codec should be updated after format upgrade"
729 );
730 }
731
732 #[test]
733 fn test_album_codec_not_nulled_by_missing_codec() {
734 let db = test_db();
735 let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
736
737 let id = get_or_create_album(
739 &db.conn,
740 "MHTRTC",
741 artist,
742 Some("1998"),
743 None,
744 None,
745 Some("FLAC"),
746 Some("Warp"),
747 None,
748 None,
749 )
750 .unwrap();
751
752 get_or_create_album(
754 &db.conn,
755 "MHTRTC",
756 artist,
757 Some("1998"),
758 None,
759 None,
760 None, None, None,
763 None,
764 )
765 .unwrap();
766
767 let (codec, label): (Option<String>, Option<String>) = db
768 .conn
769 .query_row(
770 "SELECT codec, label FROM albums WHERE id = ?1",
771 params![id],
772 |r| Ok((r.get(0)?, r.get(1)?)),
773 )
774 .unwrap();
775 assert_eq!(
776 codec.as_deref(),
777 Some("FLAC"),
778 "codec should not be nulled by a None value"
779 );
780 assert_eq!(
781 label.as_deref(),
782 Some("Warp"),
783 "label should not be nulled by a None value"
784 );
785 }
786
787 fn stocked_db() -> Database {
789 use crate::db::queries::{sample_meta, upsert_track};
790 let db = test_db();
791 for (i, (artist, album)) in [
792 ("Autechre", "Amber"),
793 ("Autechre", "Tri Repetae"),
794 ("Autechre", "Confield"),
795 ("Boards of Canada", "Geogaddi"),
796 ("Boards of Canada", "Twoism"),
797 ("Coil", "Horse Rotorvator"),
798 ]
799 .iter()
800 .enumerate()
801 {
802 let mut m = sample_meta("t", artist, album);
803 m.path = Some(format!("/music/{album}/t.flac"));
804 m.date = Some(format!("199{i}"));
805 upsert_track(&db.conn, &m).unwrap();
806 }
807 db
808 }
809
810 #[test]
811 fn paging_walks_the_listing_without_repeating() {
812 let db = stocked_db();
813 let page = |offset| {
814 list_albums(
815 &db.conn,
816 &AlbumQuery {
817 limit: Some(2),
818 offset,
819 ..Default::default()
820 },
821 )
822 .unwrap()
823 .into_iter()
824 .map(|a| a.title)
825 .collect::<Vec<_>>()
826 };
827 let whole = all_albums(&db.conn)
828 .unwrap()
829 .into_iter()
830 .map(|a| a.title)
831 .collect::<Vec<_>>();
832 assert_eq!([page(0), page(2), page(4)].concat(), whole);
833 assert!(
834 page(6).is_empty(),
835 "a page past the end is empty, not wrapped"
836 );
837 }
838
839 #[test]
840 fn search_narrows_on_title_or_artist() {
841 let db = stocked_db();
842 let titles = |q| {
843 find_albums(&db.conn, q)
844 .unwrap()
845 .into_iter()
846 .map(|a| a.title)
847 .collect::<Vec<_>>()
848 };
849 assert_eq!(titles("geogaddi"), ["Geogaddi"]);
850 assert_eq!(titles("autechre").len(), 3, "matched on the artist name");
851 }
852
853 #[test]
856 fn a_seeded_shuffle_pages_consistently() {
857 let db = stocked_db();
858 let shuffled = |seed, limit, offset| {
859 list_albums(
860 &db.conn,
861 &AlbumQuery {
862 order: AlbumOrder::Random(seed),
863 limit,
864 offset,
865 ..Default::default()
866 },
867 )
868 .unwrap()
869 .into_iter()
870 .map(|a| a.id)
871 .collect::<Vec<_>>()
872 };
873
874 let whole = shuffled(42, None, 0);
875 assert_eq!(
876 [shuffled(42, Some(4), 0), shuffled(42, Some(4), 4)].concat(),
877 whole
878 );
879 assert_ne!(shuffled(43, None, 0), whole, "a new seed is a new order");
880 assert_eq!(whole.len(), 6, "a shuffle drops nothing");
881 }
882
883 #[test]
884 fn favourites_only_lists_what_was_hearted() {
885 use crate::db::queries::toggle_favourite_album;
886 let db = stocked_db();
887 toggle_favourite_album(
888 &db.conn,
889 crate::db::queries::LOCAL_USER,
890 "Coil",
891 "Horse Rotorvator",
892 )
893 .unwrap();
894 let rows = list_albums(
895 &db.conn,
896 &AlbumQuery {
897 favourites_of: Some(crate::db::queries::LOCAL_USER),
898 ..Default::default()
899 },
900 )
901 .unwrap();
902 assert_eq!(
903 rows.iter().map(|a| a.title.as_str()).collect::<Vec<_>>(),
904 ["Horse Rotorvator"]
905 );
906 }
907
908 #[test]
909 fn test_all_albums_and_tracks() {
910 use crate::db::queries::{sample_meta, tracks_for_album, upsert_track};
911
912 let db = test_db();
913 let mut m1 = sample_meta("Track1", "Artist1", "Album1");
914 m1.track_number = Some(1);
915 let mut m2 = sample_meta("Track2", "Artist1", "Album1");
916 m2.track_number = Some(2);
917 m2.path = Some("/music/Album1/Track2.flac".into());
918 upsert_track(&db.conn, &m1).unwrap();
919 upsert_track(&db.conn, &m2).unwrap();
920
921 let albums = all_albums(&db.conn).unwrap();
922 assert_eq!(albums.len(), 1);
923 assert_eq!(albums[0].title, "Album1");
924
925 let tracks = tracks_for_album(&db.conn, albums[0].id).unwrap();
926 assert_eq!(tracks.len(), 2);
927 assert_eq!(tracks[0].track_number, Some(1));
928 assert_eq!(tracks[1].track_number, Some(2));
929 }
930}