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    db.optimize();
233
234    Ok(result)
235}
236
237/// Record what the server knows about each artist.
238///
239/// Best-effort: a library that synced its tracks fine should not fail because
240/// one artist row could not be updated.
241fn write_artists(db: &Database, artists: &[SubsonicArtist], result: &mut SyncResult) {
242    if db.conn.execute_batch("BEGIN").is_err() {
243        return;
244    }
245    let mut enriched = 0;
246    for artist in artists {
247        match queries::enrich_remote_artist(
248            &db.conn,
249            &artist.id,
250            artist.music_brainz_id.as_deref(),
251            artist.sort_name.as_deref(),
252        ) {
253            Ok(()) => enriched += 1,
254            Err(e) => log::warn!(
255                "failed to record artist metadata for {}: {}",
256                artist.name,
257                e
258            ),
259        }
260    }
261    if db.conn.execute_batch("COMMIT").is_err() {
262        let _ = db.conn.execute_batch("ROLLBACK");
263        return;
264    }
265    result.artists_synced = enriched;
266    log::info!("recorded metadata for {enriched} artists");
267}
268
269/// Write one batch of fetched albums in a single transaction.
270fn write_albums(
271    db: &Database,
272    client: &SubsonicClient,
273    albums: &[SubsonicAlbumFull],
274    result: &mut SyncResult,
275) -> Result<(), SyncError> {
276    db.conn
277        .execute_batch("BEGIN")
278        .map_err(crate::db::connection::DbError::from)?;
279
280    for album in albums {
281        result.albums_synced += 1;
282        let artist_name = album.artist.as_deref().unwrap_or("Unknown Artist");
283
284        for song in &album.song {
285            let meta = TrackMeta {
286                title: song.title.clone(),
287                artist: song
288                    .artist
289                    .clone()
290                    .unwrap_or_else(|| artist_name.to_string()),
291                album_artist: album.artist.clone(),
292                album: album.name.clone(),
293                date: album.year.map(|y| y.to_string()),
294                disc: song.disc_number,
295                track_number: song.track,
296                genre: song.genre.clone().or_else(|| album.genre.clone()),
297                label: None,
298                duration_ms: song.duration.map(|d| d * 1000),
299                codec: song.suffix.clone(),
300                // OpenSubsonic servers report these; a plain Subsonic one
301                // leaves them out and the track keeps no quality figures,
302                // which is what every remote track used to get.
303                //
304                // Zero means "not applicable", not "zero" — Navidrome reports
305                // bitDepth 0 for every lossy file. Storing it would render an
306                // MP3 as 0-bit, which is worse than saying nothing.
307                sample_rate: positive(song.sampling_rate),
308                bit_depth: positive(song.bit_depth),
309                channels: positive(song.channel_count),
310                bitrate: song.bit_rate,
311                size_bytes: None,
312                mtime: None,
313                path: None,
314                source: "remote".to_string(),
315                remote_id: Some(song.id.clone()),
316                remote_url: Some(client.stream_url_template(&song.id)),
317                album_remote_id: Some(album.id.clone()),
318                artist_remote_id: album.artist_id.clone(),
319                mbid: song.music_brainz_id.clone(),
320                album_added_at: album.created.clone(),
321            };
322
323            match queries::upsert_track(&db.conn, &meta) {
324                Ok(_) => result.tracks_synced += 1,
325                Err(e) => log::warn!("failed to insert remote track {}: {}", song.title, e),
326            }
327        }
328
329        // The album row is created through its tracks, so it only ever sees
330        // what a file's tags say. Track totals, the label and the MusicBrainz
331        // id belong to the release, and came back in the same response.
332        if let Err(e) = queries::enrich_remote_album(
333            &db.conn,
334            &album.id,
335            album.music_brainz_id.as_deref(),
336            album.sort_name.as_deref(),
337            album.song_count,
338            album.record_labels.first().map(|l| l.name.as_str()),
339        ) {
340            log::warn!("failed to record album metadata for {}: {}", album.name, e);
341        }
342    }
343
344    db.conn
345        .execute_batch("COMMIT")
346        .map_err(crate::db::connection::DbError::from)?;
347
348    Ok(())
349}
350
351/// Treat a non-positive figure as absent. None of sample rate, bit depth or
352/// channel count has a meaningful zero.
353fn positive(value: Option<i32>) -> Option<i32> {
354    value.filter(|v| *v > 0)
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn parse_rfc3339_with_z() {
363        // 2024-01-15T10:30:00Z = 1705314600
364        assert_eq!(
365            parse_iso8601_to_unix("2024-01-15T10:30:00Z"),
366            Some(1705314600)
367        );
368    }
369
370    #[test]
371    fn parse_rfc3339_with_offset() {
372        // 10:30 IST (+05:30) = 05:00 UTC = 1705294800
373        assert_eq!(
374            parse_iso8601_to_unix("2024-01-15T10:30:00+05:30"),
375            Some(1705294800)
376        );
377    }
378
379    #[test]
380    fn parse_rfc3339_negative_offset() {
381        // 10:30 EST (-05:00) = 15:30 UTC = 1705332600
382        assert_eq!(
383            parse_iso8601_to_unix("2024-01-15T10:30:00-05:00"),
384            Some(1705332600)
385        );
386    }
387
388    #[test]
389    fn parse_fractional_seconds_z() {
390        assert_eq!(
391            parse_iso8601_to_unix("2024-01-15T10:30:00.123Z"),
392            Some(1705314600)
393        );
394    }
395
396    #[test]
397    fn parse_fractional_seconds_offset() {
398        assert_eq!(
399            parse_iso8601_to_unix("2024-01-15T10:30:00.999+00:00"),
400            Some(1705314600)
401        );
402    }
403
404    #[test]
405    fn parse_no_timezone_assumes_utc() {
406        assert_eq!(
407            parse_iso8601_to_unix("2024-01-15T10:30:00"),
408            Some(1705314600)
409        );
410    }
411
412    #[test]
413    fn parse_no_timezone_fractional() {
414        assert_eq!(
415            parse_iso8601_to_unix("2024-01-15T10:30:00.500"),
416            Some(1705314600)
417        );
418    }
419
420    #[test]
421    fn parse_space_separator_with_tz() {
422        assert_eq!(
423            parse_iso8601_to_unix("2024-01-15 10:30:00+00:00"),
424            Some(1705314600)
425        );
426    }
427
428    #[test]
429    fn parse_space_separator_no_tz() {
430        assert_eq!(
431            parse_iso8601_to_unix("2024-01-15 10:30:00"),
432            Some(1705314600)
433        );
434    }
435
436    #[test]
437    fn parse_garbage_returns_none() {
438        assert_eq!(parse_iso8601_to_unix("not-a-date"), None);
439        assert_eq!(parse_iso8601_to_unix(""), None);
440        assert_eq!(parse_iso8601_to_unix("2024"), None);
441    }
442
443    #[test]
444    fn parse_epoch() {
445        assert_eq!(parse_iso8601_to_unix("1970-01-01T00:00:00Z"), Some(0));
446    }
447
448    // --- Sync → DB integration tests ---
449
450    use crate::db::connection::Database;
451    use crate::db::queries;
452    use std::sync::{Arc, Mutex};
453
454    fn test_db() -> (Database, tempfile::TempDir) {
455        let dir = tempfile::tempdir().unwrap();
456        let db = Database::open(&dir.path().join("sync_test.db")).unwrap();
457        (db, dir)
458    }
459
460    /// Build a TrackMeta matching how sync_library constructs them from SubsonicSong data.
461    fn remote_track_meta(remote_id: &str, title: &str, artist: &str, album: &str) -> TrackMeta {
462        TrackMeta {
463            title: title.into(),
464            artist: artist.into(),
465            album_artist: Some(artist.into()),
466            album: album.into(),
467            date: Some("2024".into()),
468            disc: Some(1),
469            track_number: Some(1),
470            genre: Some("Electronic".into()),
471            label: None,
472            duration_ms: Some(240_000),
473            codec: Some("FLAC".into()),
474            sample_rate: Some(44100),
475            bit_depth: Some(16),
476            channels: Some(2),
477            bitrate: Some(1000),
478            size_bytes: None,
479            mtime: None,
480            path: None,
481            source: "remote".into(),
482            remote_id: Some(remote_id.into()),
483            remote_url: Some(format!("https://example.com/stream?id={}", remote_id)),
484            album_remote_id: Some(format!("album-of-{remote_id}")),
485            artist_remote_id: Some(format!("artist-of-{remote_id}")),
486            mbid: Some(format!("mbid-of-{remote_id}")),
487            album_added_at: None,
488        }
489    }
490
491    /// Navidrome reports bitDepth 0 for every lossy file. Keeping it would
492    /// render an MP3 as 0-bit; absent is the honest answer.
493    #[test]
494    fn a_zero_quality_figure_is_treated_as_absent() {
495        assert_eq!(positive(Some(0)), None);
496        assert_eq!(positive(Some(16)), Some(16));
497        assert_eq!(positive(None), None);
498    }
499
500    /// The album row is created through its tracks, so it only ever sees what
501    /// a file's tags say. Track totals, the label and the MusicBrainz id are
502    /// properties of the release and arrive in the same response.
503    #[test]
504    fn album_metadata_from_the_server_is_recorded() {
505        let (db, _dir) = test_db();
506
507        let meta = remote_track_meta("remote-300", "Anguish", "Sleep", "Volume One");
508        queries::upsert_track(&db.conn, &meta).unwrap();
509
510        queries::enrich_remote_album(
511            &db.conn,
512            "album-of-remote-300",
513            Some("mb-album-1"),
514            Some("volume one"),
515            Some(6),
516            Some("Off The Disk"),
517        )
518        .unwrap();
519
520        let row: (Option<String>, Option<String>, Option<i32>, Option<String>) = db
521            .conn
522            .query_row(
523                "SELECT mbid, sort_name, total_tracks, label FROM albums WHERE title = 'Volume One'",
524                [],
525                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
526            )
527            .unwrap();
528        assert_eq!(row.0.as_deref(), Some("mb-album-1"));
529        assert_eq!(row.1.as_deref(), Some("volume one"));
530        assert_eq!(row.2, Some(6));
531        assert_eq!(row.3.as_deref(), Some("Off The Disk"));
532    }
533
534    /// Enrichment fills blanks. A locally-scanned album whose tags named a
535    /// label must keep it, rather than have the server's answer written over.
536    #[test]
537    fn server_metadata_does_not_overwrite_what_tags_said() {
538        let (db, _dir) = test_db();
539
540        let mut meta = remote_track_meta("remote-301", "Dopesmoker", "Sleep", "Dopesmoker");
541        meta.label = Some("From The Tags".into());
542        queries::upsert_track(&db.conn, &meta).unwrap();
543
544        queries::enrich_remote_album(
545            &db.conn,
546            "album-of-remote-301",
547            None,
548            None,
549            None,
550            Some("From The Server"),
551        )
552        .unwrap();
553
554        let label: Option<String> = db
555            .conn
556            .query_row(
557                "SELECT label FROM albums WHERE title = 'Dopesmoker'",
558                [],
559                |r| r.get(0),
560            )
561            .unwrap();
562        assert_eq!(label.as_deref(), Some("From The Tags"));
563    }
564
565    /// Artists existed only as a side effect of a track upsert, so nothing
566    /// ever wrote their MusicBrainz id or sort name.
567    #[test]
568    fn artist_metadata_from_the_server_is_recorded() {
569        let (db, _dir) = test_db();
570
571        let meta = remote_track_meta("remote-302", "Holy Mountain", "Sleep", "Holy Mountain");
572        queries::upsert_track(&db.conn, &meta).unwrap();
573
574        queries::enrich_remote_artist(
575            &db.conn,
576            "artist-of-remote-302",
577            Some("mb-artist-1"),
578            Some("sleep"),
579        )
580        .unwrap();
581
582        let row: (Option<String>, Option<String>) = db
583            .conn
584            .query_row(
585                "SELECT mbid, sort_name FROM artists WHERE name = 'Sleep'",
586                [],
587                |r| Ok((r.get(0)?, r.get(1)?)),
588            )
589            .unwrap();
590        assert_eq!(row.0.as_deref(), Some("mb-artist-1"));
591        assert_eq!(row.1.as_deref(), Some("sleep"));
592    }
593
594    /// The recording id travels with the track.
595    #[test]
596    fn a_synced_track_keeps_its_musicbrainz_id() {
597        let (db, _dir) = test_db();
598
599        let meta = remote_track_meta("remote-303", "Aquarian", "Sleep", "Dopesmoker");
600        let id = queries::upsert_track(&db.conn, &meta).unwrap();
601
602        let mbid: Option<String> = db
603            .conn
604            .query_row("SELECT mbid FROM tracks WHERE id = ?1", [id], |r| r.get(0))
605            .unwrap();
606        assert_eq!(mbid.as_deref(), Some("mbid-of-remote-303"));
607    }
608
609    /// A remote track carries the quality figures an OpenSubsonic server
610    /// reports. Without them the format badge has nothing to show, which is
611    /// what every remote-only track in a synced library used to look like.
612    #[test]
613    fn a_synced_track_keeps_its_quality_figures() {
614        let (db, _dir) = test_db();
615
616        let meta = remote_track_meta("remote-200", "Anguish", "Sleep", "Volume One");
617        let id = queries::upsert_track(&db.conn, &meta).unwrap();
618
619        let row = queries::get_track_row(&db.conn, id).unwrap().unwrap();
620        assert_eq!(row.sample_rate, Some(44100));
621        assert_eq!(row.bit_depth, Some(16));
622        assert_eq!(row.channels, Some(2));
623    }
624
625    /// The server keys stars, shares and cover art off album and artist ids,
626    /// so a sync that only kept the track's left the library unable to refer
627    /// to either — every album row came back with a null remote_id.
628    #[test]
629    fn a_sync_records_the_album_and_artist_ids_too() {
630        let (db, _dir) = test_db();
631
632        let meta = remote_track_meta("remote-100", "Enter", "Russian Circles", "Enter");
633        queries::upsert_track(&db.conn, &meta).unwrap();
634
635        let album: Option<String> = db
636            .conn
637            .query_row(
638                "SELECT remote_id FROM albums WHERE title = 'Enter'",
639                [],
640                |r| r.get(0),
641            )
642            .unwrap();
643        assert_eq!(album.as_deref(), Some("album-of-remote-100"));
644
645        let artist: Option<String> = db
646            .conn
647            .query_row(
648                "SELECT remote_id FROM artists WHERE name = 'Russian Circles'",
649                [],
650                |r| r.get(0),
651            )
652            .unwrap();
653        assert_eq!(artist.as_deref(), Some("artist-of-remote-100"));
654    }
655
656    #[test]
657    fn sync_upserts_tracks_to_database() {
658        let (db, _dir) = test_db();
659
660        let meta = remote_track_meta("remote-001", "Vordhosbn", "Aphex Twin", "Drukqs");
661        let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
662        assert!(track_id > 0, "upsert should return a valid track ID");
663
664        // Verify the track exists with correct remote_id.
665        let row = queries::get_track_row(&db.conn, track_id)
666            .unwrap()
667            .expect("track should exist in DB");
668        assert_eq!(row.title, "Vordhosbn");
669        assert_eq!(row.artist_name, "Aphex Twin");
670        assert_eq!(row.album_title, "Drukqs");
671        assert_eq!(row.remote_id.as_deref(), Some("remote-001"));
672        assert_eq!(row.source, "remote");
673    }
674
675    // --- sync_library against a stub Subsonic server ---
676
677    /// Minimal Subsonic server: serves getArtists, getAlbumList2 and getAlbum
678    /// so `sync_library` can be driven end to end without a real Navidrome.
679    struct StubServer {
680        addr: std::net::SocketAddr,
681        shutdown: Arc<std::sync::atomic::AtomicBool>,
682    }
683
684    #[derive(Default)]
685    struct StubState {
686        /// Album (id, name, created) in the order the server lists them.
687        albums: Mutex<Vec<(String, String, String)>>,
688        /// Album ids whose getAlbum call fails with a 500.
689        failing: Mutex<HashSet<String>>,
690        /// Prepended to the album list once the first list page has been served,
691        /// modelling a server-side insert landing mid-pagination.
692        insert_after_first_page: Mutex<Option<(String, String, String)>>,
693        list_pages_served: AtomicUsize,
694        list_types: Mutex<Vec<String>>,
695        album_calls: Mutex<Vec<String>>,
696    }
697
698    impl StubServer {
699        fn start(state: Arc<StubState>) -> Self {
700            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
701            listener.set_nonblocking(true).unwrap();
702            let addr = listener.local_addr().unwrap();
703            let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
704
705            let stop = shutdown.clone();
706            std::thread::spawn(move || {
707                while !stop.load(Ordering::Relaxed) {
708                    match listener.accept() {
709                        Ok((stream, _)) => {
710                            // BSD sockets inherit O_NONBLOCK from the listener.
711                            let _ = stream.set_nonblocking(false);
712                            let state = state.clone();
713                            std::thread::spawn(move || handle(stream, state));
714                        }
715                        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
716                            std::thread::sleep(std::time::Duration::from_millis(2));
717                        }
718                        Err(_) => break,
719                    }
720                }
721            });
722
723            Self { addr, shutdown }
724        }
725
726        fn url(&self) -> String {
727            format!("http://{}", self.addr)
728        }
729    }
730
731    impl Drop for StubServer {
732        fn drop(&mut self) {
733            self.shutdown.store(true, Ordering::Relaxed);
734        }
735    }
736
737    /// Serve requests on one connection until the peer closes it. Keep-alive
738    /// matters here: reqwest pools connections, and a server that hangs up after
739    /// every response makes parallel fetches fail on reused sockets.
740    fn handle(mut stream: std::net::TcpStream, state: Arc<StubState>) {
741        use std::io::{BufRead, Write};
742
743        let Ok(peek) = stream.try_clone() else { return };
744        let mut reader = std::io::BufReader::new(peek);
745
746        loop {
747            let mut request_line = String::new();
748            if reader.read_line(&mut request_line).unwrap_or(0) == 0 {
749                return;
750            }
751            let mut line = String::new();
752            while reader.read_line(&mut line).unwrap_or(0) > 0 {
753                if line == "\r\n" || line == "\n" {
754                    break;
755                }
756                line.clear();
757            }
758
759            let target = request_line.split_whitespace().nth(1).unwrap_or("/");
760            let (path, query) = target.split_once('?').unwrap_or((target, ""));
761            let params: std::collections::HashMap<&str, &str> = query
762                .split('&')
763                .filter_map(|kv| kv.split_once('='))
764                .collect();
765
766            let (status, body) = respond(&state, path, &params);
767
768            let write = write!(
769                stream,
770                "HTTP/1.1 {} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
771                status,
772                body.len()
773            )
774            .and_then(|()| stream.write_all(body.as_bytes()))
775            .and_then(|()| stream.flush());
776            if write.is_err() {
777                return;
778            }
779        }
780    }
781
782    fn respond(
783        state: &Arc<StubState>,
784        path: &str,
785        params: &std::collections::HashMap<&str, &str>,
786    ) -> (u16, String) {
787        match path.rsplit('/').next().unwrap_or("") {
788            "getArtists" => (
789                200,
790                r#"{"subsonic-response":{"status":"ok","artists":{"index":[{"artist":[{"id":"ar1","name":"Stub Artist"}]}]}}}"#.to_string(),
791            ),
792            "getAlbumList2" => {
793                state
794                    .list_types
795                    .lock()
796                    .unwrap()
797                    .push(params.get("type").copied().unwrap_or("").to_string());
798                let offset: usize = params.get("offset").and_then(|o| o.parse().ok()).unwrap_or(0);
799                let size: usize = params.get("size").and_then(|s| s.parse().ok()).unwrap_or(500);
800
801                let albums = state.albums.lock().unwrap();
802                let slice: Vec<String> = albums
803                    .iter()
804                    .skip(offset)
805                    .take(size)
806                    .map(|(id, name, created)| {
807                        format!(
808                            r#"{{"id":"{}","name":"{}","artist":"Stub Artist","created":"{}"}}"#,
809                            id, name, created
810                        )
811                    })
812                    .collect();
813                drop(albums);
814
815                if state.list_pages_served.fetch_add(1, Ordering::SeqCst) == 0
816                    && let Some(new_album) = state.insert_after_first_page.lock().unwrap().take()
817                {
818                    state.albums.lock().unwrap().insert(0, new_album);
819                }
820
821                (
822                    200,
823                    format!(
824                        r#"{{"subsonic-response":{{"status":"ok","albumList2":{{"album":[{}]}}}}}}"#,
825                        slice.join(",")
826                    ),
827                )
828            }
829            "getAlbum" => {
830                let id = params.get("id").copied().unwrap_or("");
831                state.album_calls.lock().unwrap().push(id.to_string());
832                if state.failing.lock().unwrap().contains(id) {
833                    (500, r#"{"error":"boom"}"#.to_string())
834                } else {
835                    (
836                        200,
837                        format!(
838                            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"}}]}}}}}}"#
839                        ),
840                    )
841                }
842            }
843            _ => (404, "{}".to_string()),
844        }
845    }
846
847    fn stub_albums(n: usize) -> Vec<(String, String, String)> {
848        (0..n)
849            .map(|i| {
850                (
851                    format!("a{:04}", i),
852                    format!("Album {:04}", i),
853                    "2024-01-15T10:30:00Z".to_string(),
854                )
855            })
856            .collect()
857    }
858
859    #[test]
860    fn failed_album_fetch_does_not_advance_last_sync_and_next_sync_retries() {
861        let (db, _dir) = test_db();
862        let state = Arc::new(StubState {
863            albums: Mutex::new(stub_albums(4)),
864            failing: Mutex::new(["a0002".to_string()].into_iter().collect()),
865            ..Default::default()
866        });
867        let server = StubServer::start(state.clone());
868        let client = SubsonicClient::new(&server.url(), "u", "p");
869
870        let first = sync_library(&db, &client, false, &server.url(), "u").unwrap();
871        assert_eq!(first.albums_failed, 1, "the failing album must be counted");
872        assert_eq!(first.albums_synced, 3);
873        assert!(!first.is_complete());
874        assert_eq!(
875            get_last_sync(&db, &server.url()).unwrap(),
876            None,
877            "an incomplete sync must not advance last_sync"
878        );
879
880        // Second run: the album now succeeds and is picked up because the sync
881        // still has no watermark to skip past.
882        state.failing.lock().unwrap().clear();
883        state.album_calls.lock().unwrap().clear();
884        let second = sync_library(&db, &client, false, &server.url(), "u").unwrap();
885
886        assert!(
887            state
888                .album_calls
889                .lock()
890                .unwrap()
891                .contains(&"a0002".to_string()),
892            "the previously failed album must be retried"
893        );
894        assert_eq!(second.albums_failed, 0);
895        assert!(second.is_complete());
896        assert!(
897            get_last_sync(&db, &server.url()).unwrap().is_some(),
898            "a clean sync advances last_sync"
899        );
900    }
901
902    #[test]
903    fn album_inserted_mid_pagination_is_not_fetched_twice_or_skipped() {
904        // 600 albums forces a second list page; a server-side insert between
905        // pages shifts the offset window, which without de-dup replays the
906        // page boundary and, with `newest` ordering, can drop albums entirely.
907        let (db, _dir) = test_db();
908        let state = Arc::new(StubState {
909            albums: Mutex::new(stub_albums(600)),
910            insert_after_first_page: Mutex::new(Some((
911                "aNEW".to_string(),
912                "AAA Brand New".to_string(),
913                "2024-06-01T00:00:00Z".to_string(),
914            ))),
915            ..Default::default()
916        });
917        let server = StubServer::start(state.clone());
918        let client = SubsonicClient::new(&server.url(), "u", "p");
919
920        let result = sync_library(&db, &client, true, &server.url(), "u").unwrap();
921        assert_eq!(result.albums_failed, 0);
922
923        let calls = state.album_calls.lock().unwrap().clone();
924        let unique: HashSet<&String> = calls.iter().collect();
925        assert_eq!(
926            calls.len(),
927            unique.len(),
928            "no album may be fetched twice after the window shifts"
929        );
930
931        // Every album present before the shift must still have been fetched.
932        for i in 0..600 {
933            let id = format!("a{:04}", i);
934            assert!(unique.contains(&id), "album {} was skipped", id);
935        }
936
937        let types = state.list_types.lock().unwrap().clone();
938        assert!(
939            types.iter().all(|t| t == "alphabeticalByName"),
940            "the paginated walk must use a stable ordering, got {:?}",
941            types
942        );
943    }
944
945    #[test]
946    fn incremental_sync_only_fetches_albums_created_after_last_sync() {
947        let (db, _dir) = test_db();
948        let mut albums = stub_albums(3);
949        albums[0].2 = "2020-01-01T00:00:00Z".into();
950        albums[1].2 = "2020-01-01T00:00:00Z".into();
951        albums[2].2 = "2030-01-01T00:00:00Z".into();
952
953        let state = Arc::new(StubState {
954            albums: Mutex::new(albums),
955            ..Default::default()
956        });
957        let server = StubServer::start(state.clone());
958        let client = SubsonicClient::new(&server.url(), "u", "p");
959
960        // Watermark between the two vintages.
961        let watermark = parse_iso8601_to_unix("2025-01-01T00:00:00Z").unwrap();
962        update_last_sync(&db, &server.url(), "u", watermark).unwrap();
963
964        let result = sync_library(&db, &client, false, &server.url(), "u").unwrap();
965
966        assert_eq!(result.albums_synced, 1, "only the new album needs fetching");
967        assert_eq!(
968            *state.album_calls.lock().unwrap(),
969            vec!["a0002".to_string()]
970        );
971    }
972
973    #[test]
974    fn album_with_unparseable_created_is_always_fetched() {
975        let (db, _dir) = test_db();
976        let state = Arc::new(StubState {
977            albums: Mutex::new(vec![("a0000".into(), "Album".into(), "who knows".into())]),
978            ..Default::default()
979        });
980        let server = StubServer::start(state.clone());
981        let client = SubsonicClient::new(&server.url(), "u", "p");
982
983        update_last_sync(&db, &server.url(), "u", 4_000_000_000).unwrap();
984        let result = sync_library(&db, &client, false, &server.url(), "u").unwrap();
985
986        assert_eq!(
987            result.albums_synced, 1,
988            "an album with no usable timestamp must not be assumed old"
989        );
990    }
991
992    #[test]
993    fn sync_deduplicates_by_remote_id() {
994        let (db, _dir) = test_db();
995
996        // First upsert.
997        let meta1 = remote_track_meta("remote-dup", "Original Title", "Artist A", "Album X");
998        let id1 = queries::upsert_track(&db.conn, &meta1).unwrap();
999
1000        // Second upsert with same remote_id but different metadata.
1001        let meta2 = remote_track_meta("remote-dup", "Updated Title", "Artist A", "Album X");
1002        let id2 = queries::upsert_track(&db.conn, &meta2).unwrap();
1003
1004        // Should be the same row (dedup by remote_id).
1005        assert_eq!(id1, id2, "same remote_id should resolve to same track row");
1006
1007        // Verify the metadata was updated.
1008        let row = queries::get_track_row(&db.conn, id2)
1009            .unwrap()
1010            .expect("track should exist");
1011        assert_eq!(row.title, "Updated Title");
1012        assert_eq!(row.remote_id.as_deref(), Some("remote-dup"));
1013
1014        // Verify only one track exists.
1015        let stats = queries::library_stats(&db.conn).unwrap();
1016        assert_eq!(
1017            stats.total_tracks, 1,
1018            "should have exactly 1 track after dedup"
1019        );
1020    }
1021}