1use std::collections::{HashMap, HashSet};
8
9use rusqlite::{Connection, ToSql, params_from_iter};
10
11use crate::db::connection::DbError;
12
13use super::tracks::row_to_track_row;
14use super::{AlbumRow, TrackRow};
15
16const TRACK_COLUMNS: &str = "t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
17 t.disc, t.track_number, t.title, t.duration_ms, t.path,
18 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
19 t.genre, t.source, t.remote_id, t.cached_path";
20
21const TRACK_JOINS: &str = "FROM tracks t
22 LEFT JOIN artists a ON t.artist_id = a.id
23 LEFT JOIN albums al ON t.album_id = al.id
24 LEFT JOIN artists aa ON al.artist_id = aa.id";
25
26fn placeholders(n: usize) -> String {
28 let mut s = String::with_capacity(2 + n * 2);
29 s.push('(');
30 for i in 0..n {
31 if i > 0 {
32 s.push(',');
33 }
34 s.push('?');
35 }
36 s.push(')');
37 s
38}
39
40fn like_contains(needle: &str) -> String {
42 let mut escaped = String::with_capacity(needle.len() + 2);
43 escaped.push('%');
44 for c in needle.chars() {
45 if matches!(c, '\\' | '%' | '_') {
46 escaped.push('\\');
47 }
48 escaped.push(c);
49 }
50 escaped.push('%');
51 escaped
52}
53
54pub fn albums_for_artists(
60 conn: &Connection,
61 artist_ids: &[i64],
62) -> Result<HashMap<i64, Vec<AlbumRow>>, DbError> {
63 if artist_ids.is_empty() {
64 return Ok(HashMap::new());
65 }
66 let sql = format!(
67 "SELECT al.id, al.title, al.artist_id, a.name, al.date,
68 al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
69 al.added_at
70 FROM albums al
71 LEFT JOIN artists a ON al.artist_id = a.id
72 WHERE al.artist_id IN {}
73 ORDER BY al.date, al.title COLLATE LIBRARY",
74 placeholders(artist_ids.len())
75 );
76 let mut stmt = conn.prepare(&sql)?;
77 let rows = stmt.query_map(params_from_iter(artist_ids), |row| {
78 Ok(AlbumRow {
79 id: row.get(0)?,
80 title: row.get(1)?,
81 artist_id: row.get(2)?,
82 artist_name: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
83 date: row.get(4)?,
84 total_discs: row.get(5)?,
85 total_tracks: row.get(6)?,
86 codec: row.get(7)?,
87 label: row.get(8)?,
88 remote_id: row.get(9)?,
89 added_at: row.get(10)?,
90 })
91 })?;
92
93 let mut out: HashMap<i64, Vec<AlbumRow>> = HashMap::new();
94 for album in rows {
95 let album = album?;
96 out.entry(album.artist_id).or_default().push(album);
97 }
98 Ok(out)
99}
100
101pub fn tracks_for_albums(
103 conn: &Connection,
104 album_ids: &[i64],
105) -> Result<HashMap<i64, Vec<TrackRow>>, DbError> {
106 if album_ids.is_empty() {
107 return Ok(HashMap::new());
108 }
109 let sql = format!(
110 "SELECT {} {} WHERE t.album_id IN {} ORDER BY t.disc, t.track_number",
111 TRACK_COLUMNS,
112 TRACK_JOINS,
113 placeholders(album_ids.len())
114 );
115 let mut stmt = conn.prepare(&sql)?;
116 let rows = stmt.query_map(params_from_iter(album_ids), row_to_track_row)?;
117
118 let mut out: HashMap<i64, Vec<TrackRow>> = HashMap::new();
119 for track in rows {
120 let track = track?;
121 if let Some(aid) = track.album_id {
122 out.entry(aid).or_default().push(track);
123 }
124 }
125 Ok(out)
126}
127
128pub fn tracks_for_artists(
137 conn: &Connection,
138 artist_ids: &[i64],
139) -> Result<HashMap<i64, Vec<TrackRow>>, DbError> {
140 if artist_ids.is_empty() {
141 return Ok(HashMap::new());
142 }
143 let ph = placeholders(artist_ids.len());
144 let sql = format!(
145 "SELECT {}, al.artist_id {}
146 WHERE t.artist_id IN {}
147 OR t.album_id IN (SELECT id FROM albums WHERE artist_id IN {})
148 ORDER BY al.date, al.title, t.disc, t.track_number",
149 TRACK_COLUMNS, TRACK_JOINS, ph, ph
150 );
151 let mut stmt = conn.prepare(&sql)?;
152 let bind: Vec<i64> = artist_ids
153 .iter()
154 .chain(artist_ids.iter())
155 .copied()
156 .collect();
157 let rows = stmt.query_map(params_from_iter(&bind), |row| {
158 Ok((row_to_track_row(row)?, row.get::<_, Option<i64>>(20)?))
159 })?;
160
161 let wanted: HashSet<i64> = artist_ids.iter().copied().collect();
162 let mut out: HashMap<i64, Vec<TrackRow>> = HashMap::new();
163 for row in rows {
164 let (track, album_artist_id) = row?;
165 for key in [track.artist_id, album_artist_id].into_iter().flatten() {
166 if wanted.contains(&key)
167 && !out.entry(key).or_default().iter().any(|t| t.id == track.id)
168 {
169 out.entry(key).or_default().push(track.clone());
170 }
171 }
172 }
173 Ok(out)
174}
175
176#[derive(Debug, Clone, Copy, Default)]
181pub struct AlbumStats {
182 pub track_count: i64,
183 pub total_duration_ms: i64,
184}
185
186pub fn album_stats(
188 conn: &Connection,
189 album_ids: &[i64],
190) -> Result<HashMap<i64, AlbumStats>, DbError> {
191 if album_ids.is_empty() {
192 return Ok(HashMap::new());
193 }
194 let sql = format!(
195 "SELECT album_id, COUNT(*), COALESCE(SUM(duration_ms), 0)
196 FROM tracks WHERE album_id IN {} GROUP BY album_id",
197 placeholders(album_ids.len())
198 );
199 let mut stmt = conn.prepare(&sql)?;
200 let rows = stmt.query_map(params_from_iter(album_ids), |row| {
201 Ok((
202 row.get::<_, i64>(0)?,
203 AlbumStats {
204 track_count: row.get(1)?,
205 total_duration_ms: row.get(2)?,
206 },
207 ))
208 })?;
209 rows.collect::<Result<HashMap<_, _>, _>>()
210 .map_err(Into::into)
211}
212
213#[derive(Debug, Clone, Copy, Default)]
214pub struct ArtistStats {
215 pub album_count: i64,
216 pub track_count: i64,
217}
218
219pub fn artist_stats(
222 conn: &Connection,
223 artist_ids: &[i64],
224) -> Result<HashMap<i64, ArtistStats>, DbError> {
225 if artist_ids.is_empty() {
226 return Ok(HashMap::new());
227 }
228 let ph = placeholders(artist_ids.len());
229 let mut out: HashMap<i64, ArtistStats> = artist_ids
230 .iter()
231 .map(|&id| (id, ArtistStats::default()))
232 .collect();
233
234 let album_sql = format!(
235 "SELECT artist_id, COUNT(*) FROM albums WHERE artist_id IN {} GROUP BY artist_id",
236 ph
237 );
238 let mut stmt = conn.prepare(&album_sql)?;
239 let rows = stmt.query_map(params_from_iter(artist_ids), |row| {
240 Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
241 })?;
242 for row in rows {
243 let (id, count) = row?;
244 out.entry(id).or_default().album_count = count;
245 }
246
247 let track_sql = format!(
249 "SELECT k.id, COUNT(DISTINCT t.id)
250 FROM artists k
251 JOIN tracks t ON t.artist_id = k.id OR t.album_id IN (
252 SELECT al.id FROM albums al WHERE al.artist_id = k.id
253 )
254 WHERE k.id IN {}
255 GROUP BY k.id",
256 ph
257 );
258 let mut stmt = conn.prepare(&track_sql)?;
259 let rows = stmt.query_map(params_from_iter(artist_ids), |row| {
260 Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
261 })?;
262 for row in rows {
263 let (id, count) = row?;
264 out.entry(id).or_default().track_count = count;
265 }
266
267 Ok(out)
268}
269
270pub fn favourite_paths(conn: &Connection, paths: &[String]) -> Result<HashSet<String>, DbError> {
273 if paths.is_empty() {
274 return Ok(HashSet::new());
275 }
276 let sql = format!(
277 "SELECT track_path FROM favourites WHERE track_path IN {}",
278 placeholders(paths.len())
279 );
280 let mut stmt = conn.prepare(&sql)?;
281 let rows = stmt.query_map(params_from_iter(paths), |row| row.get::<_, String>(0))?;
282 rows.collect::<Result<HashSet<_>, _>>().map_err(Into::into)
283}
284
285pub fn album_ids_for_tracks(
290 conn: &Connection,
291 track_ids: &[i64],
292) -> Result<HashMap<i64, i64>, DbError> {
293 if track_ids.is_empty() {
294 return Ok(HashMap::new());
295 }
296 let sql = format!(
297 "SELECT id, album_id FROM tracks WHERE id IN {} AND album_id IS NOT NULL",
298 placeholders(track_ids.len())
299 );
300 let mut stmt = conn.prepare(&sql)?;
301 let rows = stmt.query_map(params_from_iter(track_ids), |row| {
302 Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
303 })?;
304 rows.collect::<Result<HashMap<_, _>, _>>()
305 .map_err(Into::into)
306}
307
308pub fn sources_for_tracks(
315 conn: &Connection,
316 track_ids: &[i64],
317) -> Result<HashMap<i64, (bool, bool)>, DbError> {
318 if track_ids.is_empty() {
319 return Ok(HashMap::new());
320 }
321 let sql = format!(
322 "SELECT id, remote_id IS NOT NULL, COALESCE(cached_path, path) IS NOT NULL \
323 FROM tracks WHERE id IN {}",
324 placeholders(track_ids.len())
325 );
326 let mut stmt = conn.prepare(&sql)?;
327 let rows = stmt.query_map(params_from_iter(track_ids), |row| {
328 Ok((
329 row.get::<_, i64>(0)?,
330 (row.get::<_, bool>(1)?, row.get::<_, bool>(2)?),
331 ))
332 })?;
333 rows.collect::<Result<HashMap<_, _>, _>>()
334 .map_err(Into::into)
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum TrackOrder {
343 ArtistAlbumDiscTrack,
344 Title,
345 Artist,
346 Album,
347 Duration,
348}
349
350#[derive(Debug, Clone, Default)]
351pub struct TrackFilter {
352 pub ids: Option<Vec<i64>>,
353 pub search: Option<String>,
355 pub album_id: Option<i64>,
356 pub artist_ids: Option<Vec<i64>>,
357 pub title: Option<String>,
358 pub artist_name: Option<String>,
359 pub album_title: Option<String>,
360 pub genre: Option<String>,
361 pub codec: Option<String>,
362 pub source: Option<String>,
363 pub year_start: Option<i32>,
364 pub year_end: Option<i32>,
365 pub min_sample_rate: Option<i32>,
366 pub min_bit_depth: Option<i32>,
367 pub channels: Option<i32>,
368 pub min_duration_ms: Option<i64>,
369 pub max_duration_ms: Option<i64>,
370 pub favourites_only: bool,
371}
372
373pub fn filter_tracks(
378 conn: &Connection,
379 filter: &TrackFilter,
380 order: TrackOrder,
381 descending: bool,
382 limit: u32,
383 offset: u32,
384) -> Result<Vec<TrackRow>, DbError> {
385 let mut clauses: Vec<String> = Vec::new();
386 let mut binds: Vec<Box<dyn ToSql>> = Vec::new();
387
388 if let Some(ids) = &filter.ids {
389 if ids.is_empty() {
390 return Ok(Vec::new());
391 }
392 clauses.push(format!("t.id IN {}", placeholders(ids.len())));
393 binds.extend(ids.iter().map(|&i| Box::new(i) as Box<dyn ToSql>));
394 }
395
396 if let Some(query) = &filter.search {
397 clauses.push("t.id IN (SELECT rowid FROM tracks_fts WHERE tracks_fts MATCH ?)".to_string());
398 binds.push(Box::new(super::search::sanitize_fts_query(query)));
399 }
400
401 if let Some(album_id) = filter.album_id {
402 clauses.push("t.album_id = ?".to_string());
403 binds.push(Box::new(album_id));
404 }
405
406 if let Some(ids) = &filter.artist_ids {
407 if ids.is_empty() {
408 return Ok(Vec::new());
409 }
410 let ph = placeholders(ids.len());
411 clauses.push(format!("(t.artist_id IN {ph} OR al.artist_id IN {ph})"));
412 for _ in 0..2 {
413 binds.extend(ids.iter().map(|&i| Box::new(i) as Box<dyn ToSql>));
414 }
415 }
416
417 if let Some(title) = &filter.title {
418 clauses.push("t.title LIKE ? ESCAPE '\\'".to_string());
419 binds.push(Box::new(like_contains(title)));
420 }
421
422 if let Some(name) = &filter.artist_name {
423 clauses.push("(a.name LIKE ? ESCAPE '\\' OR aa.name LIKE ? ESCAPE '\\')".to_string());
424 binds.push(Box::new(like_contains(name)));
425 binds.push(Box::new(like_contains(name)));
426 }
427
428 if let Some(title) = &filter.album_title {
429 clauses.push("al.title LIKE ? ESCAPE '\\'".to_string());
430 binds.push(Box::new(like_contains(title)));
431 }
432
433 if let Some(genre) = &filter.genre {
434 clauses.push("t.genre LIKE ? ESCAPE '\\'".to_string());
435 binds.push(Box::new(like_contains(genre)));
436 }
437
438 if let Some(codec) = &filter.codec {
439 clauses.push("t.codec LIKE ? ESCAPE '\\'".to_string());
440 binds.push(Box::new(like_contains(codec)));
441 }
442
443 if let Some(source) = &filter.source {
444 clauses.push("t.source = ?".to_string());
445 binds.push(Box::new(source.clone()));
446 }
447
448 if filter.year_start.is_some() || filter.year_end.is_some() {
449 clauses.push("substr(al.date, 1, 4) GLOB '[0-9][0-9][0-9][0-9]'".to_string());
452 if let Some(start) = filter.year_start {
453 clauses.push("CAST(substr(al.date, 1, 4) AS INTEGER) >= ?".to_string());
454 binds.push(Box::new(start));
455 }
456 if let Some(end) = filter.year_end {
457 clauses.push("CAST(substr(al.date, 1, 4) AS INTEGER) <= ?".to_string());
458 binds.push(Box::new(end));
459 }
460 }
461
462 for (column, value) in [
463 ("t.sample_rate >= ?", filter.min_sample_rate),
464 ("t.bit_depth >= ?", filter.min_bit_depth),
465 ("t.channels = ?", filter.channels),
466 ] {
467 if let Some(v) = value {
468 clauses.push(column.to_string());
469 binds.push(Box::new(v));
470 }
471 }
472
473 for (column, value) in [
474 ("t.duration_ms >= ?", filter.min_duration_ms),
475 ("t.duration_ms <= ?", filter.max_duration_ms),
476 ] {
477 if let Some(v) = value {
478 clauses.push(column.to_string());
479 binds.push(Box::new(v));
480 }
481 }
482
483 if filter.favourites_only {
484 clauses.push(
485 "EXISTS (SELECT 1 FROM favourites f
486 WHERE f.track_path = t.path OR f.track_path = t.cached_path)"
487 .to_string(),
488 );
489 }
490
491 let dir = if descending { "DESC" } else { "ASC" };
492 let order_by = match order {
493 TrackOrder::ArtistAlbumDiscTrack => format!(
494 "a.name {dir}, al.date {dir}, al.title {dir}, t.disc {dir}, t.track_number {dir}"
495 ),
496 TrackOrder::Title => format!("t.title {dir}"),
497 TrackOrder::Artist => {
498 format!("a.name {dir}, al.date {dir}, t.disc {dir}, t.track_number {dir}")
499 }
500 TrackOrder::Album => format!("al.title {dir}, t.disc {dir}, t.track_number {dir}"),
501 TrackOrder::Duration => format!("t.duration_ms {dir}"),
502 };
503
504 let where_clause = if clauses.is_empty() {
505 String::new()
506 } else {
507 format!("WHERE {}", clauses.join(" AND "))
508 };
509
510 let sql = format!(
511 "SELECT {TRACK_COLUMNS} {TRACK_JOINS} {where_clause}
512 ORDER BY {order_by}, t.id {dir} LIMIT ? OFFSET ?"
513 );
514
515 binds.push(Box::new(limit));
516 binds.push(Box::new(offset));
517
518 let mut stmt = conn.prepare(&sql)?;
519 let rows = stmt
520 .query_map(params_from_iter(binds.iter()), row_to_track_row)?
521 .collect::<Result<Vec<_>, _>>()?;
522 Ok(rows)
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use crate::db::connection::Database;
529 use crate::db::queries::{add_favourite, sample_meta, upsert_track};
530
531 fn test_db() -> Database {
532 let conn = Connection::open_in_memory().unwrap();
533 conn.pragma_update(None, "foreign_keys", "on").unwrap();
534 crate::db::schema::create_tables(&conn).unwrap();
535 Database { conn }
536 }
537
538 fn seed(db: &Database) {
539 for (title, artist, album) in [
540 ("Vordhosbn", "Aphex Twin", "Drukqs"),
541 ("Avril 14th", "Aphex Twin", "Drukqs"),
542 ("Roygbiv", "Boards of Canada", "MHTRTC"),
543 ] {
544 upsert_track(&db.conn, &sample_meta(title, artist, album)).unwrap();
545 }
546 }
547
548 #[test]
549 fn filter_pushes_limit_into_sql() {
550 let db = test_db();
551 seed(&db);
552 let page = filter_tracks(
553 &db.conn,
554 &TrackFilter::default(),
555 TrackOrder::Title,
556 false,
557 2,
558 0,
559 )
560 .unwrap();
561 assert_eq!(page.len(), 2);
562 let page2 = filter_tracks(
563 &db.conn,
564 &TrackFilter::default(),
565 TrackOrder::Title,
566 false,
567 2,
568 2,
569 )
570 .unwrap();
571 assert_eq!(page2.len(), 1);
572 }
573
574 #[test]
575 fn filter_matches_substrings_and_escapes_wildcards() {
576 let db = test_db();
577 seed(&db);
578 let filter = TrackFilter {
579 title: Some("vril".into()),
580 ..Default::default()
581 };
582 let hits = filter_tracks(&db.conn, &filter, TrackOrder::Title, false, 50, 0).unwrap();
583 assert_eq!(hits.len(), 1);
584 assert_eq!(hits[0].title, "Avril 14th");
585
586 let filter = TrackFilter {
588 title: Some("%".into()),
589 ..Default::default()
590 };
591 assert!(
592 filter_tracks(&db.conn, &filter, TrackOrder::Title, false, 50, 0)
593 .unwrap()
594 .is_empty()
595 );
596 }
597
598 #[test]
599 fn filter_composes_search_with_other_predicates() {
600 let db = test_db();
601 seed(&db);
602 let filter = TrackFilter {
603 search: Some("Aphex".into()),
604 title: Some("Vordhosbn".into()),
605 ..Default::default()
606 };
607 let hits = filter_tracks(&db.conn, &filter, TrackOrder::Title, false, 50, 0).unwrap();
608 assert_eq!(hits.len(), 1);
609 }
610
611 #[test]
612 fn stats_are_aggregated_not_materialised() {
613 let db = test_db();
614 seed(&db);
615 let album_id = db
616 .conn
617 .query_row("SELECT id FROM albums WHERE title = 'Drukqs'", [], |r| {
618 r.get::<_, i64>(0)
619 })
620 .unwrap();
621 let stats = album_stats(&db.conn, &[album_id]).unwrap();
622 assert_eq!(stats[&album_id].track_count, 2);
623 assert_eq!(stats[&album_id].total_duration_ms, 480_000);
624
625 let artist_id = db
626 .conn
627 .query_row(
628 "SELECT id FROM artists WHERE name = 'Aphex Twin'",
629 [],
630 |r| r.get::<_, i64>(0),
631 )
632 .unwrap();
633 let stats = artist_stats(&db.conn, &[artist_id]).unwrap();
634 assert_eq!(stats[&artist_id].album_count, 1);
635 assert_eq!(stats[&artist_id].track_count, 2);
636 }
637
638 #[test]
639 fn favourite_paths_only_returns_the_requested_paths() {
640 let db = test_db();
641 seed(&db);
642 add_favourite(
643 &db.conn,
644 std::path::Path::new("/music/Drukqs/Vordhosbn.flac"),
645 )
646 .unwrap();
647 let hits = favourite_paths(
648 &db.conn,
649 &[
650 "/music/Drukqs/Vordhosbn.flac".to_string(),
651 "/music/MHTRTC/Roygbiv.flac".to_string(),
652 ],
653 )
654 .unwrap();
655 assert_eq!(hits.len(), 1);
656 assert!(hits.contains("/music/Drukqs/Vordhosbn.flac"));
657 }
658
659 #[test]
660 fn batched_children_are_keyed_by_parent() {
661 let db = test_db();
662 seed(&db);
663 let album_ids: Vec<i64> = db
664 .conn
665 .prepare("SELECT id FROM albums ORDER BY id")
666 .unwrap()
667 .query_map([], |r| r.get(0))
668 .unwrap()
669 .collect::<Result<_, _>>()
670 .unwrap();
671 let map = tracks_for_albums(&db.conn, &album_ids).unwrap();
672 assert_eq!(map.values().map(Vec::len).sum::<usize>(), 3);
673
674 let artist_ids: Vec<i64> = db
675 .conn
676 .prepare("SELECT id FROM artists ORDER BY id")
677 .unwrap()
678 .query_map([], |r| r.get(0))
679 .unwrap()
680 .collect::<Result<_, _>>()
681 .unwrap();
682 let map = albums_for_artists(&db.conn, &artist_ids).unwrap();
683 assert_eq!(map.values().map(Vec::len).sum::<usize>(), 2);
684 let map = tracks_for_artists(&db.conn, &artist_ids).unwrap();
685 assert_eq!(map.values().map(Vec::len).sum::<usize>(), 3);
686 }
687}