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::albums::get_or_create_album;
9use super::artists::get_or_create_artist;
10use super::{PlaybackSource, TrackMeta, TrackRow};
11
12pub(crate) fn row_to_track_row(row: &rusqlite::Row) -> rusqlite::Result<TrackRow> {
18 row_to_track_row_at(row, 0)
19}
20
21pub(crate) fn row_to_track_row_at(row: &rusqlite::Row, at: usize) -> rusqlite::Result<TrackRow> {
24 let artist_name: String = row.get::<_, Option<String>>(at + 3)?.unwrap_or_default();
25 Ok(TrackRow {
26 id: row.get(at)?,
27 album_id: row.get(at + 1)?,
28 artist_id: row.get(at + 2)?,
29 artist_name: artist_name.clone(),
30 album_artist_name: row.get::<_, Option<String>>(at + 4)?.unwrap_or(artist_name),
31 album_title: row.get::<_, Option<String>>(at + 5)?.unwrap_or_default(),
32 disc: row.get(at + 6)?,
33 track_number: row.get(at + 7)?,
34 title: row.get(at + 8)?,
35 duration_ms: row.get(at + 9)?,
36 path: row.get(at + 10)?,
37 codec: row.get(at + 11)?,
38 sample_rate: row.get(at + 12)?,
39 bit_depth: row.get(at + 13)?,
40 channels: row.get(at + 14)?,
41 bitrate: row.get(at + 15)?,
42 genre: row.get(at + 16)?,
43 source: row.get(at + 17)?,
44 remote_id: row.get(at + 18)?,
45 cached_path: row.get(at + 19)?,
46 })
47}
48
49#[derive(Clone)]
52struct ExistingTrack {
53 album_id: Option<i64>,
54 artist_id: Option<i64>,
55 disc: Option<i32>,
56 track_number: Option<i32>,
57 title: String,
58 source: String,
59 path: Option<String>,
60 remote_id: Option<String>,
61 remote_url: Option<String>,
62 cached_path: Option<String>,
63 codec: Option<String>,
64 sample_rate: Option<i32>,
65 bit_depth: Option<i32>,
66 channels: Option<i32>,
67 bitrate: Option<i32>,
68 duration_ms: Option<i64>,
69 size_bytes: Option<i64>,
70 mtime: Option<i64>,
71 genre: Option<String>,
72 mbid: Option<String>,
73}
74
75impl ExistingTrack {
76 fn load(conn: &Connection, id: i64) -> Result<Self, DbError> {
77 Ok(conn
78 .prepare_cached(
79 "SELECT album_id, artist_id, path, remote_id, remote_url, cached_path, codec,
80 sample_rate, bit_depth, channels, bitrate, duration_ms, size_bytes,
81 mtime, genre, mbid, disc, track_number, title, source
82 FROM tracks WHERE id = ?1",
83 )?
84 .query_row(params![id], |row| {
85 Ok(ExistingTrack {
86 album_id: row.get(0)?,
87 artist_id: row.get(1)?,
88 path: row.get(2)?,
89 remote_id: row.get(3)?,
90 remote_url: row.get(4)?,
91 cached_path: row.get(5)?,
92 codec: row.get(6)?,
93 sample_rate: row.get(7)?,
94 bit_depth: row.get(8)?,
95 channels: row.get(9)?,
96 bitrate: row.get(10)?,
97 duration_ms: row.get(11)?,
98 size_bytes: row.get(12)?,
99 mtime: row.get(13)?,
100 genre: row.get(14)?,
101 mbid: row.get(15)?,
102 disc: row.get(16)?,
103 track_number: row.get(17)?,
104 title: row.get(18)?,
105 source: row.get(19)?,
106 })
107 })?)
108 }
109
110 fn absorb(&mut self, other: ExistingTrack) {
114 self.path = self.path.take().or(other.path);
115 self.remote_id = self.remote_id.take().or(other.remote_id);
116 self.remote_url = self.remote_url.take().or(other.remote_url);
117 self.cached_path = self.cached_path.take().or(other.cached_path);
118 self.codec = self.codec.take().or(other.codec);
119 self.genre = self.genre.take().or(other.genre);
120 self.mbid = self.mbid.take().or(other.mbid);
121 self.sample_rate = self.sample_rate.or(other.sample_rate);
122 self.bit_depth = self.bit_depth.or(other.bit_depth);
123 self.channels = self.channels.or(other.channels);
124 self.bitrate = self.bitrate.or(other.bitrate);
125 self.duration_ms = self.duration_ms.or(other.duration_ms);
126 self.size_bytes = self.size_bytes.or(other.size_bytes);
127 self.mtime = self.mtime.or(other.mtime);
128 }
129}
130
131pub fn upsert_track(conn: &Connection, meta: &TrackMeta) -> Result<i64, DbError> {
157 upsert_track_status(conn, meta).map(|(id, _)| id)
158}
159
160pub fn upsert_track_status(conn: &Connection, meta: &TrackMeta) -> Result<(i64, bool), DbError> {
163 upsert_track_with(conn, meta, None)
164}
165
166pub fn upsert_synced_track(
173 conn: &Connection,
174 meta: &TrackMeta,
175 seen: &HashSet<String>,
176) -> Result<i64, DbError> {
177 upsert_track_with(conn, meta, Some(seen)).map(|(id, _)| id)
178}
179
180fn upsert_track_with(
181 conn: &Connection,
182 meta: &TrackMeta,
183 seen: Option<&HashSet<String>>,
184) -> Result<(i64, bool), DbError> {
185 conn.execute_batch("SAVEPOINT upsert_track")?;
188
189 let result = upsert_track_inner(conn, meta, seen).and_then(|(id, inserted)| {
190 if let Some(rid) = &meta.remote_id {
191 super::adopt_uid(conn, super::UidKind::Track, id, rid)?;
192 }
193 Ok((id, inserted))
194 });
195 match &result {
196 Ok(_) => conn.execute_batch("RELEASE upsert_track")?,
197 Err(_) => conn.execute_batch("ROLLBACK TO upsert_track; RELEASE upsert_track")?,
198 }
199 result
200}
201
202fn upsert_track_inner(
203 conn: &Connection,
204 meta: &TrackMeta,
205 seen: Option<&HashSet<String>>,
206) -> Result<(i64, bool), DbError> {
207 let disc = meta.disc.filter(|d| *d > 0);
210 let album_artist_name = meta.album_artist.as_deref().unwrap_or(&meta.artist);
211 let album_artist_id =
212 get_or_create_artist(conn, album_artist_name, meta.artist_remote_id.as_deref())?;
213 let track_artist_id = if meta.artist == album_artist_name {
215 album_artist_id
216 } else {
217 get_or_create_artist(conn, &meta.artist, None)?
218 };
219 let album_id = get_or_create_album(
220 conn,
221 &meta.album,
222 album_artist_id,
223 meta.date.as_deref(),
224 None,
225 None,
226 meta.codec.as_deref(),
227 meta.label.as_deref(),
228 meta.album_remote_id.as_deref(),
229 meta.album_added_at.as_deref(),
230 )?;
231 if let Some(release) = &meta.album_mbid {
232 conn.prepare_cached("UPDATE albums SET mbid = ?1 WHERE id = ?2 AND mbid IS NULL")?
233 .execute(params![release, album_id])?;
234 }
235
236 let track_id: Option<i64> = match &meta.path {
238 Some(path) => conn
239 .prepare_cached("SELECT id FROM tracks WHERE path = ?1")?
240 .query_row(params![path], |row| row.get(0))
241 .ok(),
242 None => None,
243 };
244
245 let track_id = match (track_id, &meta.remote_id) {
247 (None, Some(rid)) => conn
248 .prepare_cached("SELECT id FROM tracks WHERE remote_id = ?1")?
249 .query_row(params![rid], |row| row.get(0))
250 .ok(),
251 (found, _) => found,
252 };
253
254 let track_id = track_id.or_else(|| {
257 let (seen, rid) = (seen?, meta.remote_id.as_ref()?);
258 let mut stmt = conn
259 .prepare_cached(
260 "SELECT id, remote_id FROM tracks
261 WHERE album_id = ?1 AND title = ?2
262 AND COALESCE(track_number, -1) = COALESCE(?3, -1)
263 AND COALESCE(disc, -1) = COALESCE(?4, -1)
264 AND remote_id IS NOT NULL AND remote_id != ?5
265 ORDER BY id",
266 )
267 .ok()?;
268 let candidates: Vec<(i64, String)> = stmt
269 .query_map(
270 params![album_id, meta.title, meta.track_number, disc, rid],
271 |row| Ok((row.get(0)?, row.get(1)?)),
272 )
273 .ok()?
274 .filter_map(Result::ok)
275 .collect();
276 candidates
277 .into_iter()
278 .find(|(_, old)| !seen.contains(old))
279 .map(|(id, _)| id)
280 });
281
282 let track_id = track_id.or_else(|| {
288 conn.prepare_cached(
289 "SELECT id FROM tracks
290 WHERE artist_id = ?1 AND album_id = ?2 AND title = ?3
291 AND COALESCE(track_number, -1) = COALESCE(?4, -1)
292 AND COALESCE(disc, -1) = COALESCE(?5, -1)
293 AND (path IS NULL OR ?6 IS NULL)
294 AND (remote_id IS NULL OR ?7 IS NULL)",
295 )
296 .ok()?
297 .query_row(
298 params![
299 track_artist_id,
300 album_id,
301 meta.title,
302 meta.track_number,
303 disc,
304 meta.path,
305 meta.remote_id
306 ],
307 |row| row.get(0),
308 )
309 .ok()
310 });
311
312 let track_id = track_id.or_else(|| {
323 meta.track_number?;
325 conn.prepare_cached(
326 "SELECT id FROM tracks
327 WHERE album_id = ?1 AND title = ?2
328 AND track_number IS NOT NULL AND track_number = ?3
329 AND COALESCE(disc, -1) = COALESCE(?4, -1)
330 AND (path IS NULL OR ?5 IS NULL)
331 AND (remote_id IS NULL OR ?6 IS NULL)",
332 )
333 .ok()?
334 .query_row(
335 params![
336 album_id,
337 meta.title,
338 meta.track_number,
339 disc,
340 meta.path,
341 meta.remote_id
342 ],
343 |row| row.get(0),
344 )
345 .ok()
346 });
347
348 let track_id = track_id.or_else(|| {
350 musicbrainz_twin(
351 conn,
352 meta,
353 disc,
354 None,
355 meta.path.is_some(),
356 meta.remote_id.is_some(),
357 )
358 });
359
360 if let Some(id) = track_id {
361 let stored = ExistingTrack::load(conn, id)?;
365 let mut existing = stored.clone();
366
367 let mut vacated = vec![(existing.album_id, existing.artist_id)];
379 let have_path = meta.path.is_some() || existing.path.is_some();
380 let have_remote = meta.remote_id.is_some() || existing.remote_id.is_some();
381 if have_path != have_remote {
382 let counterpart: Option<i64> = conn
383 .prepare_cached(
384 "SELECT id FROM tracks
385 WHERE id != ?1 AND artist_id = ?2 AND album_id = ?3 AND title = ?4
386 AND COALESCE(track_number, -1) = COALESCE(?5, -1)
387 AND COALESCE(disc, -1) = COALESCE(?6, -1)
388 AND (path IS NULL OR ?7 = 0)
389 AND (remote_id IS NULL OR ?8 = 0)",
390 )?
391 .query_row(
392 params![
393 id,
394 track_artist_id,
395 album_id,
396 meta.title,
397 meta.track_number,
398 disc,
399 have_path,
400 have_remote
401 ],
402 |row| row.get(0),
403 )
404 .ok()
405 .or_else(|| musicbrainz_twin(conn, meta, disc, Some(id), have_path, have_remote));
406
407 if let Some(loser) = counterpart {
408 let absorbed = ExistingTrack::load(conn, loser)?;
409 vacated.push((absorbed.album_id, absorbed.artist_id));
410 existing.absorb(absorbed);
411 merge_track_rows(conn, loser, id)?;
412 log::info!(
413 "corrected tags matched track {} with {}; merged them into one row",
414 loser,
415 id
416 );
417 }
418 }
419
420 let merged_path = match (meta.path.as_ref(), existing.path.as_ref()) {
423 (Some(incoming), Some(current))
424 if incoming != current && Path::new(current).exists() =>
425 {
426 log::warn!(
427 "track {} already points at {}; not repointing it at {}",
428 id,
429 current,
430 incoming
431 );
432 Some(current)
433 }
434 (Some(incoming), _) => Some(incoming),
435 (None, current) => current,
436 };
437 let merged_remote_id = meta.remote_id.as_ref().or(existing.remote_id.as_ref());
438 let merged_remote_url = meta.remote_url.as_ref().or(existing.remote_url.as_ref());
439 if let (Some(old), Some(new)) = (existing.remote_url.as_ref(), merged_remote_url)
442 && old != new
443 {
444 conn.prepare_cached(
445 "UPDATE OR IGNORE favourites SET track_path = ?1 WHERE track_path = ?2",
446 )?
447 .execute(params![new, old])?;
448 }
449 let merged_cached_path = existing.cached_path.as_ref();
452 let merged_mbid = existing.mbid.as_ref().or(meta.mbid.as_ref());
453 let merged_codec = meta.codec.as_ref().or(existing.codec.as_ref());
454 let merged_genre = meta.genre.as_ref().or(existing.genre.as_ref());
455 let merged_sample_rate = meta.sample_rate.or(existing.sample_rate);
456 let merged_bit_depth = meta.bit_depth.or(existing.bit_depth);
457 let merged_channels = meta.channels.or(existing.channels);
458 let merged_bitrate = meta.bitrate.or(existing.bitrate);
459 let merged_duration_ms = meta.duration_ms.or(existing.duration_ms);
460 let merged_size_bytes = meta.size_bytes.or(existing.size_bytes);
461 let merged_mtime = meta.mtime.or(existing.mtime);
462
463 let source = if merged_path.is_some() {
465 "local"
466 } else {
467 &meta.source
468 };
469
470 let changed = (
474 Some(album_id),
475 Some(track_artist_id),
476 disc,
477 meta.track_number,
478 meta.title.as_str(),
479 source,
480 ) != (
481 stored.album_id,
482 stored.artist_id,
483 stored.disc,
484 stored.track_number,
485 stored.title.as_str(),
486 stored.source.as_str(),
487 ) || (
488 merged_duration_ms,
489 merged_codec.map(String::as_str),
490 merged_sample_rate,
491 merged_bit_depth,
492 merged_channels,
493 merged_bitrate,
494 merged_size_bytes,
495 merged_mtime,
496 merged_genre.map(String::as_str),
497 ) != (
498 stored.duration_ms,
499 stored.codec.as_deref(),
500 stored.sample_rate,
501 stored.bit_depth,
502 stored.channels,
503 stored.bitrate,
504 stored.size_bytes,
505 stored.mtime,
506 stored.genre.as_deref(),
507 ) || (
508 merged_remote_id.map(String::as_str),
509 merged_remote_url.map(String::as_str),
510 merged_path.map(String::as_str),
511 merged_mbid.map(String::as_str),
512 merged_cached_path.map(String::as_str),
513 ) != (
514 stored.remote_id.as_deref(),
515 stored.remote_url.as_deref(),
516 stored.path.as_deref(),
517 stored.mbid.as_deref(),
518 stored.cached_path.as_deref(),
519 );
520 if changed {
521 conn.prepare_cached(
522 "UPDATE tracks SET album_id=?1, artist_id=?2, disc=?3, track_number=?4,
523 title=?5, duration_ms=?6, codec=?7, sample_rate=?8, bit_depth=?9,
524 channels=?10, bitrate=?11, size_bytes=?12, mtime=?13, genre=?14,
525 source=?15, remote_id=?16, remote_url=?17, path=?18, mbid=?19,
526 cached_path=?20
527 WHERE id=?21",
528 )?
529 .execute(params![
530 album_id,
531 track_artist_id,
532 disc,
533 meta.track_number,
534 meta.title,
535 merged_duration_ms,
536 merged_codec,
537 merged_sample_rate,
538 merged_bit_depth,
539 merged_channels,
540 merged_bitrate,
541 merged_size_bytes,
542 merged_mtime,
543 merged_genre,
544 source,
545 merged_remote_id,
546 merged_remote_url,
547 merged_path,
548 merged_mbid,
549 merged_cached_path,
550 id
551 ])?;
552 }
553
554 index_for_search(
555 conn,
556 id,
557 &meta.title,
558 &fts_artist(meta, album_artist_name),
559 &meta.album,
560 merged_genre.map(String::as_str),
561 )?;
562
563 for (old_album, old_artist) in vacated {
564 prune_if_empty(
565 conn,
566 old_album.filter(|a| *a != album_id),
567 old_artist.filter(|a| *a != track_artist_id && *a != album_artist_id),
568 )?;
569 }
570
571 Ok((id, false))
572 } else {
573 let source = if meta.path.is_some() {
574 "local"
575 } else {
576 &meta.source
577 };
578
579 let uid = super::free_uid(conn, super::UidKind::Track, meta.remote_id.as_deref())?;
580 conn.prepare_cached(
581 "INSERT INTO tracks (album_id, artist_id, disc, track_number, title,
582 duration_ms, path, codec, sample_rate, bit_depth, channels, bitrate,
583 size_bytes, mtime, genre, source, remote_id, remote_url, mbid, uid)
584 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)",
585 )?
586 .execute(params![
587 album_id,
588 track_artist_id,
589 disc,
590 meta.track_number,
591 meta.title,
592 meta.duration_ms,
593 meta.path,
594 meta.codec,
595 meta.sample_rate,
596 meta.bit_depth,
597 meta.channels,
598 meta.bitrate,
599 meta.size_bytes,
600 meta.mtime,
601 meta.genre,
602 source,
603 meta.remote_id,
604 meta.remote_url,
605 meta.mbid,
606 uid
607 ])?;
608
609 let id = conn.last_insert_rowid();
610 index_for_search(
611 conn,
612 id,
613 &meta.title,
614 &fts_artist(meta, album_artist_name),
615 &meta.album,
616 meta.genre.as_deref(),
617 )?;
618
619 Ok((id, true))
620 }
621}
622
623fn fts_artist(meta: &TrackMeta, album_artist_name: &str) -> String {
626 if meta.artist == album_artist_name {
627 meta.artist.clone()
628 } else {
629 format!("{} {}", meta.artist, album_artist_name)
630 }
631}
632
633fn index_for_search(
638 conn: &Connection,
639 id: i64,
640 title: &str,
641 artist: &str,
642 album: &str,
643 genre: Option<&str>,
644) -> Result<(), DbError> {
645 let current: Option<bool> = conn
647 .prepare_cached(
648 "SELECT title IS ?2 AND artist_name IS ?3 AND album_title IS ?4 AND genre IS ?5
649 FROM tracks_fts WHERE rowid = ?1",
650 )?
651 .query_row(params![id, title, artist, album, genre], |r| r.get(0))
652 .optional()?;
653 if current == Some(true) {
654 return Ok(());
655 }
656 if current.is_some() {
657 conn.prepare_cached("DELETE FROM tracks_fts WHERE rowid = ?1")?
658 .execute(params![id])?;
659 }
660 conn.prepare_cached(
661 "INSERT INTO tracks_fts (rowid, title, artist_name, album_title, genre)
662 VALUES (?1, ?2, ?3, ?4, ?5)",
663 )?
664 .execute(params![id, title, artist, album, genre])?;
665 Ok(())
666}
667
668fn musicbrainz_twin(
677 conn: &Connection,
678 meta: &TrackMeta,
679 disc: Option<i32>,
680 not: Option<i64>,
681 have_path: bool,
682 have_remote: bool,
683) -> Option<i64> {
684 let recording = meta.mbid.as_deref().filter(|id| !id.is_empty())?;
686 let release = meta.album_mbid.as_deref().filter(|id| !id.is_empty())?;
687 let mut stmt = conn
688 .prepare_cached(
689 "SELECT t.id,
690 COALESCE(t.track_number, -1) = COALESCE(?4, -1)
691 AND (t.disc IS NULL OR ?5 IS NULL OR t.disc = ?5)
692 FROM tracks t JOIN albums a ON a.id = t.album_id
693 WHERE t.mbid = ?1 AND a.mbid = ?2 AND t.id IS NOT ?3
694 AND (t.path IS NULL OR ?6 = 0)
695 AND (t.remote_id IS NULL OR ?7 = 0)
696 ORDER BY 2 DESC
697 LIMIT 2",
698 )
699 .ok()?;
700 let candidates: Vec<(i64, bool)> = stmt
701 .query_map(
702 params![
703 recording,
704 release,
705 not,
706 meta.track_number,
707 disc,
708 have_path,
709 have_remote
710 ],
711 |row| Ok((row.get(0)?, row.get(1)?)),
712 )
713 .ok()?
714 .collect::<rusqlite::Result<_>>()
715 .ok()?;
716 match candidates.as_slice() {
717 [(id, _)] | [(id, true), ..] => Some(*id),
718 _ => None,
719 }
720}
721
722fn merge_track_rows(conn: &Connection, loser: i64, winner: i64) -> rusqlite::Result<()> {
731 for table in [
734 "play_history",
735 "scan_cache",
736 "organize_log",
737 "playlist_tracks",
738 "share_tracks",
739 ] {
740 conn.execute(
741 &format!("UPDATE {table} SET track_id = ?1 WHERE track_id = ?2"),
742 params![winner, loser],
743 )?;
744 }
745
746 for table in ["lyrics_cache", "track_vectors"] {
748 conn.execute(
749 &format!(
750 "UPDATE {table} SET track_id = ?1 WHERE track_id = ?2
751 AND NOT EXISTS (SELECT 1 FROM {table} WHERE track_id = ?1)"
752 ),
753 params![winner, loser],
754 )?;
755 conn.execute(
756 &format!("DELETE FROM {table} WHERE track_id = ?1"),
757 params![loser],
758 )?;
759 }
760
761 conn.execute("DELETE FROM tracks WHERE id = ?1", params![loser])?;
762 conn.execute("DELETE FROM tracks_fts WHERE rowid = ?1", params![loser])?;
763 Ok(())
764}
765
766pub(crate) fn clear_zero_discs(conn: &Connection) -> rusqlite::Result<()> {
770 conn.execute("UPDATE tracks SET disc = NULL WHERE disc = 0", [])?;
771 Ok(())
772}
773
774pub(crate) fn merge_split_cross_source_tracks(conn: &Connection) -> rusqlite::Result<()> {
789 let pairs: Vec<(i64, i64)> = {
790 let mut stmt = conn.prepare(
791 "SELECT r.id, l.id
792 FROM tracks l
793 JOIN tracks r
794 ON r.album_id = l.album_id
795 AND r.title = l.title
796 AND r.track_number = l.track_number
797 AND COALESCE(r.disc, -1) = COALESCE(l.disc, -1)
798 WHERE l.album_id IS NOT NULL
799 AND l.track_number IS NOT NULL
800 AND l.path IS NOT NULL AND l.remote_id IS NULL
801 AND r.path IS NULL AND r.remote_id IS NOT NULL",
802 )?;
803 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
804 rows.collect::<rusqlite::Result<Vec<_>>>()?
805 };
806
807 for (loser, winner) in pairs {
808 absorb_remote_row(conn, loser, winner)?;
809 }
810
811 Ok(())
812}
813
814fn absorb_remote_row(conn: &Connection, loser: i64, winner: i64) -> rusqlite::Result<()> {
817 let stranded: Option<i64> = conn
818 .query_row(
819 "SELECT artist_id FROM tracks WHERE id = ?1",
820 params![loser],
821 |row| row.get(0),
822 )
823 .optional()?;
824
825 conn.execute(
826 "UPDATE tracks SET
827 remote_id = (SELECT remote_id FROM tracks WHERE id = ?2),
828 remote_url = (SELECT remote_url FROM tracks WHERE id = ?2),
829 cached_path = COALESCE(cached_path, (SELECT cached_path FROM tracks WHERE id = ?2)),
830 cache_size_bytes = COALESCE(cache_size_bytes, (SELECT cache_size_bytes FROM tracks WHERE id = ?2)),
831 cache_download_date = COALESCE(cache_download_date, (SELECT cache_download_date FROM tracks WHERE id = ?2)),
832 genre = COALESCE(genre, (SELECT genre FROM tracks WHERE id = ?2)),
833 mbid = COALESCE(mbid, (SELECT mbid FROM tracks WHERE id = ?2))
834 WHERE id = ?1",
835 params![winner, loser],
836 )?;
837
838 merge_track_rows(conn, loser, winner)?;
839 prune_if_empty(conn, None, stranded)
840}
841
842pub fn remove_vanished_remote(
848 conn: &Connection,
849 live_tracks: Option<&HashSet<String>>,
850 live_albums: Option<&HashSet<String>>,
851) -> rusqlite::Result<usize> {
852 conn.execute_batch(
853 "SAVEPOINT remove_vanished;
854 CREATE TEMP TABLE IF NOT EXISTS live_ids (kind TEXT, id TEXT, PRIMARY KEY (kind, id));
855 DELETE FROM live_ids;",
856 )?;
857 let removed = (|| {
858 let mut insert =
859 conn.prepare("INSERT OR IGNORE INTO live_ids (kind, id) VALUES (?1, ?2)")?;
860 for (kind, ids) in [("track", live_tracks), ("album", live_albums)] {
861 for id in ids.into_iter().flatten() {
862 insert.execute(params![kind, id])?;
863 }
864 }
865 let track_gone = if live_tracks.is_some() {
866 "t.remote_id NOT IN (SELECT id FROM live_ids WHERE kind = 'track')"
867 } else {
868 "0"
869 };
870 let album_gone = if live_albums.is_some() {
871 "al.remote_id IS NOT NULL
872 AND al.remote_id NOT IN (SELECT id FROM live_ids WHERE kind = 'album')"
873 } else {
874 "0"
875 };
876 type Gone = (i64, Option<i64>, Option<i64>, Option<String>);
878 let gone: Vec<Gone> = conn
879 .prepare(&format!(
880 "SELECT t.id, t.album_id, t.artist_id, t.cached_path FROM tracks t
881 LEFT JOIN albums al ON al.id = t.album_id
882 WHERE t.path IS NULL AND t.remote_id IS NOT NULL
883 AND ({track_gone} OR {album_gone})"
884 ))?
885 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
886 .collect::<rusqlite::Result<_>>()?;
887 for (id, album, artist, cached) in &gone {
888 if let Some(path) = cached {
891 let _ = std::fs::remove_file(path);
892 }
893 for table in [
894 "tracks_fts WHERE rowid",
895 "lyrics_cache WHERE track_id",
896 "play_history WHERE track_id",
897 "track_vectors WHERE track_id",
898 "scan_cache WHERE track_id",
899 ] {
900 conn.execute(&format!("DELETE FROM {table} = ?1"), params![id])?;
901 }
902 conn.execute("DELETE FROM tracks WHERE id = ?1", params![id])?;
903 prune_if_empty(conn, *album, *artist)?;
904 }
905 Ok(gone.len())
906 })();
907 match removed {
908 Ok(n) => {
909 conn.execute_batch("RELEASE remove_vanished")?;
910 Ok(n)
911 }
912 Err(e) => {
913 conn.execute_batch("ROLLBACK TO remove_vanished; RELEASE remove_vanished")?;
914 Err(e)
915 }
916 }
917}
918
919pub fn relink_vanished_remote_ids(
929 conn: &Connection,
930 live: &HashSet<String>,
931) -> rusqlite::Result<usize> {
932 conn.execute_batch(
933 "SAVEPOINT relink_vanished; CREATE TEMP TABLE live_remote_ids (id TEXT PRIMARY KEY)",
934 )?;
935 let unlinked = (|| {
936 let mut insert = conn.prepare("INSERT OR IGNORE INTO live_remote_ids (id) VALUES (?1)")?;
937 for id in live {
938 insert.execute(params![id])?;
939 }
940 let unlinked: Vec<i64> = {
941 let mut stmt = conn.prepare(
942 "UPDATE tracks SET remote_id = NULL, remote_url = NULL
943 WHERE path IS NOT NULL AND remote_id IS NOT NULL
944 AND remote_id NOT IN (SELECT id FROM live_remote_ids)
945 RETURNING id",
946 )?;
947 stmt.query_map([], |row| row.get(0))?
948 .collect::<rusqlite::Result<_>>()?
949 };
950 for &file in &unlinked {
953 let counterpart: Option<i64> = conn
954 .query_row(
955 "SELECT MIN(r.id) FROM tracks f
956 JOIN tracks r
957 ON r.artist_id = f.artist_id AND r.album_id = f.album_id
958 AND r.title = f.title
959 AND COALESCE(r.track_number, -1) = COALESCE(f.track_number, -1)
960 AND COALESCE(r.disc, -1) = COALESCE(f.disc, -1)
961 WHERE f.id = ?1 AND r.path IS NULL
962 AND r.remote_id IN (SELECT id FROM live_remote_ids)
963 HAVING COUNT(*) = 1",
964 params![file],
965 |row| row.get(0),
966 )
967 .optional()?;
968 if let Some(remote) = counterpart {
969 absorb_remote_row(conn, remote, file)?;
970 }
971 }
972 merge_split_cross_source_tracks(conn)?;
973 fold_vanished_remote_rows(conn)?;
974 Ok(unlinked.len())
975 })();
976 conn.execute_batch("DROP TABLE temp.live_remote_ids")?;
977 match &unlinked {
978 Ok(_) => conn.execute_batch("RELEASE relink_vanished")?,
979 Err(_) => conn.execute_batch("ROLLBACK TO relink_vanished; RELEASE relink_vanished")?,
980 }
981 unlinked
982}
983
984fn fold_vanished_remote_rows(conn: &Connection) -> rusqlite::Result<()> {
990 let pairs: Vec<(i64, i64)> = {
991 let mut stmt = conn.prepare(
992 "SELECT d.id, MIN(l.id)
993 FROM tracks d
994 JOIN tracks l
995 ON l.album_id = d.album_id
996 AND l.title = d.title
997 AND l.track_number IS d.track_number
998 AND COALESCE(l.disc, -1) = COALESCE(d.disc, -1)
999 AND l.remote_id IN (SELECT id FROM live_remote_ids)
1000 WHERE d.path IS NULL AND d.remote_id IS NOT NULL
1001 AND d.remote_id NOT IN (SELECT id FROM live_remote_ids)
1002 GROUP BY d.id
1003 HAVING COUNT(*) = 1",
1004 )?;
1005 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1006 rows.collect::<rusqlite::Result<Vec<_>>>()?
1007 };
1008
1009 for (loser, winner) in pairs {
1010 let stranded: Option<i64> = conn
1011 .query_row(
1012 "SELECT artist_id FROM tracks WHERE id = ?1",
1013 params![loser],
1014 |row| row.get(0),
1015 )
1016 .optional()?;
1017 conn.execute(
1018 "UPDATE OR IGNORE favourites
1019 SET track_path = (SELECT COALESCE(path, remote_url) FROM tracks WHERE id = ?1)
1020 WHERE track_path = (SELECT remote_url FROM tracks WHERE id = ?2)",
1021 params![winner, loser],
1022 )?;
1023 conn.execute(
1024 "DELETE FROM favourites WHERE track_path = (SELECT remote_url FROM tracks WHERE id = ?1)",
1025 params![loser],
1026 )?;
1027 merge_track_rows(conn, loser, winner)?;
1028 prune_if_empty(conn, None, stranded)?;
1029 }
1030 Ok(())
1031}
1032
1033pub(crate) fn merge_spelling_twins(conn: &Connection) -> rusqlite::Result<()> {
1049 use unicode_normalization::{UnicodeNormalization, is_nfd};
1050
1051 let rows: Vec<(i64, String)> = {
1052 let mut stmt = conn.prepare("SELECT id, path FROM tracks WHERE path IS NOT NULL")?;
1053 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1054 rows.collect::<rusqlite::Result<Vec<_>>>()?
1055 };
1056 let mut by_spelling: HashMap<String, Vec<(i64, String)>> = HashMap::new();
1057 for (id, path) in rows.into_iter().filter(|(_, path)| !path.is_ascii()) {
1058 by_spelling
1059 .entry(path.nfc().collect())
1060 .or_default()
1061 .push((id, path));
1062 }
1063
1064 for mut pair in by_spelling.into_values().filter(|group| group.len() == 2) {
1065 pair.sort_by_key(|(id, _)| *id);
1066 let (winner, winner_path) = &pair[0];
1067 let (loser, loser_path) = &pair[1];
1068 let Some(disk) = [winner_path, loser_path]
1069 .into_iter()
1070 .find(|path| is_nfd(path))
1071 else {
1072 continue;
1073 };
1074 let disk = disk.clone();
1075 let stale = if winner_path == &disk {
1076 loser_path
1077 } else {
1078 winner_path
1079 }
1080 .clone();
1081
1082 conn.execute(
1083 "UPDATE tracks SET
1084 remote_id = COALESCE(remote_id, (SELECT remote_id FROM tracks WHERE id = ?2)),
1085 remote_url = COALESCE(remote_url, (SELECT remote_url FROM tracks WHERE id = ?2)),
1086 cached_path = COALESCE(cached_path, (SELECT cached_path FROM tracks WHERE id = ?2)),
1087 cache_size_bytes = COALESCE(cache_size_bytes, (SELECT cache_size_bytes FROM tracks WHERE id = ?2)),
1088 cache_download_date = COALESCE(cache_download_date, (SELECT cache_download_date FROM tracks WHERE id = ?2)),
1089 genre = COALESCE(genre, (SELECT genre FROM tracks WHERE id = ?2)),
1090 mbid = COALESCE(mbid, (SELECT mbid FROM tracks WHERE id = ?2))
1091 WHERE id = ?1",
1092 params![winner, loser],
1093 )?;
1094 merge_track_rows(conn, *loser, *winner)?;
1095 conn.execute("DELETE FROM scan_cache WHERE path = ?1", params![stale])?;
1096 conn.execute(
1097 "UPDATE tracks SET path = ?1 WHERE id = ?2",
1098 params![disk, winner],
1099 )?;
1100 conn.execute(
1101 "UPDATE OR REPLACE favourites SET track_path = ?1 WHERE track_path = ?2",
1102 params![disk, stale],
1103 )?;
1104 }
1105
1106 Ok(())
1107}
1108
1109fn prune_if_empty(
1113 conn: &Connection,
1114 album_id: Option<i64>,
1115 artist_id: Option<i64>,
1116) -> rusqlite::Result<()> {
1117 if let Some(album_id) = album_id {
1118 conn.execute(
1119 "DELETE FROM albums WHERE id = ?1
1120 AND NOT EXISTS (SELECT 1 FROM tracks WHERE album_id = ?1)",
1121 params![album_id],
1122 )?;
1123 }
1124
1125 if let Some(artist_id) = artist_id {
1126 let stranded: bool = conn.query_row(
1127 "SELECT NOT EXISTS (SELECT 1 FROM tracks WHERE artist_id = ?1)
1128 AND NOT EXISTS (SELECT 1 FROM albums WHERE artist_id = ?1)",
1129 params![artist_id],
1130 |row| row.get(0),
1131 )?;
1132 if stranded {
1133 conn.execute(
1134 "DELETE FROM similar_artists WHERE artist_id = ?1 OR similar_id = ?1",
1135 params![artist_id],
1136 )?;
1137 conn.execute("DELETE FROM artists WHERE id = ?1", params![artist_id])?;
1138 }
1139 }
1140
1141 Ok(())
1142}
1143
1144const STALE_CHECK_MIN_ROWS: i64 = 100;
1147
1148const MAX_STALE_FRACTION: f64 = 0.2;
1151
1152pub fn remove_stale_tracks(
1173 conn: &Connection,
1174 folder: &Path,
1175 force_remove: bool,
1176) -> Result<Vec<String>, DbError> {
1177 let (lower, upper) = super::folder_prefix_range(folder);
1178
1179 let total: i64 = conn.query_row(
1180 "SELECT COUNT(*) FROM tracks WHERE path >= ?1 AND path < ?2",
1181 params![lower, upper],
1182 |row| row.get(0),
1183 )?;
1184
1185 let mut stmt = conn.prepare(
1190 "SELECT t.id, t.path, t.remote_id, t.album_id, t.artist_id FROM tracks t
1191 WHERE t.path >= ?1 AND t.path < ?2",
1192 )?;
1193
1194 type Stale = (i64, String, Option<String>, Option<i64>, Option<i64>);
1195 let stale: Vec<Stale> = stmt
1196 .query_map(params![lower, upper], |row| {
1197 Ok((
1198 row.get::<_, i64>(0)?,
1199 row.get::<_, String>(1)?,
1200 row.get::<_, Option<String>>(2)?,
1201 row.get::<_, Option<i64>>(3)?,
1202 row.get::<_, Option<i64>>(4)?,
1203 ))
1204 })?
1205 .filter_map(|r| r.ok())
1206 .filter(|(_, path, ..)| matches!(Path::new(path).try_exists(), Ok(false)))
1209 .collect();
1210
1211 let count = stale.len();
1212 if !force_remove
1213 && total >= STALE_CHECK_MIN_ROWS
1214 && count as f64 > total as f64 * MAX_STALE_FRACTION
1215 {
1216 return Err(DbError::UnsafeBulkDelete(format!(
1217 "{} of {} tracks under {} are missing ({:.0}% of the folder) — that reads as an \
1218 unmounted or unreadable folder rather than a deletion, so nothing was removed. \
1219 If the files really are gone, re-run with `koan scan --force-remove`.",
1220 count,
1221 total,
1222 folder.display(),
1223 count as f64 / total as f64 * 100.0
1224 )));
1225 }
1226
1227 if force_remove && count > 0 {
1228 log::warn!(
1229 "--force-remove: deleting {} of {} tracks under {} along with their play history",
1230 count,
1231 total,
1232 folder.display()
1233 );
1234 }
1235
1236 for (id, path, remote_id, album_id, artist_id) in &stale {
1237 conn.execute(
1240 "DELETE FROM scan_cache WHERE track_id = ?1 OR path = ?2",
1241 params![id, path],
1242 )?;
1243
1244 if remote_id.is_some() {
1245 conn.execute(
1247 "UPDATE tracks SET path = NULL, source = 'remote', mtime = NULL, size_bytes = NULL
1248 WHERE id = ?1",
1249 params![id],
1250 )?;
1251 } else {
1252 conn.execute("DELETE FROM tracks_fts WHERE rowid = ?1", params![id])?;
1254 conn.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", params![id])?;
1255 conn.execute("DELETE FROM play_history WHERE track_id = ?1", params![id])?;
1256 conn.execute("DELETE FROM track_vectors WHERE track_id = ?1", params![id])?;
1257 conn.execute("DELETE FROM tracks WHERE id = ?1", params![id])?;
1258 prune_if_empty(conn, *album_id, *artist_id)?;
1261 }
1262 }
1263
1264 Ok(stale.into_iter().map(|(_, path, ..)| path).collect())
1265}
1266
1267pub fn tracks_for_artist(conn: &Connection, artist_id: i64) -> Result<Vec<TrackRow>, DbError> {
1274 let mut stmt = conn.prepare(
1275 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1276 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1277 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1278 t.genre, t.source, t.remote_id, t.cached_path
1279 FROM tracks t
1280 LEFT JOIN artists a ON t.artist_id = a.id
1281 LEFT JOIN albums al ON t.album_id = al.id
1282 LEFT JOIN artists aa ON al.artist_id = aa.id
1283 WHERE t.artist_id = ?1
1284 OR t.album_id IN (SELECT id FROM albums WHERE artist_id = ?1)
1285 ORDER BY al.date, al.title COLLATE LIBRARY, t.disc, t.track_number",
1286 )?;
1287 let rows = stmt
1288 .query_map(params![artist_id], row_to_track_row)?
1289 .collect::<Result<Vec<_>, _>>()?;
1290 Ok(rows)
1291}
1292
1293pub fn tracks_for_artists_in_order(
1300 conn: &Connection,
1301 artist_ids: &[i64],
1302 limit: usize,
1303) -> Result<Vec<TrackRow>, DbError> {
1304 if artist_ids.is_empty() || limit == 0 {
1305 return Ok(Vec::new());
1306 }
1307 let mut stmt = conn.prepare_cached(
1308 "WITH s AS (SELECT key AS rank, value AS artist_id FROM json_each(?1)),
1309 hits AS (
1310 SELECT t.id, s.rank FROM s JOIN tracks t ON t.artist_id = s.artist_id
1311 UNION ALL
1312 SELECT t.id, s.rank FROM s
1313 JOIN albums x ON x.artist_id = s.artist_id
1314 JOIN tracks t ON t.album_id = x.id
1315 ),
1316 best AS (SELECT id, MIN(rank) AS rank FROM hits GROUP BY id)
1317 SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1318 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1319 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1320 t.genre, t.source, t.remote_id, t.cached_path
1321 FROM best b
1322 JOIN tracks t ON t.id = b.id
1323 LEFT JOIN artists a ON t.artist_id = a.id
1324 LEFT JOIN albums al ON t.album_id = al.id
1325 LEFT JOIN artists aa ON al.artist_id = aa.id
1326 ORDER BY b.rank, al.date, al.title COLLATE LIBRARY, t.disc, t.track_number
1327 LIMIT ?2",
1328 )?;
1329 let rows = stmt
1330 .query_map(
1331 params![super::json_list(artist_ids), limit as i64],
1332 row_to_track_row,
1333 )?
1334 .collect::<Result<Vec<_>, _>>()?;
1335 Ok(rows)
1336}
1337
1338pub fn all_tracks_by_path(
1344 conn: &Connection,
1345) -> Result<std::collections::HashMap<String, TrackRow>, DbError> {
1346 let mut stmt = conn.prepare(
1347 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1348 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1349 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1350 t.genre, t.source, t.remote_id, t.cached_path
1351 FROM tracks t
1352 LEFT JOIN artists a ON t.artist_id = a.id
1353 LEFT JOIN albums al ON t.album_id = al.id
1354 LEFT JOIN artists aa ON al.artist_id = aa.id
1355 WHERE t.path IS NOT NULL",
1356 )?;
1357
1358 let rows = stmt
1359 .query_map(params![], row_to_track_row)?
1360 .collect::<Result<Vec<_>, _>>()?;
1361
1362 let mut map = std::collections::HashMap::with_capacity(rows.len());
1363 for row in rows {
1364 if let Some(ref path) = row.path {
1365 map.insert(path.clone(), row);
1366 }
1367 }
1368 Ok(map)
1369}
1370
1371pub fn tracks_by_paths(
1375 conn: &Connection,
1376 paths: &[String],
1377) -> Result<std::collections::HashMap<String, TrackRow>, DbError> {
1378 const BATCH_SIZE: usize = 500;
1379 let mut map = std::collections::HashMap::with_capacity(paths.len());
1380
1381 for chunk in paths.chunks(BATCH_SIZE) {
1382 let placeholders: String = chunk
1383 .iter()
1384 .enumerate()
1385 .map(|(i, _)| {
1386 if i == 0 {
1387 "?".to_string()
1388 } else {
1389 ",?".to_string()
1390 }
1391 })
1392 .collect();
1393
1394 let sql = format!(
1395 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1396 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1397 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1398 t.genre, t.source, t.remote_id, t.cached_path
1399 FROM tracks t
1400 LEFT JOIN artists a ON t.artist_id = a.id
1401 LEFT JOIN albums al ON t.album_id = al.id
1402 LEFT JOIN artists aa ON al.artist_id = aa.id
1403 WHERE t.path IN ({placeholders})"
1404 );
1405
1406 let mut stmt = conn.prepare(&sql)?;
1407 let params: Vec<&dyn rusqlite::types::ToSql> = chunk
1408 .iter()
1409 .map(|s| s as &dyn rusqlite::types::ToSql)
1410 .collect();
1411 let rows = stmt
1412 .query_map(params.as_slice(), row_to_track_row)?
1413 .collect::<Result<Vec<_>, _>>()?;
1414
1415 for row in rows {
1416 if let Some(ref path) = row.path {
1417 map.insert(path.clone(), row);
1418 }
1419 }
1420 }
1421
1422 Ok(map)
1423}
1424
1425pub fn all_tracks(conn: &Connection) -> Result<Vec<TrackRow>, DbError> {
1427 let mut stmt = conn.prepare(
1428 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1429 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1430 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1431 t.genre, t.source, t.remote_id, t.cached_path
1432 FROM tracks t
1433 LEFT JOIN artists a ON t.artist_id = a.id
1434 LEFT JOIN albums al ON t.album_id = al.id
1435 LEFT JOIN artists aa ON al.artist_id = aa.id
1436 ORDER BY a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY, t.disc, t.track_number",
1437 )?;
1438
1439 let rows = stmt
1440 .query_map(params![], row_to_track_row)?
1441 .collect::<Result<Vec<_>, _>>()?;
1442
1443 Ok(rows)
1444}
1445
1446pub fn random_tracks(
1448 conn: &Connection,
1449 count: u32,
1450 artist_id: Option<i64>,
1451) -> Result<Vec<TrackRow>, DbError> {
1452 random_tracks_where(
1453 conn,
1454 count,
1455 &RandomFilter {
1456 artist_id,
1457 ..Default::default()
1458 },
1459 )
1460}
1461
1462#[derive(Debug, Clone, Copy, Default)]
1464pub struct RandomFilter<'a> {
1465 pub artist_id: Option<i64>,
1467 pub genre: Option<&'a str>,
1469 pub year_from: Option<i32>,
1472 pub year_to: Option<i32>,
1473 pub exclude: &'a [i64],
1475}
1476
1477pub fn random_tracks_where(
1482 conn: &Connection,
1483 count: u32,
1484 filter: &RandomFilter,
1485) -> Result<Vec<TrackRow>, DbError> {
1486 let mut wheres: Vec<String> = Vec::new();
1487 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1488 if let Some(aid) = filter.artist_id {
1489 wheres.push(
1490 "(r.artist_id = ? OR r.album_id IN (SELECT id FROM albums WHERE artist_id = ?))".into(),
1491 );
1492 params.push(Box::new(aid));
1493 params.push(Box::new(aid));
1494 }
1495 if let Some(genre) = filter.genre {
1496 wheres.push("r.genre = ? COLLATE NOCASE".into());
1497 params.push(Box::new(genre.to_owned()));
1498 }
1499 let year =
1500 "(SELECT CAST(substr(ra.date, 1, 4) AS INTEGER) FROM albums ra WHERE ra.id = r.album_id)";
1501 if let Some(from) = filter.year_from {
1502 wheres.push(format!("{year} >= ?"));
1503 params.push(Box::new(from));
1504 }
1505 if let Some(to) = filter.year_to {
1506 wheres.push(format!("{year} <= ?"));
1507 params.push(Box::new(to));
1508 }
1509 if !filter.exclude.is_empty() {
1510 wheres.push("r.id NOT IN (SELECT value FROM json_each(?))".into());
1511 params.push(Box::new(super::json_list(filter.exclude)));
1512 }
1513 params.push(Box::new(count));
1514 let draw = if wheres.is_empty() {
1515 String::new()
1516 } else {
1517 format!("WHERE {}", wheres.join(" AND "))
1518 };
1519 let sql = format!(
1520 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1521 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1522 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1523 t.genre, t.source, t.remote_id, t.cached_path
1524 FROM tracks t
1525 LEFT JOIN artists a ON t.artist_id = a.id
1526 LEFT JOIN albums al ON t.album_id = al.id
1527 LEFT JOIN artists aa ON al.artist_id = aa.id
1528 WHERE t.id IN (SELECT r.id FROM tracks r {draw} ORDER BY RANDOM() LIMIT ?)
1529 ORDER BY RANDOM()"
1530 );
1531 let mut stmt = conn.prepare(&sql)?;
1532 let rows = stmt
1533 .query_map(rusqlite::params_from_iter(params.iter()), row_to_track_row)?
1534 .collect::<Result<Vec<_>, _>>()?;
1535 Ok(rows)
1536}
1537
1538pub fn all_tracks_paged(
1540 conn: &Connection,
1541 limit: u32,
1542 offset: u32,
1543) -> Result<Vec<TrackRow>, DbError> {
1544 let mut stmt = conn.prepare(
1545 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1546 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1547 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1548 t.genre, t.source, t.remote_id, t.cached_path
1549 FROM tracks t
1550 LEFT JOIN artists a ON t.artist_id = a.id
1551 LEFT JOIN albums al ON t.album_id = al.id
1552 LEFT JOIN artists aa ON al.artist_id = aa.id
1553 ORDER BY a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY, t.disc, t.track_number
1554 LIMIT ?1 OFFSET ?2",
1555 )?;
1556 let rows = stmt
1557 .query_map(params![limit, offset], row_to_track_row)?
1558 .collect::<Result<Vec<_>, _>>()?;
1559 Ok(rows)
1560}
1561
1562pub fn tracks_by_ids(conn: &Connection, ids: &[i64]) -> Result<Vec<TrackRow>, DbError> {
1565 if ids.is_empty() {
1566 return Ok(Vec::new());
1567 }
1568 let mut stmt = conn.prepare_cached(
1569 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1570 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1571 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1572 t.genre, t.source, t.remote_id, t.cached_path
1573 FROM tracks t
1574 LEFT JOIN artists a ON t.artist_id = a.id
1575 LEFT JOIN albums al ON t.album_id = al.id
1576 LEFT JOIN artists aa ON al.artist_id = aa.id
1577 WHERE t.id IN (SELECT value FROM json_each(?1))",
1578 )?;
1579 let rows = stmt
1580 .query_map([super::json_list(ids)], row_to_track_row)?
1581 .collect::<Result<Vec<_>, _>>()?;
1582
1583 let by_id: HashMap<i64, TrackRow> = rows.into_iter().map(|r| (r.id, r)).collect();
1590 Ok(ids.iter().filter_map(|id| by_id.get(id).cloned()).collect())
1591}
1592
1593pub fn get_track_row(conn: &Connection, track_id: i64) -> Result<Option<TrackRow>, DbError> {
1595 let result = conn
1596 .prepare_cached(
1597 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1598 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1599 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1600 t.genre, t.source, t.remote_id, t.cached_path
1601 FROM tracks t
1602 LEFT JOIN artists a ON t.artist_id = a.id
1603 LEFT JOIN albums al ON t.album_id = al.id
1604 LEFT JOIN artists aa ON al.artist_id = aa.id
1605 WHERE t.id = ?1",
1606 )?
1607 .query_row(params![track_id], row_to_track_row);
1608
1609 match result {
1610 Ok(row) => Ok(Some(row)),
1611 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1612 Err(e) => Err(e.into()),
1613 }
1614}
1615
1616pub fn track_id_by_path(conn: &Connection, path: &str) -> Result<Option<i64>, DbError> {
1618 let result = conn
1619 .prepare_cached(
1620 "SELECT id FROM tracks WHERE path = ?1 OR cached_path = ?1 OR remote_url = ?1",
1621 )?
1622 .query_row(params![path], |row| row.get(0));
1623 match result {
1624 Ok(id) => Ok(Some(id)),
1625 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1626 Err(e) => Err(e.into()),
1627 }
1628}
1629
1630pub fn clear_cached_paths(conn: &Connection) -> Result<(), DbError> {
1632 conn.execute(
1635 "UPDATE tracks SET cached_path = NULL, cache_size_bytes = NULL, cache_download_date = NULL
1636 WHERE cached_path IS NOT NULL",
1637 params![],
1638 )?;
1639 Ok(())
1640}
1641
1642pub fn cached_paths_for(conn: &Connection, track_ids: &[i64]) -> Result<Vec<String>, DbError> {
1644 if track_ids.is_empty() {
1645 return Ok(Vec::new());
1646 }
1647 let mut stmt = conn.prepare_cached(
1648 "SELECT cached_path FROM tracks
1649 WHERE id IN (SELECT value FROM json_each(?1)) AND cached_path IS NOT NULL",
1650 )?;
1651 let rows = stmt.query_map([super::json_list(track_ids)], |row| row.get(0))?;
1652 Ok(rows.filter_map(Result::ok).collect())
1653}
1654
1655pub fn clear_cached_paths_for(conn: &Connection, track_ids: &[i64]) -> Result<(), DbError> {
1658 if track_ids.is_empty() {
1659 return Ok(());
1660 }
1661 conn.execute(
1662 "UPDATE tracks SET cached_path = NULL, cache_size_bytes = NULL, cache_download_date = NULL
1663 WHERE id IN (SELECT value FROM json_each(?1))",
1664 [super::json_list(track_ids)],
1665 )?;
1666 Ok(())
1667}
1668
1669pub fn set_cached_path(conn: &Connection, track_id: i64, path: &str) -> Result<(), DbError> {
1671 let size_bytes: Option<i64> = std::fs::metadata(path).ok().map(|m| m.len() as i64);
1672 let now = std::time::SystemTime::now()
1673 .duration_since(std::time::UNIX_EPOCH)
1674 .unwrap_or_default()
1675 .as_secs() as i64;
1676 conn.execute(
1677 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = ?2, cache_download_date = ?3
1678 WHERE id = ?4",
1679 params![path, size_bytes, now, track_id],
1680 )?;
1681 Ok(())
1682}
1683
1684#[derive(Debug, Clone)]
1686pub struct CachedAlbumInfo {
1687 pub album_id: i64,
1688 pub album_title: String,
1689 pub artist_name: String,
1690 pub total_size: i64,
1691 pub track_ids: Vec<i64>,
1692 pub cached_paths: Vec<String>,
1693}
1694
1695pub fn cached_albums_lru(conn: &Connection) -> Result<Vec<CachedAlbumInfo>, DbError> {
1701 let mut stmt = conn.prepare(
1706 "SELECT t.id, t.album_id, COALESCE(al.title, 'Unknown'), COALESCE(a.name, 'Unknown'),
1707 t.cached_path, COALESCE(t.cache_size_bytes, 0),
1708 NULLIF(MAX(COALESCE(ph_max.last_play, 0), COALESCE(t.cache_download_date, 0)), 0),
1709 EXISTS(SELECT 1 FROM favourites f
1710 WHERE f.track_path = t.cached_path
1711 OR f.track_path = t.path
1712 OR f.track_path = t.remote_url) as is_fav
1713 FROM tracks t
1714 LEFT JOIN albums al ON t.album_id = al.id
1715 LEFT JOIN artists a ON al.artist_id = a.id
1716 LEFT JOIN (SELECT track_id, MAX(played_at) as last_play
1717 FROM play_history GROUP BY track_id) ph_max
1718 ON ph_max.track_id = t.id
1719 WHERE t.cached_path IS NOT NULL
1720 ORDER BY t.album_id, t.disc, t.track_number",
1721 )?;
1722
1723 struct CachedTrackRow {
1724 track_id: i64,
1725 album_id: Option<i64>,
1726 album_title: String,
1727 artist_name: String,
1728 cached_path: String,
1729 size: i64,
1730 last_play: Option<i64>,
1731 is_fav: bool,
1732 }
1733
1734 let rows: Vec<CachedTrackRow> = stmt
1735 .query_map([], |row| {
1736 Ok(CachedTrackRow {
1737 track_id: row.get(0)?,
1738 album_id: row.get(1)?,
1739 album_title: row.get(2)?,
1740 artist_name: row.get(3)?,
1741 cached_path: row.get(4)?,
1742 size: row.get(5)?,
1743 last_play: row.get(6)?,
1744 is_fav: row.get(7)?,
1745 })
1746 })?
1747 .collect::<Result<Vec<_>, _>>()?;
1748
1749 let mut albums: std::collections::BTreeMap<i64, CachedAlbumInfo> =
1751 std::collections::BTreeMap::new();
1752 let mut album_last_play: HashMap<i64, Option<i64>> = HashMap::new();
1754 let mut album_has_fav: HashSet<i64> = HashSet::new();
1755
1756 for r in &rows {
1757 let aid = r.album_id.unwrap_or(-r.track_id); if r.is_fav {
1759 album_has_fav.insert(aid);
1760 }
1761 let entry = albums.entry(aid).or_insert_with(|| CachedAlbumInfo {
1762 album_id: aid,
1763 album_title: r.album_title.clone(),
1764 artist_name: r.artist_name.clone(),
1765 total_size: 0,
1766 track_ids: Vec::new(),
1767 cached_paths: Vec::new(),
1768 });
1769 entry.total_size += r.size;
1770 entry.track_ids.push(r.track_id);
1771 entry.cached_paths.push(r.cached_path.clone());
1772
1773 let current_max = album_last_play.entry(aid).or_insert(None);
1774 *current_max = match (*current_max, r.last_play) {
1775 (Some(a), Some(b)) => Some(a.max(b)),
1776 (Some(a), None) => Some(a),
1777 (None, Some(b)) => Some(b),
1778 (None, None) => None,
1779 };
1780 }
1781
1782 let mut result: Vec<CachedAlbumInfo> = albums
1785 .into_values()
1786 .filter(|a| !album_has_fav.contains(&a.album_id))
1787 .collect();
1788
1789 result.sort_by_key(|a| album_last_play.get(&a.album_id).copied().unwrap_or(None));
1790
1791 Ok(result)
1792}
1793
1794pub fn total_cache_size(conn: &Connection) -> Result<i64, DbError> {
1796 let size: i64 = conn.query_row(
1797 "SELECT COALESCE(SUM(cache_size_bytes), 0) FROM tracks WHERE cached_path IS NOT NULL",
1798 [],
1799 |row| row.get(0),
1800 )?;
1801 Ok(size)
1802}
1803
1804pub fn resolve_playback_path(
1806 conn: &Connection,
1807 track_id: i64,
1808) -> Result<Option<PlaybackSource>, DbError> {
1809 let row = conn
1810 .prepare_cached("SELECT path, cached_path, remote_url FROM tracks WHERE id = ?1")?
1811 .query_row(params![track_id], |row| {
1812 Ok((
1813 row.get::<_, Option<String>>(0)?,
1814 row.get::<_, Option<String>>(1)?,
1815 row.get::<_, Option<String>>(2)?,
1816 ))
1817 })
1818 .optional()?;
1819 Ok(row.and_then(|(path, cached_path, remote_url)| {
1820 choose_playback_source(
1821 path.as_deref(),
1822 cached_path.as_deref(),
1823 remote_url.as_deref(),
1824 )
1825 }))
1826}
1827
1828pub fn choose_playback_source(
1831 path: Option<&str>,
1832 cached_path: Option<&str>,
1833 remote_url: Option<&str>,
1834) -> Option<PlaybackSource> {
1835 if let Some(p) = path.map(PathBuf::from).filter(|p| p.exists()) {
1836 return Some(PlaybackSource::Local(p));
1837 }
1838 if let Some(p) = cached_path.map(PathBuf::from).filter(|p| p.exists()) {
1839 return Some(PlaybackSource::Cached(p));
1840 }
1841 remote_url.map(|url| PlaybackSource::Remote(url.to_owned()))
1842}
1843
1844#[derive(Debug, Clone, Default)]
1846pub struct QueueItemExtras {
1847 pub remote_url: Option<String>,
1848 pub album_date: Option<String>,
1849}
1850
1851pub fn queue_item_extras(
1853 conn: &Connection,
1854 track_ids: &[i64],
1855) -> Result<HashMap<i64, QueueItemExtras>, DbError> {
1856 if track_ids.is_empty() {
1857 return Ok(HashMap::new());
1858 }
1859 let mut stmt = conn.prepare_cached(
1860 "SELECT t.id, t.remote_url, al.date FROM tracks t
1861 LEFT JOIN albums al ON al.id = t.album_id
1862 WHERE t.id IN (SELECT value FROM json_each(?1))",
1863 )?;
1864 let rows = stmt.query_map([super::json_list(track_ids)], |row| {
1865 Ok((
1866 row.get::<_, i64>(0)?,
1867 QueueItemExtras {
1868 remote_url: row.get(1)?,
1869 album_date: row.get(2)?,
1870 },
1871 ))
1872 })?;
1873 rows.collect::<Result<HashMap<_, _>, _>>()
1874 .map_err(Into::into)
1875}
1876
1877pub fn tracks_page(conn: &Connection, limit: u32, offset: u32) -> Result<Vec<TrackRow>, DbError> {
1884 let mut stmt = conn.prepare_cached(
1885 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1886 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1887 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1888 t.genre, t.source, t.remote_id, t.cached_path
1889 FROM tracks t
1890 LEFT JOIN artists a ON t.artist_id = a.id
1891 LEFT JOIN albums al ON t.album_id = al.id
1892 LEFT JOIN artists aa ON al.artist_id = aa.id
1893 ORDER BY t.id
1894 LIMIT ?1 OFFSET ?2",
1895 )?;
1896 let rows = stmt
1897 .query_map(params![limit, offset], row_to_track_row)?
1898 .collect::<Result<Vec<_>, _>>()?;
1899 Ok(rows)
1900}
1901
1902pub fn tracks_for_album(conn: &Connection, album_id: i64) -> Result<Vec<TrackRow>, DbError> {
1904 let mut stmt = conn.prepare_cached(
1905 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1906 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1907 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1908 t.genre, t.source, t.remote_id, t.cached_path
1909 FROM tracks t
1910 LEFT JOIN artists a ON t.artist_id = a.id
1911 LEFT JOIN albums al ON t.album_id = al.id
1912 LEFT JOIN artists aa ON al.artist_id = aa.id
1913 WHERE t.album_id = ?1
1914 ORDER BY t.disc, t.track_number",
1915 )?;
1916
1917 let rows = stmt
1918 .query_map(params![album_id], row_to_track_row)?
1919 .collect::<Result<Vec<_>, _>>()?;
1920
1921 Ok(rows)
1922}
1923
1924pub fn cover_track_for_album(
1934 conn: &Connection,
1935 album_id: i64,
1936) -> Result<Option<TrackRow>, DbError> {
1937 let mut stmt = conn.prepare_cached(
1938 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1939 t.disc, t.track_number, t.title, t.duration_ms, t.path,
1940 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1941 t.genre, t.source, t.remote_id, t.cached_path
1942 FROM tracks t
1943 LEFT JOIN artists a ON t.artist_id = a.id
1944 LEFT JOIN albums al ON t.album_id = al.id
1945 LEFT JOIN artists aa ON al.artist_id = aa.id
1946 WHERE t.album_id = ?1
1947 ORDER BY (t.path IS NULL AND t.cached_path IS NULL), t.disc, t.track_number
1948 LIMIT 1",
1949 )?;
1950
1951 let mut rows = stmt.query_map(params![album_id], row_to_track_row)?;
1952 rows.next().transpose().map_err(Into::into)
1953}
1954
1955pub fn genres_by_artist_ids(
1958 conn: &Connection,
1959 ids: &[i64],
1960) -> Result<HashMap<i64, HashSet<String>>, DbError> {
1961 if ids.is_empty() {
1962 return Ok(HashMap::new());
1963 }
1964 let mut stmt = conn.prepare_cached(
1965 "SELECT t.artist_id, t.genre FROM tracks t
1966 WHERE t.artist_id IN (SELECT value FROM json_each(?1)) AND t.genre IS NOT NULL
1967 UNION
1968 SELECT al.artist_id, t.genre FROM tracks t
1969 JOIN albums al ON t.album_id = al.id
1970 WHERE al.artist_id IN (SELECT value FROM json_each(?1)) AND t.genre IS NOT NULL",
1971 )?;
1972 let rows = stmt.query_map([super::json_list(ids)], |row| {
1973 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
1974 })?;
1975 let mut map: HashMap<i64, HashSet<String>> = HashMap::new();
1976 for row in rows {
1977 let (artist_id, genre) = row?;
1978 map.entry(artist_id)
1979 .or_default()
1980 .insert(genre.to_lowercase());
1981 }
1982 Ok(map)
1983}
1984
1985pub fn genres_by_album_ids(
1988 conn: &Connection,
1989 ids: &[i64],
1990) -> Result<HashMap<i64, HashSet<String>>, DbError> {
1991 if ids.is_empty() {
1992 return Ok(HashMap::new());
1993 }
1994 let mut stmt = conn.prepare_cached(
1995 "SELECT t.album_id, t.genre FROM tracks t
1996 WHERE t.album_id IN (SELECT value FROM json_each(?1)) AND t.genre IS NOT NULL",
1997 )?;
1998 let rows = stmt.query_map([super::json_list(ids)], |row| {
1999 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
2000 })?;
2001 let mut map: HashMap<i64, HashSet<String>> = HashMap::new();
2002 for row in rows {
2003 let (album_id, genre) = row?;
2004 map.entry(album_id)
2005 .or_default()
2006 .insert(genre.to_lowercase());
2007 }
2008 Ok(map)
2009}
2010
2011pub(crate) fn favourite_track_ids_sql(user: &str) -> String {
2020 format!(
2021 "SELECT id FROM tracks WHERE path IN (SELECT track_path FROM favourites WHERE user_id = {user})
2022 UNION
2023 SELECT id FROM tracks WHERE cached_path IN (SELECT track_path FROM favourites WHERE user_id = {user})
2024 UNION
2025 SELECT id FROM tracks WHERE remote_url IN (SELECT track_path FROM favourites WHERE user_id = {user})"
2026 )
2027}
2028
2029pub fn favourite_artist_ids_batch(conn: &Connection, user: i64) -> Result<HashSet<i64>, DbError> {
2031 let mut stmt = conn.prepare_cached(&format!(
2032 "SELECT DISTINCT artist_id FROM tracks
2033 WHERE artist_id IS NOT NULL AND id IN ({})",
2034 favourite_track_ids_sql("?1")
2035 ))?;
2036 let rows = stmt.query_map([super::auth::resolve_user(conn, user)?], |row| {
2037 row.get::<_, i64>(0)
2038 })?;
2039 let mut ids = HashSet::new();
2040 for row in rows {
2041 ids.insert(row?);
2042 }
2043 Ok(ids)
2044}
2045
2046pub fn track_favourite_key(conn: &Connection, track_id: i64) -> Result<Option<String>, DbError> {
2053 let result = conn.query_row(
2054 "SELECT COALESCE(path, cached_path, remote_url) FROM tracks WHERE id = ?1",
2055 params![track_id],
2056 |row| row.get::<_, Option<String>>(0),
2057 );
2058 match result {
2059 Ok(key) => Ok(key),
2060 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2061 Err(e) => Err(e.into()),
2062 }
2063}
2064
2065pub fn favourite_track_ids_batch(conn: &Connection, user: i64) -> Result<HashSet<i64>, DbError> {
2071 let mut stmt = conn.prepare_cached(&favourite_track_ids_sql("?1"))?;
2072 let rows = stmt.query_map([super::auth::resolve_user(conn, user)?], |row| {
2073 row.get::<_, i64>(0)
2074 })?;
2075 let mut ids = HashSet::new();
2076 for row in rows {
2077 ids.insert(row?);
2078 }
2079 Ok(ids)
2080}
2081
2082pub fn favourite_tracks(
2090 conn: &Connection,
2091 user: i64,
2092 search: Option<&str>,
2093) -> Result<Vec<TrackRow>, DbError> {
2094 let mut sql = format!(
2095 "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
2096 t.disc, t.track_number, t.title, t.duration_ms, t.path,
2097 t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
2098 t.genre, t.source, t.remote_id, t.cached_path
2099 FROM tracks t
2100 LEFT JOIN artists a ON t.artist_id = a.id
2101 LEFT JOIN albums al ON t.album_id = al.id
2102 LEFT JOIN artists aa ON al.artist_id = aa.id
2103 WHERE t.id IN ({})",
2104 favourite_track_ids_sql("?1")
2105 );
2106 let mut params: Vec<Box<dyn rusqlite::ToSql>> =
2107 vec![Box::new(super::auth::resolve_user(conn, user)?)];
2108 if let Some(query) = search {
2109 let pattern = format!("%{}%", super::artists::escape_like(query));
2110 for _ in 0..3 {
2111 params.push(Box::new(pattern.clone()));
2112 }
2113 sql.push_str(
2114 " AND (t.title LIKE ? COLLATE NOCASE ESCAPE '\\'
2115 OR a.name LIKE ? COLLATE NOCASE ESCAPE '\\'
2116 OR al.title LIKE ? COLLATE NOCASE ESCAPE '\\')",
2117 );
2118 }
2119 sql.push_str(
2120 " ORDER BY a.name COLLATE LIBRARY, al.title COLLATE LIBRARY, t.disc, t.track_number",
2121 );
2122
2123 let mut stmt = conn.prepare(&sql)?;
2124 let rows = stmt
2125 .query_map(rusqlite::params_from_iter(params.iter()), row_to_track_row)?
2126 .collect::<Result<Vec<_>, _>>()?;
2127 Ok(rows)
2128}
2129
2130pub fn favourite_album_ids_batch(conn: &Connection, user: i64) -> Result<HashSet<i64>, DbError> {
2132 let mut stmt = conn.prepare_cached(&format!(
2133 "SELECT DISTINCT album_id FROM tracks
2134 WHERE album_id IS NOT NULL AND id IN ({})",
2135 favourite_track_ids_sql("?1")
2136 ))?;
2137 let rows = stmt.query_map([super::auth::resolve_user(conn, user)?], |row| {
2138 row.get::<_, i64>(0)
2139 })?;
2140 let mut ids = HashSet::new();
2141 for row in rows {
2142 ids.insert(row?);
2143 }
2144 Ok(ids)
2145}
2146
2147#[cfg(test)]
2148mod tests {
2149 use super::*;
2150 use crate::db::connection::Database;
2151 use crate::db::queries::{library_stats, sample_meta, search_tracks};
2152
2153 fn test_db() -> Database {
2154 let conn = rusqlite::Connection::open_in_memory().unwrap();
2155 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2156 crate::db::schema::create_tables(&conn).unwrap();
2157 Database { conn }
2158 }
2159
2160 #[test]
2163 fn an_unchanged_track_is_not_rewritten() {
2164 let db = test_db();
2165 let local = sample_meta("Archangel", "Burial", "Untrue");
2166 let mut remote = sample_meta("Near Dark", "Burial", "Untrue");
2167 remote.path = None;
2168 remote.source = "remote".into();
2169 remote.remote_id = Some("0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e6f".into());
2170 remote.remote_url = Some("https://music.example/rest/stream?id=a".into());
2171 remote.album_remote_id = Some("0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e70".into());
2172 remote.artist_remote_id = Some("0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e71".into());
2173 remote.album_mbid = Some("release".into());
2174 remote.album_added_at = Some("2026-01-01T00:00:00Z".into());
2175 let seen: HashSet<String> = remote.remote_id.iter().cloned().collect();
2176 upsert_track(&db.conn, &local).unwrap();
2177 upsert_synced_track(&db.conn, &remote, &seen).unwrap();
2178
2179 let before = db.conn.total_changes();
2180 upsert_track(&db.conn, &local).unwrap();
2181 upsert_synced_track(&db.conn, &remote, &seen).unwrap();
2182 assert_eq!(db.conn.total_changes(), before, "nothing was written");
2183 assert_eq!(search_tracks(&db.conn, "Archangel").unwrap().len(), 1);
2184 assert_eq!(search_tracks(&db.conn, "Near Dark").unwrap().len(), 1);
2185 }
2186
2187 #[test]
2188 fn a_new_server_uid_is_inserted_with_the_row() {
2189 let db = test_db();
2190 let uid = "0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e6f";
2191 let mut meta = sample_meta("Near Dark", "Burial", "Untrue");
2192 meta.path = None;
2193 meta.remote_id = Some(uid.into());
2194 let id = upsert_track(&db.conn, &meta).unwrap();
2195 let stored: String = db
2196 .conn
2197 .query_row("SELECT uid FROM tracks WHERE id = ?1", [id], |r| r.get(0))
2198 .unwrap();
2199 assert_eq!(stored, uid);
2200 }
2201
2202 #[test]
2203 fn a_changed_track_is_still_rewritten() {
2204 let db = test_db();
2205 let mut meta = sample_meta("Archangel", "Burial", "Untrue");
2206 meta.album_added_at = Some("2026-02-01T00:00:00Z".into());
2207 let id = upsert_track(&db.conn, &meta).unwrap();
2208
2209 meta.title = "Archangel (Remastered)".into();
2210 meta.genre = Some("Dubstep".into());
2211 meta.bit_depth = Some(24);
2212 meta.codec = Some("ALAC".into());
2213 meta.album_added_at = Some("2026-01-01T00:00:00Z".into());
2214 assert_eq!(upsert_track(&db.conn, &meta).unwrap(), id);
2215
2216 let (title, genre, bit_depth): (String, String, i32) = db
2217 .conn
2218 .query_row(
2219 "SELECT title, genre, bit_depth FROM tracks WHERE id = ?1",
2220 [id],
2221 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
2222 )
2223 .unwrap();
2224 assert_eq!(
2225 (title.as_str(), genre.as_str(), bit_depth),
2226 ("Archangel (Remastered)", "Dubstep", 24)
2227 );
2228 assert_eq!(search_tracks(&db.conn, "Remastered").unwrap().len(), 1);
2229 assert_eq!(search_tracks(&db.conn, "Dubstep").unwrap().len(), 1);
2230 assert!(search_tracks(&db.conn, "Electronic").unwrap().is_empty());
2231 let (codec, added): (String, String) = db
2232 .conn
2233 .query_row("SELECT codec, added_at FROM albums", [], |r| {
2234 Ok((r.get(0)?, r.get(1)?))
2235 })
2236 .unwrap();
2237 assert_eq!(
2238 (codec.as_str(), added.as_str()),
2239 ("ALAC", "2026-01-01T00:00:00Z"),
2240 "the album takes the new codec and the earlier date"
2241 );
2242
2243 meta.album_added_at = Some("2026-03-01T00:00:00Z".into());
2244 upsert_track(&db.conn, &meta).unwrap();
2245 let added: String = db
2246 .conn
2247 .query_row("SELECT added_at FROM albums", [], |r| r.get(0))
2248 .unwrap();
2249 assert_eq!(added, "2026-01-01T00:00:00Z", "a later date does not win");
2250 }
2251
2252 #[test]
2253 fn favourite_tracks_come_back_as_rows_narrowed_by_search() {
2254 use crate::db::queries::toggle_favourite;
2255 let db = test_db();
2256 upsert_track(&db.conn, &sample_meta("Amber", "Autechre", "Amber")).unwrap();
2257 upsert_track(&db.conn, &sample_meta("Foil", "Autechre", "Amber")).unwrap();
2258
2259 let titles = |q| {
2260 favourite_tracks(&db.conn, crate::db::queries::LOCAL_USER, q)
2261 .unwrap()
2262 .into_iter()
2263 .map(|t| t.title)
2264 .collect::<Vec<_>>()
2265 };
2266 assert!(titles(None).is_empty(), "nothing is favourite until it is");
2267
2268 toggle_favourite(
2269 &db.conn,
2270 crate::db::queries::LOCAL_USER,
2271 Path::new("/music/Amber/Amber.flac"),
2272 )
2273 .unwrap();
2274 toggle_favourite(
2275 &db.conn,
2276 crate::db::queries::LOCAL_USER,
2277 Path::new("/music/Amber/Foil.flac"),
2278 )
2279 .unwrap();
2280 assert_eq!(titles(None), ["Amber", "Foil"]);
2281 assert_eq!(titles(Some("foil")), ["Foil"]);
2282 assert_eq!(
2283 titles(Some("autechre")).len(),
2284 2,
2285 "matched on the artist name"
2286 );
2287 }
2288
2289 #[test]
2292 fn favourite_tracks_finds_a_track_by_any_of_its_paths() {
2293 let db = test_db();
2294 db.conn
2295 .execute_batch(
2296 "INSERT INTO artists (id, name) VALUES (1, 'Boards of Canada');
2297 INSERT INTO albums (id, title, artist_id) VALUES (1, 'Geogaddi', 1);
2298 INSERT INTO tracks (id, title, artist_id, album_id, source, path)
2299 VALUES (1, 'Music Is Math', 1, 1, 'local', '/music/math.flac');
2300 INSERT INTO tracks (id, title, artist_id, album_id, source, cached_path)
2301 VALUES (2, 'Sixtyten', 1, 1, 'cached', '/cache/sixtyten.flac');
2302 INSERT INTO tracks (id, title, artist_id, album_id, source, remote_url)
2303 VALUES (3, 'Dawn Chorus', 1, 1, 'remote', 'http://server/dawn');
2304 INSERT INTO tracks (id, title, artist_id, album_id, source, path)
2305 VALUES (4, 'Alpha and Omega', 1, 1, 'local', '/music/alpha.flac');
2306 INSERT INTO favourites (track_path) VALUES
2307 ('/music/math.flac'), ('/cache/sixtyten.flac'), ('http://server/dawn');",
2308 )
2309 .unwrap();
2310
2311 let mut ids = favourite_tracks(&db.conn, crate::db::queries::LOCAL_USER, None)
2312 .unwrap()
2313 .into_iter()
2314 .map(|t| t.id)
2315 .collect::<Vec<_>>();
2316 ids.sort();
2317 assert_eq!(ids, [1, 2, 3], "the unfavourited fourth track stays out");
2318
2319 let narrowed =
2320 favourite_tracks(&db.conn, crate::db::queries::LOCAL_USER, Some("dawn")).unwrap();
2321 assert_eq!(
2322 narrowed.len(),
2323 1,
2324 "search narrows the favourites, not all of them"
2325 );
2326 assert_eq!(narrowed[0].id, 3);
2327 }
2328
2329 #[test]
2332 fn tracks_for_artist_counts_the_album_credit() {
2333 let db = test_db();
2334 db.conn
2335 .execute_batch(
2336 "INSERT INTO artists (id, name) VALUES (1, 'Aphex Twin'), (2, 'Various');
2337 INSERT INTO albums (id, title, artist_id) VALUES
2338 (1, 'Selected Ambient Works', 1), (2, 'Artificial Intelligence', 2);
2339 -- Own credit, on their own record.
2340 INSERT INTO tracks (id, title, artist_id, album_id, source, path)
2341 VALUES (1, 'Xtal', 1, 1, 'local', '/music/xtal.flac');
2342 -- Own credit, on somebody else's compilation.
2343 INSERT INTO tracks (id, title, artist_id, album_id, source, path)
2344 VALUES (2, 'Polygon Window', 1, 2, 'local', '/music/polygon.flac');
2345 -- Album credit only: uncredited track on their record.
2346 INSERT INTO tracks (id, title, album_id, source, path)
2347 VALUES (3, 'Untitled', 1, 'local', '/music/untitled.flac');
2348 -- Neither.
2349 INSERT INTO tracks (id, title, artist_id, album_id, source, path)
2350 VALUES (4, 'The Clan Call', 2, 2, 'local', '/music/clan.flac');",
2351 )
2352 .unwrap();
2353
2354 let mut ids = tracks_for_artist(&db.conn, 1)
2355 .unwrap()
2356 .into_iter()
2357 .map(|t| t.id)
2358 .collect::<Vec<_>>();
2359 ids.sort();
2360 assert_eq!(ids, [1, 2, 3]);
2361 }
2362
2363 #[test]
2364 fn an_id_asked_for_twice_comes_back_twice() {
2365 let db = test_db();
2366 let a = upsert_track(&db.conn, &sample_meta("One", "Artist", "Album")).unwrap();
2367 let b = upsert_track(&db.conn, &sample_meta("Two", "Artist", "Album")).unwrap();
2368
2369 let rows = tracks_by_ids(&db.conn, &[a, b, a]).unwrap();
2370 assert_eq!(
2371 rows.iter().map(|r| r.id).collect::<Vec<_>>(),
2372 vec![a, b, a],
2373 "a queue may hold the same track twice, and a playlist certainly may"
2374 );
2375 }
2376
2377 #[test]
2378 fn test_upsert_track() {
2379 let db = test_db();
2380 let meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
2381 let id1 = upsert_track(&db.conn, &meta).unwrap();
2382
2383 let id2 = upsert_track(&db.conn, &meta).unwrap();
2385 assert_eq!(id1, id2);
2386
2387 let stats = library_stats(&db.conn).unwrap();
2388 assert_eq!(stats.total_tracks, 1);
2389 assert_eq!(stats.local_tracks, 1);
2390 }
2391
2392 #[test]
2393 fn test_dedup_keeps_discs_apart() {
2394 let db = test_db();
2395
2396 let mut cd1 = sample_meta("Overture", "Wagner", "Ring Cycle");
2398 cd1.disc = Some(1);
2399 cd1.path = Some("/music/Ring Cycle/CD1/01 - Overture.flac".into());
2400 let mut cd2 = cd1.clone();
2401 cd2.disc = Some(2);
2402 cd2.path = Some("/music/Ring Cycle/CD2/01 - Overture.flac".into());
2403
2404 let id1 = upsert_track(&db.conn, &cd1).unwrap();
2405 let id2 = upsert_track(&db.conn, &cd2).unwrap();
2406
2407 assert_ne!(id1, id2, "discs 1 and 2 must not collapse into one row");
2408 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
2409
2410 let paths: Vec<String> = db
2411 .conn
2412 .prepare("SELECT path FROM tracks ORDER BY disc")
2413 .unwrap()
2414 .query_map([], |row| row.get(0))
2415 .unwrap()
2416 .map(|r| r.unwrap())
2417 .collect();
2418 assert_eq!(paths, vec![cd1.path.unwrap(), cd2.path.unwrap()]);
2419 }
2420
2421 #[test]
2422 fn test_dedup_never_merges_two_local_files() {
2423 let db = test_db();
2424
2425 let mut a = sample_meta("Intro", "Various", "Compilation");
2427 a.path = Some("/music/Compilation/a.flac".into());
2428 let mut b = a.clone();
2429 b.path = Some("/music/Compilation/b.flac".into());
2430
2431 let id_a = upsert_track(&db.conn, &a).unwrap();
2432 let id_b = upsert_track(&db.conn, &b).unwrap();
2433
2434 assert_ne!(id_a, id_b);
2435 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
2436 }
2437
2438 #[test]
2439 fn test_dedup_never_merges_two_remote_entries() {
2440 let db = test_db();
2441
2442 let mut first = sample_meta("Untitled", "Artist", "Album");
2445 first.source = "remote".into();
2446 first.path = None;
2447 first.disc = None;
2448 first.remote_id = Some("sub-1".into());
2449 let mut second = first.clone();
2450 second.remote_id = Some("sub-2".into());
2451
2452 let id1 = upsert_track(&db.conn, &first).unwrap();
2453 let id2 = upsert_track(&db.conn, &second).unwrap();
2454
2455 assert_ne!(
2456 id1, id2,
2457 "two server entries must not collapse into one row"
2458 );
2459 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
2460 }
2461
2462 fn uid_of(db: &Database, table: &str, id: i64) -> String {
2463 db.conn
2464 .query_row(
2465 &format!("SELECT uid FROM {table} WHERE id = ?1"),
2466 [id],
2467 |r| r.get(0),
2468 )
2469 .unwrap()
2470 }
2471
2472 #[test]
2473 fn a_track_synced_from_koan_takes_the_servers_uids() {
2474 let db = test_db();
2475 let [track, album, artist] = [(); 3].map(|_| uuid::Uuid::now_v7().to_string());
2476 let mut meta = remote_meta("Archangel", "Burial", "Untrue", &track);
2477 meta.album_remote_id = Some(album.clone());
2478 meta.artist_remote_id = Some(artist.clone());
2479
2480 let id = upsert_track(&db.conn, &meta).unwrap();
2481
2482 let row = get_track_row(&db.conn, id).unwrap().unwrap();
2483 assert_eq!(uid_of(&db, "tracks", id), track);
2484 assert_eq!(uid_of(&db, "albums", row.album_id.unwrap()), album);
2485 assert_eq!(uid_of(&db, "artists", row.artist_id.unwrap()), artist);
2486 }
2487
2488 #[test]
2489 fn a_local_file_merged_with_its_koan_copy_takes_the_servers_uid() {
2490 let db = test_db();
2491 let local = upsert_track(&db.conn, &sample_meta("Archangel", "Burial", "Untrue")).unwrap();
2492 let minted = uid_of(&db, "tracks", local);
2493
2494 let server = uuid::Uuid::now_v7().to_string();
2495 let merged = upsert_track(
2496 &db.conn,
2497 &remote_meta("Archangel", "Burial", "Untrue", &server),
2498 )
2499 .unwrap();
2500
2501 assert_eq!(merged, local);
2502 assert_ne!(minted, server);
2503 assert_eq!(uid_of(&db, "tracks", local), server);
2504 }
2505
2506 #[test]
2507 fn ids_from_other_servers_leave_the_minted_uid() {
2508 let db = test_db();
2509 for remote_id in [
2510 "3xJ9kQ2pZ",
2511 "42",
2512 "0f8fad5b-d9cb-469f-a165-70867728950e",
2513 "018f8fad5bd9cb769fa16570867728950e",
2514 ] {
2515 let id = upsert_track(
2516 &db.conn,
2517 &remote_meta(remote_id, "Burial", "Untrue", remote_id),
2518 )
2519 .unwrap();
2520 let uid = uid_of(&db, "tracks", id);
2521 assert_ne!(uid, remote_id);
2522 assert!(super::super::is_uid(&uid), "{uid}");
2523 }
2524 }
2525
2526 fn remote_meta(title: &str, artist: &str, album: &str, remote_id: &str) -> TrackMeta {
2528 let mut meta = sample_meta(title, artist, album);
2529 meta.source = "remote".into();
2530 meta.path = None;
2531 meta.remote_id = Some(remote_id.into());
2532 meta.remote_url = Some(format!("https://server/rest/stream?id={remote_id}"));
2533 meta.sample_rate = None;
2534 meta.bit_depth = None;
2535 meta
2536 }
2537
2538 #[test]
2539 fn test_corrected_tags_remerge_with_the_remote_copy() {
2540 let db = test_db();
2541
2542 let mut bad = sample_meta(
2545 "Golden Skans (David E Sugar R",
2546 "Klaxons",
2547 "Golden Skans (David E Sugar R",
2548 );
2549 bad.path = Some("/music/klaxons/01.mp3".into());
2550 bad.track_number = None;
2551 let local_id = upsert_track(&db.conn, &bad).unwrap();
2552
2553 let remote = remote_meta(
2554 "Golden Skans (David E Sugar Remix)",
2555 "Klaxons",
2556 "Golden Skans (David E Sugar Remix)",
2557 "sub-42",
2558 );
2559 let remote_id = upsert_track(&db.conn, &remote).unwrap();
2560 assert_ne!(local_id, remote_id, "bad tags cannot content-match");
2561 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
2562
2563 let mut fixed = bad.clone();
2566 fixed.title = "Golden Skans (David E Sugar Remix)".into();
2567 fixed.album = "Golden Skans (David E Sugar Remix)".into();
2568 fixed.track_number = Some(1);
2569 let merged = upsert_track(&db.conn, &fixed).unwrap();
2570
2571 assert_eq!(merged, local_id, "the row holding the file survives");
2572 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
2573
2574 let row = get_track_row(&db.conn, merged).unwrap().unwrap();
2575 assert_eq!(row.path.as_deref(), Some("/music/klaxons/01.mp3"));
2576 assert_eq!(row.remote_id.as_deref(), Some("sub-42"));
2577 assert_eq!(row.source, "local");
2578 assert_eq!(row.sample_rate, Some(44100), "local audio properties kept");
2579
2580 let albums: Vec<String> = db
2582 .conn
2583 .prepare("SELECT title FROM albums")
2584 .unwrap()
2585 .query_map([], |row| row.get(0))
2586 .unwrap()
2587 .map(|r| r.unwrap())
2588 .collect();
2589 assert_eq!(albums, vec!["Golden Skans (David E Sugar Remix)"]);
2590 }
2591
2592 #[test]
2593 fn test_remerge_carries_history_and_lyrics_across() {
2594 let db = test_db();
2595
2596 let mut bad = sample_meta("Untitled", "Boards of Canada", "Geogaddi");
2597 bad.path = Some("/music/boc/05.flac".into());
2598 let local_id = upsert_track(&db.conn, &bad).unwrap();
2599
2600 let remote = remote_meta("Sunshine Recorder", "Boards of Canada", "Geogaddi", "sub-7");
2601 let remote_id = upsert_track(&db.conn, &remote).unwrap();
2602
2603 crate::db::queries::record_play(
2605 &db.conn,
2606 crate::db::queries::LOCAL_USER,
2607 local_id,
2608 Some(1_000),
2609 )
2610 .unwrap();
2611 crate::db::queries::record_play(
2612 &db.conn,
2613 crate::db::queries::LOCAL_USER,
2614 remote_id,
2615 Some(2_000),
2616 )
2617 .unwrap();
2618 crate::db::queries::cache_lyrics(&db.conn, remote_id, "lrclib", true, "[00:01.00] la")
2619 .unwrap();
2620
2621 let mut fixed = bad.clone();
2622 fixed.title = "Sunshine Recorder".into();
2623 let merged = upsert_track(&db.conn, &fixed).unwrap();
2624 assert_eq!(merged, local_id);
2625
2626 assert_eq!(
2627 crate::db::queries::play_count(&db.conn, crate::db::queries::LOCAL_USER, merged)
2628 .unwrap(),
2629 2,
2630 "both rows' plays were plays of this track"
2631 );
2632 assert!(
2633 crate::db::queries::get_cached_lyrics(&db.conn, merged)
2634 .unwrap()
2635 .is_some()
2636 );
2637 let orphans: i64 = db
2638 .conn
2639 .query_row(
2640 "SELECT COUNT(*) FROM play_history WHERE track_id NOT IN (SELECT id FROM tracks)",
2641 [],
2642 |row| row.get(0),
2643 )
2644 .unwrap();
2645 assert_eq!(orphans, 0);
2646 }
2647
2648 #[test]
2649 fn test_remerge_keeps_playlist_entries() {
2650 let db = test_db();
2651
2652 let mut bad = sample_meta("Untitled", "Boards of Canada", "Geogaddi");
2653 bad.path = Some("/music/boc/05.flac".into());
2654 let local_id = upsert_track(&db.conn, &bad).unwrap();
2655 let remote = remote_meta("Sunshine Recorder", "Boards of Canada", "Geogaddi", "sub-7");
2656 let remote_id = upsert_track(&db.conn, &remote).unwrap();
2657
2658 let list = crate::db::queries::create_playlist(
2659 &db.conn,
2660 crate::db::queries::LOCAL_USER,
2661 "Road trip",
2662 None,
2663 )
2664 .unwrap();
2665 let entry = crate::db::queries::add_tracks(&db.conn, list, &[remote_id]).unwrap()[0];
2666
2667 let mut fixed = bad.clone();
2668 fixed.title = "Sunshine Recorder".into();
2669 let merged = upsert_track(&db.conn, &fixed).unwrap();
2670 assert_eq!(merged, local_id);
2671
2672 let entries = crate::db::queries::playlist_entries(&db.conn, list).unwrap();
2673 assert_eq!(
2674 entries.len(),
2675 1,
2676 "the merge left the playlist entry in place"
2677 );
2678 assert_eq!((entries[0].id, entries[0].track.id), (entry, merged));
2679 }
2680
2681 #[test]
2682 fn test_remerge_never_folds_two_local_files() {
2683 let db = test_db();
2684
2685 let mut first = sample_meta("Intro", "Various", "Compilation");
2686 first.path = Some("/music/comp/a.flac".into());
2687 let mut second = first.clone();
2688 second.title = "Untitled".into();
2689 second.path = Some("/music/comp/b.flac".into());
2690
2691 let id_a = upsert_track(&db.conn, &first).unwrap();
2692 let id_b = upsert_track(&db.conn, &second).unwrap();
2693
2694 second.title = "Intro".into();
2697 assert_eq!(upsert_track(&db.conn, &second).unwrap(), id_b);
2698 assert_ne!(id_a, id_b);
2699 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
2700 }
2701
2702 #[test]
2703 fn test_remerge_folds_a_renamed_remote_entry_into_the_local_file() {
2704 let db = test_db();
2705
2706 let local = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker");
2707 let local_id = upsert_track(&db.conn, &local).unwrap();
2708
2709 let mut remote = remote_meta("Windowlickr", "Aphex Twin", "Windowlicker", "sub-1");
2711 let remote_id = upsert_track(&db.conn, &remote).unwrap();
2712 assert_ne!(local_id, remote_id);
2713
2714 remote.title = "Windowlicker".into();
2717 let merged = upsert_track(&db.conn, &remote).unwrap();
2718
2719 assert_eq!(merged, remote_id, "the row holding the remote id survives");
2720 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
2721 let row = get_track_row(&db.conn, merged).unwrap().unwrap();
2722 assert_eq!(row.path, local.path);
2723 assert_eq!(row.remote_id.as_deref(), Some("sub-1"));
2724 assert_eq!(row.source, "local");
2725 }
2726
2727 #[test]
2728 fn test_force_remove_lifts_the_fraction_brake_only() {
2729 let db = test_db();
2730 for i in 0..STALE_CHECK_MIN_ROWS + 20 {
2731 let mut meta = sample_meta(&format!("Track{}", i), "Artist", "Album");
2732 meta.track_number = Some(i as i32);
2733 meta.path = Some(format!("/music/Album/{}.flac", i));
2734 upsert_track(&db.conn, &meta).unwrap();
2735 }
2736 let total = library_stats(&db.conn).unwrap().total_tracks as usize;
2737
2738 let removed = remove_stale_tracks(&db.conn, Path::new("/music"), true).unwrap();
2739 assert_eq!(removed.len(), total, "every missing file should go");
2740 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
2741 assert!(
2742 removed.iter().all(|p| p.starts_with("/music/Album/")),
2743 "the removed paths should be reported back"
2744 );
2745 }
2746
2747 #[test]
2748 fn test_remote_upsert_preserves_local_audio_properties() {
2749 let db = test_db();
2750
2751 let local = sample_meta("Song", "Artist", "Album");
2753 let id = upsert_track(&db.conn, &local).unwrap();
2754
2755 let mut remote = sample_meta("Song", "Artist", "Album");
2757 remote.source = "remote".into();
2758 remote.path = None;
2759 remote.remote_id = Some("sub-1".into());
2760 remote.sample_rate = None;
2761 remote.bit_depth = None;
2762 remote.channels = None;
2763 remote.size_bytes = None;
2764 remote.mtime = None;
2765 remote.codec = None;
2766 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
2767
2768 let codec: Option<String> = db
2769 .conn
2770 .query_row("SELECT codec FROM tracks WHERE id = ?1", params![id], |r| {
2771 r.get(0)
2772 })
2773 .unwrap();
2774 let num = |col: &str| -> Option<i64> {
2775 db.conn
2776 .query_row(
2777 &format!("SELECT {} FROM tracks WHERE id = ?1", col),
2778 params![id],
2779 |r| r.get(0),
2780 )
2781 .unwrap()
2782 };
2783
2784 assert_eq!(codec.as_deref(), Some("FLAC"));
2785 assert_eq!(num("sample_rate"), Some(44100));
2786 assert_eq!(num("bit_depth"), Some(16));
2787 assert_eq!(num("channels"), Some(2));
2788 assert_eq!(num("size_bytes"), Some(30_000_000));
2789 assert_eq!(num("mtime"), Some(1700000000));
2790 }
2791
2792 #[test]
2793 fn test_dedup_matches_across_differing_artist_credits() {
2794 let db = test_db();
2795
2796 let local = sample_meta("Treading Water", "Petrol Girls", "Talk of Violence");
2800 let id = upsert_track(&db.conn, &local).unwrap();
2801
2802 let mut remote = sample_meta(
2803 "Treading Water",
2804 "Petrol Girls • Ren Aldridge",
2805 "Talk of Violence",
2806 );
2807 remote.album_artist = Some("Petrol Girls".into());
2808 remote.source = "remote".into();
2809 remote.path = None;
2810 remote.remote_id = Some("sub-1".into());
2811
2812 assert_eq!(
2813 upsert_track(&db.conn, &remote).unwrap(),
2814 id,
2815 "one recording, however each source spells the credit"
2816 );
2817
2818 let rows: i64 = db
2819 .conn
2820 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2821 .unwrap();
2822 assert_eq!(rows, 1, "the album page must not show the track twice");
2823 }
2824
2825 #[test]
2826 fn test_dedup_reads_disc_zero_as_no_disc() {
2827 let db = test_db();
2828
2829 let mut local = sample_meta(
2831 "Miss Broadway (Main Version)",
2832 "Glass Candy",
2833 "Miss Broadway",
2834 );
2835 local.disc = Some(0);
2836 let id = upsert_track(&db.conn, &local).unwrap();
2837
2838 let mut remote = remote_meta(
2839 "Miss Broadway (Main Version)",
2840 "Glass Candy",
2841 "Miss Broadway",
2842 "sub-1",
2843 );
2844 remote.disc = None;
2845 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
2846
2847 let disc: Option<i32> = db
2848 .conn
2849 .query_row("SELECT disc FROM tracks WHERE id = ?1", params![id], |r| {
2850 r.get(0)
2851 })
2852 .unwrap();
2853 assert_eq!(disc, None);
2854 }
2855
2856 #[test]
2857 fn test_migration_folds_tracks_split_by_a_zero_disc() {
2858 let db = test_db();
2859
2860 let local = sample_meta("Sumo", "Simtek", "In The Face EP");
2861 let winner = upsert_track(&db.conn, &local).unwrap();
2862 db.conn
2863 .execute("UPDATE tracks SET disc = 0 WHERE id = ?1", params![winner])
2864 .unwrap();
2865 db.conn
2866 .execute(
2867 "INSERT INTO tracks (album_id, artist_id, disc, track_number, title,
2868 source, remote_id, remote_url)
2869 SELECT album_id, artist_id, NULL, track_number, title,
2870 'remote', 'sub-4', 'http://server/4'
2871 FROM tracks WHERE id = ?1",
2872 params![winner],
2873 )
2874 .unwrap();
2875
2876 clear_zero_discs(&db.conn).unwrap();
2877 merge_split_cross_source_tracks(&db.conn).unwrap();
2878
2879 let (rows, remote_id): (i64, Option<String>) = db
2880 .conn
2881 .query_row("SELECT COUNT(*), MAX(remote_id) FROM tracks", [], |r| {
2882 Ok((r.get(0)?, r.get(1)?))
2883 })
2884 .unwrap();
2885 assert_eq!(rows, 1);
2886 assert_eq!(remote_id.as_deref(), Some("sub-4"));
2887 }
2888
2889 fn with_ids(mut meta: TrackMeta, recording: &str, release: &str) -> TrackMeta {
2891 meta.mbid = Some(recording.into());
2892 meta.album_mbid = Some(release.into());
2893 meta
2894 }
2895
2896 fn track_count(db: &Database) -> i64 {
2897 db.conn
2898 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2899 .unwrap()
2900 }
2901
2902 #[test]
2903 fn test_dedup_matches_by_musicbrainz_ids_whatever_the_album_is_called() {
2904 let db = test_db();
2905
2906 let local = with_ids(
2908 sample_meta(
2909 "Hypnotized",
2910 "Oliver Koletzki",
2911 "Renaissance: The Mix Collection",
2912 ),
2913 "rec-1",
2914 "rel-1",
2915 );
2916 let id = upsert_track(&db.conn, &local).unwrap();
2917
2918 let mut remote = with_ids(
2919 remote_meta(
2920 "Hypnotized",
2921 "Oliver Koletzki",
2922 "Renaissance: The Mix Collection (Unmixed)",
2923 "sub-1",
2924 ),
2925 "rec-1",
2926 "rel-1",
2927 );
2928 remote.disc = None;
2929 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
2930 assert_eq!(track_count(&db), 1);
2931 }
2932
2933 #[test]
2934 fn test_dedup_keeps_a_recording_apart_across_releases() {
2935 let db = test_db();
2936
2937 let local = with_ids(
2939 sample_meta("Azure", "Paul Kalkbrenner", "Album"),
2940 "rec-1",
2941 "rel-1",
2942 );
2943 let first = upsert_track(&db.conn, &local).unwrap();
2944
2945 let remote = with_ids(
2946 remote_meta("Azure", "Paul Kalkbrenner", "Compilation", "sub-1"),
2947 "rec-1",
2948 "rel-2",
2949 );
2950 assert_ne!(upsert_track(&db.conn, &remote).unwrap(), first);
2951 }
2952
2953 #[test]
2954 fn test_dedup_by_musicbrainz_ids_picks_the_slot_when_a_release_repeats_a_recording() {
2955 let db = test_db();
2956
2957 let mut first = with_ids(
2959 sample_meta("Azure", "Paul Kalkbrenner", "Mixes"),
2960 "rec-1",
2961 "rel-1",
2962 );
2963 first.disc = Some(1);
2964 let first = upsert_track(&db.conn, &first).unwrap();
2965 let mut second = with_ids(
2966 sample_meta("Azure", "Paul Kalkbrenner", "Mixes"),
2967 "rec-1",
2968 "rel-1",
2969 );
2970 second.disc = Some(2);
2971 second.path = Some("/music/Mixes/2-01 Azure.flac".into());
2972 let second = upsert_track(&db.conn, &second).unwrap();
2973
2974 let mut remote = with_ids(
2975 remote_meta("Azure", "Paul Kalkbrenner", "Mixes (Unmixed)", "sub-1"),
2976 "rec-1",
2977 "rel-1",
2978 );
2979 remote.disc = Some(2);
2980 assert_eq!(upsert_track(&db.conn, &remote).unwrap(), second);
2981 assert_ne!(first, second);
2982 }
2983
2984 #[test]
2985 fn test_rescan_with_musicbrainz_ids_folds_an_already_split_pair() {
2986 let db = test_db();
2987
2988 let local = sample_meta("Hypnotized", "Oliver Koletzki", "The Mix Collection");
2990 let id = upsert_track(&db.conn, &local).unwrap();
2991 let remote = with_ids(
2992 remote_meta(
2993 "Hypnotized",
2994 "Oliver Koletzki",
2995 "The Mix Collection (Unmixed)",
2996 "sub-1",
2997 ),
2998 "rec-1",
2999 "rel-1",
3000 );
3001 upsert_track(&db.conn, &remote).unwrap();
3002 assert_eq!(track_count(&db), 2);
3003
3004 assert_eq!(
3005 upsert_track(&db.conn, &with_ids(local, "rec-1", "rel-1")).unwrap(),
3006 id
3007 );
3008 assert_eq!(track_count(&db), 1);
3009 let remote_id: Option<String> = db
3010 .conn
3011 .query_row(
3012 "SELECT remote_id FROM tracks WHERE id = ?1",
3013 params![id],
3014 |r| r.get(0),
3015 )
3016 .unwrap();
3017 assert_eq!(remote_id.as_deref(), Some("sub-1"));
3018 let albums: i64 = db
3019 .conn
3020 .query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))
3021 .unwrap();
3022 assert_eq!(albums, 1, "the server's album goes with its last track");
3023 }
3024
3025 #[test]
3026 fn test_dedup_without_a_track_number_still_needs_the_artist() {
3027 let db = test_db();
3028
3029 let mut local = sample_meta("Untitled", "One", "Split");
3033 local.album_artist = Some("Various Artists".into());
3034 local.track_number = None;
3035 let first = upsert_track(&db.conn, &local).unwrap();
3036
3037 let mut remote = sample_meta("Untitled", "Two", "Split");
3038 remote.album_artist = Some("Various Artists".into());
3039 remote.track_number = None;
3040 remote.source = "remote".into();
3041 remote.path = None;
3042 remote.remote_id = Some("sub-1".into());
3043 let second = upsert_track(&db.conn, &remote).unwrap();
3044
3045 assert_ne!(first, second, "different artists, no slot to match on");
3046 }
3047
3048 #[test]
3049 fn test_migration_folds_tracks_split_by_an_artist_credit() {
3050 let db = test_db();
3051
3052 let local = sample_meta("Rewild", "Petrol Girls", "Talk of Violence");
3057 let winner = upsert_track(&db.conn, &local).unwrap();
3058
3059 let mut remote = sample_meta("Rewild", "Petrol Girls • Ren Aldridge", "Talk of Violence");
3060 remote.album_artist = Some("Petrol Girls".into());
3061 remote.source = "remote".into();
3062 remote.path = None;
3063 remote.remote_id = Some("sub-9".into());
3064 db.conn
3065 .execute(
3066 "INSERT INTO tracks (album_id, artist_id, disc, track_number, title,
3067 duration_ms, source, remote_id, remote_url)
3068 SELECT album_id, artist_id, disc, track_number, title, duration_ms,
3069 'remote', 'sub-9', 'http://server/9'
3070 FROM tracks WHERE id = ?1",
3071 params![winner],
3072 )
3073 .unwrap();
3074 let loser: i64 = db.conn.last_insert_rowid();
3075 assert_ne!(loser, winner);
3076
3077 merge_split_cross_source_tracks(&db.conn).unwrap();
3078
3079 let rows: i64 = db
3080 .conn
3081 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
3082 .unwrap();
3083 assert_eq!(rows, 1, "the pair collapses to one row");
3084
3085 let (path, remote_id): (Option<String>, Option<String>) = db
3086 .conn
3087 .query_row(
3088 "SELECT path, remote_id FROM tracks WHERE id = ?1",
3089 params![winner],
3090 |r| Ok((r.get(0)?, r.get(1)?)),
3091 )
3092 .unwrap();
3093 assert!(path.is_some(), "the local row survives with its file");
3094 assert_eq!(
3095 remote_id.as_deref(),
3096 Some("sub-9"),
3097 "and inherits how the server knows it"
3098 );
3099 }
3100
3101 #[test]
3102 fn test_migration_leaves_an_ambiguous_pair_alone() {
3103 let db = test_db();
3104
3105 let first = upsert_track(&db.conn, &sample_meta("Rewild", "A", "Album")).unwrap();
3107 let mut second = sample_meta("Rewild", "A", "Album");
3108 second.path = Some("/music/Album/Rewild (alt).flac".into());
3109 let second = upsert_track(&db.conn, &second).unwrap();
3110 assert_ne!(first, second);
3111
3112 merge_split_cross_source_tracks(&db.conn).unwrap();
3113
3114 let rows: i64 = db
3115 .conn
3116 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
3117 .unwrap();
3118 assert_eq!(rows, 2, "two files stay two tracks");
3119 }
3120
3121 #[test]
3122 fn test_upsert_does_not_repoint_at_a_different_live_file() {
3123 let db = test_db();
3124 let tmp = tempfile::tempdir().unwrap();
3125 let existing = tmp.path().join("original.flac");
3126 std::fs::write(&existing, b"x").unwrap();
3127
3128 let mut first = sample_meta("Song", "Artist", "Album");
3129 first.path = Some(existing.to_string_lossy().into_owned());
3130 let id = upsert_track(&db.conn, &first).unwrap();
3131
3132 let mut second = first.clone();
3135 second.path = Some(tmp.path().join("other.flac").to_string_lossy().into_owned());
3136 second.remote_id = None;
3137 db.conn
3138 .execute(
3139 "UPDATE tracks SET remote_id = 'r1' WHERE id = ?1",
3140 params![id],
3141 )
3142 .unwrap();
3143 second.remote_id = Some("r1".into());
3144 assert_eq!(upsert_track(&db.conn, &second).unwrap(), id);
3145
3146 let path: String = db
3147 .conn
3148 .query_row("SELECT path FROM tracks WHERE id = ?1", params![id], |r| {
3149 r.get(0)
3150 })
3151 .unwrap();
3152 assert_eq!(path, existing.to_string_lossy());
3153 }
3154
3155 #[test]
3156 fn test_stale_removal_clears_all_foreign_keys() {
3157 let db = test_db();
3158 let id = upsert_track(&db.conn, &sample_meta("Gone", "Artist", "Album")).unwrap();
3159
3160 db.conn
3161 .execute(
3162 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
3163 VALUES (?1, 'lrclib', 'la la', 1)",
3164 params![id],
3165 )
3166 .unwrap();
3167 db.conn
3168 .execute(
3169 "INSERT INTO play_history (track_id, played_at) VALUES (?1, 1)",
3170 params![id],
3171 )
3172 .unwrap();
3173 db.conn
3174 .execute(
3175 "INSERT INTO track_vectors (track_id, embedding) VALUES (?1, x'00')",
3176 params![id],
3177 )
3178 .unwrap();
3179 crate::db::queries::update_scan_cache(&db.conn, "/music/Album/Gone.flac", 1, 2, id)
3180 .unwrap();
3181
3182 assert_eq!(
3183 remove_stale_tracks(&db.conn, Path::new("/music"), false)
3184 .unwrap()
3185 .len(),
3186 1
3187 );
3188 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
3189 }
3190
3191 #[test]
3192 fn test_stale_removal_survives_orphaned_scan_cache_row() {
3193 let db = test_db();
3194 let id = upsert_track(&db.conn, &sample_meta("Gone", "Artist", "Album")).unwrap();
3195
3196 crate::db::queries::update_scan_cache(&db.conn, "/music/Album/old-name.flac", 1, 2, id)
3198 .unwrap();
3199
3200 assert_eq!(
3201 remove_stale_tracks(&db.conn, Path::new("/music"), false)
3202 .unwrap()
3203 .len(),
3204 1
3205 );
3206 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
3207
3208 let orphans: i64 = db
3209 .conn
3210 .query_row("SELECT COUNT(*) FROM scan_cache", [], |row| row.get(0))
3211 .unwrap();
3212 assert_eq!(orphans, 0);
3213 }
3214
3215 #[test]
3216 fn test_stale_removal_ignores_sibling_folder_with_shared_prefix() {
3217 let db = test_db();
3218
3219 let mut main = sample_meta("Song", "Artist", "Album");
3220 main.path = Some("/Volumes/Music/Album/Song.flac".into());
3221 upsert_track(&db.conn, &main).unwrap();
3222
3223 let mut backup = sample_meta("Song", "Artist", "Album");
3224 backup.path = Some("/Volumes/Music Backup/Album/Song.flac".into());
3225 backup.disc = Some(2);
3226 upsert_track(&db.conn, &backup).unwrap();
3227 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
3228
3229 assert_eq!(
3231 remove_stale_tracks(&db.conn, Path::new("/Volumes/Music"), false)
3232 .unwrap()
3233 .len(),
3234 1
3235 );
3236
3237 let survivor: String = db
3238 .conn
3239 .query_row("SELECT path FROM tracks", [], |row| row.get(0))
3240 .unwrap();
3241 assert_eq!(survivor, "/Volumes/Music Backup/Album/Song.flac");
3242 }
3243
3244 #[test]
3245 fn test_stale_removal_refuses_wholesale_disappearance() {
3246 let db = test_db();
3247 for i in 0..STALE_CHECK_MIN_ROWS + 20 {
3248 let mut meta = sample_meta(&format!("Track{}", i), "Artist", "Album");
3249 meta.track_number = Some(i as i32);
3250 meta.path = Some(format!("/music/Album/{}.flac", i));
3251 upsert_track(&db.conn, &meta).unwrap();
3252 }
3253 let before = library_stats(&db.conn).unwrap().total_tracks;
3254
3255 let err = remove_stale_tracks(&db.conn, Path::new("/music"), false).unwrap_err();
3256 assert!(
3257 matches!(err, DbError::UnsafeBulkDelete(_)),
3258 "expected refusal, got {:?}",
3259 err
3260 );
3261 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, before);
3262 }
3263
3264 #[test]
3265 fn server_deletions_reach_the_client() {
3266 use std::collections::HashSet;
3267 let db = test_db();
3268 let remote = |title: &str, album: &str, rid: &str, album_rid: &str| {
3269 let mut m = sample_meta(title, "Tove Lo", album);
3270 m.path = None;
3271 m.remote_id = Some(rid.into());
3272 let id = upsert_track(&db.conn, &m).unwrap();
3273 db.conn
3274 .execute(
3275 "UPDATE albums SET remote_id = ?1 WHERE id = (SELECT album_id FROM tracks WHERE id = ?2)",
3276 params![album_rid, id],
3277 )
3278 .unwrap();
3279 };
3280 remote("Habits", "Queen of the Clouds", "t1", "al-1");
3281 remote("Talking Body", "Queen of the Clouds", "t2", "al-1");
3282 remote("Habits", "Habits (single)", "t3", "al-2");
3283 let count = |sql: &str| -> i64 { db.conn.query_row(sql, [], |r| r.get(0)).unwrap() };
3284
3285 let albums: HashSet<String> = ["al-1".to_string()].into();
3287 assert_eq!(
3288 remove_vanished_remote(&db.conn, None, Some(&albums)).unwrap(),
3289 1
3290 );
3291 assert_eq!(
3292 count("SELECT COUNT(*) FROM albums WHERE title = 'Habits (single)'"),
3293 0
3294 );
3295 assert_eq!(count("SELECT COUNT(*) FROM tracks"), 2);
3296
3297 let tracks: HashSet<String> = ["t1".to_string()].into();
3299 assert_eq!(
3300 remove_vanished_remote(&db.conn, Some(&tracks), Some(&albums)).unwrap(),
3301 1
3302 );
3303 assert_eq!(count("SELECT COUNT(*) FROM tracks"), 1);
3304 assert_eq!(count("SELECT COUNT(*) FROM albums"), 1);
3305 }
3306
3307 #[test]
3308 fn test_stale_removal_drops_the_album_and_artist_it_empties() {
3309 let db = test_db();
3310 let tmp = tempfile::tempdir().unwrap();
3311 let keep = tmp.path().join("keep.flac");
3312 std::fs::write(&keep, b"x").unwrap();
3313 let mut kept = sample_meta("Kept", "Artist", "Staying");
3314 kept.path = Some(keep.to_string_lossy().into_owned());
3315 upsert_track(&db.conn, &kept).unwrap();
3316 let mut moved = sample_meta("Moved", "Other Credit", "Moved Away");
3317 moved.path = Some(tmp.path().join("gone.flac").to_string_lossy().into_owned());
3318 upsert_track(&db.conn, &moved).unwrap();
3319
3320 assert_eq!(
3321 remove_stale_tracks(&db.conn, tmp.path(), false)
3322 .unwrap()
3323 .len(),
3324 1
3325 );
3326 let count = |sql: &str| -> i64 { db.conn.query_row(sql, [], |r| r.get(0)).unwrap() };
3327 assert_eq!(
3328 count("SELECT COUNT(*) FROM albums WHERE title = 'Moved Away'"),
3329 0
3330 );
3331 assert_eq!(
3332 count("SELECT COUNT(*) FROM artists WHERE name = 'Other Credit'"),
3333 0
3334 );
3335 assert_eq!(
3336 count("SELECT COUNT(*) FROM albums WHERE title = 'Staying'"),
3337 1
3338 );
3339 }
3340
3341 #[test]
3342 fn test_stale_removal_allows_a_normal_deletion() {
3343 let db = test_db();
3344 let tmp = tempfile::tempdir().unwrap();
3345 let folder = tmp.path();
3346
3347 for i in 0..STALE_CHECK_MIN_ROWS + 20 {
3349 let file = folder.join(format!("{}.flac", i));
3350 if i > 0 {
3351 std::fs::write(&file, b"x").unwrap();
3352 }
3353 let mut meta = sample_meta(&format!("Track{}", i), "Artist", "Album");
3354 meta.track_number = Some(i as i32);
3355 meta.path = Some(file.to_string_lossy().into_owned());
3356 upsert_track(&db.conn, &meta).unwrap();
3357 }
3358
3359 assert_eq!(
3360 remove_stale_tracks(&db.conn, folder, false).unwrap().len(),
3361 1
3362 );
3363 assert_eq!(
3364 library_stats(&db.conn).unwrap().total_tracks,
3365 STALE_CHECK_MIN_ROWS + 19
3366 );
3367 }
3368
3369 #[test]
3370 fn test_resolve_playback_local_wins() {
3371 let db = test_db();
3372
3373 let local = sample_meta("Song", "Artist", "Album");
3375 let local_id = upsert_track(&db.conn, &local).unwrap();
3376
3377 match resolve_playback_path(&db.conn, local_id).unwrap() {
3378 Some(_) | None => {}
3381 }
3382 }
3383
3384 #[test]
3385 fn test_resolve_playback_remote_fallback() {
3386 let db = test_db();
3387
3388 let mut meta = sample_meta("Song", "Artist", "Album");
3389 meta.source = "remote".into();
3390 meta.path = None;
3391 meta.remote_id = Some("r42".into());
3392 meta.remote_url = Some("https://example.com/stream/r42".into());
3393 let id = upsert_track(&db.conn, &meta).unwrap();
3394
3395 let source = resolve_playback_path(&db.conn, id).unwrap().unwrap();
3396 match source {
3397 PlaybackSource::Remote(url) => {
3398 assert!(url.contains("r42"));
3399 }
3400 _ => panic!("expected Remote source"),
3401 }
3402 }
3403
3404 #[test]
3405 fn test_nonexistent_track_resolution() {
3406 let db = test_db();
3407 let result = resolve_playback_path(&db.conn, 99999).unwrap();
3408 assert!(result.is_none());
3409 }
3410
3411 #[test]
3412 fn test_dedup_local_then_remote() {
3413 let db = test_db();
3414
3415 let local = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
3417 let local_id = upsert_track(&db.conn, &local).unwrap();
3418
3419 let mut remote = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
3421 remote.source = "remote".into();
3422 remote.path = None;
3423 remote.remote_id = Some("sub-42".into());
3424 remote.remote_url = Some("https://example.com/stream/sub-42".into());
3425 let remote_id = upsert_track(&db.conn, &remote).unwrap();
3426
3427 assert_eq!(local_id, remote_id);
3429
3430 let stats = library_stats(&db.conn).unwrap();
3432 assert_eq!(stats.total_tracks, 1);
3433
3434 assert_eq!(stats.local_tracks, 1);
3436 assert_eq!(stats.remote_tracks, 0);
3437
3438 let row: (Option<String>, Option<String>, Option<String>) = db
3440 .conn
3441 .query_row(
3442 "SELECT path, remote_id, remote_url FROM tracks WHERE id = ?1",
3443 params![local_id],
3444 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
3445 )
3446 .unwrap();
3447 assert!(row.0.is_some()); assert_eq!(row.1.as_deref(), Some("sub-42")); assert!(row.2.is_some()); }
3451
3452 #[test]
3453 fn test_dedup_remote_then_local() {
3454 let db = test_db();
3455
3456 let mut remote = sample_meta("Vordhosbn", "Aphex Twin", "Drukqs");
3458 remote.source = "remote".into();
3459 remote.path = None;
3460 remote.remote_id = Some("sub-99".into());
3461 remote.remote_url = Some("https://example.com/stream/sub-99".into());
3462 let remote_id = upsert_track(&db.conn, &remote).unwrap();
3463
3464 let local = sample_meta("Vordhosbn", "Aphex Twin", "Drukqs");
3466 let local_id = upsert_track(&db.conn, &local).unwrap();
3467
3468 assert_eq!(remote_id, local_id);
3470
3471 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
3473
3474 assert_eq!(library_stats(&db.conn).unwrap().local_tracks, 1);
3476
3477 let rid: Option<String> = db
3479 .conn
3480 .query_row(
3481 "SELECT remote_id FROM tracks WHERE id = ?1",
3482 params![local_id],
3483 |row| row.get(0),
3484 )
3485 .unwrap();
3486 assert_eq!(rid.as_deref(), Some("sub-99"));
3487 }
3488
3489 #[test]
3490 fn test_remove_stale_preserves_remote_backed() {
3491 let db = test_db();
3492
3493 let mut meta = sample_meta("Ageispolis", "Aphex Twin", "SAW 85-92");
3495 meta.path = Some("/nonexistent/SAW 85-92/Ageispolis.flac".into());
3496 meta.remote_id = Some("sub-10".into());
3497 meta.remote_url = Some("https://example.com/stream/sub-10".into());
3498 let id = upsert_track(&db.conn, &meta).unwrap();
3499
3500 let source: String = db
3502 .conn
3503 .query_row(
3504 "SELECT source FROM tracks WHERE id = ?1",
3505 params![id],
3506 |row| row.get(0),
3507 )
3508 .unwrap();
3509 assert_eq!(source, "local");
3510
3511 let removed =
3513 remove_stale_tracks(&db.conn, Path::new("/nonexistent/SAW 85-92"), false).unwrap();
3514 assert_eq!(removed.len(), 1);
3515
3516 let row: (
3518 Option<String>,
3519 String,
3520 Option<i64>,
3521 Option<i64>,
3522 Option<String>,
3523 ) = db
3524 .conn
3525 .query_row(
3526 "SELECT path, source, mtime, size_bytes, remote_id FROM tracks WHERE id = ?1",
3527 params![id],
3528 |row| {
3529 Ok((
3530 row.get(0)?,
3531 row.get(1)?,
3532 row.get(2)?,
3533 row.get(3)?,
3534 row.get(4)?,
3535 ))
3536 },
3537 )
3538 .unwrap();
3539 assert!(row.0.is_none(), "path should be NULL");
3540 assert_eq!(row.1, "remote", "source should be 'remote'");
3541 assert!(row.2.is_none(), "mtime should be NULL");
3542 assert!(row.3.is_none(), "size_bytes should be NULL");
3543 assert_eq!(row.4.as_deref(), Some("sub-10"), "remote_id preserved");
3544
3545 let playback = resolve_playback_path(&db.conn, id).unwrap().unwrap();
3547 match playback {
3548 PlaybackSource::Remote(url) => assert!(url.contains("sub-10")),
3549 _ => panic!("expected Remote playback source"),
3550 }
3551 }
3552
3553 #[test]
3554 fn test_remove_stale_deletes_pure_local() {
3555 let db = test_db();
3556
3557 let meta = sample_meta("PureLocal", "Artist", "Album");
3559 let id = upsert_track(&db.conn, &meta).unwrap();
3561
3562 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
3563
3564 let removed = remove_stale_tracks(&db.conn, Path::new("/music/Album"), false).unwrap();
3565 assert_eq!(removed.len(), 1);
3566
3567 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
3569
3570 let exists: bool = db
3572 .conn
3573 .query_row(
3574 "SELECT COUNT(*) > 0 FROM tracks WHERE id = ?1",
3575 params![id],
3576 |row| row.get(0),
3577 )
3578 .unwrap();
3579 assert!(!exists, "pure local track should be deleted");
3580 }
3581
3582 #[test]
3583 fn test_reattach_on_rescan() {
3584 let db = test_db();
3585
3586 let mut meta = sample_meta("Xtal", "Aphex Twin", "SAW 85-92");
3588 meta.path = Some("/nonexistent/SAW 85-92/Xtal.flac".into());
3589 meta.remote_id = Some("sub-20".into());
3590 meta.remote_url = Some("https://example.com/stream/sub-20".into());
3591 let original_id = upsert_track(&db.conn, &meta).unwrap();
3592
3593 remove_stale_tracks(&db.conn, Path::new("/nonexistent/SAW 85-92"), false).unwrap();
3595
3596 let source: String = db
3598 .conn
3599 .query_row(
3600 "SELECT source FROM tracks WHERE id = ?1",
3601 params![original_id],
3602 |row| row.get(0),
3603 )
3604 .unwrap();
3605 assert_eq!(source, "remote");
3606
3607 let mut rescan = sample_meta("Xtal", "Aphex Twin", "SAW 85-92");
3610 rescan.path = Some("/nonexistent/SAW 85-92/Xtal.flac".into());
3611 let rescan_id = upsert_track(&db.conn, &rescan).unwrap();
3612
3613 assert_eq!(original_id, rescan_id);
3615
3616 let row: (Option<String>, String, Option<String>) = db
3618 .conn
3619 .query_row(
3620 "SELECT path, source, remote_id FROM tracks WHERE id = ?1",
3621 params![rescan_id],
3622 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
3623 )
3624 .unwrap();
3625 assert_eq!(
3626 row.0.as_deref(),
3627 Some("/nonexistent/SAW 85-92/Xtal.flac"),
3628 "path re-attached"
3629 );
3630 assert_eq!(row.1, "local", "source flipped back to local");
3631 assert_eq!(row.2.as_deref(), Some("sub-20"), "remote_id preserved");
3632
3633 assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
3635 }
3636
3637 #[test]
3638 fn test_genres_by_artist_ids() {
3639 let db = test_db();
3640 let mut meta1 = sample_meta("Track1", "ArtistA", "Album1");
3641 meta1.genre = Some("Rock".into());
3642 upsert_track(&db.conn, &meta1).unwrap();
3643
3644 let mut meta2 = sample_meta("Track2", "ArtistA", "Album1");
3645 meta2.genre = Some("Jazz".into());
3646 meta2.track_number = Some(2);
3647 meta2.path = Some("/music/Album1/Track2.flac".into());
3648 upsert_track(&db.conn, &meta2).unwrap();
3649
3650 let mut meta3 = sample_meta("Track3", "ArtistB", "Album2");
3651 meta3.genre = Some("Metal".into());
3652 upsert_track(&db.conn, &meta3).unwrap();
3653
3654 let artist_a_id: i64 = db
3656 .conn
3657 .query_row("SELECT id FROM artists WHERE name = 'ArtistA'", [], |row| {
3658 row.get(0)
3659 })
3660 .unwrap();
3661 let artist_b_id: i64 = db
3662 .conn
3663 .query_row("SELECT id FROM artists WHERE name = 'ArtistB'", [], |row| {
3664 row.get(0)
3665 })
3666 .unwrap();
3667
3668 let genres = genres_by_artist_ids(&db.conn, &[artist_a_id, artist_b_id]).unwrap();
3669 let a_genres = genres.get(&artist_a_id).unwrap();
3670 assert!(a_genres.contains("rock"));
3671 assert!(a_genres.contains("jazz"));
3672 let b_genres = genres.get(&artist_b_id).unwrap();
3673 assert!(b_genres.contains("metal"));
3674 }
3675
3676 #[test]
3677 fn test_genres_by_artist_ids_empty() {
3678 let db = test_db();
3679 let genres = genres_by_artist_ids(&db.conn, &[]).unwrap();
3680 assert!(genres.is_empty());
3681 }
3682
3683 #[test]
3684 fn test_genres_by_album_ids() {
3685 let db = test_db();
3686 let mut meta1 = sample_meta("Track1", "Artist", "AlbumX");
3687 meta1.genre = Some("Ambient".into());
3688 upsert_track(&db.conn, &meta1).unwrap();
3689
3690 let mut meta2 = sample_meta("Track2", "Artist", "AlbumX");
3691 meta2.genre = Some("IDM".into());
3692 meta2.track_number = Some(2);
3693 meta2.path = Some("/music/AlbumX/Track2.flac".into());
3694 upsert_track(&db.conn, &meta2).unwrap();
3695
3696 let album_id: i64 = db
3697 .conn
3698 .query_row("SELECT id FROM albums WHERE title = 'AlbumX'", [], |row| {
3699 row.get(0)
3700 })
3701 .unwrap();
3702
3703 let genres = genres_by_album_ids(&db.conn, &[album_id]).unwrap();
3704 let album_genres = genres.get(&album_id).unwrap();
3705 assert!(album_genres.contains("ambient"));
3706 assert!(album_genres.contains("idm"));
3707 }
3708
3709 #[test]
3710 fn test_favourite_artist_ids_batch() {
3711 let db = test_db();
3712 let meta = sample_meta("FavTrack", "FavArtist", "FavAlbum");
3713 upsert_track(&db.conn, &meta).unwrap();
3714
3715 crate::db::queries::add_favourite(
3717 &db.conn,
3718 crate::db::queries::LOCAL_USER,
3719 std::path::Path::new("/music/FavAlbum/FavTrack.flac"),
3720 )
3721 .unwrap();
3722
3723 let artist_id: i64 = db
3724 .conn
3725 .query_row(
3726 "SELECT id FROM artists WHERE name = 'FavArtist'",
3727 [],
3728 |row| row.get(0),
3729 )
3730 .unwrap();
3731
3732 let fav_ids = favourite_artist_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
3733 assert!(fav_ids.contains(&artist_id));
3734 }
3735
3736 #[test]
3737 fn test_favourite_artist_ids_batch_empty() {
3738 let db = test_db();
3739 let fav_ids = favourite_artist_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
3740 assert!(fav_ids.is_empty());
3741 }
3742
3743 #[test]
3744 fn test_favourite_album_ids_batch() {
3745 let db = test_db();
3746 let meta = sample_meta("FavTrack", "FavArtist", "FavAlbum");
3747 upsert_track(&db.conn, &meta).unwrap();
3748
3749 crate::db::queries::add_favourite(
3750 &db.conn,
3751 crate::db::queries::LOCAL_USER,
3752 std::path::Path::new("/music/FavAlbum/FavTrack.flac"),
3753 )
3754 .unwrap();
3755
3756 let album_id: i64 = db
3757 .conn
3758 .query_row(
3759 "SELECT id FROM albums WHERE title = 'FavAlbum'",
3760 [],
3761 |row| row.get(0),
3762 )
3763 .unwrap();
3764
3765 let fav_ids = favourite_album_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
3766 assert!(fav_ids.contains(&album_id));
3767 }
3768
3769 #[test]
3770 fn test_favourite_album_ids_batch_empty() {
3771 let db = test_db();
3772 let fav_ids = favourite_album_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
3773 assert!(fav_ids.is_empty());
3774 }
3775
3776 #[test]
3777 fn test_set_cached_path_records_size_and_date() {
3778 let db = test_db();
3779 let mut meta = sample_meta("Song", "Artist", "Album");
3780 meta.source = "remote".into();
3781 meta.path = None;
3782 meta.remote_id = Some("r1".into());
3783 meta.remote_url = Some("https://example.com/r1".into());
3784 let id = upsert_track(&db.conn, &meta).unwrap();
3785
3786 let tmp = tempfile::NamedTempFile::new().unwrap();
3788 std::io::Write::write_all(&mut tmp.as_file().try_clone().unwrap(), &[0u8; 1024]).unwrap();
3789 let path = tmp.path().to_string_lossy().to_string();
3790
3791 set_cached_path(&db.conn, id, &path).unwrap();
3792
3793 let (cached_path, size, download_date): (Option<String>, Option<i64>, Option<i64>) = db
3794 .conn
3795 .query_row(
3796 "SELECT cached_path, cache_size_bytes, cache_download_date FROM tracks WHERE id = ?1",
3797 params![id],
3798 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
3799 )
3800 .unwrap();
3801
3802 assert_eq!(cached_path.as_deref(), Some(path.as_str()));
3803 assert!(size.unwrap() > 0, "cache_size_bytes should be positive");
3804 assert!(
3805 download_date.unwrap() > 0,
3806 "cache_download_date should be set"
3807 );
3808 }
3809
3810 #[test]
3811 fn test_total_cache_size() {
3812 let db = test_db();
3813
3814 assert_eq!(total_cache_size(&db.conn).unwrap(), 0);
3816
3817 let mut meta = sample_meta("Song", "Artist", "Album");
3819 meta.source = "remote".into();
3820 meta.path = None;
3821 meta.remote_id = Some("r1".into());
3822 let id = upsert_track(&db.conn, &meta).unwrap();
3823
3824 db.conn
3825 .execute(
3826 "UPDATE tracks SET cached_path = '/cache/song.flac', cache_size_bytes = 50000000 WHERE id = ?1",
3827 params![id],
3828 )
3829 .unwrap();
3830
3831 assert_eq!(total_cache_size(&db.conn).unwrap(), 50_000_000);
3832 }
3833
3834 #[test]
3835 fn test_clear_cache_for_tracks() {
3836 let db = test_db();
3837 let mut meta = sample_meta("Song", "Artist", "Album");
3838 meta.source = "remote".into();
3839 meta.path = None;
3840 meta.remote_id = Some("r1".into());
3841 let id = upsert_track(&db.conn, &meta).unwrap();
3842
3843 db.conn
3844 .execute(
3845 "UPDATE tracks SET cached_path = '/cache/song.flac', cache_size_bytes = 1000, cache_download_date = 12345 WHERE id = ?1",
3846 params![id],
3847 )
3848 .unwrap();
3849
3850 clear_cached_paths_for(&db.conn, &[id]).unwrap();
3851
3852 let (path, size, date): (Option<String>, Option<i64>, Option<i64>) = db
3853 .conn
3854 .query_row(
3855 "SELECT cached_path, cache_size_bytes, cache_download_date FROM tracks WHERE id = ?1",
3856 params![id],
3857 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
3858 )
3859 .unwrap();
3860
3861 assert!(path.is_none());
3862 assert!(size.is_none());
3863 assert!(date.is_none());
3864 }
3865
3866 #[test]
3867 fn test_cached_albums_lru_excludes_favourites() {
3868 let db = test_db();
3869
3870 for (album, tracks) in &[("AlbumA", vec!["T1", "T2"]), ("AlbumB", vec!["T3", "T4"])] {
3872 for (i, title) in tracks.iter().enumerate() {
3873 let mut meta = sample_meta(title, "Artist", album);
3874 meta.source = "remote".into();
3875 meta.path = None;
3876 meta.remote_id = Some(format!("r-{}", title));
3877 meta.track_number = Some((i + 1) as i32);
3878 let id = upsert_track(&db.conn, &meta).unwrap();
3879 let cached = format!("/cache/{}/{}.flac", album, title);
3880 db.conn
3881 .execute(
3882 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000 WHERE id = ?2",
3883 params![cached, id],
3884 )
3885 .unwrap();
3886 }
3887 }
3888
3889 crate::db::queries::add_favourite(
3891 &db.conn,
3892 crate::db::queries::LOCAL_USER,
3893 std::path::Path::new("/cache/AlbumB/T3.flac"),
3894 )
3895 .unwrap();
3896
3897 let albums = cached_albums_lru(&db.conn).unwrap();
3898
3899 assert_eq!(albums.len(), 1);
3901 assert_eq!(albums[0].album_title, "AlbumA");
3902 }
3903
3904 #[test]
3905 fn eviction_keeps_what_the_queue_holds() {
3906 crate::config::isolate_config_for_tests();
3907 let db = test_db();
3908 let mut ids = Vec::new();
3909 for (album, played_at) in &[("OldAlbum", 1000), ("NewAlbum", 9000)] {
3910 let mut meta = sample_meta("Track", "Artist", album);
3911 meta.source = "remote".into();
3912 meta.path = None;
3913 meta.remote_id = Some(format!("r-{}", album));
3914 let id = upsert_track(&db.conn, &meta).unwrap();
3915 db.conn
3916 .execute(
3917 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000 WHERE id = ?2",
3918 params![format!("/nonexistent/{album}/Track.flac"), id],
3919 )
3920 .unwrap();
3921 db.conn
3922 .execute(
3923 "INSERT INTO play_history (track_id, played_at) VALUES (?1, ?2)",
3924 params![id, played_at],
3925 )
3926 .unwrap();
3927 ids.push(id);
3928 }
3929 let mut cfg = crate::config::Config::default();
3930 cfg.remote.cache_limit = Some("15MB".into());
3931
3932 let keep = std::collections::HashSet::from([ids[0]]);
3934 crate::helpers::evict_cache(&db, &cfg, &keep, false);
3935
3936 let cached: Vec<i64> = ids
3937 .iter()
3938 .copied()
3939 .filter(|id| {
3940 db.conn
3941 .query_row(
3942 "SELECT cached_path IS NOT NULL FROM tracks WHERE id = ?1",
3943 params![id],
3944 |r| r.get(0),
3945 )
3946 .unwrap()
3947 })
3948 .collect();
3949 assert_eq!(cached, vec![ids[0]]);
3950 }
3951
3952 #[test]
3953 fn test_cached_albums_lru_sorted_by_last_play() {
3954 let db = test_db();
3955
3956 let mut album_ids = Vec::new();
3958 for (album, played_at) in &[("OldAlbum", 1000), ("NewAlbum", 9000)] {
3959 let mut meta = sample_meta("Track", "Artist", album);
3960 meta.source = "remote".into();
3961 meta.path = None;
3962 meta.remote_id = Some(format!("r-{}", album));
3963 let id = upsert_track(&db.conn, &meta).unwrap();
3964 let cached = format!("/cache/{}/Track.flac", album);
3965 db.conn
3966 .execute(
3967 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000 WHERE id = ?2",
3968 params![cached, id],
3969 )
3970 .unwrap();
3971
3972 db.conn
3974 .execute(
3975 "INSERT INTO play_history (track_id, played_at) VALUES (?1, ?2)",
3976 params![id, played_at],
3977 )
3978 .unwrap();
3979
3980 album_ids.push(id);
3981 }
3982
3983 let albums = cached_albums_lru(&db.conn).unwrap();
3984 assert_eq!(albums.len(), 2);
3985 assert_eq!(albums[0].album_title, "OldAlbum");
3987 assert_eq!(albums[1].album_title, "NewAlbum");
3988 }
3989
3990 #[test]
3991 fn test_cached_albums_lru_counts_a_fresh_download_as_used() {
3992 let db = test_db();
3993 for (album, played_at, downloaded) in
3995 [("Played", Some(5000), 1000), ("Fetched", None, 9000)]
3996 {
3997 let mut meta = sample_meta("Track", "Artist", album);
3998 meta.source = "remote".into();
3999 meta.path = None;
4000 meta.remote_id = Some(format!("r-{album}"));
4001 let id = upsert_track(&db.conn, &meta).unwrap();
4002 db.conn
4003 .execute(
4004 "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 1, cache_download_date = ?2
4005 WHERE id = ?3",
4006 params![format!("/cache/{album}.flac"), downloaded, id],
4007 )
4008 .unwrap();
4009 if let Some(played_at) = played_at {
4010 db.conn
4011 .execute(
4012 "INSERT INTO play_history (track_id, played_at) VALUES (?1, ?2)",
4013 params![id, played_at],
4014 )
4015 .unwrap();
4016 }
4017 }
4018
4019 let albums = cached_albums_lru(&db.conn).unwrap();
4020 let order: Vec<&str> = albums.iter().map(|a| a.album_title.as_str()).collect();
4021 assert_eq!(order, ["Played", "Fetched"]);
4022 }
4023
4024 #[test]
4025 fn test_clear_cached_paths_clears_all_tracking() {
4026 let db = test_db();
4027 let mut meta = sample_meta("Song", "Artist", "Album");
4028 meta.source = "remote".into();
4029 meta.path = None;
4030 meta.remote_id = Some("r1".into());
4031 let id = upsert_track(&db.conn, &meta).unwrap();
4032
4033 db.conn
4034 .execute(
4035 "UPDATE tracks SET cached_path = '/x', cache_size_bytes = 100, cache_download_date = 999 WHERE id = ?1",
4036 params![id],
4037 )
4038 .unwrap();
4039
4040 clear_cached_paths(&db.conn).unwrap();
4041
4042 let (path, size, date): (Option<String>, Option<i64>, Option<i64>) = db
4043 .conn
4044 .query_row(
4045 "SELECT cached_path, cache_size_bytes, cache_download_date FROM tracks WHERE id = ?1",
4046 params![id],
4047 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
4048 )
4049 .unwrap();
4050
4051 assert!(path.is_none());
4052 assert!(size.is_none());
4053 assert!(date.is_none());
4054 }
4055
4056 #[test]
4057 fn test_migration_folds_a_file_indexed_under_two_spellings() {
4058 use unicode_normalization::UnicodeNormalization;
4059 let db = test_db();
4060 let nfc: String = "/music/Roman Flügel - Softice.flac".nfc().collect();
4061 let nfd: String = "/music/Roman Flügel - Softice.flac".nfd().collect();
4062 assert_ne!(nfc, nfd);
4063
4064 let mut dropped = sample_meta("Softice", "Roman Flügel", "Renaissance");
4067 dropped.path = Some(nfc.clone());
4068 dropped.remote_id = Some("sub-7".into());
4069 let winner = upsert_track(&db.conn, &dropped).unwrap();
4070 let mut scanned = sample_meta("Softice", "Roman Flügel", "Renaissance");
4071 scanned.path = Some(nfd.clone());
4072 let loser = upsert_track(&db.conn, &scanned).unwrap();
4073 assert_ne!(
4074 winner, loser,
4075 "the bytes differ, so the old key made two rows"
4076 );
4077 db.conn
4078 .execute(
4079 "INSERT INTO favourites (track_path) VALUES (?1)",
4080 params![nfc],
4081 )
4082 .unwrap();
4083 for (path, id) in [(&nfc, winner), (&nfd, loser)] {
4084 db.conn
4085 .execute(
4086 "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
4087 params![path, id],
4088 )
4089 .unwrap();
4090 }
4091
4092 merge_spelling_twins(&db.conn).unwrap();
4093
4094 let rows: Vec<(i64, String, Option<String>)> = db
4095 .conn
4096 .prepare("SELECT id, path, remote_id FROM tracks")
4097 .unwrap()
4098 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
4099 .unwrap()
4100 .collect::<Result<_, _>>()
4101 .unwrap();
4102 assert_eq!(rows.len(), 1, "one file, one row");
4103 assert_eq!(rows[0].0, winner, "the older row keeps its identity");
4104 assert_eq!(rows[0].1, nfd, "and takes the disk's spelling");
4105 assert_eq!(rows[0].2.as_deref(), Some("sub-7"), "and its sync link");
4106 let starred: String = db
4107 .conn
4108 .query_row("SELECT track_path FROM favourites", [], |r| r.get(0))
4109 .unwrap();
4110 assert_eq!(starred, nfd, "the favourite follows the path");
4111 let cached: Vec<(String, i64)> = db
4112 .conn
4113 .prepare("SELECT path, track_id FROM scan_cache")
4114 .unwrap()
4115 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
4116 .unwrap()
4117 .collect::<Result<_, _>>()
4118 .unwrap();
4119 assert_eq!(
4120 cached,
4121 vec![(nfd, winner)],
4122 "one cache row, under the spelling a scan will ask for"
4123 );
4124 }
4125
4126 #[test]
4127 fn test_migration_leaves_a_pair_with_no_disk_spelling_alone() {
4128 use unicode_normalization::UnicodeNormalization;
4129 let db = test_db();
4130 let a: String = "/music/Isolée - Allowance.flac".nfc().collect();
4133 let b: String = "/music/Isolée - Allowance.flac"
4134 .nfc()
4135 .chain(" ".chars())
4136 .collect();
4137 for path in [&a, &b] {
4138 let mut meta = sample_meta("Allowance", "Isolée", "Renaissance");
4139 meta.path = Some(path.clone());
4140 upsert_track(&db.conn, &meta).unwrap();
4141 }
4142 merge_spelling_twins(&db.conn).unwrap();
4143 let rows: i64 = db
4144 .conn
4145 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
4146 .unwrap();
4147 assert_eq!(rows, 2);
4148 }
4149}