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 let credited: Vec<i64> = conn
177 .prepare_cached("SELECT artist_id FROM album_credits WHERE album_id = ?1")?
178 .query_map(params![gone], |r| r.get(0))?
179 .collect::<rusqlite::Result<_>>()?;
180 for sql in [
183 "DELETE FROM favourite_albums WHERE album_id = ?1",
184 "DELETE FROM album_ratings WHERE album_id = ?1",
185 "DELETE FROM album_credits WHERE album_id = ?1",
186 "DELETE FROM albums WHERE id = ?1",
187 ] {
188 conn.execute(sql, params![gone])?;
189 }
190 super::sources::recount_album_credits(conn, keep)?;
192 for artist in credited {
193 super::tracks::prune_if_empty(conn, None, Some(artist))?;
194 }
195 if let Some(uid) = &server_uid {
196 conn.execute(
197 "UPDATE albums SET uid = ?1 WHERE id = ?2",
198 params![uid, keep],
199 )?;
200 }
201 Ok(())
202 })();
203 match merged {
204 Ok(()) => conn.execute_batch("RELEASE merge_albums"),
205 Err(e) => {
206 conn.execute_batch("ROLLBACK TO merge_albums; RELEASE merge_albums")?;
207 Err(e)
208 }
209 }
210}
211
212#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
217pub enum AlbumOrder {
218 #[default]
220 ArtistThenDate,
221 Date,
223 RecentlyAdded,
226 Title,
227 YearDesc,
229 Random(i64),
232 LastPlayed,
235 Id,
238 Downloaded,
241 Relevance,
245}
246
247impl AlbumOrder {
248 fn clause(self) -> &'static str {
249 match self {
250 Self::ArtistThenDate => "a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY",
251 Self::Date => "al.date, al.title COLLATE LIBRARY",
252 Self::RecentlyAdded | Self::Relevance => {
255 "COALESCE(al.added_at, '') DESC, a.name COLLATE LIBRARY, al.title COLLATE LIBRARY"
256 }
257 Self::Title => "al.title COLLATE LIBRARY, a.name COLLATE LIBRARY, al.date",
258 Self::YearDesc => {
259 "COALESCE(CAST(substr(al.date, 1, 4) AS INTEGER), 0) DESC, \
260 a.name COLLATE LIBRARY, al.title COLLATE LIBRARY"
261 }
262 Self::Random(_) => "koan_shuffle(al.id, ?)",
263 Self::LastPlayed => "p.last DESC, p.last_id DESC",
264 Self::Id => "al.id",
265 Self::Downloaded => {
266 "have = total DESC, CAST(have AS REAL) / total DESC, fetched DESC, al.id"
267 }
268 }
269 }
270}
271
272pub const LOSSLESS_CODECS: [&str; 5] = ["FLAC", "ALAC", "WAV", "AIFF", "PCM"];
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub struct OnDevice {
278 pub have: u32,
280 pub total: u32,
281}
282
283const ON_DEVICE_COLUMNS: &str = ",
287 (SELECT SUM(COALESCE(d.cached_path, d.path) IS NOT NULL) FROM tracks d WHERE d.album_id = al.id) AS have,
288 (SELECT COUNT(*) FROM tracks d WHERE d.album_id = al.id) AS total,
289 (SELECT MAX(COALESCE(d.cache_download_date, 0)) FROM tracks d WHERE d.album_id = al.id) AS fetched";
290
291#[derive(Debug, Clone, Copy, Default)]
294pub struct AlbumFilter<'a> {
295 pub lossless: bool,
297 pub codec: Option<&'a str>,
299 pub year_from: Option<i32>,
302 pub year_to: Option<i32>,
303 pub genre: Option<&'a str>,
305 pub on_device: bool,
308}
309
310impl AlbumFilter<'_> {
311 pub(crate) fn push(
313 &self,
314 wheres: &mut Vec<String>,
315 params: &mut Vec<Box<dyn rusqlite::ToSql>>,
316 ) {
317 if self.lossless {
318 wheres.push(format!(
319 "al.codec IN ({})",
320 vec!["?"; LOSSLESS_CODECS.len()].join(",")
321 ));
322 params.extend(
323 LOSSLESS_CODECS
324 .iter()
325 .map(|c| Box::new(*c) as Box<dyn rusqlite::ToSql>),
326 );
327 }
328 if let Some(codec) = self.codec {
329 wheres.push("al.codec = ? COLLATE NOCASE".into());
330 params.push(Box::new(codec.to_owned()));
331 }
332 let year = "CAST(substr(al.date, 1, 4) AS INTEGER)";
333 if let Some(from) = self.year_from {
334 wheres.push(format!("{year} >= ?"));
335 params.push(Box::new(from));
336 }
337 if let Some(to) = self.year_to {
338 wheres.push(format!("al.date IS NOT NULL AND {year} <= ?"));
339 params.push(Box::new(to));
340 }
341 if self.on_device {
342 wheres.push(
343 "EXISTS (SELECT 1 FROM tracks d WHERE d.album_id = al.id
344 AND COALESCE(d.cached_path, d.path) IS NOT NULL)"
345 .into(),
346 );
347 }
348 if let Some(genre) = self.genre {
349 wheres.push(
350 "EXISTS (SELECT 1 FROM tracks g WHERE g.album_id = al.id AND g.genre = ? COLLATE NOCASE)"
351 .into(),
352 );
353 params.push(Box::new(genre.to_owned()));
354 }
355 }
356}
357
358#[derive(Debug, Clone, Copy, Default)]
361pub struct AlbumQuery<'a> {
362 pub ids: Option<&'a [i64]>,
364 pub artist_id: Option<i64>,
366 pub features_of: Option<i64>,
370 pub search: Option<&'a str>,
374 pub order: AlbumOrder,
375 pub favourites_of: Option<i64>,
377 pub played: Option<super::history::PlayedSince>,
379 pub filter: AlbumFilter<'a>,
380 pub limit: Option<u32>,
382 pub offset: u32,
383}
384
385pub fn list_albums(conn: &Connection, q: &AlbumQuery) -> Result<Vec<AlbumRow>, DbError> {
394 if let Some(ids) = fuzzy_fallback(conn, q)? {
395 return list_albums(
396 conn,
397 &AlbumQuery {
398 search: None,
399 ids: Some(&ids),
400 ..*q
401 },
402 );
403 }
404 let (body, mut params) = album_body(conn, q)?;
405 let counted = q.filter.on_device || q.order == AlbumOrder::Downloaded;
406 let mut sql = format!(
407 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
408 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
409 al.added_at{}
410 {body} ORDER BY ",
411 if counted { ON_DEVICE_COLUMNS } else { "" }
412 );
413 let order = match q.order {
414 AlbumOrder::LastPlayed if q.played.is_none() => AlbumOrder::RecentlyAdded,
415 order => order,
416 };
417 match (order, q.search, q.ids) {
418 (AlbumOrder::Random(seed), ..) => {
419 params.push(Box::new(seed));
420 sql.push_str(order.clause());
421 }
422 (AlbumOrder::Relevance, Some(query), _) => {
423 let binds = super::search::match_rank_binds(query);
424 params.extend(
425 binds
426 .iter()
427 .chain(&binds)
428 .map(|b| Box::new(b.clone()) as Box<dyn rusqlite::ToSql>),
429 );
430 sql.push_str(&format!(
431 "min({}, {}), {}",
432 super::search::match_rank("al.title"),
433 super::search::match_rank("a.name"),
434 AlbumOrder::ArtistThenDate.clause()
435 ));
436 }
437 (AlbumOrder::Relevance, None, Some(_)) => sql.push_str("r.key"),
438 _ => sql.push_str(order.clause()),
439 }
440
441 if let Some(limit) = q.limit {
442 params.push(Box::new(limit as i64));
443 params.push(Box::new(q.offset as i64));
444 sql.push_str(" LIMIT ? OFFSET ?");
445 }
446
447 let mut stmt = conn.prepare(&sql)?;
448 let rows = stmt
449 .query_map(rusqlite::params_from_iter(params.iter()), |row| {
450 let mut album = album_row(row)?;
451 if counted {
452 album.on_device = Some(OnDevice {
453 have: row.get(11)?,
454 total: row.get(12)?,
455 });
456 }
457 Ok(album)
458 })?
459 .collect::<Result<Vec<_>, _>>()?;
460 Ok(rows)
461}
462
463pub fn count_albums(conn: &Connection, q: &AlbumQuery) -> Result<u64, DbError> {
466 if let Some(ids) = fuzzy_fallback(conn, q)? {
467 return count_albums(
468 conn,
469 &AlbumQuery {
470 search: None,
471 ids: Some(&ids),
472 ..*q
473 },
474 );
475 }
476 let (body, params) = album_body(conn, q)?;
477 let n: i64 = conn.query_row(
478 &format!("SELECT COUNT(*) {body}"),
479 rusqlite::params_from_iter(params.iter()),
480 |r| r.get(0),
481 )?;
482 Ok(n as u64)
483}
484
485fn fuzzy_fallback(conn: &Connection, q: &AlbumQuery) -> Result<Option<Vec<i64>>, DbError> {
488 let Some(query) = q.search else {
489 return Ok(None);
490 };
491 let (body, params) = album_body(conn, q)?;
492 let found: bool = conn.query_row(
493 &format!("SELECT EXISTS (SELECT 1 {body})"),
494 rusqlite::params_from_iter(params.iter()),
495 |r| r.get(0),
496 )?;
497 if found {
498 return Ok(None);
499 }
500 let mut ids = super::search::fuzzy_ids(conn, super::search::CorpusKind::Album, query)?;
501 if let Some(only) = q.ids {
502 ids.retain(|id| only.contains(id));
503 }
504 Ok(Some(ids))
505}
506
507fn album_body(
510 conn: &Connection,
511 q: &AlbumQuery,
512) -> Result<(String, Vec<Box<dyn rusqlite::ToSql>>), DbError> {
513 let mut sql = String::from(
514 "FROM albums al
515 LEFT JOIN artists a ON al.artist_id = a.id",
516 );
517 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
518 if let Some(user) = q.favourites_of {
519 params.push(Box::new(super::auth::resolve_user(conn, user)?));
520 sql.push_str(" JOIN favourite_albums f ON f.album_id = al.id AND f.user_id = ?");
521 }
522 if let Some(played) = q.played {
523 params.push(Box::new(super::auth::resolve_user(conn, played.user)?));
524 params.push(Box::new(played.since));
525 sql.push_str(
526 " JOIN (SELECT t.album_id AS id, MAX(h.played_at) AS last, MAX(h.id) AS last_id
527 FROM play_history h JOIN tracks t ON t.id = h.track_id
528 WHERE h.user_id = ? AND h.played_at >= ? AND t.album_id IS NOT NULL
529 GROUP BY t.album_id) p ON p.id = al.id",
530 );
531 }
532 let mut wheres: Vec<String> = Vec::new();
533 match q.ids {
534 Some(ids) if q.order == AlbumOrder::Relevance && q.search.is_none() => {
537 params.push(Box::new(super::json_list(ids)));
538 sql.push_str(" JOIN json_each(?) r ON r.value = al.id");
539 }
540 Some(ids) => {
541 params.push(Box::new(super::json_list(ids)));
542 wheres.push("al.id IN (SELECT value FROM json_each(?))".into());
543 }
544 None => {}
545 }
546 if let Some(id) = q.artist_id {
547 params.push(Box::new(id));
548 wheres
549 .push("al.id IN (SELECT album_id FROM album_artist_links WHERE artist_id = ?)".into());
550 }
551 if let Some(id) = q.features_of {
552 params.push(Box::new(id));
553 params.push(Box::new(id));
554 params.push(Box::new(id));
555 wheres.push(
556 "al.id IN (SELECT album_id FROM tracks WHERE artist_id = ?
557 UNION SELECT t.album_id FROM track_credits c
558 JOIN tracks t ON t.id = c.track_id WHERE c.artist_id = ?)
559 AND al.id NOT IN (SELECT album_id FROM album_artist_links WHERE artist_id = ?)"
560 .into(),
561 );
562 }
563 if let Some(query) = q.search {
564 let pattern = format!("%{}%", super::artists::escape_like(query));
565 params.push(Box::new(pattern.clone()));
568 params.push(Box::new(pattern));
569 wheres.push(
570 "(al.title LIKE ? COLLATE NOCASE ESCAPE '\\'
571 OR a.name LIKE ? COLLATE NOCASE ESCAPE '\\')"
572 .into(),
573 );
574 }
575 q.filter.push(&mut wheres, &mut params);
576 if !wheres.is_empty() {
577 sql.push_str(" WHERE ");
578 sql.push_str(&wheres.join(" AND "));
579 }
580 Ok((sql, params))
581}
582
583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
585pub enum PlayedOrder {
586 Recent,
588 Frequent,
590}
591
592pub fn played_albums(
595 conn: &Connection,
596 user: i64,
597 order: PlayedOrder,
598 limit: u32,
599 offset: u32,
600) -> Result<Vec<AlbumRow>, DbError> {
601 let order = match order {
602 PlayedOrder::Recent => "p.last DESC",
603 PlayedOrder::Frequent => "p.plays DESC, p.last DESC",
604 };
605 let sql = format!(
606 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
607 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
608 al.added_at
609 FROM (SELECT t.album_id, MAX(h.played_at) AS last, COUNT(*) AS plays
610 FROM play_history h JOIN tracks t ON t.id = h.track_id
611 WHERE h.user_id = ?1 AND t.album_id IS NOT NULL
612 GROUP BY t.album_id) p
613 JOIN albums al ON al.id = p.album_id
614 LEFT JOIN artists a ON al.artist_id = a.id
615 ORDER BY {order}, al.id
616 LIMIT ?2 OFFSET ?3"
617 );
618 let mut stmt = conn.prepare(&sql)?;
619 let rows = stmt
620 .query_map(
621 params![super::auth::resolve_user(conn, user)?, limit, offset],
622 album_row,
623 )?
624 .collect::<Result<Vec<_>, _>>()?;
625 Ok(rows)
626}
627
628pub fn highest_rated_albums(
631 conn: &Connection,
632 user: i64,
633 limit: u32,
634 offset: u32,
635) -> Result<Vec<AlbumRow>, DbError> {
636 let mut stmt = conn.prepare_cached(
637 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
638 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
639 al.added_at
640 FROM album_ratings r
641 JOIN albums al ON al.id = r.album_id
642 LEFT JOIN artists a ON al.artist_id = a.id
643 WHERE r.user_id = ?1
644 ORDER BY r.rating DESC, r.changed_at DESC, al.id
645 LIMIT ?2 OFFSET ?3",
646 )?;
647 let rows = stmt
648 .query_map(
649 params![super::auth::resolve_user(conn, user)?, limit, offset],
650 album_row,
651 )?
652 .collect::<Result<Vec<_>, _>>()?;
653 Ok(rows)
654}
655
656pub fn genres(conn: &Connection, limit: u32) -> Result<Vec<String>, DbError> {
659 let mut stmt = conn.prepare(
660 "SELECT genre FROM tracks
661 WHERE genre IS NOT NULL AND TRIM(genre) != '' AND album_id IS NOT NULL
662 GROUP BY genre COLLATE NOCASE
663 ORDER BY COUNT(DISTINCT album_id) DESC, genre COLLATE NOCASE
664 LIMIT ?1",
665 )?;
666 let rows = stmt.query_map([limit], |r| r.get(0))?;
667 Ok(rows.collect::<Result<_, _>>()?)
668}
669
670pub fn album_codecs(conn: &Connection) -> Result<Vec<String>, DbError> {
672 let mut stmt = conn.prepare(
673 "SELECT codec FROM albums WHERE codec IS NOT NULL AND codec != ''
674 GROUP BY codec ORDER BY COUNT(*) DESC, codec",
675 )?;
676 let rows = stmt.query_map([], |r| r.get(0))?;
677 Ok(rows.collect::<Result<_, _>>()?)
678}
679
680pub fn albums_for_artist(conn: &Connection, artist_id: i64) -> Result<Vec<AlbumRow>, DbError> {
682 list_albums(
683 conn,
684 &AlbumQuery {
685 artist_id: Some(artist_id),
686 order: AlbumOrder::Date,
687 ..Default::default()
688 },
689 )
690}
691
692pub fn get_album(conn: &Connection, album_id: i64) -> Result<Option<AlbumRow>, DbError> {
694 let result = conn
695 .prepare_cached(
696 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
697 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
698 al.added_at
699 FROM albums al
700 LEFT JOIN artists a ON al.artist_id = a.id
701 WHERE al.id = ?1",
702 )
703 .and_then(|mut stmt| stmt.query_row(params![album_id], album_row))
704 .ok();
705 Ok(result)
706}
707
708pub fn album_date(conn: &Connection, album_id: i64) -> Result<Option<String>, DbError> {
710 Ok(conn
711 .query_row(
712 "SELECT date FROM albums WHERE id = ?1",
713 params![album_id],
714 |row| row.get(0),
715 )
716 .ok()
717 .flatten())
718}
719
720pub fn find_albums(conn: &Connection, query: &str) -> Result<Vec<AlbumRow>, DbError> {
722 list_albums(
723 conn,
724 &AlbumQuery {
725 search: Some(query),
726 ..Default::default()
727 },
728 )
729}
730
731pub fn all_albums(conn: &Connection) -> Result<Vec<AlbumRow>, DbError> {
733 list_albums(conn, &AlbumQuery::default())
734}
735
736pub fn enrich_remote_album(
746 conn: &Connection,
747 remote_id: &str,
748 mbid: Option<&str>,
749 sort_name: Option<&str>,
750 total_tracks: Option<i32>,
751 label: Option<&str>,
752) -> Result<(), DbError> {
753 conn.execute(
754 "UPDATE albums SET
755 mbid = COALESCE(mbid, ?2),
756 sort_name = COALESCE(sort_name, ?3),
757 total_tracks = COALESCE(total_tracks, ?4),
758 label = COALESCE(label, ?5)
759 WHERE remote_id = ?1",
760 params![remote_id, mbid, sort_name, total_tracks, label],
761 )?;
762 Ok(())
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768 use crate::db::connection::Database;
769 use crate::db::queries::get_or_create_artist;
770
771 fn test_db() -> Database {
772 let conn = rusqlite::Connection::open_in_memory().unwrap();
773 conn.pragma_update(None, "foreign_keys", "on").unwrap();
774 crate::db::schema::create_tables(&conn).unwrap();
775 Database { conn }
776 }
777
778 fn filter_library() -> Database {
781 let db = test_db();
782 for (title, artist, album, codec, date, genre) in [
783 ("A", "Rrose", "Early", "FLAC", "1995", "Techno"),
784 ("B", "Band", "Middle", "MP3", "2005", "Rock"),
785 ("C", "Rrose", "Late", "ALAC", "2010-03-01", "techno"),
786 ] {
787 let mut meta = crate::db::queries::sample_meta(title, artist, album);
788 meta.codec = Some(codec.into());
789 meta.date = Some(date.into());
790 meta.genre = Some(genre.into());
791 crate::db::queries::upsert_track(&db.conn, &meta).unwrap();
792 }
793 db
794 }
795
796 #[test]
799 fn own_records_and_features_split() {
800 let db = test_db();
801 for (title, artist, album_artist, album, date) in [
802 ("Own A", "Ayla", "Ayla", "First", "2001"),
805 ("Own B", "Bryn", "Ayla", "First", "2001"),
806 ("Own C", "Ayla", "Ayla", "Second", "2008"),
807 ("Comp A", "Ayla", "Various Artists", "Comp", "2004"),
809 ("Comp B", "Cato", "Various Artists", "Comp", "2004"),
810 ("Guest", "Ayla", "Bryn", "Tide", "2012"),
811 ("Bryn", "Bryn", "Bryn", "Tide", "2012"),
812 ("Comp C", "Cato", "Various Artists", "Later Comp", "2015"),
814 ] {
815 let mut meta = crate::db::queries::sample_meta(title, artist, album);
816 meta.album_artist = Some(album_artist.into());
817 meta.date = Some(date.into());
818 crate::db::queries::upsert_track(&db.conn, &meta).unwrap();
819 }
820 let artist = |name| get_or_create_artist(&db.conn, name, None).unwrap();
821 let titles = |q: AlbumQuery| -> Vec<String> {
822 list_albums(&db.conn, &q)
823 .unwrap()
824 .into_iter()
825 .map(|a| a.title)
826 .collect()
827 };
828 let page = |id| {
829 (
830 titles(AlbumQuery {
831 artist_id: Some(id),
832 order: AlbumOrder::YearDesc,
833 ..Default::default()
834 }),
835 titles(AlbumQuery {
836 features_of: Some(id),
837 order: AlbumOrder::YearDesc,
838 ..Default::default()
839 }),
840 )
841 };
842
843 assert_eq!(
844 page(artist("Ayla")),
845 (
846 vec!["Second".into(), "First".into()],
847 vec!["Tide".into(), "Comp".into()]
848 )
849 );
850 assert_eq!(
851 page(artist("Bryn")),
852 (vec!["Tide".into()], vec!["First".into()])
853 );
854 assert_eq!(
855 page(artist("Cato")),
856 (vec![], vec!["Later Comp".into(), "Comp".into()]),
857 "only on compilations: nothing of their own"
858 );
859
860 let features = AlbumQuery {
861 features_of: Some(artist("Ayla")),
862 ..Default::default()
863 };
864 assert_eq!(count_albums(&db.conn, &features).unwrap(), 2);
865 }
866
867 #[test]
870 fn on_device_counts_what_can_play_here() {
871 let db = test_db();
872 for (title, album) in [
873 ("X1", "Whole"),
874 ("X2", "Whole"),
875 ("Y1", "Half"),
876 ("Y2", "Half"),
877 ("Z1", "None"),
878 ] {
879 let mut meta = crate::db::queries::sample_meta(title, "Artist", album);
880 meta.path = Some(format!("/music/{title}.flac"));
881 crate::db::queries::upsert_track(&db.conn, &meta).unwrap();
882 }
883 db.conn
885 .execute("UPDATE tracks SET path = NULL", [])
886 .unwrap();
887 db.conn
888 .execute(
889 "UPDATE tracks SET cached_path = '/cache/' || title WHERE title IN ('X1', 'X2', 'Y1')",
890 [],
891 )
892 .unwrap();
893 let album = |title: &str| -> i64 {
894 db.conn
895 .query_row("SELECT id FROM albums WHERE title = ?1", [title], |r| {
896 r.get(0)
897 })
898 .unwrap()
899 };
900
901 let downloaded = list_albums(
902 &db.conn,
903 &AlbumQuery {
904 filter: AlbumFilter {
905 on_device: true,
906 ..Default::default()
907 },
908 order: AlbumOrder::Downloaded,
909 ..Default::default()
910 },
911 )
912 .unwrap();
913 assert_eq!(
914 downloaded
915 .iter()
916 .map(|a| (a.id, a.on_device))
917 .collect::<Vec<_>>(),
918 vec![
919 (album("Whole"), Some(OnDevice { have: 2, total: 2 })),
920 (album("Half"), Some(OnDevice { have: 1, total: 2 })),
921 ]
922 );
923 let offline = AlbumFilter {
924 on_device: true,
925 ..Default::default()
926 };
927 let mut listed = titles(&db, offline);
928 listed.sort();
929 assert_eq!(listed, vec!["Half", "Whole"]);
930 }
931
932 fn titles(db: &Database, filter: AlbumFilter) -> Vec<String> {
933 list_albums(
934 &db.conn,
935 &AlbumQuery {
936 filter,
937 order: AlbumOrder::Date,
938 ..Default::default()
939 },
940 )
941 .unwrap()
942 .into_iter()
943 .map(|a| a.title)
944 .collect()
945 }
946
947 #[test]
948 fn albums_filter_by_codec_year_and_genre_in_sql() {
949 let db = filter_library();
950 let lossless = AlbumFilter {
951 lossless: true,
952 ..Default::default()
953 };
954 assert_eq!(titles(&db, lossless), ["Early", "Late"]);
955 let mp3 = AlbumFilter {
956 codec: Some("mp3"),
957 ..Default::default()
958 };
959 assert_eq!(titles(&db, mp3), ["Middle"]);
960 let years = AlbumFilter {
961 year_from: Some(2000),
962 year_to: Some(2010),
963 ..Default::default()
964 };
965 assert_eq!(titles(&db, years), ["Middle", "Late"]);
966 let techno = AlbumFilter {
967 genre: Some("TECHNO"),
968 ..Default::default()
969 };
970 assert_eq!(titles(&db, techno), ["Early", "Late"]);
971 let all = AlbumFilter {
972 lossless: true,
973 year_from: Some(2000),
974 genre: Some("techno"),
975 ..Default::default()
976 };
977 assert_eq!(titles(&db, all), ["Late"]);
978 assert_eq!(
979 genres(&db.conn, 10).unwrap().len(),
980 2,
981 "techno counted once"
982 );
983 assert_eq!(album_codecs(&db.conn).unwrap().len(), 3);
984 }
985
986 #[test]
987 fn played_albums_follow_one_users_history() {
988 use crate::db::queries::{LOCAL_USER, SOURCE_LOCAL, record_play_at};
989 let db = filter_library();
990 let track = |title: &str| -> i64 {
991 db.conn
992 .query_row("SELECT id FROM tracks WHERE title = ?1", [title], |r| {
993 r.get(0)
994 })
995 .unwrap()
996 };
997 for (title, at) in [("A", 10), ("C", 30), ("A", 20)] {
998 record_play_at(&db.conn, LOCAL_USER, track(title), at, None, SOURCE_LOCAL).unwrap();
999 }
1000 let played = |order, limit, offset| {
1001 played_albums(&db.conn, LOCAL_USER, order, limit, offset)
1002 .unwrap()
1003 .into_iter()
1004 .map(|a| a.title)
1005 .collect::<Vec<_>>()
1006 };
1007 assert_eq!(played(PlayedOrder::Recent, 10, 0), ["Late", "Early"]);
1008 assert_eq!(played(PlayedOrder::Frequent, 10, 0), ["Early", "Late"]);
1009 assert_eq!(played(PlayedOrder::Frequent, 1, 1), ["Late"]);
1010 }
1011
1012 #[test]
1013 fn random_draws_narrow_in_sql() {
1014 use crate::db::queries::{RandomFilter, random_tracks_where};
1015 let db = filter_library();
1016 let draw = |filter: RandomFilter, count| {
1017 let mut titles: Vec<String> = random_tracks_where(&db.conn, count, &filter)
1018 .unwrap()
1019 .into_iter()
1020 .map(|t| t.title)
1021 .collect();
1022 titles.sort();
1023 titles
1024 };
1025 assert_eq!(draw(RandomFilter::default(), 10), ["A", "B", "C"]);
1026 assert_eq!(draw(RandomFilter::default(), 2).len(), 2);
1027 let techno = RandomFilter {
1028 genre: Some("TECHNO"),
1029 ..Default::default()
1030 };
1031 assert_eq!(draw(techno, 10), ["A", "C"]);
1032 let nineties = RandomFilter {
1033 year_from: Some(1990),
1034 year_to: Some(1999),
1035 ..Default::default()
1036 };
1037 assert_eq!(draw(nineties, 10), ["A"]);
1038 }
1039
1040 #[test]
1041 fn artists_sort_and_count_what_the_filter_leaves() {
1042 use crate::db::queries::{ArtistOrder, ArtistQuery, list_artists};
1043 let db = filter_library();
1044 let names = |q: ArtistQuery| {
1045 list_artists(&db.conn, &q)
1046 .unwrap()
1047 .into_iter()
1048 .map(|a| (a.name, a.album_count))
1049 .collect::<Vec<_>>()
1050 };
1051 assert_eq!(
1052 names(ArtistQuery {
1053 order: ArtistOrder::AlbumCount,
1054 ..Default::default()
1055 }),
1056 [("Rrose".to_string(), 2), ("Band".to_string(), 1)]
1057 );
1058 assert_eq!(
1059 names(ArtistQuery {
1060 filter: AlbumFilter {
1061 year_from: Some(2000),
1062 ..Default::default()
1063 },
1064 ..Default::default()
1065 }),
1066 [("Band".to_string(), 1), ("Rrose".to_string(), 1)]
1067 );
1068 }
1069
1070 #[test]
1071 fn test_album_create_and_dedup() {
1072 let db = test_db();
1073 let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
1074 let a1 = get_or_create_album(
1075 &db.conn,
1076 "Music Has the Right to Children",
1077 artist,
1078 Some("1998"),
1079 None,
1080 None,
1081 Some("FLAC"),
1082 Some("Warp"),
1083 None,
1084 None,
1085 )
1086 .unwrap();
1087 let a2 = get_or_create_album(
1088 &db.conn,
1089 "Music Has the Right to Children",
1090 artist,
1091 Some("1998"),
1092 None,
1093 None,
1094 Some("FLAC"),
1095 Some("Warp"),
1096 None,
1097 None,
1098 )
1099 .unwrap();
1100 assert_eq!(a1, a2);
1101 }
1102
1103 #[test]
1104 fn test_album_codec_updated_on_format_upgrade() {
1105 let db = test_db();
1106 let artist = get_or_create_artist(&db.conn, "WAGDUG FUTURISTIC UNITY", None).unwrap();
1107
1108 let id1 = get_or_create_album(
1110 &db.conn,
1111 "HAKAI",
1112 artist,
1113 Some("2008"),
1114 None,
1115 None,
1116 Some("MP3"),
1117 None,
1118 None,
1119 None,
1120 )
1121 .unwrap();
1122
1123 let codec: Option<String> = db
1124 .conn
1125 .query_row(
1126 "SELECT codec FROM albums WHERE id = ?1",
1127 params![id1],
1128 |r| r.get(0),
1129 )
1130 .unwrap();
1131 assert_eq!(codec.as_deref(), Some("MP3"));
1132
1133 let id2 = get_or_create_album(
1135 &db.conn,
1136 "HAKAI",
1137 artist,
1138 Some("2008"),
1139 None,
1140 None,
1141 Some("FLAC"),
1142 None,
1143 None,
1144 None,
1145 )
1146 .unwrap();
1147
1148 assert_eq!(id1, id2, "should return the same album ID");
1149
1150 let codec: Option<String> = db
1151 .conn
1152 .query_row(
1153 "SELECT codec FROM albums WHERE id = ?1",
1154 params![id1],
1155 |r| r.get(0),
1156 )
1157 .unwrap();
1158 assert_eq!(
1159 codec.as_deref(),
1160 Some("FLAC"),
1161 "album codec should be updated after format upgrade"
1162 );
1163 }
1164
1165 #[test]
1166 fn test_album_codec_not_nulled_by_missing_codec() {
1167 let db = test_db();
1168 let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
1169
1170 let id = get_or_create_album(
1172 &db.conn,
1173 "MHTRTC",
1174 artist,
1175 Some("1998"),
1176 None,
1177 None,
1178 Some("FLAC"),
1179 Some("Warp"),
1180 None,
1181 None,
1182 )
1183 .unwrap();
1184
1185 get_or_create_album(
1187 &db.conn,
1188 "MHTRTC",
1189 artist,
1190 Some("1998"),
1191 None,
1192 None,
1193 None, None, None,
1196 None,
1197 )
1198 .unwrap();
1199
1200 let (codec, label): (Option<String>, Option<String>) = db
1201 .conn
1202 .query_row(
1203 "SELECT codec, label FROM albums WHERE id = ?1",
1204 params![id],
1205 |r| Ok((r.get(0)?, r.get(1)?)),
1206 )
1207 .unwrap();
1208 assert_eq!(
1209 codec.as_deref(),
1210 Some("FLAC"),
1211 "codec should not be nulled by a None value"
1212 );
1213 assert_eq!(
1214 label.as_deref(),
1215 Some("Warp"),
1216 "label should not be nulled by a None value"
1217 );
1218 }
1219
1220 fn stocked_db() -> Database {
1222 use crate::db::queries::{sample_meta, upsert_track};
1223 let db = test_db();
1224 for (i, (artist, album)) in [
1225 ("Autechre", "Amber"),
1226 ("Autechre", "Tri Repetae"),
1227 ("Autechre", "Confield"),
1228 ("Boards of Canada", "Geogaddi"),
1229 ("Boards of Canada", "Twoism"),
1230 ("Coil", "Horse Rotorvator"),
1231 ]
1232 .iter()
1233 .enumerate()
1234 {
1235 let mut m = sample_meta("t", artist, album);
1236 m.path = Some(format!("/music/{album}/t.flac"));
1237 m.date = Some(format!("199{i}"));
1238 upsert_track(&db.conn, &m).unwrap();
1239 }
1240 db
1241 }
1242
1243 #[test]
1244 fn paging_walks_the_listing_without_repeating() {
1245 let db = stocked_db();
1246 let page = |offset| {
1247 list_albums(
1248 &db.conn,
1249 &AlbumQuery {
1250 limit: Some(2),
1251 offset,
1252 ..Default::default()
1253 },
1254 )
1255 .unwrap()
1256 .into_iter()
1257 .map(|a| a.title)
1258 .collect::<Vec<_>>()
1259 };
1260 let whole = all_albums(&db.conn)
1261 .unwrap()
1262 .into_iter()
1263 .map(|a| a.title)
1264 .collect::<Vec<_>>();
1265 assert_eq!([page(0), page(2), page(4)].concat(), whole);
1266 assert!(
1267 page(6).is_empty(),
1268 "a page past the end is empty, not wrapped"
1269 );
1270 }
1271
1272 #[test]
1273 fn search_narrows_on_title_or_artist() {
1274 let db = stocked_db();
1275 let titles = |q| {
1276 find_albums(&db.conn, q)
1277 .unwrap()
1278 .into_iter()
1279 .map(|a| a.title)
1280 .collect::<Vec<_>>()
1281 };
1282 assert_eq!(titles("geogaddi"), ["Geogaddi"]);
1283 assert_eq!(titles("autechre").len(), 3, "matched on the artist name");
1284 }
1285
1286 #[test]
1289 fn a_seeded_shuffle_pages_consistently() {
1290 let db = stocked_db();
1291 let shuffled = |seed, limit, offset| {
1292 list_albums(
1293 &db.conn,
1294 &AlbumQuery {
1295 order: AlbumOrder::Random(seed),
1296 limit,
1297 offset,
1298 ..Default::default()
1299 },
1300 )
1301 .unwrap()
1302 .into_iter()
1303 .map(|a| a.id)
1304 .collect::<Vec<_>>()
1305 };
1306
1307 let whole = shuffled(42, None, 0);
1308 assert_eq!(
1309 [shuffled(42, Some(4), 0), shuffled(42, Some(4), 4)].concat(),
1310 whole
1311 );
1312 assert_ne!(shuffled(43, None, 0), whole, "a new seed is a new order");
1313 assert_eq!(whole.len(), 6, "a shuffle drops nothing");
1314 }
1315
1316 #[test]
1317 fn favourites_only_lists_what_was_hearted() {
1318 use crate::db::queries::toggle_favourite_album;
1319 let db = stocked_db();
1320 let album: i64 = db
1321 .conn
1322 .query_row(
1323 "SELECT id FROM albums WHERE title = 'Horse Rotorvator'",
1324 [],
1325 |r| r.get(0),
1326 )
1327 .unwrap();
1328 toggle_favourite_album(&db.conn, crate::db::queries::LOCAL_USER, album).unwrap();
1329 let rows = list_albums(
1330 &db.conn,
1331 &AlbumQuery {
1332 favourites_of: Some(crate::db::queries::LOCAL_USER),
1333 ..Default::default()
1334 },
1335 )
1336 .unwrap();
1337 assert_eq!(
1338 rows.iter().map(|a| a.title.as_str()).collect::<Vec<_>>(),
1339 ["Horse Rotorvator"]
1340 );
1341 }
1342
1343 #[test]
1344 fn test_all_albums_and_tracks() {
1345 use crate::db::queries::{sample_meta, tracks_for_album, upsert_track};
1346
1347 let db = test_db();
1348 let mut m1 = sample_meta("Track1", "Artist1", "Album1");
1349 m1.track_number = Some(1);
1350 let mut m2 = sample_meta("Track2", "Artist1", "Album1");
1351 m2.track_number = Some(2);
1352 m2.path = Some("/music/Album1/Track2.flac".into());
1353 upsert_track(&db.conn, &m1).unwrap();
1354 upsert_track(&db.conn, &m2).unwrap();
1355
1356 let albums = all_albums(&db.conn).unwrap();
1357 assert_eq!(albums.len(), 1);
1358 assert_eq!(albums[0].title, "Album1");
1359
1360 let tracks = tracks_for_album(&db.conn, albums[0].id).unwrap();
1361 assert_eq!(tracks.len(), 2);
1362 assert_eq!(tracks[0].track_number, Some(1));
1363 assert_eq!(tracks[1].track_number, Some(2));
1364 }
1365}