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>,
354 pub features_of: Option<i64>,
358 pub search: Option<&'a str>,
362 pub order: AlbumOrder,
363 pub favourites_of: Option<i64>,
365 pub played: Option<super::history::PlayedSince>,
367 pub filter: AlbumFilter<'a>,
368 pub limit: Option<u32>,
370 pub offset: u32,
371}
372
373pub 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
451pub 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
473fn 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
495fn 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569pub enum PlayedOrder {
570 Recent,
572 Frequent,
574}
575
576pub 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
612pub 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
640pub 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
654pub 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
664pub 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
676pub 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
692pub 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
704pub 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
715pub fn all_albums(conn: &Connection) -> Result<Vec<AlbumRow>, DbError> {
717 list_albums(conn, &AlbumQuery::default())
718}
719
720pub 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 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 #[test]
783 fn own_records_and_features_split() {
784 let db = test_db();
785 for (title, artist, album_artist, album, date) in [
786 ("Own A", "Ayla", "Ayla", "First", "2001"),
789 ("Own B", "Bryn", "Ayla", "First", "2001"),
790 ("Own C", "Ayla", "Ayla", "Second", "2008"),
791 ("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 ("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 #[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 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 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 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 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 get_or_create_album(
1171 &db.conn,
1172 "MHTRTC",
1173 artist,
1174 Some("1998"),
1175 None,
1176 None,
1177 None, None, 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 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 #[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}