1use rusqlite::{Connection, params};
2
3use crate::db::connection::DbError;
4
5use super::AlbumRow;
6
7fn album_row(row: &rusqlite::Row) -> rusqlite::Result<AlbumRow> {
9 Ok(AlbumRow {
10 id: row.get(0)?,
11 title: row.get(1)?,
12 artist_id: row.get(2)?,
13 artist_name: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
14 date: row.get(4)?,
15 total_discs: row.get(5)?,
16 total_tracks: row.get(6)?,
17 codec: row.get(7)?,
18 label: row.get(8)?,
19 remote_id: row.get(9)?,
20 added_at: row.get(10)?,
21 })
22}
23
24#[allow(clippy::too_many_arguments)]
26pub fn get_or_create_album(
27 conn: &Connection,
28 title: &str,
29 artist_id: i64,
30 date: Option<&str>,
31 total_discs: Option<i32>,
32 total_tracks: Option<i32>,
33 codec: Option<&str>,
34 label: Option<&str>,
35 remote_id: Option<&str>,
39 added_at: Option<&str>,
40) -> Result<i64, DbError> {
41 let existing: Option<i64> = conn
42 .query_row(
43 "SELECT id FROM albums WHERE title = ?1 AND artist_id = ?2",
44 params![title, artist_id],
45 |row| row.get(0),
46 )
47 .ok();
48
49 if let Some(id) = existing {
50 conn.execute(
53 "UPDATE albums SET
54 codec = COALESCE(?1, codec),
55 date = COALESCE(?2, date),
56 label = COALESCE(?3, label),
57 remote_id = COALESCE(?4, remote_id),
58 -- Earliest wins. A record acquired over months should date
59 -- from its first file, not its last, and filling only would
60 -- freeze whichever file the first scan happened to reach.
61 added_at = MIN(COALESCE(added_at, ?5), COALESCE(?5, added_at))
62 WHERE id = ?6",
63 params![codec, date, label, remote_id, added_at, id],
64 )?;
65 if let Some(rid) = remote_id {
66 super::adopt_uid(conn, super::UidKind::Album, id, rid)?;
67 }
68 return Ok(id);
69 }
70
71 conn.execute(
72 "INSERT INTO albums (title, artist_id, date, total_discs, total_tracks, codec, label, remote_id, added_at)
73 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
74 params![title, artist_id, date, total_discs, total_tracks, codec, label, remote_id, added_at],
75 )?;
76 let id = conn.last_insert_rowid();
77 if let Some(rid) = remote_id {
78 super::adopt_uid(conn, super::UidKind::Album, id, rid)?;
79 }
80 Ok(id)
81}
82
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
88pub enum AlbumOrder {
89 #[default]
91 ArtistThenDate,
92 Date,
94 RecentlyAdded,
97 Title,
98 YearDesc,
100 Random(i64),
103 Id,
106}
107
108impl AlbumOrder {
109 fn clause(self) -> &'static str {
110 match self {
111 Self::ArtistThenDate => "a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY",
112 Self::Date => "al.date, al.title COLLATE LIBRARY",
113 Self::RecentlyAdded => {
116 "COALESCE(al.added_at, '') DESC, a.name COLLATE LIBRARY, al.title COLLATE LIBRARY"
117 }
118 Self::Title => "al.title COLLATE LIBRARY, a.name COLLATE LIBRARY, al.date",
119 Self::YearDesc => {
120 "COALESCE(CAST(substr(al.date, 1, 4) AS INTEGER), 0) DESC, \
121 a.name COLLATE LIBRARY, al.title COLLATE LIBRARY"
122 }
123 Self::Random(_) => "koan_shuffle(al.id, ?)",
124 Self::Id => "al.id",
125 }
126 }
127}
128
129pub const LOSSLESS_CODECS: [&str; 5] = ["FLAC", "ALAC", "WAV", "AIFF", "PCM"];
131
132#[derive(Debug, Clone, Copy, Default)]
135pub struct AlbumFilter<'a> {
136 pub lossless: bool,
138 pub codec: Option<&'a str>,
140 pub year_from: Option<i32>,
143 pub year_to: Option<i32>,
144 pub genre: Option<&'a str>,
146}
147
148impl AlbumFilter<'_> {
149 pub(crate) fn push(
151 &self,
152 wheres: &mut Vec<String>,
153 params: &mut Vec<Box<dyn rusqlite::ToSql>>,
154 ) {
155 if self.lossless {
156 wheres.push(format!(
157 "al.codec IN ({})",
158 vec!["?"; LOSSLESS_CODECS.len()].join(",")
159 ));
160 params.extend(
161 LOSSLESS_CODECS
162 .iter()
163 .map(|c| Box::new(*c) as Box<dyn rusqlite::ToSql>),
164 );
165 }
166 if let Some(codec) = self.codec {
167 wheres.push("al.codec = ? COLLATE NOCASE".into());
168 params.push(Box::new(codec.to_owned()));
169 }
170 let year = "CAST(substr(al.date, 1, 4) AS INTEGER)";
171 if let Some(from) = self.year_from {
172 wheres.push(format!("{year} >= ?"));
173 params.push(Box::new(from));
174 }
175 if let Some(to) = self.year_to {
176 wheres.push(format!("al.date IS NOT NULL AND {year} <= ?"));
177 params.push(Box::new(to));
178 }
179 if let Some(genre) = self.genre {
180 wheres.push(
181 "EXISTS (SELECT 1 FROM tracks g WHERE g.album_id = al.id AND g.genre = ? COLLATE NOCASE)"
182 .into(),
183 );
184 params.push(Box::new(genre.to_owned()));
185 }
186 }
187}
188
189#[derive(Debug, Clone, Copy, Default)]
192pub struct AlbumQuery<'a> {
193 pub artist_id: Option<i64>,
194 pub search: Option<&'a str>,
196 pub order: AlbumOrder,
197 pub favourites_of: Option<i64>,
199 pub filter: AlbumFilter<'a>,
200 pub limit: Option<u32>,
202 pub offset: u32,
203}
204
205pub fn list_albums(conn: &Connection, q: &AlbumQuery) -> Result<Vec<AlbumRow>, DbError> {
214 let mut sql = String::from(
215 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
216 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
217 al.added_at
218 FROM albums al
219 LEFT JOIN artists a ON al.artist_id = a.id",
220 );
221 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
222 if let Some(user) = q.favourites_of {
223 params.push(Box::new(super::auth::resolve_user(conn, user)?));
224 sql.push_str(
225 " JOIN favourite_albums f
226 ON f.artist_name = a.name AND f.album_title = al.title AND f.user_id = ?",
227 );
228 }
229 let mut wheres: Vec<String> = Vec::new();
230 if let Some(id) = q.artist_id {
231 params.push(Box::new(id));
232 wheres.push("al.artist_id = ?".into());
233 }
234 if let Some(query) = q.search {
235 let pattern = format!("%{}%", super::artists::escape_like(query));
236 params.push(Box::new(pattern.clone()));
239 params.push(Box::new(pattern));
240 wheres.push(
241 "(al.title LIKE ? COLLATE NOCASE ESCAPE '\\'
242 OR a.name LIKE ? COLLATE NOCASE ESCAPE '\\')"
243 .into(),
244 );
245 }
246 q.filter.push(&mut wheres, &mut params);
247 if !wheres.is_empty() {
248 sql.push_str(" WHERE ");
249 sql.push_str(&wheres.join(" AND "));
250 }
251
252 sql.push_str(" ORDER BY ");
253 if let AlbumOrder::Random(seed) = q.order {
254 params.push(Box::new(seed));
255 }
256 sql.push_str(q.order.clause());
257
258 if let Some(limit) = q.limit {
259 params.push(Box::new(limit as i64));
260 params.push(Box::new(q.offset as i64));
261 sql.push_str(" LIMIT ? OFFSET ?");
262 }
263
264 let mut stmt = conn.prepare(&sql)?;
265 let rows = stmt
266 .query_map(rusqlite::params_from_iter(params.iter()), album_row)?
267 .collect::<Result<Vec<_>, _>>()?;
268 Ok(rows)
269}
270
271pub fn genres(conn: &Connection, limit: u32) -> Result<Vec<String>, DbError> {
274 let mut stmt = conn.prepare(
275 "SELECT genre FROM tracks
276 WHERE genre IS NOT NULL AND TRIM(genre) != '' AND album_id IS NOT NULL
277 GROUP BY genre COLLATE NOCASE
278 ORDER BY COUNT(DISTINCT album_id) DESC, genre COLLATE NOCASE
279 LIMIT ?1",
280 )?;
281 let rows = stmt.query_map([limit], |r| r.get(0))?;
282 Ok(rows.collect::<Result<_, _>>()?)
283}
284
285pub fn album_codecs(conn: &Connection) -> Result<Vec<String>, DbError> {
287 let mut stmt = conn.prepare(
288 "SELECT codec FROM albums WHERE codec IS NOT NULL AND codec != ''
289 GROUP BY codec ORDER BY COUNT(*) DESC, codec",
290 )?;
291 let rows = stmt.query_map([], |r| r.get(0))?;
292 Ok(rows.collect::<Result<_, _>>()?)
293}
294
295pub fn albums_for_artist(conn: &Connection, artist_id: i64) -> Result<Vec<AlbumRow>, DbError> {
297 list_albums(
298 conn,
299 &AlbumQuery {
300 artist_id: Some(artist_id),
301 order: AlbumOrder::Date,
302 ..Default::default()
303 },
304 )
305}
306
307pub fn get_album(conn: &Connection, album_id: i64) -> Result<Option<AlbumRow>, DbError> {
309 let result = conn
310 .query_row(
311 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
312 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
313 al.added_at
314 FROM albums al
315 LEFT JOIN artists a ON al.artist_id = a.id
316 WHERE al.id = ?1",
317 params![album_id],
318 album_row,
319 )
320 .ok();
321 Ok(result)
322}
323
324pub fn album_date(conn: &Connection, album_id: i64) -> Result<Option<String>, DbError> {
326 Ok(conn
327 .query_row(
328 "SELECT date FROM albums WHERE id = ?1",
329 params![album_id],
330 |row| row.get(0),
331 )
332 .ok()
333 .flatten())
334}
335
336pub fn find_albums(conn: &Connection, query: &str) -> Result<Vec<AlbumRow>, DbError> {
338 list_albums(
339 conn,
340 &AlbumQuery {
341 search: Some(query),
342 ..Default::default()
343 },
344 )
345}
346
347pub fn all_albums(conn: &Connection) -> Result<Vec<AlbumRow>, DbError> {
349 list_albums(conn, &AlbumQuery::default())
350}
351
352pub fn enrich_remote_album(
362 conn: &Connection,
363 remote_id: &str,
364 mbid: Option<&str>,
365 sort_name: Option<&str>,
366 total_tracks: Option<i32>,
367 label: Option<&str>,
368) -> Result<(), DbError> {
369 conn.execute(
370 "UPDATE albums SET
371 mbid = COALESCE(mbid, ?2),
372 sort_name = COALESCE(sort_name, ?3),
373 total_tracks = COALESCE(total_tracks, ?4),
374 label = COALESCE(label, ?5)
375 WHERE remote_id = ?1",
376 params![remote_id, mbid, sort_name, total_tracks, label],
377 )?;
378 Ok(())
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::db::connection::Database;
385 use crate::db::queries::get_or_create_artist;
386
387 fn test_db() -> Database {
388 let conn = rusqlite::Connection::open_in_memory().unwrap();
389 conn.pragma_update(None, "foreign_keys", "on").unwrap();
390 crate::db::schema::create_tables(&conn).unwrap();
391 Database { conn }
392 }
393
394 fn filter_library() -> Database {
397 let db = test_db();
398 for (title, artist, album, codec, date, genre) in [
399 ("A", "Rrose", "Early", "FLAC", "1995", "Techno"),
400 ("B", "Band", "Middle", "MP3", "2005", "Rock"),
401 ("C", "Rrose", "Late", "ALAC", "2010-03-01", "techno"),
402 ] {
403 let mut meta = crate::db::queries::sample_meta(title, artist, album);
404 meta.codec = Some(codec.into());
405 meta.date = Some(date.into());
406 meta.genre = Some(genre.into());
407 crate::db::queries::upsert_track(&db.conn, &meta).unwrap();
408 }
409 db
410 }
411
412 fn titles(db: &Database, filter: AlbumFilter) -> Vec<String> {
413 list_albums(
414 &db.conn,
415 &AlbumQuery {
416 filter,
417 order: AlbumOrder::Date,
418 ..Default::default()
419 },
420 )
421 .unwrap()
422 .into_iter()
423 .map(|a| a.title)
424 .collect()
425 }
426
427 #[test]
428 fn albums_filter_by_codec_year_and_genre_in_sql() {
429 let db = filter_library();
430 let lossless = AlbumFilter {
431 lossless: true,
432 ..Default::default()
433 };
434 assert_eq!(titles(&db, lossless), ["Early", "Late"]);
435 let mp3 = AlbumFilter {
436 codec: Some("mp3"),
437 ..Default::default()
438 };
439 assert_eq!(titles(&db, mp3), ["Middle"]);
440 let years = AlbumFilter {
441 year_from: Some(2000),
442 year_to: Some(2010),
443 ..Default::default()
444 };
445 assert_eq!(titles(&db, years), ["Middle", "Late"]);
446 let techno = AlbumFilter {
447 genre: Some("TECHNO"),
448 ..Default::default()
449 };
450 assert_eq!(titles(&db, techno), ["Early", "Late"]);
451 let all = AlbumFilter {
452 lossless: true,
453 year_from: Some(2000),
454 genre: Some("techno"),
455 ..Default::default()
456 };
457 assert_eq!(titles(&db, all), ["Late"]);
458 assert_eq!(
459 genres(&db.conn, 10).unwrap().len(),
460 2,
461 "techno counted once"
462 );
463 assert_eq!(album_codecs(&db.conn).unwrap().len(), 3);
464 }
465
466 #[test]
467 fn artists_sort_and_count_what_the_filter_leaves() {
468 use crate::db::queries::{ArtistOrder, ArtistQuery, list_artists};
469 let db = filter_library();
470 let names = |q: ArtistQuery| {
471 list_artists(&db.conn, &q)
472 .unwrap()
473 .into_iter()
474 .map(|a| (a.name, a.album_count))
475 .collect::<Vec<_>>()
476 };
477 assert_eq!(
478 names(ArtistQuery {
479 order: ArtistOrder::AlbumCount,
480 ..Default::default()
481 }),
482 [("Rrose".to_string(), 2), ("Band".to_string(), 1)]
483 );
484 assert_eq!(
485 names(ArtistQuery {
486 filter: AlbumFilter {
487 year_from: Some(2000),
488 ..Default::default()
489 },
490 ..Default::default()
491 }),
492 [("Band".to_string(), 1), ("Rrose".to_string(), 1)]
493 );
494 }
495
496 #[test]
497 fn test_album_create_and_dedup() {
498 let db = test_db();
499 let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
500 let a1 = get_or_create_album(
501 &db.conn,
502 "Music Has the Right to Children",
503 artist,
504 Some("1998"),
505 None,
506 None,
507 Some("FLAC"),
508 Some("Warp"),
509 None,
510 None,
511 )
512 .unwrap();
513 let a2 = get_or_create_album(
514 &db.conn,
515 "Music Has the Right to Children",
516 artist,
517 Some("1998"),
518 None,
519 None,
520 Some("FLAC"),
521 Some("Warp"),
522 None,
523 None,
524 )
525 .unwrap();
526 assert_eq!(a1, a2);
527 }
528
529 #[test]
530 fn test_album_codec_updated_on_format_upgrade() {
531 let db = test_db();
532 let artist = get_or_create_artist(&db.conn, "WAGDUG FUTURISTIC UNITY", None).unwrap();
533
534 let id1 = get_or_create_album(
536 &db.conn,
537 "HAKAI",
538 artist,
539 Some("2008"),
540 None,
541 None,
542 Some("MP3"),
543 None,
544 None,
545 None,
546 )
547 .unwrap();
548
549 let codec: Option<String> = db
550 .conn
551 .query_row(
552 "SELECT codec FROM albums WHERE id = ?1",
553 params![id1],
554 |r| r.get(0),
555 )
556 .unwrap();
557 assert_eq!(codec.as_deref(), Some("MP3"));
558
559 let id2 = get_or_create_album(
561 &db.conn,
562 "HAKAI",
563 artist,
564 Some("2008"),
565 None,
566 None,
567 Some("FLAC"),
568 None,
569 None,
570 None,
571 )
572 .unwrap();
573
574 assert_eq!(id1, id2, "should return the same album ID");
575
576 let codec: Option<String> = db
577 .conn
578 .query_row(
579 "SELECT codec FROM albums WHERE id = ?1",
580 params![id1],
581 |r| r.get(0),
582 )
583 .unwrap();
584 assert_eq!(
585 codec.as_deref(),
586 Some("FLAC"),
587 "album codec should be updated after format upgrade"
588 );
589 }
590
591 #[test]
592 fn test_album_codec_not_nulled_by_missing_codec() {
593 let db = test_db();
594 let artist = get_or_create_artist(&db.conn, "Boards of Canada", None).unwrap();
595
596 let id = get_or_create_album(
598 &db.conn,
599 "MHTRTC",
600 artist,
601 Some("1998"),
602 None,
603 None,
604 Some("FLAC"),
605 Some("Warp"),
606 None,
607 None,
608 )
609 .unwrap();
610
611 get_or_create_album(
613 &db.conn,
614 "MHTRTC",
615 artist,
616 Some("1998"),
617 None,
618 None,
619 None, None, None,
622 None,
623 )
624 .unwrap();
625
626 let (codec, label): (Option<String>, Option<String>) = db
627 .conn
628 .query_row(
629 "SELECT codec, label FROM albums WHERE id = ?1",
630 params![id],
631 |r| Ok((r.get(0)?, r.get(1)?)),
632 )
633 .unwrap();
634 assert_eq!(
635 codec.as_deref(),
636 Some("FLAC"),
637 "codec should not be nulled by a None value"
638 );
639 assert_eq!(
640 label.as_deref(),
641 Some("Warp"),
642 "label should not be nulled by a None value"
643 );
644 }
645
646 fn stocked_db() -> Database {
648 use crate::db::queries::{sample_meta, upsert_track};
649 let db = test_db();
650 for (i, (artist, album)) in [
651 ("Autechre", "Amber"),
652 ("Autechre", "Tri Repetae"),
653 ("Autechre", "Confield"),
654 ("Boards of Canada", "Geogaddi"),
655 ("Boards of Canada", "Twoism"),
656 ("Coil", "Horse Rotorvator"),
657 ]
658 .iter()
659 .enumerate()
660 {
661 let mut m = sample_meta("t", artist, album);
662 m.path = Some(format!("/music/{album}/t.flac"));
663 m.date = Some(format!("199{i}"));
664 upsert_track(&db.conn, &m).unwrap();
665 }
666 db
667 }
668
669 #[test]
670 fn paging_walks_the_listing_without_repeating() {
671 let db = stocked_db();
672 let page = |offset| {
673 list_albums(
674 &db.conn,
675 &AlbumQuery {
676 limit: Some(2),
677 offset,
678 ..Default::default()
679 },
680 )
681 .unwrap()
682 .into_iter()
683 .map(|a| a.title)
684 .collect::<Vec<_>>()
685 };
686 let whole = all_albums(&db.conn)
687 .unwrap()
688 .into_iter()
689 .map(|a| a.title)
690 .collect::<Vec<_>>();
691 assert_eq!([page(0), page(2), page(4)].concat(), whole);
692 assert!(
693 page(6).is_empty(),
694 "a page past the end is empty, not wrapped"
695 );
696 }
697
698 #[test]
699 fn search_narrows_on_title_or_artist() {
700 let db = stocked_db();
701 let titles = |q| {
702 find_albums(&db.conn, q)
703 .unwrap()
704 .into_iter()
705 .map(|a| a.title)
706 .collect::<Vec<_>>()
707 };
708 assert_eq!(titles("geogaddi"), ["Geogaddi"]);
709 assert_eq!(titles("autechre").len(), 3, "matched on the artist name");
710 }
711
712 #[test]
715 fn a_seeded_shuffle_pages_consistently() {
716 let db = stocked_db();
717 let shuffled = |seed, limit, offset| {
718 list_albums(
719 &db.conn,
720 &AlbumQuery {
721 order: AlbumOrder::Random(seed),
722 limit,
723 offset,
724 ..Default::default()
725 },
726 )
727 .unwrap()
728 .into_iter()
729 .map(|a| a.id)
730 .collect::<Vec<_>>()
731 };
732
733 let whole = shuffled(42, None, 0);
734 assert_eq!(
735 [shuffled(42, Some(4), 0), shuffled(42, Some(4), 4)].concat(),
736 whole
737 );
738 assert_ne!(shuffled(43, None, 0), whole, "a new seed is a new order");
739 assert_eq!(whole.len(), 6, "a shuffle drops nothing");
740 }
741
742 #[test]
743 fn favourites_only_lists_what_was_hearted() {
744 use crate::db::queries::toggle_favourite_album;
745 let db = stocked_db();
746 toggle_favourite_album(
747 &db.conn,
748 crate::db::queries::LOCAL_USER,
749 "Coil",
750 "Horse Rotorvator",
751 )
752 .unwrap();
753 let rows = list_albums(
754 &db.conn,
755 &AlbumQuery {
756 favourites_of: Some(crate::db::queries::LOCAL_USER),
757 ..Default::default()
758 },
759 )
760 .unwrap();
761 assert_eq!(
762 rows.iter().map(|a| a.title.as_str()).collect::<Vec<_>>(),
763 ["Horse Rotorvator"]
764 );
765 }
766
767 #[test]
768 fn test_all_albums_and_tracks() {
769 use crate::db::queries::{sample_meta, tracks_for_album, upsert_track};
770
771 let db = test_db();
772 let mut m1 = sample_meta("Track1", "Artist1", "Album1");
773 m1.track_number = Some(1);
774 let mut m2 = sample_meta("Track2", "Artist1", "Album1");
775 m2.track_number = Some(2);
776 m2.path = Some("/music/Album1/Track2.flac".into());
777 upsert_track(&db.conn, &m1).unwrap();
778 upsert_track(&db.conn, &m2).unwrap();
779
780 let albums = all_albums(&db.conn).unwrap();
781 assert_eq!(albums.len(), 1);
782 assert_eq!(albums[0].title, "Album1");
783
784 let tracks = tracks_for_album(&db.conn, albums[0].id).unwrap();
785 assert_eq!(tracks.len(), 2);
786 assert_eq!(tracks[0].track_number, Some(1));
787 assert_eq!(tracks[1].track_number, Some(2));
788 }
789}