1use std::collections::{HashMap, HashSet};
2use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
3
4use crate::db::connection::Database;
5use crate::db::queries::{self, TrackMeta};
6use crate::remote::client::{
7 SubsonicAlbum, SubsonicAlbumFull, SubsonicArtist, SubsonicClient, SubsonicError, SubsonicSong,
8};
9
10use rayon::prelude::*;
11use rusqlite::params;
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum SyncError {
16 #[error("subsonic error: {0}")]
17 Subsonic(#[from] super::client::SubsonicError),
18 #[error("db error: {0}")]
19 Db(#[from] crate::db::connection::DbError),
20}
21
22#[derive(Debug, Default)]
23pub struct SyncResult {
24 pub artists_synced: usize,
25 pub albums_synced: usize,
26 pub tracks_synced: usize,
27 pub albums_failed: usize,
30 pub pages_failed: usize,
33 pub tracks_failed: usize,
36 pub tracks_removed: usize,
38}
39
40impl SyncResult {
41 pub fn is_complete(&self) -> bool {
43 self.albums_failed == 0 && self.pages_failed == 0 && self.tracks_failed == 0
44 }
45}
46
47pub fn get_last_sync(
49 db: &Database,
50 url: &str,
51) -> Result<Option<i64>, crate::db::connection::DbError> {
52 let result = db.conn.query_row(
53 "SELECT last_sync FROM remote_servers WHERE url = ?1",
54 params![url],
55 |row| row.get::<_, Option<i64>>(0),
56 );
57 match result {
58 Ok(ts) => Ok(ts),
59 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
60 Err(e) => Err(e.into()),
61 }
62}
63
64pub fn library_version(db: &Database, url: &str) -> Option<i64> {
67 db.conn
68 .query_row(
69 "SELECT library_version FROM remote_servers WHERE url = ?1",
70 params![url],
71 |row| row.get::<_, Option<i64>>(0),
72 )
73 .ok()
74 .flatten()
75}
76
77pub fn set_library_version(
79 db: &Database,
80 url: &str,
81 username: &str,
82 version: i64,
83) -> Result<(), crate::db::connection::DbError> {
84 db.conn.execute(
85 "INSERT INTO remote_servers (url, username, library_version)
86 VALUES (?1, ?2, ?3)
87 ON CONFLICT(url) DO UPDATE SET library_version = ?3",
88 params![url, username, version],
89 )?;
90 Ok(())
91}
92
93pub fn update_last_sync(
95 db: &Database,
96 url: &str,
97 username: &str,
98 timestamp: i64,
99) -> Result<(), crate::db::connection::DbError> {
100 db.conn.execute(
101 "INSERT INTO remote_servers (url, username, last_sync)
102 VALUES (?1, ?2, ?3)
103 ON CONFLICT(url) DO UPDATE SET last_sync = ?3",
104 params![url, username, timestamp],
105 )?;
106 Ok(())
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum SyncPhase {
112 Albums,
114 Tracks,
116 Artists,
118 Finishing,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub struct SyncProgress {
127 pub phase: SyncPhase,
128 pub done: u64,
129 pub total: Option<u64>,
130}
131
132const PAGE_SIZE: u32 = 500;
134
135const FETCH_LANES: usize = 4;
139
140const PAGE_ATTEMPTS: u32 = 3;
142
143pub fn sync_library(
164 db: &Database,
165 client: &SubsonicClient,
166 server_url: &str,
167 username: &str,
168 progress: &(dyn Fn(SyncProgress) + Sync),
169) -> Result<SyncResult, SyncError> {
170 let mut result = SyncResult::default();
171 log::info!("syncing the library from {server_url}");
172
173 let sync_start = std::time::SystemTime::now()
174 .duration_since(std::time::UNIX_EPOCH)
175 .unwrap_or_default()
176 .as_secs() as i64;
177
178 let albums = list_albums(client, progress)?;
179
180 let expected: u64 = albums
181 .iter()
182 .filter_map(|a| a.song_count)
183 .map(|n| n.max(0) as u64)
184 .sum();
185
186 let mut song_ids: HashSet<String> = HashSet::new();
187 let mut total = (expected > 0).then_some(expected);
188 if total.is_none() {
189 total = client.song_count().ok().flatten();
190 }
191 let walked = !albums.is_empty()
192 && sync_all_songs(
193 db,
194 client,
195 &albums,
196 total,
197 &mut result,
198 &mut song_ids,
199 progress,
200 )?;
201 if !walked {
202 let all: Vec<&SubsonicAlbum> = albums.iter().collect();
203 sync_by_album(
204 db,
205 client,
206 &all,
207 total,
208 &mut result,
209 &mut song_ids,
210 progress,
211 )?;
212 }
213
214 let artists = client.get_artists()?;
218 result.artists_synced = artists.len();
219 progress(SyncProgress {
220 phase: SyncPhase::Artists,
221 done: 0,
222 total: Some(artists.len() as u64),
223 });
224 write_artists(db, &artists, &mut result);
225
226 progress(SyncProgress {
227 phase: SyncPhase::Finishing,
228 done: 0,
229 total: None,
230 });
231
232 let mut live_albums: std::collections::HashSet<String> =
238 albums.iter().map(|a| a.id.clone()).collect();
239 if result.is_complete() && !live_albums.is_empty() {
240 confirm_missing_albums(db, client, &mut live_albums);
241 }
242 let albums_short = live_albums.len() > albums.len();
243
244 let listed_everything = !albums_short && total.is_none_or(|n| song_ids.len() as u64 >= n);
250 let live_tracks =
251 (result.is_complete() && !song_ids.is_empty() && listed_everything).then_some(&song_ids);
252 if result.is_complete() && !live_albums.is_empty() {
253 match queries::remove_vanished_remote(&db.conn, live_tracks, Some(&live_albums)) {
254 Ok(0) => {}
255 Ok(n) => {
256 result.tracks_removed = n;
257 log::info!("{n} tracks the server no longer has; removed");
258 }
259 Err(e) => log::warn!("failed to remove tracks the server deleted: {e}"),
260 }
261 }
262
263 if result.is_complete() {
264 update_last_sync(db, server_url, username, sync_start)?;
265 } else {
266 log::warn!(
267 "{} album(s) and {} page(s) failed to fetch and {} track(s) failed to write — the next sync walks the library again",
268 result.albums_failed,
269 result.pages_failed,
270 result.tracks_failed,
271 );
272 }
273
274 log::info!(
275 "sync complete: {} artists, {} albums, {} tracks, {} albums and {} tracks failed",
276 result.artists_synced,
277 result.albums_synced,
278 result.tracks_synced,
279 result.albums_failed,
280 result.tracks_failed,
281 );
282
283 db.optimize();
284
285 Ok(result)
286}
287
288fn confirm_missing_albums(db: &Database, client: &SubsonicClient, live: &mut HashSet<String>) {
294 let held: Vec<String> = db
295 .conn
296 .prepare(
297 "SELECT DISTINCT al.remote_id FROM albums al JOIN tracks t ON t.album_id = al.id
298 WHERE al.remote_id IS NOT NULL AND t.path IS NULL AND t.remote_id IS NOT NULL",
299 )
300 .and_then(|mut stmt| {
301 stmt.query_map([], |r| r.get(0))?
302 .collect::<rusqlite::Result<Vec<String>>>()
303 })
304 .unwrap_or_default();
305 let missing: Vec<String> = held.into_iter().filter(|id| !live.contains(id)).collect();
306 for id in missing {
307 match client.get_album(&id) {
308 Err(SubsonicError::Api { code: 70, .. }) => {}
309 Ok(_) => {
310 log::info!("album {id} was missing from the listing but still exists");
311 live.insert(id);
312 }
313 Err(e) => {
314 log::warn!("could not confirm album {id} was deleted ({e}); keeping it");
315 live.insert(id);
316 }
317 }
318 }
319}
320
321fn list_albums(
323 client: &SubsonicClient,
324 progress: &(dyn Fn(SyncProgress) + Sync),
325) -> Result<Vec<SubsonicAlbum>, SyncError> {
326 let mut albums = Vec::new();
327 let mut seen: HashSet<String> = HashSet::new();
330 let mut offset = 0u32;
331 loop {
332 let page = client.get_album_list("alphabeticalByName", PAGE_SIZE, offset)?;
333 let count = page.len() as u32;
334 offset += count;
335 albums.extend(page.into_iter().filter(|a| seen.insert(a.id.clone())));
336 progress(SyncProgress {
337 phase: SyncPhase::Albums,
338 done: albums.len() as u64,
339 total: None,
340 });
341 if count < PAGE_SIZE {
342 return Ok(albums);
343 }
344 }
345}
346
347fn sync_all_songs(
354 db: &Database,
355 client: &SubsonicClient,
356 albums: &[SubsonicAlbum],
357 total: Option<u64>,
358 result: &mut SyncResult,
359 song_ids: &mut HashSet<String>,
360 progress: &(dyn Fn(SyncProgress) + Sync),
361) -> Result<bool, SyncError> {
362 let first = match client.all_songs_page(PAGE_SIZE, 0) {
363 Ok(songs) if !songs.is_empty() => songs,
364 Ok(_) => {
365 log::info!("server lists no songs for an empty search; syncing album by album");
366 return Ok(false);
367 }
368 Err(e) => {
369 log::info!("server refused an empty search ({e}); syncing album by album");
370 return Ok(false);
371 }
372 };
373
374 let by_id: HashMap<&str, &SubsonicAlbum> = albums.iter().map(|a| (a.id.as_str(), a)).collect();
375 let mut albums_seen: HashSet<String> = HashSet::new();
376 let mut write = |songs: Vec<SubsonicSong>,
377 result: &mut SyncResult,
378 song_ids: &mut HashSet<String>|
379 -> Result<(), SyncError> {
380 let batch = group_by_album(songs, &by_id);
381 albums_seen.extend(batch.iter().map(|a| a.id.clone()));
382 write_albums(db, client, &batch, result, song_ids)?;
383 progress(SyncProgress {
384 phase: SyncPhase::Tracks,
385 done: song_ids.len() as u64,
386 total,
387 });
388 Ok(())
389 };
390
391 let short = (first.len() as u32) < PAGE_SIZE;
392 write(first, result, song_ids)?;
393
394 if !short {
395 let next = AtomicU32::new(PAGE_SIZE);
401 let end = AtomicU32::new(u32::MAX);
404 let (tx, rx) = std::sync::mpsc::sync_channel(FETCH_LANES);
405 let failed = std::thread::scope(|scope| -> Result<usize, SyncError> {
406 for _ in 0..FETCH_LANES {
407 let tx = tx.clone();
408 let (next, end) = (&next, &end);
409 scope.spawn(move || {
410 loop {
411 let offset = next.fetch_add(PAGE_SIZE, Ordering::Relaxed);
412 if offset >= end.load(Ordering::Relaxed) {
413 return;
414 }
415 let page = fetch_page(client, offset);
416 if page
421 .as_ref()
422 .map_or(true, |songs| (songs.len() as u32) < PAGE_SIZE)
423 {
424 end.fetch_min(offset, Ordering::Relaxed);
425 }
426 if tx.send((offset, page)).is_err() {
427 return;
428 }
429 }
430 });
431 }
432 drop(tx);
433
434 let mut failed = 0;
435 for (offset, page) in rx {
436 match page {
437 Ok(songs) => write(songs, result, song_ids)?,
438 Err(e) => {
439 log::warn!("failed to fetch songs from offset {offset}: {e}");
440 failed += 1;
441 }
442 }
443 }
444 Ok(failed)
445 })?;
446 result.pages_failed += failed;
447 }
448
449 result.albums_synced += albums_seen.len();
450 Ok(true)
451}
452
453fn fetch_page(
456 client: &SubsonicClient,
457 offset: u32,
458) -> Result<Vec<SubsonicSong>, super::client::SubsonicError> {
459 let mut attempt = 1;
460 loop {
461 match client.all_songs_page(PAGE_SIZE, offset) {
462 Ok(songs) => return Ok(songs),
463 Err(e) if attempt >= PAGE_ATTEMPTS => return Err(e),
464 Err(e) => {
465 log::debug!("songs from offset {offset}, attempt {attempt}: {e}");
466 std::thread::sleep(std::time::Duration::from_millis(250 * attempt as u64));
467 attempt += 1;
468 }
469 }
470 }
471}
472
473fn group_by_album(
479 songs: Vec<SubsonicSong>,
480 albums: &HashMap<&str, &SubsonicAlbum>,
481) -> Vec<SubsonicAlbumFull> {
482 let mut grouped: Vec<SubsonicAlbumFull> = Vec::new();
483 let mut index: HashMap<String, usize> = HashMap::new();
484 for song in songs {
485 let Some(album_id) = song.album_id.clone() else {
486 log::warn!("song {} has no album id; skipped", song.id);
487 continue;
488 };
489 let i = *index.entry(album_id.clone()).or_insert_with(|| {
490 grouped.push(match albums.get(album_id.as_str()) {
491 Some(album) => SubsonicAlbumFull {
492 id: album.id.clone(),
493 name: album.name.clone(),
494 artist: album.artist.clone(),
495 artist_id: album.artist_id.clone(),
496 year: album.year,
497 genre: album.genre.clone(),
498 song_count: album.song_count,
499 created: album.created.clone(),
500 music_brainz_id: album.music_brainz_id.clone(),
501 sort_name: album.sort_name.clone(),
502 record_labels: album.record_labels.clone(),
503 song: Vec::new(),
504 },
505 None => SubsonicAlbumFull {
506 id: album_id,
507 name: song.album.clone().unwrap_or_default(),
508 artist: song.artist.clone(),
509 artist_id: song.artist_id.clone(),
510 year: song.year,
511 genre: song.genre.clone(),
512 song_count: None,
513 created: None,
514 music_brainz_id: None,
515 sort_name: None,
516 record_labels: Vec::new(),
517 song: Vec::new(),
518 },
519 });
520 grouped.len() - 1
521 });
522 grouped[i].song.push(song);
523 }
524 grouped
525}
526
527const ALBUM_BATCH: usize = 100;
530
531fn sync_by_album(
534 db: &Database,
535 client: &SubsonicClient,
536 albums: &[&SubsonicAlbum],
537 total: Option<u64>,
538 result: &mut SyncResult,
539 song_ids: &mut HashSet<String>,
540 progress: &(dyn Fn(SyncProgress) + Sync),
541) -> Result<(), SyncError> {
542 for batch in albums.chunks(ALBUM_BATCH) {
543 let failures = AtomicUsize::new(0);
544 let fetched: Vec<SubsonicAlbumFull> = batch
545 .par_iter()
546 .filter_map(|album| match client.get_album(&album.id) {
547 Ok(full) => Some(full),
548 Err(e) => {
549 log::warn!("failed to fetch album {}: {}", album.id, e);
550 failures.fetch_add(1, Ordering::Relaxed);
551 None
552 }
553 })
554 .collect();
555 result.albums_failed += failures.into_inner();
556 result.albums_synced += fetched.len();
557
558 write_albums(db, client, &fetched, result, song_ids)?;
559 progress(SyncProgress {
560 phase: SyncPhase::Tracks,
561 done: song_ids.len() as u64,
562 total,
563 });
564 log::info!(
565 "synced {} albums ({} tracks) so far...",
566 result.albums_synced,
567 result.tracks_synced
568 );
569 }
570 Ok(())
571}
572
573fn write_artists(db: &Database, artists: &[SubsonicArtist], result: &mut SyncResult) {
578 if db.conn.execute_batch("BEGIN IMMEDIATE").is_err() {
582 return;
583 }
584 let mut enriched = 0;
585 for artist in artists {
586 match queries::enrich_remote_artist(
587 &db.conn,
588 &artist.id,
589 artist.music_brainz_id.as_deref(),
590 artist.sort_name.as_deref(),
591 ) {
592 Ok(()) => enriched += 1,
593 Err(e) => log::warn!(
594 "failed to record artist metadata for {}: {}",
595 artist.name,
596 e
597 ),
598 }
599 }
600 if db.conn.execute_batch("COMMIT").is_err() {
601 let _ = db.conn.execute_batch("ROLLBACK");
602 return;
603 }
604 result.artists_synced = enriched;
605 log::info!("recorded metadata for {enriched} artists");
606}
607
608fn write_albums(
610 db: &Database,
611 client: &SubsonicClient,
612 albums: &[SubsonicAlbumFull],
613 result: &mut SyncResult,
614 song_ids: &mut HashSet<String>,
615) -> Result<(), SyncError> {
616 db.conn
619 .execute_batch("BEGIN IMMEDIATE")
620 .map_err(crate::db::connection::DbError::from)?;
621
622 for album in albums {
623 let artist_name = album.artist.as_deref().unwrap_or("Unknown Artist");
624
625 for song in &album.song {
626 song_ids.insert(song.id.clone());
627 let meta = TrackMeta {
628 title: song.title.clone(),
629 artist: song
630 .artist
631 .clone()
632 .unwrap_or_else(|| artist_name.to_string()),
633 album_artist: album.artist.clone(),
634 album: album.name.clone(),
635 date: album.year.map(|y| y.to_string()),
636 disc: song.disc_number,
637 track_number: song.track,
638 genre: song.genre.clone().or_else(|| album.genre.clone()),
639 label: None,
640 duration_ms: song.duration.map(|d| d * 1000),
641 codec: song.codec.clone().or_else(|| song.suffix.clone()),
642 sample_rate: positive(song.sampling_rate),
649 bit_depth: positive(song.bit_depth),
650 channels: positive(song.channel_count),
651 bitrate: song.bit_rate,
652 size_bytes: None,
653 mtime: None,
654 path: None,
655 source: "remote".to_string(),
656 remote_id: Some(song.id.clone()),
657 remote_url: Some(client.stream_url_template(&song.id)),
658 album_remote_id: Some(album.id.clone()),
659 artist_remote_id: album.artist_id.clone(),
660 mbid: song.music_brainz_id.clone(),
661 album_mbid: album.music_brainz_id.clone(),
662 album_added_at: album.created.clone(),
663 credits: song.credits(),
664 };
665
666 match queries::upsert_synced_track(&db.conn, &meta, song_ids) {
667 Ok(_) => result.tracks_synced += 1,
668 Err(e) => {
669 result.tracks_failed += 1;
670 log::warn!("failed to insert remote track {}: {}", song.title, e);
671 }
672 }
673 }
674
675 if let Err(e) = queries::enrich_remote_album(
679 &db.conn,
680 &album.id,
681 album.music_brainz_id.as_deref(),
682 album.sort_name.as_deref(),
683 album.song_count,
684 album
685 .record_labels
686 .iter()
687 .map(|l| l.name.as_str())
688 .find(|l| !l.is_empty()),
689 ) {
690 log::warn!("failed to record album metadata for {}: {}", album.name, e);
691 }
692 }
693
694 db.conn
695 .execute_batch("COMMIT")
696 .map_err(crate::db::connection::DbError::from)?;
697
698 Ok(())
699}
700
701fn positive(value: Option<i32>) -> Option<i32> {
704 value.filter(|v| *v > 0)
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 use crate::db::connection::Database;
714 use crate::db::queries;
715 use std::sync::{Arc, Mutex};
716
717 fn test_db() -> (Database, tempfile::TempDir) {
718 let dir = tempfile::tempdir().unwrap();
719 let db = Database::open(&dir.path().join("sync_test.db")).unwrap();
720 (db, dir)
721 }
722
723 fn remote_track_meta(remote_id: &str, title: &str, artist: &str, album: &str) -> TrackMeta {
725 TrackMeta {
726 title: title.into(),
727 artist: artist.into(),
728 album_artist: Some(artist.into()),
729 album: album.into(),
730 date: Some("2024".into()),
731 disc: Some(1),
732 track_number: Some(1),
733 genre: Some("Electronic".into()),
734 label: None,
735 duration_ms: Some(240_000),
736 codec: Some("FLAC".into()),
737 sample_rate: Some(44100),
738 bit_depth: Some(16),
739 channels: Some(2),
740 bitrate: Some(1000),
741 size_bytes: None,
742 mtime: None,
743 path: None,
744 source: "remote".into(),
745 remote_id: Some(remote_id.into()),
746 remote_url: Some(format!("https://example.com/stream?id={}", remote_id)),
747 album_remote_id: Some(format!("album-of-{remote_id}")),
748 artist_remote_id: Some(format!("artist-of-{remote_id}")),
749 mbid: Some(format!("mbid-of-{remote_id}")),
750 album_mbid: None,
751 album_added_at: None,
752 credits: Default::default(),
753 }
754 }
755
756 #[test]
759 fn a_zero_quality_figure_is_treated_as_absent() {
760 assert_eq!(positive(Some(0)), None);
761 assert_eq!(positive(Some(16)), Some(16));
762 assert_eq!(positive(None), None);
763 }
764
765 #[test]
769 fn album_metadata_from_the_server_is_recorded() {
770 let (db, _dir) = test_db();
771
772 let meta = remote_track_meta("remote-300", "Anguish", "Sleep", "Volume One");
773 queries::upsert_track(&db.conn, &meta).unwrap();
774
775 queries::enrich_remote_album(
776 &db.conn,
777 "album-of-remote-300",
778 Some("mb-album-1"),
779 Some("volume one"),
780 Some(6),
781 Some("Off The Disk"),
782 )
783 .unwrap();
784
785 let row: (Option<String>, Option<String>, Option<i32>, Option<String>) = db
786 .conn
787 .query_row(
788 "SELECT mbid, sort_name, total_tracks, label FROM albums WHERE title = 'Volume One'",
789 [],
790 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
791 )
792 .unwrap();
793 assert_eq!(row.0.as_deref(), Some("mb-album-1"));
794 assert_eq!(row.1.as_deref(), Some("volume one"));
795 assert_eq!(row.2, Some(6));
796 assert_eq!(row.3.as_deref(), Some("Off The Disk"));
797 }
798
799 #[test]
802 fn server_metadata_does_not_overwrite_what_tags_said() {
803 let (db, _dir) = test_db();
804
805 let mut meta = remote_track_meta("remote-301", "Dopesmoker", "Sleep", "Dopesmoker");
806 meta.label = Some("From The Tags".into());
807 queries::upsert_track(&db.conn, &meta).unwrap();
808
809 queries::enrich_remote_album(
810 &db.conn,
811 "album-of-remote-301",
812 None,
813 None,
814 None,
815 Some("From The Server"),
816 )
817 .unwrap();
818
819 let label: Option<String> = db
820 .conn
821 .query_row(
822 "SELECT label FROM albums WHERE title = 'Dopesmoker'",
823 [],
824 |r| r.get(0),
825 )
826 .unwrap();
827 assert_eq!(label.as_deref(), Some("From The Tags"));
828 }
829
830 #[test]
833 fn artist_metadata_from_the_server_is_recorded() {
834 let (db, _dir) = test_db();
835
836 let meta = remote_track_meta("remote-302", "Holy Mountain", "Sleep", "Holy Mountain");
837 queries::upsert_track(&db.conn, &meta).unwrap();
838
839 queries::enrich_remote_artist(
840 &db.conn,
841 "artist-of-remote-302",
842 Some("mb-artist-1"),
843 Some("sleep"),
844 )
845 .unwrap();
846
847 let row: (Option<String>, Option<String>) = db
848 .conn
849 .query_row(
850 "SELECT mbid, sort_name FROM artists WHERE name = 'Sleep'",
851 [],
852 |r| Ok((r.get(0)?, r.get(1)?)),
853 )
854 .unwrap();
855 assert_eq!(row.0.as_deref(), Some("mb-artist-1"));
856 assert_eq!(row.1.as_deref(), Some("sleep"));
857 }
858
859 #[test]
861 fn a_synced_track_keeps_its_musicbrainz_id() {
862 let (db, _dir) = test_db();
863
864 let meta = remote_track_meta("remote-303", "Aquarian", "Sleep", "Dopesmoker");
865 let id = queries::upsert_track(&db.conn, &meta).unwrap();
866
867 let mbid: Option<String> = db
868 .conn
869 .query_row("SELECT mbid FROM tracks WHERE id = ?1", [id], |r| r.get(0))
870 .unwrap();
871 assert_eq!(mbid.as_deref(), Some("mbid-of-remote-303"));
872 }
873
874 #[test]
878 fn a_synced_track_keeps_its_quality_figures() {
879 let (db, _dir) = test_db();
880
881 let meta = remote_track_meta("remote-200", "Anguish", "Sleep", "Volume One");
882 let id = queries::upsert_track(&db.conn, &meta).unwrap();
883
884 let row = queries::get_track_row(&db.conn, id).unwrap().unwrap();
885 assert_eq!(row.sample_rate, Some(44100));
886 assert_eq!(row.bit_depth, Some(16));
887 assert_eq!(row.channels, Some(2));
888 }
889
890 #[test]
894 fn a_sync_records_the_album_and_artist_ids_too() {
895 let (db, _dir) = test_db();
896
897 let meta = remote_track_meta("remote-100", "Enter", "Russian Circles", "Enter");
898 queries::upsert_track(&db.conn, &meta).unwrap();
899
900 let album: Option<String> = db
901 .conn
902 .query_row(
903 "SELECT remote_id FROM albums WHERE title = 'Enter'",
904 [],
905 |r| r.get(0),
906 )
907 .unwrap();
908 assert_eq!(album.as_deref(), Some("album-of-remote-100"));
909
910 let artist: Option<String> = db
911 .conn
912 .query_row(
913 "SELECT remote_id FROM artists WHERE name = 'Russian Circles'",
914 [],
915 |r| r.get(0),
916 )
917 .unwrap();
918 assert_eq!(artist.as_deref(), Some("artist-of-remote-100"));
919 }
920
921 #[test]
922 fn sync_upserts_tracks_to_database() {
923 let (db, _dir) = test_db();
924
925 let meta = remote_track_meta("remote-001", "Vordhosbn", "Aphex Twin", "Drukqs");
926 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
927 assert!(track_id > 0, "upsert should return a valid track ID");
928
929 let row = queries::get_track_row(&db.conn, track_id)
931 .unwrap()
932 .expect("track should exist in DB");
933 assert_eq!(row.title, "Vordhosbn");
934 assert_eq!(row.artist_name, "Aphex Twin");
935 assert_eq!(row.album_title, "Drukqs");
936 assert_eq!(row.remote_id.as_deref(), Some("remote-001"));
937 assert_eq!(row.source, "remote");
938 }
939
940 struct StubServer {
945 addr: std::net::SocketAddr,
946 shutdown: Arc<std::sync::atomic::AtomicBool>,
947 }
948
949 #[derive(Default)]
950 struct StubState {
951 albums: Mutex<Vec<(String, String, String)>>,
953 failing: Mutex<HashSet<String>>,
955 unlisted: Mutex<HashSet<String>>,
958 insert_after_first_page: Mutex<Option<(String, String, String)>>,
961 list_pages_served: AtomicUsize,
962 list_types: Mutex<Vec<String>>,
963 album_calls: Mutex<Vec<String>>,
964 lists_songs: bool,
968 song_pages: Mutex<Vec<usize>>,
970 failing_song_pages: Mutex<HashSet<usize>>,
972 library_modified: Mutex<Option<i64>>,
974 }
975
976 impl StubServer {
977 fn start(state: Arc<StubState>) -> Self {
978 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
979 listener.set_nonblocking(true).unwrap();
980 let addr = listener.local_addr().unwrap();
981 let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
982
983 let stop = shutdown.clone();
984 std::thread::spawn(move || {
985 while !stop.load(Ordering::Relaxed) {
986 match listener.accept() {
987 Ok((stream, _)) => {
988 let _ = stream.set_nonblocking(false);
990 let state = state.clone();
991 std::thread::spawn(move || handle(stream, state));
992 }
993 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
994 std::thread::sleep(std::time::Duration::from_millis(2));
995 }
996 Err(_) => break,
997 }
998 }
999 });
1000
1001 Self { addr, shutdown }
1002 }
1003
1004 fn url(&self) -> String {
1005 format!("http://{}", self.addr)
1006 }
1007 }
1008
1009 impl Drop for StubServer {
1010 fn drop(&mut self) {
1011 self.shutdown.store(true, Ordering::Relaxed);
1012 }
1013 }
1014
1015 fn handle(mut stream: std::net::TcpStream, state: Arc<StubState>) {
1019 use std::io::{BufRead, Write};
1020
1021 let Ok(peek) = stream.try_clone() else { return };
1022 let mut reader = std::io::BufReader::new(peek);
1023
1024 loop {
1025 let mut request_line = String::new();
1026 if reader.read_line(&mut request_line).unwrap_or(0) == 0 {
1027 return;
1028 }
1029 let mut line = String::new();
1030 while reader.read_line(&mut line).unwrap_or(0) > 0 {
1031 if line == "\r\n" || line == "\n" {
1032 break;
1033 }
1034 line.clear();
1035 }
1036
1037 let target = request_line.split_whitespace().nth(1).unwrap_or("/");
1038 let (path, query) = target.split_once('?').unwrap_or((target, ""));
1039 let params: std::collections::HashMap<&str, &str> = query
1040 .split('&')
1041 .filter_map(|kv| kv.split_once('='))
1042 .collect();
1043
1044 let (status, body) = respond(&state, path, ¶ms);
1045
1046 let write = write!(
1047 stream,
1048 "HTTP/1.1 {} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
1049 status,
1050 body.len()
1051 )
1052 .and_then(|()| stream.write_all(body.as_bytes()))
1053 .and_then(|()| stream.flush());
1054 if write.is_err() {
1055 return;
1056 }
1057 }
1058 }
1059
1060 fn respond(
1061 state: &Arc<StubState>,
1062 path: &str,
1063 params: &std::collections::HashMap<&str, &str>,
1064 ) -> (u16, String) {
1065 match path.rsplit('/').next().unwrap_or("") {
1066 "getArtists" => (
1067 200,
1068 r#"{"subsonic-response":{"status":"ok","artists":{"index":[{"artist":[{"id":"ar1","name":"Stub Artist"}]}]}}}"#.to_string(),
1069 ),
1070 "getAlbumList2" => {
1071 state
1072 .list_types
1073 .lock()
1074 .unwrap()
1075 .push(params.get("type").copied().unwrap_or("").to_string());
1076 let offset: usize = params.get("offset").and_then(|o| o.parse().ok()).unwrap_or(0);
1077 let size: usize = params.get("size").and_then(|s| s.parse().ok()).unwrap_or(500);
1078
1079 let albums = state.albums.lock().unwrap();
1080 let unlisted = state.unlisted.lock().unwrap();
1081 let slice: Vec<String> = albums
1082 .iter()
1083 .filter(|(id, _, _)| !unlisted.contains(id))
1084 .skip(offset)
1085 .take(size)
1086 .map(|(id, name, created)| {
1087 format!(
1088 r#"{{"id":"{}","name":"{}","artist":"Stub Artist","created":"{}","songCount":1}}"#,
1089 id, name, created
1090 )
1091 })
1092 .collect();
1093 drop(albums);
1094 drop(unlisted);
1095
1096 if state.list_pages_served.fetch_add(1, Ordering::SeqCst) == 0
1097 && let Some(new_album) = state.insert_after_first_page.lock().unwrap().take()
1098 {
1099 state.albums.lock().unwrap().insert(0, new_album);
1100 }
1101
1102 (
1103 200,
1104 format!(
1105 r#"{{"subsonic-response":{{"status":"ok","albumList2":{{"album":[{}]}}}}}}"#,
1106 slice.join(",")
1107 ),
1108 )
1109 }
1110 "getIndexes" => match *state.library_modified.lock().unwrap() {
1111 Some(at) => (
1112 200,
1113 format!(
1114 r#"{{"subsonic-response":{{"status":"ok","indexes":{{"lastModified":{at}}}}}}}"#
1115 ),
1116 ),
1117 None => (200, r#"{"subsonic-response":{"status":"ok"}}"#.to_string()),
1118 },
1119 "search3" => {
1120 let offset: usize = params.get("songOffset").and_then(|o| o.parse().ok()).unwrap_or(0);
1121 let size: usize = params.get("songCount").and_then(|s| s.parse().ok()).unwrap_or(20);
1122 state.song_pages.lock().unwrap().push(offset);
1123 if state.failing_song_pages.lock().unwrap().contains(&offset) {
1124 return (500, r#"{"error":"boom"}"#.to_string());
1125 }
1126 let songs: Vec<String> = if state.lists_songs {
1127 state
1128 .albums
1129 .lock()
1130 .unwrap()
1131 .iter()
1132 .skip(offset)
1133 .take(size)
1134 .map(|(id, name, _)| {
1135 format!(
1136 r#"{{"id":"s{id}","title":"Song {id}","albumId":"{id}","album":"{name}","track":1,"suffix":"flac"}}"#
1137 )
1138 })
1139 .collect()
1140 } else {
1141 Vec::new()
1142 };
1143 (
1144 200,
1145 format!(
1146 r#"{{"subsonic-response":{{"status":"ok","searchResult3":{{"song":[{}]}}}}}}"#,
1147 songs.join(",")
1148 ),
1149 )
1150 }
1151 "getAlbum" => {
1152 let id = params.get("id").copied().unwrap_or("");
1153 state.album_calls.lock().unwrap().push(id.to_string());
1154 if state.failing.lock().unwrap().contains(id) {
1155 (500, r#"{"error":"boom"}"#.to_string())
1156 } else if !state.albums.lock().unwrap().iter().any(|(a, _, _)| a == id) {
1157 (
1158 200,
1159 r#"{"subsonic-response":{"status":"failed","error":{"code":70,"message":"not found"}}}"#
1160 .to_string(),
1161 )
1162 } else {
1163 (
1164 200,
1165 format!(
1166 r#"{{"subsonic-response":{{"status":"ok","album":{{"id":"{id}","name":"{name}","artist":"Stub Artist","song":[{{"id":"s{id}","title":"Song {id}","track":1,"suffix":"flac"}}]}}}}}}"#,
1167 name = state
1168 .albums
1169 .lock()
1170 .unwrap()
1171 .iter()
1172 .find(|(a, _, _)| a == id)
1173 .map_or_else(|| format!("Album {id}"), |(_, n, _)| n.clone())
1174 ),
1175 )
1176 }
1177 }
1178 _ => (404, "{}".to_string()),
1179 }
1180 }
1181
1182 fn stub_albums(n: usize) -> Vec<(String, String, String)> {
1183 (0..n)
1184 .map(|i| {
1185 (
1186 format!("a{:04}", i),
1187 format!("Album {:04}", i),
1188 "2024-01-15T10:30:00Z".to_string(),
1189 )
1190 })
1191 .collect()
1192 }
1193
1194 #[test]
1195 fn failed_album_fetch_does_not_advance_last_sync_and_next_sync_retries() {
1196 let (db, _dir) = test_db();
1197 let state = Arc::new(StubState {
1198 albums: Mutex::new(stub_albums(4)),
1199 failing: Mutex::new(["a0002".to_string()].into_iter().collect()),
1200 ..Default::default()
1201 });
1202 let server = StubServer::start(state.clone());
1203 let client = SubsonicClient::new(&server.url(), "u", "p");
1204
1205 let first = sync_library(&db, &client, &server.url(), "u", &|_| {}).unwrap();
1206 assert_eq!(first.albums_failed, 1, "the failing album must be counted");
1207 assert_eq!(first.albums_synced, 3);
1208 assert!(!first.is_complete());
1209 assert_eq!(
1210 get_last_sync(&db, &server.url()).unwrap(),
1211 None,
1212 "an incomplete sync must not advance last_sync"
1213 );
1214
1215 state.failing.lock().unwrap().clear();
1218 state.album_calls.lock().unwrap().clear();
1219 let second = sync_library(&db, &client, &server.url(), "u", &|_| {}).unwrap();
1220
1221 assert!(
1222 state
1223 .album_calls
1224 .lock()
1225 .unwrap()
1226 .contains(&"a0002".to_string()),
1227 "the previously failed album must be retried"
1228 );
1229 assert_eq!(second.albums_failed, 0);
1230 assert!(second.is_complete());
1231 assert!(
1232 get_last_sync(&db, &server.url()).unwrap().is_some(),
1233 "a clean sync advances last_sync"
1234 );
1235 }
1236
1237 #[test]
1238 fn album_inserted_mid_pagination_is_not_fetched_twice_or_skipped() {
1239 let (db, _dir) = test_db();
1243 let state = Arc::new(StubState {
1244 albums: Mutex::new(stub_albums(600)),
1245 insert_after_first_page: Mutex::new(Some((
1246 "aNEW".to_string(),
1247 "AAA Brand New".to_string(),
1248 "2024-06-01T00:00:00Z".to_string(),
1249 ))),
1250 ..Default::default()
1251 });
1252 let server = StubServer::start(state.clone());
1253 let client = SubsonicClient::new(&server.url(), "u", "p");
1254
1255 let result = sync_library(&db, &client, &server.url(), "u", &|_| {}).unwrap();
1256 assert_eq!(result.albums_failed, 0);
1257
1258 let calls = state.album_calls.lock().unwrap().clone();
1259 let unique: HashSet<&String> = calls.iter().collect();
1260 assert_eq!(
1261 calls.len(),
1262 unique.len(),
1263 "no album may be fetched twice after the window shifts"
1264 );
1265
1266 for i in 0..600 {
1268 let id = format!("a{:04}", i);
1269 assert!(unique.contains(&id), "album {} was skipped", id);
1270 }
1271
1272 let types = state.list_types.lock().unwrap().clone();
1273 assert!(
1274 types.iter().all(|t| t == "alphabeticalByName"),
1275 "the paginated walk must use a stable ordering, got {:?}",
1276 types
1277 );
1278 }
1279
1280 #[test]
1281 fn a_full_sync_relinks_files_whose_server_id_changed() {
1282 let (db, _dir) = test_db();
1283 let file = TrackMeta {
1284 date: None,
1285 disc: None,
1286 path: Some("/music/song.flac".into()),
1287 source: "local".into(),
1288 album_remote_id: None,
1289 artist_remote_id: None,
1290 mbid: None,
1291 ..remote_track_meta("s-before-rescan", "Song a0000", "Stub Artist", "Album 0000")
1292 };
1293 queries::upsert_track(&db.conn, &file).unwrap();
1294
1295 let state = Arc::new(StubState {
1296 albums: Mutex::new(stub_albums(1)),
1297 ..Default::default()
1298 });
1299 let server = StubServer::start(state);
1300 let client = SubsonicClient::new(&server.url(), "u", "p");
1301 sync_library(&db, &client, &server.url(), "u", &|_| {}).unwrap();
1302
1303 let rows: Vec<(Option<String>, Option<String>)> = db
1304 .conn
1305 .prepare("SELECT path, remote_id FROM tracks WHERE title = 'Song a0000'")
1306 .unwrap()
1307 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
1308 .unwrap()
1309 .collect::<Result<_, _>>()
1310 .unwrap();
1311 assert_eq!(
1312 rows,
1313 vec![(Some("/music/song.flac".into()), Some("sa0000".into()))],
1314 "the file takes the server's current id, and the recording is listed once"
1315 );
1316 }
1317
1318 #[test]
1319 fn a_full_sync_folds_server_rows_whose_id_vanished() {
1320 let (db, _dir) = test_db();
1321 let ghost = TrackMeta {
1322 date: None,
1323 disc: None,
1324 album_remote_id: None,
1325 artist_remote_id: None,
1326 mbid: None,
1327 ..remote_track_meta("s-before-rescan", "Song a0000", "Stub Artist", "Album 0000")
1328 };
1329 let ghost_id = queries::upsert_track(&db.conn, &ghost).unwrap();
1330 queries::add_favourite(&db.conn, queries::LOCAL_USER, ghost_id).unwrap();
1331
1332 let state = Arc::new(StubState {
1333 albums: Mutex::new(stub_albums(1)),
1334 ..Default::default()
1335 });
1336 let server = StubServer::start(state);
1337 let client = SubsonicClient::new(&server.url(), "u", "p");
1338 sync_library(&db, &client, &server.url(), "u", &|_| {}).unwrap();
1339
1340 let rows: Vec<(i64, String)> = db
1341 .conn
1342 .prepare("SELECT id, remote_id FROM tracks WHERE title = 'Song a0000'")
1343 .unwrap()
1344 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
1345 .unwrap()
1346 .collect::<Result<_, _>>()
1347 .unwrap();
1348 assert_eq!(
1349 rows,
1350 vec![(ghost_id, "sa0000".to_string())],
1351 "the dead row takes the live id"
1352 );
1353 assert_eq!(
1354 queries::load_favourites(&db.conn, queries::LOCAL_USER).unwrap(),
1355 HashSet::from([ghost_id]),
1356 "the favourite follows"
1357 );
1358 }
1359
1360 #[test]
1363 fn a_library_that_has_not_moved_is_not_walked_again() {
1364 use crate::helpers::{Walk, sync_remote};
1365 let (db, _dir) = test_db();
1366 let state = Arc::new(StubState {
1367 albums: Mutex::new(stub_albums(3)),
1368 library_modified: Mutex::new(Some(1_000)),
1369 ..Default::default()
1370 });
1371 let server = StubServer::start(state.clone());
1372 let client = SubsonicClient::new(&server.url(), "u", "p");
1373 let walks = || state.list_types.lock().unwrap().len();
1374 let sync = |walk| sync_remote(&db, &client, walk, &server.url(), "u", &|_| {}).unwrap();
1375
1376 sync(Walk::IfChanged);
1377 let first = walks();
1378 assert!(first > 0, "nothing recorded, so walked");
1379
1380 sync(Walk::IfChanged);
1381 assert_eq!(walks(), first, "unchanged, so not walked");
1382
1383 sync(Walk::Always);
1384 let asked = walks();
1385 assert!(asked > first, "asked for, so walked");
1386
1387 *state.library_modified.lock().unwrap() = Some(2_000);
1388 sync(Walk::IfChanged);
1389 assert!(walks() > asked, "moved, so walked");
1390 }
1391
1392 #[test]
1395 fn an_unchanged_library_is_still_synced_as_of_now() {
1396 use crate::helpers::{Walk, sync_remote};
1397 let (db, _dir) = test_db();
1398 let state = Arc::new(StubState {
1399 albums: Mutex::new(stub_albums(3)),
1400 library_modified: Mutex::new(Some(1_000)),
1401 ..Default::default()
1402 });
1403 let server = StubServer::start(state.clone());
1404 let client = SubsonicClient::new(&server.url(), "u", "p");
1405 sync_remote(&db, &client, Walk::IfChanged, &server.url(), "u", &|_| {}).unwrap();
1406 let walks = state.list_types.lock().unwrap().len();
1407
1408 update_last_sync(&db, &server.url(), "u", 1).unwrap();
1409 sync_remote(&db, &client, Walk::IfChanged, &server.url(), "u", &|_| {}).unwrap();
1410 assert_eq!(
1411 state.list_types.lock().unwrap().len(),
1412 walks,
1413 "unchanged, so not walked"
1414 );
1415 assert!(get_last_sync(&db, &server.url()).unwrap().unwrap() > 1);
1416 }
1417
1418 #[test]
1420 fn a_server_without_a_library_version_is_always_walked() {
1421 use crate::helpers::{Walk, sync_remote};
1422 let (db, _dir) = test_db();
1423 let state = Arc::new(StubState {
1424 albums: Mutex::new(stub_albums(3)),
1425 ..Default::default()
1426 });
1427 let server = StubServer::start(state.clone());
1428 let client = SubsonicClient::new(&server.url(), "u", "p");
1429 let walks = || state.list_types.lock().unwrap().len();
1430
1431 sync_remote(&db, &client, Walk::IfChanged, &server.url(), "u", &|_| {}).unwrap();
1432 let first = walks();
1433 sync_remote(&db, &client, Walk::IfChanged, &server.url(), "u", &|_| {}).unwrap();
1434 assert!(walks() > first);
1435 }
1436
1437 #[test]
1440 fn an_album_left_out_of_the_listing_is_removed_only_when_gone() {
1441 let (db, _dir) = test_db();
1442 let state = Arc::new(StubState {
1443 albums: Mutex::new(stub_albums(3)),
1444 ..Default::default()
1445 });
1446 let server = StubServer::start(state.clone());
1447 let client = SubsonicClient::new(&server.url(), "u", "p");
1448 sync_library(&db, &client, &server.url(), "u", &|_| {}).unwrap();
1449
1450 state.unlisted.lock().unwrap().insert("a0001".into());
1451 let shifted = sync_library(&db, &client, &server.url(), "u", &|_| {}).unwrap();
1452 assert_eq!(shifted.tracks_removed, 0, "still there, only unlisted");
1453
1454 state
1455 .albums
1456 .lock()
1457 .unwrap()
1458 .retain(|(id, _, _)| id != "a0001");
1459 let deleted = sync_library(&db, &client, &server.url(), "u", &|_| {}).unwrap();
1460 assert_eq!(deleted.tracks_removed, 1);
1461 }
1462
1463 #[test]
1466 fn a_full_sync_pages_songs_instead_of_fetching_each_album() {
1467 let (db, _dir) = test_db();
1468 let state = Arc::new(StubState {
1469 albums: Mutex::new(stub_albums(1_234)),
1470 lists_songs: true,
1471 ..Default::default()
1472 });
1473 let server = StubServer::start(state.clone());
1474 let client = SubsonicClient::new(&server.url(), "u", "p");
1475
1476 let seen = Mutex::new(Vec::new());
1477 let result = sync_library(&db, &client, &server.url(), "u", &|p| {
1478 seen.lock().unwrap().push(p)
1479 })
1480 .unwrap();
1481
1482 assert!(state.album_calls.lock().unwrap().is_empty(), "no getAlbum");
1483 let mut pages = state.song_pages.lock().unwrap().clone();
1484 pages.sort();
1485 pages.dedup();
1486 assert_eq!(pages[..3], [0, 500, 1000]);
1487 assert_eq!(result.tracks_synced, 1_234);
1488 assert_eq!(result.albums_synced, 1_234);
1489 assert!(result.is_complete());
1490 assert!(get_last_sync(&db, &server.url()).unwrap().is_some());
1491
1492 let album: (Option<String>, Option<i32>) = db
1493 .conn
1494 .query_row(
1495 "SELECT al.remote_id, al.total_tracks FROM albums al WHERE al.title = 'Album 0007'",
1496 [],
1497 |r| Ok((r.get(0)?, r.get(1)?)),
1498 )
1499 .unwrap();
1500 assert_eq!(
1501 album,
1502 (Some("a0007".into()), Some(1)),
1503 "album metadata comes from the list"
1504 );
1505
1506 let seen = seen.into_inner().unwrap();
1507 let tracks: Vec<&SyncProgress> = seen
1508 .iter()
1509 .filter(|p| p.phase == SyncPhase::Tracks)
1510 .collect();
1511 assert!(tracks.len() >= 3, "progress at least per page");
1512 assert!(tracks.iter().all(|p| p.total == Some(1_234)));
1513 assert_eq!(tracks.last().unwrap().done, 1_234);
1514 assert_eq!(seen.last().unwrap().phase, SyncPhase::Finishing);
1515 }
1516
1517 #[test]
1520 fn a_server_that_lists_no_songs_is_synced_album_by_album() {
1521 let (db, _dir) = test_db();
1522 let state = Arc::new(StubState {
1523 albums: Mutex::new(stub_albums(3)),
1524 ..Default::default()
1525 });
1526 let server = StubServer::start(state.clone());
1527 let client = SubsonicClient::new(&server.url(), "u", "p");
1528
1529 let result = sync_library(&db, &client, &server.url(), "u", &|_| {}).unwrap();
1530
1531 assert_eq!(state.song_pages.lock().unwrap().clone(), vec![0]);
1532 assert_eq!(state.album_calls.lock().unwrap().len(), 3);
1533 assert_eq!(result.tracks_synced, 3);
1534 assert!(result.is_complete());
1535 }
1536
1537 #[test]
1540 fn a_failed_song_page_does_not_advance_last_sync() {
1541 let (db, _dir) = test_db();
1542 let state = Arc::new(StubState {
1543 albums: Mutex::new(stub_albums(1_200)),
1544 lists_songs: true,
1545 failing_song_pages: Mutex::new([500].into_iter().collect()),
1546 ..Default::default()
1547 });
1548 let server = StubServer::start(state.clone());
1549 let client = SubsonicClient::new(&server.url(), "u", "p");
1550
1551 let result = sync_library(&db, &client, &server.url(), "u", &|_| {}).unwrap();
1552
1553 assert_eq!(result.pages_failed, 1);
1554 assert!(!result.is_complete());
1555 assert_eq!(get_last_sync(&db, &server.url()).unwrap(), None);
1556 let retries = state
1557 .song_pages
1558 .lock()
1559 .unwrap()
1560 .iter()
1561 .filter(|&&o| o == 500)
1562 .count();
1563 assert_eq!(retries as u32, PAGE_ATTEMPTS);
1564 }
1565
1566 #[test]
1567 fn sync_deduplicates_by_remote_id() {
1568 let (db, _dir) = test_db();
1569
1570 let meta1 = remote_track_meta("remote-dup", "Original Title", "Artist A", "Album X");
1572 let id1 = queries::upsert_track(&db.conn, &meta1).unwrap();
1573
1574 let meta2 = remote_track_meta("remote-dup", "Updated Title", "Artist A", "Album X");
1576 let id2 = queries::upsert_track(&db.conn, &meta2).unwrap();
1577
1578 assert_eq!(id1, id2, "same remote_id should resolve to same track row");
1580
1581 let row = queries::get_track_row(&db.conn, id2)
1583 .unwrap()
1584 .expect("track should exist");
1585 assert_eq!(row.title, "Updated Title");
1586 assert_eq!(row.remote_id.as_deref(), Some("remote-dup"));
1587
1588 let stats = queries::library_stats(&db.conn).unwrap();
1590 assert_eq!(
1591 stats.total_tracks, 1,
1592 "should have exactly 1 track after dedup"
1593 );
1594 }
1595
1596 #[test]
1600 fn resyncing_a_track_under_a_new_server_id_keeps_one_row() {
1601 let (db, _dir) = test_db();
1602 let before = remote_track_meta("46215", "Archangel", "Burial", "Untrue");
1603 let row = queries::upsert_synced_track(&db.conn, &before, &HashSet::from(["46215".into()]))
1604 .unwrap();
1605 queries::add_favourite(&db.conn, queries::LOCAL_USER, row).unwrap();
1606
1607 let uid = "0199a0b2-7c4e-7d3a-9f1b-2c3d4e5f6a7b";
1608 let after = remote_track_meta(uid, "Archangel", "Burial", "Untrue");
1609 let again =
1610 queries::upsert_synced_track(&db.conn, &after, &HashSet::from([uid.into()])).unwrap();
1611
1612 assert_eq!(again, row, "the same row, not a second copy");
1613 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
1614 let (remote_id, album, artist): (String, String, String) = db
1615 .conn
1616 .query_row(
1617 "SELECT t.remote_id, al.remote_id, ar.remote_id FROM tracks t
1618 JOIN albums al ON al.id = t.album_id JOIN artists ar ON ar.id = t.artist_id",
1619 [],
1620 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1621 )
1622 .unwrap();
1623 assert_eq!(remote_id, uid);
1624 assert_eq!(album, format!("album-of-{uid}"));
1625 assert_eq!(artist, format!("artist-of-{uid}"));
1626 let adopted: String = db
1627 .conn
1628 .query_row("SELECT uid FROM tracks WHERE id = ?1", [row], |r| r.get(0))
1629 .unwrap();
1630 assert_eq!(adopted, uid, "the row takes the server's uid");
1631 let favourites = queries::load_favourites(&db.conn, queries::LOCAL_USER).unwrap();
1632 assert_eq!(
1633 favourites,
1634 HashSet::from([row]),
1635 "the favourite stays with the row"
1636 );
1637 }
1638
1639 #[test]
1642 fn identical_entries_in_one_sync_stay_two_rows() {
1643 let (db, _dir) = test_db();
1644 let mut seen = HashSet::new();
1645 for id in ["dup-1", "dup-2"] {
1646 seen.insert(id.to_string());
1647 queries::upsert_synced_track(
1648 &db.conn,
1649 &remote_track_meta(id, "Archangel", "Burial", "Untrue"),
1650 &seen,
1651 )
1652 .unwrap();
1653 }
1654 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
1655 }
1656}