mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
b2a5baa48d
* fix(library-db): name slow-write ops for macOS stall diagnosis (#1040) Replace generic op=misc labels on production library-db write paths with stable module.action names (sync_state.*, track.*, tombstone.*, cmd.*, …) so SLOW write logs pinpoint the call site. Document the naming convention on LibraryStore::with_conn. Diagnostic step for #1040 — no behaviour change. * docs: add CHANGELOG and credits for PR #1043 Library-db slow-write op naming diagnostic for issue #1040.
1053 lines
38 KiB
Rust
1053 lines
38 KiB
Rust
//! C3 — `DeltaSyncRunner` (spec §6.4 DS-0 … DS-9). Drives a targeted
|
||
//! re-fetch when the server reports new content since the last
|
||
//! successful sync. Compared to `InitialSyncRunner`:
|
||
//!
|
||
//! - Cheap probe first (DS-0 / DS-2) — short-circuits to zero further
|
||
//! requests on the happy path.
|
||
//! - Strategy choice from `capability_flags`: N1-delta when Navidrome
|
||
//! native bulk is available, otherwise S2-delta via
|
||
//! `getAlbumList2 type=newest + recent`. S1 (`search3` empty query)
|
||
//! doesn't carry a delta semantic so it's not used here.
|
||
//! - No artist/album index pass — DS-9 only re-stamps watermarks +
|
||
//! `last_delta_sync_at`. Browse acceleration tables stay in sync
|
||
//! incrementally via the initial pass and a future PR-3d hook.
|
||
//!
|
||
//! DS-5 canonical matcher and DS-7 starred delta are explicitly out
|
||
//! of scope for PR-3c (Phase H / follow-up).
|
||
|
||
use std::collections::HashSet;
|
||
use std::sync::atomic::Ordering;
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
|
||
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
|
||
use psysonic_integration::subsonic::SubsonicClient;
|
||
use serde_json::Value;
|
||
|
||
use super::backoff::{jitter_salt, with_jitter, Backoff};
|
||
use super::capability::{CapabilityFlags, NavidromeProbeCredentials};
|
||
use super::error::SyncError;
|
||
use super::mapping::{
|
||
merge_album_open_subsonic_track_raw, navidrome_song_to_track_row, subsonic_song_to_track_row,
|
||
};
|
||
use super::progress::{NoopProgress, Progress, ProgressEvent};
|
||
use super::strategy::IngestStrategy;
|
||
use super::tombstone::TombstoneReconciler;
|
||
use crate::repos::{SyncStateRepository, TrackRepository, TrackRow};
|
||
use crate::store::LibraryStore;
|
||
|
||
/// Default batch size for delta pages — same as initial sync; servers
|
||
/// already tolerate 500-row pages at scale.
|
||
const DEFAULT_BATCH_SIZE: u32 = 500;
|
||
|
||
/// Maximum attempts per page before propagating. Same as initial sync.
|
||
const MAX_ATTEMPTS_PER_BATCH: u32 = 5;
|
||
|
||
/// How many `getAlbumList2 type=newest + recent` pages the S2-delta
|
||
/// loop walks before stopping. 2× DEFAULT_BATCH_SIZE = 1000 most-recent
|
||
/// albums per type per pass — enough overlap on small/medium libs to
|
||
/// catch every change between polls.
|
||
const S2_DELTA_MAX_PAGES_PER_TYPE: u32 = 4;
|
||
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||
pub struct DeltaSyncReport {
|
||
pub strategy: Option<String>,
|
||
/// `true` when DS-2 short-circuited — server watermark matched
|
||
/// local; no tracks were touched.
|
||
pub up_to_date: bool,
|
||
/// `true` when DS-3 saw an active scan and deferred. Caller
|
||
/// re-runs the delta on the next tick.
|
||
pub deferred_scanning: bool,
|
||
/// Track upserts performed during DS-4.
|
||
pub changed_count: u32,
|
||
pub remapped_count: u32,
|
||
/// Tombstone chunk stats from DS-8 — `0` when the runner wasn't
|
||
/// configured with `with_tombstone_budget`.
|
||
pub tombstones_checked: u32,
|
||
pub tombstones_deleted: u32,
|
||
}
|
||
|
||
pub struct DeltaSyncRunner<'a> {
|
||
store: &'a LibraryStore,
|
||
subsonic: &'a SubsonicClient,
|
||
navidrome: Option<NavidromeProbeCredentials>,
|
||
server_id: String,
|
||
library_scope: String,
|
||
capability_flags: CapabilityFlags,
|
||
cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||
batch_size: u32,
|
||
sleep_enabled: bool,
|
||
/// DS-8 budget. `None` skips the tombstone chunk entirely; `Some(n)`
|
||
/// drives `TombstoneReconciler::reconcile_chunk(n)` after DS-4.
|
||
tombstone_budget: Option<u32>,
|
||
progress: Arc<dyn Progress + Send + Sync>,
|
||
}
|
||
|
||
impl<'a> DeltaSyncRunner<'a> {
|
||
pub fn new(
|
||
store: &'a LibraryStore,
|
||
subsonic: &'a SubsonicClient,
|
||
server_id: impl Into<String>,
|
||
library_scope: impl Into<String>,
|
||
capability_flags: CapabilityFlags,
|
||
) -> Self {
|
||
Self {
|
||
store,
|
||
subsonic,
|
||
navidrome: None,
|
||
server_id: server_id.into(),
|
||
library_scope: library_scope.into(),
|
||
capability_flags,
|
||
cancel: None,
|
||
batch_size: DEFAULT_BATCH_SIZE,
|
||
sleep_enabled: true,
|
||
tombstone_budget: None,
|
||
progress: Arc::new(NoopProgress),
|
||
}
|
||
}
|
||
|
||
pub fn with_navidrome_credentials(mut self, creds: NavidromeProbeCredentials) -> Self {
|
||
self.navidrome = Some(creds);
|
||
self
|
||
}
|
||
|
||
pub fn with_cancellation(mut self, flag: Arc<std::sync::atomic::AtomicBool>) -> Self {
|
||
self.cancel = Some(flag);
|
||
self
|
||
}
|
||
|
||
pub fn with_batch_size(mut self, n: u32) -> Self {
|
||
if n > 0 {
|
||
self.batch_size = n;
|
||
}
|
||
self
|
||
}
|
||
|
||
pub fn with_sleep_disabled(mut self) -> Self {
|
||
self.sleep_enabled = false;
|
||
self
|
||
}
|
||
|
||
/// DS-8 — run a `TombstoneReconciler::reconcile_chunk(budget)`
|
||
/// pass after DS-4 ingest. Caller (PR-3d scheduler) decides
|
||
/// budget based on §6.7 threshold detection and per-tick limits.
|
||
pub fn with_tombstone_budget(mut self, budget: u32) -> Self {
|
||
self.tombstone_budget = Some(budget);
|
||
self
|
||
}
|
||
|
||
pub fn with_progress(mut self, progress: Arc<dyn Progress + Send + Sync>) -> Self {
|
||
self.progress = progress;
|
||
self
|
||
}
|
||
|
||
/// DS-0 … DS-9. Returns a report describing what happened — caller
|
||
/// (PR-3d background scheduler) decides whether to re-tick on
|
||
/// `deferred_scanning`.
|
||
pub async fn run(&self) -> Result<DeltaSyncReport, SyncError> {
|
||
let sync_state = SyncStateRepository::new(self.store);
|
||
sync_state
|
||
.ensure(&self.server_id, &self.library_scope)
|
||
.map_err(SyncError::Storage)?;
|
||
|
||
let mut report = DeltaSyncReport::default();
|
||
|
||
// DS-0 / DS-1 / DS-2 / DS-3 — poll + watermark compare.
|
||
let probe = self.poll_for_change(&sync_state).await?;
|
||
report.deferred_scanning = probe.deferred_scanning;
|
||
if probe.deferred_scanning {
|
||
return Ok(report);
|
||
}
|
||
if probe.up_to_date {
|
||
report.up_to_date = true;
|
||
self.stamp_last_delta(&sync_state)?;
|
||
return Ok(report);
|
||
}
|
||
|
||
// DS-4 — targeted ingest. Strategy choice matches initial sync
|
||
// but S1 is skipped: `search3` doesn't carry a delta semantic.
|
||
let strategy = self.delta_strategy();
|
||
report.strategy = Some(strategy.as_tag().to_string());
|
||
self.progress.emit(ProgressEvent::PhaseChanged {
|
||
phase: format!("delta:{}", strategy.as_tag()),
|
||
});
|
||
match strategy {
|
||
IngestStrategy::N1 => self.run_n1_delta(&mut report).await?,
|
||
IngestStrategy::S2 => self.run_s2_delta(&mut report).await?,
|
||
IngestStrategy::S1 | IngestStrategy::S3 => {
|
||
return Err(SyncError::StrategyUnsupported {
|
||
strategy: strategy.as_tag(),
|
||
})
|
||
}
|
||
}
|
||
|
||
// DS-8 — optional tombstone chunk (PR-3d wiring). Runs after
|
||
// ingest so newly-arrived rows are already in `track` before
|
||
// we probe `getSong` for stale ids.
|
||
if let Some(budget) = self.tombstone_budget {
|
||
if budget > 0 {
|
||
let mut reconciler =
|
||
TombstoneReconciler::new(self.store, self.subsonic, &self.server_id);
|
||
if !self.sleep_enabled {
|
||
reconciler = reconciler.with_sleep_disabled();
|
||
}
|
||
if let Some(flag) = &self.cancel {
|
||
reconciler = reconciler.with_cancellation(Arc::clone(flag));
|
||
}
|
||
let stats = reconciler.reconcile_chunk(budget).await?;
|
||
report.tombstones_checked = stats.checked;
|
||
report.tombstones_deleted = stats.deleted;
|
||
self.progress.emit(ProgressEvent::Tombstoned {
|
||
deleted_count: stats.deleted,
|
||
checked_count: stats.checked,
|
||
});
|
||
}
|
||
}
|
||
|
||
// DS-9 — stamp watermarks.
|
||
if let Some(ms) = probe.next_artists_watermark {
|
||
sync_state
|
||
.set_artists_last_modified_ms(&self.server_id, &self.library_scope, ms)
|
||
.map_err(SyncError::Storage)?;
|
||
}
|
||
if let Some(iso) = probe.next_last_scan_iso.as_deref() {
|
||
sync_state
|
||
.set_server_last_scan_iso(
|
||
&self.server_id,
|
||
&self.library_scope,
|
||
Some(iso),
|
||
)
|
||
.map_err(SyncError::Storage)?;
|
||
}
|
||
self.stamp_last_delta(&sync_state)?;
|
||
|
||
self.progress.emit(ProgressEvent::Completed {
|
||
kind: "delta_sync".into(),
|
||
});
|
||
Ok(report)
|
||
}
|
||
|
||
// ── helpers ────────────────────────────────────────────────────────
|
||
|
||
fn check_cancellation(&self) -> Result<(), SyncError> {
|
||
if let Some(flag) = &self.cancel {
|
||
if flag.load(Ordering::SeqCst) {
|
||
return Err(SyncError::Cancelled);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn unstable_track_ids(&self) -> bool {
|
||
self.capability_flags
|
||
.contains(CapabilityFlags::UNSTABLE_TRACK_IDS)
|
||
}
|
||
|
||
fn library_scope_opt(&self) -> Option<&str> {
|
||
if self.library_scope.is_empty() {
|
||
None
|
||
} else {
|
||
Some(self.library_scope.as_str())
|
||
}
|
||
}
|
||
|
||
async fn sleep(&self, d: Duration) {
|
||
if self.sleep_enabled && !d.is_zero() {
|
||
tokio::time::sleep(d).await;
|
||
}
|
||
}
|
||
|
||
fn write_batch(&self, rows: &[TrackRow]) -> Result<(u32, u32), SyncError> {
|
||
let stats = TrackRepository::new(self.store)
|
||
.upsert_batch_with_remap(rows, self.unstable_track_ids())
|
||
.map_err(SyncError::Storage)?;
|
||
Ok((rows.len() as u32, stats.remapped.len() as u32))
|
||
}
|
||
|
||
fn delta_strategy(&self) -> IngestStrategy {
|
||
if self
|
||
.capability_flags
|
||
.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)
|
||
{
|
||
IngestStrategy::N1
|
||
} else {
|
||
// S1 has no delta semantic — fall through to album-crawl.
|
||
IngestStrategy::S2
|
||
}
|
||
}
|
||
|
||
fn stamp_last_delta(&self, sync_state: &SyncStateRepository<'_>) -> Result<(), SyncError> {
|
||
sync_state
|
||
.set_last_delta_sync_at(&self.server_id, &self.library_scope, now_unix_ms())
|
||
.map_err(SyncError::Storage)
|
||
}
|
||
|
||
fn local_track_updated_watermark(&self) -> Result<Option<i64>, SyncError> {
|
||
self.store
|
||
.with_conn("delta.local_track_watermark", |c| {
|
||
c.query_row(
|
||
"SELECT MAX(server_updated_at) FROM track \
|
||
WHERE server_id = ?1 AND deleted = 0",
|
||
rusqlite::params![self.server_id],
|
||
|row| row.get::<_, Option<i64>>(0),
|
||
)
|
||
})
|
||
.map_err(SyncError::Storage)
|
||
}
|
||
|
||
fn local_album_ids(&self) -> Result<HashSet<String>, SyncError> {
|
||
self.store
|
||
.with_conn("delta.local_album_ids", |c| {
|
||
let mut stmt = c.prepare(
|
||
"SELECT DISTINCT album_id FROM track \
|
||
WHERE server_id = ?1 AND deleted = 0 AND album_id IS NOT NULL",
|
||
)?;
|
||
let rows: rusqlite::Result<HashSet<String>> = stmt
|
||
.query_map(rusqlite::params![self.server_id], |r| {
|
||
r.get::<_, String>(0)
|
||
})?
|
||
.collect();
|
||
rows
|
||
})
|
||
.map_err(SyncError::Storage)
|
||
}
|
||
|
||
// ── DS-0 / DS-1 / DS-2 / DS-3 — poll + watermark compare ───────────
|
||
|
||
async fn poll_for_change(
|
||
&self,
|
||
sync_state: &SyncStateRepository<'_>,
|
||
) -> Result<DeltaPollOutcome, SyncError> {
|
||
let tier = sync_state
|
||
.get_library_tier(&self.server_id, &self.library_scope)
|
||
.map_err(SyncError::Storage)?
|
||
.unwrap_or_else(|| "unknown".to_string());
|
||
|
||
let mut outcome = DeltaPollOutcome::default();
|
||
|
||
let use_scan_status = tier == "huge"
|
||
&& self
|
||
.capability_flags
|
||
.contains(CapabilityFlags::SCAN_STATUS_AVAILABLE);
|
||
|
||
if use_scan_status {
|
||
let scan = self.subsonic.get_scan_status().await?;
|
||
// DS-3 — defer when a scan is in flight on the server.
|
||
if scan.scanning {
|
||
outcome.deferred_scanning = true;
|
||
return Ok(outcome);
|
||
}
|
||
// DS-2 — watermark match → short-circuit.
|
||
let stored = sync_state
|
||
.get_server_last_scan_iso(&self.server_id, &self.library_scope)
|
||
.map_err(SyncError::Storage)?;
|
||
if let (Some(stored), Some(live)) = (stored.as_deref(), scan.last_scan.as_deref()) {
|
||
if stored == live {
|
||
outcome.up_to_date = true;
|
||
return Ok(outcome);
|
||
}
|
||
}
|
||
outcome.next_last_scan_iso = scan.last_scan;
|
||
} else {
|
||
// Small/medium tier (or unknown): `getArtists` carries
|
||
// `lastModified` which is the watermark.
|
||
let scope = self.library_scope_opt();
|
||
let artists = self.subsonic.get_artists(scope).await?;
|
||
let stored = sync_state
|
||
.get_artists_last_modified_ms(&self.server_id, &self.library_scope)
|
||
.map_err(SyncError::Storage)?;
|
||
if let (Some(stored), Some(live)) = (stored, artists.last_modified_ms) {
|
||
if stored == live {
|
||
outcome.up_to_date = true;
|
||
return Ok(outcome);
|
||
}
|
||
}
|
||
outcome.next_artists_watermark = artists.last_modified_ms;
|
||
}
|
||
|
||
Ok(outcome)
|
||
}
|
||
|
||
// ── DS-4 N1-delta — Navidrome native /api/song _sort=updated_at ───
|
||
|
||
async fn run_n1_delta(&self, report: &mut DeltaSyncReport) -> Result<(), SyncError> {
|
||
let creds = self.navidrome.as_ref().ok_or_else(|| {
|
||
SyncError::Transport("n1-delta selected but no Navidrome credentials supplied".into())
|
||
})?;
|
||
let watermark = self.local_track_updated_watermark()?;
|
||
|
||
let mut offset: u32 = 0;
|
||
loop {
|
||
self.check_cancellation()?;
|
||
let end = offset.saturating_add(self.batch_size);
|
||
let response = retry_with_backoff(
|
||
self,
|
||
|| {
|
||
nd_list_songs_internal(
|
||
&creds.server_url,
|
||
&creds.bearer_token,
|
||
"updated_at",
|
||
"DESC",
|
||
offset,
|
||
end,
|
||
)
|
||
},
|
||
SyncError::Navidrome,
|
||
)
|
||
.await?;
|
||
|
||
let array = response.as_array().cloned().unwrap_or_default();
|
||
if array.is_empty() {
|
||
break;
|
||
}
|
||
|
||
let synced_at = now_unix_ms();
|
||
let mut rows: Vec<TrackRow> = Vec::with_capacity(array.len());
|
||
let mut crossed_watermark = false;
|
||
for v in &array {
|
||
if let Some(row) = navidrome_song_to_track_row(
|
||
&self.server_id,
|
||
v,
|
||
synced_at,
|
||
self.library_scope_opt(),
|
||
) {
|
||
if let (Some(watermark), Some(server_updated)) =
|
||
(watermark, row.server_updated_at)
|
||
{
|
||
if server_updated < watermark {
|
||
crossed_watermark = true;
|
||
continue;
|
||
}
|
||
}
|
||
rows.push(row);
|
||
}
|
||
}
|
||
|
||
if !rows.is_empty() {
|
||
let (changed, remapped) = self.write_batch(&rows)?;
|
||
report.changed_count = report.changed_count.saturating_add(changed);
|
||
report.remapped_count = report.remapped_count.saturating_add(remapped);
|
||
}
|
||
|
||
if crossed_watermark || (array.len() as u32) < self.batch_size {
|
||
break;
|
||
}
|
||
offset = end;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ── DS-4 S2-delta — getAlbumList2 newest + recent, getAlbum diff ──
|
||
|
||
async fn run_s2_delta(&self, report: &mut DeltaSyncReport) -> Result<(), SyncError> {
|
||
let scope = self.library_scope_opt();
|
||
let known_albums = self.local_album_ids()?;
|
||
let mut seen_albums: HashSet<String> = HashSet::new();
|
||
|
||
for list_type in ["newest", "recent"] {
|
||
let mut offset: u32 = 0;
|
||
for _ in 0..S2_DELTA_MAX_PAGES_PER_TYPE {
|
||
self.check_cancellation()?;
|
||
let page = retry_with_backoff(
|
||
self,
|
||
|| {
|
||
self.subsonic
|
||
.get_album_list2(list_type, self.batch_size, offset, scope)
|
||
},
|
||
SyncError::from,
|
||
)
|
||
.await?;
|
||
if page.is_empty() {
|
||
break;
|
||
}
|
||
let page_len = page.len() as u32;
|
||
|
||
for album_summary in page {
|
||
if !seen_albums.insert(album_summary.id.clone()) {
|
||
continue;
|
||
}
|
||
// S2-delta only fetches album bodies the local
|
||
// store doesn't already have. `recent` (`getAlbumList2
|
||
// type=recent`) returns play-time order, so a
|
||
// known album that just got played still skips
|
||
// the song-list re-fetch.
|
||
if known_albums.contains(&album_summary.id) {
|
||
continue;
|
||
}
|
||
self.check_cancellation()?;
|
||
let (album, raw_album) = retry_with_backoff(
|
||
self,
|
||
|| self.subsonic.get_album_with_raw(&album_summary.id),
|
||
SyncError::from,
|
||
)
|
||
.await?;
|
||
|
||
let synced_at = now_unix_ms();
|
||
let raw_songs = raw_album
|
||
.get("song")
|
||
.and_then(|s| s.as_array())
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
let mut rows: Vec<TrackRow> = Vec::with_capacity(album.song.len());
|
||
for (i, song) in album.song.iter().enumerate() {
|
||
let mut raw = raw_songs
|
||
.get(i)
|
||
.cloned()
|
||
.unwrap_or_else(|| serde_json::to_value(song).unwrap_or(Value::Null));
|
||
merge_album_open_subsonic_track_raw(&raw_album, &mut raw);
|
||
rows.push(subsonic_song_to_track_row(
|
||
&self.server_id,
|
||
song,
|
||
&raw,
|
||
synced_at,
|
||
self.library_scope_opt(),
|
||
));
|
||
}
|
||
if !rows.is_empty() {
|
||
let (changed, remapped) = self.write_batch(&rows)?;
|
||
report.changed_count = report.changed_count.saturating_add(changed);
|
||
report.remapped_count =
|
||
report.remapped_count.saturating_add(remapped);
|
||
}
|
||
}
|
||
|
||
if page_len < self.batch_size {
|
||
break;
|
||
}
|
||
offset = offset.saturating_add(self.batch_size);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Default)]
|
||
struct DeltaPollOutcome {
|
||
deferred_scanning: bool,
|
||
up_to_date: bool,
|
||
next_last_scan_iso: Option<String>,
|
||
next_artists_watermark: Option<i64>,
|
||
}
|
||
|
||
fn now_unix_ms() -> i64 {
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|d| d.as_millis().min(i64::MAX as u128) as i64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
async fn retry_with_backoff<'a, F, FFut, T, E>(
|
||
runner: &DeltaSyncRunner<'a>,
|
||
mut build: F,
|
||
map_err: impl Fn(E) -> SyncError,
|
||
) -> Result<T, SyncError>
|
||
where
|
||
F: FnMut() -> FFut,
|
||
FFut: std::future::Future<Output = Result<T, E>>,
|
||
{
|
||
let mut backoff = Backoff::default();
|
||
let mut attempt = 0u32;
|
||
loop {
|
||
runner.check_cancellation()?;
|
||
attempt += 1;
|
||
match build().await {
|
||
Ok(v) => return Ok(v),
|
||
Err(e) => {
|
||
let mapped = map_err(e);
|
||
if !is_retryable(&mapped) || attempt >= MAX_ATTEMPTS_PER_BATCH {
|
||
return Err(mapped);
|
||
}
|
||
let delay = backoff.next_delay();
|
||
let jittered = with_jitter(delay, jitter_salt(attempt));
|
||
runner.sleep(jittered).await;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn is_retryable(e: &SyncError) -> bool {
|
||
matches!(e, SyncError::Transport(_) | SyncError::Navidrome(_))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::repos::TrackRow;
|
||
use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials};
|
||
use serde_json::json;
|
||
use wiremock::matchers::{header, method as wm_method, path as wm_path, query_param};
|
||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||
|
||
fn test_subsonic(uri: &str) -> SubsonicClient {
|
||
SubsonicClient::with_static_credentials(
|
||
uri,
|
||
SubsonicCredentials::with_static("user", "tok", "salt"),
|
||
reqwest::Client::new(),
|
||
)
|
||
}
|
||
|
||
fn flags(bits: u32) -> CapabilityFlags {
|
||
CapabilityFlags::new(bits)
|
||
}
|
||
|
||
fn seed_track(store: &LibraryStore, id: &str, album_id: &str, server_updated_at: i64) {
|
||
TrackRepository::new(store)
|
||
.upsert_batch(&[TrackRow {
|
||
server_id: "s1".into(),
|
||
id: id.into(),
|
||
title: "seed".into(),
|
||
title_sort: None,
|
||
artist: None,
|
||
artist_id: None,
|
||
album: "A".into(),
|
||
album_id: Some(album_id.into()),
|
||
album_artist: None,
|
||
duration_sec: 240,
|
||
track_number: None,
|
||
disc_number: None,
|
||
year: None,
|
||
genre: None,
|
||
suffix: None,
|
||
bit_rate: None,
|
||
size_bytes: None,
|
||
cover_art_id: None,
|
||
starred_at: None,
|
||
user_rating: None,
|
||
play_count: None,
|
||
played_at: None,
|
||
server_path: None,
|
||
library_id: None,
|
||
isrc: None,
|
||
mbid_recording: None,
|
||
bpm: None,
|
||
replay_gain_track_db: None,
|
||
replay_gain_album_db: None,
|
||
content_hash: None,
|
||
server_updated_at: Some(server_updated_at),
|
||
server_created_at: None,
|
||
deleted: false,
|
||
synced_at: 1,
|
||
raw_json: "{}".into(),
|
||
}])
|
||
.unwrap();
|
||
}
|
||
|
||
// ── DS-2: getArtists watermark match → short-circuit ─────────────
|
||
|
||
#[tokio::test(flavor = "multi_thread")]
|
||
async fn ds2_short_circuits_when_artists_watermark_matches() {
|
||
let server = MockServer::start().await;
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getArtists.view"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"artists": {
|
||
"lastModified": 1_700_000_000_000_i64,
|
||
"ignoredArticles": "",
|
||
"index": []
|
||
}
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
|
||
let store = LibraryStore::open_in_memory();
|
||
let sync_state = SyncStateRepository::new(&store);
|
||
sync_state.ensure("s1", "").unwrap();
|
||
sync_state
|
||
.set_artists_last_modified_ms("s1", "", 1_700_000_000_000)
|
||
.unwrap();
|
||
|
||
let subsonic = test_subsonic(&server.uri());
|
||
let report = DeltaSyncRunner::new(
|
||
&store,
|
||
&subsonic,
|
||
"s1",
|
||
"",
|
||
flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK),
|
||
)
|
||
.with_sleep_disabled()
|
||
.run()
|
||
.await
|
||
.unwrap();
|
||
|
||
assert!(report.up_to_date);
|
||
assert_eq!(report.changed_count, 0);
|
||
assert!(!report.deferred_scanning);
|
||
}
|
||
|
||
// ── DS-3: huge-tier scan in progress → defer ─────────────────────
|
||
|
||
#[tokio::test(flavor = "multi_thread")]
|
||
async fn ds3_defers_when_getscanstatus_is_scanning() {
|
||
let server = MockServer::start().await;
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getScanStatus.view"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"scanStatus": { "scanning": true, "count": 10000 }
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
|
||
let store = LibraryStore::open_in_memory();
|
||
let sync_state = SyncStateRepository::new(&store);
|
||
sync_state.ensure("s1", "").unwrap();
|
||
sync_state.set_library_tier("s1", "", "huge").unwrap();
|
||
|
||
let subsonic = test_subsonic(&server.uri());
|
||
let report = DeltaSyncRunner::new(
|
||
&store,
|
||
&subsonic,
|
||
"s1",
|
||
"",
|
||
flags(CapabilityFlags::SCAN_STATUS_AVAILABLE),
|
||
)
|
||
.with_sleep_disabled()
|
||
.run()
|
||
.await
|
||
.unwrap();
|
||
|
||
assert!(report.deferred_scanning);
|
||
assert!(!report.up_to_date);
|
||
assert_eq!(report.changed_count, 0);
|
||
}
|
||
|
||
// ── DS-4 N1-delta crosses watermark and stops ────────────────────
|
||
|
||
#[tokio::test(flavor = "multi_thread")]
|
||
async fn n1_delta_stops_at_local_watermark() {
|
||
let server = MockServer::start().await;
|
||
// getArtists path: claim new lastModified to trigger DS-4.
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getArtists.view"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"artists": {
|
||
"lastModified": 1_716_840_000_000_i64,
|
||
"ignoredArticles": "",
|
||
"index": []
|
||
}
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
// /api/song _sort=updated_at _order=DESC: 3 fresh, then 2 stale
|
||
// (server_updated_at < watermark).
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/api/song"))
|
||
.and(query_param("_start", "0"))
|
||
.and(query_param("_sort", "updated_at"))
|
||
.and(query_param("_order", "DESC"))
|
||
.and(header("X-ND-Authorization", "Bearer nd-tok"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||
{ "id": "tr_n3", "title": "new3", "updatedAt": "2024-06-03T00:00:00Z" },
|
||
{ "id": "tr_n2", "title": "new2", "updatedAt": "2024-06-02T00:00:00Z" },
|
||
{ "id": "tr_n1", "title": "new1", "updatedAt": "2024-06-01T00:00:00Z" },
|
||
{ "id": "tr_old1", "title": "old", "updatedAt": "2024-01-01T00:00:00Z" },
|
||
{ "id": "tr_old2", "title": "old", "updatedAt": "2024-01-01T00:00:00Z" }
|
||
])))
|
||
.mount(&server)
|
||
.await;
|
||
|
||
let store = LibraryStore::open_in_memory();
|
||
// Seed a track with server_updated_at = 2024-05-01 — fresh3..1
|
||
// are newer (above watermark); old1/old2 are older (below).
|
||
seed_track(&store, "tr_old_seed", "al_x", parse_test_iso("2024-05-01"));
|
||
|
||
let nav = NavidromeProbeCredentials {
|
||
server_url: server.uri(),
|
||
bearer_token: "nd-tok".into(),
|
||
};
|
||
let subsonic = test_subsonic(&server.uri());
|
||
let report = DeltaSyncRunner::new(
|
||
&store,
|
||
&subsonic,
|
||
"s1",
|
||
"",
|
||
flags(CapabilityFlags::NAVIDROME_NATIVE_BULK),
|
||
)
|
||
.with_navidrome_credentials(nav)
|
||
.with_batch_size(10)
|
||
.with_sleep_disabled()
|
||
.run()
|
||
.await
|
||
.unwrap();
|
||
|
||
assert_eq!(report.changed_count, 3, "only the 3 fresh rows upserted");
|
||
assert_eq!(report.strategy.as_deref(), Some("n1"));
|
||
}
|
||
|
||
// ── DS-4 S2-delta only fetches unknown album ids ─────────────────
|
||
|
||
#[tokio::test(flavor = "multi_thread")]
|
||
async fn s2_delta_skips_known_album_ids() {
|
||
let server = MockServer::start().await;
|
||
// Watermark change: getArtists lastModified differs from stored
|
||
// (null) → falls through to DS-4.
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getArtists.view"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"artists": {
|
||
"lastModified": 1_716_840_000_000_i64,
|
||
"ignoredArticles": "",
|
||
"index": []
|
||
}
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
// getAlbumList2 type=newest page 0: two albums, one we already
|
||
// have locally and one fresh.
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getAlbumList2.view"))
|
||
.and(query_param("type", "newest"))
|
||
.and(query_param("offset", "0"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"albumList2": {
|
||
"album": [
|
||
{ "id": "al_known", "name": "Known" },
|
||
{ "id": "al_fresh", "name": "Fresh" }
|
||
]
|
||
}
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
// Empty pages after the first one for both types.
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getAlbumList2.view"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"albumList2": { "album": [] }
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
// getAlbum body for the fresh id only. If "al_known" is
|
||
// accidentally fetched, the test mock returns 404 by default
|
||
// and the runner errors out — that's the assertion.
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getAlbum.view"))
|
||
.and(query_param("id", "al_fresh"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"album": {
|
||
"id": "al_fresh",
|
||
"name": "Fresh",
|
||
"song": [
|
||
{ "id": "tr_new", "title": "Just landed", "duration": 240 }
|
||
]
|
||
}
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
|
||
let store = LibraryStore::open_in_memory();
|
||
seed_track(&store, "tr_existing", "al_known", 1_000);
|
||
|
||
let subsonic = test_subsonic(&server.uri());
|
||
let report = DeltaSyncRunner::new(
|
||
&store,
|
||
&subsonic,
|
||
"s1",
|
||
"",
|
||
flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK),
|
||
)
|
||
.with_batch_size(10)
|
||
.with_sleep_disabled()
|
||
.run()
|
||
.await
|
||
.unwrap();
|
||
|
||
assert_eq!(report.strategy.as_deref(), Some("s2"));
|
||
assert_eq!(report.changed_count, 1, "only the fresh album got upserted");
|
||
// The seed plus the new track land in the store.
|
||
let count: i64 = store
|
||
.with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0)))
|
||
.unwrap();
|
||
assert_eq!(count, 2);
|
||
}
|
||
|
||
// ── DS-9 watermarks land + last_delta_sync_at stamped ────────────
|
||
|
||
#[tokio::test(flavor = "multi_thread")]
|
||
async fn ds9_writes_watermarks_and_last_delta_timestamp() {
|
||
let server = MockServer::start().await;
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getArtists.view"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"artists": {
|
||
"lastModified": 1_716_840_000_000_i64,
|
||
"ignoredArticles": "",
|
||
"index": []
|
||
}
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getAlbumList2.view"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"albumList2": { "album": [] }
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
|
||
let store = LibraryStore::open_in_memory();
|
||
let subsonic = test_subsonic(&server.uri());
|
||
DeltaSyncRunner::new(
|
||
&store,
|
||
&subsonic,
|
||
"s1",
|
||
"",
|
||
flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK),
|
||
)
|
||
.with_sleep_disabled()
|
||
.run()
|
||
.await
|
||
.unwrap();
|
||
|
||
let sync_state = SyncStateRepository::new(&store);
|
||
assert_eq!(
|
||
sync_state
|
||
.get_artists_last_modified_ms("s1", "")
|
||
.unwrap(),
|
||
Some(1_716_840_000_000)
|
||
);
|
||
let (last_delta,): (Option<i64>,) = store
|
||
.with_conn("misc", |c| {
|
||
c.query_row(
|
||
"SELECT last_delta_sync_at FROM sync_state WHERE server_id='s1'",
|
||
[],
|
||
|r| Ok((r.get(0)?,)),
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert!(last_delta.unwrap_or(0) > 0);
|
||
}
|
||
|
||
// ── DS-8: tombstone wire runs after DS-4 ─────────────────────────
|
||
|
||
#[tokio::test(flavor = "multi_thread")]
|
||
async fn ds8_runs_tombstone_chunk_when_budget_set() {
|
||
let server = MockServer::start().await;
|
||
// Watermark change → DS-4 ingest path runs.
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getArtists.view"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"artists": {
|
||
"lastModified": 1_716_840_000_000_i64,
|
||
"ignoredArticles": "",
|
||
"index": []
|
||
}
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
// S2-delta: empty album list → no ingest, but DS-8 still runs.
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getAlbumList2.view"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"albumList2": { "album": [] }
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
// getSong probe — first id returns ok, second returns code 70.
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getSong.view"))
|
||
.and(query_param("id", "tr_alive"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "ok",
|
||
"song": { "id": "tr_alive", "title": "Alive" }
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
Mock::given(wm_method("GET"))
|
||
.and(wm_path("/rest/getSong.view"))
|
||
.and(query_param("id", "tr_gone"))
|
||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||
"subsonic-response": {
|
||
"status": "failed",
|
||
"error": { "code": 70, "message": "Song not found" }
|
||
}
|
||
})))
|
||
.mount(&server)
|
||
.await;
|
||
|
||
let store = LibraryStore::open_in_memory();
|
||
seed_track(&store, "tr_alive", "al_x", 1_000);
|
||
seed_track(&store, "tr_gone", "al_x", 1_000);
|
||
|
||
let subsonic = test_subsonic(&server.uri());
|
||
let report = DeltaSyncRunner::new(
|
||
&store,
|
||
&subsonic,
|
||
"s1",
|
||
"",
|
||
flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK),
|
||
)
|
||
.with_tombstone_budget(10)
|
||
.with_sleep_disabled()
|
||
.run()
|
||
.await
|
||
.unwrap();
|
||
|
||
assert_eq!(report.tombstones_checked, 2);
|
||
assert_eq!(report.tombstones_deleted, 1);
|
||
|
||
// tr_gone is now soft-deleted.
|
||
let gone_deleted: i64 = store
|
||
.with_conn("misc", |c| {
|
||
c.query_row(
|
||
"SELECT deleted FROM track WHERE id='tr_gone'",
|
||
[],
|
||
|r| r.get(0),
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert_eq!(gone_deleted, 1);
|
||
}
|
||
|
||
fn parse_test_iso(s: &str) -> i64 {
|
||
// Tiny helper for the seed track watermark — full date only,
|
||
// midnight UTC, ms epoch.
|
||
let mut parts = s.split('-');
|
||
let y: i64 = parts.next().unwrap().parse().unwrap();
|
||
let m: i64 = parts.next().unwrap().parse().unwrap();
|
||
let d: i64 = parts.next().unwrap().parse().unwrap();
|
||
let y2 = if m <= 2 { y - 1 } else { y };
|
||
let era = y2.div_euclid(400);
|
||
let yoe = y2 - era * 400;
|
||
let mm = if m > 2 { m - 3 } else { m + 9 };
|
||
let doy = (153 * mm + 2) / 5 + d - 1;
|
||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||
let days = era * 146_097 + doe - 719_468;
|
||
days * 86_400_000
|
||
}
|
||
}
|