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
24pub 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
41pub 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
57fn parse_iso8601_to_unix(s: &str) -> Option<i64> {
65 use chrono::{DateTime, FixedOffset, NaiveDateTime};
66
67 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
69 return Some(dt.timestamp());
70 }
71
72 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 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
92pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(id1, id2, "same remote_id should resolve to same track row");
414
415 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 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}