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