1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use rusqlite::{Connection, OptionalExtension, params};
5
6use crate::db::connection::DbError;
7
8use super::sources;
9use super::{PlaybackSource, TrackMeta, TrackRow};
10
11pub(crate) fn row_to_track_row(row: &rusqlite::Row) -> rusqlite::Result<TrackRow> {
17 row_to_track_row_at(row, 0)
18}
19
20pub(crate) fn row_to_track_row_at(row: &rusqlite::Row, at: usize) -> rusqlite::Result<TrackRow> {
23 let artist_name: String = row.get::<_, Option<String>>(at + 3)?.unwrap_or_default();
24 Ok(TrackRow {
25 id: row.get(at)?,
26 album_id: row.get(at + 1)?,
27 artist_id: row.get(at + 2)?,
28 artist_name: artist_name.clone(),
29 album_artist_name: row.get::<_, Option<String>>(at + 4)?.unwrap_or(artist_name),
30 album_title: row.get::<_, Option<String>>(at + 5)?.unwrap_or_default(),
31 disc: row.get(at + 6)?,
32 track_number: row.get(at + 7)?,
33 title: row.get(at + 8)?,
34 duration_ms: row.get(at + 9)?,
35 path: row.get(at + 10)?,
36 codec: row.get(at + 11)?,
37 sample_rate: row.get(at + 12)?,
38 bit_depth: row.get(at + 13)?,
39 channels: row.get(at + 14)?,
40 bitrate: row.get(at + 15)?,
41 genre: row.get(at + 16)?,
42 source: row.get(at + 17)?,
43 remote_id: row.get(at + 18)?,
44 cached_path: row.get(at + 19)?,
45 })
46}
47
48pub fn upsert_track(conn: &Connection, meta: &TrackMeta) -> Result<i64, DbError> {
59 upsert_track_status(conn, meta).map(|(id, _)| id)
60}
61
62pub fn upsert_track_status(conn: &Connection, meta: &TrackMeta) -> Result<(i64, bool), DbError> {
65 upsert_track_with(conn, meta, None)
66}
67
68pub fn upsert_synced_track(
75 conn: &Connection,
76 meta: &TrackMeta,
77 seen: &HashSet<String>,
78) -> Result<i64, DbError> {
79 upsert_track_with(conn, meta, Some(seen)).map(|(id, _)| id)
80}
81
82fn upsert_track_with(
83 conn: &Connection,
84 meta: &TrackMeta,
85 seen: Option<&HashSet<String>>,
86) -> Result<(i64, bool), DbError> {
87 conn.execute_batch("SAVEPOINT upsert_track")?;
90 let result = (|| {
91 let mut recorded = None;
92 if meta.path.is_some() {
93 recorded = Some(sources::record(conn, sources::Kind::Local, meta, None)?);
94 }
95 if meta.remote_id.is_some() {
96 let (track, inserted) = sources::record(conn, sources::Kind::Remote, meta, seen)?;
97 recorded = Some((track, recorded.is_some_and(|(_, i)| i) || inserted));
98 }
99 recorded.ok_or(DbError::NoSource)
100 })();
101 match &result {
102 Ok(_) => conn.execute_batch("RELEASE upsert_track")?,
103 Err(_) => conn.execute_batch("ROLLBACK TO upsert_track; RELEASE upsert_track")?,
104 }
105 result
106}
107
108pub(crate) fn clear_zero_discs(conn: &Connection) -> rusqlite::Result<()> {
111 conn.execute("UPDATE tracks SET disc = NULL WHERE disc = 0", [])?;
112 Ok(())
113}
114
115pub fn remove_vanished_remote(
125 conn: &Connection,
126 live_tracks: Option<&HashSet<String>>,
127 live_albums: Option<&HashSet<String>>,
128) -> Result<usize, DbError> {
129 conn.execute_batch(
130 "SAVEPOINT remove_vanished;
131 CREATE TEMP TABLE IF NOT EXISTS live_ids (kind TEXT, id TEXT, PRIMARY KEY (kind, id));
132 DELETE FROM temp.live_ids;",
133 )?;
134 let removed = (|| {
135 let mut insert =
136 conn.prepare("INSERT OR IGNORE INTO temp.live_ids (kind, id) VALUES (?1, ?2)")?;
137 for (kind, ids) in [("track", live_tracks), ("album", live_albums)] {
138 for id in ids.into_iter().flatten() {
139 insert.execute(params![kind, id])?;
140 }
141 }
142 let track_gone = if live_tracks.is_some() {
143 "remote_id NOT IN (SELECT id FROM temp.live_ids WHERE kind = 'track')"
144 } else {
145 "0"
146 };
147 let album_gone = if live_albums.is_some() {
148 "album_remote_id IS NOT NULL
149 AND album_remote_id NOT IN (SELECT id FROM temp.live_ids WHERE kind = 'album')"
150 } else {
151 "0"
152 };
153 let gone: Vec<String> = conn
154 .prepare(&format!(
155 "SELECT remote_id FROM remote_entries WHERE {track_gone} OR {album_gone}"
156 ))?
157 .query_map([], |r| r.get(0))?
158 .collect::<rusqlite::Result<_>>()?;
159 let mut downloads = sources::remove_vanished(conn, &gone, live_tracks.is_some())?;
160 if live_tracks.is_some() {
163 let unread: Vec<String> = conn
164 .prepare(
165 "SELECT t.remote_id FROM tracks t WHERE t.remote_id IS NOT NULL
166 AND NOT EXISTS (SELECT 1 FROM remote_entries r WHERE r.track_id = t.id)
167 AND t.remote_id NOT IN (SELECT id FROM temp.live_ids WHERE kind = 'track')",
168 )?
169 .query_map([], |r| r.get(0))?
170 .collect::<rusqlite::Result<_>>()?;
171 for key in &unread {
172 downloads.extend(sources::forget_unread(conn, sources::Kind::Remote, key)?);
173 }
174 }
175 Ok::<_, DbError>((gone.len(), downloads))
176 })();
177 match removed {
178 Ok((n, downloads)) => {
179 conn.execute_batch("RELEASE remove_vanished")?;
180 let mut freed = 0;
183 for path in downloads {
184 let size = std::fs::metadata(&path).map_or(0, |m| m.len());
185 if std::fs::remove_file(&path).is_ok() {
186 freed += size;
187 }
188 }
189 if freed > 0 {
190 crate::helpers::cache_shrank(freed);
191 }
192 Ok(n)
193 }
194 Err(e) => {
195 conn.execute_batch("ROLLBACK TO remove_vanished; RELEASE remove_vanished")?;
196 Err(e)
197 }
198 }
199}
200
201pub(crate) fn merge_spelling_twins(conn: &Connection) -> rusqlite::Result<()> {
217 use unicode_normalization::{UnicodeNormalization, is_nfd};
218
219 let rows: Vec<(i64, String)> = {
220 let mut stmt = conn.prepare("SELECT id, path FROM tracks WHERE path IS NOT NULL")?;
221 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
222 rows.collect::<rusqlite::Result<Vec<_>>>()?
223 };
224 let mut by_spelling: HashMap<String, Vec<(i64, String)>> = HashMap::new();
225 for (id, path) in rows.into_iter().filter(|(_, path)| !path.is_ascii()) {
226 by_spelling
227 .entry(path.nfc().collect())
228 .or_default()
229 .push((id, path));
230 }
231
232 for mut pair in by_spelling.into_values().filter(|group| group.len() == 2) {
233 pair.sort_by_key(|(id, _)| *id);
234 let (winner, winner_path) = &pair[0];
235 let (loser, loser_path) = &pair[1];
236 let Some(disk) = [winner_path, loser_path]
237 .into_iter()
238 .find(|path| is_nfd(path))
239 else {
240 continue;
241 };
242 let disk = disk.clone();
243 let stale = if winner_path == &disk {
244 loser_path
245 } else {
246 winner_path
247 }
248 .clone();
249
250 conn.execute(
251 "UPDATE tracks SET
252 remote_id = COALESCE(remote_id, (SELECT remote_id FROM tracks WHERE id = ?2)),
253 remote_url = COALESCE(remote_url, (SELECT remote_url FROM tracks WHERE id = ?2)),
254 cached_path = COALESCE(cached_path, (SELECT cached_path FROM tracks WHERE id = ?2)),
255 cache_size_bytes = COALESCE(cache_size_bytes, (SELECT cache_size_bytes FROM tracks WHERE id = ?2)),
256 cache_download_date = COALESCE(cache_download_date, (SELECT cache_download_date FROM tracks WHERE id = ?2)),
257 genre = COALESCE(genre, (SELECT genre FROM tracks WHERE id = ?2)),
258 mbid = COALESCE(mbid, (SELECT mbid FROM tracks WHERE id = ?2))
259 WHERE id = ?1",
260 params![winner, loser],
261 )?;
262 sources::fold_rows(conn, *winner, *loser)?;
263 conn.execute("DELETE FROM scan_cache WHERE path = ?1", params![stale])?;
264 conn.execute(
265 "UPDATE tracks SET path = ?1 WHERE id = ?2",
266 params![disk, winner],
267 )?;
268 }
269
270 Ok(())
271}
272
273pub(crate) fn prune_if_empty(
277 conn: &Connection,
278 album_id: Option<i64>,
279 artist_id: Option<i64>,
280) -> rusqlite::Result<()> {
281 if let Some(album_id) = album_id {
282 let emptied = conn.execute(
283 "DELETE FROM albums WHERE id = ?1
284 AND NOT EXISTS (SELECT 1 FROM tracks WHERE album_id = ?1)",
285 params![album_id],
286 )?;
287 if emptied > 0 {
288 conn.execute(
289 "DELETE FROM favourite_albums WHERE album_id = ?1",
290 params![album_id],
291 )?;
292 conn.execute(
293 "DELETE FROM album_ratings WHERE album_id = ?1",
294 params![album_id],
295 )?;
296 }
297 }
298
299 if let Some(artist_id) = artist_id {
300 let stranded: bool = conn.query_row(
301 "SELECT NOT EXISTS (SELECT 1 FROM tracks WHERE artist_id = ?1)
302 AND NOT EXISTS (SELECT 1 FROM albums WHERE artist_id = ?1)",
303 params![artist_id],
304 |row| row.get(0),
305 )?;
306 if stranded {
307 conn.execute(
308 "DELETE FROM favourite_artists WHERE artist_id = ?1",
309 params![artist_id],
310 )?;
311 conn.execute(
312 "DELETE FROM artist_ratings WHERE artist_id = ?1",
313 params![artist_id],
314 )?;
315 conn.execute("DELETE FROM artists WHERE id = ?1", params![artist_id])?;
316 }
317 }
318
319 Ok(())
320}
321
322const STALE_CHECK_MIN_ROWS: i64 = 100;
325
326const MAX_STALE_FRACTION: f64 = 0.2;
329
330pub fn evict_art_under(conn: &Connection, folder: &Path) -> Result<usize, DbError> {
335 let (lower, upper) = super::folder_prefix_range(folder);
336 Ok(conn.execute(
337 "INSERT INTO art_evictions (kind, id)
338 SELECT 'album', album_id FROM tracks
339 WHERE path >= ?1 AND path < ?2 AND album_id IS NOT NULL
340 GROUP BY album_id
341 UNION ALL
342 SELECT 'track', id FROM tracks WHERE path >= ?1 AND path < ?2",
343 params![lower, upper],
344 )?)
345}
346
347pub fn remove_stale_tracks(
365 conn: &Connection,
366 folder: &Path,
367 force_remove: bool,
368) -> Result<Vec<String>, DbError> {
369 remove_stale_tracks_walked(conn, folder, force_remove, None)
370}
371
372pub fn remove_stale_tracks_walked(
379 conn: &Connection,
380 folder: &Path,
381 force_remove: bool,
382 walked: Option<&std::collections::HashSet<String>>,
383) -> Result<Vec<String>, DbError> {
384 let (lower, upper) = super::folder_prefix_range(folder);
385
386 let paths: Vec<String> = conn
389 .prepare(
390 "SELECT path FROM local_files WHERE path >= ?1 AND path < ?2
391 UNION
392 SELECT t.path FROM tracks t WHERE t.path >= ?1 AND t.path < ?2
393 AND NOT EXISTS (SELECT 1 FROM local_files f WHERE f.track_id = t.id)",
394 )?
395 .query_map(params![lower, upper], |row| row.get(0))?
396 .collect::<rusqlite::Result<_>>()?;
397 let total = paths.len() as i64;
398 let unwalked = paths
399 .into_iter()
400 .filter(|path| !walked.is_some_and(|w| w.contains(path)));
401 let mut spelling = crate::index::spelling::Spelling::default();
404 let mut unclaimed = Vec::new();
405 for path in unwalked {
406 let spelled = walked.and_then(|w| {
407 let on_disk = spelling.on_disk(Path::new(&path));
408 let on_disk = on_disk.to_string_lossy();
409 (on_disk != path.as_str() && w.contains(on_disk.as_ref())).then(|| on_disk.into_owned())
410 });
411 match spelled {
412 Some(on_disk) if sources::fold_twin(conn, &path, &on_disk)? => {}
413 _ => unclaimed.push(path),
414 }
415 }
416 let stale: Vec<String> = unclaimed
417 .into_iter()
418 .filter(|path| crate::index::known_missing(Path::new(path)))
421 .collect();
422
423 let count = stale.len();
424 if !force_remove
425 && total >= STALE_CHECK_MIN_ROWS
426 && count as f64 > total as f64 * MAX_STALE_FRACTION
427 {
428 return Err(DbError::UnsafeBulkDelete(format!(
429 "{} of {} tracks under {} are missing ({:.0}% of the folder) — that reads as an \
430 unmounted or unreadable folder rather than a deletion, so nothing was removed. \
431 If the files really are gone, re-run with `koan scan --force-remove`.",
432 count,
433 total,
434 folder.display(),
435 count as f64 / total as f64 * 100.0
436 )));
437 }
438
439 if force_remove && count > 0 {
440 log::warn!(
441 "--force-remove: deleting {} of {} tracks under {} along with their play history",
442 count,
443 total,
444 folder.display()
445 );
446 }
447
448 let mut tracks = Vec::with_capacity(stale.len());
449 for path in &stale {
450 tracks.extend(sources::track_of_file(conn, path)?);
451 }
452 sources::forget_tracks(conn, &tracks, sources::Forget::Demote)?;
453
454 Ok(stale)
455}
456
457pub fn adopt_moved_files(
461 conn: &Connection,
462 folder: &Path,
463 arrived: &[i64],
464 walked: Option<&std::collections::HashSet<String>>,
465) -> Result<usize, DbError> {
466 if arrived.is_empty() {
467 return Ok(0);
468 }
469 let (lower, upper) = super::folder_prefix_range(folder);
470 let paths: Vec<String> = conn
471 .prepare("SELECT path FROM local_files WHERE path >= ?1 AND path < ?2")?
472 .query_map(params![lower, upper], |row| row.get(0))?
473 .collect::<rusqlite::Result<_>>()?;
474 let mut adopted = 0;
475 for path in paths
477 .into_iter()
478 .filter(|path| !walked.is_some_and(|w| w.contains(path)))
479 {
480 if crate::index::known_missing(Path::new(&path))
481 && sources::adopt_moved(conn, &path, arrived)?
482 {
483 adopted += 1;
484 }
485 }
486 Ok(adopted)
487}
488
489pub fn tracks_for_artist(conn: &Connection, artist_id: i64) -> Result<Vec<TrackRow>, DbError> {
496 let mut stmt = conn.prepare(
497 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
498 t.disc, t.track_number, t.title, t.duration_ms, t.path,
499 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
500 t.genre, t.source, t.remote_id, t.cached_path
501 FROM tracks t
502 LEFT JOIN artists a ON t.artist_id = a.id
503 LEFT JOIN albums al ON t.album_id = al.id
504 LEFT JOIN artists aa ON al.artist_id = aa.id
505 WHERE t.artist_id = ?1
506 OR t.album_id IN (SELECT id FROM albums WHERE artist_id = ?1)
507 ORDER BY al.date, al.title COLLATE LIBRARY, t.disc, t.track_number",
508 )?;
509 let rows = stmt
510 .query_map(params![artist_id], row_to_track_row)?
511 .collect::<Result<Vec<_>, _>>()?;
512 Ok(rows)
513}
514
515pub fn all_tracks_by_path(
521 conn: &Connection,
522) -> Result<std::collections::HashMap<String, TrackRow>, DbError> {
523 let mut stmt = conn.prepare(
524 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
525 t.disc, t.track_number, t.title, t.duration_ms, t.path,
526 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
527 t.genre, t.source, t.remote_id, t.cached_path
528 FROM tracks t
529 LEFT JOIN artists a ON t.artist_id = a.id
530 LEFT JOIN albums al ON t.album_id = al.id
531 LEFT JOIN artists aa ON al.artist_id = aa.id
532 WHERE t.path IS NOT NULL",
533 )?;
534
535 let rows = stmt
536 .query_map(params![], row_to_track_row)?
537 .collect::<Result<Vec<_>, _>>()?;
538
539 let mut map = std::collections::HashMap::with_capacity(rows.len());
540 for row in rows {
541 if let Some(ref path) = row.path {
542 map.insert(path.clone(), row);
543 }
544 }
545 Ok(map)
546}
547
548pub fn tracks_by_paths(
552 conn: &Connection,
553 paths: &[String],
554) -> Result<std::collections::HashMap<String, TrackRow>, DbError> {
555 const BATCH_SIZE: usize = 500;
556 let mut map = std::collections::HashMap::with_capacity(paths.len());
557
558 for chunk in paths.chunks(BATCH_SIZE) {
559 let placeholders: String = chunk
560 .iter()
561 .enumerate()
562 .map(|(i, _)| {
563 if i == 0 {
564 "?".to_string()
565 } else {
566 ",?".to_string()
567 }
568 })
569 .collect();
570
571 let sql = format!(
572 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
573 t.disc, t.track_number, t.title, t.duration_ms, t.path,
574 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
575 t.genre, t.source, t.remote_id, t.cached_path
576 FROM tracks t
577 LEFT JOIN artists a ON t.artist_id = a.id
578 LEFT JOIN albums al ON t.album_id = al.id
579 LEFT JOIN artists aa ON al.artist_id = aa.id
580 WHERE t.path IN ({placeholders})"
581 );
582
583 let mut stmt = conn.prepare(&sql)?;
584 let params: Vec<&dyn rusqlite::types::ToSql> = chunk
585 .iter()
586 .map(|s| s as &dyn rusqlite::types::ToSql)
587 .collect();
588 let rows = stmt
589 .query_map(params.as_slice(), row_to_track_row)?
590 .collect::<Result<Vec<_>, _>>()?;
591
592 for row in rows {
593 if let Some(ref path) = row.path {
594 map.insert(path.clone(), row);
595 }
596 }
597 }
598
599 Ok(map)
600}
601
602pub fn all_tracks(conn: &Connection) -> Result<Vec<TrackRow>, DbError> {
604 let mut stmt = conn.prepare(
605 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
606 t.disc, t.track_number, t.title, t.duration_ms, t.path,
607 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
608 t.genre, t.source, t.remote_id, t.cached_path
609 FROM tracks t
610 LEFT JOIN artists a ON t.artist_id = a.id
611 LEFT JOIN albums al ON t.album_id = al.id
612 LEFT JOIN artists aa ON al.artist_id = aa.id
613 ORDER BY a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY, t.disc, t.track_number",
614 )?;
615
616 let rows = stmt
617 .query_map(params![], row_to_track_row)?
618 .collect::<Result<Vec<_>, _>>()?;
619
620 Ok(rows)
621}
622
623pub fn random_tracks(
625 conn: &Connection,
626 count: u32,
627 artist_id: Option<i64>,
628) -> Result<Vec<TrackRow>, DbError> {
629 random_tracks_where(
630 conn,
631 count,
632 &RandomFilter {
633 artist_id,
634 ..Default::default()
635 },
636 )
637}
638
639#[derive(Debug, Clone, Copy, Default)]
641pub struct RandomFilter<'a> {
642 pub artist_id: Option<i64>,
644 pub genre: Option<&'a str>,
646 pub year_from: Option<i32>,
649 pub year_to: Option<i32>,
650 pub exclude: &'a [i64],
652}
653
654pub fn random_tracks_where(
659 conn: &Connection,
660 count: u32,
661 filter: &RandomFilter,
662) -> Result<Vec<TrackRow>, DbError> {
663 let mut wheres: Vec<String> = Vec::new();
664 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
665 if let Some(aid) = filter.artist_id {
666 wheres.push(
667 "(r.artist_id = ? OR r.album_id IN (SELECT id FROM albums WHERE artist_id = ?))".into(),
668 );
669 params.push(Box::new(aid));
670 params.push(Box::new(aid));
671 }
672 if let Some(genre) = filter.genre {
673 wheres.push("r.genre = ? COLLATE NOCASE".into());
674 params.push(Box::new(genre.to_owned()));
675 }
676 let year =
677 "(SELECT CAST(substr(ra.date, 1, 4) AS INTEGER) FROM albums ra WHERE ra.id = r.album_id)";
678 if let Some(from) = filter.year_from {
679 wheres.push(format!("{year} >= ?"));
680 params.push(Box::new(from));
681 }
682 if let Some(to) = filter.year_to {
683 wheres.push(format!("{year} <= ?"));
684 params.push(Box::new(to));
685 }
686 if !filter.exclude.is_empty() {
687 wheres.push("r.id NOT IN (SELECT value FROM json_each(?))".into());
688 params.push(Box::new(super::json_list(filter.exclude)));
689 }
690 params.push(Box::new(count));
691 let draw = if wheres.is_empty() {
692 String::new()
693 } else {
694 format!("WHERE {}", wheres.join(" AND "))
695 };
696 let sql = format!(
697 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
698 t.disc, t.track_number, t.title, t.duration_ms, t.path,
699 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
700 t.genre, t.source, t.remote_id, t.cached_path
701 FROM tracks t
702 LEFT JOIN artists a ON t.artist_id = a.id
703 LEFT JOIN albums al ON t.album_id = al.id
704 LEFT JOIN artists aa ON al.artist_id = aa.id
705 WHERE t.id IN (SELECT r.id FROM tracks r {draw} ORDER BY RANDOM() LIMIT ?)
706 ORDER BY RANDOM()"
707 );
708 let mut stmt = conn.prepare(&sql)?;
709 let rows = stmt
710 .query_map(rusqlite::params_from_iter(params.iter()), row_to_track_row)?
711 .collect::<Result<Vec<_>, _>>()?;
712 Ok(rows)
713}
714
715pub fn all_tracks_paged(
717 conn: &Connection,
718 limit: u32,
719 offset: u32,
720) -> Result<Vec<TrackRow>, DbError> {
721 let mut stmt = conn.prepare(
722 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
723 t.disc, t.track_number, t.title, t.duration_ms, t.path,
724 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
725 t.genre, t.source, t.remote_id, t.cached_path
726 FROM tracks t
727 LEFT JOIN artists a ON t.artist_id = a.id
728 LEFT JOIN albums al ON t.album_id = al.id
729 LEFT JOIN artists aa ON al.artist_id = aa.id
730 ORDER BY a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY, t.disc, t.track_number
731 LIMIT ?1 OFFSET ?2",
732 )?;
733 let rows = stmt
734 .query_map(params![limit, offset], row_to_track_row)?
735 .collect::<Result<Vec<_>, _>>()?;
736 Ok(rows)
737}
738
739pub fn tracks_by_ids(conn: &Connection, ids: &[i64]) -> Result<Vec<TrackRow>, DbError> {
742 if ids.is_empty() {
743 return Ok(Vec::new());
744 }
745 let mut stmt = conn.prepare_cached(
746 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
747 t.disc, t.track_number, t.title, t.duration_ms, t.path,
748 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
749 t.genre, t.source, t.remote_id, t.cached_path
750 FROM tracks t
751 LEFT JOIN artists a ON t.artist_id = a.id
752 LEFT JOIN albums al ON t.album_id = al.id
753 LEFT JOIN artists aa ON al.artist_id = aa.id
754 WHERE t.id IN (SELECT value FROM json_each(?1))",
755 )?;
756 let rows = stmt
757 .query_map([super::json_list(ids)], row_to_track_row)?
758 .collect::<Result<Vec<_>, _>>()?;
759
760 let by_id: HashMap<i64, TrackRow> = rows.into_iter().map(|r| (r.id, r)).collect();
767 Ok(ids.iter().filter_map(|id| by_id.get(id).cloned()).collect())
768}
769
770pub fn get_track_row(conn: &Connection, track_id: i64) -> Result<Option<TrackRow>, DbError> {
772 let result = conn
773 .prepare_cached(
774 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
775 t.disc, t.track_number, t.title, t.duration_ms, t.path,
776 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
777 t.genre, t.source, t.remote_id, t.cached_path
778 FROM tracks t
779 LEFT JOIN artists a ON t.artist_id = a.id
780 LEFT JOIN albums al ON t.album_id = al.id
781 LEFT JOIN artists aa ON al.artist_id = aa.id
782 WHERE t.id = ?1",
783 )?
784 .query_row(params![track_id], row_to_track_row);
785
786 match result {
787 Ok(row) => Ok(Some(row)),
788 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
789 Err(e) => Err(e.into()),
790 }
791}
792
793pub fn track_id_by_path(conn: &Connection, path: &str) -> Result<Option<i64>, DbError> {
795 let result = conn
796 .prepare_cached(
797 "SELECT id FROM tracks WHERE path = ?1 OR cached_path = ?1 OR remote_url = ?1",
798 )?
799 .query_row(params![path], |row| row.get(0));
800 match result {
801 Ok(id) => Ok(Some(id)),
802 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
803 Err(e) => Err(e.into()),
804 }
805}
806
807pub fn clear_cached_paths(conn: &Connection) -> Result<(), DbError> {
809 conn.execute(
812 "UPDATE tracks SET cached_path = NULL, cache_size_bytes = NULL, cache_download_date = NULL,
813 cache_pinned = 0
814 WHERE cached_path IS NOT NULL",
815 params![],
816 )?;
817 Ok(())
818}
819
820pub fn cached_paths_for(conn: &Connection, track_ids: &[i64]) -> Result<Vec<String>, DbError> {
822 if track_ids.is_empty() {
823 return Ok(Vec::new());
824 }
825 let mut stmt = conn.prepare_cached(
826 "SELECT DISTINCT cached_path FROM tracks
827 WHERE id IN (SELECT value FROM json_each(?1)) AND cached_path IS NOT NULL",
828 )?;
829 let rows = stmt.query_map([super::json_list(track_ids)], |row| row.get(0))?;
830 Ok(rows.filter_map(Result::ok).collect())
831}
832
833pub fn clear_cached_paths_for(conn: &Connection, track_ids: &[i64]) -> Result<(), DbError> {
838 if track_ids.is_empty() {
839 return Ok(());
840 }
841 conn.execute(
842 "UPDATE tracks SET cached_path = NULL, cache_size_bytes = NULL, cache_download_date = NULL,
843 cache_pinned = 0
844 WHERE cached_path IN (SELECT cached_path FROM tracks
845 WHERE id IN (SELECT value FROM json_each(?1)))
846 OR id IN (SELECT value FROM json_each(?1))",
847 [super::json_list(track_ids)],
848 )?;
849 Ok(())
850}
851
852pub fn set_cached_path(conn: &Connection, track_id: i64, path: &str) -> Result<(), DbError> {
854 let size_bytes: Option<i64> = std::fs::metadata(path).ok().map(|m| m.len() as i64);
855 let now = std::time::SystemTime::now()
856 .duration_since(std::time::UNIX_EPOCH)
857 .unwrap_or_default()
858 .as_secs() as i64;
859 conn.execute(
860 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = ?2, cache_download_date = ?3
861 WHERE id = ?4",
862 params![path, size_bytes, now, track_id],
863 )?;
864 Ok(())
865}
866
867pub fn pin_cached(conn: &Connection, track_ids: &[i64]) -> Result<(), DbError> {
870 if track_ids.is_empty() {
871 return Ok(());
872 }
873 conn.execute(
874 "UPDATE tracks SET cache_pinned = 1
875 WHERE id IN (SELECT value FROM json_each(?1)) AND cached_path IS NOT NULL",
876 [super::json_list(track_ids)],
877 )?;
878 Ok(())
879}
880
881#[derive(Debug, Clone)]
883pub struct CachedFile {
884 pub track_id: i64,
885 pub path: String,
886 pub size: i64,
887 pub pinned: bool,
888}
889
890pub fn cached_files_lru(conn: &Connection) -> Result<Vec<CachedFile>, DbError> {
899 let mut stmt = conn.prepare(
902 "SELECT t.id, t.cached_path, COALESCE(t.cache_size_bytes, 0), t.cache_pinned != 0
903 FROM tracks t
904 LEFT JOIN (SELECT track_id, MAX(played_at) as last_play
905 FROM play_history GROUP BY track_id) ph_max
906 ON ph_max.track_id = t.id
907 WHERE t.cached_path IS NOT NULL
908 AND NOT EXISTS (SELECT 1 FROM tracks s
909 JOIN favourites f
910 JOIN tracks ft ON ft.id = f.track_id
911 AND (ft.id = s.id OR ft.album_id = s.album_id)
912 WHERE s.cached_path = t.cached_path)
913 ORDER BY t.cache_pinned != 0,
914 NULLIF(MAX(COALESCE(ph_max.last_play, 0), COALESCE(t.cache_download_date, 0)), 0),
915 t.album_id, t.disc, t.track_number",
916 )?;
917 let rows = stmt.query_map([], |row| {
918 Ok(CachedFile {
919 track_id: row.get(0)?,
920 path: row.get(1)?,
921 size: row.get(2)?,
922 pinned: row.get(3)?,
923 })
924 })?;
925 Ok(rows.collect::<Result<_, _>>()?)
926}
927
928pub fn download_estimates(
934 conn: &Connection,
935 track_ids: &[i64],
936) -> Result<HashMap<i64, i64>, DbError> {
937 if track_ids.is_empty() {
938 return Ok(HashMap::new());
939 }
940 let mut stmt = conn.prepare_cached(
941 "SELECT t.id,
942 CASE WHEN EXISTS(SELECT 1 FROM local_files l WHERE l.track_id = t.id) THEN 0
943 ELSE COALESCE(r.size_bytes, r.bitrate * r.duration_ms / 8,
944 r.duration_ms * 1411 / 8, 0)
945 END
946 FROM tracks t
947 JOIN remote_entries r ON r.track_id = t.id
948 WHERE t.id IN (SELECT value FROM json_each(?1)) AND t.cached_path IS NULL",
949 )?;
950 let rows = stmt.query_map([super::json_list(track_ids)], |r| {
951 Ok((r.get(0)?, r.get(1)?))
952 })?;
953 Ok(rows.collect::<Result<_, _>>()?)
954}
955
956pub fn total_cache_size(conn: &Connection) -> Result<i64, DbError> {
959 let size: i64 = conn.query_row(
960 "SELECT COALESCE(SUM(size), 0) FROM
961 (SELECT MAX(cache_size_bytes) AS size FROM tracks
962 WHERE cached_path IS NOT NULL GROUP BY cached_path)",
963 [],
964 |row| row.get(0),
965 )?;
966 Ok(size)
967}
968
969pub fn resolve_playback_path(
971 conn: &Connection,
972 track_id: i64,
973) -> Result<Option<PlaybackSource>, DbError> {
974 let row = conn
975 .prepare_cached("SELECT path, cached_path, remote_url FROM tracks WHERE id = ?1")?
976 .query_row(params![track_id], |row| {
977 Ok((
978 row.get::<_, Option<String>>(0)?,
979 row.get::<_, Option<String>>(1)?,
980 row.get::<_, Option<String>>(2)?,
981 ))
982 })
983 .optional()?;
984 Ok(row.and_then(|(path, cached_path, remote_url)| {
985 choose_playback_source(
986 path.as_deref(),
987 cached_path.as_deref(),
988 remote_url.as_deref(),
989 )
990 }))
991}
992
993pub fn choose_playback_source(
996 path: Option<&str>,
997 cached_path: Option<&str>,
998 remote_url: Option<&str>,
999) -> Option<PlaybackSource> {
1000 if let Some(p) = path.map(PathBuf::from).filter(|p| p.exists()) {
1001 return Some(PlaybackSource::Local(p));
1002 }
1003 if let Some(p) = cached_path.map(PathBuf::from).filter(|p| p.exists()) {
1004 return Some(PlaybackSource::Cached(p));
1005 }
1006 remote_url.map(|url| PlaybackSource::Remote(url.to_owned()))
1007}
1008
1009#[derive(Debug, Clone, Default)]
1011pub struct QueueItemExtras {
1012 pub remote_url: Option<String>,
1013 pub album_date: Option<String>,
1014}
1015
1016pub fn queue_item_extras(
1018 conn: &Connection,
1019 track_ids: &[i64],
1020) -> Result<HashMap<i64, QueueItemExtras>, DbError> {
1021 if track_ids.is_empty() {
1022 return Ok(HashMap::new());
1023 }
1024 let mut stmt = conn.prepare_cached(
1025 "SELECT t.id, t.remote_url, al.date FROM tracks t
1026 LEFT JOIN albums al ON al.id = t.album_id
1027 WHERE t.id IN (SELECT value FROM json_each(?1))",
1028 )?;
1029 let rows = stmt.query_map([super::json_list(track_ids)], |row| {
1030 Ok((
1031 row.get::<_, i64>(0)?,
1032 QueueItemExtras {
1033 remote_url: row.get(1)?,
1034 album_date: row.get(2)?,
1035 },
1036 ))
1037 })?;
1038 rows.collect::<Result<HashMap<_, _>, _>>()
1039 .map_err(Into::into)
1040}
1041
1042pub fn tracks_page(conn: &Connection, limit: u32, offset: u32) -> Result<Vec<TrackRow>, DbError> {
1049 let mut stmt = conn.prepare_cached(
1050 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1051 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1052 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1053 t.genre, t.source, t.remote_id, t.cached_path
1054 FROM tracks t
1055 LEFT JOIN artists a ON t.artist_id = a.id
1056 LEFT JOIN albums al ON t.album_id = al.id
1057 LEFT JOIN artists aa ON al.artist_id = aa.id
1058 ORDER BY t.id
1059 LIMIT ?1 OFFSET ?2",
1060 )?;
1061 let rows = stmt
1062 .query_map(params![limit, offset], row_to_track_row)?
1063 .collect::<Result<Vec<_>, _>>()?;
1064 Ok(rows)
1065}
1066
1067pub fn tracks_for_album(conn: &Connection, album_id: i64) -> Result<Vec<TrackRow>, DbError> {
1069 let mut stmt = conn.prepare_cached(
1070 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1071 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1072 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1073 t.genre, t.source, t.remote_id, t.cached_path
1074 FROM tracks t
1075 LEFT JOIN artists a ON t.artist_id = a.id
1076 LEFT JOIN albums al ON t.album_id = al.id
1077 LEFT JOIN artists aa ON al.artist_id = aa.id
1078 WHERE t.album_id = ?1
1079 ORDER BY t.disc, t.track_number",
1080 )?;
1081
1082 let rows = stmt
1083 .query_map(params![album_id], row_to_track_row)?
1084 .collect::<Result<Vec<_>, _>>()?;
1085
1086 Ok(rows)
1087}
1088
1089pub fn cover_track_for_album(
1099 conn: &Connection,
1100 album_id: i64,
1101) -> Result<Option<TrackRow>, DbError> {
1102 let mut stmt = conn.prepare_cached(
1103 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1104 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1105 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1106 t.genre, t.source, t.remote_id, t.cached_path
1107 FROM tracks t
1108 LEFT JOIN artists a ON t.artist_id = a.id
1109 LEFT JOIN albums al ON t.album_id = al.id
1110 LEFT JOIN artists aa ON al.artist_id = aa.id
1111 WHERE t.album_id = ?1
1112 ORDER BY (t.path IS NULL AND t.cached_path IS NULL), t.disc, t.track_number
1113 LIMIT 1",
1114 )?;
1115
1116 let mut rows = stmt.query_map(params![album_id], row_to_track_row)?;
1117 rows.next().transpose().map_err(Into::into)
1118}
1119
1120pub fn genres_by_artist_ids(
1123 conn: &Connection,
1124 ids: &[i64],
1125) -> Result<HashMap<i64, HashSet<String>>, DbError> {
1126 if ids.is_empty() {
1127 return Ok(HashMap::new());
1128 }
1129 let mut stmt = conn.prepare_cached(
1130 "SELECT t.artist_id, t.genre FROM tracks t
1131 WHERE t.artist_id IN (SELECT value FROM json_each(?1)) AND t.genre IS NOT NULL
1132 UNION
1133 SELECT al.artist_id, t.genre FROM tracks t
1134 JOIN albums al ON t.album_id = al.id
1135 WHERE al.artist_id IN (SELECT value FROM json_each(?1)) AND t.genre IS NOT NULL",
1136 )?;
1137 let rows = stmt.query_map([super::json_list(ids)], |row| {
1138 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
1139 })?;
1140 let mut map: HashMap<i64, HashSet<String>> = HashMap::new();
1141 for row in rows {
1142 let (artist_id, genre) = row?;
1143 map.entry(artist_id)
1144 .or_default()
1145 .insert(genre.to_lowercase());
1146 }
1147 Ok(map)
1148}
1149
1150pub fn genres_by_album_ids(
1153 conn: &Connection,
1154 ids: &[i64],
1155) -> Result<HashMap<i64, HashSet<String>>, DbError> {
1156 if ids.is_empty() {
1157 return Ok(HashMap::new());
1158 }
1159 let mut stmt = conn.prepare_cached(
1160 "SELECT t.album_id, t.genre FROM tracks t
1161 WHERE t.album_id IN (SELECT value FROM json_each(?1)) AND t.genre IS NOT NULL",
1162 )?;
1163 let rows = stmt.query_map([super::json_list(ids)], |row| {
1164 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
1165 })?;
1166 let mut map: HashMap<i64, HashSet<String>> = HashMap::new();
1167 for row in rows {
1168 let (album_id, genre) = row?;
1169 map.entry(album_id)
1170 .or_default()
1171 .insert(genre.to_lowercase());
1172 }
1173 Ok(map)
1174}
1175
1176pub(crate) fn favourite_track_ids_sql(user: &str) -> String {
1185 format!("SELECT track_id FROM favourites WHERE user_id = {user}")
1186}
1187
1188pub fn favourite_artist_ids_batch(conn: &Connection, user: i64) -> Result<HashSet<i64>, DbError> {
1190 let mut stmt = conn.prepare_cached(&format!(
1191 "SELECT DISTINCT artist_id FROM tracks
1192 WHERE artist_id IS NOT NULL AND id IN ({})",
1193 favourite_track_ids_sql("?1")
1194 ))?;
1195 let rows = stmt.query_map([super::auth::resolve_user(conn, user)?], |row| {
1196 row.get::<_, i64>(0)
1197 })?;
1198 let mut ids = HashSet::new();
1199 for row in rows {
1200 ids.insert(row?);
1201 }
1202 Ok(ids)
1203}
1204
1205pub fn favourite_track_ids_batch(conn: &Connection, user: i64) -> Result<HashSet<i64>, DbError> {
1207 let mut stmt = conn.prepare_cached(&favourite_track_ids_sql("?1"))?;
1208 let rows = stmt.query_map([super::auth::resolve_user(conn, user)?], |row| {
1209 row.get::<_, i64>(0)
1210 })?;
1211 let mut ids = HashSet::new();
1212 for row in rows {
1213 ids.insert(row?);
1214 }
1215 Ok(ids)
1216}
1217
1218pub fn favourite_tracks(
1226 conn: &Connection,
1227 user: i64,
1228 search: Option<&str>,
1229) -> Result<Vec<TrackRow>, DbError> {
1230 let mut sql = format!(
1231 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1232 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1233 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1234 t.genre, t.source, t.remote_id, t.cached_path
1235 FROM tracks t
1236 LEFT JOIN artists a ON t.artist_id = a.id
1237 LEFT JOIN albums al ON t.album_id = al.id
1238 LEFT JOIN artists aa ON al.artist_id = aa.id
1239 WHERE t.id IN ({})",
1240 favourite_track_ids_sql("?1")
1241 );
1242 let mut params: Vec<Box<dyn rusqlite::ToSql>> =
1243 vec![Box::new(super::auth::resolve_user(conn, user)?)];
1244 if let Some(query) = search {
1245 let pattern = format!("%{}%", super::artists::escape_like(query));
1246 for _ in 0..3 {
1247 params.push(Box::new(pattern.clone()));
1248 }
1249 sql.push_str(
1250 " AND (t.title LIKE ? COLLATE NOCASE ESCAPE '\\'
1251 OR a.name LIKE ? COLLATE NOCASE ESCAPE '\\'
1252 OR al.title LIKE ? COLLATE NOCASE ESCAPE '\\')",
1253 );
1254 }
1255 sql.push_str(
1256 " ORDER BY a.name COLLATE LIBRARY, al.title COLLATE LIBRARY, t.disc, t.track_number",
1257 );
1258
1259 let mut stmt = conn.prepare(&sql)?;
1260 let rows = stmt
1261 .query_map(rusqlite::params_from_iter(params.iter()), row_to_track_row)?
1262 .collect::<Result<Vec<_>, _>>()?;
1263 Ok(rows)
1264}
1265
1266pub fn favourite_album_ids_batch(conn: &Connection, user: i64) -> Result<HashSet<i64>, DbError> {
1268 let mut stmt = conn.prepare_cached(&format!(
1269 "SELECT DISTINCT album_id FROM tracks
1270 WHERE album_id IS NOT NULL AND id IN ({})",
1271 favourite_track_ids_sql("?1")
1272 ))?;
1273 let rows = stmt.query_map([super::auth::resolve_user(conn, user)?], |row| {
1274 row.get::<_, i64>(0)
1275 })?;
1276 let mut ids = HashSet::new();
1277 for row in rows {
1278 ids.insert(row?);
1279 }
1280 Ok(ids)
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285 use super::*;
1286 use crate::db::connection::Database;
1287 use crate::db::queries::{library_stats, sample_meta, search_tracks};
1288
1289 fn test_db() -> Database {
1290 let conn = rusqlite::Connection::open_in_memory().unwrap();
1291 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1292 crate::db::schema::create_tables(&conn).unwrap();
1293 Database { conn }
1294 }
1295
1296 #[test]
1297 fn a_track_lists_each_source_file_first() {
1298 let db = test_db();
1299 let local = sample_meta("Archangel", "Burial", "Untrue");
1300 let id = upsert_track(&db.conn, &local).unwrap();
1301 let mut remote = local.clone();
1302 remote.path = None;
1303 remote.source = "remote".into();
1304 remote.remote_id = Some("song-1".into());
1305 remote.mbid = Some("recording".into());
1306 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id, "one track");
1307
1308 let sources = crate::db::queries::sources_of_track(&db.conn, id).unwrap();
1309 let kinds: Vec<&str> = sources.iter().map(|m| m.source.as_str()).collect();
1310 assert_eq!(kinds, ["local", "remote"]);
1311 assert_eq!(sources[0].path, local.path);
1312 assert_eq!(sources[1].remote_id.as_deref(), Some("song-1"));
1313 assert_eq!(sources[1].mbid.as_deref(), Some("recording"));
1314 assert!(
1315 crate::db::queries::sources_of_track(&db.conn, id + 1)
1316 .unwrap()
1317 .is_empty()
1318 );
1319 }
1320
1321 #[test]
1324 fn an_unchanged_track_is_not_rewritten() {
1325 let db = test_db();
1326 let local = sample_meta("Archangel", "Burial", "Untrue");
1327 let mut remote = sample_meta("Near Dark", "Burial", "Untrue");
1328 remote.path = None;
1329 remote.source = "remote".into();
1330 remote.remote_id = Some("0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e6f".into());
1331 remote.remote_url = Some("https://music.example/rest/stream?id=a".into());
1332 remote.album_remote_id = Some("0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e70".into());
1333 remote.artist_remote_id = Some("0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e71".into());
1334 remote.album_mbid = Some("release".into());
1335 remote.album_added_at = Some("2026-01-01T00:00:00Z".into());
1336 let seen: HashSet<String> = remote.remote_id.iter().cloned().collect();
1337 upsert_track(&db.conn, &local).unwrap();
1338 upsert_synced_track(&db.conn, &remote, &seen).unwrap();
1339
1340 let before = db.conn.total_changes();
1341 upsert_track(&db.conn, &local).unwrap();
1342 upsert_synced_track(&db.conn, &remote, &seen).unwrap();
1343 assert_eq!(db.conn.total_changes(), before, "nothing was written");
1344 assert_eq!(search_tracks(&db.conn, "Archangel").unwrap().len(), 1);
1345 assert_eq!(search_tracks(&db.conn, "Near Dark").unwrap().len(), 1);
1346 }
1347
1348 #[test]
1349 fn a_new_server_uid_is_inserted_with_the_row() {
1350 let db = test_db();
1351 let uid = "0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e6f";
1352 let mut meta = sample_meta("Near Dark", "Burial", "Untrue");
1353 meta.path = None;
1354 meta.remote_id = Some(uid.into());
1355 let id = upsert_track(&db.conn, &meta).unwrap();
1356 let stored: String = db
1357 .conn
1358 .query_row("SELECT uid FROM tracks WHERE id = ?1", [id], |r| r.get(0))
1359 .unwrap();
1360 assert_eq!(stored, uid);
1361 }
1362
1363 #[test]
1364 fn a_changed_track_is_still_rewritten() {
1365 let db = test_db();
1366 let mut meta = sample_meta("Archangel", "Burial", "Untrue");
1367 meta.album_added_at = Some("2026-02-01T00:00:00Z".into());
1368 let id = upsert_track(&db.conn, &meta).unwrap();
1369
1370 meta.title = "Archangel (Remastered)".into();
1371 meta.genre = Some("Dubstep".into());
1372 meta.bit_depth = Some(24);
1373 meta.codec = Some("ALAC".into());
1374 meta.album_added_at = Some("2026-01-01T00:00:00Z".into());
1375 assert_eq!(upsert_track(&db.conn, &meta).unwrap(), id);
1376
1377 let (title, genre, bit_depth): (String, String, i32) = db
1378 .conn
1379 .query_row(
1380 "SELECT title, genre, bit_depth FROM tracks WHERE id = ?1",
1381 [id],
1382 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1383 )
1384 .unwrap();
1385 assert_eq!(
1386 (title.as_str(), genre.as_str(), bit_depth),
1387 ("Archangel (Remastered)", "Dubstep", 24)
1388 );
1389 assert_eq!(search_tracks(&db.conn, "Remastered").unwrap().len(), 1);
1390 assert_eq!(search_tracks(&db.conn, "Dubstep").unwrap().len(), 1);
1391 assert!(search_tracks(&db.conn, "Electronic").unwrap().is_empty());
1392 let (codec, added): (String, String) = db
1393 .conn
1394 .query_row("SELECT codec, added_at FROM albums", [], |r| {
1395 Ok((r.get(0)?, r.get(1)?))
1396 })
1397 .unwrap();
1398 assert_eq!(
1399 (codec.as_str(), added.as_str()),
1400 ("ALAC", "2026-01-01T00:00:00Z"),
1401 "the album takes the new codec and the earlier date"
1402 );
1403
1404 meta.album_added_at = Some("2026-03-01T00:00:00Z".into());
1405 upsert_track(&db.conn, &meta).unwrap();
1406 let added: String = db
1407 .conn
1408 .query_row("SELECT added_at FROM albums", [], |r| r.get(0))
1409 .unwrap();
1410 assert_eq!(added, "2026-01-01T00:00:00Z", "a later date does not win");
1411 }
1412
1413 #[test]
1414 fn favourite_tracks_come_back_as_rows_narrowed_by_search() {
1415 use crate::db::queries::toggle_favourite;
1416 let db = test_db();
1417 let amber = upsert_track(&db.conn, &sample_meta("Amber", "Autechre", "Amber")).unwrap();
1418 let mut foil = sample_meta("Foil", "Autechre", "Amber");
1419 foil.track_number = Some(2);
1420 let foil = upsert_track(&db.conn, &foil).unwrap();
1421
1422 let titles = |q| {
1423 favourite_tracks(&db.conn, crate::db::queries::LOCAL_USER, q)
1424 .unwrap()
1425 .into_iter()
1426 .map(|t| t.title)
1427 .collect::<Vec<_>>()
1428 };
1429 assert!(titles(None).is_empty(), "nothing is favourite until it is");
1430
1431 toggle_favourite(&db.conn, crate::db::queries::LOCAL_USER, amber).unwrap();
1432 toggle_favourite(&db.conn, crate::db::queries::LOCAL_USER, foil).unwrap();
1433 assert_eq!(titles(None), ["Amber", "Foil"]);
1434 assert_eq!(titles(Some("foil")), ["Foil"]);
1435 assert_eq!(
1436 titles(Some("autechre")).len(),
1437 2,
1438 "matched on the artist name"
1439 );
1440 }
1441
1442 #[test]
1445 fn tracks_for_artist_counts_the_album_credit() {
1446 let db = test_db();
1447 db.conn
1448 .execute_batch(
1449 "INSERT INTO artists (id, name) VALUES (1, 'Aphex Twin'), (2, 'Various');
1450 INSERT INTO albums (id, title, artist_id) VALUES
1451 (1, 'Selected Ambient Works', 1), (2, 'Artificial Intelligence', 2);
1452 -- Own credit, on their own record.
1453 INSERT INTO tracks (id, title, artist_id, album_id, source, path)
1454 VALUES (1, 'Xtal', 1, 1, 'local', '/music/xtal.flac');
1455 -- Own credit, on somebody else's compilation.
1456 INSERT INTO tracks (id, title, artist_id, album_id, source, path)
1457 VALUES (2, 'Polygon Window', 1, 2, 'local', '/music/polygon.flac');
1458 -- Album credit only: uncredited track on their record.
1459 INSERT INTO tracks (id, title, album_id, source, path)
1460 VALUES (3, 'Untitled', 1, 'local', '/music/untitled.flac');
1461 -- Neither.
1462 INSERT INTO tracks (id, title, artist_id, album_id, source, path)
1463 VALUES (4, 'The Clan Call', 2, 2, 'local', '/music/clan.flac');",
1464 )
1465 .unwrap();
1466
1467 let mut ids = tracks_for_artist(&db.conn, 1)
1468 .unwrap()
1469 .into_iter()
1470 .map(|t| t.id)
1471 .collect::<Vec<_>>();
1472 ids.sort();
1473 assert_eq!(ids, [1, 2, 3]);
1474 }
1475
1476 #[test]
1477 fn an_id_asked_for_twice_comes_back_twice() {
1478 let db = test_db();
1479 let a = upsert_track(&db.conn, &sample_meta("One", "Artist", "Album")).unwrap();
1480 let b = upsert_track(&db.conn, &sample_meta("Two", "Artist", "Album")).unwrap();
1481
1482 let rows = tracks_by_ids(&db.conn, &[a, b, a]).unwrap();
1483 assert_eq!(
1484 rows.iter().map(|r| r.id).collect::<Vec<_>>(),
1485 vec![a, b, a],
1486 "a queue may hold the same track twice, and a playlist certainly may"
1487 );
1488 }
1489
1490 #[test]
1491 fn test_upsert_track() {
1492 let db = test_db();
1493 let meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1494 let id1 = upsert_track(&db.conn, &meta).unwrap();
1495
1496 let id2 = upsert_track(&db.conn, &meta).unwrap();
1498 assert_eq!(id1, id2);
1499
1500 let stats = library_stats(&db.conn).unwrap();
1501 assert_eq!(stats.total_tracks, 1);
1502 assert_eq!(stats.local_tracks, 1);
1503 }
1504
1505 #[test]
1506 fn test_dedup_keeps_discs_apart() {
1507 let db = test_db();
1508
1509 let mut cd1 = sample_meta("Overture", "Wagner", "Ring Cycle");
1511 cd1.disc = Some(1);
1512 cd1.path = Some("/music/Ring Cycle/CD1/01 - Overture.flac".into());
1513 let mut cd2 = cd1.clone();
1514 cd2.disc = Some(2);
1515 cd2.path = Some("/music/Ring Cycle/CD2/01 - Overture.flac".into());
1516
1517 let id1 = upsert_track(&db.conn, &cd1).unwrap();
1518 let id2 = upsert_track(&db.conn, &cd2).unwrap();
1519
1520 assert_ne!(id1, id2, "discs 1 and 2 must not collapse into one row");
1521 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
1522
1523 let paths: Vec<String> = db
1524 .conn
1525 .prepare("SELECT path FROM tracks ORDER BY disc")
1526 .unwrap()
1527 .query_map([], |row| row.get(0))
1528 .unwrap()
1529 .map(|r| r.unwrap())
1530 .collect();
1531 assert_eq!(paths, vec![cd1.path.unwrap(), cd2.path.unwrap()]);
1532 }
1533
1534 #[test]
1535 fn test_dedup_never_merges_two_local_files() {
1536 let db = test_db();
1537
1538 let mut a = sample_meta("Intro", "Various", "Compilation");
1540 a.path = Some("/music/Compilation/a.flac".into());
1541 let mut b = a.clone();
1542 b.path = Some("/music/Compilation/b.flac".into());
1543
1544 let id_a = upsert_track(&db.conn, &a).unwrap();
1545 let id_b = upsert_track(&db.conn, &b).unwrap();
1546
1547 assert_ne!(id_a, id_b);
1548 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
1549 }
1550
1551 #[test]
1552 fn test_dedup_never_merges_two_remote_entries() {
1553 let db = test_db();
1554
1555 let mut first = sample_meta("Untitled", "Artist", "Album");
1558 first.source = "remote".into();
1559 first.path = None;
1560 first.disc = None;
1561 first.remote_id = Some("sub-1".into());
1562 let mut second = first.clone();
1563 second.remote_id = Some("sub-2".into());
1564
1565 let id1 = upsert_track(&db.conn, &first).unwrap();
1566 let id2 = upsert_track(&db.conn, &second).unwrap();
1567
1568 assert_ne!(
1569 id1, id2,
1570 "two server entries must not collapse into one row"
1571 );
1572 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
1573 }
1574
1575 fn uid_of(db: &Database, table: &str, id: i64) -> String {
1576 db.conn
1577 .query_row(
1578 &format!("SELECT uid FROM {table} WHERE id = ?1"),
1579 [id],
1580 |r| r.get(0),
1581 )
1582 .unwrap()
1583 }
1584
1585 #[test]
1586 fn a_track_synced_from_koan_takes_the_servers_uids() {
1587 let db = test_db();
1588 let [track, album, artist] = [(); 3].map(|_| uuid::Uuid::now_v7().to_string());
1589 let mut meta = remote_meta("Archangel", "Burial", "Untrue", &track);
1590 meta.album_remote_id = Some(album.clone());
1591 meta.artist_remote_id = Some(artist.clone());
1592
1593 let id = upsert_track(&db.conn, &meta).unwrap();
1594
1595 let row = get_track_row(&db.conn, id).unwrap().unwrap();
1596 assert_eq!(uid_of(&db, "tracks", id), track);
1597 assert_eq!(uid_of(&db, "albums", row.album_id.unwrap()), album);
1598 assert_eq!(uid_of(&db, "artists", row.artist_id.unwrap()), artist);
1599 }
1600
1601 #[test]
1602 fn a_local_file_merged_with_its_koan_copy_takes_the_servers_uid() {
1603 let db = test_db();
1604 let local = upsert_track(&db.conn, &sample_meta("Archangel", "Burial", "Untrue")).unwrap();
1605 let minted = uid_of(&db, "tracks", local);
1606
1607 let server = uuid::Uuid::now_v7().to_string();
1608 let merged = upsert_track(
1609 &db.conn,
1610 &remote_meta("Archangel", "Burial", "Untrue", &server),
1611 )
1612 .unwrap();
1613
1614 assert_eq!(merged, local);
1615 assert_ne!(minted, server);
1616 assert_eq!(uid_of(&db, "tracks", local), server);
1617 }
1618
1619 #[test]
1620 fn ids_from_other_servers_leave_the_minted_uid() {
1621 let db = test_db();
1622 for remote_id in [
1623 "3xJ9kQ2pZ",
1624 "42",
1625 "0f8fad5b-d9cb-469f-a165-70867728950e",
1626 "018f8fad5bd9cb769fa16570867728950e",
1627 ] {
1628 let id = upsert_track(
1629 &db.conn,
1630 &remote_meta(remote_id, "Burial", "Untrue", remote_id),
1631 )
1632 .unwrap();
1633 let uid = uid_of(&db, "tracks", id);
1634 assert_ne!(uid, remote_id);
1635 assert!(super::super::is_uid(&uid), "{uid}");
1636 }
1637 }
1638
1639 fn remote_meta(title: &str, artist: &str, album: &str, remote_id: &str) -> TrackMeta {
1641 let mut meta = sample_meta(title, artist, album);
1642 meta.source = "remote".into();
1643 meta.path = None;
1644 meta.remote_id = Some(remote_id.into());
1645 meta.remote_url = Some(format!("https://server/rest/stream?id={remote_id}"));
1646 meta.sample_rate = None;
1647 meta.bit_depth = None;
1648 meta
1649 }
1650
1651 #[test]
1652 fn test_corrected_tags_remerge_with_the_remote_copy() {
1653 let db = test_db();
1654
1655 let mut bad = sample_meta(
1658 "Golden Skans (David E Sugar R",
1659 "Klaxons",
1660 "Golden Skans (David E Sugar R",
1661 );
1662 bad.path = Some("/music/klaxons/01.mp3".into());
1663 bad.track_number = None;
1664 let local_id = upsert_track(&db.conn, &bad).unwrap();
1665
1666 let remote = remote_meta(
1667 "Golden Skans (David E Sugar Remix)",
1668 "Klaxons",
1669 "Golden Skans (David E Sugar Remix)",
1670 "sub-42",
1671 );
1672 let remote_id = upsert_track(&db.conn, &remote).unwrap();
1673 assert_ne!(local_id, remote_id, "bad tags cannot content-match");
1674 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
1675
1676 let mut fixed = bad.clone();
1679 fixed.title = "Golden Skans (David E Sugar Remix)".into();
1680 fixed.album = "Golden Skans (David E Sugar Remix)".into();
1681 fixed.track_number = Some(1);
1682 let merged = upsert_track(&db.conn, &fixed).unwrap();
1683
1684 assert_eq!(merged, local_id, "the row holding the file survives");
1685 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
1686
1687 let row = get_track_row(&db.conn, merged).unwrap().unwrap();
1688 assert_eq!(row.path.as_deref(), Some("/music/klaxons/01.mp3"));
1689 assert_eq!(row.remote_id.as_deref(), Some("sub-42"));
1690 assert_eq!(row.source, "local");
1691 assert_eq!(row.sample_rate, Some(44100), "local audio properties kept");
1692
1693 let albums: Vec<String> = db
1695 .conn
1696 .prepare("SELECT title FROM albums")
1697 .unwrap()
1698 .query_map([], |row| row.get(0))
1699 .unwrap()
1700 .map(|r| r.unwrap())
1701 .collect();
1702 assert_eq!(albums, vec!["Golden Skans (David E Sugar Remix)"]);
1703 }
1704
1705 #[test]
1706 fn test_remerge_carries_history_and_lyrics_across() {
1707 let db = test_db();
1708
1709 let mut bad = sample_meta("Untitled", "Boards of Canada", "Geogaddi");
1710 bad.path = Some("/music/boc/05.flac".into());
1711 let local_id = upsert_track(&db.conn, &bad).unwrap();
1712
1713 let remote = remote_meta("Sunshine Recorder", "Boards of Canada", "Geogaddi", "sub-7");
1714 let remote_id = upsert_track(&db.conn, &remote).unwrap();
1715
1716 crate::db::queries::record_play(
1718 &db.conn,
1719 crate::db::queries::LOCAL_USER,
1720 local_id,
1721 Some(1_000),
1722 )
1723 .unwrap();
1724 crate::db::queries::record_play(
1725 &db.conn,
1726 crate::db::queries::LOCAL_USER,
1727 remote_id,
1728 Some(2_000),
1729 )
1730 .unwrap();
1731 crate::db::queries::cache_lyrics(&db.conn, remote_id, "lrclib", true, "[00:01.00] la")
1732 .unwrap();
1733
1734 let mut fixed = bad.clone();
1735 fixed.title = "Sunshine Recorder".into();
1736 let merged = upsert_track(&db.conn, &fixed).unwrap();
1737 assert_eq!(merged, local_id);
1738
1739 assert_eq!(
1740 crate::db::queries::play_count(&db.conn, crate::db::queries::LOCAL_USER, merged)
1741 .unwrap(),
1742 2,
1743 "both rows' plays were plays of this track"
1744 );
1745 assert!(
1746 crate::db::queries::get_cached_lyrics(&db.conn, merged)
1747 .unwrap()
1748 .is_some()
1749 );
1750 let orphans: i64 = db
1751 .conn
1752 .query_row(
1753 "SELECT COUNT(*) FROM play_history WHERE track_id NOT IN (SELECT id FROM tracks)",
1754 [],
1755 |row| row.get(0),
1756 )
1757 .unwrap();
1758 assert_eq!(orphans, 0);
1759 }
1760
1761 #[test]
1762 fn test_remerge_keeps_playlist_entries() {
1763 let db = test_db();
1764
1765 let mut bad = sample_meta("Untitled", "Boards of Canada", "Geogaddi");
1766 bad.path = Some("/music/boc/05.flac".into());
1767 let local_id = upsert_track(&db.conn, &bad).unwrap();
1768 let remote = remote_meta("Sunshine Recorder", "Boards of Canada", "Geogaddi", "sub-7");
1769 let remote_id = upsert_track(&db.conn, &remote).unwrap();
1770
1771 let list = crate::db::queries::create_playlist(
1772 &db.conn,
1773 crate::db::queries::LOCAL_USER,
1774 "Road trip",
1775 None,
1776 )
1777 .unwrap();
1778 let entry = crate::db::queries::add_tracks(&db.conn, list, &[remote_id]).unwrap()[0];
1779
1780 let mut fixed = bad.clone();
1781 fixed.title = "Sunshine Recorder".into();
1782 let merged = upsert_track(&db.conn, &fixed).unwrap();
1783 assert_eq!(merged, local_id);
1784
1785 let entries = crate::db::queries::playlist_entries(&db.conn, list).unwrap();
1786 assert_eq!(
1787 entries.len(),
1788 1,
1789 "the merge left the playlist entry in place"
1790 );
1791 assert_eq!((entries[0].id, entries[0].track.id), (entry, merged));
1792 }
1793
1794 #[test]
1795 fn test_remerge_never_folds_two_local_files() {
1796 let db = test_db();
1797
1798 let mut first = sample_meta("Intro", "Various", "Compilation");
1799 first.path = Some("/music/comp/a.flac".into());
1800 let mut second = first.clone();
1801 second.title = "Untitled".into();
1802 second.path = Some("/music/comp/b.flac".into());
1803
1804 let id_a = upsert_track(&db.conn, &first).unwrap();
1805 let id_b = upsert_track(&db.conn, &second).unwrap();
1806
1807 second.title = "Intro".into();
1810 assert_eq!(upsert_track(&db.conn, &second).unwrap(), id_b);
1811 assert_ne!(id_a, id_b);
1812 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
1813 }
1814
1815 #[test]
1816 fn test_remerge_folds_a_renamed_remote_entry_into_the_local_file() {
1817 let db = test_db();
1818
1819 let local = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker");
1820 let local_id = upsert_track(&db.conn, &local).unwrap();
1821
1822 let mut remote = remote_meta("Windowlickr", "Aphex Twin", "Windowlicker", "sub-1");
1824 let remote_id = upsert_track(&db.conn, &remote).unwrap();
1825 assert_ne!(local_id, remote_id);
1826
1827 remote.title = "Windowlicker".into();
1830 let merged = upsert_track(&db.conn, &remote).unwrap();
1831
1832 assert_eq!(
1833 merged, local_id,
1834 "the older row survives, with its id and history"
1835 );
1836 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
1837 let row = get_track_row(&db.conn, merged).unwrap().unwrap();
1838 assert_eq!(row.path, local.path);
1839 assert_eq!(row.remote_id.as_deref(), Some("sub-1"));
1840 assert_eq!(row.source, "local");
1841 }
1842
1843 #[test]
1844 fn test_force_remove_lifts_the_fraction_brake_only() {
1845 let db = test_db();
1846 for i in 0..STALE_CHECK_MIN_ROWS + 20 {
1847 let mut meta = sample_meta(&format!("Track{}", i), "Artist", "Album");
1848 meta.track_number = Some(i as i32);
1849 meta.path = Some(format!("/music/Album/{}.flac", i));
1850 upsert_track(&db.conn, &meta).unwrap();
1851 }
1852 let total = library_stats(&db.conn).unwrap().total_tracks as usize;
1853
1854 let removed = remove_stale_tracks(&db.conn, Path::new("/music"), true).unwrap();
1855 assert_eq!(removed.len(), total, "every missing file should go");
1856 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
1857 assert!(
1858 removed.iter().all(|p| p.starts_with("/music/Album/")),
1859 "the removed paths should be reported back"
1860 );
1861 }
1862
1863 #[test]
1864 fn test_remote_upsert_preserves_local_audio_properties() {
1865 let db = test_db();
1866
1867 let local = sample_meta("Song", "Artist", "Album");
1869 let id = upsert_track(&db.conn, &local).unwrap();
1870
1871 let mut remote = sample_meta("Song", "Artist", "Album");
1873 remote.source = "remote".into();
1874 remote.path = None;
1875 remote.remote_id = Some("sub-1".into());
1876 remote.sample_rate = None;
1877 remote.bit_depth = None;
1878 remote.channels = None;
1879 remote.size_bytes = None;
1880 remote.mtime = None;
1881 remote.codec = None;
1882 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
1883
1884 let codec: Option<String> = db
1885 .conn
1886 .query_row("SELECT codec FROM tracks WHERE id = ?1", params![id], |r| {
1887 r.get(0)
1888 })
1889 .unwrap();
1890 let num = |col: &str| -> Option<i64> {
1891 db.conn
1892 .query_row(
1893 &format!("SELECT {} FROM tracks WHERE id = ?1", col),
1894 params![id],
1895 |r| r.get(0),
1896 )
1897 .unwrap()
1898 };
1899
1900 assert_eq!(codec.as_deref(), Some("FLAC"));
1901 assert_eq!(num("sample_rate"), Some(44100));
1902 assert_eq!(num("bit_depth"), Some(16));
1903 assert_eq!(num("channels"), Some(2));
1904 assert_eq!(num("size_bytes"), Some(30_000_000));
1905 assert_eq!(num("mtime"), Some(1700000000));
1906 }
1907
1908 #[test]
1909 fn test_dedup_matches_across_differing_artist_credits() {
1910 let db = test_db();
1911
1912 let local = sample_meta("Treading Water", "Petrol Girls", "Talk of Violence");
1916 let id = upsert_track(&db.conn, &local).unwrap();
1917
1918 let mut remote = sample_meta(
1919 "Treading Water",
1920 "Petrol Girls • Ren Aldridge",
1921 "Talk of Violence",
1922 );
1923 remote.album_artist = Some("Petrol Girls".into());
1924 remote.source = "remote".into();
1925 remote.path = None;
1926 remote.remote_id = Some("sub-1".into());
1927
1928 assert_eq!(
1929 upsert_track(&db.conn, &remote).unwrap(),
1930 id,
1931 "one recording, however each source spells the credit"
1932 );
1933
1934 let rows: i64 = db
1935 .conn
1936 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
1937 .unwrap();
1938 assert_eq!(rows, 1, "the album page must not show the track twice");
1939 }
1940
1941 #[test]
1942 fn test_dedup_reads_disc_zero_as_no_disc() {
1943 let db = test_db();
1944
1945 let mut local = sample_meta(
1947 "Miss Broadway (Main Version)",
1948 "Glass Candy",
1949 "Miss Broadway",
1950 );
1951 local.disc = Some(0);
1952 let id = upsert_track(&db.conn, &local).unwrap();
1953
1954 let mut remote = remote_meta(
1955 "Miss Broadway (Main Version)",
1956 "Glass Candy",
1957 "Miss Broadway",
1958 "sub-1",
1959 );
1960 remote.disc = None;
1961 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
1962
1963 let disc: Option<i32> = db
1964 .conn
1965 .query_row("SELECT disc FROM tracks WHERE id = ?1", params![id], |r| {
1966 r.get(0)
1967 })
1968 .unwrap();
1969 assert_eq!(disc, None);
1970 }
1971
1972 #[test]
1973 fn test_migration_folds_tracks_split_by_a_zero_disc() {
1974 let db = test_db();
1975
1976 let local = sample_meta("Sumo", "Simtek", "In The Face EP");
1977 let winner = upsert_track(&db.conn, &local).unwrap();
1978 db.conn
1979 .execute("UPDATE tracks SET disc = 0 WHERE id = ?1", params![winner])
1980 .unwrap();
1981 db.conn
1982 .execute(
1983 "INSERT INTO tracks (album_id, artist_id, disc, track_number, title,
1984 source, remote_id, remote_url)
1985 SELECT album_id, artist_id, NULL, track_number, title,
1986 'remote', 'sub-4', 'http://server/4'
1987 FROM tracks WHERE id = ?1",
1988 params![winner],
1989 )
1990 .unwrap();
1991
1992 clear_zero_discs(&db.conn).unwrap();
1993 sources::build_from_tracks(&db.conn).unwrap();
1994
1995 let (rows, remote_id): (i64, Option<String>) = db
1996 .conn
1997 .query_row("SELECT COUNT(*), MAX(remote_id) FROM tracks", [], |r| {
1998 Ok((r.get(0)?, r.get(1)?))
1999 })
2000 .unwrap();
2001 assert_eq!(rows, 1);
2002 assert_eq!(remote_id.as_deref(), Some("sub-4"));
2003 }
2004
2005 fn with_ids(mut meta: TrackMeta, recording: &str, release: &str) -> TrackMeta {
2007 meta.mbid = Some(recording.into());
2008 meta.album_mbid = Some(release.into());
2009 meta
2010 }
2011
2012 fn track_count(db: &Database) -> i64 {
2013 db.conn
2014 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2015 .unwrap()
2016 }
2017
2018 #[test]
2019 fn test_dedup_matches_by_musicbrainz_ids_whatever_the_album_is_called() {
2020 let db = test_db();
2021
2022 let local = with_ids(
2024 sample_meta(
2025 "Hypnotized",
2026 "Oliver Koletzki",
2027 "Renaissance: The Mix Collection",
2028 ),
2029 "rec-1",
2030 "rel-1",
2031 );
2032 let id = upsert_track(&db.conn, &local).unwrap();
2033
2034 let mut remote = with_ids(
2035 remote_meta(
2036 "Hypnotized",
2037 "Oliver Koletzki",
2038 "Renaissance: The Mix Collection (Unmixed)",
2039 "sub-1",
2040 ),
2041 "rec-1",
2042 "rel-1",
2043 );
2044 remote.disc = None;
2045 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
2046 assert_eq!(track_count(&db), 1);
2047 }
2048
2049 #[test]
2050 fn test_dedup_keeps_a_recording_apart_across_releases() {
2051 let db = test_db();
2052
2053 let local = with_ids(
2055 sample_meta("Azure", "Paul Kalkbrenner", "Album"),
2056 "rec-1",
2057 "rel-1",
2058 );
2059 let first = upsert_track(&db.conn, &local).unwrap();
2060
2061 let remote = with_ids(
2062 remote_meta("Azure", "Paul Kalkbrenner", "Compilation", "sub-1"),
2063 "rec-1",
2064 "rel-2",
2065 );
2066 assert_ne!(upsert_track(&db.conn, &remote).unwrap(), first);
2067 }
2068
2069 #[test]
2070 fn test_dedup_by_musicbrainz_ids_picks_the_slot_when_a_release_repeats_a_recording() {
2071 let db = test_db();
2072
2073 let mut first = with_ids(
2075 sample_meta("Azure", "Paul Kalkbrenner", "Mixes"),
2076 "rec-1",
2077 "rel-1",
2078 );
2079 first.disc = Some(1);
2080 let first = upsert_track(&db.conn, &first).unwrap();
2081 let mut second = with_ids(
2082 sample_meta("Azure", "Paul Kalkbrenner", "Mixes"),
2083 "rec-1",
2084 "rel-1",
2085 );
2086 second.disc = Some(2);
2087 second.path = Some("/music/Mixes/2-01 Azure.flac".into());
2088 let second = upsert_track(&db.conn, &second).unwrap();
2089
2090 let mut remote = with_ids(
2091 remote_meta("Azure", "Paul Kalkbrenner", "Mixes (Unmixed)", "sub-1"),
2092 "rec-1",
2093 "rel-1",
2094 );
2095 remote.disc = Some(2);
2096 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), second);
2097 assert_ne!(first, second);
2098 }
2099
2100 #[test]
2101 fn test_rescan_with_musicbrainz_ids_folds_an_already_split_pair() {
2102 let db = test_db();
2103
2104 let local = sample_meta("Hypnotized", "Oliver Koletzki", "The Mix Collection");
2106 let id = upsert_track(&db.conn, &local).unwrap();
2107 let remote = with_ids(
2108 remote_meta(
2109 "Hypnotized",
2110 "Oliver Koletzki",
2111 "The Mix Collection (Unmixed)",
2112 "sub-1",
2113 ),
2114 "rec-1",
2115 "rel-1",
2116 );
2117 upsert_track(&db.conn, &remote).unwrap();
2118 assert_eq!(track_count(&db), 2);
2119
2120 assert_eq!(
2121 upsert_track(&db.conn, &with_ids(local, "rec-1", "rel-1")).unwrap(),
2122 id
2123 );
2124 assert_eq!(track_count(&db), 1);
2125 let remote_id: Option<String> = db
2126 .conn
2127 .query_row(
2128 "SELECT remote_id FROM tracks WHERE id = ?1",
2129 params![id],
2130 |r| r.get(0),
2131 )
2132 .unwrap();
2133 assert_eq!(remote_id.as_deref(), Some("sub-1"));
2134 let albums: i64 = db
2135 .conn
2136 .query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))
2137 .unwrap();
2138 assert_eq!(albums, 1, "the server's album goes with its last track");
2139 }
2140
2141 #[test]
2142 fn test_dedup_without_a_track_number_still_needs_the_artist() {
2143 let db = test_db();
2144
2145 let mut local = sample_meta("Untitled", "One", "Split");
2149 local.album_artist = Some("Various Artists".into());
2150 local.track_number = None;
2151 let first = upsert_track(&db.conn, &local).unwrap();
2152
2153 let mut remote = sample_meta("Untitled", "Two", "Split");
2154 remote.album_artist = Some("Various Artists".into());
2155 remote.track_number = None;
2156 remote.source = "remote".into();
2157 remote.path = None;
2158 remote.remote_id = Some("sub-1".into());
2159 let second = upsert_track(&db.conn, &remote).unwrap();
2160
2161 assert_ne!(first, second, "different artists, no slot to match on");
2162 }
2163
2164 #[test]
2165 fn test_migration_folds_tracks_split_by_an_artist_credit() {
2166 let db = test_db();
2167
2168 let local = sample_meta("Rewild", "Petrol Girls", "Talk of Violence");
2173 let winner = upsert_track(&db.conn, &local).unwrap();
2174
2175 let mut remote = sample_meta("Rewild", "Petrol Girls • Ren Aldridge", "Talk of Violence");
2176 remote.album_artist = Some("Petrol Girls".into());
2177 remote.source = "remote".into();
2178 remote.path = None;
2179 remote.remote_id = Some("sub-9".into());
2180 db.conn
2181 .execute(
2182 "INSERT INTO tracks (album_id, artist_id, disc, track_number, title,
2183 duration_ms, source, remote_id, remote_url)
2184 SELECT album_id, artist_id, disc, track_number, title, duration_ms,
2185 'remote', 'sub-9', 'http://server/9'
2186 FROM tracks WHERE id = ?1",
2187 params![winner],
2188 )
2189 .unwrap();
2190 let loser: i64 = db.conn.last_insert_rowid();
2191 assert_ne!(loser, winner);
2192
2193 sources::build_from_tracks(&db.conn).unwrap();
2194
2195 let rows: i64 = db
2196 .conn
2197 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2198 .unwrap();
2199 assert_eq!(rows, 1, "the pair collapses to one row");
2200
2201 let (path, remote_id): (Option<String>, Option<String>) = db
2202 .conn
2203 .query_row(
2204 "SELECT path, remote_id FROM tracks WHERE id = ?1",
2205 params![winner],
2206 |r| Ok((r.get(0)?, r.get(1)?)),
2207 )
2208 .unwrap();
2209 assert!(path.is_some(), "the local row survives with its file");
2210 assert_eq!(
2211 remote_id.as_deref(),
2212 Some("sub-9"),
2213 "and inherits how the server knows it"
2214 );
2215 }
2216
2217 #[test]
2218 fn test_migration_leaves_an_ambiguous_pair_alone() {
2219 let db = test_db();
2220
2221 let first = upsert_track(&db.conn, &sample_meta("Rewild", "A", "Album")).unwrap();
2223 let mut second = sample_meta("Rewild", "A", "Album");
2224 second.path = Some("/music/Album/Rewild (alt).flac".into());
2225 let second = upsert_track(&db.conn, &second).unwrap();
2226 assert_ne!(first, second);
2227
2228 sources::build_from_tracks(&db.conn).unwrap();
2229
2230 let rows: i64 = db
2231 .conn
2232 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2233 .unwrap();
2234 assert_eq!(rows, 2, "two files stay two tracks");
2235 }
2236
2237 #[test]
2238 fn test_upsert_does_not_repoint_at_a_different_live_file() {
2239 let db = test_db();
2240 let tmp = tempfile::tempdir().unwrap();
2241 let existing = tmp.path().join("original.flac");
2242 std::fs::write(&existing, b"x").unwrap();
2243
2244 let mut first = sample_meta("Song", "Artist", "Album");
2245 first.path = Some(existing.to_string_lossy().into_owned());
2246 let id = upsert_track(&db.conn, &first).unwrap();
2247
2248 let mut second = first.clone();
2251 second.path = Some(tmp.path().join("other.flac").to_string_lossy().into_owned());
2252 second.remote_id = None;
2253 db.conn
2254 .execute(
2255 "UPDATE tracks SET remote_id = 'r1' WHERE id = ?1",
2256 params![id],
2257 )
2258 .unwrap();
2259 second.remote_id = Some("r1".into());
2260 assert_eq!(upsert_track(&db.conn, &second).unwrap(), id);
2261
2262 let path: String = db
2263 .conn
2264 .query_row("SELECT path FROM tracks WHERE id = ?1", params![id], |r| {
2265 r.get(0)
2266 })
2267 .unwrap();
2268 assert_eq!(path, existing.to_string_lossy());
2269 }
2270
2271 #[test]
2272 fn test_stale_removal_clears_all_foreign_keys() {
2273 let db = test_db();
2274 let id = upsert_track(&db.conn, &sample_meta("Gone", "Artist", "Album")).unwrap();
2275
2276 db.conn
2277 .execute(
2278 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
2279 VALUES (?1, 'lrclib', 'la la', 1)",
2280 params![id],
2281 )
2282 .unwrap();
2283 db.conn
2284 .execute(
2285 "INSERT INTO play_history (track_id, played_at) VALUES (?1, 1)",
2286 params![id],
2287 )
2288 .unwrap();
2289 crate::db::queries::update_scan_cache(&db.conn, "/music/Album/Gone.flac", 1, 2, id)
2290 .unwrap();
2291
2292 assert_eq!(
2293 remove_stale_tracks(&db.conn, Path::new("/music"), false)
2294 .unwrap()
2295 .len(),
2296 1
2297 );
2298 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
2299 }
2300
2301 #[test]
2302 fn test_stale_removal_survives_orphaned_scan_cache_row() {
2303 let db = test_db();
2304 let id = upsert_track(&db.conn, &sample_meta("Gone", "Artist", "Album")).unwrap();
2305
2306 crate::db::queries::update_scan_cache(&db.conn, "/music/Album/old-name.flac", 1, 2, id)
2308 .unwrap();
2309
2310 assert_eq!(
2311 remove_stale_tracks(&db.conn, Path::new("/music"), false)
2312 .unwrap()
2313 .len(),
2314 1
2315 );
2316 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
2317
2318 let orphans: i64 = db
2319 .conn
2320 .query_row("SELECT COUNT(*) FROM scan_cache", [], |row| row.get(0))
2321 .unwrap();
2322 assert_eq!(orphans, 0);
2323 }
2324
2325 #[test]
2326 fn test_stale_removal_ignores_sibling_folder_with_shared_prefix() {
2327 let db = test_db();
2328
2329 let mut main = sample_meta("Song", "Artist", "Album");
2330 main.path = Some("/Volumes/Music/Album/Song.flac".into());
2331 upsert_track(&db.conn, &main).unwrap();
2332
2333 let mut backup = sample_meta("Song", "Artist", "Album");
2334 backup.path = Some("/Volumes/Music Backup/Album/Song.flac".into());
2335 backup.disc = Some(2);
2336 upsert_track(&db.conn, &backup).unwrap();
2337 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
2338
2339 assert_eq!(
2341 remove_stale_tracks(&db.conn, Path::new("/Volumes/Music"), false)
2342 .unwrap()
2343 .len(),
2344 1
2345 );
2346
2347 let survivor: String = db
2348 .conn
2349 .query_row("SELECT path FROM tracks", [], |row| row.get(0))
2350 .unwrap();
2351 assert_eq!(survivor, "/Volumes/Music Backup/Album/Song.flac");
2352 }
2353
2354 #[test]
2355 fn test_stale_removal_refuses_wholesale_disappearance() {
2356 let db = test_db();
2357 for i in 0..STALE_CHECK_MIN_ROWS + 20 {
2358 let mut meta = sample_meta(&format!("Track{}", i), "Artist", "Album");
2359 meta.track_number = Some(i as i32);
2360 meta.path = Some(format!("/music/Album/{}.flac", i));
2361 upsert_track(&db.conn, &meta).unwrap();
2362 }
2363 let before = library_stats(&db.conn).unwrap().total_tracks;
2364
2365 let err = remove_stale_tracks(&db.conn, Path::new("/music"), false).unwrap_err();
2366 assert!(
2367 matches!(err, DbError::UnsafeBulkDelete(_)),
2368 "expected refusal, got {:?}",
2369 err
2370 );
2371 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, before);
2372 }
2373
2374 #[test]
2375 fn server_deletions_reach_the_client() {
2376 use std::collections::HashSet;
2377 let db = test_db();
2378 let remote = |title: &str, album: &str, rid: &str, album_rid: &str| {
2379 let mut m = sample_meta(title, "Tove Lo", album);
2380 m.path = None;
2381 m.remote_id = Some(rid.into());
2382 m.album_remote_id = Some(album_rid.into());
2383 upsert_track(&db.conn, &m).unwrap();
2384 };
2385 remote("Habits", "Queen of the Clouds", "t1", "al-1");
2386 remote("Talking Body", "Queen of the Clouds", "t2", "al-1");
2387 remote("Habits", "Habits (single)", "t3", "al-2");
2388 let count = |sql: &str| -> i64 { db.conn.query_row(sql, [], |r| r.get(0)).unwrap() };
2389
2390 let albums: HashSet<String> = ["al-1".to_string()].into();
2393 assert_eq!(
2394 remove_vanished_remote(&db.conn, None, Some(&albums)).unwrap(),
2395 1
2396 );
2397 assert_eq!(
2398 count("SELECT COUNT(*) FROM albums WHERE title = 'Habits (single)'"),
2399 0
2400 );
2401 assert_eq!(count("SELECT COUNT(*) FROM tracks"), 2);
2402
2403 let tracks: HashSet<String> = ["t1".to_string()].into();
2405 assert_eq!(
2406 remove_vanished_remote(&db.conn, Some(&tracks), Some(&albums)).unwrap(),
2407 1
2408 );
2409 assert_eq!(count("SELECT COUNT(*) FROM tracks"), 1);
2410 assert_eq!(count("SELECT COUNT(*) FROM albums"), 1);
2411 }
2412
2413 #[test]
2416 fn stale_removal_trusts_the_walk_and_confirms_the_rest() {
2417 let db = test_db();
2418 let tmp = tempfile::tempdir().unwrap();
2419 let row = |name: &str| {
2420 let path = tmp.path().join(name).to_string_lossy().into_owned();
2421 let mut meta = sample_meta(name, "Artist", "Album");
2422 meta.path = Some(path.clone());
2423 upsert_track(&db.conn, &meta).unwrap();
2424 path
2425 };
2426 let walked = row("walked.flac");
2428 let gone = row("gone.flac");
2430 let present = row(".stversions-copy.flac");
2432 std::fs::write(&present, b"x").unwrap();
2433 let seen: std::collections::HashSet<String> = [walked.clone()].into();
2434
2435 let removed = remove_stale_tracks_walked(&db.conn, tmp.path(), true, Some(&seen)).unwrap();
2436 assert_eq!(removed, [gone]);
2437 let paths: Vec<String> = db
2438 .conn
2439 .prepare("SELECT path FROM local_files ORDER BY path")
2440 .unwrap()
2441 .query_map([], |r| r.get(0))
2442 .unwrap()
2443 .collect::<Result<_, _>>()
2444 .unwrap();
2445 assert_eq!(paths.len(), 2);
2446 assert!(paths.contains(&walked) && paths.contains(&present));
2447 }
2448
2449 #[test]
2450 fn test_stale_removal_drops_the_album_and_artist_it_empties() {
2451 let db = test_db();
2452 let tmp = tempfile::tempdir().unwrap();
2453 let keep = tmp.path().join("keep.flac");
2454 std::fs::write(&keep, b"x").unwrap();
2455 let mut kept = sample_meta("Kept", "Artist", "Staying");
2456 kept.path = Some(keep.to_string_lossy().into_owned());
2457 upsert_track(&db.conn, &kept).unwrap();
2458 let mut moved = sample_meta("Moved", "Other Credit", "Moved Away");
2459 moved.path = Some(tmp.path().join("gone.flac").to_string_lossy().into_owned());
2460 upsert_track(&db.conn, &moved).unwrap();
2461
2462 assert_eq!(
2463 remove_stale_tracks(&db.conn, tmp.path(), false)
2464 .unwrap()
2465 .len(),
2466 1
2467 );
2468 let count = |sql: &str| -> i64 { db.conn.query_row(sql, [], |r| r.get(0)).unwrap() };
2469 assert_eq!(
2470 count("SELECT COUNT(*) FROM albums WHERE title = 'Moved Away'"),
2471 0
2472 );
2473 assert_eq!(
2474 count("SELECT COUNT(*) FROM artists WHERE name = 'Other Credit'"),
2475 0
2476 );
2477 assert_eq!(
2478 count("SELECT COUNT(*) FROM albums WHERE title = 'Staying'"),
2479 1
2480 );
2481 }
2482
2483 #[test]
2484 fn test_stale_removal_allows_a_normal_deletion() {
2485 let db = test_db();
2486 let tmp = tempfile::tempdir().unwrap();
2487 let folder = tmp.path();
2488
2489 for i in 0..STALE_CHECK_MIN_ROWS + 20 {
2491 let file = folder.join(format!("{}.flac", i));
2492 if i > 0 {
2493 std::fs::write(&file, b"x").unwrap();
2494 }
2495 let mut meta = sample_meta(&format!("Track{}", i), "Artist", "Album");
2496 meta.track_number = Some(i as i32);
2497 meta.path = Some(file.to_string_lossy().into_owned());
2498 upsert_track(&db.conn, &meta).unwrap();
2499 }
2500
2501 assert_eq!(
2502 remove_stale_tracks(&db.conn, folder, false).unwrap().len(),
2503 1
2504 );
2505 assert_eq!(
2506 library_stats(&db.conn).unwrap().total_tracks,
2507 STALE_CHECK_MIN_ROWS + 19
2508 );
2509 }
2510
2511 #[test]
2512 fn test_resolve_playback_local_wins() {
2513 let db = test_db();
2514
2515 let local = sample_meta("Song", "Artist", "Album");
2517 let local_id = upsert_track(&db.conn, &local).unwrap();
2518
2519 match resolve_playback_path(&db.conn, local_id).unwrap() {
2520 Some(_) | None => {}
2523 }
2524 }
2525
2526 #[test]
2527 fn test_resolve_playback_remote_fallback() {
2528 let db = test_db();
2529
2530 let mut meta = sample_meta("Song", "Artist", "Album");
2531 meta.source = "remote".into();
2532 meta.path = None;
2533 meta.remote_id = Some("r42".into());
2534 meta.remote_url = Some("https://example.com/stream/r42".into());
2535 let id = upsert_track(&db.conn, &meta).unwrap();
2536
2537 let source = resolve_playback_path(&db.conn, id).unwrap().unwrap();
2538 match source {
2539 PlaybackSource::Remote(url) => {
2540 assert!(url.contains("r42"));
2541 }
2542 _ => panic!("expected Remote source"),
2543 }
2544 }
2545
2546 #[test]
2547 fn test_nonexistent_track_resolution() {
2548 let db = test_db();
2549 let result = resolve_playback_path(&db.conn, 99999).unwrap();
2550 assert!(result.is_none());
2551 }
2552
2553 #[test]
2554 fn test_dedup_local_then_remote() {
2555 let db = test_db();
2556
2557 let local = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
2559 let local_id = upsert_track(&db.conn, &local).unwrap();
2560
2561 let mut remote = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
2563 remote.source = "remote".into();
2564 remote.path = None;
2565 remote.remote_id = Some("sub-42".into());
2566 remote.remote_url = Some("https://example.com/stream/sub-42".into());
2567 let remote_id = upsert_track(&db.conn, &remote).unwrap();
2568
2569 assert_eq!(local_id, remote_id);
2571
2572 let stats = library_stats(&db.conn).unwrap();
2574 assert_eq!(stats.total_tracks, 1);
2575
2576 assert_eq!(stats.local_tracks, 1);
2578 assert_eq!(stats.remote_tracks, 0);
2579
2580 let row: (Option<String>, Option<String>, Option<String>) = db
2582 .conn
2583 .query_row(
2584 "SELECT path, remote_id, remote_url FROM tracks WHERE id = ?1",
2585 params![local_id],
2586 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2587 )
2588 .unwrap();
2589 assert!(row.0.is_some()); assert_eq!(row.1.as_deref(), Some("sub-42")); assert!(row.2.is_some()); }
2593
2594 #[test]
2595 fn test_dedup_remote_then_local() {
2596 let db = test_db();
2597
2598 let mut remote = sample_meta("Vordhosbn", "Aphex Twin", "Drukqs");
2600 remote.source = "remote".into();
2601 remote.path = None;
2602 remote.remote_id = Some("sub-99".into());
2603 remote.remote_url = Some("https://example.com/stream/sub-99".into());
2604 let remote_id = upsert_track(&db.conn, &remote).unwrap();
2605
2606 let local = sample_meta("Vordhosbn", "Aphex Twin", "Drukqs");
2608 let local_id = upsert_track(&db.conn, &local).unwrap();
2609
2610 assert_eq!(remote_id, local_id);
2612
2613 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
2615
2616 assert_eq!(library_stats(&db.conn).unwrap().local_tracks, 1);
2618
2619 let rid: Option<String> = db
2621 .conn
2622 .query_row(
2623 "SELECT remote_id FROM tracks WHERE id = ?1",
2624 params![local_id],
2625 |row| row.get(0),
2626 )
2627 .unwrap();
2628 assert_eq!(rid.as_deref(), Some("sub-99"));
2629 }
2630
2631 #[test]
2632 fn test_remove_stale_preserves_remote_backed() {
2633 let db = test_db();
2634
2635 let mut meta = sample_meta("Ageispolis", "Aphex Twin", "SAW 85-92");
2637 meta.path = Some("/nonexistent/SAW 85-92/Ageispolis.flac".into());
2638 meta.remote_id = Some("sub-10".into());
2639 meta.remote_url = Some("https://example.com/stream/sub-10".into());
2640 let id = upsert_track(&db.conn, &meta).unwrap();
2641
2642 let source: String = db
2644 .conn
2645 .query_row(
2646 "SELECT source FROM tracks WHERE id = ?1",
2647 params![id],
2648 |row| row.get(0),
2649 )
2650 .unwrap();
2651 assert_eq!(source, "local");
2652
2653 let removed =
2655 remove_stale_tracks(&db.conn, Path::new("/nonexistent/SAW 85-92"), false).unwrap();
2656 assert_eq!(removed.len(), 1);
2657
2658 let row: (
2660 Option<String>,
2661 String,
2662 Option<i64>,
2663 Option<i64>,
2664 Option<String>,
2665 ) = db
2666 .conn
2667 .query_row(
2668 "SELECT path, source, mtime, size_bytes, remote_id FROM tracks WHERE id = ?1",
2669 params![id],
2670 |row| {
2671 Ok((
2672 row.get(0)?,
2673 row.get(1)?,
2674 row.get(2)?,
2675 row.get(3)?,
2676 row.get(4)?,
2677 ))
2678 },
2679 )
2680 .unwrap();
2681 assert!(row.0.is_none(), "path should be NULL");
2682 assert_eq!(row.1, "remote", "source should be 'remote'");
2683 assert!(row.2.is_none(), "mtime should be NULL");
2684 assert!(row.3.is_none(), "size_bytes should be NULL");
2685 assert_eq!(row.4.as_deref(), Some("sub-10"), "remote_id preserved");
2686
2687 let playback = resolve_playback_path(&db.conn, id).unwrap().unwrap();
2689 match playback {
2690 PlaybackSource::Remote(url) => assert!(url.contains("sub-10")),
2691 _ => panic!("expected Remote playback source"),
2692 }
2693 }
2694
2695 #[test]
2696 fn test_remove_stale_deletes_pure_local() {
2697 let db = test_db();
2698
2699 let meta = sample_meta("PureLocal", "Artist", "Album");
2701 let id = upsert_track(&db.conn, &meta).unwrap();
2703
2704 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
2705
2706 let removed = remove_stale_tracks(&db.conn, Path::new("/music/Album"), false).unwrap();
2707 assert_eq!(removed.len(), 1);
2708
2709 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
2711
2712 let exists: bool = db
2714 .conn
2715 .query_row(
2716 "SELECT COUNT(*) > 0 FROM tracks WHERE id = ?1",
2717 params![id],
2718 |row| row.get(0),
2719 )
2720 .unwrap();
2721 assert!(!exists, "pure local track should be deleted");
2722 }
2723
2724 #[test]
2725 fn test_reattach_on_rescan() {
2726 let db = test_db();
2727
2728 let mut meta = sample_meta("Xtal", "Aphex Twin", "SAW 85-92");
2730 meta.path = Some("/nonexistent/SAW 85-92/Xtal.flac".into());
2731 meta.remote_id = Some("sub-20".into());
2732 meta.remote_url = Some("https://example.com/stream/sub-20".into());
2733 let original_id = upsert_track(&db.conn, &meta).unwrap();
2734
2735 remove_stale_tracks(&db.conn, Path::new("/nonexistent/SAW 85-92"), false).unwrap();
2737
2738 let source: String = db
2740 .conn
2741 .query_row(
2742 "SELECT source FROM tracks WHERE id = ?1",
2743 params![original_id],
2744 |row| row.get(0),
2745 )
2746 .unwrap();
2747 assert_eq!(source, "remote");
2748
2749 let mut rescan = sample_meta("Xtal", "Aphex Twin", "SAW 85-92");
2752 rescan.path = Some("/nonexistent/SAW 85-92/Xtal.flac".into());
2753 let rescan_id = upsert_track(&db.conn, &rescan).unwrap();
2754
2755 assert_eq!(original_id, rescan_id);
2757
2758 let row: (Option<String>, String, Option<String>) = db
2760 .conn
2761 .query_row(
2762 "SELECT path, source, remote_id FROM tracks WHERE id = ?1",
2763 params![rescan_id],
2764 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2765 )
2766 .unwrap();
2767 assert_eq!(
2768 row.0.as_deref(),
2769 Some("/nonexistent/SAW 85-92/Xtal.flac"),
2770 "path re-attached"
2771 );
2772 assert_eq!(row.1, "local", "source flipped back to local");
2773 assert_eq!(row.2.as_deref(), Some("sub-20"), "remote_id preserved");
2774
2775 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
2777 }
2778
2779 #[test]
2780 fn test_genres_by_artist_ids() {
2781 let db = test_db();
2782 let mut meta1 = sample_meta("Track1", "ArtistA", "Album1");
2783 meta1.genre = Some("Rock".into());
2784 upsert_track(&db.conn, &meta1).unwrap();
2785
2786 let mut meta2 = sample_meta("Track2", "ArtistA", "Album1");
2787 meta2.genre = Some("Jazz".into());
2788 meta2.track_number = Some(2);
2789 meta2.path = Some("/music/Album1/Track2.flac".into());
2790 upsert_track(&db.conn, &meta2).unwrap();
2791
2792 let mut meta3 = sample_meta("Track3", "ArtistB", "Album2");
2793 meta3.genre = Some("Metal".into());
2794 upsert_track(&db.conn, &meta3).unwrap();
2795
2796 let artist_a_id: i64 = db
2798 .conn
2799 .query_row("SELECT id FROM artists WHERE name = 'ArtistA'", [], |row| {
2800 row.get(0)
2801 })
2802 .unwrap();
2803 let artist_b_id: i64 = db
2804 .conn
2805 .query_row("SELECT id FROM artists WHERE name = 'ArtistB'", [], |row| {
2806 row.get(0)
2807 })
2808 .unwrap();
2809
2810 let genres = genres_by_artist_ids(&db.conn, &[artist_a_id, artist_b_id]).unwrap();
2811 let a_genres = genres.get(&artist_a_id).unwrap();
2812 assert!(a_genres.contains("rock"));
2813 assert!(a_genres.contains("jazz"));
2814 let b_genres = genres.get(&artist_b_id).unwrap();
2815 assert!(b_genres.contains("metal"));
2816 }
2817
2818 #[test]
2819 fn test_genres_by_artist_ids_empty() {
2820 let db = test_db();
2821 let genres = genres_by_artist_ids(&db.conn, &[]).unwrap();
2822 assert!(genres.is_empty());
2823 }
2824
2825 #[test]
2826 fn test_genres_by_album_ids() {
2827 let db = test_db();
2828 let mut meta1 = sample_meta("Track1", "Artist", "AlbumX");
2829 meta1.genre = Some("Ambient".into());
2830 upsert_track(&db.conn, &meta1).unwrap();
2831
2832 let mut meta2 = sample_meta("Track2", "Artist", "AlbumX");
2833 meta2.genre = Some("IDM".into());
2834 meta2.track_number = Some(2);
2835 meta2.path = Some("/music/AlbumX/Track2.flac".into());
2836 upsert_track(&db.conn, &meta2).unwrap();
2837
2838 let album_id: i64 = db
2839 .conn
2840 .query_row("SELECT id FROM albums WHERE title = 'AlbumX'", [], |row| {
2841 row.get(0)
2842 })
2843 .unwrap();
2844
2845 let genres = genres_by_album_ids(&db.conn, &[album_id]).unwrap();
2846 let album_genres = genres.get(&album_id).unwrap();
2847 assert!(album_genres.contains("ambient"));
2848 assert!(album_genres.contains("idm"));
2849 }
2850
2851 #[test]
2852 fn test_favourite_artist_ids_batch() {
2853 let db = test_db();
2854 let meta = sample_meta("FavTrack", "FavArtist", "FavAlbum");
2855 let id = upsert_track(&db.conn, &meta).unwrap();
2856 crate::db::queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, id).unwrap();
2857
2858 let artist_id: i64 = db
2859 .conn
2860 .query_row(
2861 "SELECT id FROM artists WHERE name = 'FavArtist'",
2862 [],
2863 |row| row.get(0),
2864 )
2865 .unwrap();
2866
2867 let fav_ids = favourite_artist_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2868 assert!(fav_ids.contains(&artist_id));
2869 }
2870
2871 #[test]
2872 fn test_favourite_artist_ids_batch_empty() {
2873 let db = test_db();
2874 let fav_ids = favourite_artist_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2875 assert!(fav_ids.is_empty());
2876 }
2877
2878 #[test]
2879 fn test_favourite_album_ids_batch() {
2880 let db = test_db();
2881 let meta = sample_meta("FavTrack", "FavArtist", "FavAlbum");
2882 let id = upsert_track(&db.conn, &meta).unwrap();
2883 crate::db::queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, id).unwrap();
2884
2885 let album_id: i64 = db
2886 .conn
2887 .query_row(
2888 "SELECT id FROM albums WHERE title = 'FavAlbum'",
2889 [],
2890 |row| row.get(0),
2891 )
2892 .unwrap();
2893
2894 let fav_ids = favourite_album_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2895 assert!(fav_ids.contains(&album_id));
2896 }
2897
2898 #[test]
2899 fn test_favourite_album_ids_batch_empty() {
2900 let db = test_db();
2901 let fav_ids = favourite_album_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2902 assert!(fav_ids.is_empty());
2903 }
2904
2905 #[test]
2906 fn test_set_cached_path_records_size_and_date() {
2907 let db = test_db();
2908 let mut meta = sample_meta("Song", "Artist", "Album");
2909 meta.source = "remote".into();
2910 meta.path = None;
2911 meta.remote_id = Some("r1".into());
2912 meta.remote_url = Some("https://example.com/r1".into());
2913 let id = upsert_track(&db.conn, &meta).unwrap();
2914
2915 let tmp = tempfile::NamedTempFile::new().unwrap();
2917 std::io::Write::write_all(&mut tmp.as_file().try_clone().unwrap(), &[0u8; 1024]).unwrap();
2918 let path = tmp.path().to_string_lossy().to_string();
2919
2920 set_cached_path(&db.conn, id, &path).unwrap();
2921
2922 let (cached_path, size, download_date): (Option<String>, Option<i64>, Option<i64>) = db
2923 .conn
2924 .query_row(
2925 "SELECT cached_path, cache_size_bytes, cache_download_date FROM tracks WHERE id = ?1",
2926 params![id],
2927 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2928 )
2929 .unwrap();
2930
2931 assert_eq!(cached_path.as_deref(), Some(path.as_str()));
2932 assert!(size.unwrap() > 0, "cache_size_bytes should be positive");
2933 assert!(
2934 download_date.unwrap() > 0,
2935 "cache_download_date should be set"
2936 );
2937 }
2938
2939 #[test]
2940 fn test_total_cache_size() {
2941 let db = test_db();
2942
2943 assert_eq!(total_cache_size(&db.conn).unwrap(), 0);
2945
2946 let mut meta = sample_meta("Song", "Artist", "Album");
2948 meta.source = "remote".into();
2949 meta.path = None;
2950 meta.remote_id = Some("r1".into());
2951 let id = upsert_track(&db.conn, &meta).unwrap();
2952
2953 db.conn
2954 .execute(
2955 "UPDATE tracks SET cached_path = '/cache/song.flac', cache_size_bytes = 50000000 WHERE id = ?1",
2956 params![id],
2957 )
2958 .unwrap();
2959
2960 assert_eq!(total_cache_size(&db.conn).unwrap(), 50_000_000);
2961 }
2962
2963 #[test]
2964 fn test_clear_cache_for_tracks() {
2965 let db = test_db();
2966 let mut meta = sample_meta("Song", "Artist", "Album");
2967 meta.source = "remote".into();
2968 meta.path = None;
2969 meta.remote_id = Some("r1".into());
2970 let id = upsert_track(&db.conn, &meta).unwrap();
2971
2972 db.conn
2973 .execute(
2974 "UPDATE tracks SET cached_path = '/cache/song.flac', cache_size_bytes = 1000, cache_download_date = 12345 WHERE id = ?1",
2975 params![id],
2976 )
2977 .unwrap();
2978
2979 clear_cached_paths_for(&db.conn, &[id]).unwrap();
2980
2981 let (path, size, date): (Option<String>, Option<i64>, Option<i64>) = db
2982 .conn
2983 .query_row(
2984 "SELECT cached_path, cache_size_bytes, cache_download_date FROM tracks WHERE id = ?1",
2985 params![id],
2986 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2987 )
2988 .unwrap();
2989
2990 assert!(path.is_none());
2991 assert!(size.is_none());
2992 assert!(date.is_none());
2993 }
2994
2995 #[test]
2996 fn test_cached_files_lru_excludes_favourited_albums() {
2997 let db = test_db();
2998
2999 for (album, tracks) in &[("AlbumA", vec!["T1", "T2"]), ("AlbumB", vec!["T3", "T4"])] {
3001 for (i, title) in tracks.iter().enumerate() {
3002 let mut meta = sample_meta(title, "Artist", album);
3003 meta.source = "remote".into();
3004 meta.path = None;
3005 meta.remote_id = Some(format!("r-{}", title));
3006 meta.track_number = Some((i + 1) as i32);
3007 let id = upsert_track(&db.conn, &meta).unwrap();
3008 let cached = format!("/cache/{}/{}.flac", album, title);
3009 db.conn
3010 .execute(
3011 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000 WHERE id = ?2",
3012 params![cached, id],
3013 )
3014 .unwrap();
3015 }
3016 }
3017
3018 let t3: i64 = db
3020 .conn
3021 .query_row(
3022 "SELECT id FROM tracks WHERE cached_path = '/cache/AlbumB/T3.flac'",
3023 [],
3024 |r| r.get(0),
3025 )
3026 .unwrap();
3027 crate::db::queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, t3).unwrap();
3028
3029 let files = cached_files_lru(&db.conn).unwrap();
3030
3031 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
3033 assert_eq!(paths, ["/cache/AlbumA/T1.flac", "/cache/AlbumA/T2.flac"]);
3034 }
3035
3036 #[test]
3037 fn eviction_keeps_what_the_queue_holds() {
3038 crate::config::isolate_config_for_tests();
3039 let db = test_db();
3040 let mut ids = Vec::new();
3041 for (album, played_at) in &[("OldAlbum", 1000), ("NewAlbum", 9000)] {
3042 let mut meta = sample_meta("Track", "Artist", album);
3043 meta.source = "remote".into();
3044 meta.path = None;
3045 meta.remote_id = Some(format!("r-{}", album));
3046 let id = upsert_track(&db.conn, &meta).unwrap();
3047 db.conn
3048 .execute(
3049 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000 WHERE id = ?2",
3050 params![format!("/nonexistent/{album}/Track.flac"), id],
3051 )
3052 .unwrap();
3053 db.conn
3054 .execute(
3055 "INSERT INTO play_history (track_id, played_at) VALUES (?1, ?2)",
3056 params![id, played_at],
3057 )
3058 .unwrap();
3059 ids.push(id);
3060 }
3061 let mut cfg = crate::config::Config::default();
3062 cfg.remote.cache_limit = Some("15MB".into());
3063
3064 let keep = std::collections::HashSet::from([ids[0]]);
3066 crate::helpers::evict_cache(&db, &cfg, &keep, false);
3067
3068 let cached: Vec<i64> = ids
3069 .iter()
3070 .copied()
3071 .filter(|id| {
3072 db.conn
3073 .query_row(
3074 "SELECT cached_path IS NOT NULL FROM tracks WHERE id = ?1",
3075 params![id],
3076 |r| r.get(0),
3077 )
3078 .unwrap()
3079 })
3080 .collect();
3081 assert_eq!(cached, vec![ids[0]]);
3082 }
3083
3084 fn cached_tracks(db: &Database, albums: &[(&str, i64, bool)]) -> Vec<i64> {
3086 albums
3087 .iter()
3088 .map(|(album, used, pinned)| {
3089 let mut meta = sample_meta("Track", "Artist", album);
3090 meta.source = "remote".into();
3091 meta.path = None;
3092 meta.remote_id = Some(format!("r-{album}"));
3093 let id = upsert_track(&db.conn, &meta).unwrap();
3094 db.conn
3095 .execute(
3096 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000,
3097 cache_download_date = ?2, cache_pinned = ?3
3098 WHERE id = ?4",
3099 params![format!("/nonexistent/{album}/Track.flac"), used, pinned, id],
3100 )
3101 .unwrap();
3102 id
3103 })
3104 .collect()
3105 }
3106
3107 fn still_cached(db: &Database, ids: &[i64]) -> Vec<i64> {
3108 ids.iter()
3109 .copied()
3110 .filter(|id| {
3111 db.conn
3112 .query_row(
3113 "SELECT cached_path IS NOT NULL FROM tracks WHERE id = ?1",
3114 params![id],
3115 |r| r.get(0),
3116 )
3117 .unwrap()
3118 })
3119 .collect()
3120 }
3121
3122 #[test]
3123 fn pinned_downloads_are_evicted_after_everything_fetched_to_play() {
3124 crate::config::isolate_config_for_tests();
3125 let db = test_db();
3126 let ids = cached_tracks(
3128 &db,
3129 &[
3130 ("Pinned", 1000, true),
3131 ("Old", 2000, false),
3132 ("New", 3000, false),
3133 ],
3134 );
3135 let mut cfg = crate::config::Config::default();
3136 cfg.remote.cache_limit = Some("15MB".into());
3137
3138 crate::helpers::evict_cache(&db, &cfg, &Default::default(), false);
3139 assert_eq!(still_cached(&db, &ids), vec![ids[0]]);
3140
3141 cfg.remote.cache_limit = Some("5MB".into());
3142 crate::helpers::evict_cache(&db, &cfg, &Default::default(), false);
3143 assert!(still_cached(&db, &ids).is_empty(), "pinned is not forever");
3144 }
3145
3146 #[test]
3147 fn eviction_takes_the_played_half_of_a_queued_album() {
3148 crate::config::isolate_config_for_tests();
3149 let db = test_db();
3150 let mut ids = Vec::new();
3151 for n in 1..=4 {
3152 let mut meta = sample_meta(&format!("T{n}"), "Artist", "Album");
3153 meta.source = "remote".into();
3154 meta.path = None;
3155 meta.remote_id = Some(format!("r-{n}"));
3156 meta.track_number = Some(n);
3157 let id = upsert_track(&db.conn, &meta).unwrap();
3158 db.conn
3159 .execute(
3160 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000 WHERE id = ?2",
3161 params![format!("/nonexistent/T{n}.flac"), id],
3162 )
3163 .unwrap();
3164 ids.push(id);
3165 }
3166 let mut cfg = crate::config::Config::default();
3167 cfg.remote.cache_limit = Some("25MB".into());
3168
3169 let keep = std::collections::HashSet::from([ids[2], ids[3]]);
3171 crate::helpers::evict_cache(&db, &cfg, &keep, false);
3172 assert_eq!(still_cached(&db, &ids), vec![ids[2], ids[3]]);
3173 }
3174
3175 #[test]
3176 fn the_window_is_what_fits_beside_pinned_downloads() {
3177 crate::config::isolate_config_for_tests();
3178 let db = test_db();
3179 let pinned = cached_tracks(&db, &[("Pinned", 1000, true)]);
3180 let mut upcoming = Vec::new();
3181 for n in 1..=5 {
3182 let mut meta = sample_meta(&format!("Q{n}"), "Artist", "Queued");
3183 meta.source = "remote".into();
3184 meta.path = None;
3185 meta.remote_id = Some(format!("q-{n}"));
3186 meta.track_number = Some(n);
3187 meta.size_bytes = None;
3189 meta.bitrate = Some(1000);
3190 meta.duration_ms = Some(80_000);
3191 upcoming.push(upsert_track(&db.conn, &meta).unwrap());
3192 }
3193
3194 let fits = crate::helpers::playback_window(&db, 45_000_000, &upcoming).unwrap();
3196 assert_eq!(fits, 3);
3197
3198 let fits = crate::helpers::playback_window(&db, 1, &upcoming).unwrap();
3200 assert_eq!(fits, 2);
3201
3202 let mut with_pinned = vec![pinned[0]];
3204 with_pinned.extend(&upcoming);
3205 let fits = crate::helpers::playback_window(&db, 45_000_000, &with_pinned).unwrap();
3206 assert_eq!(fits, 4);
3207 }
3208
3209 #[test]
3210 fn test_cached_files_lru_sorted_by_last_play() {
3211 let db = test_db();
3212
3213 let mut album_ids = Vec::new();
3215 for (album, played_at) in &[("OldAlbum", 1000), ("NewAlbum", 9000)] {
3216 let mut meta = sample_meta("Track", "Artist", album);
3217 meta.source = "remote".into();
3218 meta.path = None;
3219 meta.remote_id = Some(format!("r-{}", album));
3220 let id = upsert_track(&db.conn, &meta).unwrap();
3221 let cached = format!("/cache/{}/Track.flac", album);
3222 db.conn
3223 .execute(
3224 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000 WHERE id = ?2",
3225 params![cached, id],
3226 )
3227 .unwrap();
3228
3229 db.conn
3231 .execute(
3232 "INSERT INTO play_history (track_id, played_at) VALUES (?1, ?2)",
3233 params![id, played_at],
3234 )
3235 .unwrap();
3236
3237 album_ids.push(id);
3238 }
3239
3240 let files = cached_files_lru(&db.conn).unwrap();
3241 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
3243 assert_eq!(
3244 paths,
3245 ["/cache/OldAlbum/Track.flac", "/cache/NewAlbum/Track.flac"]
3246 );
3247 }
3248
3249 #[test]
3250 fn test_cached_files_lru_counts_a_fresh_download_as_used() {
3251 let db = test_db();
3252 for (album, played_at, downloaded) in
3254 [("Played", Some(5000), 1000), ("Fetched", None, 9000)]
3255 {
3256 let mut meta = sample_meta("Track", "Artist", album);
3257 meta.source = "remote".into();
3258 meta.path = None;
3259 meta.remote_id = Some(format!("r-{album}"));
3260 let id = upsert_track(&db.conn, &meta).unwrap();
3261 db.conn
3262 .execute(
3263 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 1, cache_download_date = ?2
3264 WHERE id = ?3",
3265 params![format!("/cache/{album}.flac"), downloaded, id],
3266 )
3267 .unwrap();
3268 if let Some(played_at) = played_at {
3269 db.conn
3270 .execute(
3271 "INSERT INTO play_history (track_id, played_at) VALUES (?1, ?2)",
3272 params![id, played_at],
3273 )
3274 .unwrap();
3275 }
3276 }
3277
3278 let files = cached_files_lru(&db.conn).unwrap();
3279 let order: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
3280 assert_eq!(order, ["/cache/Played.flac", "/cache/Fetched.flac"]);
3281 }
3282
3283 #[test]
3284 fn test_clear_cached_paths_clears_all_tracking() {
3285 let db = test_db();
3286 let mut meta = sample_meta("Song", "Artist", "Album");
3287 meta.source = "remote".into();
3288 meta.path = None;
3289 meta.remote_id = Some("r1".into());
3290 let id = upsert_track(&db.conn, &meta).unwrap();
3291
3292 db.conn
3293 .execute(
3294 "UPDATE tracks SET cached_path = '/x', cache_size_bytes = 100, cache_download_date = 999 WHERE id = ?1",
3295 params![id],
3296 )
3297 .unwrap();
3298
3299 clear_cached_paths(&db.conn).unwrap();
3300
3301 let (path, size, date): (Option<String>, Option<i64>, Option<i64>) = db
3302 .conn
3303 .query_row(
3304 "SELECT cached_path, cache_size_bytes, cache_download_date FROM tracks WHERE id = ?1",
3305 params![id],
3306 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
3307 )
3308 .unwrap();
3309
3310 assert!(path.is_none());
3311 assert!(size.is_none());
3312 assert!(date.is_none());
3313 }
3314
3315 #[test]
3316 fn test_migration_folds_a_file_indexed_under_two_spellings() {
3317 use unicode_normalization::UnicodeNormalization;
3318 let db = test_db();
3319 let nfc: String = "/music/Roman Flügel - Softice.flac".nfc().collect();
3320 let nfd: String = "/music/Roman Flügel - Softice.flac".nfd().collect();
3321 assert_ne!(nfc, nfd);
3322
3323 let mut dropped = sample_meta("Softice", "Roman Flügel", "Renaissance");
3326 dropped.path = Some(nfc.clone());
3327 dropped.remote_id = Some("sub-7".into());
3328 let winner = upsert_track(&db.conn, &dropped).unwrap();
3329 let mut scanned = sample_meta("Softice", "Roman Flügel", "Renaissance");
3330 scanned.path = Some(nfd.clone());
3331 let loser = upsert_track(&db.conn, &scanned).unwrap();
3332 assert_ne!(
3333 winner, loser,
3334 "the bytes differ, so the old key made two rows"
3335 );
3336 crate::db::queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, loser).unwrap();
3337 for (path, id) in [(&nfc, winner), (&nfd, loser)] {
3338 db.conn
3339 .execute(
3340 "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
3341 params![path, id],
3342 )
3343 .unwrap();
3344 }
3345
3346 db.conn
3348 .execute_batch("DELETE FROM local_files; DELETE FROM remote_entries;")
3349 .unwrap();
3350 merge_spelling_twins(&db.conn).unwrap();
3351
3352 let rows: Vec<(i64, String, Option<String>)> = db
3353 .conn
3354 .prepare("SELECT id, path, remote_id FROM tracks")
3355 .unwrap()
3356 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
3357 .unwrap()
3358 .collect::<Result<_, _>>()
3359 .unwrap();
3360 assert_eq!(rows.len(), 1, "one file, one row");
3361 assert_eq!(rows[0].0, winner, "the older row keeps its identity");
3362 assert_eq!(rows[0].1, nfd, "and takes the disk's spelling");
3363 assert_eq!(rows[0].2.as_deref(), Some("sub-7"), "and its sync link");
3364 let starred: i64 = db
3365 .conn
3366 .query_row("SELECT track_id FROM favourites", [], |r| r.get(0))
3367 .unwrap();
3368 assert_eq!(starred, winner, "the favourite follows the row");
3369 let cached: Vec<(String, i64)> = db
3370 .conn
3371 .prepare("SELECT path, track_id FROM scan_cache")
3372 .unwrap()
3373 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
3374 .unwrap()
3375 .collect::<Result<_, _>>()
3376 .unwrap();
3377 assert_eq!(
3378 cached,
3379 vec![(nfd, winner)],
3380 "one cache row, under the spelling a scan will ask for"
3381 );
3382 }
3383
3384 #[test]
3385 fn test_migration_leaves_a_pair_with_no_disk_spelling_alone() {
3386 use unicode_normalization::UnicodeNormalization;
3387 let db = test_db();
3388 let a: String = "/music/Isolée - Allowance.flac".nfc().collect();
3391 let b: String = "/music/Isolée - Allowance.flac"
3392 .nfc()
3393 .chain(" ".chars())
3394 .collect();
3395 for path in [&a, &b] {
3396 let mut meta = sample_meta("Allowance", "Isolée", "Renaissance");
3397 meta.path = Some(path.clone());
3398 upsert_track(&db.conn, &meta).unwrap();
3399 }
3400 merge_spelling_twins(&db.conn).unwrap();
3401 let rows: i64 = db
3402 .conn
3403 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
3404 .unwrap();
3405 assert_eq!(rows, 2);
3406 }
3407
3408 fn names_of(db: &Database, id: i64) -> (String, String, String, Option<String>) {
3409 db.conn
3410 .query_row(
3411 "SELECT t.title, a.name, al.title, t.mbid FROM tracks t
3412 JOIN artists a ON a.id = t.artist_id
3413 JOIN albums al ON al.id = t.album_id
3414 WHERE t.id = ?1",
3415 [id],
3416 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
3417 )
3418 .unwrap()
3419 }
3420
3421 #[test]
3422 fn a_merged_track_keeps_its_files_names_through_every_sync() {
3423 let db = test_db();
3424 let local = with_ids(
3425 sample_meta("Hypnotized", "Oliver Koletzki", "Renaissance"),
3426 "rec-1",
3427 "rel-1",
3428 );
3429 let id = upsert_track(&db.conn, &local).unwrap();
3430 let album = db
3431 .conn
3432 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [id], |r| {
3433 r.get::<_, i64>(0)
3434 })
3435 .unwrap();
3436 let album_uid = uid_of(&db, "albums", album);
3437
3438 let mut remote = with_ids(
3439 remote_meta(
3440 "Hypnotized",
3441 "Oliver Koletzki • Fran",
3442 "Renaissance (Unmixed)",
3443 "sub-1",
3444 ),
3445 "rec-1",
3446 "rel-1",
3447 );
3448 remote.album_artist = Some("Oliver Koletzki".into());
3449 for _ in 0..2 {
3450 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3451 assert_eq!(upsert_track(&db.conn, &local).unwrap(), id);
3452 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3453 let (_, artist, album_title, _) = names_of(&db, id);
3454 assert_eq!(artist, "Oliver Koletzki");
3455 assert_eq!(album_title, "Renaissance");
3456 }
3457
3458 assert_eq!(uid_of(&db, "albums", album), album_uid);
3461 let albums: i64 = db
3462 .conn
3463 .query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))
3464 .unwrap();
3465 let artists: i64 = db
3466 .conn
3467 .query_row("SELECT COUNT(*) FROM artists", [], |r| r.get(0))
3468 .unwrap();
3469 assert_eq!((albums, artists), (1, 1));
3470 let hits: i64 = db
3471 .conn
3472 .query_row(
3473 "SELECT COUNT(*) FROM tracks_fts WHERE tracks_fts MATCH 'Unmixed'",
3474 [],
3475 |r| r.get(0),
3476 )
3477 .unwrap();
3478 assert_eq!(hits, 0, "search indexes the names the row holds");
3479 }
3480
3481 #[test]
3482 fn a_corrected_tag_survives_a_sync_of_the_old_one() {
3483 let db = test_db();
3484 let mut local = sample_meta("Treading Water", "Petrol Girls", "Talk of Violence");
3485 local.genre = Some("Punk".into());
3486 let id = upsert_track(&db.conn, &local).unwrap();
3487 let mut remote = remote_meta(
3488 "Treading Water",
3489 "Petrol Girls",
3490 "Talk of Violence",
3491 "sub-1",
3492 );
3493 remote.genre = Some("Punk".into());
3494 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3495
3496 local.genre = Some("Post-Hardcore".into());
3497 local.mbid = Some("rec-corrected".into());
3498 upsert_track(&db.conn, &local).unwrap();
3499 remote.mbid = Some("rec-stale".into());
3500 upsert_track(&db.conn, &remote).unwrap();
3501
3502 let row = get_track_row(&db.conn, id).unwrap().unwrap();
3503 assert_eq!(row.genre.as_deref(), Some("Post-Hardcore"));
3504 assert_eq!(names_of(&db, id).3.as_deref(), Some("rec-corrected"));
3505 }
3506
3507 #[test]
3508 fn a_server_only_track_takes_the_servers_corrections() {
3509 let db = test_db();
3510 let mut remote = remote_meta(
3511 "Treading Water",
3512 "Petrol Girls",
3513 "Talk of Violence",
3514 "sub-1",
3515 );
3516 let id = upsert_track(&db.conn, &remote).unwrap();
3517 remote.title = "Treading Water (Live)".into();
3518 remote.mbid = Some("rec-2".into());
3519 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3520 let (title, _, _, mbid) = names_of(&db, id);
3521 assert_eq!(title, "Treading Water (Live)");
3522 assert_eq!(mbid.as_deref(), Some("rec-2"));
3523 }
3524
3525 #[test]
3526 fn a_corrected_musicbrainz_id_lands() {
3527 let db = test_db();
3528 let mut local = with_ids(
3529 sample_meta("Azure", "Paul Kalkbrenner", "Album"),
3530 "rec-wrong",
3531 "rel-wrong",
3532 );
3533 let id = upsert_track(&db.conn, &local).unwrap();
3534 local.mbid = Some("rec-right".into());
3535 local.album_mbid = Some("rel-right".into());
3536 upsert_track(&db.conn, &local).unwrap();
3537
3538 let release: String = db
3539 .conn
3540 .query_row(
3541 "SELECT al.mbid FROM tracks t JOIN albums al ON al.id = t.album_id WHERE t.id = ?1",
3542 [id],
3543 |r| r.get(0),
3544 )
3545 .unwrap();
3546 assert_eq!(names_of(&db, id).3.as_deref(), Some("rec-right"));
3547 assert_eq!(release, "rel-right");
3548
3549 let remote = with_ids(
3551 remote_meta("Azure", "Paul Kalkbrenner", "Album", "sub-1"),
3552 "rec-right",
3553 "rel-other",
3554 );
3555 upsert_track(&db.conn, &remote).unwrap();
3556 let release: String = db
3557 .conn
3558 .query_row(
3559 "SELECT al.mbid FROM tracks t JOIN albums al ON al.id = t.album_id WHERE t.id = ?1",
3560 [id],
3561 |r| r.get(0),
3562 )
3563 .unwrap();
3564 assert_eq!(release, "rel-right");
3565 }
3566
3567 #[test]
3568 fn corrected_tags_remerge_across_differing_artist_credits() {
3569 let db = test_db();
3570 let mut bad = sample_meta("Treading Wat", "Petrol Girls", "Talk of Viol");
3571 bad.path = Some("/music/petrol-girls/01.mp3".into());
3572 let local_id = upsert_track(&db.conn, &bad).unwrap();
3573
3574 let mut remote = remote_meta(
3575 "Treading Water",
3576 "Petrol Girls • Ren Aldridge",
3577 "Talk of Violence",
3578 "sub-1",
3579 );
3580 remote.album_artist = Some("Petrol Girls".into());
3581 upsert_track(&db.conn, &remote).unwrap();
3582 assert_eq!(track_count(&db), 2);
3583
3584 let mut fixed = bad.clone();
3585 fixed.title = "Treading Water".into();
3586 fixed.album = "Talk of Violence".into();
3587 assert_eq!(upsert_track(&db.conn, &fixed).unwrap(), local_id);
3588 assert_eq!(track_count(&db), 1, "the re-merge is the whole cascade");
3589 }
3590
3591 #[test]
3592 fn a_remerge_keeps_the_servers_uid() {
3593 let db = test_db();
3594 let mut bad = sample_meta("Archangel", "Burial", "Untru");
3595 bad.path = Some("/music/burial/01.flac".into());
3596 let local_id = upsert_track(&db.conn, &bad).unwrap();
3597
3598 let server = uuid::Uuid::now_v7().to_string();
3599 upsert_track(
3600 &db.conn,
3601 &remote_meta("Archangel", "Burial", "Untrue", &server),
3602 )
3603 .unwrap();
3604 assert_eq!(track_count(&db), 2);
3605
3606 let mut fixed = bad.clone();
3607 fixed.album = "Untrue".into();
3608 assert_eq!(upsert_track(&db.conn, &fixed).unwrap(), local_id);
3609 assert_eq!(track_count(&db), 1);
3610 assert_eq!(uid_of(&db, "tracks", local_id), server);
3611 }
3612
3613 #[test]
3614 fn a_relinked_file_keeps_the_servers_uid() {
3615 let db = test_db();
3616 let old = uuid::Uuid::now_v7().to_string();
3617 let mut file = sample_meta("Archangel", "Burial", "Untrue");
3618 file.remote_id = Some(old.clone());
3619 let file_id = upsert_track(&db.conn, &file).unwrap();
3620
3621 let new = uuid::Uuid::now_v7().to_string();
3622 upsert_track(
3623 &db.conn,
3624 &remote_meta("Archangel", "Burial", "Untrue", &new),
3625 )
3626 .unwrap();
3627 remove_vanished_remote(&db.conn, Some(&HashSet::from([new.clone()])), None).unwrap();
3628
3629 assert_eq!(track_count(&db), 1);
3630 assert_eq!(uid_of(&db, "tracks", file_id), new);
3631 }
3632
3633 fn source_names(db: &Database, table: &str) -> Vec<(String, String)> {
3634 db.conn
3635 .prepare(&format!("SELECT title, artist FROM {table} ORDER BY title"))
3636 .unwrap()
3637 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
3638 .unwrap()
3639 .collect::<rusqlite::Result<_>>()
3640 .unwrap()
3641 }
3642
3643 #[test]
3644 fn each_source_keeps_its_own_tags() {
3645 let db = test_db();
3646 let local = sample_meta("Treading Water", "Petrol Girls", "Talk of Violence");
3647 let id = upsert_track(&db.conn, &local).unwrap();
3648 let mut remote = remote_meta(
3649 "Treading Water",
3650 "Petrol Girls • Ren Aldridge",
3651 "Talk of Violence",
3652 "sub-1",
3653 );
3654 remote.album_artist = Some("Petrol Girls".into());
3655 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3656
3657 assert_eq!(
3658 source_names(&db, "local_files"),
3659 [("Treading Water".into(), "Petrol Girls".into())]
3660 );
3661 assert_eq!(
3662 source_names(&db, "remote_entries"),
3663 [(
3664 "Treading Water".into(),
3665 "Petrol Girls • Ren Aldridge".into()
3666 )]
3667 );
3668 }
3669
3670 #[test]
3671 fn a_file_retagged_as_another_track_leaves_the_server_copy() {
3672 let db = test_db();
3673 let mut local = sample_meta("Archangel", "Burial", "Untrue");
3674 let id = upsert_track(&db.conn, &local).unwrap();
3675 let remote = remote_meta("Archangel", "Burial", "Untrue", "sub-1");
3676 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3677
3678 local.title = "Near Dark".into();
3679 local.track_number = Some(2);
3680 let moved = upsert_track(&db.conn, &local).unwrap();
3681
3682 assert_eq!(track_count(&db), 2);
3683 let stays = get_track_row(&db.conn, id).unwrap().unwrap();
3684 let left = get_track_row(&db.conn, moved).unwrap().unwrap();
3685 assert_eq!(
3686 (stays.title.as_str(), stays.remote_id.as_deref(), stays.path),
3687 ("Archangel", Some("sub-1"), None)
3688 );
3689 assert_eq!(
3690 (left.title.as_str(), left.remote_id, left.path),
3691 ("Near Dark", None, local.path)
3692 );
3693 }
3694
3695 #[test]
3696 fn an_ambiguous_match_is_declined() {
3697 let db = test_db();
3698 upsert_track(
3700 &db.conn,
3701 &remote_meta("Archangel", "Burial", "Untrue", "sub-1"),
3702 )
3703 .unwrap();
3704 upsert_track(
3705 &db.conn,
3706 &remote_meta("Archangel", "Burial", "Untrue", "sub-2"),
3707 )
3708 .unwrap();
3709 upsert_track(&db.conn, &sample_meta("Archangel", "Burial", "Untrue")).unwrap();
3710 assert_eq!(track_count(&db), 3, "which copy is the file is a guess");
3711 }
3712
3713 #[test]
3714 fn names_match_whatever_their_case_or_normal_form() {
3715 use unicode_normalization::UnicodeNormalization;
3716 let db = test_db();
3717 let local = sample_meta("Sæglópur", "Sigur Rós", "Takk...");
3718 let id = upsert_track(&db.conn, &local).unwrap();
3719 let mut remote = remote_meta(
3720 &"SÆGLÓPUR".nfd().collect::<String>(),
3721 "SIGUR RÓS",
3722 "TAKK...",
3723 "sub-1",
3724 );
3725 remote.album_artist = Some("SIGUR RÓS".into());
3726 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3727 }
3728
3729 #[test]
3730 fn a_moved_file_takes_over_its_server_copy() {
3731 let tmp = tempfile::tempdir().unwrap();
3732 let db = test_db();
3733 let old = tmp.path().join("old.flac");
3734 let new = tmp.path().join("new.flac");
3735 std::fs::write(&new, b"").unwrap();
3736
3737 let mut local = sample_meta("Archangel", "Burial", "Untrue");
3738 local.path = Some(old.to_string_lossy().into_owned());
3739 let id = upsert_track(&db.conn, &local).unwrap();
3740 upsert_track(
3741 &db.conn,
3742 &remote_meta("Archangel", "Burial", "Untrue", "sub-1"),
3743 )
3744 .unwrap();
3745 db.conn
3746 .execute(
3747 "INSERT INTO play_history (track_id, played_at) VALUES (?1, 1)",
3748 params![id],
3749 )
3750 .unwrap();
3751
3752 local.path = Some(new.to_string_lossy().into_owned());
3754 upsert_track(&db.conn, &local).unwrap();
3755 assert_eq!(track_count(&db), 2);
3756 remove_stale_tracks(&db.conn, tmp.path(), false).unwrap();
3757
3758 assert_eq!(track_count(&db), 1);
3759 let row = tracks_by_ids(&db.conn, &[id]).unwrap();
3760 let row = row
3761 .first()
3762 .expect("the older row, with its history, survives");
3763 assert_eq!(row.path, local.path);
3764 assert_eq!(row.remote_id.as_deref(), Some("sub-1"));
3765 let plays: i64 = db
3766 .conn
3767 .query_row(
3768 "SELECT COUNT(*) FROM play_history WHERE track_id = ?1",
3769 [id],
3770 |r| r.get(0),
3771 )
3772 .unwrap();
3773 assert_eq!(plays, 1);
3774 }
3775
3776 #[test]
3777 fn a_favourite_follows_a_track_whose_file_is_gone() {
3778 let tmp = tempfile::tempdir().unwrap();
3779 let db = test_db();
3780 let mut local = sample_meta("Archangel", "Burial", "Untrue");
3781 local.path = Some(tmp.path().join("a.flac").to_string_lossy().into_owned());
3782 let id = upsert_track(&db.conn, &local).unwrap();
3783 upsert_track(
3784 &db.conn,
3785 &remote_meta("Archangel", "Burial", "Untrue", "sub-1"),
3786 )
3787 .unwrap();
3788 crate::db::queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, id).unwrap();
3789
3790 remove_stale_tracks(&db.conn, tmp.path(), false).unwrap();
3791
3792 let favourites =
3793 favourite_track_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
3794 assert!(favourites.contains(&id), "streamed now, and still starred");
3795 }
3796
3797 #[test]
3798 fn the_servers_album_id_lands_on_the_album_the_file_names() {
3799 let db = test_db();
3800 let id = upsert_track(
3801 &db.conn,
3802 &with_ids(
3803 sample_meta("Hypnotized", "Oliver Koletzki", "Renaissance"),
3804 "rec-1",
3805 "rel-1",
3806 ),
3807 )
3808 .unwrap();
3809 let album_uid = uuid::Uuid::now_v7().to_string();
3810 let artist_uid = uuid::Uuid::now_v7().to_string();
3811 let mut remote = with_ids(
3812 remote_meta(
3813 "Hypnotized",
3814 "Oliver Koletzki",
3815 "Renaissance (Unmixed)",
3816 "sub-1",
3817 ),
3818 "rec-1",
3819 "rel-1",
3820 );
3821 remote.album_remote_id = Some(album_uid.clone());
3822 remote.artist_remote_id = Some(artist_uid.clone());
3823 upsert_track(&db.conn, &remote).unwrap();
3824
3825 let (album, artist): (i64, i64) = db
3826 .conn
3827 .query_row(
3828 "SELECT al.id, al.artist_id FROM tracks t JOIN albums al ON al.id = t.album_id
3829 WHERE t.id = ?1",
3830 [id],
3831 |r| Ok((r.get(0)?, r.get(1)?)),
3832 )
3833 .unwrap();
3834 let rid: String = db
3835 .conn
3836 .query_row("SELECT remote_id FROM albums WHERE id = ?1", [album], |r| {
3837 r.get(0)
3838 })
3839 .unwrap();
3840 assert_eq!(rid, album_uid, "album favourites reconcile by it");
3841 assert_eq!(uid_of(&db, "albums", album), album_uid);
3842 assert_eq!(uid_of(&db, "artists", artist), artist_uid);
3843 }
3844
3845 #[test]
3846 fn a_server_fills_a_number_the_file_lacks() {
3847 let db = test_db();
3848 let mut local = with_ids(
3849 sample_meta("Archangel", "Burial", "Untrue"),
3850 "rec-1",
3851 "rel-1",
3852 );
3853 local.track_number = None;
3854 let id = upsert_track(&db.conn, &local).unwrap();
3855 let remote = with_ids(
3856 remote_meta("Archangel", "Burial", "Untrue", "sub-1"),
3857 "rec-1",
3858 "rel-1",
3859 );
3860 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3861 let row = get_track_row(&db.conn, id).unwrap().unwrap();
3862 assert_eq!(
3863 (row.track_number, row.title.as_str()),
3864 (Some(1), "Archangel")
3865 );
3866 }
3867
3868 #[test]
3869 fn two_editions_with_their_own_release_ids_are_two_albums() {
3870 let db = test_db();
3871 let edition = |title: &str, number: i32, release: &str| {
3872 let mut m = with_ids(
3873 sample_meta(title, "Paul Kalkbrenner", "Album"),
3874 title,
3875 release,
3876 );
3877 m.track_number = Some(number);
3878 m
3879 };
3880 let mut original = edition("Azure", 1, "rel-original");
3881 let remaster = edition("Azure", 1, "rel-remaster");
3882 let mut remaster = TrackMeta {
3883 path: Some("/music/Album (Remaster)/Azure.flac".into()),
3884 ..remaster
3885 };
3886 let a = upsert_track(&db.conn, &original).unwrap();
3887 let b = upsert_track(&db.conn, &remaster).unwrap();
3888 let albums = |db: &Database| -> Vec<(i64, String)> {
3889 db.conn
3890 .prepare("SELECT id, mbid FROM albums ORDER BY id")
3891 .unwrap()
3892 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
3893 .unwrap()
3894 .collect::<rusqlite::Result<_>>()
3895 .unwrap()
3896 };
3897 let before = albums(&db);
3898 assert_eq!(before.len(), 2);
3899 let album_of = |id| get_track_row(&db.conn, id).unwrap().unwrap().album_id;
3900 assert_ne!(album_of(a), album_of(b));
3901
3902 for _ in 0..2 {
3904 original.mtime = original.mtime.map(|t| t + 1);
3905 upsert_track(&db.conn, &original).unwrap();
3906 remaster.mtime = remaster.mtime.map(|t| t + 1);
3907 upsert_track(&db.conn, &remaster).unwrap();
3908 assert_eq!(albums(&db), before);
3909 }
3910 }
3911
3912 #[test]
3913 fn a_server_album_named_differently_joins_the_files_album() {
3914 let db = test_db();
3915 let file = with_ids(
3916 sample_meta("Hypnotized", "Oliver Koletzki", "Renaissance"),
3917 "rec-1",
3918 "rel-1",
3919 );
3920 let held = upsert_track(&db.conn, &file).unwrap();
3921 let entry = |title: &str, number: i32, id: &str, rec: &str| {
3922 let mut m = with_ids(
3923 remote_meta(title, "Oliver Koletzki", "Renaissance (Unmixed)", id),
3924 rec,
3925 "rel-1",
3926 );
3927 m.track_number = Some(number);
3928 m.album_remote_id = Some("al-1".into());
3929 m
3930 };
3931 upsert_track(&db.conn, &entry("Hypnotized", 1, "s-1", "rec-1")).unwrap();
3933 let streamed = upsert_track(&db.conn, &entry("Dance Tonight", 2, "s-2", "rec-2")).unwrap();
3934
3935 let album_of = |id| {
3936 get_track_row(&db.conn, id)
3937 .unwrap()
3938 .unwrap()
3939 .album_id
3940 .unwrap()
3941 };
3942 assert_eq!(album_of(held), album_of(streamed), "one record, one album");
3943 let (title, rid): (String, String) = db
3944 .conn
3945 .query_row(
3946 "SELECT title, remote_id FROM albums WHERE id = ?1",
3947 [album_of(held)],
3948 |r| Ok((r.get(0)?, r.get(1)?)),
3949 )
3950 .unwrap();
3951 assert_eq!((title.as_str(), rid.as_str()), ("Renaissance", "al-1"));
3952 let albums: i64 = db
3953 .conn
3954 .query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))
3955 .unwrap();
3956 assert_eq!(albums, 1);
3957 }
3958
3959 #[test]
3960 fn an_artist_is_one_row_whatever_its_case_or_normal_form() {
3961 use unicode_normalization::UnicodeNormalization;
3962 let db = test_db();
3963 upsert_track(&db.conn, &sample_meta("Hoppípolla", "Sigur Rós", "Takk...")).unwrap();
3964 let mut other = sample_meta(
3965 "Svefn-g-englar",
3966 &"SIGUR RÓS".nfd().collect::<String>(),
3967 "Ágætis byrjun",
3968 );
3969 other.path = Some("/music/agaetis/01.flac".into());
3970 upsert_track(&db.conn, &other).unwrap();
3971 let artists: Vec<String> = db
3972 .conn
3973 .prepare("SELECT name FROM artists")
3974 .unwrap()
3975 .query_map([], |r| r.get(0))
3976 .unwrap()
3977 .collect::<rusqlite::Result<_>>()
3978 .unwrap();
3979 assert_eq!(artists, ["Sigur Rós"], "the first spelling seen names it");
3980 }
3981
3982 #[test]
3983 fn a_server_album_synced_before_the_files_folds_into_theirs() {
3984 let db = test_db();
3985 let entry = |title: &str, number: i32, id: &str, rec: &str| {
3986 let mut m = with_ids(
3987 remote_meta(title, "Oliver Koletzki", "Renaissance (Unmixed)", id),
3988 rec,
3989 "rel-1",
3990 );
3991 m.track_number = Some(number);
3992 m.album_remote_id = Some("al-1".into());
3993 m
3994 };
3995 upsert_track(&db.conn, &entry("Hypnotized", 1, "s-1", "rec-1")).unwrap();
3996 let streamed = upsert_track(&db.conn, &entry("Dance Tonight", 2, "s-2", "rec-2")).unwrap();
3997 let held = upsert_track(
3998 &db.conn,
3999 &with_ids(
4000 sample_meta("Hypnotized", "Oliver Koletzki", "Renaissance"),
4001 "rec-1",
4002 "rel-1",
4003 ),
4004 )
4005 .unwrap();
4006
4007 let album_of = |id| {
4008 get_track_row(&db.conn, id)
4009 .unwrap()
4010 .unwrap()
4011 .album_id
4012 .unwrap()
4013 };
4014 assert_eq!(album_of(held), album_of(streamed));
4015 let titles: Vec<String> = db
4016 .conn
4017 .prepare("SELECT title FROM albums")
4018 .unwrap()
4019 .query_map([], |r| r.get(0))
4020 .unwrap()
4021 .collect::<rusqlite::Result<_>>()
4022 .unwrap();
4023 assert_eq!(titles, ["Renaissance"], "named as the files name it");
4024 }
4025}