Skip to main content

koan_core/remote/
sync.rs

1use crate::db::connection::Database;
2use crate::db::queries::{self, TrackMeta};
3use crate::remote::client::{SubsonicAlbum, SubsonicAlbumFull, SubsonicClient};
4
5use rayon::prelude::*;
6use rusqlite::params;
7use thiserror::Error;
8
9#[derive(Debug, Error)]
10pub enum SyncError {
11    #[error("subsonic error: {0}")]
12    Subsonic(#[from] super::client::SubsonicError),
13    #[error("db error: {0}")]
14    Db(#[from] crate::db::connection::DbError),
15}
16
17#[derive(Debug, Default)]
18pub struct SyncResult {
19    pub artists_synced: usize,
20    pub albums_synced: usize,
21    pub tracks_synced: usize,
22}
23
24/// Get the last sync timestamp for a remote server, if any.
25pub fn get_last_sync(
26    db: &Database,
27    url: &str,
28) -> Result<Option<i64>, crate::db::connection::DbError> {
29    let result = db.conn.query_row(
30        "SELECT last_sync FROM remote_servers WHERE url = ?1",
31        params![url],
32        |row| row.get::<_, Option<i64>>(0),
33    );
34    match result {
35        Ok(ts) => Ok(ts),
36        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
37        Err(e) => Err(e.into()),
38    }
39}
40
41/// Update (or insert) the last sync timestamp for a remote server.
42pub fn update_last_sync(
43    db: &Database,
44    url: &str,
45    username: &str,
46    timestamp: i64,
47) -> Result<(), crate::db::connection::DbError> {
48    db.conn.execute(
49        "INSERT INTO remote_servers (url, username, last_sync)
50         VALUES (?1, ?2, ?3)
51         ON CONFLICT(url) DO UPDATE SET last_sync = ?3",
52        params![url, username, timestamp],
53    )?;
54    Ok(())
55}
56
57/// Parse an ISO 8601 / RFC 3339 timestamp string into a unix timestamp (seconds).
58/// Returns `None` if the string can't be parsed.
59///
60/// Handles common Subsonic/Navidrome variants:
61/// - Full RFC 3339: `2024-01-15T10:30:00Z`, `2024-01-15T10:30:00+05:30`
62/// - Fractional seconds: `2024-01-15T10:30:00.123Z`
63/// - Missing timezone (assumed UTC): `2024-01-15T10:30:00`
64fn parse_iso8601_to_unix(s: &str) -> Option<i64> {
65    use chrono::{DateTime, FixedOffset, NaiveDateTime};
66
67    // Try strict RFC 3339 first (handles Z, offsets, fractional seconds).
68    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
69        return Some(dt.timestamp());
70    }
71
72    // Subsonic sometimes omits timezone — parse as naive and assume UTC.
73    // Try with fractional seconds first, then without.
74    if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
75        return Some(naive.and_utc().timestamp());
76    }
77    if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
78        return Some(naive.and_utc().timestamp());
79    }
80
81    // Some servers use space instead of T.
82    if let Ok(dt) = DateTime::<FixedOffset>::parse_from_str(s, "%Y-%m-%d %H:%M:%S%:z") {
83        return Some(dt.timestamp());
84    }
85    if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
86        return Some(naive.and_utc().timestamp());
87    }
88
89    None
90}
91
92/// Pull the Navidrome/Subsonic library into the local DB.
93///
94/// If `full` is false and a `last_sync` timestamp exists for this server,
95/// performs an incremental sync using `getAlbumList2(type=newest)`, stopping
96/// when all albums on a page predate the last sync. Otherwise does a full sync
97/// using `alphabeticalByName`.
98///
99/// Pipeline: paginate album list -> fetch album details in parallel (rayon) ->
100/// batch-write each page in a single transaction.
101///
102/// Deduplication happens in `upsert_track` — if a local track already exists
103/// with the same artist + album + title + track#, the remote_id and remote_url
104/// are merged onto the existing row instead of creating a duplicate.
105pub fn sync_library(
106    db: &Database,
107    client: &SubsonicClient,
108    full: bool,
109    server_url: &str,
110    username: &str,
111) -> Result<SyncResult, SyncError> {
112    let mut result = SyncResult::default();
113
114    let artists = client.get_artists()?;
115    result.artists_synced = artists.len();
116    log::info!("syncing {} artists from remote", artists.len());
117
118    // Determine sync mode: incremental (newest-first, stop at last_sync) or full.
119    let last_sync = if full {
120        None
121    } else {
122        get_last_sync(db, server_url)?
123    };
124
125    let (list_type, is_incremental) = match last_sync {
126        Some(_) => ("newest", true),
127        None => ("alphabeticalByName", false),
128    };
129
130    if is_incremental {
131        log::info!("incremental sync (newest since last sync)");
132    } else {
133        log::info!("full sync (alphabetical)");
134    }
135
136    let sync_start = std::time::SystemTime::now()
137        .duration_since(std::time::UNIX_EPOCH)
138        .unwrap_or_default()
139        .as_secs() as i64;
140
141    let mut offset = 0u32;
142    let page_size = 500u32;
143
144    loop {
145        let album_summaries = client.get_album_list(list_type, page_size, offset)?;
146        if album_summaries.is_empty() {
147            break;
148        }
149
150        let page_count = album_summaries.len();
151
152        // For incremental sync, check if we've passed the last_sync boundary.
153        // If the oldest album on this page was created before last_sync, we
154        // still process this page but stop after it.
155        let should_stop_after = if let Some(last_ts) = last_sync {
156            album_summaries
157                .iter()
158                .filter_map(|a| a.created.as_deref())
159                .filter_map(parse_iso8601_to_unix)
160                .min()
161                .is_some_and(|oldest| oldest < last_ts)
162        } else {
163            false
164        };
165
166        // Parallel fetch: get full album details (with tracks) concurrently.
167        let fetched: Vec<(SubsonicAlbum, SubsonicAlbumFull)> = album_summaries
168            .into_par_iter()
169            .filter_map(|summary| match client.get_album(&summary.id) {
170                Ok(full) => Some((summary, full)),
171                Err(e) => {
172                    log::warn!("failed to fetch album {}: {}", summary.id, e);
173                    None
174                }
175            })
176            .collect();
177
178        // Batch write in a single transaction.
179        db.conn
180            .execute_batch("BEGIN")
181            .map_err(crate::db::connection::DbError::from)?;
182
183        for (_, album) in &fetched {
184            result.albums_synced += 1;
185            let artist_name = album.artist.as_deref().unwrap_or("Unknown Artist");
186
187            for song in &album.song {
188                let meta = TrackMeta {
189                    title: song.title.clone(),
190                    artist: song
191                        .artist
192                        .clone()
193                        .unwrap_or_else(|| artist_name.to_string()),
194                    album_artist: album.artist.clone(),
195                    album: album.name.clone(),
196                    date: album.year.map(|y| y.to_string()),
197                    disc: song.disc_number,
198                    track_number: song.track,
199                    genre: song.genre.clone().or_else(|| album.genre.clone()),
200                    label: None,
201                    duration_ms: song.duration.map(|d| d * 1000),
202                    codec: song.suffix.clone(),
203                    sample_rate: None,
204                    bit_depth: None,
205                    channels: None,
206                    bitrate: song.bit_rate,
207                    size_bytes: None,
208                    mtime: None,
209                    path: None,
210                    source: "remote".to_string(),
211                    remote_id: Some(song.id.clone()),
212                    remote_url: Some(client.stream_url_template(&song.id)),
213                };
214
215                match queries::upsert_track(&db.conn, &meta) {
216                    Ok(_) => result.tracks_synced += 1,
217                    Err(e) => log::warn!("failed to insert remote track {}: {}", song.title, e),
218                }
219            }
220        }
221
222        db.conn
223            .execute_batch("COMMIT")
224            .map_err(crate::db::connection::DbError::from)?;
225
226        offset += page_count as u32;
227        log::info!(
228            "synced {} albums ({} tracks) so far...",
229            result.albums_synced,
230            result.tracks_synced
231        );
232
233        if should_stop_after {
234            log::info!("incremental sync: reached albums older than last sync, stopping");
235            break;
236        }
237    }
238
239    // Record successful sync timestamp.
240    update_last_sync(db, server_url, username, sync_start)?;
241
242    log::info!(
243        "sync complete: {} artists, {} albums, {} tracks",
244        result.artists_synced,
245        result.albums_synced,
246        result.tracks_synced,
247    );
248
249    Ok(result)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn parse_rfc3339_with_z() {
258        // 2024-01-15T10:30:00Z = 1705314600
259        assert_eq!(
260            parse_iso8601_to_unix("2024-01-15T10:30:00Z"),
261            Some(1705314600)
262        );
263    }
264
265    #[test]
266    fn parse_rfc3339_with_offset() {
267        // 10:30 IST (+05:30) = 05:00 UTC = 1705294800
268        assert_eq!(
269            parse_iso8601_to_unix("2024-01-15T10:30:00+05:30"),
270            Some(1705294800)
271        );
272    }
273
274    #[test]
275    fn parse_rfc3339_negative_offset() {
276        // 10:30 EST (-05:00) = 15:30 UTC = 1705332600
277        assert_eq!(
278            parse_iso8601_to_unix("2024-01-15T10:30:00-05:00"),
279            Some(1705332600)
280        );
281    }
282
283    #[test]
284    fn parse_fractional_seconds_z() {
285        assert_eq!(
286            parse_iso8601_to_unix("2024-01-15T10:30:00.123Z"),
287            Some(1705314600)
288        );
289    }
290
291    #[test]
292    fn parse_fractional_seconds_offset() {
293        assert_eq!(
294            parse_iso8601_to_unix("2024-01-15T10:30:00.999+00:00"),
295            Some(1705314600)
296        );
297    }
298
299    #[test]
300    fn parse_no_timezone_assumes_utc() {
301        assert_eq!(
302            parse_iso8601_to_unix("2024-01-15T10:30:00"),
303            Some(1705314600)
304        );
305    }
306
307    #[test]
308    fn parse_no_timezone_fractional() {
309        assert_eq!(
310            parse_iso8601_to_unix("2024-01-15T10:30:00.500"),
311            Some(1705314600)
312        );
313    }
314
315    #[test]
316    fn parse_space_separator_with_tz() {
317        assert_eq!(
318            parse_iso8601_to_unix("2024-01-15 10:30:00+00:00"),
319            Some(1705314600)
320        );
321    }
322
323    #[test]
324    fn parse_space_separator_no_tz() {
325        assert_eq!(
326            parse_iso8601_to_unix("2024-01-15 10:30:00"),
327            Some(1705314600)
328        );
329    }
330
331    #[test]
332    fn parse_garbage_returns_none() {
333        assert_eq!(parse_iso8601_to_unix("not-a-date"), None);
334        assert_eq!(parse_iso8601_to_unix(""), None);
335        assert_eq!(parse_iso8601_to_unix("2024"), None);
336    }
337
338    #[test]
339    fn parse_epoch() {
340        assert_eq!(parse_iso8601_to_unix("1970-01-01T00:00:00Z"), Some(0));
341    }
342
343    // --- Sync → DB integration tests ---
344
345    use crate::db::connection::Database;
346    use crate::db::queries;
347
348    fn test_db() -> (Database, tempfile::TempDir) {
349        let dir = tempfile::tempdir().unwrap();
350        let db = Database::open(&dir.path().join("sync_test.db")).unwrap();
351        (db, dir)
352    }
353
354    /// Build a TrackMeta matching how sync_library constructs them from SubsonicSong data.
355    fn remote_track_meta(remote_id: &str, title: &str, artist: &str, album: &str) -> TrackMeta {
356        TrackMeta {
357            title: title.into(),
358            artist: artist.into(),
359            album_artist: Some(artist.into()),
360            album: album.into(),
361            date: Some("2024".into()),
362            disc: Some(1),
363            track_number: Some(1),
364            genre: Some("Electronic".into()),
365            label: None,
366            duration_ms: Some(240_000),
367            codec: Some("FLAC".into()),
368            sample_rate: None,
369            bit_depth: None,
370            channels: None,
371            bitrate: Some(1000),
372            size_bytes: None,
373            mtime: None,
374            path: None,
375            source: "remote".into(),
376            remote_id: Some(remote_id.into()),
377            remote_url: Some(format!("https://example.com/stream?id={}", remote_id)),
378        }
379    }
380
381    #[test]
382    fn sync_upserts_tracks_to_database() {
383        let (db, _dir) = test_db();
384
385        let meta = remote_track_meta("remote-001", "Vordhosbn", "Aphex Twin", "Drukqs");
386        let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
387        assert!(track_id > 0, "upsert should return a valid track ID");
388
389        // Verify the track exists with correct remote_id.
390        let row = queries::get_track_row(&db.conn, track_id)
391            .unwrap()
392            .expect("track should exist in DB");
393        assert_eq!(row.title, "Vordhosbn");
394        assert_eq!(row.artist_name, "Aphex Twin");
395        assert_eq!(row.album_title, "Drukqs");
396        assert_eq!(row.remote_id.as_deref(), Some("remote-001"));
397        assert_eq!(row.source, "remote");
398    }
399
400    #[test]
401    fn sync_deduplicates_by_remote_id() {
402        let (db, _dir) = test_db();
403
404        // First upsert.
405        let meta1 = remote_track_meta("remote-dup", "Original Title", "Artist A", "Album X");
406        let id1 = queries::upsert_track(&db.conn, &meta1).unwrap();
407
408        // Second upsert with same remote_id but different metadata.
409        let meta2 = remote_track_meta("remote-dup", "Updated Title", "Artist A", "Album X");
410        let id2 = queries::upsert_track(&db.conn, &meta2).unwrap();
411
412        // Should be the same row (dedup by remote_id).
413        assert_eq!(id1, id2, "same remote_id should resolve to same track row");
414
415        // Verify the metadata was updated.
416        let row = queries::get_track_row(&db.conn, id2)
417            .unwrap()
418            .expect("track should exist");
419        assert_eq!(row.title, "Updated Title");
420        assert_eq!(row.remote_id.as_deref(), Some("remote-dup"));
421
422        // Verify only one track exists.
423        let stats = queries::library_stats(&db.conn).unwrap();
424        assert_eq!(
425            stats.total_tracks, 1,
426            "should have exactly 1 track after dedup"
427        );
428    }
429}