Skip to main content

koan_core/remote/
sync.rs

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    /// Albums whose details could not be fetched. Non-zero leaves the
28    /// library's version unrecorded, so the next sync walks it again.
29    pub albums_failed: usize,
30    /// Pages of songs that could not be fetched, on the bulk path. Counts
31    /// against completeness the same way.
32    pub pages_failed: usize,
33    /// Tracks the server listed that could not be written. Counts against
34    /// completeness the same way.
35    pub tracks_failed: usize,
36    /// Tracks removed because the server no longer has them.
37    pub tracks_removed: usize,
38}
39
40impl SyncResult {
41    /// Whether the run covered everything it set out to.
42    pub fn is_complete(&self) -> bool {
43        self.albums_failed == 0 && self.pages_failed == 0 && self.tracks_failed == 0
44    }
45}
46
47/// Get the last sync timestamp for a remote server, if any.
48pub 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
64/// The library version, as the server gave it, that the last complete walk
65/// was of. See `helpers::sync_remote`.
66pub 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
77/// Record that the library as of `version` has been walked in full.
78pub 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
93/// Update (or insert) the last sync timestamp for a remote server.
94pub 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/// Which part of a sync is running.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum SyncPhase {
112    /// Paging through the server's album list.
113    Albums,
114    /// Fetching and writing tracks. The long part.
115    Tracks,
116    /// Recording artist metadata.
117    Artists,
118    /// Relinking, recording the watermark, optimising the database.
119    Finishing,
120}
121
122/// How far a sync has got. `done` and `total` count albums in the `Albums`
123/// phase and tracks in the `Tracks` phase; `total` is `None` where the server
124/// gives no way to know it in advance.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub struct SyncProgress {
127    pub phase: SyncPhase,
128    pub done: u64,
129    pub total: Option<u64>,
130}
131
132/// Albums and songs per list request. 500 is the most `getAlbumList2` allows.
133const PAGE_SIZE: u32 = 500;
134
135/// Song pages in flight at once during a sync. The walk is bound by
136/// round trips, not bandwidth; four hides most of a mobile link's latency
137/// without asking much of the server.
138const FETCH_LANES: usize = 4;
139
140/// Attempts at one song page before it counts as failed.
141const PAGE_ATTEMPTS: u32 = 3;
142
143/// Pull the Navidrome/Subsonic library into the local DB.
144///
145/// The album list is always walked in `alphabeticalByName` order: it is the one
146/// ordering stable under concurrent server-side inserts, so an offset walk can
147/// never skip an album that was added between two pages.
148///
149/// Then it pages through every song with an empty `search3` query — about a
150/// hundred requests for fifty thousand tracks — and joins them to the album
151/// list. Fetching each album on its own costs one round trip per album, which
152/// on a phone is minutes; a server that does not answer an empty query gets
153/// that walk instead. Listing everything is what lets a sync see a track the
154/// server deleted from an album, or a file it gave a new id.
155///
156/// Whether to walk at all is the caller's: see `helpers::sync_remote`, which
157/// asks the server whether its library moved first.
158///
159/// `last_sync` only advances when everything was fetched.
160///
161/// Deduplication happens in `upsert_track`, which merges a server's copy of a
162/// track onto the local row for it instead of creating a duplicate.
163pub 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    // Artist rows are created by track upserts, which carry no MusicBrainz id
215    // or sort name. Applied last, because the rows do not exist until their
216    // tracks have been written.
217    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    // Albums this library holds that the listing left out are asked about one
233    // by one; see `confirm_missing_albums`. One the server still answers for
234    // means the album listing came up short — an album deleted mid-walk shifts
235    // the next page — and the songs of an album nobody listed were never
236    // fetched, so nothing can be concluded about its tracks this time.
237    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    // What the server deleted or renumbered: an album gone from the list, or a
245    // track gone from an album. An empty listing is far likelier to be a server
246    // fault than an empty library, and would unlink every file. So is one
247    // shorter than the count the server gave: a track deleted mid-walk shifts a
248    // later page by one, and the track it pushes out of view is not gone.
249    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
288/// Ask the server about each album this library holds that the listing left
289/// out, keeping any it still answers for. The listing is an offset walk: an
290/// album deleted from an earlier page mid-walk shifts the next page by one, and
291/// the album pushed off the boundary is missing from the list without being
292/// gone. Only "not found" confirms a deletion; any other failure keeps it.
293fn 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
321/// Every album on the server, once each.
322fn list_albums(
323    client: &SubsonicClient,
324    progress: &(dyn Fn(SyncProgress) + Sync),
325) -> Result<Vec<SubsonicAlbum>, SyncError> {
326    let mut albums = Vec::new();
327    // Guards against an album appearing on two pages when the server-side list
328    // shifts under the offset walk.
329    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
347/// Page through every song with an empty `search3` query, joined to the album
348/// list, one transaction per page.
349///
350/// Returns `false`, having written nothing, when the server does not list
351/// songs that way — older servers answer an empty query with nothing, or with
352/// an error. The caller then walks the albums one at a time.
353fn 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        // Pages are fetched on a few lanes and written here, in whatever order
396        // they arrive — each is a whole transaction on its own, and the
397        // database has one writer however many are fetching. The lanes are
398        // threads rather than rayon tasks because they spend their time
399        // waiting on the network, not the CPU.
400        let next = AtomicU32::new(PAGE_SIZE);
401        // The offset of the first page that came back short. Nothing past it
402        // is worth asking for.
403        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                        // A page that failed after its retries ends the walk
417                        // too: the server is likely gone, and asking on for
418                        // ever would never finish. The run is then incomplete
419                        // and the next sync walks it again.
420                        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
453/// One page of songs, retried a couple of times: a failed page is a hole in
454/// the library until the next sync, so it is worth a second ask.
455fn 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
473/// A page of songs as the albums they belong to, each carrying the metadata
474/// the album list gave for it.
475///
476/// A song whose album is not in the list — added after the list was read — is
477/// written under what the song itself says about its album.
478fn 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
527/// Albums to write per transaction on the per-album path. Small enough that
528/// progress moves often, large enough that the fetches overlap.
529const ALBUM_BATCH: usize = 100;
530
531/// Fetch each album on its own and write them a batch at a time, for a server
532/// that cannot list songs in bulk.
533fn 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
573/// Record what the server knows about each artist.
574///
575/// Best-effort: a library that synced its tracks fine should not fail because
576/// one artist row could not be updated.
577fn write_artists(db: &Database, artists: &[SubsonicArtist], result: &mut SyncResult) {
578    // Immediate: a transaction that reads before it writes cannot take the
579    // write lock later if another connection wrote in between, and fails at
580    // once instead of waiting for it.
581    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
608/// Write one batch of fetched albums in a single transaction.
609fn 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    // Immediate, for the reason `write_artists` gives: deferred, a page that
617    // met another writer failed every insert in it, logging each.
618    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                // OpenSubsonic servers report these; a plain Subsonic one
643                // leaves them out and the track keeps no quality figures.
644                //
645                // Zero means "not applicable", not "zero" — Navidrome reports
646                // bitDepth 0 for every lossy file. Storing it would render an
647                // MP3 as 0-bit, which is worse than saying nothing.
648                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        // The album row is created through its tracks, so it only ever sees
676        // what a file's tags say. Track totals, the label and the MusicBrainz
677        // id belong to the release, and came back in the same response.
678        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
701/// Treat a non-positive figure as absent. None of sample rate, bit depth or
702/// channel count has a meaningful zero.
703fn positive(value: Option<i32>) -> Option<i32> {
704    value.filter(|v| *v > 0)
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    // --- Sync → DB integration tests ---
712
713    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    /// Build a TrackMeta matching how sync_library constructs them from SubsonicSong data.
724    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    /// Navidrome reports bitDepth 0 for every lossy file. Keeping it would
757    /// render an MP3 as 0-bit; absent is the honest answer.
758    #[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    /// The album row is created through its tracks, so it only ever sees what
766    /// a file's tags say. Track totals, the label and the MusicBrainz id are
767    /// properties of the release and arrive in the same response.
768    #[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    /// Enrichment fills blanks. A locally-scanned album whose tags named a
800    /// label must keep it, rather than have the server's answer written over.
801    #[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    /// Artists existed only as a side effect of a track upsert, so nothing
831    /// ever wrote their MusicBrainz id or sort name.
832    #[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    /// The recording id travels with the track.
860    #[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    /// A remote track carries the quality figures an OpenSubsonic server
875    /// reports. Without them the format badge has nothing to show, which is
876    /// what every remote-only track in a synced library used to look like.
877    #[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    /// The server keys stars, shares and cover art off album and artist ids,
891    /// so a sync that only kept the track's left the library unable to refer
892    /// to either — every album row came back with a null remote_id.
893    #[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        // Verify the track exists with correct remote_id.
930        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    // --- sync_library against a stub Subsonic server ---
941
942    /// Minimal Subsonic server: serves getArtists, getAlbumList2 and getAlbum
943    /// so `sync_library` can be driven end to end without a real Navidrome.
944    struct StubServer {
945        addr: std::net::SocketAddr,
946        shutdown: Arc<std::sync::atomic::AtomicBool>,
947    }
948
949    #[derive(Default)]
950    struct StubState {
951        /// Album (id, name, created) in the order the server lists them.
952        albums: Mutex<Vec<(String, String, String)>>,
953        /// Album ids whose getAlbum call fails with a 500.
954        failing: Mutex<HashSet<String>>,
955        /// Album ids left out of the album list while still existing, as an
956        /// offset walk does when a deletion shifts a page.
957        unlisted: Mutex<HashSet<String>>,
958        /// Prepended to the album list once the first list page has been served,
959        /// modelling a server-side insert landing mid-pagination.
960        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        /// Answer an empty `search3` with every album's song, as an
965        /// OpenSubsonic server does. Off, it answers with nothing, as older
966        /// servers do.
967        lists_songs: bool,
968        /// `songOffset`s asked for.
969        song_pages: Mutex<Vec<usize>>,
970        /// Song pages that fail with a 500, by offset.
971        failing_song_pages: Mutex<HashSet<usize>>,
972        /// `getIndexes`'s `lastModified`; `None` answers without one.
973        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                            // BSD sockets inherit O_NONBLOCK from the listener.
989                            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    /// Serve requests on one connection until the peer closes it. Keep-alive
1016    /// matters here: reqwest pools connections, and a server that hangs up after
1017    /// every response makes parallel fetches fail on reused sockets.
1018    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, &params);
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        // Second run: the album now succeeds and is picked up because the sync
1216        // still has no watermark to skip past.
1217        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        // 600 albums forces a second list page; a server-side insert between
1240        // pages shifts the offset window, which without de-dup replays the
1241        // page boundary and, with `newest` ordering, can drop albums entirely.
1242        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        // Every album present before the shift must still have been fetched.
1267        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    /// koan's own syncs walk the library only when the server says it moved;
1361    /// one that was asked for walks it regardless.
1362    #[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    /// A sync that finds the library unchanged still says when it was last
1393    /// known to match the server's.
1394    #[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    /// A server that gives no version is walked every time.
1419    #[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    /// An album missing from the listing is removed only once the server says
1438    /// it is gone.
1439    #[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    /// A server that lists songs for an empty query is synced in pages of
1464    /// songs, not one request per album.
1465    #[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    /// Older servers answer an empty query with nothing. The album walk is
1518    /// what they get instead.
1519    #[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    /// A page lost to the network leaves the run incomplete, so the watermark
1538    /// stays put and the next sync walks the library again.
1539    #[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        // First upsert.
1571        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        // Second upsert with same remote_id but different metadata.
1575        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        // Should be the same row (dedup by remote_id).
1579        assert_eq!(id1, id2, "same remote_id should resolve to same track row");
1580
1581        // Verify the metadata was updated.
1582        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        // Verify only one track exists.
1589        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    /// A koan server once published row ids and now publishes UUIDs; any
1597    /// server that rescans can renumber. A re-sync keeps the row, and with it
1598    /// the history and the favourite, rather than adding a second copy.
1599    #[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    /// Two entries a server lists with the same tags are two tracks, however
1640    /// alike: the second is not taken for the first under an old id.
1641    #[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}