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