Skip to main content

koan_core/remote/
sync.rs

1use std::collections::HashSet;
2use std::sync::atomic::{AtomicUsize, Ordering};
3
4use crate::db::connection::Database;
5use crate::db::queries::{self, TrackMeta};
6use crate::remote::client::{SubsonicAlbumFull, SubsonicArtist, SubsonicClient};
7
8use rayon::prelude::*;
9use rusqlite::params;
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum SyncError {
14    #[error("subsonic error: {0}")]
15    Subsonic(#[from] super::client::SubsonicError),
16    #[error("db error: {0}")]
17    Db(#[from] crate::db::connection::DbError),
18}
19
20#[derive(Debug, Default)]
21pub struct SyncResult {
22    pub artists_synced: usize,
23    pub albums_synced: usize,
24    pub tracks_synced: usize,
25    /// Albums whose details could not be fetched. Non-zero means `last_sync`
26    /// was left where it was so the next sync picks them up again.
27    pub albums_failed: usize,
28}
29
30impl SyncResult {
31    /// Whether the run covered everything it set out to.
32    pub fn is_complete(&self) -> bool {
33        self.albums_failed == 0
34    }
35}
36
37/// Get the last sync timestamp for a remote server, if any.
38pub fn get_last_sync(
39    db: &Database,
40    url: &str,
41) -> Result<Option<i64>, crate::db::connection::DbError> {
42    let result = db.conn.query_row(
43        "SELECT last_sync FROM remote_servers WHERE url = ?1",
44        params![url],
45        |row| row.get::<_, Option<i64>>(0),
46    );
47    match result {
48        Ok(ts) => Ok(ts),
49        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
50        Err(e) => Err(e.into()),
51    }
52}
53
54/// Update (or insert) the last sync timestamp for a remote server.
55pub fn update_last_sync(
56    db: &Database,
57    url: &str,
58    username: &str,
59    timestamp: i64,
60) -> Result<(), crate::db::connection::DbError> {
61    db.conn.execute(
62        "INSERT INTO remote_servers (url, username, last_sync)
63         VALUES (?1, ?2, ?3)
64         ON CONFLICT(url) DO UPDATE SET last_sync = ?3",
65        params![url, username, timestamp],
66    )?;
67    Ok(())
68}
69
70/// Parse an ISO 8601 / RFC 3339 timestamp string into a unix timestamp (seconds).
71/// Returns `None` if the string can't be parsed.
72///
73/// Handles common Subsonic/Navidrome variants:
74/// - Full RFC 3339: `2024-01-15T10:30:00Z`, `2024-01-15T10:30:00+05:30`
75/// - Fractional seconds: `2024-01-15T10:30:00.123Z`
76/// - Missing timezone (assumed UTC): `2024-01-15T10:30:00`
77fn parse_iso8601_to_unix(s: &str) -> Option<i64> {
78    use chrono::{DateTime, FixedOffset, NaiveDateTime};
79
80    // Try strict RFC 3339 first (handles Z, offsets, fractional seconds).
81    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
82        return Some(dt.timestamp());
83    }
84
85    // Subsonic sometimes omits timezone — parse as naive and assume UTC.
86    // Try with fractional seconds first, then without.
87    if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
88        return Some(naive.and_utc().timestamp());
89    }
90    if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
91        return Some(naive.and_utc().timestamp());
92    }
93
94    // Some servers use space instead of T.
95    if let Ok(dt) = DateTime::<FixedOffset>::parse_from_str(s, "%Y-%m-%d %H:%M:%S%:z") {
96        return Some(dt.timestamp());
97    }
98    if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
99        return Some(naive.and_utc().timestamp());
100    }
101
102    None
103}
104
105/// Pull the Navidrome/Subsonic library into the local DB.
106///
107/// The album list is always walked in `alphabeticalByName` order: it is the one
108/// ordering stable under concurrent server-side inserts, so an offset walk can
109/// never skip an album that was added between two pages. An incremental sync
110/// walks the same list but only fetches details for albums created after
111/// `last_sync` — the list pages are cheap, the per-album fetches are not.
112///
113/// `last_sync` only advances when every album fetch succeeded. A run that lost
114/// albums to network errors leaves the timestamp alone so the next incremental
115/// sync re-fetches them, rather than writing a permanent hole in the library.
116///
117/// Deduplication happens in `upsert_track` — if a local track already exists
118/// with the same artist + album + title + track#, the remote_id and remote_url
119/// are merged onto the existing row instead of creating a duplicate.
120pub fn sync_library(
121    db: &Database,
122    client: &SubsonicClient,
123    full: bool,
124    server_url: &str,
125    username: &str,
126) -> Result<SyncResult, SyncError> {
127    let mut result = SyncResult::default();
128
129    let artists = client.get_artists()?;
130    result.artists_synced = artists.len();
131    log::info!("syncing {} artists from remote", artists.len());
132
133    let last_sync = if full {
134        None
135    } else {
136        get_last_sync(db, server_url)?
137    };
138
139    match last_sync {
140        Some(ts) => log::info!("incremental sync (albums created after {})", ts),
141        None => log::info!("full sync"),
142    }
143
144    let sync_start = std::time::SystemTime::now()
145        .duration_since(std::time::UNIX_EPOCH)
146        .unwrap_or_default()
147        .as_secs() as i64;
148
149    let mut offset = 0u32;
150    let page_size = 500u32;
151    // Guards against an album appearing on two pages when the server-side list
152    // shifts under the offset walk.
153    let mut seen_ids: HashSet<String> = HashSet::new();
154
155    loop {
156        let page = client.get_album_list("alphabeticalByName", page_size, offset)?;
157        if page.is_empty() {
158            break;
159        }
160        let page_count = page.len();
161        offset += page_count as u32;
162
163        // Skip albums already handled this run, and — when incremental —
164        // albums the server says predate the last sync. An unparseable
165        // `created` is treated as new: re-fetching is cheap, missing is not.
166        let to_fetch: Vec<String> = page
167            .into_iter()
168            .filter(|a| seen_ids.insert(a.id.clone()))
169            .filter(|a| match last_sync {
170                None => true,
171                Some(ts) => a
172                    .created
173                    .as_deref()
174                    .and_then(parse_iso8601_to_unix)
175                    .is_none_or(|created| created >= ts),
176            })
177            .map(|a| a.id)
178            .collect();
179
180        if !to_fetch.is_empty() {
181            let failures = AtomicUsize::new(0);
182            let fetched: Vec<SubsonicAlbumFull> = to_fetch
183                .into_par_iter()
184                .filter_map(|id| match client.get_album(&id) {
185                    Ok(full) => Some(full),
186                    Err(e) => {
187                        log::warn!("failed to fetch album {}: {}", id, e);
188                        failures.fetch_add(1, Ordering::Relaxed);
189                        None
190                    }
191                })
192                .collect();
193            result.albums_failed += failures.into_inner();
194
195            write_albums(db, client, &fetched, &mut result)?;
196        }
197
198        log::info!(
199            "synced {} albums ({} tracks) so far...",
200            result.albums_synced,
201            result.tracks_synced
202        );
203
204        if (page_count as u32) < page_size {
205            break;
206        }
207    }
208
209    // The artist list was fetched at the top and, until this, thrown away —
210    // artists only ever existed as a side effect of a track upsert, which is
211    // why not one of them had a MusicBrainz id or a sort name. Applied last,
212    // because the rows do not exist until their tracks have been written.
213    write_artists(db, &artists, &mut result);
214
215    if result.is_complete() {
216        update_last_sync(db, server_url, username, sync_start)?;
217    } else {
218        log::warn!(
219            "{} album(s) failed to fetch — leaving last_sync unchanged so the next sync retries them",
220            result.albums_failed
221        );
222    }
223
224    log::info!(
225        "sync complete: {} artists, {} albums, {} tracks, {} failed",
226        result.artists_synced,
227        result.albums_synced,
228        result.tracks_synced,
229        result.albums_failed,
230    );
231
232    Ok(result)
233}
234
235/// Record what the server knows about each artist.
236///
237/// Best-effort: a library that synced its tracks fine should not fail because
238/// one artist row could not be updated.
239fn write_artists(db: &Database, artists: &[SubsonicArtist], result: &mut SyncResult) {
240    if db.conn.execute_batch("BEGIN").is_err() {
241        return;
242    }
243    let mut enriched = 0;
244    for artist in artists {
245        match queries::enrich_remote_artist(
246            &db.conn,
247            &artist.id,
248            artist.music_brainz_id.as_deref(),
249            artist.sort_name.as_deref(),
250        ) {
251            Ok(()) => enriched += 1,
252            Err(e) => log::warn!(
253                "failed to record artist metadata for {}: {}",
254                artist.name,
255                e
256            ),
257        }
258    }
259    if db.conn.execute_batch("COMMIT").is_err() {
260        let _ = db.conn.execute_batch("ROLLBACK");
261        return;
262    }
263    result.artists_synced = enriched;
264    log::info!("recorded metadata for {enriched} artists");
265}
266
267/// Write one batch of fetched albums in a single transaction.
268fn write_albums(
269    db: &Database,
270    client: &SubsonicClient,
271    albums: &[SubsonicAlbumFull],
272    result: &mut SyncResult,
273) -> Result<(), SyncError> {
274    db.conn
275        .execute_batch("BEGIN")
276        .map_err(crate::db::connection::DbError::from)?;
277
278    for album in albums {
279        result.albums_synced += 1;
280        let artist_name = album.artist.as_deref().unwrap_or("Unknown Artist");
281
282        for song in &album.song {
283            let meta = TrackMeta {
284                title: song.title.clone(),
285                artist: song
286                    .artist
287                    .clone()
288                    .unwrap_or_else(|| artist_name.to_string()),
289                album_artist: album.artist.clone(),
290                album: album.name.clone(),
291                date: album.year.map(|y| y.to_string()),
292                disc: song.disc_number,
293                track_number: song.track,
294                genre: song.genre.clone().or_else(|| album.genre.clone()),
295                label: None,
296                duration_ms: song.duration.map(|d| d * 1000),
297                codec: song.suffix.clone(),
298                // OpenSubsonic servers report these; a plain Subsonic one
299                // leaves them out and the track keeps no quality figures,
300                // which is what every remote track used to get.
301                //
302                // Zero means "not applicable", not "zero" — Navidrome reports
303                // bitDepth 0 for every lossy file. Storing it would render an
304                // MP3 as 0-bit, which is worse than saying nothing.
305                sample_rate: positive(song.sampling_rate),
306                bit_depth: positive(song.bit_depth),
307                channels: positive(song.channel_count),
308                bitrate: song.bit_rate,
309                size_bytes: None,
310                mtime: None,
311                path: None,
312                source: "remote".to_string(),
313                remote_id: Some(song.id.clone()),
314                remote_url: Some(client.stream_url_template(&song.id)),
315                album_remote_id: Some(album.id.clone()),
316                artist_remote_id: album.artist_id.clone(),
317                mbid: song.music_brainz_id.clone(),
318                album_added_at: album.created.clone(),
319            };
320
321            match queries::upsert_track(&db.conn, &meta) {
322                Ok(_) => result.tracks_synced += 1,
323                Err(e) => log::warn!("failed to insert remote track {}: {}", song.title, e),
324            }
325        }
326
327        // The album row is created through its tracks, so it only ever sees
328        // what a file's tags say. Track totals, the label and the MusicBrainz
329        // id belong to the release, and came back in the same response.
330        if let Err(e) = queries::enrich_remote_album(
331            &db.conn,
332            &album.id,
333            album.music_brainz_id.as_deref(),
334            album.sort_name.as_deref(),
335            album.song_count,
336            album.record_labels.first().map(|l| l.name.as_str()),
337        ) {
338            log::warn!("failed to record album metadata for {}: {}", album.name, e);
339        }
340    }
341
342    db.conn
343        .execute_batch("COMMIT")
344        .map_err(crate::db::connection::DbError::from)?;
345
346    Ok(())
347}
348
349/// Treat a non-positive figure as absent. None of sample rate, bit depth or
350/// channel count has a meaningful zero.
351fn positive(value: Option<i32>) -> Option<i32> {
352    value.filter(|v| *v > 0)
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn parse_rfc3339_with_z() {
361        // 2024-01-15T10:30:00Z = 1705314600
362        assert_eq!(
363            parse_iso8601_to_unix("2024-01-15T10:30:00Z"),
364            Some(1705314600)
365        );
366    }
367
368    #[test]
369    fn parse_rfc3339_with_offset() {
370        // 10:30 IST (+05:30) = 05:00 UTC = 1705294800
371        assert_eq!(
372            parse_iso8601_to_unix("2024-01-15T10:30:00+05:30"),
373            Some(1705294800)
374        );
375    }
376
377    #[test]
378    fn parse_rfc3339_negative_offset() {
379        // 10:30 EST (-05:00) = 15:30 UTC = 1705332600
380        assert_eq!(
381            parse_iso8601_to_unix("2024-01-15T10:30:00-05:00"),
382            Some(1705332600)
383        );
384    }
385
386    #[test]
387    fn parse_fractional_seconds_z() {
388        assert_eq!(
389            parse_iso8601_to_unix("2024-01-15T10:30:00.123Z"),
390            Some(1705314600)
391        );
392    }
393
394    #[test]
395    fn parse_fractional_seconds_offset() {
396        assert_eq!(
397            parse_iso8601_to_unix("2024-01-15T10:30:00.999+00:00"),
398            Some(1705314600)
399        );
400    }
401
402    #[test]
403    fn parse_no_timezone_assumes_utc() {
404        assert_eq!(
405            parse_iso8601_to_unix("2024-01-15T10:30:00"),
406            Some(1705314600)
407        );
408    }
409
410    #[test]
411    fn parse_no_timezone_fractional() {
412        assert_eq!(
413            parse_iso8601_to_unix("2024-01-15T10:30:00.500"),
414            Some(1705314600)
415        );
416    }
417
418    #[test]
419    fn parse_space_separator_with_tz() {
420        assert_eq!(
421            parse_iso8601_to_unix("2024-01-15 10:30:00+00:00"),
422            Some(1705314600)
423        );
424    }
425
426    #[test]
427    fn parse_space_separator_no_tz() {
428        assert_eq!(
429            parse_iso8601_to_unix("2024-01-15 10:30:00"),
430            Some(1705314600)
431        );
432    }
433
434    #[test]
435    fn parse_garbage_returns_none() {
436        assert_eq!(parse_iso8601_to_unix("not-a-date"), None);
437        assert_eq!(parse_iso8601_to_unix(""), None);
438        assert_eq!(parse_iso8601_to_unix("2024"), None);
439    }
440
441    #[test]
442    fn parse_epoch() {
443        assert_eq!(parse_iso8601_to_unix("1970-01-01T00:00:00Z"), Some(0));
444    }
445
446    // --- Sync → DB integration tests ---
447
448    use crate::db::connection::Database;
449    use crate::db::queries;
450    use std::sync::{Arc, Mutex};
451
452    fn test_db() -> (Database, tempfile::TempDir) {
453        let dir = tempfile::tempdir().unwrap();
454        let db = Database::open(&dir.path().join("sync_test.db")).unwrap();
455        (db, dir)
456    }
457
458    /// Build a TrackMeta matching how sync_library constructs them from SubsonicSong data.
459    fn remote_track_meta(remote_id: &str, title: &str, artist: &str, album: &str) -> TrackMeta {
460        TrackMeta {
461            title: title.into(),
462            artist: artist.into(),
463            album_artist: Some(artist.into()),
464            album: album.into(),
465            date: Some("2024".into()),
466            disc: Some(1),
467            track_number: Some(1),
468            genre: Some("Electronic".into()),
469            label: None,
470            duration_ms: Some(240_000),
471            codec: Some("FLAC".into()),
472            sample_rate: Some(44100),
473            bit_depth: Some(16),
474            channels: Some(2),
475            bitrate: Some(1000),
476            size_bytes: None,
477            mtime: None,
478            path: None,
479            source: "remote".into(),
480            remote_id: Some(remote_id.into()),
481            remote_url: Some(format!("https://example.com/stream?id={}", remote_id)),
482            album_remote_id: Some(format!("album-of-{remote_id}")),
483            artist_remote_id: Some(format!("artist-of-{remote_id}")),
484            mbid: Some(format!("mbid-of-{remote_id}")),
485            album_added_at: None,
486        }
487    }
488
489    /// Navidrome reports bitDepth 0 for every lossy file. Keeping it would
490    /// render an MP3 as 0-bit; absent is the honest answer.
491    #[test]
492    fn a_zero_quality_figure_is_treated_as_absent() {
493        assert_eq!(positive(Some(0)), None);
494        assert_eq!(positive(Some(16)), Some(16));
495        assert_eq!(positive(None), None);
496    }
497
498    /// The album row is created through its tracks, so it only ever sees what
499    /// a file's tags say. Track totals, the label and the MusicBrainz id are
500    /// properties of the release and arrive in the same response.
501    #[test]
502    fn album_metadata_from_the_server_is_recorded() {
503        let (db, _dir) = test_db();
504
505        let meta = remote_track_meta("remote-300", "Anguish", "Sleep", "Volume One");
506        queries::upsert_track(&db.conn, &meta).unwrap();
507
508        queries::enrich_remote_album(
509            &db.conn,
510            "album-of-remote-300",
511            Some("mb-album-1"),
512            Some("volume one"),
513            Some(6),
514            Some("Off The Disk"),
515        )
516        .unwrap();
517
518        let row: (Option<String>, Option<String>, Option<i32>, Option<String>) = db
519            .conn
520            .query_row(
521                "SELECT mbid, sort_name, total_tracks, label FROM albums WHERE title = 'Volume One'",
522                [],
523                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
524            )
525            .unwrap();
526        assert_eq!(row.0.as_deref(), Some("mb-album-1"));
527        assert_eq!(row.1.as_deref(), Some("volume one"));
528        assert_eq!(row.2, Some(6));
529        assert_eq!(row.3.as_deref(), Some("Off The Disk"));
530    }
531
532    /// Enrichment fills blanks. A locally-scanned album whose tags named a
533    /// label must keep it, rather than have the server's answer written over.
534    #[test]
535    fn server_metadata_does_not_overwrite_what_tags_said() {
536        let (db, _dir) = test_db();
537
538        let mut meta = remote_track_meta("remote-301", "Dopesmoker", "Sleep", "Dopesmoker");
539        meta.label = Some("From The Tags".into());
540        queries::upsert_track(&db.conn, &meta).unwrap();
541
542        queries::enrich_remote_album(
543            &db.conn,
544            "album-of-remote-301",
545            None,
546            None,
547            None,
548            Some("From The Server"),
549        )
550        .unwrap();
551
552        let label: Option<String> = db
553            .conn
554            .query_row(
555                "SELECT label FROM albums WHERE title = 'Dopesmoker'",
556                [],
557                |r| r.get(0),
558            )
559            .unwrap();
560        assert_eq!(label.as_deref(), Some("From The Tags"));
561    }
562
563    /// Artists existed only as a side effect of a track upsert, so nothing
564    /// ever wrote their MusicBrainz id or sort name.
565    #[test]
566    fn artist_metadata_from_the_server_is_recorded() {
567        let (db, _dir) = test_db();
568
569        let meta = remote_track_meta("remote-302", "Holy Mountain", "Sleep", "Holy Mountain");
570        queries::upsert_track(&db.conn, &meta).unwrap();
571
572        queries::enrich_remote_artist(
573            &db.conn,
574            "artist-of-remote-302",
575            Some("mb-artist-1"),
576            Some("sleep"),
577        )
578        .unwrap();
579
580        let row: (Option<String>, Option<String>) = db
581            .conn
582            .query_row(
583                "SELECT mbid, sort_name FROM artists WHERE name = 'Sleep'",
584                [],
585                |r| Ok((r.get(0)?, r.get(1)?)),
586            )
587            .unwrap();
588        assert_eq!(row.0.as_deref(), Some("mb-artist-1"));
589        assert_eq!(row.1.as_deref(), Some("sleep"));
590    }
591
592    /// The recording id travels with the track.
593    #[test]
594    fn a_synced_track_keeps_its_musicbrainz_id() {
595        let (db, _dir) = test_db();
596
597        let meta = remote_track_meta("remote-303", "Aquarian", "Sleep", "Dopesmoker");
598        let id = queries::upsert_track(&db.conn, &meta).unwrap();
599
600        let mbid: Option<String> = db
601            .conn
602            .query_row("SELECT mbid FROM tracks WHERE id = ?1", [id], |r| r.get(0))
603            .unwrap();
604        assert_eq!(mbid.as_deref(), Some("mbid-of-remote-303"));
605    }
606
607    /// A remote track carries the quality figures an OpenSubsonic server
608    /// reports. Without them the format badge has nothing to show, which is
609    /// what every remote-only track in a synced library used to look like.
610    #[test]
611    fn a_synced_track_keeps_its_quality_figures() {
612        let (db, _dir) = test_db();
613
614        let meta = remote_track_meta("remote-200", "Anguish", "Sleep", "Volume One");
615        let id = queries::upsert_track(&db.conn, &meta).unwrap();
616
617        let row = queries::get_track_row(&db.conn, id).unwrap().unwrap();
618        assert_eq!(row.sample_rate, Some(44100));
619        assert_eq!(row.bit_depth, Some(16));
620        assert_eq!(row.channels, Some(2));
621    }
622
623    /// The server keys stars, shares and cover art off album and artist ids,
624    /// so a sync that only kept the track's left the library unable to refer
625    /// to either — every album row came back with a null remote_id.
626    #[test]
627    fn a_sync_records_the_album_and_artist_ids_too() {
628        let (db, _dir) = test_db();
629
630        let meta = remote_track_meta("remote-100", "Enter", "Russian Circles", "Enter");
631        queries::upsert_track(&db.conn, &meta).unwrap();
632
633        let album: Option<String> = db
634            .conn
635            .query_row(
636                "SELECT remote_id FROM albums WHERE title = 'Enter'",
637                [],
638                |r| r.get(0),
639            )
640            .unwrap();
641        assert_eq!(album.as_deref(), Some("album-of-remote-100"));
642
643        let artist: Option<String> = db
644            .conn
645            .query_row(
646                "SELECT remote_id FROM artists WHERE name = 'Russian Circles'",
647                [],
648                |r| r.get(0),
649            )
650            .unwrap();
651        assert_eq!(artist.as_deref(), Some("artist-of-remote-100"));
652    }
653
654    #[test]
655    fn sync_upserts_tracks_to_database() {
656        let (db, _dir) = test_db();
657
658        let meta = remote_track_meta("remote-001", "Vordhosbn", "Aphex Twin", "Drukqs");
659        let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
660        assert!(track_id > 0, "upsert should return a valid track ID");
661
662        // Verify the track exists with correct remote_id.
663        let row = queries::get_track_row(&db.conn, track_id)
664            .unwrap()
665            .expect("track should exist in DB");
666        assert_eq!(row.title, "Vordhosbn");
667        assert_eq!(row.artist_name, "Aphex Twin");
668        assert_eq!(row.album_title, "Drukqs");
669        assert_eq!(row.remote_id.as_deref(), Some("remote-001"));
670        assert_eq!(row.source, "remote");
671    }
672
673    // --- sync_library against a stub Subsonic server ---
674
675    /// Minimal Subsonic server: serves getArtists, getAlbumList2 and getAlbum
676    /// so `sync_library` can be driven end to end without a real Navidrome.
677    struct StubServer {
678        addr: std::net::SocketAddr,
679        shutdown: Arc<std::sync::atomic::AtomicBool>,
680    }
681
682    #[derive(Default)]
683    struct StubState {
684        /// Album (id, name, created) in the order the server lists them.
685        albums: Mutex<Vec<(String, String, String)>>,
686        /// Album ids whose getAlbum call fails with a 500.
687        failing: Mutex<HashSet<String>>,
688        /// Prepended to the album list once the first list page has been served,
689        /// modelling a server-side insert landing mid-pagination.
690        insert_after_first_page: Mutex<Option<(String, String, String)>>,
691        list_pages_served: AtomicUsize,
692        list_types: Mutex<Vec<String>>,
693        album_calls: Mutex<Vec<String>>,
694    }
695
696    impl StubServer {
697        fn start(state: Arc<StubState>) -> Self {
698            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
699            listener.set_nonblocking(true).unwrap();
700            let addr = listener.local_addr().unwrap();
701            let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
702
703            let stop = shutdown.clone();
704            std::thread::spawn(move || {
705                while !stop.load(Ordering::Relaxed) {
706                    match listener.accept() {
707                        Ok((stream, _)) => {
708                            // BSD sockets inherit O_NONBLOCK from the listener.
709                            let _ = stream.set_nonblocking(false);
710                            let state = state.clone();
711                            std::thread::spawn(move || handle(stream, state));
712                        }
713                        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
714                            std::thread::sleep(std::time::Duration::from_millis(2));
715                        }
716                        Err(_) => break,
717                    }
718                }
719            });
720
721            Self { addr, shutdown }
722        }
723
724        fn url(&self) -> String {
725            format!("http://{}", self.addr)
726        }
727    }
728
729    impl Drop for StubServer {
730        fn drop(&mut self) {
731            self.shutdown.store(true, Ordering::Relaxed);
732        }
733    }
734
735    /// Serve requests on one connection until the peer closes it. Keep-alive
736    /// matters here: reqwest pools connections, and a server that hangs up after
737    /// every response makes parallel fetches fail on reused sockets.
738    fn handle(mut stream: std::net::TcpStream, state: Arc<StubState>) {
739        use std::io::{BufRead, Write};
740
741        let Ok(peek) = stream.try_clone() else { return };
742        let mut reader = std::io::BufReader::new(peek);
743
744        loop {
745            let mut request_line = String::new();
746            if reader.read_line(&mut request_line).unwrap_or(0) == 0 {
747                return;
748            }
749            let mut line = String::new();
750            while reader.read_line(&mut line).unwrap_or(0) > 0 {
751                if line == "\r\n" || line == "\n" {
752                    break;
753                }
754                line.clear();
755            }
756
757            let target = request_line.split_whitespace().nth(1).unwrap_or("/");
758            let (path, query) = target.split_once('?').unwrap_or((target, ""));
759            let params: std::collections::HashMap<&str, &str> = query
760                .split('&')
761                .filter_map(|kv| kv.split_once('='))
762                .collect();
763
764            let (status, body) = respond(&state, path, &params);
765
766            let write = write!(
767                stream,
768                "HTTP/1.1 {} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
769                status,
770                body.len()
771            )
772            .and_then(|()| stream.write_all(body.as_bytes()))
773            .and_then(|()| stream.flush());
774            if write.is_err() {
775                return;
776            }
777        }
778    }
779
780    fn respond(
781        state: &Arc<StubState>,
782        path: &str,
783        params: &std::collections::HashMap<&str, &str>,
784    ) -> (u16, String) {
785        match path.rsplit('/').next().unwrap_or("") {
786            "getArtists" => (
787                200,
788                r#"{"subsonic-response":{"status":"ok","artists":{"index":[{"artist":[{"id":"ar1","name":"Stub Artist"}]}]}}}"#.to_string(),
789            ),
790            "getAlbumList2" => {
791                state
792                    .list_types
793                    .lock()
794                    .unwrap()
795                    .push(params.get("type").copied().unwrap_or("").to_string());
796                let offset: usize = params.get("offset").and_then(|o| o.parse().ok()).unwrap_or(0);
797                let size: usize = params.get("size").and_then(|s| s.parse().ok()).unwrap_or(500);
798
799                let albums = state.albums.lock().unwrap();
800                let slice: Vec<String> = albums
801                    .iter()
802                    .skip(offset)
803                    .take(size)
804                    .map(|(id, name, created)| {
805                        format!(
806                            r#"{{"id":"{}","name":"{}","artist":"Stub Artist","created":"{}"}}"#,
807                            id, name, created
808                        )
809                    })
810                    .collect();
811                drop(albums);
812
813                if state.list_pages_served.fetch_add(1, Ordering::SeqCst) == 0
814                    && let Some(new_album) = state.insert_after_first_page.lock().unwrap().take()
815                {
816                    state.albums.lock().unwrap().insert(0, new_album);
817                }
818
819                (
820                    200,
821                    format!(
822                        r#"{{"subsonic-response":{{"status":"ok","albumList2":{{"album":[{}]}}}}}}"#,
823                        slice.join(",")
824                    ),
825                )
826            }
827            "getAlbum" => {
828                let id = params.get("id").copied().unwrap_or("");
829                state.album_calls.lock().unwrap().push(id.to_string());
830                if state.failing.lock().unwrap().contains(id) {
831                    (500, r#"{"error":"boom"}"#.to_string())
832                } else {
833                    (
834                        200,
835                        format!(
836                            r#"{{"subsonic-response":{{"status":"ok","album":{{"id":"{id}","name":"Album {id}","artist":"Stub Artist","song":[{{"id":"s{id}","title":"Song {id}","track":1,"suffix":"flac"}}]}}}}}}"#
837                        ),
838                    )
839                }
840            }
841            _ => (404, "{}".to_string()),
842        }
843    }
844
845    fn stub_albums(n: usize) -> Vec<(String, String, String)> {
846        (0..n)
847            .map(|i| {
848                (
849                    format!("a{:04}", i),
850                    format!("Album {:04}", i),
851                    "2024-01-15T10:30:00Z".to_string(),
852                )
853            })
854            .collect()
855    }
856
857    #[test]
858    fn failed_album_fetch_does_not_advance_last_sync_and_next_sync_retries() {
859        let (db, _dir) = test_db();
860        let state = Arc::new(StubState {
861            albums: Mutex::new(stub_albums(4)),
862            failing: Mutex::new(["a0002".to_string()].into_iter().collect()),
863            ..Default::default()
864        });
865        let server = StubServer::start(state.clone());
866        let client = SubsonicClient::new(&server.url(), "u", "p");
867
868        let first = sync_library(&db, &client, false, &server.url(), "u").unwrap();
869        assert_eq!(first.albums_failed, 1, "the failing album must be counted");
870        assert_eq!(first.albums_synced, 3);
871        assert!(!first.is_complete());
872        assert_eq!(
873            get_last_sync(&db, &server.url()).unwrap(),
874            None,
875            "an incomplete sync must not advance last_sync"
876        );
877
878        // Second run: the album now succeeds and is picked up because the sync
879        // still has no watermark to skip past.
880        state.failing.lock().unwrap().clear();
881        state.album_calls.lock().unwrap().clear();
882        let second = sync_library(&db, &client, false, &server.url(), "u").unwrap();
883
884        assert!(
885            state
886                .album_calls
887                .lock()
888                .unwrap()
889                .contains(&"a0002".to_string()),
890            "the previously failed album must be retried"
891        );
892        assert_eq!(second.albums_failed, 0);
893        assert!(second.is_complete());
894        assert!(
895            get_last_sync(&db, &server.url()).unwrap().is_some(),
896            "a clean sync advances last_sync"
897        );
898    }
899
900    #[test]
901    fn album_inserted_mid_pagination_is_not_fetched_twice_or_skipped() {
902        // 600 albums forces a second list page; a server-side insert between
903        // pages shifts the offset window, which without de-dup replays the
904        // page boundary and, with `newest` ordering, can drop albums entirely.
905        let (db, _dir) = test_db();
906        let state = Arc::new(StubState {
907            albums: Mutex::new(stub_albums(600)),
908            insert_after_first_page: Mutex::new(Some((
909                "aNEW".to_string(),
910                "AAA Brand New".to_string(),
911                "2024-06-01T00:00:00Z".to_string(),
912            ))),
913            ..Default::default()
914        });
915        let server = StubServer::start(state.clone());
916        let client = SubsonicClient::new(&server.url(), "u", "p");
917
918        let result = sync_library(&db, &client, true, &server.url(), "u").unwrap();
919        assert_eq!(result.albums_failed, 0);
920
921        let calls = state.album_calls.lock().unwrap().clone();
922        let unique: HashSet<&String> = calls.iter().collect();
923        assert_eq!(
924            calls.len(),
925            unique.len(),
926            "no album may be fetched twice after the window shifts"
927        );
928
929        // Every album present before the shift must still have been fetched.
930        for i in 0..600 {
931            let id = format!("a{:04}", i);
932            assert!(unique.contains(&id), "album {} was skipped", id);
933        }
934
935        let types = state.list_types.lock().unwrap().clone();
936        assert!(
937            types.iter().all(|t| t == "alphabeticalByName"),
938            "the paginated walk must use a stable ordering, got {:?}",
939            types
940        );
941    }
942
943    #[test]
944    fn incremental_sync_only_fetches_albums_created_after_last_sync() {
945        let (db, _dir) = test_db();
946        let mut albums = stub_albums(3);
947        albums[0].2 = "2020-01-01T00:00:00Z".into();
948        albums[1].2 = "2020-01-01T00:00:00Z".into();
949        albums[2].2 = "2030-01-01T00:00:00Z".into();
950
951        let state = Arc::new(StubState {
952            albums: Mutex::new(albums),
953            ..Default::default()
954        });
955        let server = StubServer::start(state.clone());
956        let client = SubsonicClient::new(&server.url(), "u", "p");
957
958        // Watermark between the two vintages.
959        let watermark = parse_iso8601_to_unix("2025-01-01T00:00:00Z").unwrap();
960        update_last_sync(&db, &server.url(), "u", watermark).unwrap();
961
962        let result = sync_library(&db, &client, false, &server.url(), "u").unwrap();
963
964        assert_eq!(result.albums_synced, 1, "only the new album needs fetching");
965        assert_eq!(
966            *state.album_calls.lock().unwrap(),
967            vec!["a0002".to_string()]
968        );
969    }
970
971    #[test]
972    fn album_with_unparseable_created_is_always_fetched() {
973        let (db, _dir) = test_db();
974        let state = Arc::new(StubState {
975            albums: Mutex::new(vec![("a0000".into(), "Album".into(), "who knows".into())]),
976            ..Default::default()
977        });
978        let server = StubServer::start(state.clone());
979        let client = SubsonicClient::new(&server.url(), "u", "p");
980
981        update_last_sync(&db, &server.url(), "u", 4_000_000_000).unwrap();
982        let result = sync_library(&db, &client, false, &server.url(), "u").unwrap();
983
984        assert_eq!(
985            result.albums_synced, 1,
986            "an album with no usable timestamp must not be assumed old"
987        );
988    }
989
990    #[test]
991    fn sync_deduplicates_by_remote_id() {
992        let (db, _dir) = test_db();
993
994        // First upsert.
995        let meta1 = remote_track_meta("remote-dup", "Original Title", "Artist A", "Album X");
996        let id1 = queries::upsert_track(&db.conn, &meta1).unwrap();
997
998        // Second upsert with same remote_id but different metadata.
999        let meta2 = remote_track_meta("remote-dup", "Updated Title", "Artist A", "Album X");
1000        let id2 = queries::upsert_track(&db.conn, &meta2).unwrap();
1001
1002        // Should be the same row (dedup by remote_id).
1003        assert_eq!(id1, id2, "same remote_id should resolve to same track row");
1004
1005        // Verify the metadata was updated.
1006        let row = queries::get_track_row(&db.conn, id2)
1007            .unwrap()
1008            .expect("track should exist");
1009        assert_eq!(row.title, "Updated Title");
1010        assert_eq!(row.remote_id.as_deref(), Some("remote-dup"));
1011
1012        // Verify only one track exists.
1013        let stats = queries::library_stats(&db.conn).unwrap();
1014        assert_eq!(
1015            stats.total_tracks, 1,
1016            "should have exactly 1 track after dedup"
1017        );
1018    }
1019}