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 on_device: None,
22 })
23}
24
25#[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: 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 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
142pub(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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
205pub enum AlbumOrder {
206 #[default]
208 ArtistThenDate,
209 Date,
211 RecentlyAdded,
214 Title,
215 YearDesc,
217 Random(i64),
220 LastPlayed,
223 Id,
226 Downloaded,
229 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 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
260pub const LOSSLESS_CODECS: [&str; 5] = ["FLAC", "ALAC", "WAV", "AIFF", "PCM"];
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub struct OnDevice {
266 pub have: u32,
268 pub total: u32,
269}
270
271const 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#[derive(Debug, Clone, Copy, Default)]
282pub struct AlbumFilter<'a> {
283 pub lossless: bool,
285 pub codec: Option<&'a str>,
287 pub year_from: Option<i32>,
290 pub year_to: Option<i32>,
291 pub genre: Option<&'a str>,
293 pub on_device: bool,
296}
297
298impl AlbumFilter<'_> {
299 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#[derive(Debug, Clone, Copy, Default)]
349pub struct AlbumQuery<'a> {
350 pub ids: Option<&'a [i64]>,
352 pub artist_id: Option<i64>,
353 pub search: Option<&'a str>,
357 pub order: AlbumOrder,
358 pub favourites_of: Option<i64>,
360 pub played: Option<super::history::PlayedSince>,
362 pub filter: AlbumFilter<'a>,
363 pub limit: Option<u32>,
365 pub offset: u32,
366}
367
368pub fn list_albums(conn: &Connection, q: &AlbumQuery) -> Result<Vec<AlbumRow>, DbError> {
377 if let Some(ids) = fuzzy_fallback(conn, q)? {
378 return list_albums(
379 conn,
380 &AlbumQuery {
381 search: None,
382 ids: Some(&ids),
383 ..*q
384 },
385 );
386 }
387 let (body, mut params) = album_body(conn, q)?;
388 let counted = q.filter.on_device || q.order == AlbumOrder::Downloaded;
389 let mut sql = format!(
390 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
391 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
392 al.added_at{}
393 {body} ORDER BY ",
394 if counted { ON_DEVICE_COLUMNS } else { "" }
395 );
396 let order = match q.order {
397 AlbumOrder::LastPlayed if q.played.is_none() => AlbumOrder::RecentlyAdded,
398 order => order,
399 };
400 match (order, q.search, q.ids) {
401 (AlbumOrder::Random(seed), ..) => {
402 params.push(Box::new(seed));
403 sql.push_str(order.clause());
404 }
405 (AlbumOrder::Relevance, Some(query), _) => {
406 let binds = super::search::match_rank_binds(query);
407 params.extend(
408 binds
409 .iter()
410 .chain(&binds)
411 .map(|b| Box::new(b.clone()) as Box<dyn rusqlite::ToSql>),
412 );
413 sql.push_str(&format!(
414 "min({}, {}), {}",
415 super::search::match_rank("al.title"),
416 super::search::match_rank("a.name"),
417 AlbumOrder::ArtistThenDate.clause()
418 ));
419 }
420 (AlbumOrder::Relevance, None, Some(_)) => sql.push_str("r.key"),
421 _ => sql.push_str(order.clause()),
422 }
423
424 if let Some(limit) = q.limit {
425 params.push(Box::new(limit as i64));
426 params.push(Box::new(q.offset as i64));
427 sql.push_str(" LIMIT ? OFFSET ?");
428 }
429
430 let mut stmt = conn.prepare(&sql)?;
431 let rows = stmt
432 .query_map(rusqlite::params_from_iter(params.iter()), |row| {
433 let mut album = album_row(row)?;
434 if counted {
435 album.on_device = Some(OnDevice {
436 have: row.get(11)?,
437 total: row.get(12)?,
438 });
439 }
440 Ok(album)
441 })?
442 .collect::<Result<Vec<_>, _>>()?;
443 Ok(rows)
444}
445
446pub fn count_albums(conn: &Connection, q: &AlbumQuery) -> Result<u64, DbError> {
449 if let Some(ids) = fuzzy_fallback(conn, q)? {
450 return count_albums(
451 conn,
452 &AlbumQuery {
453 search: None,
454 ids: Some(&ids),
455 ..*q
456 },
457 );
458 }
459 let (body, params) = album_body(conn, q)?;
460 let n: i64 = conn.query_row(
461 &format!("SELECT COUNT(*) {body}"),
462 rusqlite::params_from_iter(params.iter()),
463 |r| r.get(0),
464 )?;
465 Ok(n as u64)
466}
467
468fn fuzzy_fallback(conn: &Connection, q: &AlbumQuery) -> Result<Option<Vec<i64>>, DbError> {
471 let Some(query) = q.search else {
472 return Ok(None);
473 };
474 let (body, params) = album_body(conn, q)?;
475 let found: bool = conn.query_row(
476 &format!("SELECT EXISTS (SELECT 1 {body})"),
477 rusqlite::params_from_iter(params.iter()),
478 |r| r.get(0),
479 )?;
480 if found {
481 return Ok(None);
482 }
483 let mut ids = super::search::fuzzy_ids(conn, super::search::CorpusKind::Album, query)?;
484 if let Some(only) = q.ids {
485 ids.retain(|id| only.contains(id));
486 }
487 Ok(Some(ids))
488}
489
490fn album_body(
493 conn: &Connection,
494 q: &AlbumQuery,
495) -> Result<(String, Vec<Box<dyn rusqlite::ToSql>>), DbError> {
496 let mut sql = String::from(
497 "FROM albums al
498 LEFT JOIN artists a ON al.artist_id = a.id",
499 );
500 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
501 if let Some(user) = q.favourites_of {
502 params.push(Box::new(super::auth::resolve_user(conn, user)?));
503 sql.push_str(" JOIN favourite_albums f ON f.album_id = al.id AND f.user_id = ?");
504 }
505 if let Some(played) = q.played {
506 params.push(Box::new(super::auth::resolve_user(conn, played.user)?));
507 params.push(Box::new(played.since));
508 sql.push_str(
509 " JOIN (SELECT t.album_id AS id, MAX(h.played_at) AS last, MAX(h.id) AS last_id
510 FROM play_history h JOIN tracks t ON t.id = h.track_id
511 WHERE h.user_id = ? AND h.played_at >= ? AND t.album_id IS NOT NULL
512 GROUP BY t.album_id) p ON p.id = al.id",
513 );
514 }
515 let mut wheres: Vec<String> = Vec::new();
516 match q.ids {
517 Some(ids) if q.order == AlbumOrder::Relevance && q.search.is_none() => {
520 params.push(Box::new(super::json_list(ids)));
521 sql.push_str(" JOIN json_each(?) r ON r.value = al.id");
522 }
523 Some(ids) => {
524 params.push(Box::new(super::json_list(ids)));
525 wheres.push("al.id IN (SELECT value FROM json_each(?))".into());
526 }
527 None => {}
528 }
529 if let Some(id) = q.artist_id {
530 params.push(Box::new(id));
531 wheres.push("al.artist_id = ?".into());
532 }
533 if let Some(query) = q.search {
534 let pattern = format!("%{}%", super::artists::escape_like(query));
535 params.push(Box::new(pattern.clone()));
538 params.push(Box::new(pattern));
539 wheres.push(
540 "(al.title LIKE ? COLLATE NOCASE ESCAPE '\\'
541 OR a.name LIKE ? COLLATE NOCASE ESCAPE '\\')"
542 .into(),
543 );
544 }
545 q.filter.push(&mut wheres, &mut params);
546 if !wheres.is_empty() {
547 sql.push_str(" WHERE ");
548 sql.push_str(&wheres.join(" AND "));
549 }
550 Ok((sql, params))
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub enum PlayedOrder {
556 Recent,
558 Frequent,
560}
561
562pub fn played_albums(
565 conn: &Connection,
566 user: i64,
567 order: PlayedOrder,
568 limit: u32,
569 offset: u32,
570) -> Result<Vec<AlbumRow>, DbError> {
571 let order = match order {
572 PlayedOrder::Recent => "p.last DESC",
573 PlayedOrder::Frequent => "p.plays DESC, p.last DESC",
574 };
575 let sql = format!(
576 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
577 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
578 al.added_at
579 FROM (SELECT t.album_id, MAX(h.played_at) AS last, COUNT(*) AS plays
580 FROM play_history h JOIN tracks t ON t.id = h.track_id
581 WHERE h.user_id = ?1 AND t.album_id IS NOT NULL
582 GROUP BY t.album_id) p
583 JOIN albums al ON al.id = p.album_id
584 LEFT JOIN artists a ON al.artist_id = a.id
585 ORDER BY {order}, al.id
586 LIMIT ?2 OFFSET ?3"
587 );
588 let mut stmt = conn.prepare(&sql)?;
589 let rows = stmt
590 .query_map(
591 params![super::auth::resolve_user(conn, user)?, limit, offset],
592 album_row,
593 )?
594 .collect::<Result<Vec<_>, _>>()?;
595 Ok(rows)
596}
597
598pub fn highest_rated_albums(
601 conn: &Connection,
602 user: i64,
603 limit: u32,
604 offset: u32,
605) -> Result<Vec<AlbumRow>, DbError> {
606 let mut stmt = conn.prepare_cached(
607 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
608 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
609 al.added_at
610 FROM album_ratings r
611 JOIN albums al ON al.id = r.album_id
612 LEFT JOIN artists a ON al.artist_id = a.id
613 WHERE r.user_id = ?1
614 ORDER BY r.rating DESC, r.changed_at DESC, al.id
615 LIMIT ?2 OFFSET ?3",
616 )?;
617 let rows = stmt
618 .query_map(
619 params![super::auth::resolve_user(conn, user)?, limit, offset],
620 album_row,
621 )?
622 .collect::<Result<Vec<_>, _>>()?;
623 Ok(rows)
624}
625
626pub fn genres(conn: &Connection, limit: u32) -> Result<Vec<String>, DbError> {
629 let mut stmt = conn.prepare(
630 "SELECT genre FROM tracks
631 WHERE genre IS NOT NULL AND TRIM(genre) != '' AND album_id IS NOT NULL
632 GROUP BY genre COLLATE NOCASE
633 ORDER BY COUNT(DISTINCT album_id) DESC, genre COLLATE NOCASE
634 LIMIT ?1",
635 )?;
636 let rows = stmt.query_map([limit], |r| r.get(0))?;
637 Ok(rows.collect::<Result<_, _>>()?)
638}
639
640pub fn album_codecs(conn: &Connection) -> Result<Vec<String>, DbError> {
642 let mut stmt = conn.prepare(
643 "SELECT codec FROM albums WHERE codec IS NOT NULL AND codec != ''
644 GROUP BY codec ORDER BY COUNT(*) DESC, codec",
645 )?;
646 let rows = stmt.query_map([], |r| r.get(0))?;
647 Ok(rows.collect::<Result<_, _>>()?)
648}
649
650pub fn albums_for_artist(conn: &Connection, artist_id: i64) -> Result<Vec<AlbumRow>, DbError> {
652 list_albums(
653 conn,
654 &AlbumQuery {
655 artist_id: Some(artist_id),
656 order: AlbumOrder::Date,
657 ..Default::default()
658 },
659 )
660}
661
662pub fn get_album(conn: &Connection, album_id: i64) -> Result<Option<AlbumRow>, DbError> {
664 let result = conn
665 .prepare_cached(
666 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
667 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
668 al.added_at
669 FROM albums al
670 LEFT JOIN artists a ON al.artist_id = a.id
671 WHERE al.id = ?1",
672 )
673 .and_then(|mut stmt| stmt.query_row(params![album_id], album_row))
674 .ok();
675 Ok(result)
676}
677
678pub fn album_date(conn: &Connection, album_id: i64) -> Result<Option<String>, DbError> {
680 Ok(conn
681 .query_row(
682 "SELECT date FROM albums WHERE id = ?1",
683 params![album_id],
684 |row| row.get(0),
685 )
686 .ok()
687 .flatten())
688}
689
690pub fn find_albums(conn: &Connection, query: &str) -> Result<Vec<AlbumRow>, DbError> {
692 list_albums(
693 conn,
694 &AlbumQuery {
695 search: Some(query),
696 ..Default::default()
697 },
698 )
699}
700
701pub fn all_albums(conn: &Connection) -> Result<Vec<AlbumRow>, DbError> {
703 list_albums(conn, &AlbumQuery::default())
704}
705
706pub fn enrich_remote_album(
716 conn: &Connection,
717 remote_id: &str,
718 mbid: Option<&str>,
719 sort_name: Option<&str>,
720 total_tracks: Option<i32>,
721 label: Option<&str>,
722) -> Result<(), DbError> {
723 conn.execute(
724 "UPDATE albums SET
725 mbid = COALESCE(mbid, ?2),
726 sort_name = COALESCE(sort_name, ?3),
727 total_tracks = COALESCE(total_tracks, ?4),
728 label = COALESCE(label, ?5)
729 WHERE remote_id = ?1",
730 params![remote_id, mbid, sort_name, total_tracks, label],
731 )?;
732 Ok(())
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use crate::db::connection::Database;
739 use crate::db::queries::get_or_create_artist;
740
741 fn test_db() -> Database {
742 let conn = rusqlite::Connection::open_in_memory().unwrap();
743 conn.pragma_update(None, "foreign_keys", "on").unwrap();
744 crate::db::schema::create_tables(&conn).unwrap();
745 Database { conn }
746 }
747
748 fn filter_library() -> Database {
751 let db = test_db();
752 for (title, artist, album, codec, date, genre) in [
753 ("A", "Rrose", "Early", "FLAC", "1995", "Techno"),
754 ("B", "Band", "Middle", "MP3", "2005", "Rock"),
755 ("C", "Rrose", "Late", "ALAC", "2010-03-01", "techno"),
756 ] {
757 let mut meta = crate::db::queries::sample_meta(title, artist, album);
758 meta.codec = Some(codec.into());
759 meta.date = Some(date.into());
760 meta.genre = Some(genre.into());
761 crate::db::queries::upsert_track(&db.conn, &meta).unwrap();
762 }
763 db
764 }
765
766 #[test]
769 fn on_device_counts_what_can_play_here() {
770 let db = test_db();
771 for (title, album) in [
772 ("X1", "Whole"),
773 ("X2", "Whole"),
774 ("Y1", "Half"),
775 ("Y2", "Half"),
776 ("Z1", "None"),
777 ] {
778 let mut meta = crate::db::queries::sample_meta(title, "Artist", album);
779 meta.path = Some(format!("/music/{title}.flac"));
780 crate::db::queries::upsert_track(&db.conn, &meta).unwrap();
781 }
782 db.conn
784 .execute("UPDATE tracks SET path = NULL", [])
785 .unwrap();
786 db.conn
787 .execute(
788 "UPDATE tracks SET cached_path = '/cache/' || title WHERE title IN ('X1', 'X2', 'Y1')",
789 [],
790 )
791 .unwrap();
792 let album = |title: &str| -> i64 {
793 db.conn
794 .query_row("SELECT id FROM albums WHERE title = ?1", [title], |r| {
795 r.get(0)
796 })
797 .unwrap()
798 };
799
800 let downloaded = list_albums(
801 &db.conn,
802 &AlbumQuery {
803 filter: AlbumFilter {
804 on_device: true,
805 ..Default::default()
806 },
807 order: AlbumOrder::Downloaded,
808 ..Default::default()
809 },
810 )
811 .unwrap();
812 assert_eq!(
813 downloaded
814 .iter()
815 .map(|a| (a.id, a.on_device))
816 .collect::<Vec<_>>(),
817 vec![
818 (album("Whole"), Some(OnDevice { have: 2, total: 2 })),
819 (album("Half"), Some(OnDevice { have: 1, total: 2 })),
820 ]
821 );
822 let offline = AlbumFilter {
823 on_device: true,
824 ..Default::default()
825 };
826 let mut listed = titles(&db, offline);
827 listed.sort();
828 assert_eq!(listed, vec!["Half", "Whole"]);
829 }
830
831 fn titles(db: &Database, filter: AlbumFilter) -> Vec<String> {
832 list_albums(
833 &db.conn,
834 &AlbumQuery {
835 filter,
836 order: AlbumOrder::Date,
837 ..Default::default()
838 },
839 )
840 .unwrap()
841 .into_iter()
842 .map(|a| a.title)
843 .collect()
844 }
845
846 #[test]
847 fn albums_filter_by_codec_year_and_genre_in_sql() {
848 let db = filter_library();
849 let lossless = AlbumFilter {
850 lossless: true,
851 ..Default::default()
852 };
853 assert_eq!(titles(&db, lossless), ["Early", "Late"]);
854 let mp3 = AlbumFilter {
855 codec: Some("mp3"),
856 ..Default::default()
857 };
858 assert_eq!(titles(&db, mp3), ["Middle"]);
859 let years = AlbumFilter {
860 year_from: Some(2000),
861 year_to: Some(2010),
862 ..Default::default()
863 };
864 assert_eq!(titles(&db, years), ["Middle", "Late"]);
865 let techno = AlbumFilter {
866 genre: Some("TECHNO"),
867 ..Default::default()
868 };
869 assert_eq!(titles(&db, techno), ["Early", "Late"]);
870 let all = AlbumFilter {
871 lossless: true,
872 year_from: Some(2000),
873 genre: Some("techno"),
874 ..Default::default()
875 };
876 assert_eq!(titles(&db, all), ["Late"]);
877 assert_eq!(
878 genres(&db.conn, 10).unwrap().len(),
879 2,
880 "techno counted once"
881 );
882 assert_eq!(album_codecs(&db.conn).unwrap().len(), 3);
883 }
884
885 #[test]
886 fn played_albums_follow_one_users_history() {
887 use crate::db::queries::{LOCAL_USER, SOURCE_LOCAL, record_play_at};
888 let db = filter_library();
889 let track = |title: &str| -> i64 {
890 db.conn
891 .query_row("SELECT id FROM tracks WHERE title = ?1", [title], |r| {
892 r.get(0)
893 })
894 .unwrap()
895 };
896 for (title, at) in [("A", 10), ("C", 30), ("A", 20)] {
897 record_play_at(&db.conn, LOCAL_USER, track(title), at, None, SOURCE_LOCAL).unwrap();
898 }
899 let played = |order, limit, offset| {
900 played_albums(&db.conn, LOCAL_USER, order, limit, offset)
901 .unwrap()
902 .into_iter()
903 .map(|a| a.title)
904 .collect::<Vec<_>>()
905 };
906 assert_eq!(played(PlayedOrder::Recent, 10, 0), ["Late", "Early"]);
907 assert_eq!(played(PlayedOrder::Frequent, 10, 0), ["Early", "Late"]);
908 assert_eq!(played(PlayedOrder::Frequent, 1, 1), ["Late"]);
909 }
910
911 #[test]
912 fn random_draws_narrow_in_sql() {
913 use crate::db::queries::{RandomFilter, random_tracks_where};
914 let db = filter_library();
915 let draw = |filter: RandomFilter, count| {
916 let mut titles: Vec<String> = random_tracks_where(&db.conn, count, &filter)
917 .unwrap()
918 .into_iter()
919 .map(|t| t.title)
920 .collect();
921 titles.sort();
922 titles
923 };
924 assert_eq!(draw(RandomFilter::default(), 10), ["A", "B", "C"]);
925 assert_eq!(draw(RandomFilter::default(), 2).len(), 2);
926 let techno = RandomFilter {
927 genre: Some("TECHNO"),
928 ..Default::default()
929 };
930 assert_eq!(draw(techno, 10), ["A", "C"]);
931 let nineties = RandomFilter {
932 year_from: Some(1990),
933 year_to: Some(1999),
934 ..Default::default()
935 };
936 assert_eq!(draw(nineties, 10), ["A"]);
937 }
938
939 #[test]
940 fn artists_sort_and_count_what_the_filter_leaves() {
941 use crate::db::queries::{ArtistOrder, ArtistQuery, list_artists};
942 let db = filter_library();
943 let names = |q: ArtistQuery| {
944 list_artists(&db.conn, &q)
945 .unwrap()
946 .into_iter()
947 .map(|a| (a.name, a.album_count))
948 .collect::<Vec<_>>()
949 };
950 assert_eq!(
951 names(ArtistQuery {
952 order: ArtistOrder::AlbumCount,
953 ..Default::default()
954 }),
955 [("Rrose".to_string(), 2), ("Band".to_string(), 1)]
956 );
957 assert_eq!(
958 names(ArtistQuery {
959 filter: AlbumFilter {
960 year_from: Some(2000),
961 ..Default::default()
962 },
963 ..Default::default()
964 }),
965 [("Band".to_string(), 1), ("Rrose".to_string(), 1)]
966 );
967 }
968
969 #[test]
970 fn test_album_create_and_dedup() {
971 let db = test_db();
972 let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
973 let a1 = get_or_create_album(
974 &db.conn,
975 "Music Has the Right to Children",
976 artist,
977 Some("1998"),
978 None,
979 None,
980 Some("FLAC"),
981 Some("Warp"),
982 None,
983 None,
984 )
985 .unwrap();
986 let a2 = get_or_create_album(
987 &db.conn,
988 "Music Has the Right to Children",
989 artist,
990 Some("1998"),
991 None,
992 None,
993 Some("FLAC"),
994 Some("Warp"),
995 None,
996 None,
997 )
998 .unwrap();
999 assert_eq!(a1, a2);
1000 }
1001
1002 #[test]
1003 fn test_album_codec_updated_on_format_upgrade() {
1004 let db = test_db();
1005 let artist = get_or_create_artist(&db.conn, "WAGDUG FUTURISTIC UNITY", None).unwrap();
1006
1007 let id1 = get_or_create_album(
1009 &db.conn,
1010 "HAKAI",
1011 artist,
1012 Some("2008"),
1013 None,
1014 None,
1015 Some("MP3"),
1016 None,
1017 None,
1018 None,
1019 )
1020 .unwrap();
1021
1022 let codec: Option<String> = db
1023 .conn
1024 .query_row(
1025 "SELECT codec FROM albums WHERE id = ?1",
1026 params![id1],
1027 |r| r.get(0),
1028 )
1029 .unwrap();
1030 assert_eq!(codec.as_deref(), Some("MP3"));
1031
1032 let id2 = get_or_create_album(
1034 &db.conn,
1035 "HAKAI",
1036 artist,
1037 Some("2008"),
1038 None,
1039 None,
1040 Some("FLAC"),
1041 None,
1042 None,
1043 None,
1044 )
1045 .unwrap();
1046
1047 assert_eq!(id1, id2, "should return the same album ID");
1048
1049 let codec: Option<String> = db
1050 .conn
1051 .query_row(
1052 "SELECT codec FROM albums WHERE id = ?1",
1053 params![id1],
1054 |r| r.get(0),
1055 )
1056 .unwrap();
1057 assert_eq!(
1058 codec.as_deref(),
1059 Some("FLAC"),
1060 "album codec should be updated after format upgrade"
1061 );
1062 }
1063
1064 #[test]
1065 fn test_album_codec_not_nulled_by_missing_codec() {
1066 let db = test_db();
1067 let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
1068
1069 let id = get_or_create_album(
1071 &db.conn,
1072 "MHTRTC",
1073 artist,
1074 Some("1998"),
1075 None,
1076 None,
1077 Some("FLAC"),
1078 Some("Warp"),
1079 None,
1080 None,
1081 )
1082 .unwrap();
1083
1084 get_or_create_album(
1086 &db.conn,
1087 "MHTRTC",
1088 artist,
1089 Some("1998"),
1090 None,
1091 None,
1092 None, None, None,
1095 None,
1096 )
1097 .unwrap();
1098
1099 let (codec, label): (Option<String>, Option<String>) = db
1100 .conn
1101 .query_row(
1102 "SELECT codec, label FROM albums WHERE id = ?1",
1103 params![id],
1104 |r| Ok((r.get(0)?, r.get(1)?)),
1105 )
1106 .unwrap();
1107 assert_eq!(
1108 codec.as_deref(),
1109 Some("FLAC"),
1110 "codec should not be nulled by a None value"
1111 );
1112 assert_eq!(
1113 label.as_deref(),
1114 Some("Warp"),
1115 "label should not be nulled by a None value"
1116 );
1117 }
1118
1119 fn stocked_db() -> Database {
1121 use crate::db::queries::{sample_meta, upsert_track};
1122 let db = test_db();
1123 for (i, (artist, album)) in [
1124 ("Autechre", "Amber"),
1125 ("Autechre", "Tri Repetae"),
1126 ("Autechre", "Confield"),
1127 ("Boards of Canada", "Geogaddi"),
1128 ("Boards of Canada", "Twoism"),
1129 ("Coil", "Horse Rotorvator"),
1130 ]
1131 .iter()
1132 .enumerate()
1133 {
1134 let mut m = sample_meta("t", artist, album);
1135 m.path = Some(format!("/music/{album}/t.flac"));
1136 m.date = Some(format!("199{i}"));
1137 upsert_track(&db.conn, &m).unwrap();
1138 }
1139 db
1140 }
1141
1142 #[test]
1143 fn paging_walks_the_listing_without_repeating() {
1144 let db = stocked_db();
1145 let page = |offset| {
1146 list_albums(
1147 &db.conn,
1148 &AlbumQuery {
1149 limit: Some(2),
1150 offset,
1151 ..Default::default()
1152 },
1153 )
1154 .unwrap()
1155 .into_iter()
1156 .map(|a| a.title)
1157 .collect::<Vec<_>>()
1158 };
1159 let whole = all_albums(&db.conn)
1160 .unwrap()
1161 .into_iter()
1162 .map(|a| a.title)
1163 .collect::<Vec<_>>();
1164 assert_eq!([page(0), page(2), page(4)].concat(), whole);
1165 assert!(
1166 page(6).is_empty(),
1167 "a page past the end is empty, not wrapped"
1168 );
1169 }
1170
1171 #[test]
1172 fn search_narrows_on_title_or_artist() {
1173 let db = stocked_db();
1174 let titles = |q| {
1175 find_albums(&db.conn, q)
1176 .unwrap()
1177 .into_iter()
1178 .map(|a| a.title)
1179 .collect::<Vec<_>>()
1180 };
1181 assert_eq!(titles("geogaddi"), ["Geogaddi"]);
1182 assert_eq!(titles("autechre").len(), 3, "matched on the artist name");
1183 }
1184
1185 #[test]
1188 fn a_seeded_shuffle_pages_consistently() {
1189 let db = stocked_db();
1190 let shuffled = |seed, limit, offset| {
1191 list_albums(
1192 &db.conn,
1193 &AlbumQuery {
1194 order: AlbumOrder::Random(seed),
1195 limit,
1196 offset,
1197 ..Default::default()
1198 },
1199 )
1200 .unwrap()
1201 .into_iter()
1202 .map(|a| a.id)
1203 .collect::<Vec<_>>()
1204 };
1205
1206 let whole = shuffled(42, None, 0);
1207 assert_eq!(
1208 [shuffled(42, Some(4), 0), shuffled(42, Some(4), 4)].concat(),
1209 whole
1210 );
1211 assert_ne!(shuffled(43, None, 0), whole, "a new seed is a new order");
1212 assert_eq!(whole.len(), 6, "a shuffle drops nothing");
1213 }
1214
1215 #[test]
1216 fn favourites_only_lists_what_was_hearted() {
1217 use crate::db::queries::toggle_favourite_album;
1218 let db = stocked_db();
1219 let album: i64 = db
1220 .conn
1221 .query_row(
1222 "SELECT id FROM albums WHERE title = 'Horse Rotorvator'",
1223 [],
1224 |r| r.get(0),
1225 )
1226 .unwrap();
1227 toggle_favourite_album(&db.conn, crate::db::queries::LOCAL_USER, album).unwrap();
1228 let rows = list_albums(
1229 &db.conn,
1230 &AlbumQuery {
1231 favourites_of: Some(crate::db::queries::LOCAL_USER),
1232 ..Default::default()
1233 },
1234 )
1235 .unwrap();
1236 assert_eq!(
1237 rows.iter().map(|a| a.title.as_str()).collect::<Vec<_>>(),
1238 ["Horse Rotorvator"]
1239 );
1240 }
1241
1242 #[test]
1243 fn test_all_albums_and_tracks() {
1244 use crate::db::queries::{sample_meta, tracks_for_album, upsert_track};
1245
1246 let db = test_db();
1247 let mut m1 = sample_meta("Track1", "Artist1", "Album1");
1248 m1.track_number = Some(1);
1249 let mut m2 = sample_meta("Track2", "Artist1", "Album1");
1250 m2.track_number = Some(2);
1251 m2.path = Some("/music/Album1/Track2.flac".into());
1252 upsert_track(&db.conn, &m1).unwrap();
1253 upsert_track(&db.conn, &m2).unwrap();
1254
1255 let albums = all_albums(&db.conn).unwrap();
1256 assert_eq!(albums.len(), 1);
1257 assert_eq!(albums[0].title, "Album1");
1258
1259 let tracks = tracks_for_album(&db.conn, albums[0].id).unwrap();
1260 assert_eq!(tracks.len(), 2);
1261 assert_eq!(tracks[0].track_number, Some(1));
1262 assert_eq!(tracks[1].track_number, Some(2));
1263 }
1264}