From 5bf2441ccf727729461d240472fcc35f626325e0 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 22 May 2026 01:33:09 +0300 Subject: [PATCH] feat(library): local library index and search (preview) (#846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(library): scaffold psysonic-library crate with v1 schema and store (#791) Adds a new workspace crate that will host the unified track store and the upcoming sync engine. PR-1a covers spec phases A1–A6: - migrations/001_initial.sql: full v1 schema — sync_state, track, album, artist, track_fts (+ ai/ad/au triggers), track_extension, track_offline, track_id_history, track_fact, track_artifact, canonical_track, canonical_identity, track_canonical_link, canonical_enrichment_link, and all §5.2 partial indexes. - store::LibraryStore: WAL + foreign_keys=ON SQLite connection rooted at app_data_dir/library.sqlite (distinct from the analysis cache, which uses app_config_dir). schema_migrations table + idempotent embedded migration runner; LIBRARY_DB_SCHEMA_VERSION = 1. - repos::TrackRepository::upsert_batch: 35-column transactional upsert with ON CONFLICT(server_id, id) all-fields rewrite; FTS rows follow via the triggers. - search::search_tracks: minimal bm25-ordered FTS5 helper scoped to a single server_id, filtering deleted rows. - filter::FilterFieldRegistry: static v1 registry (text, genre, year, starred = V1; bpm = SchemaV1UiLater; user_rating/suffix/bit_rate = Planned). Entity routing is a silent skip per §5.13.3. No Tauri commands, no frontend, no sync — those land in PR-2..PR-7. PR-1b will follow with the migration-runner edge-case tests, the initial_sync_cursor_json read/write API, and the breaking-migration hook stub (P22). * feat(library): A7 migration-runner safety net + initial-sync cursor API (PR-1b) (#792) * feat(library): wire migration-runner safety net and initial-sync cursor API PR-1b — Phase A7 infrastructure on top of PR-1a. Production behaviour is unchanged at v1 launch; everything here is plumbing that PR-3 will consume. - store::run_migrations_with: testable entry point that takes an explicit migration slice, a min-compatible-version threshold, and a breaking-bump hook. The prod `run_migrations` fixes those to MIGRATIONS, LIBRARY_DB_MIN_COMPATIBLE_VERSION, and the no-op stub. The slice is now sorted defensively before applying. - store::LIBRARY_DB_MIN_COMPATIBLE_VERSION: new public constant (currently equal to LIBRARY_DB_SCHEMA_VERSION). When a future release needs to invalidate v1 data, bumping this above the max applied version trips the hook on next open per spec §5.7 / P22. - store::MigrationOutcome (Applied | BreakingBump): crate-internal signal callers can branch on. PR-1b consumers ignore it; PR-3 / Settings will surface the "library rebuilt after update" toast when it surfaces. - store::handle_breaking_schema_bump: documented no-op stub. The drop + resync logic lands with the first real breaking bump. - repos::SyncStateRepository: ensure(server_id, scope) idempotently inserts a default row; get_initial_sync_cursor / set_initial_sync_cursor read and write sync_state.initial_sync_cursor_json via serde_json::Value. The set uses ON CONFLICT … DO UPDATE scoped to the cursor column only, so phase / poll-stats / tier survive cursor writes intact. - Tests cover: additive 002-style migration preserves prior data (spec §5.7 explicit integration test), runner sorts an unsorted source slice, breaking-bump hook fires when max applied < min_compatible, hook does not fire on a fresh DB, cursor round-trips a nested serde_json::Value, ON CONFLICT preserves sibling columns, library_scope separates rows per server. End-to-end "kill mid-500k-sync → resume same cursor" stays out of scope per the kickoff answer — it belongs to PR-3 / C2 where the InitialSyncRunner lives. * test(library): cover AC A3 — 500-row upsert_batch under perf budget * feat(library): Subsonic REST client for the sync engine (Phase B, PR-2) (#793) Phase B (B1-B9 per spec §10) — pure-Rust Subsonic client that the library-sync engine (PR-3) will drive. No Tauri commands, no events; the surface is added internally to psysonic-integration as a sibling of the existing navidrome native-REST module. - B1 — SubsonicClient + ping over /rest/{method}.view. Auth via the legacy salted-md5 token (spec v1.13+, advertised as 1.16.1). New SubsonicCredentials helper computes token = md5(password || salt) and ships a per-process unique salt nonce so back-to-back calls don't repeat. - B2 — get_scan_status → ScanStatus { scanning, count, folder_count, last_scan }. Lightweight poll for the Huge-tier path (§6.2.2). - B3 — get_album_list2(type, size, offset, musicFolderId?) + get_album(id). The two-call pattern the sync engine walks during initial ingest (§6.3). - B4 — search3(query, songCount, songOffset, musicFolderId?). Empty query → all songs paged (Navidrome quirk, spec §2.4). - B5 — get_indexes(musicFolderId?, ifModifiedSince?). Conditional fetch for file-tree fallback (S3 / §3.1). - B6 — get_song(id). Error code 70 maps to the dedicated SubsonicError::NotFound variant so the tombstone reconciler can match on the variant instead of parsing strings. - B8 — get_artists(musicFolderId?). ID3-path artist index; clients compare ArtistIndex.last_modified_ms against the local watermark to decide if a delta pass is needed (§2.2.1). - B9 — fingerprint_sample helper picks every-Nth track id for the server-fingerprint verify pass. Sampling is deterministic so reruns probe the same tracks. The verify-and-compare glue itself is library-side (PR-3 territory, deps on the store). Tests cover envelope parsing (status=ok/failed, code 70 → NotFound, missing body key), credentials (md5 vectors, salt uniqueness across 1k rapid calls, salt differs per from_password call), each endpoint end-to-end through wiremock with query-param matchers, OpenSubsonic forward-compat (unknown fields ignored on Song), and the trailing-slash base-URL normalisation. Cargo.toml — adds query + form + multipart to psysonic-integration's reqwest feature set. PR-2's client needs `query`; the other two were already used by existing navidrome::covers / remote::lastfm code and only worked via top-crate feature unification. Aligning the crate's own deps means `cargo test -p psysonic-integration` now compiles without depending on the workspace build. Out of scope: capability detection (C1 / PR-3), Navidrome native bulk path (uses existing psysonic-integration::navidrome::queries), fixtures harness expansion (G1). * feat(library): subsonic client follow-ups from PR-2 review (PR-2b) (#794) Picks up the three non-blocking items from cucadmuh's PR-793 review (handoffs/2026-05-19-pr-793-review.md) before PR-3 starts on top. - Fresh `(token, salt)` per request. `SubsonicClient` now caches the plaintext username + password and derives a new `SubsonicCredentials` inside `send()` for every endpoint call — matches the frontend's `subsonicClient.ts` `getAuthParams()` lifecycle and follows Subsonic replay-resistance guidance. Test path keeps a `with_static_credentials` constructor so wiremock matchers stay deterministic. New `build_credentials` (`pub(crate)`) routes the two modes. - `SUBSONIC_CLIENT_ID` now carries the crate version (`psysonic/`) — aligns with the frontend's `psysonic/${version}` so Navidrome log lines correlate across the WebView and Rust sync paths. - `Song.mbid_recording` gains the `musicBrainzId` serde alias (plus the schema-column spelling) so the OpenSubsonic field lands on the same hot column the §5.1 schema names. P13 strong-key matching can now key off it on ingest. - `get_song_with_raw` / `get_album_with_raw` return both the typed projection and the raw `serde_json::Value` body sub-tree. PR-3 ingest will write that raw value verbatim into `track.raw_json`, so OpenSubsonic extensions (`contributors`, `replayGain`, future fields) survive without manual field mirroring. Internal `parse_envelope_body` extracts the validation + body-key lookup once; `parse_envelope` and the new `parse_envelope_with_raw` share it. Tests cover: `from_password` produces unique salt/token across two back-to-back calls (direct + over-the-wire via wiremock `received_requests`), static mode returns the same triple, `c` query param starts with `psysonic/` and equals `SUBSONIC_CLIENT_ID`, `get_song_with_raw` preserves untyped fields (`replayGain`, `contributors`) in the raw value, `get_album_with_raw` keeps per-track extensions in `raw.song[i]`, error 70 still maps to `NotFound` on the raw variant, and `Song` deserializes `musicBrainzId` and `mbid_recording` interchangeably. B9 fingerprint-verify glue and the wider raw-ingest call sites stay with PR-3 / C2 as the review's §5 / §7 checklist directs. * feat(library): capability probe + sync_state accessors (Phase C1+C7, PR-3a) (#795) First sub-PR of Phase C (sync orchestrator). Lands the foundation that PR-3b's InitialSyncRunner consumes — pure plumbing, no runners or background tasks yet. - C1 capability probe. `psysonic_library::sync::CapabilityProbe::run` drives the §6.1 probe chain: Subsonic ping (captures `ServerInfo` envelope metadata for server-type / OpenSubsonic detection), then best-effort probes for search3 / getScanStatus / getIndexes, plus an optional Navidrome native bulk probe (caller passes `NavidromeProbeCredentials`). `CapabilityFlags(u32)` matches the §6.1.1 bitfield: NavidromeNativeBulk / SubsonicSearch3Bulk / ScanStatusAvailable / OpenSubsonic / UnstableTrackIds / FileTreeBrowse. - C7 sync_state accessors. `SyncStateRepository` gains get/set capability_flags, get/set sync_phase (idle / probing / initial_sync / ready / error), and column-scoped setters for server_last_scan_iso, indexes_last_modified_ms, artists_last_modified_ms, library_tier. Every setter uses `ON CONFLICT … DO UPDATE` scoped to its own column so concurrent watermark writes don't clobber each other. - Supporting additions in `psysonic-integration`: - `subsonic::SubsonicClient::server_info()` extracts `ServerInfo` from the ping envelope (server_type, server_version, api_version, open_subsonic). Re-uses `send()` so auth lifecycle is the same. - `navidrome::probe::native_bulk_available(url, token)` does the `GET /api/song?_start=0&_end=1` Bearer-auth probe. Returns Ok(true) on 2xx, Ok(false) on 4xx (auth ok but endpoint missing), Err on 5xx. Probe-only — full nd_list_songs port is PR-3b. - `psysonic-library/Cargo.toml` gains a `psysonic-integration` dependency (sync calls into Subsonic + Navidrome probes). DAG stays acyclic: integration does not depend on library. Per cucadmuh's PR-3 kickoff answer (handoff `2026-05-19-pr3-kickoff.md`): - Crate placement: option A — sync lives in `psysonic-library/src/sync/`, no new psysonic-sync crate. - N1 gate: probe is `/api/song?_start=0&_end=1` only; `nd_list_artists_by_role` is NOT required (Q3 answer + N1 ingest port lands in PR-3b). - UnstableTrackIds: set for Navidrome via `ServerInfo.server_type`, cleared for generic Subsonic. Tests added: 23 across library/sync, library/repos/sync_state, integration/subsonic, integration/navidrome/probe. Cover bitfield contains/insert/remove + spec bit values, probe across mixed-capability servers (full Navidrome, minimal Subsonic, broken endpoints), ping-failure short-circuit, optional Navidrome creds gating N1, sync_state column-scoped upserts (capability_flags / sync_phase / watermarks / library_tier), cross-column independence (capability writes don't reset cursor), ServerInfo extraction from ping envelope, Navidrome bulk probe across 2xx/4xx/5xx. * feat(library): InitialSyncRunner + C12 backoff + C13 id remap (Phase C2/C12/C13, PR-3b) (#796) Second sub-PR of Phase C — wires the actual ingest path on top of PR-3a's capability + sync_state foundation. Runner is pure async Rust: PR-3d will spawn it inside a tokio task and emit Tauri progress events on top. - C2 InitialSyncRunner. Drives spec §6.3 IS-1 → IS-6: probe-derived IngestStrategy (enum N1/S1/S2/S3, selector picks N1 → S1 → S2 chain per kickoff Q3), per-page upsert loop, cursor flush after every successful batch, IS-4 best-effort getArtists watermark, IS-5 getScanStatus.lastScan capture, IS-6 phase=ready + cursor cleared. Resume is automatic: a non-empty initial_sync_cursor_json restarts at the persisted offset; a strategy mismatch between cursor and capability flags surfaces as SyncError::CursorIncompatible. - C12 backoff. sync::backoff::Backoff implements the §6.8 schedule (2s → 4s → … cap 120s) with ±25% jitter via deterministic salt. retry_with_backoff wraps every endpoint call: transport / Navidrome failures retry up to MAX_ATTEMPTS_PER_BATCH (5), the cursor never advances on failure, success resets the counter. Cancellation AtomicBool is checked between attempts. - C13 id remap. TrackRepository::upsert_batch_with_remap performs the §6.9 detect-and-rebind pass inside the same SQLite transaction as the upsert: a content_hash or server_path collision on a different existing id triggers UPDATE of child tables (track_offline, track_extension, track_fact, track_artifact, track_canonical_link), INSERT INTO track_id_history, DELETE old track row. Off when UnstableTrackIds is clear (generic Subsonic). New TrackIdHistoryRepository read-side helper for forward lookups (analysis cache reuse, Phase E). - IngestStrategy enum + selector (sync::strategy) — N1 → S1 → S2; N1 requires Navidrome bearer credentials at runtime (skipped when None). S3 is enumerated for future file-tree fallback but returns StrategyUnsupported in v1 per kickoff Q3. - InitialSyncCursor (sync::cursor) — JSON-serialisable { strategy, phase, library_scope, ingested_count, strategy_state }. StrategyState tagged enum: LinearOffset { offset } for N1/S1, AlbumCrawl { album_offset, current_album_id } for S2. - mapping::subsonic_song_to_track_row + navidrome_song_to_track_row centralise the JSON → TrackRow projection. Subsonic path also reads replayGain.{trackGain,albumGain} from the raw value so PR-3b doesn't drop the columns that PR-2b reserves on TrackRow. - Supporting bits in psysonic-integration: - subsonic types now derive Serialize so the runner can round-trip a typed Song back into raw JSON when feeding upsert. - navidrome::queries gains nd_list_songs_internal — pure async function (no #[tauri::command] decorator) that the N1 ingest loop calls directly. The existing Tauri command wraps it. Tests added across sync::* and repos::track_id_history. Wiremock covers S1 happy-path, mid-cursor resume from a persisted offset, strategy mismatch → CursorIncompatible, 503 transient → retry-then- succeed, AtomicBool cancellation → Cancelled, N1 paginated /api/song ingest, S2 album crawl, and §6.9 remap firing under UnstableTrackIds during an actual sync. Backoff schedule + jitter formula pinned. TrackRepository remap path covered by content_hash collision, server_path collision, hash+path-missing skip, identity-noop, and remap-off compatibility with the existing upsert_batch contract. Also fixes cucadmuh's PR-3a review minor 1: drops the dead `mount_ok` scaffolding from sync::capability tests. Out of scope per kickoff Q2: - DeltaSyncRunner + tombstones → PR-3c - Background task lifecycle, cancellation wiring, progress emit throttle, adaptive scheduler, request budget, bandwidth lane → PR-3d - Tauri command surface for "sync now" / progress events → PR-5 * feat(library): search3 raw envelope fidelity for S1 ingest (PR-3b follow-up) (#797) Picks up cucadmuh's PR-3b review minor 1: the S1 path in InitialSyncRunner was reserialising the typed `Song` for `track.raw_json`, dropping unknown OpenSubsonic extensions (`replayGain`, `contributors`, …). N1 and S2 already carry the raw sub-tree verbatim through `nd_list_songs_internal` and `get_album_with_raw`; S1 now matches via the new `SubsonicClient::search3_with_raw` mirror of the PR-2b pattern. - subsonic::SubsonicClient::search3_with_raw — returns `(SearchResult, serde_json::Value)`; uses the existing `parse_envelope_with_raw` so error 70 / `Api { code, .. }` mapping stays consistent. - sync::initial::run_s1 now calls `search3_with_raw` and feeds the per-song raw sub-tree (`raw_body.song[i]`) into `subsonic_song_to_track_row` instead of a typed reserialise. Tests cover `search3_with_raw` round-trip on a payload with `replayGain` + `contributors` (verifies the raw value preserves both) and the empty-result case where the body is `searchResult3: {}`. Plus an end-to-end S1 ingest test that asserts the persisted `track.raw_json` column contains the OpenSubsonic extensions after a full runner pass, and that `replay_gain_track_db` / `_album_db` still land on the typed columns via the mapping helper. Full review: psysonic-workdocs/internal/collaboration/handoffs/2026-05-19-pr-796-review.md * feat(library): DeltaSyncRunner + TombstoneReconciler (Phase C3/C4, PR-3c) (#798) Third sub-PR of Phase C — drives targeted delta passes on top of PR-3a/b's foundation. Pure async; PR-3d will spawn it inside the background scheduler. - C3 DeltaSyncRunner. Walks spec §6.4 DS-0 … DS-9: - DS-0/1/2/3 cheap probe via `getArtists` (small/medium tier) or `getScanStatus` (huge tier when `ScanStatusAvailable`). Server watermark match → up_to_date short-circuit, scan-in-progress → deferred_scanning report; zero further requests in either case. - DS-4 targeted ingest. Strategy from capability_flags: N1-delta when NavidromeNativeBulk is set, otherwise S2-delta. S1 has no delta semantic so it's not used here. - N1-delta: GET /api/song _sort=updated_at _order=DESC, pages until rows fall under the local `MAX(server_updated_at)` watermark; out-of-band rows in the same page are dropped. - S2-delta: getAlbumList2 type=newest then type=recent, up to a small page cap; getAlbum is fetched only for album_ids the local store doesn't already have. Known albums are skipped so a play-bump under "recent" doesn't re-ingest the whole tracklist. - DS-6 id remap reuses TrackRepository::upsert_batch_with_remap. - DS-9 stamps next watermark (artists_last_modified_ms or server_last_scan_iso) + last_delta_sync_at. - DS-5 canonical matcher (Phase H) and DS-7 starred delta are out of scope for PR-3c. - C4 TombstoneReconciler. Caller-driven streaming: each `reconcile_chunk(budget)` picks the next `budget` ids ordered by synced_at ASC, calls getSong, marks deleted=1 on code 70, and refreshes synced_at on every checked id so the queue rotates. Mode A (manual integrity) loops until checked == 0; Mode B (auto-threshold) tests `should_auto_reconcile(local, server, pct)` per delta tick and runs a small budgeted chunk. Memory bounded — no full local-id list ever held in RAM. - SyncStateRepository: new getters for artists_last_modified_ms, server_last_scan_iso, library_tier; new set_last_delta_sync_at stamp helper. All column-scoped upserts preserve neighbouring fields. Tests cover DS-2 short-circuit (watermark match), DS-3 defer (scanning=true), N1-delta watermark cutoff (3 fresh + 2 stale rows → only 3 upserted), S2-delta known-album skip (mock 404 on al_known guards the assertion), DS-9 watermark + last_delta stamping, should_auto_reconcile threshold cases (gap, tolerance, server=0, local<=server), reconcile_chunk code-70 → deleted=1, budget + ordering (oldest first, newest untouched), empty-store noop, and cancellation. PR-3d (background task, probe→flags wiring, progress emit, adaptive scheduler, request budget, bandwidth throttle) lands next on the same integration branch. * feat(library): sync supervisor + progress channel + DS-8 wiring (Phase C5/C6, PR-3d1) (#799) First half of PR-3d (cucadmuh-approved split per kickoff Q2). Pure-Rust lifecycle + progress infrastructure on top of the runners from PR-3a/b/c. Tauri events stay in the top crate (PR-5); this PR only ships the channel the top crate will subscribe to. - C5 SyncSupervisor. Spawns a sync workload inside a tokio task, owns the cancellation AtomicBool, and exposes a single-consumer mpsc receiver for ProgressEvent. join() returns the inner Result<(), SyncError>; panics surface as Storage so callers never need to know about tokio internals. - C6 progress channel. New sync::progress module: - ProgressEvent enum — lean variants (PhaseChanged / IngestPage / Remapped / Tombstoned / Completed / Error). Server / scope context lives on the channel side (one supervisor = one scope). - Progress trait + NoopProgress default + ChannelProgress forwarding through tokio mpsc. Throttle is the simple last-emit-timestamp gate; terminal events (Completed / Error) bypass it. - InitialSyncRunner + DeltaSyncRunner gain with_progress(...) builders. IS-1 / IS-6 emit PhaseChanged + Completed; delta emits PhaseChanged at strategy pick, Tombstoned at DS-8, and Completed at DS-9. Defaults to NoopProgress so existing call sites keep working. - DS-8 wired. DeltaSyncRunner::with_tombstone_budget(n) drives TombstoneReconciler::reconcile_chunk(n) after DS-4 ingest; shares the runner's cancellation flag + sleep override. The DeltaSyncReport gains tombstones_checked / tombstones_deleted so callers can act on the counts. - capability::probe_and_persist helper. Chains CapabilityProbe::run with sync_state writes: sets phase to "probing" before the probe, persists capability_flags, then drops back to "idle". PR-3d2 (the scheduler) will call this in front of every initial / delta run so the stored flags reflect the live server. Tests cover: ChannelProgress throttle (zero-interval pass-through, terminal bypass, non-terminal collapse, sender alive after receiver drop), SyncSupervisor task completion + cancel + panic-as-Storage + receiver-take-once, probe_and_persist round-trip through SyncStateRepository (flags persisted, phase ends at "idle"), DS-8 reconcile-after-ingest landing tombstones on code 70 returns. PR-3d2 follows with the adaptive scheduler (C8), request budget (C9), poll EWMA (C10), and the bandwidth / queue priority lane (C11). * feat(library): adaptive scheduler + request budget + EWMA poll + bandwidth (Phase C8/C9/C10/C11, PR-3d2) (#800) Second half of PR-3d per cucadmuh's kickoff-Q2 split. Wraps the runners + supervisor from PR-3a/b/c/d1 into a tick-driven background scheduler. Top crate (PR-5) plumbs the timer. - C8 BackgroundScheduler. Tick-based — caller drives the interval, scheduler decides whether the tick should run. is_due(now_ms) checks sync_state.next_poll_at; tick(now_ms) either skips (not due / PrefetchActive pause), or runs a DeltaSyncRunner with the right budget + tombstone trigger, then stamps the next poll_at via the adaptive formula. No tokio task ownership — tests stay deterministic, PR-5 plugs spawn behaviour to taste. - C9 RequestBudget. PassKind enum (PollTick / DeltaLight / DeltaMismatch / InitialSync) with caps per spec §6.2.5 (1 / 50 / 200 / unlimited). RequestBudget::has_room(used) gates the runner; PR-3d2 ships the data type, runner enforcement of the cap is a future tightening (DeltaSyncRunner already has its own page cap so the soft cap mostly informs Settings). - C10 PollStats EWMA. New sync::poll_stats with PollStats (artist_count, ewma_bytes, ewma_duration_ms, library_tier), observe()/set_artist_count()/reclassify() helpers, the §6.2.2 tier table (<2k / 2k-15k / >15k or ewma_bytes >2MB), and next_interval_ms following the spec formula (base * load_factor * artist_factor, load_factor clamped [1, 10]). - C11 PlaybackHint + ParallelismBudget. PlaybackHint enum (Idle / Playing / PrefetchActive) resolved to a ParallelismBudget { max_concurrent, min_request_gap_ms }. PrefetchActive pauses bulk (`max_concurrent = 0`) per §6.2.4; the scheduler honours it via tick short-circuit. - Auto-tombstone wire. Before running the DeltaSyncRunner the scheduler tests `should_auto_reconcile(local, server, pct)` against the persisted counts; on threshold trip it sets `with_tombstone_budget(200)` (the §6.2.5 DeltaMismatch cap). - SyncStateRepository gains poll_stats_json get/set, next_poll_at get/set, local_track_count get/set, and server_track_count get/set — all column-scoped upserts. Tests: ~30 new across poll_stats / budget / bandwidth / scheduler. EWMA seed + smoothing, tier-classification edges (artist + size overrides), next-interval formula bounds (idle base, slow-network load_factor clamp), RequestBudget caps per pass, ParallelismBudget resolution, scheduler is_due (no schedule / future schedule), tick short-circuit (not due, PrefetchActive pause), tick runs delta and persists next_poll_at, auto-tombstone trigger above 5 % threshold, PollStats round-trip through SQLite. Together with PR-3d1 this finishes Phase C — Tauri command surface (D1-D4) lands with PR-5. * feat(library): read-only Tauri command surface (Phase D1 part 1, PR-5a) (#801) First sub-PR of Phase D per cucadmuh's kickoff Q1 split. Lands the LibraryRuntime Tauri State plus the 8 read-only library commands from spec §7.1. No SyncSupervisor spawn, no sync lifecycle commands, no credentials store — those land in PR-5b. - New psysonic_library::runtime::LibraryRuntime — Tauri State wrapping Arc. Top crate's lib.rs setup() now calls LibraryStore::init(app), wraps the result in the runtime, and app.manage's it. Mirrors the AnalysisCache wiring above it. - New psysonic_library::dto module — camelCase wire DTOs per src-tauri/CLAUDE.md: SyncStateDto, LibraryTrackDto (flat projection over the track hot columns + raw_json sub-tree), LibraryTracksEnvelope, TrackArtifactDto, TrackFactDto, OfflinePathDto, TrackRefDto. local_tracks_max_updated_ms helper surfaces the implicit N1-delta watermark on the SyncStateDto. - New psysonic_library::payload module — pure ProgressEvent → LibrarySyncProgressPayload mapper (the payload Tauri events carry once PR-5b plugs the supervisor's mpsc receiver into AppHandle::emit). Constants for the event names too. Unit-testable without Tauri runtime. - New psysonic_library::commands module with 8 #[tauri::command] handlers: - library_get_status — joins the sync_state row + the track-watermark MAX query into one SyncStateDto. - library_search — FTS5 via the existing search_tracks helper, paginated; hydrates hits to full LibraryTrackDto. - library_get_track — single SELECT through new TrackRepository::find_one. - library_get_tracks_batch — capped at 100 refs/call per spec, preserves caller-supplied order, drops unknowns silently. - library_get_tracks_by_album — ordered by disc/track/id via new TrackRepository::find_by_album. - library_get_artifact — flexible WHERE over track_artifact (artifact_kind required, source/format optional), latest fetched_at wins. - library_get_facts — fact_kinds filter optional; returns all rows for the (server_id, track_id) pair when none specified, sorted by fact_kind + fetched_at DESC. - library_get_offline_path — returns local_path with a `missing: true` flag when the row is absent. - TrackRepository gains find_one / find_batch / find_by_album with a shared row-to-TrackRow mapper. SQL constants pinned next to the existing UPSERT_SQL so a schema change touches one file. - src-tauri/src/lib.rs: LibraryStore::init in setup(), the eight command handlers added to invoke_handler!. Tests cover: DTO field-name camelCase (IPC contract guard), LibraryTrackDto round-trip through TrackRow, raw_json fallback to Value::Null on bad input, local_tracks_max_updated_ms ignores deleted rows, TrackRepository::find_one / find_batch / find_by_album ordering + unknown-ref drop, ProgressEvent mapper across all six variants + serialization keys camelCase. Library tests at 166; workspace stays green. Out of scope per kickoff Q1: - Mutating commands (library_sync_*, library_patch_*, library_put_*, library_purge_*, library_delete_*) → PR-5b - SyncSupervisor spawn + background scheduler tick loop + progress emit → PR-5b - library_sync_bind_session / clear_session credentials → PR-5b - TS wrappers + Settings UI + server-remove modal → PR-5c - library_advanced_search / library_search_cross_server SQL builders → PR-5d * feat(library): sync lifecycle + mutate + purge Tauri surface (Phase D1 part 2, PR-5b) (#802) Second sub-PR of Phase D per cucadmuh's kickoff Q1 split. Adds the mutating side of §7.1 plus the SyncSession credentials store, the PlaybackHint setter, the orchestrator that runs InitialSyncRunner / DeltaSyncRunner under a Tauri AppHandle and emits library:sync-progress and library:sync-idle events, and the top-crate scheduler tick task that sweeps every bound session through BackgroundScheduler::tick. - LibraryRuntime extended per kickoff Q2: sync_sessions HashMap, playback_hint cell, current_job (cancel handle + identity), and scheduler_cancel flag the tick task watches. Kickoff sketch said Mutex> — supervisor's join() consumes self, so holding it in the mutex would block library_sync_cancel behind the orchestrator's join; CurrentJob carries the Arc cancel + metadata instead, orchestrator task owns supervisor / receiver / join. - New commands (spec §7.1): - library_sync_bind_session — caches Subsonic creds in memory, tries navidrome_token once for bearer cache, runs probe_and_persist so capability_flags reflect the live server. - library_sync_clear_session — drops cached credentials. - library_set_playback_hint — JS pushes idle / playing / prefetch_active from existing audio listeners. - library_sync_start — dispatches InitialSyncRunner (mode='full') or DeltaSyncRunner (mode='delta', with auto-tombstone budget when local/server count gap exceeds threshold). Spawns runner + orchestrator task that drains the progress mpsc into library:sync-progress emits and emits library:sync-idle when the runner exits. - library_sync_cancel — trips the current job's cancel flag. - library_patch_track — sparse JSON patch (starredAt, userRating, playCount, playedAt) per §6.5. - library_put_artifact / library_put_fact — upserts with ON CONFLICT scoped to the PK so lyrics / BPM writes survive re-fetches. - library_purge_server — transactional DELETE across the v1 schema tables for this server_id. include_offline (default false) controls track_offline + bytes_freed. - library_delete_server_data — alias that always purges offline too (logout flow). - src-tauri/src/lib.rs setup() spawns a 30 s MissedTickBehavior::Skip task that snapshots bound sessions and drives BackgroundScheduler::tick(now_ms) for each. Honours runtime.scheduler_cancel + the current PlaybackHint. Background ticks stay silent (NoopProgress) — Tauri emit for the scheduler path lands when Settings (PR-5c) surfaces it. - psysonic-integration::navidrome re-exports navidrome_token so the bind_session command can drive the bearer cache without making the client module pub. Tests cover: LibraryRuntime session round-trip (set/get/clear scopes per server), playback_hint default + setter, snapshot returns clones so callers can mutate freely. Existing library tests stay green (171 → 171; new code paths under the Tauri command surface — devtools integration smoke is PR-5c's job). Out of scope per kickoff Q1: - src/library/ TS wrappers + Settings UI subsection + server-remove modal → PR-5c - library_advanced_search / library_search_cross_server SQL builders → PR-5d - Background-tick Tauri emit (NoopProgress today) → PR-5c - analysis_cache cross-purge in library_purge_server → PR-6 * feat(library): typed invoke wrappers + verify_integrity command (Phase D2 + part of D1, PR-5c) (#803) Frontend-facing slice of Phase D. Ships the typed src/api/library.ts wrapper layer that any Settings / browse code will import from, plus the manual-integrity backend command PR-5b's review §5 note 2 called out as missing. Scope cut from cucadmuh's PR-5 kickoff Q1 split: that proposal had PR-5c = D2 + D3 + D4 (wrappers + Settings subsection + server-remove modal). The Settings UI + server-remove + audio playback-hint wiring + authStore extensions + i18n strings turn into a thick frontend patch in their own right; landing them in one PR with the wrappers would mix Tauri-surface review with Settings UX review. The split: - PR-5c (this PR) — D2 wrappers + library_sync_verify_integrity. - PR-5c-ui (follow-up) — D3 Library Settings subsection, D4 server-remove modal contract, playback hint feed, authStore / i18n. Per kickoff exit clause ("Do not split 5c unless review size forces it"). Reviewable as a clean Tauri-surface vs UX boundary. - Backend: `library_sync_verify_integrity { serverId, libraryScope? }` command — same dispatch shape as `library_sync_start { mode:'delta' }` but always forces the full `DELTA_MISMATCH_CAP` tombstone budget regardless of the local/server count gap. Spec §6.7 Mode A user- initiated full reconcile bypasses the threshold check that governs background ticks. `library_sync_start` itself is refactored to delegate to a private `library_sync_start_inner(force_full_tombstone)` so both entry points share the runner-spawn + orchestrator + emit code. - Frontend `src/api/library.ts`: full typed wrapper layer over the 19 `library_*` Tauri commands. DTO mirrors carry the camelCase wire shape (`SyncStateDto`, `LibraryTrackDto`, `TrackArtifactDto`, `TrackFactDto`, `OfflinePathDto`, `PurgeReportDto`, `SyncJobDto`, `TrackRefDto`, `ArtifactInputDto`, `FactInputDto`). Plus the `LibrarySyncProgressPayload` / `LibrarySyncIdlePayload` interfaces and `subscribeLibrarySyncProgress` / `subscribeLibrarySyncIdle` helpers that wrap `@tauri-apps/api/event` listen. PlaybackHint literal type lives here too (`'idle' | 'playing' | 'prefetch_active'`) so the audio listeners in PR-5c-ui can import a single source of truth. - `src-tauri/src/lib.rs` adds the new verify_integrity handler to the `invoke_handler!` aggregate. Tests: library tests stay at 171 — verify_integrity is exercised through the existing `sync_start_inner` paths; the wrapper layer is trivial passthrough that TypeScript types already check. Vitest coverage for the typed wrappers belongs with PR-5c-ui where there are real consumers (LibraryTab) to drive integration tests. PR-5c-ui (next) lands: - Library Settings subsection (§7.3 minus advanced toggles) - ServerRemoveModal extension (keep vs delete local index per §5.6) - authStore: libraryIndexEnabledByServer + auto-reconcile toggle - src/store/audioListenerSetup audio:playing / ended / setDeferHotCachePrefetch → library_set_playback_hint - i18n keys for the new strings * feat(library): Settings library index UI + playback hint + purge-on-remove (Phase D3/D4, PR-5c-ui) (#804) * feat(library): Settings library index UI + playback hint + purge-on-remove (Phase D3/D4, PR-5c-ui) Frontend half of Phase D, on top of PR-5c's typed wrappers. Wires the Settings → Library subsection (§7.3), the audio playback-hint feed (§6.2.4), and the server-remove keep-vs-delete choice (§5.6). - New libraryIndexStore (Zustand, persisted) — per-server enable flag + auto-reconcile toggle. Kept out of authStore so the index feature evolves independently and the persisted blob stays small. - New LibraryIndexSection in Settings → Library: - Per-server "Enable local library index" toggle → binds / clears the Rust sync session with the active server's credentials. Off by default (P6). - Read-only status (Idle / Checking / Initial sync / Ready (n) / Error) polled from library_get_status every 3 s, overlaid with live library:sync-progress events. - Sync now / Verify integrity / Cancel buttons. Verify runs one §6.7 pass (budget 200) per click; the status line shows the checked/removed counts so large libraries can be continued with another click (auto-resume loop is a follow-up). - Auto-reconcile toggle. - Subscribes to library:sync-progress + library:sync-idle for the active server; errors surface as a toast. - Audio playback hint: handleAudioPlaying → 'playing', handleAudioEnded → 'idle' via notifyLibraryPlaybackHint, which gates on the per-server index toggle + dedupes repeated hints so the IPC boundary isn't spammed on every progress tick. - ServersTab delete flow: when a server with an enabled index is removed, a second confirm offers keep-vs-delete of the local library cache (OK = library_delete_server_data, Cancel = retain for offline). Always clears the sync session. - i18n: en + de keys for the new strings; other locales fall back to en via i18next (later sweep). Per PR-803 review §5: verify-integrity resume UX is one-pass-per-click with a visible counter; sync_start idempotency (replaces in-flight) is surfaced via the Cancel button appearing while busy. Out of scope: - VirtualSongList / playerStore local-mode consumers → PR-7 (F1/F3/F5) - library_advanced_search / cross-server UI → PR-5d + PR-7 F2 - Auto-resume loop for very large verify-integrity runs → follow-up - Search-all-servers + threshold input (advanced §7.3) → later * fix(library): normalize server base URL before bind probe The bind toggle threw "subsonic transport: builder error | relative URL without a base" — `server.url` is stored bare (e.g. `nas.example.com`) and reqwest needs a scheme. Two-sided fix: - Frontend: LibraryIndexSection passes `authStore.getBaseUrl()` (adds http:// + strips trailing slash) instead of the raw `server.url`, matching the existing `subsonic.ts` convention. - Backend: `library_sync_bind_session` normalizes the incoming `base_url` defensively so the stored session + every downstream caller (sync_start, scheduler tick, navidrome_token) gets a scheme-qualified URL regardless of what the WebView sends. Tests: normalize_base_url covers bare host, trailing slash, existing http/https scheme, and whitespace. * fix(library): re-bind sync session on startup + server switch "Library sync failed: no bound session" — the per-server index toggle persists in localStorage but the Rust sync session (credentials + bearer) lives in process memory and is gone after an app restart, so the toggle showed "on" while no session existed. Per PR-5 kickoff Q5 ("on server connect if index already on"). - New `ensureActiveServerSessionBound()` helper: re-binds the active server's session when its index toggle is enabled. Best-effort — silent on failure (Settings surfaces the real error on explicit toggle). - MainApp re-binds on every `activeServerId` change (covers app startup + server switch — `setActiveServer` drives the effect). - LibraryIndexSection re-binds on mount before the first status poll, so Sync now / Verify integrity work immediately even when the toggle was already on from a previous run. * fix(library): trigger initial full sync on first enable (PR-804 review §5.1) cucadmuh's PR-804 review flagged this as release-blocking: the toggle only bound the session and «Sync now» / the background tick ran delta-only, so a fresh enable left the index empty — delta can't populate a never-synced library. - On first enable, after bind, fetch status and dispatch `library_sync_start { mode: 'full' }` when `lastFullSyncAt` is null (matches spec §6.2 "initial sync always background"). - «Sync now» now picks mode adaptively: `full` until a full sync has completed, `delta` afterward — so the button works both for the initial population and incremental updates. Other PR-804 review notes (auto-reconcile toggle → backend wiring, prefetch_active hint, clear-old-session-on-switch) stay as documented non-blocking follow-ups. * feat(library): advanced search + cross-server SQL builders (Phase D-search, PR-5d) (#806) * feat(library): advanced search + cross-server SQL builders + commands (Phase D-search, PR-5d) - FilterFieldRegistry SQL resolution: SqlFragment, compare_fragment, validate_for_entity (§5.13.5) - Advanced Search builder: per-entity track/album/artist queries; genre (case-insensitive), year, starred, bpm filters; bpm dual-storage resolution (§5.13.4); libraryScope; sort allowlist; full-match totals - Cross-server FTS union (§5.5B / §5.9 A') with canonical-id dedup - library_advanced_search + library_search_cross_server commands, registered in the shell * feat(library): typed advanced search / cross-server invoke wrappers (PR-5d) Mirror request/response DTOs and add libraryAdvancedSearch / librarySearchCrossServer in src/api/library.ts. UI parity (AdvancedSearch.tsx) stays PR-7. * fix(library): self-heal stale/unreadable initial-sync cursor instead of bricking (#807) The initial-sync cursor records the ingest strategy it was created under. When a re-probe later selects a different strategy (e.g. the Navidrome native bearer is briefly unavailable, downgrading N1->S2), the cursor guard returned a hard error — and since nothing clears the cursor, every later full sync failed with no recovery path. Reset the stale (or unreadable) cursor and start fresh under the selected strategy instead of erroring. Re-ingest is idempotent (upsert); the tombstone pass reconciles leftovers. * fix(library): emit per-batch progress during initial sync (#808) The initial-sync runner only emitted PhaseChanged (start) and Completed (end), so the Settings status sat at "initial_sync" with no count for the entire ingest — looking stuck on large libraries even while rows landed. Emit IngestPage per batch from the N1/S1/S2 loops with the running ingested total; the existing <=2 Hz throttle paces it. The frontend already renders the count from these events. * feat(library): Advanced Search reads the local index when ready (Phase F2, PR-7a) (#811) When the active server's index is fully synced, Advanced Search serves query / genre / year / result-type from library_advanced_search (instant + offline) and pages songs locally. On not-ready or any failure it falls back to the existing network path unchanged (spec 5.13.6). Results map from each entity's stored Subsonic rawJson, with the flat hot columns as a fallback. * feat(library): canonical matcher — link tracks by ISRC/MBID on ingest (Phase H1/H2, PR-4a) (#812) Adds the strong-key cross-server matcher (spec §5.5A): on every track upsert, link (server_id, track_id) to a canonical id derived from its ISRC (preferred) or MBID recording. Deterministic id (`{kind}:{value}`) keeps it O(1) and idempotent — no lookup-then-create race, no fuzzy loop on the bulk path. Tracks without a strong key stay standalone (fuzzy/search-time matching is H3). * feat(library): cross-server fuzzy fallback in search (Phase H3, PR-4b) (#813) library_search_cross_server now returns a `fuzzy` list alongside the exact FTS `hits` (spec §5.9): per-server `title LIKE %query%` for matches the exact pass missed (diacritics, partial words), capped per server, excluding exact hits and deduped by canonical id against them. Shared `like_contains` moved to the `search` module. * feat(library): FactRepository with TTL + provenance rules (Phase E4, PR-6a) (#814) Typed CRUD over track_fact behind library_get_facts / library_put_fact (spec §5.12): get lazily deletes the track's expired facts then returns the survivors (no background GC, P34); a `user` bpm fact also writes the hot track.bpm column so the override wins and survives a resync (R6-3.4). The commands now delegate here instead of inlining the SQL. * feat(library): ArtifactRepository with TTL + 512KB cap (Phase E4, PR-6b) (#815) * fix(integration): decode OpenSubsonic isrc string-array on Song (#818) OpenSubsonic types `isrc` as `string[]`; Navidrome 0.61.2 ships it as `isrc: []` or `["USRC…"]`. The typed `Song.isrc: Option` could not decode either form, which broke the S1 (`search3`) and S2 (`getAlbum`) ingest paths on real Navidrome libraries — initial sync could not complete past the first array-valued track. Add a tolerant `de_string_or_seq` deserializer: plain string → `Some`, non-empty array → first usable value (string element, or an object element's `name` for the `[{ "name": … }]` shape), `[]`/null → `None`. The full multi-value set still survives verbatim in `track.raw_json` (ADR-7). Applied to `Song.isrc`. Per maintainer policy R7-15 (workdocs question 2026-05-20-large-library-ingest-client-only, checklist item 1): treat Navidrome as a black box, harden the client decode. Tests cover `isrc: []` → None, populated array, and the legacy single-string form. * feat(library): large-library ingest strategy — S1 over N1 (R7-15) (#819) Per maintainer policy R7-15 (large-library ingest, client-only): very large Navidrome catalogs must not start initial sync on N1 — its native `/api/song` returns HTTP 500 beyond a deep offset and can never finish. S1 (`search3`) does not hit that wall. - Add `IngestStrategy::select_initial_strategy(flags, server_track_count, n1_bulk_unreliable)`. Large libraries (count > LARGE_LIBRARY_THRESHOLD, default 40_000) or servers flagged `n1_bulk_unreliable` route to S1 — or S2 when search3 bulk is absent. Normal-size libraries keep the cheapest N1 → S1 → S2 chain unchanged. - Persist the learned per-server `n1_bulk_unreliable` flag on `sync_state` (additive migration 002, DEFAULT 0). The mid-run N1→S1 fallback that sets it lands in a follow-up. - Capture `getScanStatus.count` in the capability probe and persist it as the `server_track_count` watermark, so the threshold applies from the first sync rather than only after N1 hits the wall once. A count-less probe never clobbers a watermark from a prior run. - The initial-sync runner now selects via the new policy. Tests: selector table (all branches incl. threshold boundary and the search3-absent fallback), repo flag roundtrip, probe count capture + watermark-preservation, migration head-version bookkeeping. * feat(library): freeze ingest strategy on resume (R7-15 Q3) (#820) A persisted initial-sync cursor that has already made progress must resume under its own strategy and ignore what a fresh capability probe would now pick. Previously any strategy mismatch reset the cursor to a fresh one — so a flapping Navidrome bearer (N1 flag toggling between probes) restarted ingest from offset 0 on every launch, which is why large initial syncs never completed across restarts. `load_or_init_cursor` now: - resumes the cursor's strategy when it has progress (`ingested_count > 0` or `phase != Ingest`), regardless of the re-selected strategy; - adopts the freshly-selected strategy only when there is no resumable progress (offset 0), where re-selecting costs nothing; - still resets a corrupt/unreadable cursor rather than hard-erroring. One guarded exception: a cursor still on N1 after the server was learned `n1_bulk_unreliable` is known-broken and re-selects onto the non-N1 path instead of resuming a wall-bound N1 loop (the mid-run N1→S1 fallback that preserves progress lands next). Tests: resume-with-progress freezes strategy and keeps the count; no-progress cursor adopts the re-selected strategy; known-broken N1 cursor re-selects; unreadable cursor still resets. * feat(library): one-way N1→S1 fallback on deep-offset 500 (R7-15 Q5) (#821) When the N1 ingest loop hits a persistent HTTP 500 at or beyond the deep-offset safety line (`N1_DEEP_OFFSET_SAFE`, 50_000) it now treats it as Navidrome's server-side deep-offset wall rather than a transient error: it learns `n1_bulk_unreliable` for the server and finishes the sync on S1. - `run_n1` catches the wall after retry exhaustion (`n1_hit_deep_offset_wall`: HTTP 500 AND offset >= the safety line) and hands off to `fall_back_n1_to_s1`. A 500 below the line stays a propagated error — no silent downgrade. - The fallback flags the server, then restarts S1 from offset 0. N1 (`id ASC`) and S1 (`search3` default order) don't share an offset space, so resuming from the N1 offset would skip songs; re-ingest is idempotent (PK upsert), duplicate work over the rows N1 already wrote is acceptable for v1. The cursor is rewritten in place, never zeroed. - One-way only: S1 never flips back to N1 mid-run. Combined with the persisted flag and the resume freeze, a future sync selects S1 directly. - `N1_DEEP_OFFSET_SAFE` is overridable on the runner so the fallback is testable without 50k rows of fixture data. Tests: deep-offset 500 falls back to S1, ingests the full set without duplicating N1's rows, and persists the flag; a shallow 500 propagates and does not flag the server. * feat(library): cache + retry Navidrome bearer, keep N1 flag on transient loss (R7-15 Q3) (#822) A flaky `/auth/login` previously stripped N1 for a whole bind: the bearer was fetched once, best-effort, and a single miss dropped to Subsonic-only. Per R7-15 Q3 a transient `navidrome_token` failure must not drop the `NavidromeNativeBulk` capability. - `bind_session` fetches the bearer with `navidrome_token_with_retry` (3 attempts, short backoff); if it still fails, it keeps the bearer cached from a prior bind instead of overwriting it with `None`. The token / credentials are never logged. - `probe_and_persist` preserves a previously-learned `NavidromeNativeBulk` flag when it probes without a token — the server still supports `/api/song`; only the bearer is missing this bind. The capability is a stable server property, so a token-less probe must not clear it. - `library_sync_start_inner` masks `NavidromeNativeBulk` from *this run's* strategy selection when the session has no token, so the run proceeds Subsonic-only (S1/S2) instead of selecting N1 with no creds. The persisted capability stays intact for a later bind that recovers the token. The in-flight cursor is already protected by the resume freeze. Tests: token retry yields the token on success and `None` after exhausting attempts; the probe keeps a learned N1 flag across a token-less re-probe. * feat(library): mid-run S1→S2 fallback on persistent S1 failure (R7-15 Q8) (#823) The N1→S1 fallback (#821) had no analogue when S1 itself fails on a server. Per R7-15 Q8, a persistent S1 failure (C12 retries already exhausted) must fall back to the universal S2 album crawl — no new artist-walk strategy. - `run_s1` catches a persistent fetch failure from the `search3` retry loop (`is_fetch_failure`: transport / HTTP / decode / Subsonic API / not-found) and hands off to `fall_back_s1_to_s2`. Cancellation and storage errors propagate untouched. - The fallback restarts S2 from scratch. S1 (`search3` order) and S2 (album-list order) don't share an offset space, so resuming from the S1 offset would skip songs; re-ingest is idempotent (PK upsert). The cursor is rewritten in place, never zeroed — the resume freeze then keeps the run on S2 across restarts. This completes the ingest fallback chain N1→S1→S2 from the §6.3 strategy order; the start-time "no search3 → S2" selection was already covered (#819). Tests: a persistent S1 500 falls back to S2 and the album crawl ingests the track. * fix(library): resume interrupted initial sync on startup (#824) * fix(library): resume interrupted initial sync on startup An initial sync killed mid-run (app restart) sat at `idle` until the user clicked «Sync now» — the background scheduler is delta-only and the auto-full-sync only fired on the index toggle, not on the startup re-bind. `resumeInitialSyncIfIncomplete` runs after the active server's session is re-bound (startup + server switch): if no full sync has completed yet (`!lastFullSyncAt`) it dispatches `library_sync_start { mode: 'full' }`, which resumes from the persisted cursor instead of restarting from zero. Once a full sync has landed it is a no-op, so delta stays the scheduler's job. Best-effort — errors stay silent (Settings surfaces them on explicit action). Tests: starts a full sync when none has completed, no-ops once a full sync has landed, stays silent when the status lookup fails. * fix(library): silence cancelled-sync toast, de-dupe startup resume Two rough edges from the startup resume: - A cancelled sync surfaced as «Library sync failed: sync cancelled». The orchestrator emitted the runner's `Cancelled` result as an error on the sync-idle event. Cancellation is expected — the user cancelled, or a newer `library_sync_start` superseded the job (server switch / startup resume) — and is documented as silent. `sync_outcome_to_result` now maps `SyncError::Cancelled` to a clean idle, only real errors toast. - `resumeInitialSyncIfIncomplete` is now de-duped per server. React StrictMode fires the startup effect twice, so a second `library_sync_start` cancelled the first (`set_current_job` is cancel-and-replace) — harmless with the fix above, but the dedupe avoids the wasted job + probe entirely. Tests: `sync_outcome_to_result` keeps `Cancelled` silent and forwards real errors; concurrent resume calls start a single full sync. * fix(library): run DB read commands off the main thread (async) (#825) The 10 library read commands were synchronous (`pub fn`). Per the Tauri v2 docs, commands without `async` run on the main thread — so a read that blocks freezes the UI. During an initial sync the runner holds the single `Mutex` for a whole batch write (500 rows × per-row remap on Navidrome + upsert + FTS, one transaction), and the Settings library section polls `library_get_status` on an interval. Each batch write blocked that polled read on the main thread → the window greyed out until the batch finished, with the freeze growing as the DB grew. Make the DB-touching read commands `async` so they run off the main thread: `library_get_status`, `library_search`, `library_get_track`, `library_get_tracks_batch`, `library_get_tracks_by_album`, `library_get_artifact`, `library_get_facts`, `library_get_offline_path`, `library_advanced_search`, `library_search_cross_server`. Reads still serialize behind the writer (the single connection is intentional — the schema mirrors `analysis_cache`, spec §5.1), but the wait no longer blocks the UI. State-only commands stay sync. Invoke names / payloads are unchanged, so the frontend is unaffected. Spec §15 R7-15 follow-up — surfaced in live QA on a 170k library. * feat(library): scope analysis cache by server_id (E1, schema only) (#826) Add a versioned migration to audio-analysis.sqlite so waveform/loudness rows are keyed per server. This is the schema-only step (PR-6c-1): every existing row migrates to server_id='' and behaviour is unchanged. The server_id write/read wiring, legacy fallback and lazy re-tag follow in 6c-2. - migrations 001 (baseline = the pre-versioning schema) + 002 (rebuild the three tables with server_id; PK (server_id, track_id, md5_16kb), loudness + target_lufs) - versioned runner mirroring the library store; each migration commits its schema change and version marker in one transaction, so a failure or crash rolls the whole migration back and retries cleanly - VACUUM INTO snapshot before the table rewrite as a safety net beyond the transaction (disk-full at COMMIT, FS corruption) - TrackKey gains server_id; all callers pass "" for now * feat(library): analysis cache server_id wiring (E1, 6c-2) (#827) * feat(library): scope analysis cache writes/reads/deletes by server_id (E1 wiring) Build on the 6c-1 schema migration: thread the playback server scope (playbackServerId ?? activeServerId) through the analysis cache so a server switch can no longer surface another server's waveform/loudness for the same bare track_id. - Write: seed_from_bytes_* and the CPU-seed / HTTP-backfill queues carry a server_id; every audio write path (in-memory, ranged, legacy stream, local file, spill, preload), the syncfs offline/hot caches, and the backfill command write under the playback server (empty = legacy ''). - Read: get_latest_*_for_track and the exact-key lookup try the server scope first, then fall back to the legacy '' rows; a legacy hit is re-tagged onto the server scope (INSERT OR IGNORE, never clobbers a precise row). No bulk backfill — existing caches re-tag lazily on play, so they are not re-analysed wholesale. - The backend gain-resolution path (loudness normalization, replay-gain updates, device resume) is scoped via a pinned current_playback_server_id on the audio engine, so normalization keeps working for server-scoped rows. - Delete: delete_*_for_track_id scope to (server + legacy ''); reseed on one server no longer wipes another server's analysis. delete_all_waveforms stays global (Settings -> Storage). Tauri boundary: analysis_get_waveform(_for_track), analysis_get_loudness_for_track, analysis_delete_waveform/loudness_for_track and analysis_enqueue_seed_from_url gain an optional serverId; audio_play and audio_preload gain an optional serverId. All additive (absent = legacy ''). * feat(library): pass playback serverId to analysis IPC (E1 wiring) Send getPlaybackServerId() (queueServerId ?? activeServerId) with every analysis-cache call so reads/writes/deletes scope to the right server: - audio_play / audio_preload (playTrack, resume, queue-undo restore, gapless byte-preload) - analysis_get_waveform_for_track / analysis_get_loudness_for_track (waveform + loudness refresh) - analysis_delete_waveform/loudness_for_track + analysis_enqueue_seed_from_url (reseed + loudness backfill) Absent serverId stays backward-compatible (legacy '' scope). * feat(library): content_hash from playback (E2, 6d) (#828) * feat(library): record playback content_hash into the track store (E2) Bridge the playback-derived md5_16kb into library `track.content_hash` (R7-16 Q4) so id-remap can rebind a track when the server reassigns ids (§6.9). - New `ContentHashSink` port in psysonic-core (closure handle, mirrors PlaybackQueryHandle): keeps psysonic-analysis decoupled from psysonic-library. - `seed_from_bytes_into_cache` returns the computed md5; `seed_from_bytes_execute` fires the sink after a successful seed (Upserted or cache-hit) when a real server is known. The shell crate registers the sink to patch the library. - `patch_content_hash` + `library_patch_track`'s new optional `contentHash` field write it; both no-op when the library has no row for (server_id, id), i.e. the index is off for that server. - Sync upsert no longer clobbers it: `content_hash = COALESCE(NULLIF( excluded.content_hash,''), track.content_hash)` — a sync (which passes NULL) preserves the playback hash, a non-empty incoming hash still wins. No schema migration — the `content_hash` column already exists. Tauri boundary: `library_patch_track` gains optional `contentHash` (additive). * feat(library): expose contentHash on libraryPatchTrack wrapper (E2) Add optional `contentHash` to the `libraryPatchTrack` patch type so the TS contract matches the extended Rust command. Normally written by the Rust analysis bridge; exposed for completeness. * feat(library): enrichment summary on library_get_track (E3, 6e) (#829) * feat(library): enrichment summary on library_get_track (E3) Add an optional `enrichment { waveformReady, loudnessReady, lyricsCached }` to the single-track `library_get_track` read (R7-16 Q5). Read-only, per-server, never blocks on the network; list/batch projections leave it unset. - New `AnalysisReadinessQuery` port in psysonic-core (closure handle, mirrors ContentHashSink) keeps psysonic-library decoupled from psysonic-analysis. The shell crate registers it to probe the analysis cache by exact (server_id, track_id, content_hash) key with legacy '' fallback — read-only, no re-tag. waveform/loudness readiness is gated on a known content_hash (E2). - `lyricsCached` from a new pure-read `ArtifactRepository::lyrics_cached` (valid, non-expired, non-not_found lyrics row). - `library_purge_server`'s `includeAnalysis` documented as a deliberate v1 no-op (R7-16 Q7): analysis is never deleted on purge / server remove. Tauri boundary: `LibraryTrackDto` gains optional `enrichment` (additive). * feat(library): mirror enrichment on LibraryTrackDto wrapper (E3) Add `TrackEnrichmentDto` + optional `enrichment` to the TS `LibraryTrackDto` so the contract matches the extended `library_get_track` response. * feat(library): VirtualSongList browses the local index when ready (F1) (#830) The all-songs browse now serves pages from the local library index when it is ready for the active server, falling back to the unchanged network path otherwise. - `runLocalSongBrowse` (reuses the F2 local-read adapters): empty-query browse-all via `library_advanced_search`, whose default track order (`t.title COLLATE NOCASE ASC`) matches the network `ndListSongs('title','ASC')` path, so paging stays coherent across a local↔network boundary. - Gated per page on `libraryIsReady` + `source === 'local'`; any miss / failure returns null → VirtualSongList uses the existing browse path unchanged. - Search (non-empty query) stays on the network path for now; rich search is already covered by Advanced Search (F2). * feat(library): patch-on-use for star/rating/scrobble (PR-7 F3) (#831) * feat(library): library_patch_track clears nullable fields on explicit null (F3) Extract the patch logic into a testable `apply_track_patch`. Nullable integer fields (`starredAt` / `userRating` / `playCount` / `playedAt`) now distinguish an absent key (leave untouched) from an explicit `null` (clear the column), so `unstar` ({ starredAt: null }) actually un-stars the local row. `.map` keeps the present/absent distinction; `as_i64()` yields the value or `None` → bound as SQL NULL. F3 is the first caller that sends null, so no existing behaviour changes. * feat(library): patch-on-use wiring for star / rating / scrobble (F3) After a successful star/unstar, setRating, or play scrobble, mirror the change into the local library index via `library_patch_track` so its reads (browse F1, advanced search F2) reflect the action immediately — no stale list after a rate, no full resync. - `patchLibraryTrackOnUse` helper: fire-and-forget, gated on the index being enabled for the server; the Rust command additionally no-ops when no row matches (album/artist id, or index off). - Wired at the central API chokepoints: `star`/`unstar` (song only) → `starredAt`, `setRating` → `userRating`, `scrobbleSong` → `playedAt`. - `play_count` is left to the next sync (the patch sets absolute values; a correct increment needs the current base). F4 (deprecating the player-store override maps) is intentionally separate — removing them would break instant star feedback when the index is off. * feat(library): full-queue restore from the index on startup (PR-7 F5) (#832) Persist the whole queue as a lightweight ref list and rehydrate it from the local index on startup, so the entire queue survives a restart instead of only the windowed slice (R7-17 / §8.6). - Persist adds `queueRefs` (full ordered ids) + `queueRefsIndex` alongside the existing windowed `queue`. Ids are tiny; the windowed objects stay as the no-index fallback. - `hydrateQueueFromIndex` (startup, after session bind): when the library index is ready for the queue's server, hydrate the full queue via `library_get_tracks_batch` (batched ≤100), map `songToTrack ∘ trackToSong`, re-locate the current track so `queueIndex` stays aligned, then clear the refs. - Index not ready / missing rows / current track not found → keep the windowed fallback (queue never empty when the index is off, the P6 default). Old persisted shape without refs loads unchanged. - `trackToSong` exported from the F2 local-read adapters (one mapper). Kept the windowed-objects persist (did not drop the cap per R7-17 note): the index-off default needs the embedded fallback or the queue would restore empty. * feat(library): pending-sync for song star/rating (PR-7 F4) (#833) * feat(library): central pending-sync helper for song star/rating (PR-7 F4) `queueSongStar` / `queueSongRating` (spec §6.5 / R7-18): set the player-store override optimistically, retry the Subsonic API with exponential backoff (flush on `online` / window focus), and on success clear the override + patch the in-memory Track so the UI stays correct without it. The F3 index patch-on-use runs inside the API layer, unchanged. - No rollback on the first network error (the override survives until the retry succeeds or the app restarts; overrides are session-only, not persisted). - Latest-toggle-wins coalescing + an identity guard so a fast re-toggle while a request is in flight can't retire the newer task. - v1: songs only. * feat(library): route song star/rating through the pending-sync helper (PR-7 F4) Replace the scattered optimistic-set + API-call + rollback logic with the single `queueSongStar` / `queueSongRating` helper across cucadmuh's named v1 surfaces: PlayerBar, FullscreenPlayer, MobilePlayerView, both context menus (song + queue row), both shortcut paths, the song-rating hook + player-bar stars, skip→1★, and AlbumDetail (song star + rating). The 30+ override read sites are unchanged — they already read `override ?? track`, and the override now clears on success. Standalone page toggles (Favorites, RandomMix, NowPlaying star) and the separate mini-player webview keep their existing path — no regression (a non-migrated override simply lingers as before) — and move to a follow-up. * feat(library): route remaining song star/rating sites through pending-sync (F4 follow-up) (#834) Migrate the three standalone song write sites left out of #833 onto the central queueSongStar / queueSongRating helper: - Favorites: handleRate + removeSong (un-star) - RandomMix: toggleSongStar (drops local try/catch rollback per no-rollback policy) - useNowPlayingStarLove: toggleStar (keeps local view state, helper owns override + retried sync) MiniContextMenu stays on its direct path (separate webview, no shared store). No behaviour change for album/artist rating paths. * feat(library): route playlist song star/rating through pending-sync (F4 follow-up) (#835) The playlist-detail star/rating hook was the last shared-store song write site still calling the Subsonic API directly. Route handleRate + handleToggleStar through queueSongRating / queueSongStar, matching the Favorites and RandomMix follow-ups; keep the local ratings/starredSongs view state, drop the inline override. MiniContextMenu remains on its direct path (separate webview). * feat(library): BPM range filter UI in Advanced Search (PR-7 F6) (#836) * feat(library-sync): parallel initial ingest (S2 + N1/S1 prefetch) Wire C11 ParallelismBudget (max 4 when idle) into InitialSyncRunner: parallel getAlbum for S2, up to 4 in-flight pages for N1/S1, and persist S2 cursor once per album-list page instead of per album. * fix(library-sync): defer scheduler during initial sync and improve ingest diagnostics Background delta/tombstone ticks every 30s were competing with IS-3 bulk ingest for the write mutex (20–60s lock waits on large libraries). Skip scheduler while sync_phase is initial_sync/probing or bulk ingest is active. Serialize ingest batch metrics as camelCase for DevTools, add bulk-ingest FTS/index suspension, combined cursor persist, write-op tracing, live local search, and library dev logging helpers. * fix(library-search): scoped FTS, cancel stale live search, skip 1-char queries Use column-scoped FTS for artists/albums/songs, min two graphemes for local FTS, capped match counts in Advanced Search, and title browse index (m004). Live Search aborts superseded network requests, passes requestEpoch to drop stale Rust FTS, and avoids search3 fallback for too-short queries. * fix(library-search): prefix FTS, fast subquery joins, hide BPM in Advanced Search Live and Advanced Search now use FTS5 prefix tokens ("metal"*) and limit bm25 ranking inside rowid subqueries so large libraries stay in the ms range. Advanced Search BPM filter is removed from the UI until enrichment ships. * feat(library-search): race local index vs search3, show first result Live Search and Advanced Search text queries run library and network backends in parallel; the faster source wins. Adds searchRace helper and search_race dev logging. * feat(library-index): multi-server UI, serial sync queue, scoped local search Add master library index toggle with per-server rows, offline retry, and a frontend sync queue so initial ingest runs one server at a time. Scope Live Search and Advanced Search to the sidebar music library filter via library_id and raw_json fallbacks; coerce numeric libraryId on ingest. Promote idle sync state to ready when a full sync stamp exists and block cross-server initial sync starts in Rust. * chore(library-store): compliance — clippy, i18n, CHANGELOG Fix clippy/tsc blockers (request structs, IngestPageCtx, type aliases), add library index strings to all 9 locales, and document the preview feature in CHANGELOG [1.47.0] with Psychotoxical + cucadmuh attribution. * docs(credits): library index preview contributions Credit Psychotoxical for the local library store foundation and cucadmuh for multi-server UI, scoped search, and i18n. Drop removed scan-trigger wording from PR #780 entry. * docs(release): link library index preview to PR #846 * docs(changelog): sort [1.47.0] entries by ascending PR number * docs(changelog): mark library index as Added in [1.47.0] * docs(changelog): restructure [1.47.0] into Added/Changed/Fixed Match 1.46.0 layout: new features in Added (incl. library index), enhancements in Changed, bug fixes in Fixed — PR ascending within each block. * fix(library): address PR #846 review — delta guard + FTS order Skip background scheduler delta when LibraryRuntime already has a foreground sync job for the same server. Preserve bm25 rowid ordering in live search track/artist/album fetches. * fix(library): address remaining PR #846 review items S2 resume persists current_album_id per album; same-server resync awaits the previous runner. N1 delta watermark uses strict less-than; Navidrome HTTP 500 detection is structured. Adds genre/year indexes, backoff jitter salt, LiveSearch failure toast, and user-facing search badge copy. * fix(library): close resync notify race and tighten FTS trigger test Use notify_one() so an early runner completion cannot lose the drain signal before same-server full resync awaits. FTS test now compares normalized trigger bodies from migration vs suspend/restore roundtrip. --------- Co-authored-by: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> --- CHANGELOG.md | 79 +- src-tauri/Cargo.lock | 16 + src-tauri/Cargo.toml | 1 + .../migrations/001_baseline.sql | 44 + .../migrations/002_server_id.sql | 73 + .../src/analysis_cache/compute.rs | 69 +- .../src/analysis_cache/store.rs | 770 ++++-- .../psysonic-analysis/src/analysis_runtime.rs | 122 +- .../crates/psysonic-analysis/src/commands.rs | 116 +- .../crates/psysonic-audio/src/commands.rs | 13 + .../psysonic-audio/src/device_resume.rs | 4 + src-tauri/crates/psysonic-audio/src/engine.rs | 6 + .../crates/psysonic-audio/src/helpers.rs | 40 +- .../crates/psysonic-audio/src/mix_commands.rs | 2 + .../crates/psysonic-audio/src/play_input.rs | 18 +- .../psysonic-audio/src/preload_commands.rs | 4 +- .../psysonic-audio/src/stream/ranged_http.rs | 6 +- .../psysonic-audio/src/stream/track_stream.rs | 5 +- .../psysonic-audio/src/transport_commands.rs | 1 + src-tauri/crates/psysonic-core/src/ports.rs | 60 + .../crates/psysonic-integration/Cargo.toml | 2 +- .../crates/psysonic-integration/src/lib.rs | 2 + .../psysonic-integration/src/navidrome/mod.rs | 3 + .../src/navidrome/probe.rs | 116 + .../src/navidrome/queries.rs | 33 +- .../psysonic-integration/src/subsonic/auth.rs | 91 + .../src/subsonic/client.rs | 1172 +++++++++ .../src/subsonic/error.rs | 76 + .../psysonic-integration/src/subsonic/mod.rs | 18 + .../src/subsonic/types.rs | 403 +++ src-tauri/crates/psysonic-library/Cargo.toml | 21 + .../migrations/001_initial.sql | 247 ++ .../migrations/002_n1_bulk_unreliable.sql | 6 + .../migrations/003_track_remap_indexes.sql | 10 + .../migrations/004_track_title_index.sql | 4 + .../005_track_genre_year_indexes.sql | 8 + .../psysonic-library/src/advanced_search.rs | 1471 +++++++++++ .../psysonic-library/src/bulk_ingest.rs | 84 + .../crates/psysonic-library/src/canonical.rs | 264 ++ .../crates/psysonic-library/src/commands.rs | 1397 +++++++++++ .../psysonic-library/src/cross_server.rs | 376 +++ src-tauri/crates/psysonic-library/src/dto.rs | 690 +++++ .../crates/psysonic-library/src/filter.rs | 415 ++++ src-tauri/crates/psysonic-library/src/lib.rs | 32 + .../psysonic-library/src/live_search.rs | 660 +++++ .../crates/psysonic-library/src/payload.rs | 240 ++ .../psysonic-library/src/repos/artifact.rs | 453 ++++ .../crates/psysonic-library/src/repos/fact.rs | 302 +++ .../crates/psysonic-library/src/repos/mod.rs | 15 + .../psysonic-library/src/repos/sync_state.rs | 826 ++++++ .../psysonic-library/src/repos/track.rs | 980 ++++++++ .../src/repos/track_id_history.rs | 123 + .../crates/psysonic-library/src/runtime.rs | 258 ++ .../crates/psysonic-library/src/search.rs | 338 +++ .../crates/psysonic-library/src/store.rs | 532 ++++ .../psysonic-library/src/sync/backoff.rs | 151 ++ .../psysonic-library/src/sync/bandwidth.rs | 86 + .../psysonic-library/src/sync/budget.rs | 93 + .../psysonic-library/src/sync/capability.rs | 680 +++++ .../psysonic-library/src/sync/cursor.rs | 157 ++ .../crates/psysonic-library/src/sync/delta.rs | 1049 ++++++++ .../crates/psysonic-library/src/sync/error.rs | 149 ++ .../src/sync/ingest_parallel.rs | 226 ++ .../psysonic-library/src/sync/initial.rs | 2210 +++++++++++++++++ .../psysonic-library/src/sync/mapping.rs | 303 +++ .../crates/psysonic-library/src/sync/mod.rs | 40 + .../psysonic-library/src/sync/poll_stats.rs | 250 ++ .../psysonic-library/src/sync/progress.rs | 329 +++ .../psysonic-library/src/sync/scheduler.rs | 619 +++++ .../psysonic-library/src/sync/strategy.rs | 216 ++ .../psysonic-library/src/sync/supervisor.rs | 166 ++ .../psysonic-library/src/sync/tombstone.rs | 447 ++++ .../crates/psysonic-library/src/track_fts.rs | 156 ++ .../crates/psysonic-syncfs/src/cache/hot.rs | 14 +- .../psysonic-syncfs/src/cache/offline.rs | 21 +- src-tauri/src/lib.rs | 170 ++ src/api/library.ts | 520 ++++ src/api/subsonic.contract.test.ts | 21 +- src/api/subsonicClient.ts | 21 +- src/api/subsonicScrobble.ts | 5 + src/api/subsonicSearch.ts | 29 +- src/api/subsonicStarRating.ts | 10 + src/app/MainApp.tsx | 18 + src/app/TauriEventBridge.tsx | 2 + src/components/FullscreenPlayer.tsx | 16 +- src/components/LiveSearch.tsx | 238 +- src/components/MobilePlayerView.tsx | 16 +- src/components/PlayerBar.tsx | 22 +- src/components/VirtualSongList.tsx | 13 +- .../contextMenu/QueueItemContextItems.tsx | 8 +- .../contextMenu/SongContextItems.tsx | 11 +- src/components/playerBar/PlayerTrackInfo.tsx | 7 +- .../settings/LibraryIndexSection.tsx | 420 ++++ .../settings/LibraryIndexServerRow.tsx | 145 ++ src/components/settings/LibraryTab.tsx | 4 + src/components/settings/ServersTab.tsx | 31 +- src/components/settings/settingsTabs.ts | 1 + src/config/settingsCredits.ts | 4 +- src/config/shortcutActionRegistry.ts | 23 +- src/config/shortcutDispatch.ts | 9 +- src/hooks/tauriBridge/useLibraryDevSyncLog.ts | 128 + src/hooks/useContextMenuRating.ts | 7 +- src/hooks/useNowPlayingStarLove.ts | 7 +- src/hooks/usePlaylistStarRating.ts | 11 +- src/locales/de/search.ts | 6 + src/locales/de/settings.ts | 32 + src/locales/en/search.ts | 6 + src/locales/en/settings.ts | 32 + src/locales/es/search.ts | 6 + src/locales/es/settings.ts | 31 + src/locales/fr/search.ts | 6 + src/locales/fr/settings.ts | 31 + src/locales/nb/search.ts | 6 + src/locales/nb/settings.ts | 31 + src/locales/nl/search.ts | 6 + src/locales/nl/settings.ts | 31 + src/locales/ro/search.ts | 6 + src/locales/ro/settings.ts | 31 + src/locales/ru/search.ts | 6 + src/locales/ru/settings.ts | 31 + src/locales/zh/search.ts | 6 + src/locales/zh/settings.ts | 31 + src/pages/AdvancedSearch.tsx | 108 +- src/pages/AlbumDetail.tsx | 21 +- src/pages/Favorites.tsx | 11 +- src/pages/RandomMix.tsx | 17 +- src/store/audioEventHandlers.ts | 9 + src/store/libraryIndexStore.ts | 90 + src/store/libraryPlaybackHint.ts | 25 + src/store/loudnessRefresh.ts | 5 +- src/store/loudnessReseed.test.ts | 1 + src/store/loudnessReseed.ts | 7 +- src/store/pendingStarSync.test.ts | 84 + src/store/pendingStarSync.ts | 137 + src/store/playTrackAction.ts | 1 + src/store/playerStore.ts | 6 + src/store/playerStoreTypes.ts | 6 + src/store/queueUndoAudioRestore.ts | 1 + src/store/resumeAction.ts | 2 + src/store/skipStarRating.test.ts | 70 +- src/store/skipStarRating.ts | 14 +- src/store/waveformRefresh.ts | 6 +- src/styles/components/live-search.css | 19 + src/utils/library/advancedSearchLocal.test.ts | 169 ++ src/utils/library/advancedSearchLocal.ts | 308 +++ src/utils/library/libraryDevLog.test.ts | 88 + src/utils/library/libraryDevLog.ts | 294 +++ src/utils/library/libraryReady.test.ts | 79 + src/utils/library/libraryReady.ts | 58 + src/utils/library/librarySession.test.ts | 92 + src/utils/library/librarySession.ts | 127 + src/utils/library/librarySyncQueue.test.ts | 96 + src/utils/library/librarySyncQueue.ts | 131 + src/utils/library/liveSearchLocal.test.ts | 98 + src/utils/library/liveSearchLocal.ts | 129 + src/utils/library/patchOnUse.test.ts | 52 + src/utils/library/patchOnUse.ts | 29 + src/utils/library/queueRestore.test.ts | 101 + src/utils/library/queueRestore.ts | 61 + src/utils/library/searchRace.test.ts | 100 + src/utils/library/searchRace.ts | 72 + src/utils/server/serverBaseUrl.ts | 8 + 162 files changed, 24916 insertions(+), 547 deletions(-) create mode 100644 src-tauri/crates/psysonic-analysis/migrations/001_baseline.sql create mode 100644 src-tauri/crates/psysonic-analysis/migrations/002_server_id.sql create mode 100644 src-tauri/crates/psysonic-integration/src/navidrome/probe.rs create mode 100644 src-tauri/crates/psysonic-integration/src/subsonic/auth.rs create mode 100644 src-tauri/crates/psysonic-integration/src/subsonic/client.rs create mode 100644 src-tauri/crates/psysonic-integration/src/subsonic/error.rs create mode 100644 src-tauri/crates/psysonic-integration/src/subsonic/mod.rs create mode 100644 src-tauri/crates/psysonic-integration/src/subsonic/types.rs create mode 100644 src-tauri/crates/psysonic-library/Cargo.toml create mode 100644 src-tauri/crates/psysonic-library/migrations/001_initial.sql create mode 100644 src-tauri/crates/psysonic-library/migrations/002_n1_bulk_unreliable.sql create mode 100644 src-tauri/crates/psysonic-library/migrations/003_track_remap_indexes.sql create mode 100644 src-tauri/crates/psysonic-library/migrations/004_track_title_index.sql create mode 100644 src-tauri/crates/psysonic-library/migrations/005_track_genre_year_indexes.sql create mode 100644 src-tauri/crates/psysonic-library/src/advanced_search.rs create mode 100644 src-tauri/crates/psysonic-library/src/bulk_ingest.rs create mode 100644 src-tauri/crates/psysonic-library/src/canonical.rs create mode 100644 src-tauri/crates/psysonic-library/src/commands.rs create mode 100644 src-tauri/crates/psysonic-library/src/cross_server.rs create mode 100644 src-tauri/crates/psysonic-library/src/dto.rs create mode 100644 src-tauri/crates/psysonic-library/src/filter.rs create mode 100644 src-tauri/crates/psysonic-library/src/lib.rs create mode 100644 src-tauri/crates/psysonic-library/src/live_search.rs create mode 100644 src-tauri/crates/psysonic-library/src/payload.rs create mode 100644 src-tauri/crates/psysonic-library/src/repos/artifact.rs create mode 100644 src-tauri/crates/psysonic-library/src/repos/fact.rs create mode 100644 src-tauri/crates/psysonic-library/src/repos/mod.rs create mode 100644 src-tauri/crates/psysonic-library/src/repos/sync_state.rs create mode 100644 src-tauri/crates/psysonic-library/src/repos/track.rs create mode 100644 src-tauri/crates/psysonic-library/src/repos/track_id_history.rs create mode 100644 src-tauri/crates/psysonic-library/src/runtime.rs create mode 100644 src-tauri/crates/psysonic-library/src/search.rs create mode 100644 src-tauri/crates/psysonic-library/src/store.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/backoff.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/bandwidth.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/budget.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/capability.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/cursor.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/delta.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/error.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/ingest_parallel.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/initial.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/mapping.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/mod.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/poll_stats.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/progress.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/scheduler.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/strategy.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/supervisor.rs create mode 100644 src-tauri/crates/psysonic-library/src/sync/tombstone.rs create mode 100644 src-tauri/crates/psysonic-library/src/track_fts.rs create mode 100644 src/api/library.ts create mode 100644 src/components/settings/LibraryIndexSection.tsx create mode 100644 src/components/settings/LibraryIndexServerRow.tsx create mode 100644 src/hooks/tauriBridge/useLibraryDevSyncLog.ts create mode 100644 src/store/libraryIndexStore.ts create mode 100644 src/store/libraryPlaybackHint.ts create mode 100644 src/store/pendingStarSync.test.ts create mode 100644 src/store/pendingStarSync.ts create mode 100644 src/utils/library/advancedSearchLocal.test.ts create mode 100644 src/utils/library/advancedSearchLocal.ts create mode 100644 src/utils/library/libraryDevLog.test.ts create mode 100644 src/utils/library/libraryDevLog.ts create mode 100644 src/utils/library/libraryReady.test.ts create mode 100644 src/utils/library/libraryReady.ts create mode 100644 src/utils/library/librarySession.test.ts create mode 100644 src/utils/library/librarySession.ts create mode 100644 src/utils/library/librarySyncQueue.test.ts create mode 100644 src/utils/library/librarySyncQueue.ts create mode 100644 src/utils/library/liveSearchLocal.test.ts create mode 100644 src/utils/library/liveSearchLocal.ts create mode 100644 src/utils/library/patchOnUse.test.ts create mode 100644 src/utils/library/patchOnUse.ts create mode 100644 src/utils/library/queueRestore.test.ts create mode 100644 src/utils/library/queueRestore.ts create mode 100644 src/utils/library/searchRace.test.ts create mode 100644 src/utils/library/searchRace.ts create mode 100644 src/utils/server/serverBaseUrl.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 003a0c11..cdf59871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,14 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.47.0] -### Settings + Queue polish - -**By [@kveld9](https://github.com/kveld9) + [@Psychotoxical](https://github.com/Psychotoxical), adopted from PR [#558](https://github.com/Psychotoxical/psysonic/pull/558), rewritten in PR [#778](https://github.com/Psychotoxical/psysonic/pull/778)** - -* Settings toggle rows dim non-toggle content to 0.6 opacity when their switch is off; mutex-disabled rows (Crossfade/Gapless) unchanged. -* Queue toolbar `Clear` → `Clear queue` across all 9 locales. - - +## Added ### Servers — edit existing profiles @@ -33,14 +26,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -### Interface Scale — covers the whole window +### Local library index + search (preview) -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#781](https://github.com/Psychotoxical/psysonic/pull/781)** +**By [@Psychotoxical](https://github.com/Psychotoxical) + [@cucadmuh](https://github.com/cucadmuh), PR [#846](https://github.com/Psychotoxical/psysonic/pull/846)** -* Settings → Appearance → Interface Scale now scales sidebar, queue, player bar, modals/portals and the fullscreen player alongside the main content — same behaviour as browser Ctrl+/−. +* **Settings → Library:** local SQLite track index per server — background initial and delta sync, full resync, integrity verify, and auto-reconcile when the server reports fewer tracks than expected. +* **Live Search** and **Advanced Search** query the local index when it is ready (fast, offline-capable). +* **Multi-server UI** (by [@cucadmuh](https://github.com/cucadmuh)): per-server exclude/include; indexing runs one server at a time so SQLite stays responsive; offline servers are retried automatically. +* Local search results respect the sidebar music-library filter; parallel album fetch during initial sync. +## Changed + ### Linux — session GDK, WebKitGTK mitigations, and Wayland text **By [@cucadmuh](https://github.com/cucadmuh), PR [#731](https://github.com/Psychotoxical/psysonic/pull/731)** @@ -60,20 +58,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -### Fixed +### Settings + Queue polish + +**By [@kveld9](https://github.com/kveld9) + [@Psychotoxical](https://github.com/Psychotoxical), adopted from PR [#558](https://github.com/Psychotoxical/psysonic/pull/558), rewritten in PR [#778](https://github.com/Psychotoxical/psysonic/pull/778)** + +* Settings toggle rows dim non-toggle content to 0.6 opacity when their switch is off; mutex-disabled rows (Crossfade/Gapless) unchanged. +* Queue toolbar `Clear` → `Clear queue` across all 9 locales. + + + +### Interface Scale — covers the whole window + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#781](https://github.com/Psychotoxical/psysonic/pull/781)** + +* Settings → Appearance → Interface Scale now scales sidebar, queue, player bar, modals/portals and the fullscreen player alongside the main content — same behaviour as browser Ctrl+/−. + + + +### Radio — card control polish + +**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#786](https://github.com/Psychotoxical/psysonic/pull/786)** + +* Repeat is disabled while a radio stream plays. +* Deleting the playing station fades out instead of cutting hard. +* Play / Stop tooltip on the cover-overlay button; stop uses a Square icon. + + + +## Fixed + +### In-page browse — virtual scroll and cover-art priority **By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)** * **In-page browse:** virtual artist/album/composer grids and lists no longer lose all rows after deep scroll — `scrollMargin` now targets the in-page overlay viewport, not the locked main route scrollport. * **Cover art on browse pages:** `CachedImage` priority scoring follows the real scrolling pane so visible thumbnails win network fetch slots; Artists infinite scroll loads one page per batch instead of re-entrantly queueing many pages during a fast fling. + + +### Lucky Mix after server switch + **By [@cucadmuh](https://github.com/cucadmuh), PR [#785](https://github.com/Psychotoxical/psysonic/pull/785)** -* **Lucky Mix after server switch:** starting a mix on the browsed server no longer spams cross-server enqueue errors — unpinned or foreign queues hand off cleanly before batch enqueue. +* Starting a mix on the browsed server no longer spams cross-server enqueue errors — unpinned or foreign queues hand off cleanly before batch enqueue. -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#844](https://github.com/Psychotoxical/psysonic/pull/844)** +### Radio — paused streams stay paused -* **Album view:** bulk "Add to playlist" no longer clears the track selection without opening the playlist picker. +**By [@Psychotoxical](https://github.com/Psychotoxical), reported by drelabre on GitHub, PR [#786](https://github.com/Psychotoxical/psysonic/pull/786)** + +* Pausing a radio stream no longer auto-resumes after about a minute on macOS. @@ -86,21 +119,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -### Radio — paused streams stay paused +### Album view — bulk add to playlist selection -**By [@Psychotoxical](https://github.com/Psychotoxical), reported by drelabre on GitHub, PR [#786](https://github.com/Psychotoxical/psysonic/pull/786)** +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#844](https://github.com/Psychotoxical/psysonic/pull/844)** -* Pausing a radio stream no longer auto-resumes after about a minute on macOS. - - - -### Radio — card control polish - -**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#786](https://github.com/Psychotoxical/psysonic/pull/786)** - -* Repeat is disabled while a radio stream plays. -* Deleting the playing station fades out instead of cutting hard. -* Play / Stop tooltip on the cover-overlay button; stop uses a Square icon. +* Bulk "Add to playlist" no longer clears the track selection without opening the playlist picker. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 907102f3..b6508c85 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3723,6 +3723,7 @@ dependencies = [ "psysonic-audio", "psysonic-core", "psysonic-integration", + "psysonic-library", "psysonic-syncfs", "reqwest", "ringbuf", @@ -3826,6 +3827,21 @@ dependencies = [ "wiremock", ] +[[package]] +name = "psysonic-library" +version = "1.47.0-dev" +dependencies = [ + "psysonic-core", + "psysonic-integration", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "tauri", + "tokio", + "wiremock", +] + [[package]] name = "psysonic-syncfs" version = "1.47.0-dev" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 22155821..1977fbc1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ tauri-build = { version = "2", features = [] } psysonic-core = { path = "crates/psysonic-core" } psysonic-analysis = { path = "crates/psysonic-analysis" } psysonic-audio = { path = "crates/psysonic-audio" } +psysonic-library = { path = "crates/psysonic-library" } psysonic-syncfs = { path = "crates/psysonic-syncfs" } psysonic-integration = { path = "crates/psysonic-integration" } tauri = { version = "2", features = ["tray-icon", "image-png"] } diff --git a/src-tauri/crates/psysonic-analysis/migrations/001_baseline.sql b/src-tauri/crates/psysonic-analysis/migrations/001_baseline.sql new file mode 100644 index 00000000..7f5f97f6 --- /dev/null +++ b/src-tauri/crates/psysonic-analysis/migrations/001_baseline.sql @@ -0,0 +1,44 @@ +-- Baseline: the pre-versioning analysis cache schema. +-- +-- This is the exact shape every existing user DB already carries (created by +-- the old `CREATE TABLE IF NOT EXISTS` bootstrap). `IF NOT EXISTS` keeps it a +-- no-op on those DBs and creates the tables on a fresh one, so "migration 1 +-- applied" means "the schema that shipped before versioned migrations". +-- +-- Server-scoping (server_id) is added additively in 002. + +CREATE TABLE IF NOT EXISTS analysis_track ( + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + status TEXT NOT NULL, + waveform_algo_version INTEGER NOT NULL, + loudness_algo_version INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (track_id, md5_16kb) +); + +CREATE TABLE IF NOT EXISTS waveform_cache ( + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + bins BLOB NOT NULL, + bin_count INTEGER NOT NULL, + is_partial INTEGER NOT NULL, + known_until_sec REAL NOT NULL, + duration_sec REAL NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (track_id, md5_16kb) +); + +CREATE TABLE IF NOT EXISTS loudness_cache ( + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + integrated_lufs REAL NOT NULL, + true_peak REAL NOT NULL, + recommended_gain_db REAL NOT NULL, + target_lufs REAL NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (track_id, md5_16kb, target_lufs) +); + +CREATE INDEX IF NOT EXISTS idx_analysis_track_status + ON analysis_track(status); diff --git a/src-tauri/crates/psysonic-analysis/migrations/002_server_id.sql b/src-tauri/crates/psysonic-analysis/migrations/002_server_id.sql new file mode 100644 index 00000000..90b64b2b --- /dev/null +++ b/src-tauri/crates/psysonic-analysis/migrations/002_server_id.sql @@ -0,0 +1,73 @@ +-- Add `server_id` to the analysis cache so waveform/loudness rows are scoped +-- per server (E1 / R7-16). SQLite cannot change a PRIMARY KEY in place, so each +-- table is rebuilt: create the v2 shape, copy every row with server_id = '', +-- drop the old table, rename. Existing rows become legacy ('') rows that the +-- read path still finds (server -> legacy -> lazy re-tag, added in 6c-2). +-- +-- Atomicity: the migration runner wraps this whole file plus the +-- schema_migrations marker in one transaction, so any failure or crash rolls +-- everything back to the original tables — DROP never runs unless the copy +-- before it succeeded. No BEGIN/COMMIT here (that would nest). +-- +-- These three tables have no foreign keys between them or from any other table, +-- so the drop/rename needs no `PRAGMA foreign_keys` toggle (which is a no-op +-- inside a transaction anyway). + +-- analysis_track +CREATE TABLE analysis_track_v2 ( + server_id TEXT NOT NULL DEFAULT '', + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + status TEXT NOT NULL, + waveform_algo_version INTEGER NOT NULL, + loudness_algo_version INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, track_id, md5_16kb) +); +INSERT INTO analysis_track_v2 + (server_id, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at) + SELECT '', track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at + FROM analysis_track; +DROP TABLE analysis_track; +ALTER TABLE analysis_track_v2 RENAME TO analysis_track; +CREATE INDEX IF NOT EXISTS idx_analysis_track_status + ON analysis_track(status); + +-- waveform_cache +CREATE TABLE waveform_cache_v2 ( + server_id TEXT NOT NULL DEFAULT '', + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + bins BLOB NOT NULL, + bin_count INTEGER NOT NULL, + is_partial INTEGER NOT NULL, + known_until_sec REAL NOT NULL, + duration_sec REAL NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, track_id, md5_16kb) +); +INSERT INTO waveform_cache_v2 + (server_id, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at) + SELECT '', track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at + FROM waveform_cache; +DROP TABLE waveform_cache; +ALTER TABLE waveform_cache_v2 RENAME TO waveform_cache; + +-- loudness_cache +CREATE TABLE loudness_cache_v2 ( + server_id TEXT NOT NULL DEFAULT '', + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + integrated_lufs REAL NOT NULL, + true_peak REAL NOT NULL, + recommended_gain_db REAL NOT NULL, + target_lufs REAL NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, track_id, md5_16kb, target_lufs) +); +INSERT INTO loudness_cache_v2 + (server_id, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at) + SELECT '', track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at + FROM loudness_cache; +DROP TABLE loudness_cache; +ALTER TABLE loudness_cache_v2 RENAME TO loudness_cache; diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs index 104bf19a..26a8350d 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs @@ -40,6 +40,7 @@ pub enum SeedFromBytesOutcome { /// single CPU-seed worker in `lib.rs` (`spawn_blocking`) so at most one heavy decode runs. pub fn seed_from_bytes_execute( app: &tauri::AppHandle, + server_id: &str, track_id: &str, bytes: &[u8], ) -> Result { @@ -51,20 +52,45 @@ pub fn seed_from_bytes_execute( ); return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache); }; - seed_from_bytes_into_cache(&cache, track_id, bytes) + let (outcome, md5_16kb) = seed_from_bytes_into_cache(&cache, server_id, track_id, bytes)?; + // E2 bridge (analysis → library content_hash): once the playback-derived + // md5_16kb is known — whether freshly written or already cached — record it + // as `track.content_hash` via the registered sink. Decoupled from + // psysonic-library through the psysonic-core port; a no-op when the library + // has no row for this (server_id, track_id). Skipped under the legacy '' + // scope (no server known). + if !server_id.is_empty() + && matches!( + outcome, + SeedFromBytesOutcome::Upserted | SeedFromBytesOutcome::SkippedWaveformCacheHit + ) + { + if let Some(sink) = app.try_state::() { + sink.record_content_hash(server_id, track_id, &md5_16kb); + } + } + Ok(outcome) } /// AppHandle-free entry point for [`seed_from_bytes_execute`]: takes the cache /// directly, runs the same Symphonia → waveform → EBU R128 pipeline, and /// upserts the rows. Called from `seed_from_bytes_execute` in production and /// from tests against an in-memory cache. +/// Returns the outcome plus the computed `md5_16kb` (the content fingerprint), +/// so the AppHandle-aware caller can bridge it to the library `content_hash` +/// (E2) without re-reading the bytes. pub fn seed_from_bytes_into_cache( cache: &AnalysisCache, + server_id: &str, track_id: &str, bytes: &[u8], -) -> Result { +) -> Result<(SeedFromBytesOutcome, String), String> { let started = Instant::now(); + // Write under the playback server's scope. An empty `server_id` (caller did + // not know the server) lands under the legacy '' pool — the read path's + // legacy fallback + lazy re-tag keeps it resolvable. let key = TrackKey { + server_id: server_id.to_string(), track_id: track_id.to_string(), md5_16kb: md5_first_16kb(bytes), }; @@ -78,7 +104,7 @@ pub fn seed_from_bytes_into_cache( existing.bins.len(), started.elapsed().as_millis() ); - return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit); + return Ok((SeedFromBytesOutcome::SkippedWaveformCacheHit, key.md5_16kb.clone())); } crate::app_deprintln!( "[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}", @@ -164,7 +190,7 @@ pub fn seed_from_bytes_into_cache( } match build { - Ok(_) => Ok(SeedFromBytesOutcome::Upserted), + Ok(_) => Ok((SeedFromBytesOutcome::Upserted, key.md5_16kb.clone())), Err(e) => Err(e), } } @@ -740,12 +766,14 @@ mod tests { fn seed_from_bytes_into_cache_upserts_waveform_and_loudness_for_wav() { let cache = AnalysisCache::open_in_memory(); let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100); - let outcome = seed_from_bytes_into_cache(&cache, "wav-track", &wav).unwrap(); + let (outcome, md5) = seed_from_bytes_into_cache(&cache, "", "wav-track", &wav).unwrap(); assert_eq!(outcome, SeedFromBytesOutcome::Upserted); + assert_eq!(md5, md5_first_16kb(&wav), "outcome carries the content fingerprint"); // Both a waveform AND a loudness row must exist after a successful // PCM decode + EBU R128 analysis. let key = TrackKey { + server_id: String::new(), track_id: "wav-track".to_string(), md5_16kb: md5_first_16kb(&wav), }; @@ -755,13 +783,37 @@ mod tests { assert!(cache.loudness_row_exists_for_key(&key).unwrap()); } + #[test] + fn seed_from_bytes_into_cache_writes_under_the_given_server_scope() { + let cache = AnalysisCache::open_in_memory(); + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100); + seed_from_bytes_into_cache(&cache, "server-x", "scoped-track", &wav).unwrap(); + + let md5 = md5_first_16kb(&wav); + let scoped = TrackKey { + server_id: "server-x".to_string(), + track_id: "scoped-track".to_string(), + md5_16kb: md5.clone(), + }; + let legacy = TrackKey { + server_id: String::new(), + track_id: "scoped-track".to_string(), + md5_16kb: md5, + }; + assert!(cache.get_waveform(&scoped).unwrap().is_some(), "row lands under server scope"); + assert!( + cache.get_waveform(&legacy).unwrap().is_none(), + "nothing written under the legacy '' scope" + ); + } + #[test] fn seed_from_bytes_into_cache_returns_skipped_on_second_call() { let cache = AnalysisCache::open_in_memory(); let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); - let first = seed_from_bytes_into_cache(&cache, "wav-track-2", &wav).unwrap(); + let (first, _) = seed_from_bytes_into_cache(&cache, "", "wav-track-2", &wav).unwrap(); assert_eq!(first, SeedFromBytesOutcome::Upserted); - let second = seed_from_bytes_into_cache(&cache, "wav-track-2", &wav).unwrap(); + let (second, _) = seed_from_bytes_into_cache(&cache, "", "wav-track-2", &wav).unwrap(); assert_eq!( second, SeedFromBytesOutcome::SkippedWaveformCacheHit, @@ -775,10 +827,11 @@ mod tests { // Garbage bytes — Symphonia probe fails, the pipeline falls back to // `derive_waveform_bins` (no loudness row gets cached). let bytes = vec![0xAAu8; 8 * 1024]; - let outcome = seed_from_bytes_into_cache(&cache, "garbage", &bytes).unwrap(); + let (outcome, _) = seed_from_bytes_into_cache(&cache, "", "garbage", &bytes).unwrap(); assert_eq!(outcome, SeedFromBytesOutcome::Upserted); let key = TrackKey { + server_id: String::new(), track_id: "garbage".to_string(), md5_16kb: md5_first_16kb(&bytes), }; diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs index 41a4b1d4..4e80e209 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; @@ -8,6 +8,18 @@ use tauri::Manager; pub(super) const WAVEFORM_ALGO_VERSION: i64 = 4; pub(super) const LOUDNESS_ALGO_VERSION: i64 = 1; +/// Current head of the embedded migrations. Bump for each new +/// `migrations/NNN_*.sql`. +pub const ANALYSIS_DB_SCHEMA_VERSION: i64 = 2; + +const MIGRATION_001_BASELINE: &str = include_str!("../../migrations/001_baseline.sql"); +const MIGRATION_002_SERVER_ID: &str = include_str!("../../migrations/002_server_id.sql"); + +/// Embedded migrations, ascending by version. The runner sorts defensively and +/// applies each missing one in its own transaction (schema change + version +/// marker commit together — see [`run_migrations_with`]). +const MIGRATIONS: &[(i64, &str)] = &[(1, MIGRATION_001_BASELINE), (2, MIGRATION_002_SERVER_ID)]; + /// Bins in waveform BLOB: `2 * bin_count` bytes (peak u8, then mean-abs u8 per time bin). fn waveform_cache_blob_len_ok(bins: &[u8], bin_count: i64) -> bool { if bin_count <= 0 { @@ -19,6 +31,10 @@ fn waveform_cache_blob_len_ok(bins: &[u8], bin_count: i64) -> bool { #[derive(Debug, Clone)] pub struct TrackKey { + /// App server id this analysis belongs to. Empty string is the legacy + /// (pre-002) value: rows migrated from the unscoped schema and any caller + /// that does not yet know the server (filled in by 6c-2). + pub server_id: String, pub track_id: String, pub md5_16kb: String, } @@ -84,9 +100,10 @@ impl AnalysisCache { if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - let conn = Connection::open(db_path).map_err(|e| e.to_string())?; + backup_before_pending_migration(&db_path)?; + let mut conn = Connection::open(&db_path).map_err(|e| e.to_string())?; configure_connection(&conn).map_err(|e| e.to_string())?; - migrate_schema(&conn).map_err(|e| e.to_string())?; + run_migrations(&mut conn).map_err(|e| e.to_string())?; Ok(Self { conn: Mutex::new(conn) }) } @@ -100,14 +117,19 @@ impl AnalysisCache { /// without a `test-support` Cargo feature dance. Production code does not /// use it. pub fn open_in_memory() -> Self { - let conn = Connection::open_in_memory().expect("in-memory connection"); + let mut conn = Connection::open_in_memory().expect("in-memory connection"); conn.pragma_update(None, "foreign_keys", "ON").expect("pragma foreign_keys"); - migrate_schema(&conn).expect("schema migration"); + run_migrations(&mut conn).expect("schema migration"); Self { conn: Mutex::new(conn) } } - /// Remove all `loudness_cache` rows for this logical track (bare id and `stream:` variant). - pub fn delete_loudness_for_track_id(&self, track_id: &str) -> Result { + /// Remove `loudness_cache` rows for this logical track (bare id and `stream:` + /// variant) **scoped to one server plus the legacy `''` pool**. A reseed on + /// server A must not delete server B's analysis for the same bare `track_id`; + /// the legacy `''` rows are cleared too so a stale pre-002 blob can't shadow + /// the fresh re-analysis via the read fallback (and so it isn't seen as + /// redundant). Pass `server_id = ""` to target only the legacy pool. + pub fn delete_loudness_for_track_id(&self, server_id: &str, track_id: &str) -> Result { if track_id.trim().is_empty() { return Ok(0); } @@ -118,15 +140,20 @@ impl AnalysisCache { let mut total: u64 = 0; for tid in track_id_cache_variants(track_id) { let n = conn - .execute("DELETE FROM loudness_cache WHERE track_id = ?1", params![tid]) + .execute( + "DELETE FROM loudness_cache WHERE track_id = ?1 AND server_id IN (?2, '')", + params![tid, server_id], + ) .map_err(|e| e.to_string())?; total = total.saturating_add(n as u64); } Ok(total) } - /// Remove all `waveform_cache` rows for this logical track (bare id and `stream:` variant). - pub fn delete_waveform_for_track_id(&self, track_id: &str) -> Result { + /// Remove `waveform_cache` rows for this logical track (bare id and `stream:` + /// variant) scoped to one server plus the legacy `''` pool. See + /// [`Self::delete_loudness_for_track_id`] for the scoping rationale. + pub fn delete_waveform_for_track_id(&self, server_id: &str, track_id: &str) -> Result { if track_id.trim().is_empty() { return Ok(0); } @@ -137,7 +164,10 @@ impl AnalysisCache { let mut total: u64 = 0; for tid in track_id_cache_variants(track_id) { let n = conn - .execute("DELETE FROM waveform_cache WHERE track_id = ?1", params![tid]) + .execute( + "DELETE FROM waveform_cache WHERE track_id = ?1 AND server_id IN (?2, '')", + params![tid, server_id], + ) .map_err(|e| e.to_string())?; total = total.saturating_add(n as u64); } @@ -162,15 +192,16 @@ impl AnalysisCache { conn.execute( r#" INSERT INTO analysis_track ( - track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(track_id, md5_16kb) DO UPDATE SET + server_id, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(server_id, track_id, md5_16kb) DO UPDATE SET status = excluded.status, waveform_algo_version = excluded.waveform_algo_version, loudness_algo_version = excluded.loudness_algo_version, updated_at = excluded.updated_at "#, params![ + key.server_id, key.track_id, key.md5_16kb, status, @@ -188,9 +219,9 @@ impl AnalysisCache { conn.execute( r#" INSERT INTO waveform_cache ( - track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT(track_id, md5_16kb) DO UPDATE SET + server_id, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(server_id, track_id, md5_16kb) DO UPDATE SET bins = excluded.bins, bin_count = excluded.bin_count, is_partial = excluded.is_partial, @@ -199,6 +230,7 @@ impl AnalysisCache { updated_at = excluded.updated_at "#, params![ + key.server_id, key.track_id, key.md5_16kb, entry.bins, @@ -218,15 +250,16 @@ impl AnalysisCache { conn.execute( r#" INSERT INTO loudness_cache ( - track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(track_id, md5_16kb, target_lufs) DO UPDATE SET + server_id, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(server_id, track_id, md5_16kb, target_lufs) DO UPDATE SET integrated_lufs = excluded.integrated_lufs, true_peak = excluded.true_peak, recommended_gain_db = excluded.recommended_gain_db, updated_at = excluded.updated_at "#, params![ + key.server_id, key.track_id, key.md5_16kb, entry.integrated_lufs, @@ -248,13 +281,15 @@ impl AnalysisCache { SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at FROM waveform_cache w JOIN analysis_track a - ON a.track_id = w.track_id + ON a.server_id = w.server_id + AND a.track_id = w.track_id AND a.md5_16kb = w.md5_16kb - WHERE w.track_id = ?1 - AND w.md5_16kb = ?2 - AND a.waveform_algo_version = ?3 + WHERE w.server_id = ?1 + AND w.track_id = ?2 + AND w.md5_16kb = ?3 + AND a.waveform_algo_version = ?4 "#, - params![key.track_id, key.md5_16kb, WAVEFORM_ALGO_VERSION], + params![key.server_id, key.track_id, key.md5_16kb, WAVEFORM_ALGO_VERSION], |row| { Ok(WaveformEntry { bins: row.get(0)?, @@ -282,105 +317,214 @@ impl AnalysisCache { SELECT 1 FROM loudness_cache l JOIN analysis_track a - ON a.track_id = l.track_id + ON a.server_id = l.server_id + AND a.track_id = l.track_id AND a.md5_16kb = l.md5_16kb - WHERE l.track_id = ?1 - AND l.md5_16kb = ?2 - AND a.loudness_algo_version = ?3 + WHERE l.server_id = ?1 + AND l.track_id = ?2 + AND l.md5_16kb = ?3 + AND a.loudness_algo_version = ?4 ) "#, - params![key.track_id, key.md5_16kb, LOUDNESS_ALGO_VERSION], + params![key.server_id, key.track_id, key.md5_16kb, LOUDNESS_ALGO_VERSION], |row| row.get(0), ) .map_err(|e| e.to_string())?; Ok(exists != 0) } - pub fn get_latest_waveform_for_track(&self, track_id: &str) -> Result, String> { + /// Latest waveform for `(server_id, track_id)` with legacy fallback. Tries the + /// server-scoped rows first (both id variants), then the legacy `server_id=''` + /// pool. On a legacy hit while a real `server_id` is known, the matching rows + /// are re-tagged under the server-scoped key (best-effort) so subsequent reads + /// hit the exact key and other servers can't shadow each other via `''`. + pub fn get_latest_waveform_for_track( + &self, + server_id: &str, + track_id: &str, + ) -> Result, String> { let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; - const SQL: &str = r#" - SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at - FROM waveform_cache w - JOIN analysis_track a - ON a.track_id = w.track_id - AND a.md5_16kb = w.md5_16kb - WHERE w.track_id = ?1 - AND a.waveform_algo_version = ?2 - ORDER BY w.updated_at DESC - LIMIT 1 - "#; - for tid in track_id_cache_variants(track_id) { - let row = conn - .query_row( - SQL, - params![tid, WAVEFORM_ALGO_VERSION], - |row| { - Ok(WaveformEntry { - bins: row.get(0)?, - bin_count: row.get(1)?, - is_partial: row.get::<_, i64>(2)? != 0, - known_until_sec: row.get(3)?, - duration_sec: row.get(4)?, - updated_at: row.get(5)?, - }) - }, - ) - .optional() - .map_err(|e| e.to_string())?; - if let Some(e) = row { - if waveform_cache_blob_len_ok(&e.bins, e.bin_count) { - return Ok(Some(e)); - } + if let Some(e) = query_latest_waveform_scoped(&conn, server_id, track_id)? { + return Ok(Some(e)); + } + if !server_id.is_empty() { + if let Some(e) = query_latest_waveform_scoped(&conn, "", track_id)? { + let _ = relabel_legacy_to_server(&conn, server_id, track_id); + return Ok(Some(e)); } } Ok(None) } - /// Both waveform and loudness rows exist — a CPU seed from bytes/file would only - /// decode the file to immediately skip with `SkippedWaveformCacheHit`. - pub fn cpu_seed_redundant_for_track(&self, track_id: &str) -> Result { + /// Both waveform and loudness rows exist for this `(server_id, track_id)` + /// (including the legacy `''` fallback) — a CPU seed from bytes/file would + /// only decode the file to immediately skip with `SkippedWaveformCacheHit`. + /// A legacy hit is re-tagged onto the server scope as a side effect (see + /// [`Self::get_latest_waveform_for_track`]), so skipping the seed still leaves + /// the track resolvable under its real `server_id`. + pub fn cpu_seed_redundant_for_track(&self, server_id: &str, track_id: &str) -> Result { Ok( - self.get_latest_waveform_for_track(track_id)?.is_some() - && self.get_latest_loudness_for_track(track_id)?.is_some(), + self.get_latest_waveform_for_track(server_id, track_id)?.is_some() + && self.get_latest_loudness_for_track(server_id, track_id)?.is_some(), ) } - pub fn get_latest_loudness_for_track(&self, track_id: &str) -> Result, String> { + /// Latest loudness for `(server_id, track_id)` with the same legacy fallback + + /// lazy re-tag behaviour as [`Self::get_latest_waveform_for_track`]. + pub fn get_latest_loudness_for_track( + &self, + server_id: &str, + track_id: &str, + ) -> Result, String> { let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; - const SQL: &str = r#" - SELECT l.integrated_lufs, l.true_peak, l.recommended_gain_db, l.target_lufs, l.updated_at - FROM loudness_cache l - JOIN analysis_track a - ON a.track_id = l.track_id - AND a.md5_16kb = l.md5_16kb - WHERE l.track_id = ?1 - AND a.loudness_algo_version = ?2 - ORDER BY l.updated_at DESC - LIMIT 1 - "#; - for tid in track_id_cache_variants(track_id) { - let row = conn - .query_row( - SQL, - params![tid, LOUDNESS_ALGO_VERSION], - |row| { - Ok(LoudnessSnapshot { - integrated_lufs: row.get(0)?, - true_peak: row.get(1)?, - recommended_gain_db: row.get(2)?, - target_lufs: row.get(3)?, - updated_at: row.get(4)?, - }) - }, - ) - .optional() - .map_err(|e| e.to_string())?; - if row.is_some() { - return Ok(row); + if let Some(s) = query_latest_loudness_scoped(&conn, server_id, track_id)? { + return Ok(Some(s)); + } + if !server_id.is_empty() { + if let Some(s) = query_latest_loudness_scoped(&conn, "", track_id)? { + let _ = relabel_legacy_to_server(&conn, server_id, track_id); + return Ok(Some(s)); } } Ok(None) } + + /// Copy any legacy (`server_id=''`) analysis rows for `track_id` (both id + /// variants) onto `server_id` via `INSERT OR IGNORE` — best-effort, never + /// clobbers an existing server-scoped row. Exposed for the exact-key read + /// command, which re-tags after a legacy hit. No-op when `server_id` is empty. + pub fn relabel_legacy_to_server(&self, server_id: &str, track_id: &str) -> Result<(), String> { + if server_id.is_empty() { + return Ok(()); + } + let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; + relabel_legacy_to_server(&conn, server_id, track_id).map_err(|e| e.to_string()) + } +} + +/// Server-scoped variant of the "latest waveform for this track" lookup: filters +/// `waveform_cache` to `server_id` and tries both id variants (bare ↔ `stream:`). +fn query_latest_waveform_scoped( + conn: &Connection, + server_id: &str, + track_id: &str, +) -> Result, String> { + const SQL: &str = r#" + SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at + FROM waveform_cache w + JOIN analysis_track a + ON a.server_id = w.server_id + AND a.track_id = w.track_id + AND a.md5_16kb = w.md5_16kb + WHERE w.server_id = ?1 + AND w.track_id = ?2 + AND a.waveform_algo_version = ?3 + ORDER BY w.updated_at DESC + LIMIT 1 + "#; + for tid in track_id_cache_variants(track_id) { + let row = conn + .query_row(SQL, params![server_id, tid, WAVEFORM_ALGO_VERSION], |row| { + Ok(WaveformEntry { + bins: row.get(0)?, + bin_count: row.get(1)?, + is_partial: row.get::<_, i64>(2)? != 0, + known_until_sec: row.get(3)?, + duration_sec: row.get(4)?, + updated_at: row.get(5)?, + }) + }) + .optional() + .map_err(|e| e.to_string())?; + if let Some(e) = row { + if waveform_cache_blob_len_ok(&e.bins, e.bin_count) { + return Ok(Some(e)); + } + } + } + Ok(None) +} + +/// Server-scoped variant of the "latest loudness for this track" lookup. +fn query_latest_loudness_scoped( + conn: &Connection, + server_id: &str, + track_id: &str, +) -> Result, String> { + const SQL: &str = r#" + SELECT l.integrated_lufs, l.true_peak, l.recommended_gain_db, l.target_lufs, l.updated_at + FROM loudness_cache l + JOIN analysis_track a + ON a.server_id = l.server_id + AND a.track_id = l.track_id + AND a.md5_16kb = l.md5_16kb + WHERE l.server_id = ?1 + AND l.track_id = ?2 + AND a.loudness_algo_version = ?3 + ORDER BY l.updated_at DESC + LIMIT 1 + "#; + for tid in track_id_cache_variants(track_id) { + let row = conn + .query_row(SQL, params![server_id, tid, LOUDNESS_ALGO_VERSION], |row| { + Ok(LoudnessSnapshot { + integrated_lufs: row.get(0)?, + true_peak: row.get(1)?, + recommended_gain_db: row.get(2)?, + target_lufs: row.get(3)?, + updated_at: row.get(4)?, + }) + }) + .optional() + .map_err(|e| e.to_string())?; + if row.is_some() { + return Ok(row); + } + } + Ok(None) +} + +/// Lazy re-tag: copy legacy (`server_id=''`) `analysis_track` + `waveform_cache` + +/// `loudness_cache` rows for every id variant of `track_id` onto `server_id`. +/// `INSERT OR IGNORE` so an already-present server-scoped row (e.g. a precise +/// playback-derived analysis) is never overwritten. Best-effort, no transaction: +/// the rows are individually consistent and a partial copy still leaves the +/// legacy rows readable via fallback. +fn relabel_legacy_to_server( + conn: &Connection, + server_id: &str, + track_id: &str, +) -> rusqlite::Result<()> { + for tid in track_id_cache_variants(track_id) { + conn.execute( + r#" + INSERT OR IGNORE INTO analysis_track + (server_id, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at) + SELECT ?1, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at + FROM analysis_track WHERE server_id = '' AND track_id = ?2 + "#, + params![server_id, tid], + )?; + conn.execute( + r#" + INSERT OR IGNORE INTO waveform_cache + (server_id, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at) + SELECT ?1, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at + FROM waveform_cache WHERE server_id = '' AND track_id = ?2 + "#, + params![server_id, tid], + )?; + conn.execute( + r#" + INSERT OR IGNORE INTO loudness_cache + (server_id, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at) + SELECT ?1, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at + FROM loudness_cache WHERE server_id = '' AND track_id = ?2 + "#, + params![server_id, tid], + )?; + } + Ok(()) } fn analysis_db_path(app: &tauri::AppHandle) -> Result { @@ -399,46 +543,91 @@ fn configure_connection(conn: &Connection) -> rusqlite::Result<()> { Ok(()) } -fn migrate_schema(conn: &Connection) -> rusqlite::Result<()> { +/// One-shot safety net before the first table-rewriting migration (002 +/// `server_id`). Snapshots the existing DB via `VACUUM INTO` — a transactionally +/// consistent copy even with WAL — to `.pre-v.bak`, so a catastrophic +/// failure the migration transaction can't cover (disk full at COMMIT, +/// filesystem corruption, a rebuild bug) still leaves the original recoverable. +/// Skipped for a fresh DB or one already at the target version. The analysis +/// cache is small (~1 KB/track), so the copy is cheap. +fn backup_before_pending_migration(db_path: &Path) -> Result<(), String> { + if !db_path.exists() { + return Ok(()); // fresh DB — nothing to protect + } + let conn = Connection::open(db_path).map_err(|e| e.to_string())?; + // `schema_migrations` may not exist yet on a pre-versioning DB → treat the + // missing table as version 0 so the backup runs before 002 rewrites tables. + let applied: i64 = conn + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_migrations", + [], + |r| r.get(0), + ) + .unwrap_or(0); + if applied >= ANALYSIS_DB_SCHEMA_VERSION { + return Ok(()); // already at head — no rewrite pending + } + let backup_path = db_path.with_file_name(format!( + "audio-analysis.sqlite.pre-v{ANALYSIS_DB_SCHEMA_VERSION}.bak" + )); + // `VACUUM INTO` fails if the target exists; drop a stale backup from an + // interrupted earlier attempt (the snapshot is re-creatable). + if backup_path.exists() { + std::fs::remove_file(&backup_path).map_err(|e| e.to_string())?; + } + // Documented literal form `VACUUM INTO ''`; the local path is escaped + // for the SQL string literal (single-quote doubling) so an apostrophe in a + // user's home dir can't break or inject the statement. + let escaped = backup_path.to_string_lossy().replace('\'', "''"); + conn.execute_batch(&format!("VACUUM INTO '{escaped}';")) + .map_err(|e| format!("analysis pre-migration backup failed: {e}"))?; + Ok(()) +} + +fn run_migrations(conn: &mut Connection) -> rusqlite::Result<()> { + run_migrations_with(conn, MIGRATIONS) +} + +/// Applies every embedded migration not yet recorded in `schema_migrations`. +/// Each migration runs in its own transaction that commits the schema change +/// *and* its version marker together — a failure or crash rolls the whole +/// migration back, and the next start retries it cleanly. Idempotent across +/// reopens. Forward-only: an unknown future version on the DB is left alone +/// (the analysis cache is a rebuildable derived store, so there is no +/// breaking-bump drop/resync like the library DB). +/// +/// Split out (test-friendly) so the migration set can be exercised against an +/// in-memory connection. +pub(crate) fn run_migrations_with( + conn: &mut Connection, + migrations: &[(i64, &str)], +) -> rusqlite::Result<()> { conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS analysis_track ( - track_id TEXT NOT NULL, - md5_16kb TEXT NOT NULL, - status TEXT NOT NULL, - waveform_algo_version INTEGER NOT NULL, - loudness_algo_version INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (track_id, md5_16kb) - ); - - CREATE TABLE IF NOT EXISTS waveform_cache ( - track_id TEXT NOT NULL, - md5_16kb TEXT NOT NULL, - bins BLOB NOT NULL, - bin_count INTEGER NOT NULL, - is_partial INTEGER NOT NULL, - known_until_sec REAL NOT NULL, - duration_sec REAL NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (track_id, md5_16kb) - ); - - CREATE TABLE IF NOT EXISTS loudness_cache ( - track_id TEXT NOT NULL, - md5_16kb TEXT NOT NULL, - integrated_lufs REAL NOT NULL, - true_peak REAL NOT NULL, - recommended_gain_db REAL NOT NULL, - target_lufs REAL NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (track_id, md5_16kb, target_lufs) - ); - - CREATE INDEX IF NOT EXISTS idx_analysis_track_status - ON analysis_track(status); - "#, + "CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + );", )?; + + let mut ordered: Vec<(i64, &str)> = migrations.to_vec(); + ordered.sort_by_key(|(v, _)| *v); + for (version, sql) in ordered { + let already: i64 = conn.query_row( + "SELECT COUNT(*) FROM schema_migrations WHERE version = ?1", + params![version], + |row| row.get(0), + )?; + if already > 0 { + continue; + } + let tx = conn.transaction()?; + tx.execute_batch(sql)?; + tx.execute( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, strftime('%s','now'))", + params![version], + )?; + tx.commit()?; + } Ok(()) } @@ -448,6 +637,15 @@ mod tests { fn key(track_id: &str) -> TrackKey { TrackKey { + server_id: String::new(), + track_id: track_id.to_string(), + md5_16kb: "deadbeef".to_string(), + } + } + + fn key_on(server_id: &str, track_id: &str) -> TrackKey { + TrackKey { + server_id: server_id.to_string(), track_id: track_id.to_string(), md5_16kb: "deadbeef".to_string(), } @@ -522,7 +720,15 @@ mod tests { .unwrap() .map(|r| r.unwrap()) .collect(); - assert_eq!(tables, vec!["analysis_track", "loudness_cache", "waveform_cache"]); + assert_eq!( + tables, + vec![ + "analysis_track", + "loudness_cache", + "schema_migrations", + "waveform_cache" + ] + ); } // ── waveform roundtrip ──────────────────────────────────────────────────── @@ -640,7 +846,7 @@ mod tests { cache.touch_track_status(&k, "ok").unwrap(); cache.upsert_waveform(&k, &waveform(4, false)).unwrap(); // Insert under stream:abc, look up with bare abc. - let got = cache.get_latest_waveform_for_track("abc").unwrap(); + let got = cache.get_latest_waveform_for_track("", "abc").unwrap(); assert!(got.is_some(), "bare-id lookup must find stream-prefixed row"); } @@ -650,7 +856,7 @@ mod tests { let k = key("abc"); cache.touch_track_status(&k, "ok").unwrap(); cache.upsert_loudness(&k, &loudness(-14.0)).unwrap(); - let got = cache.get_latest_loudness_for_track("stream:abc").unwrap(); + let got = cache.get_latest_loudness_for_track("", "stream:abc").unwrap(); assert!(got.is_some(), "stream-prefixed lookup must find bare row"); } @@ -662,16 +868,16 @@ mod tests { let k = key("abc"); cache.touch_track_status(&k, "ok").unwrap(); - assert!(!cache.cpu_seed_redundant_for_track("abc").unwrap()); + assert!(!cache.cpu_seed_redundant_for_track("", "abc").unwrap()); cache.upsert_waveform(&k, &waveform(4, false)).unwrap(); assert!( - !cache.cpu_seed_redundant_for_track("abc").unwrap(), + !cache.cpu_seed_redundant_for_track("", "abc").unwrap(), "waveform alone is not enough" ); cache.upsert_loudness(&k, &loudness(-14.0)).unwrap(); - assert!(cache.cpu_seed_redundant_for_track("abc").unwrap()); + assert!(cache.cpu_seed_redundant_for_track("", "abc").unwrap()); } // ── deletes ─────────────────────────────────────────────────────────────── @@ -686,7 +892,7 @@ mod tests { cache.upsert_loudness(&bare, &loudness(-14.0)).unwrap(); cache.upsert_loudness(&prefixed, &loudness(-14.0)).unwrap(); - let deleted = cache.delete_loudness_for_track_id("abc").unwrap(); + let deleted = cache.delete_loudness_for_track_id("", "abc").unwrap(); assert_eq!(deleted, 2, "delete must remove both bare and stream:abc rows"); assert!(!cache.loudness_row_exists_for_key(&bare).unwrap()); assert!(!cache.loudness_row_exists_for_key(&prefixed).unwrap()); @@ -702,7 +908,7 @@ mod tests { cache.upsert_waveform(&bare, &waveform(4, false)).unwrap(); cache.upsert_waveform(&prefixed, &waveform(4, false)).unwrap(); - let deleted = cache.delete_waveform_for_track_id("abc").unwrap(); + let deleted = cache.delete_waveform_for_track_id("", "abc").unwrap(); assert_eq!(deleted, 2); assert!(cache.get_waveform(&bare).unwrap().is_none()); assert!(cache.get_waveform(&prefixed).unwrap().is_none()); @@ -711,10 +917,102 @@ mod tests { #[test] fn delete_with_empty_or_whitespace_track_id_is_noop() { let cache = AnalysisCache::open_in_memory(); - assert_eq!(cache.delete_waveform_for_track_id("").unwrap(), 0); - assert_eq!(cache.delete_waveform_for_track_id(" ").unwrap(), 0); - assert_eq!(cache.delete_loudness_for_track_id("").unwrap(), 0); - assert_eq!(cache.delete_loudness_for_track_id(" ").unwrap(), 0); + assert_eq!(cache.delete_waveform_for_track_id("", "").unwrap(), 0); + assert_eq!(cache.delete_waveform_for_track_id("", " ").unwrap(), 0); + assert_eq!(cache.delete_loudness_for_track_id("", "").unwrap(), 0); + assert_eq!(cache.delete_loudness_for_track_id("", " ").unwrap(), 0); + } + + #[test] + fn delete_scoped_to_server_keeps_other_servers_rows() { + // A reseed on server-a must not wipe server-b's analysis for the same + // bare track_id; the legacy '' pool is cleared alongside server-a. + let cache = AnalysisCache::open_in_memory(); + let on_a = key_on("server-a", "t"); + let on_b = key_on("server-b", "t"); + let legacy = key_on("", "t"); + for k in [&on_a, &on_b, &legacy] { + cache.touch_track_status(k, "ok").unwrap(); + cache.upsert_waveform(k, &waveform(4, false)).unwrap(); + cache.upsert_loudness(k, &loudness(-14.0)).unwrap(); + } + + let deleted = cache.delete_waveform_for_track_id("server-a", "t").unwrap(); + assert_eq!(deleted, 2, "server-a + legacy '' waveform rows removed"); + assert!(cache.get_waveform(&on_a).unwrap().is_none()); + assert!(cache.get_waveform(&legacy).unwrap().is_none()); + assert!( + cache.get_waveform(&on_b).unwrap().is_some(), + "another server's waveform must survive a scoped reseed" + ); + + let deleted_l = cache.delete_loudness_for_track_id("server-a", "t").unwrap(); + assert_eq!(deleted_l, 2); + assert!(cache.loudness_row_exists_for_key(&on_b).unwrap()); + } + + // ── server scope: read fallback + lazy re-tag ───────────────────────────── + + #[test] + fn get_latest_waveform_falls_back_to_legacy_and_retags() { + // A pre-002 blob lives under server_id=''. A read for a real server must + // find it via fallback and re-tag it under the server-scoped key. + let cache = AnalysisCache::open_in_memory(); + let legacy = key_on("", "t"); + cache.touch_track_status(&legacy, "ready").unwrap(); + cache.upsert_waveform(&legacy, &waveform(4, false)).unwrap(); + cache.upsert_loudness(&legacy, &loudness(-14.0)).unwrap(); + + // server-a has no scoped row yet → fallback returns the legacy blob. + assert!(cache.get_waveform(&key_on("server-a", "t")).unwrap().is_none()); + assert!(cache.get_latest_waveform_for_track("server-a", "t").unwrap().is_some()); + + // Re-tag side effect: the exact server-scoped key now resolves directly. + assert!( + cache.get_waveform(&key_on("server-a", "t")).unwrap().is_some(), + "legacy hit must be re-tagged under the server scope" + ); + // Legacy row is preserved (copy, not move). + assert!(cache.get_waveform(&legacy).unwrap().is_some()); + } + + #[test] + fn retag_does_not_clobber_existing_server_scoped_row() { + // server-a already has a precise (playback-derived) row; a legacy hit must + // not overwrite it via INSERT OR IGNORE. + let cache = AnalysisCache::open_in_memory(); + let legacy = key_on("", "t"); + cache.touch_track_status(&legacy, "ready").unwrap(); + cache.upsert_waveform(&legacy, &waveform(4, true)).unwrap(); + cache.upsert_loudness(&legacy, &loudness(-14.0)).unwrap(); + + let on_a = key_on("server-a", "t"); + cache.touch_track_status(&on_a, "ready").unwrap(); + let precise = WaveformEntry { is_partial: false, ..waveform(4, false) }; + cache.upsert_waveform(&on_a, &precise).unwrap(); + cache.upsert_loudness(&on_a, &loudness(-14.0)).unwrap(); + + cache.relabel_legacy_to_server("server-a", "t").unwrap(); + let got = cache.get_waveform(&on_a).unwrap().expect("server row present"); + assert!(!got.is_partial, "precise server-scoped row must be preserved"); + } + + #[test] + fn get_latest_loudness_legacy_fallback_scopes_to_requested_server() { + let cache = AnalysisCache::open_in_memory(); + let legacy = key_on("", "t"); + cache.touch_track_status(&legacy, "ready").unwrap(); + cache.upsert_loudness(&legacy, &loudness(-12.0)).unwrap(); + + // server-b has its own distinct loudness → exact hit, no fallback. + let on_b = key_on("server-b", "t"); + cache.touch_track_status(&on_b, "ready").unwrap(); + cache.upsert_loudness(&on_b, &loudness(-20.0)).unwrap(); + + let a = cache.get_latest_loudness_for_track("server-a", "t").unwrap().unwrap(); + assert_eq!(a.target_lufs, -12.0, "server-a falls back to legacy blob"); + let b = cache.get_latest_loudness_for_track("server-b", "t").unwrap().unwrap(); + assert_eq!(b.target_lufs, -20.0, "server-b uses its own scoped blob, not legacy"); } #[test] @@ -748,4 +1046,176 @@ mod tests { .unwrap(); assert_eq!(status, "done"); } + + // ── schema migrations (002 server_id) ───────────────────────────────────── + + #[test] + fn run_migrations_records_all_versions_and_is_idempotent() { + let mut conn = Connection::open_in_memory().unwrap(); + run_migrations_with(&mut conn, MIGRATIONS).unwrap(); + // Second run is a no-op (every version already recorded). + run_migrations_with(&mut conn, MIGRATIONS).unwrap(); + let versions: Vec = conn + .prepare("SELECT version FROM schema_migrations ORDER BY version") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert_eq!(versions, (1..=ANALYSIS_DB_SCHEMA_VERSION).collect::>()); + } + + #[test] + fn migration_002_preserves_legacy_rows_under_empty_server_id() { + // Simulate a real pre-002 user DB: old schema + one row per table, no + // schema_migrations. + let mut conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(MIGRATION_001_BASELINE).unwrap(); + conn.execute( + "INSERT INTO analysis_track (track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at) + VALUES ('t1','m1','ready',?1,?2,123)", + params![WAVEFORM_ALGO_VERSION, LOUDNESS_ALGO_VERSION], + ) + .unwrap(); + conn.execute( + "INSERT INTO waveform_cache (track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at) + VALUES ('t1','m1',?1,4,0,0.0,60.0,123)", + params![vec![0u8; 8]], + ) + .unwrap(); + conn.execute( + "INSERT INTO loudness_cache (track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at) + VALUES ('t1','m1',-14.0,-1.0,0.0,-14.0,123)", + [], + ) + .unwrap(); + + run_migrations_with(&mut conn, MIGRATIONS).unwrap(); + + // No data lost; legacy rows now carry server_id = ''. + let track_sid: String = conn + .query_row("SELECT server_id FROM analysis_track WHERE track_id='t1'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(track_sid, ""); + let waveforms: i64 = conn + .query_row("SELECT COUNT(*) FROM waveform_cache WHERE server_id=''", [], |r| r.get(0)) + .unwrap(); + assert_eq!(waveforms, 1); + let loudness: i64 = conn + .query_row("SELECT COUNT(*) FROM loudness_cache WHERE server_id=''", [], |r| r.get(0)) + .unwrap(); + assert_eq!(loudness, 1); + + // The legacy blob is readable through the cache API under the '' key. + let cache = AnalysisCache { conn: Mutex::new(conn) }; + let legacy_key = TrackKey { + server_id: String::new(), + track_id: "t1".to_string(), + md5_16kb: "m1".to_string(), + }; + assert!(cache.get_waveform(&legacy_key).unwrap().is_some()); + } + + #[test] + fn server_id_scopes_exact_key_lookups() { + let cache = AnalysisCache::open_in_memory(); + let on_a = key_on("server-a", "t"); + let on_b = key_on("server-b", "t"); + cache.touch_track_status(&on_a, "ready").unwrap(); + cache.touch_track_status(&on_b, "ready").unwrap(); + // Only server-a has a waveform. + cache.upsert_waveform(&on_a, &waveform(4, false)).unwrap(); + + assert!(cache.get_waveform(&on_a).unwrap().is_some()); + assert!( + cache.get_waveform(&on_b).unwrap().is_none(), + "exact lookup must not return another server's analysis" + ); + + // Same (track_id, md5_16kb) under two server ids are independent rows. + let conn = cache.conn.lock().unwrap(); + let rows: i64 = conn + .query_row("SELECT COUNT(*) FROM analysis_track WHERE track_id='t'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(rows, 2); + } + + // ── pre-migration backup (on-disk; VACUUM INTO snapshot) ────────────────── + + fn unique_temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static CTR: AtomicU64 = AtomicU64::new(0); + let n = CTR.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = std::env::temp_dir().join(format!("psysonic-analysis-{tag}-{nanos}-{n}")); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn backup_file(dir: &Path) -> PathBuf { + dir.join(format!("audio-analysis.sqlite.pre-v{ANALYSIS_DB_SCHEMA_VERSION}.bak")) + } + + #[test] + fn backup_snapshots_pre_v2_db_and_overwrites_stale() { + let dir = unique_temp_dir("bkp-create"); + let db_path = dir.join("audio-analysis.sqlite"); + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute_batch(MIGRATION_001_BASELINE).unwrap(); + conn.execute( + "INSERT INTO analysis_track (track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at) + VALUES ('t','m','ready',?1,?2,1)", + params![WAVEFORM_ALGO_VERSION, LOUDNESS_ALGO_VERSION], + ) + .unwrap(); + } + + backup_before_pending_migration(&db_path).unwrap(); + + let backup = backup_file(&dir); + assert!(backup.exists(), "backup snapshot must be written"); + // The snapshot is a valid DB carrying the original row. + let bconn = Connection::open(&backup).unwrap(); + let rows: i64 = bconn + .query_row("SELECT COUNT(*) FROM analysis_track", [], |r| r.get(0)) + .unwrap(); + assert_eq!(rows, 1); + drop(bconn); + + // A second call overwrites the stale snapshot (VACUUM INTO needs a free + // target) instead of failing. + backup_before_pending_migration(&db_path).unwrap(); + assert!(backup.exists()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn backup_skips_when_db_absent() { + let dir = unique_temp_dir("bkp-absent"); + let db_path = dir.join("audio-analysis.sqlite"); + backup_before_pending_migration(&db_path).unwrap(); + assert!(!backup_file(&dir).exists(), "no backup for a fresh (absent) DB"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn backup_skips_when_already_at_head() { + let dir = unique_temp_dir("bkp-head"); + let db_path = dir.join("audio-analysis.sqlite"); + { + let mut conn = Connection::open(&db_path).unwrap(); + run_migrations_with(&mut conn, MIGRATIONS).unwrap(); + } + backup_before_pending_migration(&db_path).unwrap(); + assert!( + !backup_file(&dir).exists(), + "no backup when the DB is already at the target version" + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs index 8237155f..58dfc316 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs @@ -30,9 +30,14 @@ pub enum AnalysisBackfillEnqueueKind { RunningSkipped, } +/// One queued HTTP-backfill job: `(track_id, url, server_id)`. Dedup is by +/// `track_id` (a track is backfilled at most once at a time); `server_id` rides +/// along to scope the eventual cache write and follows the latest enqueue. +type BackfillJob = (String, String, String); + #[derive(Default)] pub struct AnalysisBackfillQueueState { - pub deque: VecDeque<(String, String)>, + pub deque: VecDeque, /// Set while this `track_id` is inside `analysis_backfill_download_and_seed` (not in deque). pub in_progress: Option, } @@ -40,13 +45,13 @@ pub struct AnalysisBackfillQueueState { impl AnalysisBackfillQueueState { fn is_reserved(&self, tid: &str) -> bool { self.in_progress.as_deref() == Some(tid) - || self.deque.iter().any(|(t, _)| t.as_str() == tid) + || self.deque.iter().any(|(t, _, _)| t.as_str() == tid) } - fn try_pop_next(&mut self) -> Option<(String, String)> { - let (tid, url) = self.deque.pop_front()?; - self.in_progress = Some(tid.clone()); - Some((tid, url)) + fn try_pop_next(&mut self) -> Option { + let job = self.deque.pop_front()?; + self.in_progress = Some(job.0.clone()); + Some(job) } fn finish_job(&mut self, tid: &str) { @@ -57,6 +62,7 @@ impl AnalysisBackfillQueueState { pub fn enqueue( &mut self, + server_id: String, tid: String, url: String, high_priority: bool, @@ -69,15 +75,15 @@ impl AnalysisBackfillQueueState { if self.in_progress.as_deref() == Some(tref) { return AnalysisBackfillEnqueueKind::RunningSkipped; } - self.deque.retain(|(t, _)| t != &tid); - self.deque.push_front((tid, url)); + self.deque.retain(|(t, _, _)| t != &tid); + self.deque.push_front((tid, url, server_id)); return AnalysisBackfillEnqueueKind::ReorderedFront; } if high_priority { - self.deque.push_front((tid, url)); + self.deque.push_front((tid, url, server_id)); AnalysisBackfillEnqueueKind::NewFront } else { - self.deque.push_back((tid, url)); + self.deque.push_back((tid, url, server_id)); AnalysisBackfillEnqueueKind::NewBack } } @@ -85,7 +91,7 @@ impl AnalysisBackfillQueueState { pub fn prune_queued_not_in(&mut self, keep_track_ids: &HashSet<&str>) -> usize { let before = self.deque.len(); self.deque - .retain(|(track_id, _)| keep_track_ids.contains(track_id.as_str())); + .retain(|(track_id, _, _)| keep_track_ids.contains(track_id.as_str())); before.saturating_sub(self.deque.len()) } } @@ -125,17 +131,19 @@ pub fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc Result { if let Some(cache) = app.try_state::() { - if cache.cpu_seed_redundant_for_track(track_id).unwrap_or(false) { + if cache.cpu_seed_redundant_for_track(server_id, track_id).unwrap_or(false) { return Ok(true); } } let high = analysis_backfill_is_current_track(app, track_id); let outcome = submit_analysis_cpu_seed( app.clone(), + server_id.to_string(), track_id.to_string(), bytes.to_vec(), high, @@ -147,7 +155,7 @@ pub async fn enqueue_analysis_seed( })?; let has_loudness = app .try_state::() - .and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten()) + .and_then(|cache| cache.get_latest_loudness_for_track(server_id, track_id).ok().flatten()) .is_some(); crate::app_deprintln!( "[analysis] seed result track_id={} bytes={} has_loudness={} outcome={outcome:?}", @@ -160,6 +168,7 @@ pub async fn enqueue_analysis_seed( async fn analysis_backfill_download_and_seed( app: &tauri::AppHandle, + server_id: &str, track_id: &str, url: &str, ) -> Result { @@ -176,7 +185,7 @@ async fn analysis_backfill_download_and_seed( if bytes.is_empty() { return Err("empty response".to_string()); } - enqueue_analysis_seed(app, track_id, &bytes).await + enqueue_analysis_seed(app, server_id, track_id, &bytes).await } async fn analysis_backfill_worker_loop( @@ -188,7 +197,7 @@ async fn analysis_backfill_worker_loop( if wake_rx.recv().await.is_none() { break; } - while let Some((track_id, url)) = { + while let Some((track_id, url, server_id)) = { let mut st = shared .state .lock() @@ -196,7 +205,7 @@ async fn analysis_backfill_worker_loop( st.try_pop_next() } { crate::app_deprintln!("[analysis] backfill worker: start track_id={}", track_id); - let result = analysis_backfill_download_and_seed(&app, &track_id, &url).await; + let result = analysis_backfill_download_and_seed(&app, &server_id, &track_id, &url).await; match &result { Ok(has_loudness) => crate::app_deprintln!( "[analysis] backfill ready: {} (has_loudness={})", @@ -238,6 +247,9 @@ type SeedDoneSender = type RunningSeedJob = (String, Arc>>); struct AnalysisCpuSeedJob { + /// Playback server scope for the write key. Empty = legacy '' (caller did not + /// know the server); the read path's fallback + lazy re-tag covers it. + server_id: String, track_id: String, bytes: Vec, waiters: Vec, @@ -253,6 +265,7 @@ struct AnalysisCpuSeedQueueState { impl AnalysisCpuSeedQueueState { fn enqueue( &mut self, + server_id: String, track_id: String, bytes: Vec, high_priority: bool, @@ -275,6 +288,8 @@ impl AnalysisCpuSeedQueueState { if let Some(pos) = self.deque.iter().position(|j| j.track_id == track_id) { let mut job = self.deque.remove(pos).unwrap(); + // Latest submission wins for both bytes and server scope. + job.server_id = server_id; job.bytes = bytes; job.waiters.push(done_tx); let kind = if high_priority { @@ -288,6 +303,7 @@ impl AnalysisCpuSeedQueueState { } let job = AnalysisCpuSeedJob { + server_id, track_id: track_id.clone(), bytes, waiters: vec![done_tx], @@ -422,10 +438,11 @@ async fn analysis_cpu_seed_worker_loop( }; let tid_log = job.track_id.clone(); let app2 = app.clone(); + let sid = job.server_id.clone(); let tid = job.track_id.clone(); let bytes = job.bytes; let outcome = tokio::task::spawn_blocking(move || { - analysis_cache::seed_from_bytes_execute(&app2, &tid, &bytes) + analysis_cache::seed_from_bytes_execute(&app2, &sid, &tid, &bytes) }) .await .unwrap_or_else(|e| Err(format!("cpu-seed spawn_blocking: {e}"))); @@ -497,6 +514,7 @@ pub fn prune_analysis_queues( /// re-run loudness refresh / waveform IPC for rows that were already current. pub async fn submit_analysis_cpu_seed( app: tauri::AppHandle, + server_id: String, track_id: String, bytes: Vec, high_priority: bool, @@ -504,7 +522,7 @@ pub async fn submit_analysis_cpu_seed( let shared = analysis_cpu_seed_shared(&app); let rx = { let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); - let (kind, rx) = st.enqueue(track_id.clone(), bytes, high_priority); + let (kind, rx) = st.enqueue(server_id, track_id.clone(), bytes, high_priority); crate::app_deprintln!("[analysis] cpu-seed submit: kind={kind:?} high_priority={high_priority}"); drop(st); shared.ping_worker(); @@ -542,7 +560,7 @@ mod tests { #[test] fn backfill_is_reserved_checks_both_deque_and_in_progress() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("queued".into(), "u".into())); + s.deque.push_back(("queued".into(), "u".into(), String::new())); s.in_progress = Some("active".into()); assert!(s.is_reserved("queued")); assert!(s.is_reserved("active")); @@ -552,8 +570,8 @@ mod tests { #[test] fn backfill_try_pop_next_promotes_head_to_in_progress() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("a".into(), "ua".into())); - s.deque.push_back(("b".into(), "ub".into())); + s.deque.push_back(("a".into(), "ua".into(), String::new())); + s.deque.push_back(("b".into(), "ub".into(), String::new())); let popped = s.try_pop_next().unwrap(); assert_eq!(popped.0, "a"); assert_eq!(s.in_progress.as_deref(), Some("a")); @@ -582,8 +600,8 @@ mod tests { #[test] fn backfill_enqueue_low_priority_appends_to_back() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("first".into(), "u".into())); - let kind = s.enqueue("second".into(), "u2".into(), false); + s.deque.push_back(("first".into(), "u".into(), String::new())); + let kind = s.enqueue(String::new(), "second".into(), "u2".into(), false); assert_eq!(kind, AnalysisBackfillEnqueueKind::NewBack); assert_eq!(s.deque.back().unwrap().0, "second"); } @@ -591,8 +609,8 @@ mod tests { #[test] fn backfill_enqueue_high_priority_pushes_to_front() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("old".into(), "u".into())); - let kind = s.enqueue("hot".into(), "u2".into(), true); + s.deque.push_back(("old".into(), "u".into(), String::new())); + let kind = s.enqueue(String::new(), "hot".into(), "u2".into(), true); assert_eq!(kind, AnalysisBackfillEnqueueKind::NewFront); assert_eq!(s.deque.front().unwrap().0, "hot"); } @@ -600,8 +618,8 @@ mod tests { #[test] fn backfill_enqueue_returns_duplicate_skipped_for_low_prio_dup() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("dup".into(), "u".into())); - let kind = s.enqueue("dup".into(), "u2".into(), false); + s.deque.push_back(("dup".into(), "u".into(), String::new())); + let kind = s.enqueue(String::new(), "dup".into(), "u2".into(), false); assert_eq!(kind, AnalysisBackfillEnqueueKind::DuplicateSkipped); assert_eq!(s.deque.len(), 1); } @@ -612,32 +630,35 @@ mod tests { in_progress: Some("active".into()), ..Default::default() }; - let kind = s.enqueue("active".into(), "u".into(), true); + let kind = s.enqueue(String::new(), "active".into(), "u".into(), true); assert_eq!(kind, AnalysisBackfillEnqueueKind::RunningSkipped); } #[test] fn backfill_enqueue_high_prio_dup_in_deque_reorders_to_front_with_new_url() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("a".into(), "u_a".into())); - s.deque.push_back(("dup".into(), "old_url".into())); - s.deque.push_back(("c".into(), "u_c".into())); - let kind = s.enqueue("dup".into(), "fresh_url".into(), true); + s.deque.push_back(("a".into(), "u_a".into(), String::new())); + s.deque.push_back(("dup".into(), "old_url".into(), String::new())); + s.deque.push_back(("c".into(), "u_c".into(), String::new())); + let kind = s.enqueue("server-1".into(), "dup".into(), "fresh_url".into(), true); assert_eq!(kind, AnalysisBackfillEnqueueKind::ReorderedFront); - assert_eq!(s.deque.front().unwrap(), &("dup".to_string(), "fresh_url".to_string())); - assert_eq!(s.deque.iter().filter(|(t, _)| t == "dup").count(), 1, "no duplicate left behind"); + assert_eq!( + s.deque.front().unwrap(), + &("dup".to_string(), "fresh_url".to_string(), "server-1".to_string()) + ); + assert_eq!(s.deque.iter().filter(|(t, _, _)| t == "dup").count(), 1, "no duplicate left behind"); } #[test] fn backfill_prune_queued_not_in_drops_unkept_entries() { let mut s = AnalysisBackfillQueueState::default(); for tid in ["a", "b", "c", "d"] { - s.deque.push_back((tid.into(), "u".into())); + s.deque.push_back((tid.into(), "u".into(), String::new())); } let keep: HashSet<&str> = ["a", "c"].iter().copied().collect(); let removed = s.prune_queued_not_in(&keep); assert_eq!(removed, 2); - let remaining: Vec<&str> = s.deque.iter().map(|(t, _)| t.as_str()).collect(); + let remaining: Vec<&str> = s.deque.iter().map(|(t, _, _)| t.as_str()).collect(); assert_eq!(remaining, vec!["a", "c"]); } @@ -646,7 +667,7 @@ mod tests { #[test] fn cpu_seed_enqueue_low_prio_appends_to_back() { let mut s = AnalysisCpuSeedQueueState::default(); - let (kind, _rx) = s.enqueue("a".into(), vec![], false); + let (kind, _rx) = s.enqueue(String::new(), "a".into(), vec![], false); assert_eq!(kind, AnalysisCpuSeedEnqueueKind::NewBack); assert_eq!(s.deque.len(), 1); } @@ -654,8 +675,8 @@ mod tests { #[test] fn cpu_seed_enqueue_high_prio_pushes_to_front() { let mut s = AnalysisCpuSeedQueueState::default(); - let (_, _r1) = s.enqueue("first".into(), vec![], false); - let (kind, _r2) = s.enqueue("hot".into(), vec![], true); + let (_, _r1) = s.enqueue(String::new(), "first".into(), vec![], false); + let (kind, _r2) = s.enqueue(String::new(), "hot".into(), vec![], true); assert_eq!(kind, AnalysisCpuSeedEnqueueKind::NewFront); assert_eq!(s.deque.front().unwrap().track_id, "hot"); } @@ -663,20 +684,21 @@ mod tests { #[test] fn cpu_seed_enqueue_existing_low_prio_merges_at_back() { let mut s = AnalysisCpuSeedQueueState::default(); - let (_, _r1) = s.enqueue("dup".into(), vec![1, 2, 3], false); - let (kind, _r2) = s.enqueue("dup".into(), vec![4, 5, 6], false); + let (_, _r1) = s.enqueue("server-a".into(), "dup".into(), vec![1, 2, 3], false); + let (kind, _r2) = s.enqueue("server-b".into(), "dup".into(), vec![4, 5, 6], false); assert_eq!(kind, AnalysisCpuSeedEnqueueKind::MergedQueued); assert_eq!(s.deque.len(), 1); assert_eq!(s.deque[0].bytes, vec![4, 5, 6], "fresh bytes overwrite"); + assert_eq!(s.deque[0].server_id, "server-b", "latest server scope wins on merge"); assert_eq!(s.deque[0].waiters.len(), 2, "both waiters attached"); } #[test] fn cpu_seed_enqueue_existing_high_prio_reorders_to_front() { let mut s = AnalysisCpuSeedQueueState::default(); - let (_, _r1) = s.enqueue("first".into(), vec![], false); - let (_, _r2) = s.enqueue("dup".into(), vec![], false); - let (kind, _r3) = s.enqueue("dup".into(), vec![], true); + let (_, _r1) = s.enqueue(String::new(), "first".into(), vec![], false); + let (_, _r2) = s.enqueue(String::new(), "dup".into(), vec![], false); + let (kind, _r3) = s.enqueue(String::new(), "dup".into(), vec![], true); assert_eq!(kind, AnalysisCpuSeedEnqueueKind::ReorderedFront); assert_eq!(s.deque.front().unwrap().track_id, "dup"); } @@ -686,7 +708,7 @@ mod tests { let mut s = AnalysisCpuSeedQueueState::default(); let followers = Arc::new(Mutex::new(Vec::new())); s.running = Some(("active".into(), followers.clone())); - let (kind, _rx) = s.enqueue("active".into(), vec![], false); + let (kind, _rx) = s.enqueue(String::new(), "active".into(), vec![], false); assert_eq!(kind, AnalysisCpuSeedEnqueueKind::RunningFollower); assert_eq!(followers.lock().unwrap().len(), 1, "follower channel attached"); assert_eq!(s.deque.len(), 0, "follower does not occupy a queue slot"); @@ -695,10 +717,10 @@ mod tests { #[test] fn cpu_seed_prune_returns_removed_jobs_and_waiter_count() { let mut s = AnalysisCpuSeedQueueState::default(); - let (_, _r1) = s.enqueue("a".into(), vec![], false); - let (_, _r2) = s.enqueue("b".into(), vec![], false); - let (_, _r3) = s.enqueue("a".into(), vec![], false); // merged: 2 waiters on a - let (_, _r4) = s.enqueue("c".into(), vec![], false); + let (_, _r1) = s.enqueue(String::new(), "a".into(), vec![], false); + let (_, _r2) = s.enqueue(String::new(), "b".into(), vec![], false); + let (_, _r3) = s.enqueue(String::new(), "a".into(), vec![], false); // merged: 2 waiters on a + let (_, _r4) = s.enqueue(String::new(), "c".into(), vec![], false); let keep: HashSet<&str> = ["a"].iter().copied().collect(); let (removed_jobs, removed_waiters) = s.prune_queued_not_in(&keep); @@ -711,7 +733,7 @@ mod tests { #[test] fn cpu_seed_prune_sends_err_to_dropped_waiters() { let mut s = AnalysisCpuSeedQueueState::default(); - let (_, rx) = s.enqueue("doomed".into(), vec![], false); + let (_, rx) = s.enqueue(String::new(), "doomed".into(), vec![], false); let keep: HashSet<&str> = HashSet::new(); let _ = s.prune_queued_not_in(&keep); // After pruning, the waiter receives the cancellation Err. diff --git a/src-tauri/crates/psysonic-analysis/src/commands.rs b/src-tauri/crates/psysonic-analysis/src/commands.rs index feb974b6..a69296f0 100644 --- a/src-tauri/crates/psysonic-analysis/src/commands.rs +++ b/src-tauri/crates/psysonic-analysis/src/commands.rs @@ -49,43 +49,63 @@ pub struct LoudnessCachePayload { pub updated_at: i64, } -/// AppHandle-free helper: looks up a waveform by exact `(track_id, md5_16kb)` -/// key and converts the `WaveformEntry` into the JSON-serialisable -/// `WaveformCachePayload`. Pulled out of [`analysis_get_waveform`] so it can -/// be tested with `AnalysisCache::open_in_memory()` and direct upserts. +/// AppHandle-free helper: looks up a waveform by exact `(server_id, track_id, +/// md5_16kb)` key, falling back to the legacy `''` scope and re-tagging it onto +/// `server_id` on a hit (best-effort). Converts the `WaveformEntry` into the +/// JSON-serialisable `WaveformCachePayload`. Pulled out of [`analysis_get_waveform`] +/// so it can be tested with `AnalysisCache::open_in_memory()` and direct upserts. pub fn get_waveform_payload( cache: &analysis_cache::AnalysisCache, + server_id: &str, track_id: &str, md5_16kb: &str, ) -> Result, String> { - let key = analysis_cache::TrackKey { + let exact = analysis_cache::TrackKey { + server_id: server_id.to_string(), track_id: track_id.to_string(), md5_16kb: md5_16kb.to_string(), }; - Ok(cache.get_waveform(&key)?.map(WaveformCachePayload::from)) + if let Some(e) = cache.get_waveform(&exact)? { + return Ok(Some(WaveformCachePayload::from(e))); + } + if !server_id.is_empty() { + let legacy = analysis_cache::TrackKey { + server_id: String::new(), + track_id: track_id.to_string(), + md5_16kb: md5_16kb.to_string(), + }; + if let Some(e) = cache.get_waveform(&legacy)? { + let _ = cache.relabel_legacy_to_server(server_id, track_id); + return Ok(Some(WaveformCachePayload::from(e))); + } + } + Ok(None) } -/// AppHandle-free helper: looks up the latest waveform for `track_id` -/// across all id variants (bare ↔ `stream:` prefix). See [`get_waveform_payload`]. +/// AppHandle-free helper: looks up the latest waveform for `(server_id, track_id)` +/// across all id variants (bare ↔ `stream:` prefix) with legacy fallback + lazy +/// re-tag. See [`get_waveform_payload`]. pub fn get_waveform_payload_for_track( cache: &analysis_cache::AnalysisCache, + server_id: &str, track_id: &str, ) -> Result, String> { Ok(cache - .get_latest_waveform_for_track(track_id)? + .get_latest_waveform_for_track(server_id, track_id)? .map(WaveformCachePayload::from)) } -/// AppHandle-free helper: looks up the latest loudness row for `track_id` -/// and recomputes `recommended_gain_db` against the optional requested target -/// (clamped to [-30, -8]). When `target_lufs` is `None`, the cached row's own -/// target is used. +/// AppHandle-free helper: looks up the latest loudness row for `(server_id, +/// track_id)` (legacy fallback + lazy re-tag) and recomputes `recommended_gain_db` +/// against the optional requested target (clamped to [-30, -8]). When +/// `target_lufs` is `None`, the cached row's own target is used. pub fn get_loudness_payload_for_track( cache: &analysis_cache::AnalysisCache, + server_id: &str, track_id: &str, target_lufs: Option, ) -> Result, String> { - Ok(cache.get_latest_loudness_for_track(track_id)?.map(|v| { + Ok(cache.get_latest_loudness_for_track(server_id, track_id)?.map(|v| { let requested_target = target_lufs.unwrap_or(v.target_lufs).clamp(-30.0, -8.0); let recommended_gain_db = analysis_cache::recommended_gain_for_target( v.integrated_lufs, @@ -106,9 +126,11 @@ pub fn get_loudness_payload_for_track( pub fn analysis_get_waveform( track_id: String, md5_16kb: String, + server_id: Option, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result, String> { - let result = get_waveform_payload(cache.inner(), &track_id, &md5_16kb); + let server_id = server_id.unwrap_or_default(); + let result = get_waveform_payload(cache.inner(), &server_id, &track_id, &md5_16kb); if let Ok(ref payload) = result { match payload { Some(v) => crate::app_deprintln!( @@ -127,9 +149,11 @@ pub fn analysis_get_waveform( #[tauri::command] pub fn analysis_get_waveform_for_track( track_id: String, + server_id: Option, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result, String> { - let result = get_waveform_payload_for_track(cache.inner(), &track_id); + let server_id = server_id.unwrap_or_default(); + let result = get_waveform_payload_for_track(cache.inner(), &server_id, &track_id); if let Ok(ref payload) = result { match payload { Some(v) => crate::app_deprintln!( @@ -146,25 +170,29 @@ pub fn analysis_get_waveform_for_track( pub fn analysis_get_loudness_for_track( track_id: String, target_lufs: Option, + server_id: Option, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result, String> { - get_loudness_payload_for_track(cache.inner(), &track_id, target_lufs) + let server_id = server_id.unwrap_or_default(); + get_loudness_payload_for_track(cache.inner(), &server_id, &track_id, target_lufs) } #[tauri::command] pub fn analysis_delete_loudness_for_track( track_id: String, + server_id: Option, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result { - cache.delete_loudness_for_track_id(&track_id) + cache.delete_loudness_for_track_id(&server_id.unwrap_or_default(), &track_id) } #[tauri::command] pub fn analysis_delete_waveform_for_track( track_id: String, + server_id: Option, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result { - cache.delete_waveform_for_track_id(&track_id) + cache.delete_waveform_for_track_id(&server_id.unwrap_or_default(), &track_id) } #[tauri::command] @@ -179,11 +207,13 @@ pub fn analysis_enqueue_seed_from_url( track_id: String, url: String, force: Option, + server_id: Option, app: tauri::AppHandle, ) -> Result<(), String> { if track_id.trim().is_empty() || url.trim().is_empty() { return Ok(()); } + let server_id = server_id.unwrap_or_default(); let force = force.unwrap_or(false); if !force { if let Some(playback) = app.try_state::() { @@ -198,7 +228,7 @@ pub fn analysis_enqueue_seed_from_url( } if !force { if let Some(cache) = app.try_state::() { - if cache.get_latest_loudness_for_track(&track_id)?.is_some() { + if cache.get_latest_loudness_for_track(&server_id, &track_id)?.is_some() { crate::app_deprintln!( "[analysis] backfill skip (already cached): {}", track_id @@ -215,7 +245,7 @@ pub fn analysis_enqueue_seed_from_url( .state .lock() .map_err(|_| "analysis backfill lock poisoned".to_string())?; - st.enqueue(track_id, url, high_priority) + st.enqueue(server_id, track_id, url, high_priority) }; match kind { AnalysisBackfillEnqueueKind::NewBack | AnalysisBackfillEnqueueKind::NewFront => { @@ -297,6 +327,7 @@ mod tests { fn key(track_id: &str, md5: &str) -> TrackKey { TrackKey { + server_id: String::new(), track_id: track_id.to_string(), md5_16kb: md5.to_string(), } @@ -342,7 +373,7 @@ mod tests { #[test] fn get_waveform_payload_returns_none_for_unknown_key() { let cache = AnalysisCache::open_in_memory(); - let payload = get_waveform_payload(&cache, "missing", "deadbeef").unwrap(); + let payload = get_waveform_payload(&cache, "", "missing", "deadbeef").unwrap(); assert!(payload.is_none()); } @@ -351,7 +382,7 @@ mod tests { let cache = AnalysisCache::open_in_memory(); let bins: Vec = (0..8u8).collect(); upsert_waveform(&cache, "abc", "deadbeef", bins.clone()); - let payload = get_waveform_payload(&cache, "abc", "deadbeef") + let payload = get_waveform_payload(&cache, "", "abc", "deadbeef") .unwrap() .expect("payload exists"); assert_eq!(payload.bins, bins); @@ -367,8 +398,8 @@ mod tests { let cache = AnalysisCache::open_in_memory(); upsert_waveform(&cache, "abc", "aaaa", vec![0u8; 8]); upsert_waveform(&cache, "abc", "bbbb", vec![0xFFu8; 8]); - let p1 = get_waveform_payload(&cache, "abc", "aaaa").unwrap().unwrap(); - let p2 = get_waveform_payload(&cache, "abc", "bbbb").unwrap().unwrap(); + let p1 = get_waveform_payload(&cache, "", "abc", "aaaa").unwrap().unwrap(); + let p2 = get_waveform_payload(&cache, "", "abc", "bbbb").unwrap().unwrap(); assert_ne!(p1.bins, p2.bins); } @@ -380,7 +411,7 @@ mod tests { // matching is the whole point of get_latest_waveform_for_track. let cache = AnalysisCache::open_in_memory(); upsert_waveform(&cache, "stream:abc", "deadbeef", vec![1u8; 8]); - let payload = get_waveform_payload_for_track(&cache, "abc") + let payload = get_waveform_payload_for_track(&cache, "", "abc") .unwrap() .expect("bare-id lookup must hit the stream-prefixed row"); assert_eq!(payload.bin_count, 4); @@ -389,7 +420,28 @@ mod tests { #[test] fn get_waveform_for_track_returns_none_for_unknown_track() { let cache = AnalysisCache::open_in_memory(); - assert!(get_waveform_payload_for_track(&cache, "phantom").unwrap().is_none()); + assert!(get_waveform_payload_for_track(&cache, "", "phantom").unwrap().is_none()); + } + + #[test] + fn get_waveform_payload_exact_key_falls_back_to_legacy_and_retags() { + // A pre-002 row lives under server_id=''. The exact-key read for a real + // server must find it via fallback and re-tag it under the server scope. + let cache = AnalysisCache::open_in_memory(); + upsert_waveform(&cache, "abc", "deadbeef", vec![7u8; 8]); // legacy '' row + + let payload = get_waveform_payload(&cache, "server-a", "abc", "deadbeef") + .unwrap() + .expect("legacy fallback must return the blob"); + assert_eq!(payload.bins, vec![7u8; 8]); + + // Re-tag side effect: the server-scoped exact key now resolves on its own. + let scoped = TrackKey { + server_id: "server-a".to_string(), + track_id: "abc".to_string(), + md5_16kb: "deadbeef".to_string(), + }; + assert!(cache.get_waveform(&scoped).unwrap().is_some()); } // ── get_loudness_payload_for_track ──────────────────────────────────────── @@ -400,7 +452,7 @@ mod tests { upsert_loudness(&cache, "abc", "deadbeef", -14.0); // Cached row: integrated -14, target -14 → gain 0. Request target -10 → // recommended gain = -10 - (-14) = +4 dB (capped by true-peak guard). - let payload = get_loudness_payload_for_track(&cache, "abc", Some(-10.0)) + let payload = get_loudness_payload_for_track(&cache, "", "abc", Some(-10.0)) .unwrap() .expect("loudness row exists"); assert_eq!(payload.target_lufs, -10.0); @@ -415,7 +467,7 @@ mod tests { fn get_loudness_for_track_uses_cached_target_when_request_is_none() { let cache = AnalysisCache::open_in_memory(); upsert_loudness(&cache, "abc", "deadbeef", -16.0); - let payload = get_loudness_payload_for_track(&cache, "abc", None) + let payload = get_loudness_payload_for_track(&cache, "", "abc", None) .unwrap() .unwrap(); assert_eq!(payload.target_lufs, -16.0); @@ -426,11 +478,11 @@ mod tests { let cache = AnalysisCache::open_in_memory(); upsert_loudness(&cache, "abc", "deadbeef", -14.0); // Out-of-range target gets clamped to [-30, -8]. - let too_high = get_loudness_payload_for_track(&cache, "abc", Some(0.0)) + let too_high = get_loudness_payload_for_track(&cache, "", "abc", Some(0.0)) .unwrap() .unwrap(); assert_eq!(too_high.target_lufs, -8.0); - let too_low = get_loudness_payload_for_track(&cache, "abc", Some(-100.0)) + let too_low = get_loudness_payload_for_track(&cache, "", "abc", Some(-100.0)) .unwrap() .unwrap(); assert_eq!(too_low.target_lufs, -30.0); @@ -439,7 +491,7 @@ mod tests { #[test] fn get_loudness_for_track_returns_none_for_unknown_track() { let cache = AnalysisCache::open_in_memory(); - assert!(get_loudness_payload_for_track(&cache, "phantom", None) + assert!(get_loudness_payload_for_track(&cache, "", "phantom", None) .unwrap() .is_none()); } diff --git a/src-tauri/crates/psysonic-audio/src/commands.rs b/src-tauri/crates/psysonic-audio/src/commands.rs index d43e0892..faa49613 100644 --- a/src-tauri/crates/psysonic-audio/src/commands.rs +++ b/src-tauri/crates/psysonic-audio/src/commands.rs @@ -29,6 +29,11 @@ use super::state::{ChainedInfo, PreloadedTrack}; /// cache to the track when playing `psysonic-local://` (hot/offline). Optional /// for HTTP streams (`playback_identity` is used as fallback). /// +/// `server_id`: app id of the server that owns this track (`playbackServerId ?? +/// activeServerId` on the frontend). Scopes the analysis-cache write key so a +/// later server switch can't surface another server's waveform for the same bare +/// `track_id`. Empty/absent falls back to the legacy `''` scope. +/// /// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no /// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP. #[tauri::command] @@ -45,6 +50,7 @@ pub async fn audio_play( manual: bool, // true = user-initiated skip → bypass crossfade, start immediately hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha) analysis_track_id: Option, + server_id: Option, stream_format_suffix: Option, app: AppHandle, state: State<'_, AudioEngine>, @@ -134,6 +140,11 @@ pub async fn audio_play( .filter(|s| !s.is_empty()); *state.current_analysis_track_id.lock().unwrap() = logical_trim.clone(); let cache_id_for_tasks = analysis_cache_track_id(logical_trim.as_deref(), &url); + // Playback server scope for the analysis-cache write key (empty → legacy ''). + let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty()); + // Pin it so the gain-resolution + replay-gain-update + device-resume reads + // scope to this server too (mirrors `current_analysis_track_id`). + *state.current_playback_server_id.lock().unwrap() = analysis_server_id.map(str::to_string); let format_hint = url_format_hint(&url); @@ -145,6 +156,7 @@ pub async fn audio_play( stream_format_suffix: stream_format_suffix.as_deref(), format_hint: format_hint.as_deref(), cache_id_for_tasks: cache_id_for_tasks.as_deref(), + server_id: analysis_server_id, reuse_chained_bytes, }, &state, @@ -239,6 +251,7 @@ pub async fn audio_play( url: &url, gen, cache_id_for_tasks: cache_id_for_tasks.as_deref(), + server_id: analysis_server_id, url_format_hint: format_hint.as_deref(), stream_format_suffix: stream_format_suffix.as_deref(), done_flag: done_flag.clone(), diff --git a/src-tauri/crates/psysonic-audio/src/device_resume.rs b/src-tauri/crates/psysonic-audio/src/device_resume.rs index a953cff5..bea2bc8a 100644 --- a/src-tauri/crates/psysonic-audio/src/device_resume.rs +++ b/src-tauri/crates/psysonic-audio/src/device_resume.rs @@ -143,6 +143,9 @@ pub(crate) async fn try_resume_after_device_change( engine.samples_played.store(0, Ordering::Relaxed); let hi_res_enabled = engine.current_sample_rate.load(Ordering::Relaxed) > 48_000; + // Resume re-plays the current track → scope its analysis writes to the + // pinned playback server (empty → legacy ''). + let resume_server = crate::helpers::current_playback_server_id_str(&engine); let ps: PlaybackSource = match build_playback_source_with_probe_fallback( play_input, @@ -150,6 +153,7 @@ pub(crate) async fn try_resume_after_device_change( url, gen, cache_id_for_tasks: snap.analysis_track_id.as_deref(), + server_id: Some(resume_server.as_str()), url_format_hint: format_hint.as_deref(), stream_format_suffix: stream_format_suffix.as_deref(), done_flag: done_flag.clone(), diff --git a/src-tauri/crates/psysonic-audio/src/engine.rs b/src-tauri/crates/psysonic-audio/src/engine.rs index b8c3fac7..fc9e0468 100644 --- a/src-tauri/crates/psysonic-audio/src/engine.rs +++ b/src-tauri/crates/psysonic-audio/src/engine.rs @@ -82,6 +82,11 @@ pub struct AudioEngine { /// Subsonic song id last passed from JS with `audio_play` (trimmed). Used /// for loudness/waveform cache when the URL is `psysonic-local://…`. pub(crate) current_analysis_track_id: Arc>>, + /// App server id (`playbackServerId ?? activeServerId`) of the current + /// playback, pinned by `audio_play`. Scopes analysis-cache reads (loudness + /// gain, replay-gain updates, device resume) to the right server so a switch + /// can't surface another server's blob for the same bare `track_id`. + pub(crate) current_playback_server_id: Arc>>, /// While a `RangedHttpSource` download task is filling the buffer for this /// `(track_id, play_generation)`, skip `analysis_enqueue_seed_from_url` for the /// same id — otherwise a parallel full GET + Symphonia competes with playback @@ -381,6 +386,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { radio_state: Mutex::new(None), current_playback_url: Arc::new(Mutex::new(None)), current_analysis_track_id: Arc::new(Mutex::new(None)), + current_playback_server_id: Arc::new(Mutex::new(None)), ranged_loudness_seed_hold: Arc::new(Mutex::new(None)), preview_sink: Arc::new(Mutex::new(None)), preview_gen: Arc::new(AtomicU64::new(0)), diff --git a/src-tauri/crates/psysonic-audio/src/helpers.rs b/src-tauri/crates/psysonic-audio/src/helpers.rs index 12bd6255..e70cc913 100644 --- a/src-tauri/crates/psysonic-audio/src/helpers.rs +++ b/src-tauri/crates/psysonic-audio/src/helpers.rs @@ -326,12 +326,14 @@ pub(crate) fn resolve_loudness_gain_from_cache( url: &str, target_lufs: f32, logical_track_id: Option<&str>, + server_id: &str, ) -> Option { resolve_loudness_gain_from_cache_impl( app, url, target_lufs, logical_track_id, + server_id, ResolveLoudnessCacheOpts::default(), ) } @@ -341,6 +343,7 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl( url: &str, target_lufs: f32, logical_track_id: Option<&str>, + server_id: &str, opts: ResolveLoudnessCacheOpts, ) -> Option { // Only a SQLite loudness row counts here. Ephemeral JS hints (`analysis:loudness-partial`) @@ -363,7 +366,7 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl( } return None; }; - resolve_loudness_gain_with_cache(cache.inner(), &track_id, target_lufs, opts) + resolve_loudness_gain_with_cache(cache.inner(), server_id, &track_id, target_lufs, opts) } /// AppHandle-free core of [`resolve_loudness_gain_from_cache_impl`]. Looks up @@ -376,15 +379,16 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl( /// connection's row cache is warm for the next IPC tick. pub(crate) fn resolve_loudness_gain_with_cache( cache: &psysonic_analysis::analysis_cache::AnalysisCache, + server_id: &str, track_id: &str, target_lufs: f32, opts: ResolveLoudnessCacheOpts, ) -> Option { if opts.touch_waveform { // Bind / preload: verify waveform context exists alongside loudness lookup. - let _ = cache.get_latest_waveform_for_track(track_id); + let _ = cache.get_latest_waveform_for_track(server_id, track_id); } - match cache.get_latest_loudness_for_track(track_id) { + match cache.get_latest_loudness_for_track(server_id, track_id) { Ok(Some(row)) if row.integrated_lufs.is_finite() => { let recommended = psysonic_analysis::analysis_cache::recommended_gain_for_target( row.integrated_lufs, @@ -493,6 +497,17 @@ pub(crate) struct TrackGainInputs { /// Read engine state + resolve the loudness cache for a track that's about to /// start playing. JS-supplied `loudness_gain_db` is **not** consulted at bind /// time (only post-cache via `audio_update_replay_gain`). +/// Current playback server scope (`current_playback_server_id`, empty when +/// unset) for scoping analysis-cache reads on the gain-resolution path. +pub(crate) fn current_playback_server_id_str(state: &AudioEngine) -> String { + state + .current_playback_server_id + .lock() + .ok() + .and_then(|g| (*g).clone()) + .unwrap_or_default() +} + pub(crate) fn resolve_track_gain_inputs( state: &AudioEngine, app: &AppHandle, @@ -503,7 +518,9 @@ pub(crate) fn resolve_track_gain_inputs( let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed)); let norm_mode = state.normalization_engine.load(Ordering::Relaxed); let pre_analysis_db = loudness_pre_analysis_db_for_engine(state); - let cache_loudness_db = resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id); + let server_id = current_playback_server_id_str(state); + let cache_loudness_db = + resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id, &server_id); let effective_loudness_db = if norm_mode == 2 { loudness_gain_db_after_resolve( cache_loudness_db, @@ -736,6 +753,7 @@ pub(crate) async fn fetch_data( /// loudness SQLite can fill **offline** without `analysis_enqueue_seed_from_url` HTTP. pub(crate) fn spawn_analysis_seed_from_in_memory_bytes( app: &AppHandle, + server_id: Option<&str>, cache_track_id: Option<&str>, gen: u64, gen_arc: &Arc, @@ -748,6 +766,7 @@ pub(crate) fn spawn_analysis_seed_from_in_memory_bytes( return; } let track_id = track_id.to_string(); + let server_id = server_id.unwrap_or("").to_string(); let bytes = bytes.to_vec(); let app = app.clone(); let gen_arc = gen_arc.clone(); @@ -761,7 +780,7 @@ pub(crate) fn spawn_analysis_seed_from_in_memory_bytes( if gen_arc.load(Ordering::SeqCst) != gen { return; } - if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), bytes, high).await { + if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), server_id, track_id.clone(), bytes, high).await { crate::app_eprintln!( "[analysis] in-memory play path seed failed for {}: {}", track_id, @@ -774,6 +793,7 @@ pub(crate) fn spawn_analysis_seed_from_in_memory_bytes( /// Full-track analysis for a completed ranged stream spilled to disk (> RAM promote cap). pub(crate) fn spawn_analysis_seed_from_spill_file( app: &AppHandle, + server_id: Option<&str>, track_id: &str, spill_path: std::path::PathBuf, gen: u64, @@ -783,6 +803,7 @@ pub(crate) fn spawn_analysis_seed_from_spill_file( if track_id.is_empty() { return; } + let server_id = server_id.unwrap_or("").to_string(); let app = app.clone(); let gen_arc = gen_arc.clone(); let max_bytes = crate::stream::LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES; @@ -822,6 +843,7 @@ pub(crate) fn spawn_analysis_seed_from_spill_file( let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id); if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed( app, + server_id, track_id.clone(), bytes, high, @@ -1407,6 +1429,7 @@ mod tests { fn upsert_loudness_row(cache: &AnalysisCache, track_id: &str, integrated: f64, target: f64) { let k = TrackKey { + server_id: String::new(), track_id: track_id.to_string(), md5_16kb: "deadbeef".to_string(), }; @@ -1430,6 +1453,7 @@ mod tests { let cache = AnalysisCache::open_in_memory(); let g = resolve_loudness_gain_with_cache( &cache, + "", "no-such-track", -14.0, ResolveLoudnessCacheOpts::default(), @@ -1444,6 +1468,7 @@ mod tests { upsert_loudness_row(&cache, "abc", -23.0, -14.0); let g = resolve_loudness_gain_with_cache( &cache, + "", "abc", -14.0, ResolveLoudnessCacheOpts::default(), @@ -1468,6 +1493,7 @@ mod tests { upsert_loudness_row(&cache, "stream:abc", -16.0, -14.0); let g = resolve_loudness_gain_with_cache( &cache, + "", "abc", -14.0, ResolveLoudnessCacheOpts::default(), @@ -1481,6 +1507,7 @@ mod tests { upsert_loudness_row(&cache, "abc", -20.0, -14.0); let g_quiet = resolve_loudness_gain_with_cache( &cache, + "", "abc", -20.0, ResolveLoudnessCacheOpts::default(), @@ -1488,6 +1515,7 @@ mod tests { .unwrap(); let g_loud = resolve_loudness_gain_with_cache( &cache, + "", "abc", -10.0, ResolveLoudnessCacheOpts::default(), @@ -1508,7 +1536,7 @@ mod tests { touch_waveform: false, log_soft_misses: false, }; - let g = resolve_loudness_gain_with_cache(&cache, "abc", -14.0, opts); + let g = resolve_loudness_gain_with_cache(&cache, "", "abc", -14.0, opts); assert!(g.is_some()); } } diff --git a/src-tauri/crates/psysonic-audio/src/mix_commands.rs b/src-tauri/crates/psysonic-audio/src/mix_commands.rs index 909a82ce..7acf245f 100644 --- a/src-tauri/crates/psysonic-audio/src/mix_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/mix_commands.rs @@ -50,12 +50,14 @@ pub fn audio_update_replay_gain( .filter(|s| !s.is_empty()); // If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db` // for the uncached path (`effective_loudness_db` / UI gain follow from `compute_gain`). + let server_for_loudness = crate::helpers::current_playback_server_id_str(&state); let cache_loudness = url_for_loudness.as_deref().and_then(|u| { resolve_loudness_gain_from_cache_impl( &app, u, target_lufs, logical_for_loudness.as_deref(), + &server_for_loudness, ResolveLoudnessCacheOpts { touch_waveform: false, log_soft_misses: false, diff --git a/src-tauri/crates/psysonic-audio/src/play_input.rs b/src-tauri/crates/psysonic-audio/src/play_input.rs index 2dbff9fc..ee088141 100644 --- a/src-tauri/crates/psysonic-audio/src/play_input.rs +++ b/src-tauri/crates/psysonic-audio/src/play_input.rs @@ -54,6 +54,9 @@ pub(super) struct PlayInputContext<'a> { pub stream_format_suffix: Option<&'a str>, pub format_hint: Option<&'a str>, pub cache_id_for_tasks: Option<&'a str>, + /// Playback server scope for the analysis-cache write key (empty/`None` → + /// legacy `''`). Rides alongside `cache_id_for_tasks` into every seed path. + pub server_id: Option<&'a str>, /// `Some(bytes)` when manual-skip onto a pre-chained track reuses bytes /// from the chained-info block. pub reuse_chained_bytes: Option>, @@ -76,6 +79,7 @@ pub(super) async fn select_play_input( if let Some(d) = ctx.reuse_chained_bytes { spawn_analysis_seed_from_in_memory_bytes( app, + ctx.server_id, ctx.cache_id_for_tasks, ctx.gen, &state.generation, @@ -112,6 +116,7 @@ pub(super) async fn select_play_input( }; spawn_analysis_seed_from_in_memory_bytes( app, + ctx.server_id, ctx.cache_id_for_tasks, ctx.gen, &state.generation, @@ -143,7 +148,10 @@ fn open_local_file_input( if let Some(seed_id) = ctx.cache_id_for_tasks { let skip_cpu_seed = app .try_state::() - .map(|c| c.cpu_seed_redundant_for_track(seed_id).unwrap_or(false)) + .map(|c| { + c.cpu_seed_redundant_for_track(ctx.server_id.unwrap_or(""), seed_id) + .unwrap_or(false) + }) .unwrap_or(false); if !skip_cpu_seed { let path_owned = std::path::PathBuf::from(path); @@ -151,6 +159,7 @@ fn open_local_file_input( let gen_seed = ctx.gen; let gen_arc_seed = state.generation.clone(); let seed_id = seed_id.to_string(); + let seed_server = ctx.server_id.unwrap_or("").to_string(); tokio::spawn(async move { if gen_arc_seed.load(Ordering::SeqCst) != gen_seed { return; @@ -178,7 +187,7 @@ fn open_local_file_input( ); let high = crate::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id); if let Err(e) = - psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await + psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app_seed.clone(), seed_server.clone(), seed_id.clone(), data, high).await { crate::app_eprintln!( "[analysis] local-file seed failed for {}: {}", @@ -316,6 +325,7 @@ async fn open_ranged_or_streaming_input( state.normalization_target_lufs.clone(), state.loudness_pre_analysis_attenuation_db.clone(), ctx.cache_id_for_tasks.map(|s| s.to_string()), + ctx.server_id.map(|s| s.to_string()), loudness_hold_for_defer, playback_armed, stream_hint.clone(), @@ -369,6 +379,7 @@ async fn open_ranged_or_streaming_input( state.normalization_target_lufs.clone(), state.loudness_pre_analysis_attenuation_db.clone(), ctx.cache_id_for_tasks.map(|s| s.to_string()), + ctx.server_id.map(|s| s.to_string()), playback_armed, )); @@ -459,6 +470,7 @@ pub(crate) struct BuildSourceArgs<'a> { pub url: &'a str, pub gen: u64, pub cache_id_for_tasks: Option<&'a str>, + pub server_id: Option<&'a str>, pub url_format_hint: Option<&'a str>, pub stream_format_suffix: Option<&'a str>, pub done_flag: Arc, @@ -686,6 +698,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback( url, gen, cache_id_for_tasks, + server_id, url_format_hint, stream_format_suffix, done_flag, @@ -751,6 +764,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback( } spawn_analysis_seed_from_in_memory_bytes( app, + server_id, cache_id_for_tasks, gen, &state.generation, diff --git a/src-tauri/crates/psysonic-audio/src/preload_commands.rs b/src-tauri/crates/psysonic-audio/src/preload_commands.rs index 097f2198..c700389b 100644 --- a/src-tauri/crates/psysonic-audio/src/preload_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/preload_commands.rs @@ -17,6 +17,7 @@ pub async fn audio_preload( url: String, duration_hint: f64, analysis_track_id: Option, + server_id: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -56,7 +57,8 @@ pub async fn audio_preload( data.len() as f64 / (1024.0 * 1024.0) ); let high = crate::engine::analysis_track_id_is_current_playback(&state, &track_id); - if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await { + let sid = server_id.clone().unwrap_or_default(); + if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), data.clone(), high).await { crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e); } } diff --git a/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs b/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs index 06ecbef8..9b7f313d 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs @@ -460,6 +460,8 @@ pub(crate) async fn ranged_download_task( normalization_target_lufs: Arc, loudness_pre_analysis_attenuation_db: Arc, cache_track_id: Option, + // Playback server scope for the analysis-cache write key (empty/`None` → legacy ''). + server_id: Option, // When `Some`, ranged playback seeds on completion — defer HTTP backfill for that // track; `None` for large files where ranged skips seed (needs backfill). loudness_seed_hold: Option, @@ -643,7 +645,8 @@ pub(crate) async fn ranged_download_task( } if let Some(track_id) = cache_track_id { let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id); - if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await { + let sid = server_id.clone().unwrap_or_default(); + if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), data.clone(), high).await { crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e); } } @@ -678,6 +681,7 @@ pub(crate) async fn ranged_download_task( install_stream_completed_spill(&spill_cache_slot, url, path.clone()); spawn_analysis_seed_from_spill_file( &app, + server_id.as_deref(), &track_id, path, gen, diff --git a/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs b/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs index bc220e09..0533fff3 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs @@ -35,6 +35,8 @@ pub(crate) async fn track_download_task( normalization_target_lufs: Arc, loudness_pre_analysis_attenuation_db: Arc, cache_track_id: Option, + // Playback server scope for the analysis-cache write key (empty/`None` → legacy ''). + server_id: Option, playback_armed: Arc, ) { let mut downloaded: u64 = 0; @@ -166,8 +168,9 @@ pub(crate) async fn track_download_task( capture.len() as f64 / (1024.0 * 1024.0) ); let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id); + let sid = server_id.clone().unwrap_or_default(); if let Err(e) = - psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), capture.clone(), high).await + psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), capture.clone(), high).await { crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e); } diff --git a/src-tauri/crates/psysonic-audio/src/transport_commands.rs b/src-tauri/crates/psysonic-audio/src/transport_commands.rs index a56a26bc..81a751c0 100644 --- a/src-tauri/crates/psysonic-audio/src/transport_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/transport_commands.rs @@ -108,6 +108,7 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) { state.generation.fetch_add(1, Ordering::SeqCst); *state.current_playback_url.lock().unwrap() = None; *state.current_analysis_track_id.lock().unwrap() = None; + *state.current_playback_server_id.lock().unwrap() = None; *state.chained_info.lock().unwrap() = None; // Keep `stream_completed_cache`: natural track end often calls `audio_stop` when the // queue is exhausted; clearing here dropped the full ranged buffer and forced a diff --git a/src-tauri/crates/psysonic-core/src/ports.rs b/src-tauri/crates/psysonic-core/src/ports.rs index fc4f0610..a49ad09c 100644 --- a/src-tauri/crates/psysonic-core/src/ports.rs +++ b/src-tauri/crates/psysonic-core/src/ports.rs @@ -49,3 +49,63 @@ impl PlaybackQueryHandle { (self.should_defer_backfill)(track_id) } } + +/// Bridge for the analysis→library back-edge (E2 content_hash): when the +/// analysis pipeline has the playback-derived `md5_16kb` for a track, it records +/// it as `track.content_hash` in the library DB. `psysonic-analysis` must not +/// depend on `psysonic-library`, so the shell crate registers a closure that +/// captures an `AppHandle` and patches the library; analysis looks this handle +/// up via `try_state::<…>()` and fires it after a successful seed. +/// +/// The patch is a no-op when the library has no row for `(server_id, track_id)` +/// (index off for that server), so the sink is safe to call unconditionally. +type RecordContentHashFn = Arc; + +#[derive(Clone)] +pub struct ContentHashSink { + record: RecordContentHashFn, +} + +impl ContentHashSink { + pub fn new(record: F) -> Self + where + F: Fn(&str, &str, &str) + Send + Sync + 'static, + { + Self { record: Arc::new(record) } + } + + /// Record `md5_16kb` as the library `content_hash` for `(server_id, track_id)`. + /// Best-effort: the registered closure swallows errors and no-ops when the + /// library has no matching row. + pub fn record_content_hash(&self, server_id: &str, track_id: &str, md5_16kb: &str) { + (self.record)(server_id, track_id, md5_16kb) + } +} + +/// Library→analysis readiness probe (E3 enrichment): given `(server_id, +/// track_id, md5_16kb)`, returns `(waveform_ready, loudness_ready)` from the +/// analysis cache. `psysonic-library` must not depend on `psysonic-analysis`, so +/// the shell crate registers a closure that captures an `AppHandle`, looks up the +/// `AnalysisCache`, and probes the exact key with a legacy `''` fallback — +/// **read-only, no lazy re-tag**. Library looks this handle up via +/// `try_state::<…>()`; absent handle ⇒ `(false, false)`. +type QueryReadinessFn = Arc (bool, bool) + Send + Sync + 'static>; + +#[derive(Clone)] +pub struct AnalysisReadinessQuery { + query: QueryReadinessFn, +} + +impl AnalysisReadinessQuery { + pub fn new(query: F) -> Self + where + F: Fn(&str, &str, &str) -> (bool, bool) + Send + Sync + 'static, + { + Self { query: Arc::new(query) } + } + + /// `(waveform_ready, loudness_ready)` for `(server_id, track_id, md5_16kb)`. + pub fn readiness(&self, server_id: &str, track_id: &str, md5_16kb: &str) -> (bool, bool) { + (self.query)(server_id, track_id, md5_16kb) + } +} diff --git a/src-tauri/crates/psysonic-integration/Cargo.toml b/src-tauri/crates/psysonic-integration/Cargo.toml index f4bc5715..62553ecb 100644 --- a/src-tauri/crates/psysonic-integration/Cargo.toml +++ b/src-tauri/crates/psysonic-integration/Cargo.toml @@ -12,7 +12,7 @@ tauri = { version = "2" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt", "time", "sync"] } -reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] } +reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] } futures-util = "0.3" discord-rich-presence = "1.1" url = "2" diff --git a/src-tauri/crates/psysonic-integration/src/lib.rs b/src-tauri/crates/psysonic-integration/src/lib.rs index 3182b29c..0047d73a 100644 --- a/src-tauri/crates/psysonic-integration/src/lib.rs +++ b/src-tauri/crates/psysonic-integration/src/lib.rs @@ -4,6 +4,7 @@ //! Domains: //! - `discord` — Discord Rich Presence (album artwork via iTunes) //! - `navidrome` — Navidrome's native REST API (admin: users/playlists/covers/queries) +//! - `subsonic` — Subsonic REST surface for the library-sync engine //! - `remote` — radio-browser, last.fm, ICY-meta probe, generic CORS proxy //! - `bandsintown` — bandsintown events for an artist @@ -15,3 +16,4 @@ pub mod bandsintown; pub mod discord; pub mod navidrome; pub mod remote; +pub mod subsonic; diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs b/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs index d838a950..72d1b096 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs @@ -7,5 +7,8 @@ mod client; pub mod covers; pub mod playlists; +pub mod probe; pub mod queries; pub mod users; + +pub use client::navidrome_token; diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/probe.rs b/src-tauri/crates/psysonic-integration/src/navidrome/probe.rs new file mode 100644 index 00000000..687a7bb3 --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/navidrome/probe.rs @@ -0,0 +1,116 @@ +//! Navidrome-side probes for the library-sync capability detection. +//! +//! Lives next to `client.rs` / `queries.rs` so the existing native-REST +//! auth shape (`Authorization: Bearer …`) is reused. PR-3a only needs +//! one probe — does the server expose the paginated `/api/song` bulk +//! endpoint? — so this stays a free function rather than a client +//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b. + +use super::client::{nd_err, nd_http_client}; + +/// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with +/// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or +/// disabled) and 5xx surfaces as `Err`. The body is intentionally not +/// inspected — empty libraries still respond with `[]` and a 200. +/// +/// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability +/// flag. Wider call into the actual ingest path (`nd_list_songs` port) +/// is PR-3b's job. +pub async fn native_bulk_available(server_url: &str, token: &str) -> Result { + let client = nd_http_client(); + let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/')); + let resp = client + .get(url) + .header("X-ND-Authorization", format!("Bearer {token}")) + .send() + .await + .map_err(nd_err)?; + + let status = resp.status(); + if status.is_success() { + return Ok(true); + } + if status.is_client_error() { + // 401/403/404 — endpoint genuinely unavailable for this token / + // build. Treat as "no native bulk" and fall back to Subsonic. + return Ok(false); + } + Err(format!("HTTP {status}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{header, method as wm_method, path as wm_path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test(flavor = "multi_thread")] + async fn native_bulk_available_returns_true_on_200() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .and(query_param("_start", "0")) + .and(query_param("_end", "1")) + .and(header("X-ND-Authorization", "Bearer tok-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) + .mount(&server) + .await; + + let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap(); + assert!(ok); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_bulk_available_returns_false_on_404() { + // Server is reachable, auth might be ok, but the endpoint just + // doesn't exist (older Navidrome, mod_rewrite mishap, …). + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let ok = native_bulk_available(&server.uri(), "tok").await.unwrap(); + assert!(!ok); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_bulk_available_returns_false_on_401_auth_failure() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let ok = native_bulk_available(&server.uri(), "bad").await.unwrap(); + assert!(!ok, "401 reads as `endpoint not available for this caller`"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_bulk_available_surfaces_5xx_as_error() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + + let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err(); + assert!(err.contains("503")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_bulk_available_strips_trailing_slash() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) + .mount(&server) + .await; + + let with_slash = format!("{}/", server.uri()); + assert!(native_bulk_available(&with_slash, "tok").await.unwrap()); + } +} diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs b/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs index fb4116f3..934f239f 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs @@ -4,14 +4,15 @@ use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry}; -/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list. -/// Available to any authenticated user (no admin required). Returns raw JSON array. -#[tauri::command] -pub async fn nd_list_songs( - server_url: String, - token: String, - sort: String, - order: String, +/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated +/// song list. Pure async helper used by the library-side N1 ingest +/// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]` +/// variant below for existing frontend callers. +pub async fn nd_list_songs_internal( + server_url: &str, + token: &str, + sort: &str, + order: &str, start: u32, end: u32, ) -> Result { @@ -22,7 +23,7 @@ pub async fn nd_list_songs( let resp = nd_retry(|| { nd_http_client() .get(&url) - .header("X-ND-Authorization", format!("Bearer {}", token)) + .header("X-ND-Authorization", format!("Bearer {token}")) .send() }).await?; if !resp.status().is_success() { @@ -31,6 +32,20 @@ pub async fn nd_list_songs( resp.json::().await.map_err(nd_err) } +/// Tauri-visible variant — owned-String arguments to keep the IPC +/// surface unchanged for existing call sites in the WebView. +#[tauri::command] +pub async fn nd_list_songs( + server_url: String, + token: String, + sort: String, + order: String, + start: u32, + end: u32, +) -> Result { + nd_list_songs_internal(&server_url, &token, &sort, &order, start, end).await +} + /// Build the `_filters` JSON for native-API list calls. Optionally narrows the /// query to a single library — `library_id` is the same scope key the Navidrome /// web UI sends, and it matches the Subsonic `musicFolderId` we store per server. diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/auth.rs b/src-tauri/crates/psysonic-integration/src/subsonic/auth.rs new file mode 100644 index 00000000..c112a881 --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/subsonic/auth.rs @@ -0,0 +1,91 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Subsonic credentials in the legacy salted-md5 shape (spec v1.13+): +/// the client sends `u` + `t = md5(password || salt)` + `s = salt`, +/// never the plaintext password. The salt is per-request to defeat +/// replay, but doesn't need to be cryptographically random — Subsonic's +/// API treats it as an opaque uniqueness nonce. +#[derive(Debug, Clone)] +pub struct SubsonicCredentials { + pub username: String, + pub token: String, + pub salt: String, +} + +impl SubsonicCredentials { + /// Derive a credentials triple from a plaintext password. Generates a + /// fresh salt and computes `md5(password || salt)`. + pub fn from_password(username: impl Into, password: &str) -> Self { + let salt = fresh_salt(); + let token = md5_hex(&format!("{password}{salt}")); + Self { username: username.into(), token, salt } + } + + /// Use a caller-supplied salt + token. Intended for tests and for + /// callers that already cache the derivation result. + pub fn with_static(username: impl Into, token: impl Into, salt: impl Into) -> Self { + Self { username: username.into(), token: token.into(), salt: salt.into() } + } +} + +/// Per-process monotonically-advancing nonce mixed into the salt so the +/// hot loop of `from_password` calls doesn't repeat itself even at the +/// same nanosecond. +static SALT_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn fresh_salt() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let counter = SALT_COUNTER.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id() as u64; + format!("{:016x}{:08x}", nanos ^ pid.rotate_left(13), counter) +} + +fn md5_hex(input: &str) -> String { + let digest = md5::compute(input.as_bytes()); + format!("{digest:x}") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn from_password_computes_md5_token() { + // Force the salt by going through with_static for a deterministic check. + let salt = "abc123"; + let password = "sesame"; + let expected = md5_hex(&format!("{password}{salt}")); + let creds = SubsonicCredentials::with_static("user", &expected, salt); + assert_eq!(creds.token, expected); + assert_eq!(creds.token.len(), 32, "md5 hex must be 32 chars"); + } + + #[test] + fn fresh_salt_is_unique_across_rapid_calls() { + let mut seen: HashSet = HashSet::new(); + for _ in 0..1000 { + assert!(seen.insert(fresh_salt()), "fresh_salt repeated"); + } + } + + #[test] + fn from_password_produces_different_salts_per_call() { + let a = SubsonicCredentials::from_password("u", "pw"); + let b = SubsonicCredentials::from_password("u", "pw"); + assert_ne!(a.salt, b.salt); + assert_ne!(a.token, b.token, "different salt → different token"); + } + + #[test] + fn md5_hex_matches_known_vector() { + // md5("") = d41d8cd98f00b204e9800998ecf8427e + assert_eq!(md5_hex(""), "d41d8cd98f00b204e9800998ecf8427e"); + // md5("abc") = 900150983cd24fb0d6963f7d28e17f72 + assert_eq!(md5_hex("abc"), "900150983cd24fb0d6963f7d28e17f72"); + } +} diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/client.rs b/src-tauri/crates/psysonic-integration/src/subsonic/client.rs new file mode 100644 index 00000000..d4104bb0 --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/subsonic/client.rs @@ -0,0 +1,1172 @@ +//! `SubsonicClient` — read-only Subsonic REST surface needed by the +//! library-sync engine (phase B per spec §10 / PR-2). Auth is the legacy +//! salted-md5 token (spec v1.13+); request shape is GET to +//! `{base}/rest/{method}.view?u&t&s&v&c&f=json&…`. +//! +//! This client is pure Rust — **no `#[tauri::command]`**. Tauri commands +//! that talk to the library live in PR-5 / phase D. + +use serde::de::DeserializeOwned; +use serde::Deserialize; + +use super::auth::SubsonicCredentials; +use super::error::{flatten_reqwest_error, SubsonicError}; +use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song}; + +/// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that +/// Navidrome and other servers in the wild support. OpenSubsonic +/// extensions deserialize when present (additive on the wire). +pub const SUBSONIC_API_VERSION: &str = "1.16.1"; + +/// Subsonic `c` parameter — server logs and rate-limiters key off this. +/// Matches the frontend `subsonicClient.ts` shape (`psysonic/`) +/// so Navidrome log lines correlate across the WebView and Rust sync +/// paths. +pub const SUBSONIC_CLIENT_ID: &str = concat!("psysonic/", env!("CARGO_PKG_VERSION")); + +#[derive(Clone)] +enum CredentialsMode { + /// Production path: cache the plaintext password and derive a fresh + /// `(token = md5(password || salt), salt)` per request. Matches the + /// frontend's `getAuthParams()` lifecycle and follows Subsonic + /// replay-resistance guidance. + FromPassword { username: String, password: String }, + /// Test path: re-use a pre-derived credentials triple as-is. Used by + /// wiremock tests (deterministic query params) and by callers that + /// already maintain a cached token+salt. + Static(SubsonicCredentials), +} + +#[derive(Clone)] +pub struct SubsonicClient { + base_url: String, + credentials: CredentialsMode, + http: reqwest::Client, +} + +impl SubsonicClient { + /// Production constructor — caches the password and derives a fresh + /// salt + token on every API call. + pub fn new( + base_url: impl Into, + username: impl Into, + password: impl Into, + ) -> Self { + Self::with_http(base_url, username, password, default_http_client()) + } + + /// As `new`, but with a caller-supplied `reqwest::Client` — used by + /// callers that share a pool across multiple Subsonic servers or + /// need custom timeouts. + pub fn with_http( + base_url: impl Into, + username: impl Into, + password: impl Into, + http: reqwest::Client, + ) -> Self { + let mut url = base_url.into(); + while url.ends_with('/') { + url.pop(); + } + Self { + base_url: url, + credentials: CredentialsMode::FromPassword { + username: username.into(), + password: password.into(), + }, + http, + } + } + + /// Test-/cache-friendly constructor — re-uses the same + /// `SubsonicCredentials` triple on every call. Wiremock tests rely on + /// this for deterministic `s=` and `t=` query params; production code + /// goes through `new` / `with_http`. + pub fn with_static_credentials( + base_url: impl Into, + credentials: SubsonicCredentials, + http: reqwest::Client, + ) -> Self { + let mut url = base_url.into(); + while url.ends_with('/') { + url.pop(); + } + Self { + base_url: url, + credentials: CredentialsMode::Static(credentials), + http, + } + } + + pub(crate) fn build_credentials(&self) -> SubsonicCredentials { + match &self.credentials { + CredentialsMode::FromPassword { username, password } => { + SubsonicCredentials::from_password(username, password) + } + CredentialsMode::Static(c) => c.clone(), + } + } + + /// B1 — ping. Returns `Ok(())` when the server replied with + /// `status="ok"`; surfaces `SubsonicError::Api{40,…}` for invalid + /// credentials and the usual transport / status errors otherwise. + pub async fn ping(&self) -> Result<(), SubsonicError> { + let body = self.send("ping", &[]).await?; + parse_envelope_status_only(&body) + } + + /// C1 helper — `#ping` with the envelope metadata captured. Used by + /// the capability probe to detect server type (`navidrome` → + /// `UnstableTrackIds`) and OpenSubsonic support without issuing a + /// second request. + pub async fn server_info(&self) -> Result { + let body = self.send("ping", &[]).await?; + parse_server_info(&body) + } + + /// B2 — `getScanStatus`. Lightweight poll for huge libraries + /// (spec §2.3 / §6.2.2). + pub async fn get_scan_status(&self) -> Result { + self.fetch("getScanStatus", &[], "scanStatus").await + } + + /// B5 — `getIndexes(musicFolderId?, ifModifiedSince?)`. File-tree + /// browse with conditional fetch — when `ifModifiedSince` matches the + /// server's `lastScan`, the response body is empty but the + /// `lastModified` watermark is still returned (spec §3.1). + pub async fn get_indexes( + &self, + music_folder_id: Option<&str>, + if_modified_since_ms: Option, + ) -> Result { + let ims = if_modified_since_ms.map(|n| n.to_string()); + let mut params: Vec<(&str, &str)> = Vec::new(); + if let Some(id) = music_folder_id { + params.push(("musicFolderId", id)); + } + if let Some(ref s) = ims { + params.push(("ifModifiedSince", s)); + } + self.fetch("getIndexes", ¶ms, "indexes").await + } + + /// B8 — `getArtists(musicFolderId?)`. ID3-path artist index. Always + /// returns full body; clients compare `last_modified_ms` against the + /// watermark in `sync_state` to decide whether a delta pass is needed + /// (spec §2.2.1). + pub async fn get_artists( + &self, + music_folder_id: Option<&str>, + ) -> Result { + let mut params: Vec<(&str, &str)> = Vec::new(); + if let Some(id) = music_folder_id { + params.push(("musicFolderId", id)); + } + self.fetch("getArtists", ¶ms, "artists").await + } + + /// B3a — `getAlbumList2(type, size, offset, musicFolderId?)`. Returns + /// just the album summaries; the caller follows up with `get_album` + /// per id to enumerate songs. + pub async fn get_album_list2( + &self, + list_type: &str, + size: u32, + offset: u32, + music_folder_id: Option<&str>, + ) -> Result, SubsonicError> { + let size_s = size.to_string(); + let offset_s = offset.to_string(); + let mut params: Vec<(&str, &str)> = vec![ + ("type", list_type), + ("size", size_s.as_str()), + ("offset", offset_s.as_str()), + ]; + if let Some(id) = music_folder_id { + params.push(("musicFolderId", id)); + } + let wrapped: AlbumListWrapper = + self.fetch("getAlbumList2", ¶ms, "albumList2").await?; + Ok(wrapped.album) + } + + /// B3b — `getAlbum(id)`. Returns the album metadata plus the full song list. + pub async fn get_album(&self, album_id: &str) -> Result { + self.fetch("getAlbum", &[("id", album_id)], "album").await + } + + /// B4 — `search3(query, songCount, songOffset, musicFolderId?)`. + /// Navidrome accepts an empty query and returns all songs paged — + /// spec §2.4 documents that quirk and Psysonic already relies on it. + pub async fn search3( + &self, + query: &str, + song_count: u32, + song_offset: u32, + music_folder_id: Option<&str>, + ) -> Result { + let song_count_s = song_count.to_string(); + let song_offset_s = song_offset.to_string(); + let mut params: Vec<(&str, &str)> = vec![ + ("query", query), + ("songCount", song_count_s.as_str()), + ("songOffset", song_offset_s.as_str()), + ]; + if let Some(id) = music_folder_id { + params.push(("musicFolderId", id)); + } + self.fetch("search3", ¶ms, "searchResult3").await + } + + /// Variant of `search3` returning the raw `serde_json::Value` for + /// the `searchResult3` body alongside the typed projection. The S1 + /// ingest path (PR-3b InitialSyncRunner) needs the per-song raw + /// sub-trees verbatim for `track.raw_json`, so unknown OpenSubsonic + /// extensions (`replayGain`, `contributors`, …) survive ingest + /// instead of being lost in the typed reserialise (ADR-7). + pub async fn search3_with_raw( + &self, + query: &str, + song_count: u32, + song_offset: u32, + music_folder_id: Option<&str>, + ) -> Result<(SearchResult, serde_json::Value), SubsonicError> { + let song_count_s = song_count.to_string(); + let song_offset_s = song_offset.to_string(); + let mut params: Vec<(&str, &str)> = vec![ + ("query", query), + ("songCount", song_count_s.as_str()), + ("songOffset", song_offset_s.as_str()), + ]; + if let Some(id) = music_folder_id { + params.push(("musicFolderId", id)); + } + let body = self.send("search3", ¶ms).await?; + parse_envelope_with_raw(&body, "searchResult3") + } + + /// B6 — `getSong(id)`. Returns `SubsonicError::NotFound` when the + /// server replies with error code 70 (spec §2.6) — the tombstone + /// reconciler matches on that variant directly. + pub async fn get_song(&self, song_id: &str) -> Result { + self.fetch("getSong", &[("id", song_id)], "song").await + } + + /// Variant of `get_song` that also returns the raw `serde_json::Value` + /// the server sent for the `song` body. The sync engine (PR-3) stores + /// that raw object verbatim in `track.raw_json` so OpenSubsonic + /// extensions (`contributors`, `replayGain`, future fields) survive + /// without being mirrored into the typed `Song` struct. + pub async fn get_song_with_raw( + &self, + song_id: &str, + ) -> Result<(Song, serde_json::Value), SubsonicError> { + let body = self.send("getSong", &[("id", song_id)]).await?; + parse_envelope_with_raw(&body, "song") + } + + /// Variant of `get_album` returning the raw `serde_json::Value` for + /// the `album` body alongside the typed projection. The album JSON + /// already nests the full song list, so the sync engine can derive + /// per-track `raw_json` cells (each entry in `album.song`) without + /// issuing follow-up `get_song` calls. + pub async fn get_album_with_raw( + &self, + album_id: &str, + ) -> Result<(Album, serde_json::Value), SubsonicError> { + let body = self.send("getAlbum", &[("id", album_id)]).await?; + parse_envelope_with_raw(&body, "album") + } + + async fn fetch( + &self, + method: &str, + extra: &[(&str, &str)], + body_key: &str, + ) -> Result { + let body = self.send(method, extra).await?; + parse_envelope(&body, body_key) + } + + async fn send(&self, method: &str, extra: &[(&str, &str)]) -> Result { + let creds = self.build_credentials(); + let auth = [ + ("u", creds.username.as_str()), + ("t", creds.token.as_str()), + ("s", creds.salt.as_str()), + ("v", SUBSONIC_API_VERSION), + ("c", SUBSONIC_CLIENT_ID), + ("f", "json"), + ]; + let mut query: Vec<(&str, &str)> = auth.to_vec(); + query.extend_from_slice(extra); + + let resp = self + .http + .get(format!("{}/rest/{method}.view", self.base_url)) + .query(&query) + .send() + .await + .map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?; + + if !resp.status().is_success() { + return Err(SubsonicError::HttpStatus(resp.status())); + } + resp.text() + .await + .map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e))) + } +} + +#[derive(Deserialize)] +struct AlbumListWrapper { + #[serde(default)] + album: Vec, +} + +fn default_http_client() -> reqwest::Client { + reqwest::Client::builder() + .user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION"))) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +/// Validate the Subsonic envelope and return the raw `serde_json::Value` +/// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound` +/// variant; surfaces every other failed status as `Api { code, message }`. +/// Callers either deserialize the value into a typed struct +/// (`parse_envelope`) or keep both alongside (`parse_envelope_with_raw`). +fn parse_envelope_body(body: &str, body_key: &str) -> Result { + let envelope: serde_json::Value = serde_json::from_str(body) + .map_err(|e| SubsonicError::Decode(format!("envelope: {e}")))?; + let response = envelope + .get("subsonic-response") + .ok_or_else(|| SubsonicError::Decode("missing `subsonic-response`".into()))?; + + if let Some(err) = response.get("error") { + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32; + let message = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or_default() + .to_string(); + return Err(map_error(code, message)); + } + + let status = response.get("status").and_then(|s| s.as_str()).unwrap_or_default(); + if status != "ok" { + return Err(SubsonicError::Decode(format!("unexpected status `{status}`"))); + } + + response + .get(body_key) + .cloned() + .ok_or_else(|| SubsonicError::Decode(format!("missing body key `{body_key}`"))) +} + +/// Validate the envelope, then deserialize the body into `T`. +fn parse_envelope(body: &str, body_key: &str) -> Result { + let body_val = parse_envelope_body(body, body_key)?; + serde_json::from_value(body_val) + .map_err(|e| SubsonicError::Decode(format!("body `{body_key}`: {e}"))) +} + +/// Validate the envelope, then return both the typed projection and the +/// raw `serde_json::Value` body sub-tree. PR-3 sync code uses this to +/// keep `track.raw_json` intact while still operating on a typed `Song` +/// at the call site. +fn parse_envelope_with_raw( + body: &str, + body_key: &str, +) -> Result<(T, serde_json::Value), SubsonicError> { + let body_val = parse_envelope_body(body, body_key)?; + let typed = serde_json::from_value(body_val.clone()) + .map_err(|e| SubsonicError::Decode(format!("body `{body_key}`: {e}")))?; + Ok((typed, body_val)) +} + +/// Variant of `parse_envelope` for endpoints that carry no body (only +/// `ping` in PR-2). Returns `Ok(())` when `status="ok"` and falls back to +/// the same error mapping. +fn parse_envelope_status_only(body: &str) -> Result<(), SubsonicError> { + let envelope: serde_json::Value = serde_json::from_str(body) + .map_err(|e| SubsonicError::Decode(format!("envelope: {e}")))?; + let response = envelope + .get("subsonic-response") + .ok_or_else(|| SubsonicError::Decode("missing `subsonic-response`".into()))?; + + if let Some(err) = response.get("error") { + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32; + let message = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or_default() + .to_string(); + return Err(map_error(code, message)); + } + + let status = response.get("status").and_then(|s| s.as_str()).unwrap_or_default(); + match status { + "ok" => Ok(()), + other => Err(SubsonicError::Decode(format!("unexpected status `{other}`"))), + } +} + +fn map_error(code: i32, message: String) -> SubsonicError { + if code == 70 { + SubsonicError::NotFound + } else { + SubsonicError::Api { code, message } + } +} + +/// Inspect the `subsonic-response` envelope itself for server metadata. +/// Used by `server_info()` and by the capability probe. +fn parse_server_info(body: &str) -> Result { + let envelope: serde_json::Value = serde_json::from_str(body) + .map_err(|e| SubsonicError::Decode(format!("envelope: {e}")))?; + let response = envelope + .get("subsonic-response") + .ok_or_else(|| SubsonicError::Decode("missing `subsonic-response`".into()))?; + + if let Some(err) = response.get("error") { + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32; + let message = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or_default() + .to_string(); + return Err(map_error(code, message)); + } + + let status = response.get("status").and_then(|s| s.as_str()).unwrap_or_default(); + if status != "ok" { + return Err(SubsonicError::Decode(format!("unexpected status `{status}`"))); + } + + Ok(ServerInfo { + server_type: response.get("type").and_then(|v| v.as_str()).map(|s| s.to_string()), + server_version: response + .get("serverVersion") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + api_version: response.get("version").and_then(|v| v.as_str()).map(|s| s.to_string()), + open_subsonic: response.get("openSubsonic").and_then(|v| v.as_bool()).unwrap_or(false), + }) +} + +/// B9 — pick every `every_n`-th id from a sorted list. Used by the +/// server-fingerprint pass on re-add: 1 % of cached track ids are +/// probed via `get_song` and any 404 (`NotFound`) means the server's +/// id space drifted (spec §5.6, P19). The actual probing + comparison +/// is glue code in `psysonic-library` (PR-3 territory); this crate +/// just ships the deterministic sampling primitive so both sides use +/// the same selection logic. +pub fn fingerprint_sample(track_ids: &[String], every_n: usize) -> Vec<&String> { + if every_n == 0 { + return Vec::new(); + } + track_ids + .iter() + .enumerate() + .filter(|(i, _)| i % every_n == 0) + .map(|(_, id)| id) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{method as wm_method, path as wm_path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // ── parse_envelope unit tests (no HTTP) ──────────────────────────────── + + #[test] + fn parse_envelope_extracts_body_on_ok_status() { + let body = json!({ + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + "scanStatus": { + "scanning": false, + "count": 42 + } + } + }) + .to_string(); + let s: ScanStatus = parse_envelope(&body, "scanStatus").unwrap(); + assert_eq!(s.count, Some(42)); + } + + #[test] + fn parse_envelope_maps_code_70_to_not_found() { + let body = json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 70, "message": "Song not found" } + } + }) + .to_string(); + let err = parse_envelope::(&body, "song").unwrap_err(); + assert!(matches!(err, SubsonicError::NotFound)); + } + + #[test] + fn parse_envelope_surfaces_other_error_codes_as_api_variant() { + let body = json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 40, "message": "Wrong username or password" } + } + }) + .to_string(); + let err = parse_envelope::(&body, "song").unwrap_err(); + match err { + SubsonicError::Api { code, message } => { + assert_eq!(code, 40); + assert!(message.contains("Wrong")); + } + other => panic!("expected Api, got {other:?}"), + } + } + + #[test] + fn parse_envelope_rejects_missing_body_key() { + let body = json!({ + "subsonic-response": { "status": "ok" } + }) + .to_string(); + let err = parse_envelope::(&body, "song").unwrap_err(); + assert!(matches!(err, SubsonicError::Decode(_))); + } + + #[test] + fn parse_envelope_status_only_accepts_empty_ok() { + let body = json!({ "subsonic-response": { "status": "ok", "version": "1.16.1" } }).to_string(); + parse_envelope_status_only(&body).unwrap(); + } + + // ── fingerprint_sample ──────────────────────────────────────────────── + + #[test] + fn fingerprint_sample_picks_every_nth_id() { + let ids: Vec = (0..10).map(|i| format!("tr_{i}")).collect(); + let sample = fingerprint_sample(&ids, 4); + assert_eq!( + sample.iter().map(|s| s.as_str()).collect::>(), + vec!["tr_0", "tr_4", "tr_8"] + ); + } + + #[test] + fn fingerprint_sample_is_deterministic_across_runs() { + let ids: Vec = (0..500).map(|i| format!("tr_{i:04}")).collect(); + let a = fingerprint_sample(&ids, 100); + let b = fingerprint_sample(&ids, 100); + assert_eq!(a, b); + assert_eq!(a.len(), 5, "500/100 = 5 samples"); + } + + #[test] + fn fingerprint_sample_zero_n_is_empty() { + let ids: Vec = vec!["a".into(), "b".into()]; + assert!(fingerprint_sample(&ids, 0).is_empty()); + } + + // ── SubsonicClient wiremock end-to-end ──────────────────────────────── + + fn test_credentials() -> SubsonicCredentials { + SubsonicCredentials::with_static("user", "deadbeef", "saltsalt") + } + + fn test_client(uri: &str) -> SubsonicClient { + SubsonicClient::with_static_credentials(uri, test_credentials(), reqwest::Client::new()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn ping_sends_auth_params_and_returns_ok() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .and(query_param("u", "user")) + .and(query_param("t", "deadbeef")) + .and(query_param("s", "saltsalt")) + .and(query_param("v", SUBSONIC_API_VERSION)) + .and(query_param("c", SUBSONIC_CLIENT_ID)) + .and(query_param("f", "json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "version": "1.16.1" } + }))) + .mount(&server) + .await; + + test_client(&server.uri()).ping().await.expect("ping must succeed"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn ping_surfaces_wrong_credentials_as_code_40() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 40, "message": "Wrong username or password" } + } + }))) + .mount(&server) + .await; + + let err = test_client(&server.uri()).ping().await.unwrap_err(); + assert!(matches!(err, SubsonicError::Api { code: 40, .. })); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_song_returns_typed_song() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .and(query_param("id", "tr_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "song": { + "id": "tr_1", + "title": "Aurora", + "artist": "Anna", + "albumId": "al_1", + "duration": 240, + "track": 3 + } + } + }))) + .mount(&server) + .await; + + let song = test_client(&server.uri()).get_song("tr_1").await.unwrap(); + assert_eq!(song.title, "Aurora"); + assert_eq!(song.album_id.as_deref(), Some("al_1")); + assert_eq!(song.track_number, Some(3)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_song_maps_error_70_to_not_found() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 70, "message": "Song not found" } + } + }))) + .mount(&server) + .await; + + let err = test_client(&server.uri()).get_song("missing").await.unwrap_err(); + assert!(matches!(err, SubsonicError::NotFound)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_scan_status_parses_typed_struct() { + 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": 9001, + "folderCount": 12 + } + } + }))) + .mount(&server) + .await; + + let s = test_client(&server.uri()).get_scan_status().await.unwrap(); + assert!(s.scanning); + assert_eq!(s.count, Some(9001)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_indexes_forwards_optional_if_modified_since() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getIndexes.view")) + .and(query_param("ifModifiedSince", "1716840000000")) + .and(query_param("musicFolderId", "lib-1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "indexes": { + "lastModified": 1716840000000_i64, + "ignoredArticles": "The", + "index": [] + } + } + }))) + .mount(&server) + .await; + + let ix = test_client(&server.uri()) + .get_indexes(Some("lib-1"), Some(1_716_840_000_000)) + .await + .unwrap(); + assert_eq!(ix.last_modified_ms, Some(1_716_840_000_000)); + assert!(ix.index.is_empty(), "empty body when nothing changed"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_artists_omits_music_folder_when_none() { + 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": 1716840000000_i64, + "ignoredArticles": "", + "index": [ + { "name": "A", "artist": [ + { "id": "ar_1", "name": "Anna" } + ]} + ] + } + } + }))) + .mount(&server) + .await; + + let ix = test_client(&server.uri()).get_artists(None).await.unwrap(); + assert_eq!(ix.index.len(), 1); + assert_eq!(ix.index[0].artist[0].name, "Anna"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_album_list2_unwraps_album_array() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .and(query_param("type", "alphabeticalByName")) + .and(query_param("size", "500")) + .and(query_param("offset", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { + "album": [ + { "id": "al_1", "name": "First" }, + { "id": "al_2", "name": "Second" } + ] + } + } + }))) + .mount(&server) + .await; + + let albums = test_client(&server.uri()) + .get_album_list2("alphabeticalByName", 500, 0, None) + .await + .unwrap(); + assert_eq!(albums.len(), 2); + assert_eq!(albums[1].id, "al_2"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_album_includes_song_list() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbum.view")) + .and(query_param("id", "al_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "album": { + "id": "al_1", + "name": "Test Album", + "songCount": 2, + "song": [ + { "id": "tr_1", "title": "One", "track": 1 }, + { "id": "tr_2", "title": "Two", "track": 2 } + ] + } + } + }))) + .mount(&server) + .await; + + let album = test_client(&server.uri()).get_album("al_1").await.unwrap(); + assert_eq!(album.song.len(), 2); + assert_eq!(album.song[0].title, "One"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn search3_handles_empty_query_navidrome_quirk() { + // Spec §2.4: Navidrome accepts empty query → returns all songs paged. + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .and(query_param("query", "")) + .and(query_param("songCount", "100")) + .and(query_param("songOffset", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "searchResult3": { + "song": [ + { "id": "tr_1", "title": "One" }, + { "id": "tr_2", "title": "Two" } + ] + } + } + }))) + .mount(&server) + .await; + + let sr = test_client(&server.uri()).search3("", 100, 0, None).await.unwrap(); + assert_eq!(sr.song.len(), 2); + assert!(sr.artist.is_empty()); + assert!(sr.album.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn base_url_trailing_slash_does_not_double_up() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok" } + }))) + .mount(&server) + .await; + + // Append a trailing slash + additional slashes — the constructor + // strips them so the request path stays `/rest/ping.view`, not + // `//rest/ping.view`. + let url = format!("{}///", server.uri()); + SubsonicClient::with_static_credentials(url, test_credentials(), reqwest::Client::new()) + .ping() + .await + .expect("ping with trailing slashes must reach the same endpoint"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn http_500_returns_http_status_error_without_decode() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let err = test_client(&server.uri()).ping().await.unwrap_err(); + match err { + SubsonicError::HttpStatus(s) => assert_eq!(s.as_u16(), 500), + other => panic!("expected HttpStatus, got {other:?}"), + } + } + + // ── PR-2b: fresh-credentials-per-request lifecycle ──────────────────── + + #[test] + fn from_password_client_derives_fresh_credentials_per_request() { + let client = SubsonicClient::new("http://test", "user", "pw"); + let a = client.build_credentials(); + let b = client.build_credentials(); + assert_ne!(a.salt, b.salt, "from_password mode must refresh salt"); + assert_ne!(a.token, b.token, "different salt → different token"); + assert_eq!(a.username, b.username); + } + + #[test] + fn static_credentials_client_returns_same_triple_each_call() { + let creds = SubsonicCredentials::with_static("u", "tok", "salt"); + let client = SubsonicClient::with_static_credentials( + "http://test", + creds, + reqwest::Client::new(), + ); + let a = client.build_credentials(); + let b = client.build_credentials(); + assert_eq!(a.token, b.token); + assert_eq!(a.salt, b.salt); + } + + #[tokio::test(flavor = "multi_thread")] + async fn from_password_client_sends_unique_salt_per_request_over_the_wire() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok" } + }))) + .mount(&server) + .await; + + let client = SubsonicClient::new(server.uri(), "user", "pw"); + client.ping().await.unwrap(); + client.ping().await.unwrap(); + + let received = server.received_requests().await.expect("requests captured"); + assert_eq!(received.len(), 2); + let salt = |r: &wiremock::Request| { + r.url + .query_pairs() + .find(|(k, _)| k == "s") + .map(|(_, v)| v.into_owned()) + .expect("`s` param present") + }; + let token = |r: &wiremock::Request| { + r.url + .query_pairs() + .find(|(k, _)| k == "t") + .map(|(_, v)| v.into_owned()) + .expect("`t` param present") + }; + assert_ne!(salt(&received[0]), salt(&received[1])); + assert_ne!(token(&received[0]), token(&received[1])); + } + + #[tokio::test(flavor = "multi_thread")] + async fn client_id_query_param_carries_crate_version() { + // PR-2b note 2: align `c` with the frontend (`psysonic/`). + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok" } + }))) + .mount(&server) + .await; + test_client(&server.uri()).ping().await.unwrap(); + + let received = server.received_requests().await.expect("requests captured"); + let c = received[0] + .url + .query_pairs() + .find(|(k, _)| k == "c") + .map(|(_, v)| v.into_owned()) + .expect("`c` param present"); + assert!(c.starts_with("psysonic/"), "got `{c}`"); + assert_eq!(c, SUBSONIC_CLIENT_ID); + } + + // ── PR-2b: raw_json capture for ingest (PR-3 prep) ──────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn get_song_with_raw_returns_typed_and_raw_subtree() { + let server = MockServer::start().await; + let song = json!({ + "id": "tr_1", + "title": "Title", + "artist": "Artist", + "musicBrainzId": "abc-123", + "replayGain": { "trackGain": -1.2, "albumGain": -0.8 }, + "contributors": [ + { "role": "producer", "artistId": "ar_9", "name": "Prod" } + ] + }); + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "song": song.clone() } + }))) + .mount(&server) + .await; + + let (typed, raw) = test_client(&server.uri()) + .get_song_with_raw("tr_1") + .await + .unwrap(); + assert_eq!(typed.id, "tr_1"); + assert_eq!(typed.title, "Title"); + // Typed struct picks up the new musicBrainzId alias. + assert_eq!(typed.mbid_recording.as_deref(), Some("abc-123")); + + // Raw value preserves OpenSubsonic extensions the typed struct + // doesn't mirror — exactly what `track.raw_json` needs. + assert_eq!(raw.get("replayGain"), song.get("replayGain")); + assert_eq!(raw.get("contributors"), song.get("contributors")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn search3_with_raw_keeps_song_extensions_in_raw_tree() { + let server = MockServer::start().await; + let result_body = json!({ + "song": [ + { "id": "tr_1", "title": "One", "replayGain": { "trackGain": -1.5 } }, + { "id": "tr_2", "title": "Two", "contributors": [{ "role": "producer" }] } + ] + }); + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "searchResult3": result_body.clone() } + }))) + .mount(&server) + .await; + + let (typed, raw) = test_client(&server.uri()) + .search3_with_raw("", 100, 0, None) + .await + .unwrap(); + assert_eq!(typed.song.len(), 2); + + // Raw value preserves the typed-struct-incompatible fields. + let raw_songs = raw.get("song").and_then(|v| v.as_array()).expect("song array"); + assert_eq!(raw_songs.len(), 2); + assert_eq!( + raw_songs[0].get("replayGain"), + result_body.get("song").unwrap().as_array().unwrap()[0].get("replayGain") + ); + assert_eq!( + raw_songs[1].get("contributors"), + result_body.get("song").unwrap().as_array().unwrap()[1].get("contributors") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn search3_with_raw_empty_envelope_maps_to_empty_search_result() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "searchResult3": {} } + }))) + .mount(&server) + .await; + let (typed, raw) = test_client(&server.uri()) + .search3_with_raw("", 50, 0, None) + .await + .unwrap(); + assert!(typed.song.is_empty()); + assert!(typed.album.is_empty()); + // Empty `searchResult3: {}` survives as an empty Object in raw, + // not Null — runner relies on this for the `get("song")` path. + assert!(raw.is_object()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_album_with_raw_keeps_song_extensions_in_raw_tree() { + let server = MockServer::start().await; + let album = json!({ + "id": "al_1", + "name": "Album", + "song": [ + { "id": "tr_1", "title": "One", "track": 1, "musicBrainzId": "mb-1" }, + { "id": "tr_2", "title": "Two", "track": 2, "replayGain": { "trackGain": -3.0 } } + ] + }); + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbum.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "album": album.clone() } + }))) + .mount(&server) + .await; + + let (typed, raw) = test_client(&server.uri()) + .get_album_with_raw("al_1") + .await + .unwrap(); + assert_eq!(typed.song.len(), 2); + assert_eq!(typed.song[0].mbid_recording.as_deref(), Some("mb-1")); + + // Per-track raw entries survive in `raw.song[i]`. + let raw_songs = raw.get("song").and_then(|v| v.as_array()).expect("song array"); + assert_eq!(raw_songs.len(), 2); + assert_eq!( + raw_songs[1].get("replayGain"), + album.get("song").unwrap().as_array().unwrap()[1].get("replayGain") + ); + } + + // ── server_info / parse_server_info ──────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn server_info_extracts_navidrome_envelope_metadata() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "0.55.2", + "openSubsonic": true + } + }))) + .mount(&server) + .await; + + let info = test_client(&server.uri()).server_info().await.unwrap(); + assert_eq!(info.server_type.as_deref(), Some("navidrome")); + assert_eq!(info.server_version.as_deref(), Some("0.55.2")); + assert_eq!(info.api_version.as_deref(), Some("1.16.1")); + assert!(info.open_subsonic); + } + + #[tokio::test(flavor = "multi_thread")] + async fn server_info_falls_back_to_defaults_for_minimal_envelope() { + // Older Subsonic servers may omit type / serverVersion / openSubsonic. + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "version": "1.16.1" } + }))) + .mount(&server) + .await; + + let info = test_client(&server.uri()).server_info().await.unwrap(); + assert!(info.server_type.is_none()); + assert!(info.server_version.is_none()); + assert!(!info.open_subsonic); + assert_eq!(info.api_version.as_deref(), Some("1.16.1")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn server_info_surfaces_wrong_credentials_as_code_40() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 40, "message": "Wrong username or password" } + } + }))) + .mount(&server) + .await; + + let err = test_client(&server.uri()).server_info().await.unwrap_err(); + assert!(matches!(err, SubsonicError::Api { code: 40, .. })); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_song_with_raw_maps_error_70_to_not_found_like_get_song() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 70, "message": "Song not found" } + } + }))) + .mount(&server) + .await; + + let err = test_client(&server.uri()) + .get_song_with_raw("missing") + .await + .unwrap_err(); + assert!(matches!(err, SubsonicError::NotFound)); + } +} diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/error.rs b/src-tauri/crates/psysonic-integration/src/subsonic/error.rs new file mode 100644 index 00000000..bd253848 --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/subsonic/error.rs @@ -0,0 +1,76 @@ +use std::fmt; + +/// Errors surfaced by `SubsonicClient`. Designed for the sync engine +/// (PR-3): `NotFound` exists as a first-class variant so the tombstone +/// reconciler can match Subsonic error code 70 without parsing strings. +/// +/// Spec §2.6 — code 70 = "The requested data was not found". +#[derive(Debug)] +pub enum SubsonicError { + /// Transport failure (DNS, TCP, TLS, body read). Wraps the flattened + /// reqwest error chain so toasts can surface the real cause. + Transport(String), + + /// Server replied with a non-2xx HTTP status before the JSON envelope + /// was inspectable. + HttpStatus(reqwest::StatusCode), + + /// Subsonic-level failure (`status = "failed"` in the envelope). + Api { code: i32, message: String }, + + /// Convenience for the common error code 70. Equivalent to + /// `Api { code: 70, .. }` and produced by the same parser. + NotFound, + + /// Response body wasn't the expected JSON shape. + Decode(String), +} + +impl fmt::Display for SubsonicError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SubsonicError::Transport(m) => write!(f, "subsonic transport: {m}"), + SubsonicError::HttpStatus(s) => write!(f, "subsonic http status: {s}"), + SubsonicError::Api { code, message } => { + write!(f, "subsonic api error {code}: {message}") + } + SubsonicError::NotFound => write!(f, "subsonic: not found (code 70)"), + SubsonicError::Decode(m) => write!(f, "subsonic decode: {m}"), + } + } +} + +impl std::error::Error for SubsonicError {} + +/// Flatten a `reqwest::Error` source chain into one readable string — +/// mirrors `psysonic-integration::navidrome::nd_err` so the two clients +/// surface comparable diagnostic text. +pub(crate) fn flatten_reqwest_error(e: reqwest::Error) -> String { + let mut msg = e.to_string(); + let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e); + while let Some(s) = src { + msg.push_str(" | "); + msg.push_str(&s.to_string()); + src = s.source(); + } + msg +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_includes_error_code_for_api_variant() { + let e = SubsonicError::Api { code: 40, message: "Wrong username or password".into() }; + let s = e.to_string(); + assert!(s.contains("40")); + assert!(s.contains("Wrong username")); + } + + #[test] + fn not_found_renders_with_code_70_for_log_search() { + let s = SubsonicError::NotFound.to_string(); + assert!(s.contains("70"), "got {s}"); + } +} diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/mod.rs b/src-tauri/crates/psysonic-integration/src/subsonic/mod.rs new file mode 100644 index 00000000..36afd40c --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/subsonic/mod.rs @@ -0,0 +1,18 @@ +//! Subsonic REST client — read-only endpoints the library-sync engine +//! consumes (phase B per spec §10). See `client::SubsonicClient` for the +//! entry point. + +pub mod auth; +pub mod client; +pub mod error; +pub mod types; + +pub use auth::SubsonicCredentials; +pub use client::{ + fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID, +}; +pub use error::SubsonicError; +pub use types::{ + Album, AlbumSummary, ArtistIndex, ArtistRef, IndexBucket, ScanStatus, SearchResult, ServerInfo, + Song, +}; diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/types.rs b/src-tauri/crates/psysonic-integration/src/subsonic/types.rs new file mode 100644 index 00000000..11d7cf46 --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/subsonic/types.rs @@ -0,0 +1,403 @@ +//! Response structs for the Subsonic REST API surface PR-2 needs. +//! +//! Only the hot fields the sync engine reads are typed; everything else +//! survives via the raw JSON the client also returns (PR-3 wires the +//! `raw_json` column on `track`). Unknown fields are simply ignored on +//! deserialize — additive OpenSubsonic extensions never break parsing. + +use serde::{Deserialize, Serialize}; + +/// Deserialize a field Navidrome/OpenSubsonic may return either as a plain +/// string or as a JSON array. OpenSubsonic types `isrc` as `string[]`; +/// Navidrome ships `isrc: []` / `["USRC…"]`, which breaks a plain +/// `Option`. Take the first usable value; the full set survives +/// verbatim in `track.raw_json` (ADR-7). +fn de_string_or_seq<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(serde_json::Value::String(s)) => Some(s), + Some(serde_json::Value::Array(arr)) => first_tag_value(&arr), + _ => None, + }) +} + +/// Navidrome often ships library ids as JSON numbers; Subsonic uses strings. +fn de_string_or_number<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(serde_json::Value::String(s)) => Some(s), + Some(serde_json::Value::Number(n)) => Some(n.to_string()), + _ => None, + }) +} + +/// First usable value in a multi-valued array: a string element, or an +/// object element's `name` (the OpenSubsonic `[{ "name": … }]` shape). +fn first_tag_value(arr: &[serde_json::Value]) -> Option { + arr.iter().find_map(|el| match el { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Object(map) => map + .get("name") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + _ => None, + }) +} + +/// Envelope-level metadata returned by `#ping` (and present on every +/// other response too). Read by the capability probe to detect the +/// server family (Navidrome vs generic Subsonic) and the OpenSubsonic +/// flag. Filled in from the `subsonic-response` object itself, not +/// from a body key — these fields sit at the same level as `status`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ServerInfo { + /// Server software family — Navidrome reports `"navidrome"`, generic + /// Subsonic implementations report their own label. `None` when the + /// server omits the field (older Subsonic). + pub server_type: Option, + /// Server build version, e.g. `"0.55.2"` on Navidrome. + pub server_version: Option, + /// Subsonic API protocol level the server advertises. + pub api_version: Option, + /// `true` when the server advertises OpenSubsonic extensions + /// (`isrc`, `played`, `bpm`, contributor arrays, …). + pub open_subsonic: bool, +} + +/// `#getScanStatus` (since 1.15.0). `lastScan` is an ISO-8601 string on +/// Navidrome (`responses.go` `ScanStatus.LastScan`); other servers may +/// omit it during an active scan. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct ScanStatus { + pub scanning: bool, + #[serde(default)] + pub count: Option, + #[serde(rename = "folderCount", default)] + pub folder_count: Option, + #[serde(rename = "lastScan", default)] + pub last_scan: Option, +} + +/// `#getIndexes` (file-structure browse) and `#getArtists` (ID3 browse) +/// share the same shape on the wire: a top-level `lastModified` watermark +/// plus a list of letter buckets. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct ArtistIndex { + /// `lastModified` is ms since epoch (spec §2.2 — response metadata, + /// not a request param). + #[serde(rename = "lastModified", default)] + pub last_modified_ms: Option, + #[serde(rename = "ignoredArticles", default)] + pub ignored_articles: Option, + #[serde(default)] + pub index: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct IndexBucket { + pub name: String, + #[serde(default)] + pub artist: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct ArtistRef { + pub id: String, + pub name: String, + #[serde(rename = "albumCount", default)] + pub album_count: Option, + #[serde(rename = "coverArt", default)] + pub cover_art: Option, +} + +/// `#getAlbumList2` — page of album summaries (no song list). +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct AlbumSummary { + pub id: String, + pub name: String, + #[serde(default)] + pub artist: Option, + #[serde(rename = "artistId", default)] + pub artist_id: Option, + #[serde(rename = "songCount", default)] + pub song_count: Option, + #[serde(default)] + pub duration: Option, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub genre: Option, + #[serde(rename = "coverArt", default)] + pub cover_art: Option, + #[serde(default)] + pub starred: Option, +} + +/// `#getAlbum` — album + its full song list. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct Album { + pub id: String, + pub name: String, + #[serde(default)] + pub artist: Option, + #[serde(rename = "artistId", default)] + pub artist_id: Option, + #[serde(rename = "songCount", default)] + pub song_count: Option, + #[serde(default)] + pub duration: Option, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub genre: Option, + #[serde(rename = "coverArt", default)] + pub cover_art: Option, + #[serde(default)] + pub song: Vec, +} + +/// `#search3` — three parallel lists, any of which may be empty. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)] +pub struct SearchResult { + #[serde(default)] + pub artist: Vec, + #[serde(default)] + pub album: Vec, + #[serde(default)] + pub song: Vec, +} + +/// `#getSong` / nested in `#getAlbum`. Only the hot columns from +/// spec §5.1 are typed; everything else (OpenSubsonic extensions, contributor +/// arrays, …) is ignored at this layer and recovered from `raw_json` later. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct Song { + pub id: String, + pub title: String, + #[serde(default)] + pub artist: Option, + #[serde(rename = "artistId", default)] + pub artist_id: Option, + #[serde(default)] + pub album: Option, + #[serde(rename = "albumId", default)] + pub album_id: Option, + #[serde(rename = "albumArtist", default)] + pub album_artist: Option, + /// Subsonic reports `duration` in whole seconds. + #[serde(default)] + pub duration: Option, + #[serde(rename = "track", default)] + pub track_number: Option, + #[serde(rename = "discNumber", default)] + pub disc_number: Option, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub genre: Option, + #[serde(default)] + pub suffix: Option, + #[serde(rename = "bitRate", default)] + pub bit_rate: Option, + /// Server reports `size` in bytes. + #[serde(default)] + pub size: Option, + #[serde(rename = "coverArt", default)] + pub cover_art: Option, + #[serde(default)] + pub starred: Option, + #[serde(rename = "userRating", default)] + pub user_rating: Option, + #[serde(rename = "playCount", default)] + pub play_count: Option, + #[serde(default)] + pub played: Option, + /// Server-side relative path (Navidrome populates; some servers don't). + #[serde(default)] + pub path: Option, + /// `libraryId` (Navidrome native) or `musicFolderId` (Subsonic generic). + /// We accept both keys — Navidrome uses `libraryId` on OpenSubsonic + /// responses, generic Subsonic stays on `musicFolderId`. + #[serde( + default, + alias = "libraryId", + alias = "musicFolderId", + deserialize_with = "de_string_or_number" + )] + pub library_id: Option, + // OpenSubsonic types `isrc` as `string[]` — Navidrome returns + // `isrc: []` / `["USRC…"]`, which breaks a plain `Option`. + #[serde(default, deserialize_with = "de_string_or_seq")] + pub isrc: Option, + /// MusicBrainz recording id. Subsonic / OpenSubsonic uses the + /// `musicBrainzId` JSON key; the schema column is `mbid_recording` + /// (spec §5.1). The alias keeps both spellings deserializable so + /// future API revisions don't break ingest. + #[serde(default, alias = "musicBrainzId", alias = "mbid_recording")] + pub mbid_recording: Option, + #[serde(default)] + pub bpm: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn song_deserialize_accepts_minimal_navidrome_payload() { + let payload = r#"{ + "id": "tr_1", + "title": "Hello", + "artist": "World", + "duration": 240, + "track": 3, + "year": 2024, + "suffix": "flac", + "bitRate": 1000, + "size": 32000000, + "coverArt": "cv_1", + "libraryId": "1", + "isrc": "USRC17607839" + }"#; + let song: Song = serde_json::from_str(payload).unwrap(); + assert_eq!(song.id, "tr_1"); + assert_eq!(song.title, "Hello"); + assert_eq!(song.duration, Some(240)); + assert_eq!(song.track_number, Some(3)); + assert_eq!(song.library_id.as_deref(), Some("1")); + assert_eq!(song.isrc.as_deref(), Some("USRC17607839")); + } + + #[test] + fn song_alias_falls_back_to_music_folder_id() { + // Generic Subsonic still ships `musicFolderId`, not Navidrome's + // `libraryId` — make sure we don't lose it. + let payload = r#"{"id":"a","title":"t","musicFolderId":"7"}"#; + let song: Song = serde_json::from_str(payload).unwrap(); + assert_eq!(song.library_id.as_deref(), Some("7")); + } + + #[test] + fn song_deserialize_library_id_from_number() { + let payload = r#"{"id":"a","title":"t","libraryId":3}"#; + let song: Song = serde_json::from_str(payload).unwrap(); + assert_eq!(song.library_id.as_deref(), Some("3")); + } + + #[test] + fn song_picks_up_music_brainz_id_from_either_alias() { + // OpenSubsonic shape — `musicBrainzId`. + let from_subsonic: Song = serde_json::from_str( + r#"{"id":"a","title":"t","musicBrainzId":"abc-123"}"#, + ) + .unwrap(); + assert_eq!(from_subsonic.mbid_recording.as_deref(), Some("abc-123")); + + // Schema-column shape — direct `mbid_recording`. Lets callers + // round-trip a row through `serde_json` without renaming. + let from_schema: Song = serde_json::from_str( + r#"{"id":"a","title":"t","mbid_recording":"xyz-789"}"#, + ) + .unwrap(); + assert_eq!(from_schema.mbid_recording.as_deref(), Some("xyz-789")); + } + + #[test] + fn song_ignores_unknown_open_subsonic_fields() { + // OpenSubsonic ships extras like `played`, `replayGain`, `artists` + // (contributor list), etc. We don't type them; they must not error. + let payload = r#"{ + "id": "tr_1", + "title": "Hello", + "replayGain": { "trackGain": -1.2, "albumGain": -0.8 }, + "artists": [{ "id": "ar_1", "name": "W" }], + "contributors": [] + }"#; + let song: Song = serde_json::from_str(payload).unwrap(); + assert_eq!(song.id, "tr_1"); + assert!(song.artist.is_none()); + } + + #[test] + fn album_with_songs_round_trips() { + let payload = r#"{ + "id": "al_1", + "name": "Test Album", + "artist": "Artist", + "artistId": "ar_1", + "songCount": 2, + "song": [ + {"id": "tr_1", "title": "One", "track": 1}, + {"id": "tr_2", "title": "Two", "track": 2} + ] + }"#; + let album: Album = serde_json::from_str(payload).unwrap(); + assert_eq!(album.name, "Test Album"); + assert_eq!(album.song.len(), 2); + assert_eq!(album.song[1].title, "Two"); + } + + #[test] + fn song_isrc_accepts_opensubsonic_string_array() { + // OpenSubsonic `isrc` is `string[]`. Navidrome ships `isrc: []` + // (the album !Brincamos! repro) or a populated array — both must + // decode, plus the legacy single-string form. + let empty: Song = serde_json::from_str(r#"{"id":"a","title":"t","isrc":[]}"#).unwrap(); + assert!(empty.isrc.is_none()); + let arr: Song = + serde_json::from_str(r#"{"id":"a","title":"t","isrc":["USRC17607839"]}"#).unwrap(); + assert_eq!(arr.isrc.as_deref(), Some("USRC17607839")); + let legacy: Song = + serde_json::from_str(r#"{"id":"a","title":"t","isrc":"USRC17607839"}"#).unwrap(); + assert_eq!(legacy.isrc.as_deref(), Some("USRC17607839")); + } + + #[test] + fn artist_index_parses_last_modified_watermark() { + let payload = r#"{ + "lastModified": 1716840000000, + "ignoredArticles": "The El La", + "index": [ + {"name": "A", "artist": [ + {"id": "ar_1", "name": "Anna"}, + {"id": "ar_2", "name": "Alex", "albumCount": 3} + ]}, + {"name": "B", "artist": []} + ] + }"#; + let ai: ArtistIndex = serde_json::from_str(payload).unwrap(); + assert_eq!(ai.last_modified_ms, Some(1716840000000)); + assert_eq!(ai.index.len(), 2); + assert_eq!(ai.index[0].artist.len(), 2); + assert_eq!(ai.index[0].artist[1].album_count, Some(3)); + } + + #[test] + fn search_result_defaults_empty_lists() { + // search3 with no hits returns just `{"searchResult3": {}}`. + let sr: SearchResult = serde_json::from_str("{}").unwrap(); + assert!(sr.artist.is_empty()); + assert!(sr.album.is_empty()); + assert!(sr.song.is_empty()); + } + + #[test] + fn scan_status_parses_navidrome_shape() { + let payload = r#"{ + "scanning": false, + "count": 12345, + "folderCount": 100, + "lastScan": "2024-06-01T12:00:00Z" + }"#; + let s: ScanStatus = serde_json::from_str(payload).unwrap(); + assert!(!s.scanning); + assert_eq!(s.count, Some(12345)); + assert_eq!(s.last_scan.as_deref(), Some("2024-06-01T12:00:00Z")); + } +} diff --git a/src-tauri/crates/psysonic-library/Cargo.toml b/src-tauri/crates/psysonic-library/Cargo.toml new file mode 100644 index 00000000..ad2ccaf5 --- /dev/null +++ b/src-tauri/crates/psysonic-library/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "psysonic-library" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +psysonic-core = { path = "../psysonic-core" } +psysonic-integration = { path = "../psysonic-integration" } + +tauri = { version = "2" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rusqlite = { version = "0.39", features = ["bundled"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "gzip", "brotli"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros", "time"] } + +[dev-dependencies] +tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "test-util"] } +wiremock = { workspace = true } diff --git a/src-tauri/crates/psysonic-library/migrations/001_initial.sql b/src-tauri/crates/psysonic-library/migrations/001_initial.sql new file mode 100644 index 00000000..f1a0c8f8 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/001_initial.sql @@ -0,0 +1,247 @@ +-- psysonic-library v1 schema — see implementation-spec.ru.md §5.1–§5.3 +-- Tables are ordered so FK targets exist before their referrers. + +-- Migration-runner bookkeeping (§5.7). Defined here so the schema file is +-- self-describing — `LibraryStore::run_migrations` also creates this table +-- defensively before applying migrations, which keeps both paths idempotent. +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL +); + +CREATE TABLE canonical_track ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE canonical_identity ( + canonical_id TEXT NOT NULL, + kind TEXT NOT NULL, + value TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 1.0, + PRIMARY KEY (kind, value), + FOREIGN KEY (canonical_id) REFERENCES canonical_track(id) +); + +CREATE TABLE sync_state ( + server_id TEXT NOT NULL, + library_scope TEXT NOT NULL DEFAULT '', + normalized_base_url TEXT NOT NULL DEFAULT '', + server_fingerprint_ok INTEGER, + fingerprint_checked_at INTEGER, + capability_flags INTEGER NOT NULL DEFAULT 0, + last_full_sync_at INTEGER, + last_delta_sync_at INTEGER, + server_last_scan_iso TEXT, + indexes_last_modified_ms INTEGER, + artists_last_modified_ms INTEGER, + server_track_count INTEGER, + local_track_count INTEGER, + artist_count INTEGER, + library_tier TEXT NOT NULL DEFAULT 'unknown', + poll_stats_json TEXT NOT NULL DEFAULT '{}', + next_poll_at INTEGER, + initial_sync_cursor_json TEXT NOT NULL DEFAULT '{}', + sync_phase TEXT NOT NULL DEFAULT 'idle', + last_error TEXT, + PRIMARY KEY (server_id, library_scope) +); + +CREATE TABLE artist ( + server_id TEXT NOT NULL, + id TEXT NOT NULL, + name TEXT NOT NULL, + album_count INTEGER, + synced_at INTEGER NOT NULL, + raw_json TEXT, + PRIMARY KEY (server_id, id) +); + +CREATE TABLE album ( + server_id TEXT NOT NULL, + id TEXT NOT NULL, + name TEXT NOT NULL, + artist TEXT, + artist_id TEXT, + song_count INTEGER, + duration_sec INTEGER, + year INTEGER, + genre TEXT, + cover_art_id TEXT, + starred_at INTEGER, + synced_at INTEGER NOT NULL, + raw_json TEXT, + PRIMARY KEY (server_id, id) +); + +CREATE TABLE track ( + server_id TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL, + title_sort TEXT, + artist TEXT, + artist_id TEXT, + album TEXT NOT NULL DEFAULT '', + album_id TEXT, + album_artist TEXT, + duration_sec INTEGER NOT NULL DEFAULT 0, + track_number INTEGER, + disc_number INTEGER, + year INTEGER, + genre TEXT, + suffix TEXT, + bit_rate INTEGER, + size_bytes INTEGER, + cover_art_id TEXT, + starred_at INTEGER, + user_rating INTEGER, + play_count INTEGER, + played_at INTEGER, + server_path TEXT, + library_id TEXT, + isrc TEXT, + mbid_recording TEXT, + bpm INTEGER, + replay_gain_track_db REAL, + replay_gain_album_db REAL, + content_hash TEXT, + server_updated_at INTEGER, + server_created_at INTEGER, + deleted INTEGER NOT NULL DEFAULT 0, + synced_at INTEGER NOT NULL, + raw_json TEXT NOT NULL, + PRIMARY KEY (server_id, id) +); + +CREATE VIRTUAL TABLE track_fts USING fts5( + title, artist, album, album_artist, genre, + content='track', content_rowid='rowid', + tokenize='unicode61 remove_diacritics 2' +); + +CREATE TRIGGER track_ai AFTER INSERT ON track BEGIN + INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre) + VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre); +END; + +CREATE TRIGGER track_ad AFTER DELETE ON track BEGIN + INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre); +END; + +CREATE TRIGGER track_au AFTER UPDATE ON track BEGIN + INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre); + INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre) + VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre); +END; + +CREATE TABLE track_extension ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + kind TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + payload BLOB NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, track_id, kind), + FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) +); + +-- NO FK to track: survives library purge when user keeps the cached file (§5.14). +CREATE TABLE track_offline ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + local_path TEXT NOT NULL, + file_size_bytes INTEGER, + suffix TEXT, + content_hash TEXT NOT NULL DEFAULT '', + server_path TEXT, + cached_at INTEGER NOT NULL, + last_verified_at INTEGER, + PRIMARY KEY (server_id, track_id) +); + +CREATE TABLE track_id_history ( + server_id TEXT NOT NULL, + old_id TEXT NOT NULL, + new_id TEXT NOT NULL, + content_hash TEXT, + server_path TEXT, + remapped_at INTEGER NOT NULL, + PRIMARY KEY (server_id, old_id) +); + +CREATE TABLE track_fact ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + fact_kind TEXT NOT NULL, + value_real REAL, + value_int INTEGER, + value_text TEXT, + unit TEXT, + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + source_detail TEXT, + confidence REAL NOT NULL DEFAULT 1.0, + content_hash TEXT, + fetched_at INTEGER NOT NULL, + expires_at INTEGER, + PRIMARY KEY (server_id, track_id, fact_kind, source_kind, source_id), + FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) +); + +CREATE TABLE track_artifact ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + artifact_kind TEXT NOT NULL, + format TEXT NOT NULL, + language TEXT, + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + content_text TEXT, + content_blob BLOB, + content_bytes INTEGER NOT NULL DEFAULT 0, + not_found INTEGER NOT NULL DEFAULT 0, + content_hash TEXT, + fetched_at INTEGER NOT NULL, + expires_at INTEGER, + PRIMARY KEY (server_id, track_id, artifact_kind, source_kind, source_id, format), + FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) +); + +CREATE TABLE track_canonical_link ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + canonical_id TEXT NOT NULL, + match_method TEXT NOT NULL, + confidence REAL NOT NULL, + linked_at INTEGER NOT NULL, + PRIMARY KEY (server_id, track_id), + FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id), + FOREIGN KEY (canonical_id) REFERENCES canonical_track(id) +); + +CREATE TABLE canonical_enrichment_link ( + canonical_id TEXT NOT NULL, + enrichment_kind TEXT NOT NULL, + owner_server_id TEXT NOT NULL, + owner_track_id TEXT NOT NULL, + share_policy TEXT NOT NULL DEFAULT 'isrc_match', + linked_at INTEGER NOT NULL, + PRIMARY KEY (canonical_id, enrichment_kind, owner_server_id, owner_track_id), + FOREIGN KEY (canonical_id) REFERENCES canonical_track(id) +); + +CREATE INDEX idx_track_album ON track(server_id, album_id) WHERE deleted = 0; +CREATE INDEX idx_track_artist ON track(server_id, artist_id) WHERE deleted = 0; +CREATE INDEX idx_track_updated ON track(server_id, server_updated_at DESC) WHERE deleted = 0; +CREATE INDEX idx_track_starred ON track(server_id, starred_at) WHERE deleted = 0 AND starred_at IS NOT NULL; +CREATE INDEX idx_track_library ON track(server_id, library_id) WHERE deleted = 0; +CREATE INDEX idx_track_bpm ON track(server_id, bpm) WHERE deleted = 0 AND bpm IS NOT NULL; +CREATE INDEX idx_track_isrc ON track(isrc) WHERE deleted = 0 AND isrc IS NOT NULL; +CREATE INDEX idx_track_fact_lookup ON track_fact(server_id, track_id, fact_kind); +CREATE INDEX idx_track_artifact_lookup ON track_artifact(server_id, track_id, artifact_kind); +CREATE INDEX idx_track_offline_hash ON track_offline(server_id, content_hash); +CREATE INDEX idx_track_id_history_new ON track_id_history(server_id, new_id); +CREATE INDEX idx_canonical_identity_lookup ON canonical_identity(kind, value); diff --git a/src-tauri/crates/psysonic-library/migrations/002_n1_bulk_unreliable.sql b/src-tauri/crates/psysonic-library/migrations/002_n1_bulk_unreliable.sql new file mode 100644 index 00000000..995f4f45 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/002_n1_bulk_unreliable.sql @@ -0,0 +1,6 @@ +-- psysonic-library schema v2 — large-library ingest policy (R7-15). +-- Per-server learned flag: when N1 (`/api/song`) returns HTTP 500 beyond a +-- deep offset on a large catalog, the strategy selector stops choosing N1 for +-- that server on future initial syncs (spec §6.3 / R7-15 Q1/Q5). Additive +-- column, DEFAULT 0 → existing rows keep N1 eligible until they hit the wall. +ALTER TABLE sync_state ADD COLUMN n1_bulk_unreliable INTEGER NOT NULL DEFAULT 0; diff --git a/src-tauri/crates/psysonic-library/migrations/003_track_remap_indexes.sql b/src-tauri/crates/psysonic-library/migrations/003_track_remap_indexes.sql new file mode 100644 index 00000000..bd2f2e43 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/003_track_remap_indexes.sql @@ -0,0 +1,10 @@ +-- Remap detection (§6.9) and unstable-id servers: without these indexes +-- each upsert in a 500-row batch can scan the whole track table. + +CREATE INDEX IF NOT EXISTS idx_track_remap_path + ON track(server_id, server_path) + WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != ''; + +CREATE INDEX IF NOT EXISTS idx_track_remap_hash + ON track(server_id, content_hash) + WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != ''; diff --git a/src-tauri/crates/psysonic-library/migrations/004_track_title_index.sql b/src-tauri/crates/psysonic-library/migrations/004_track_title_index.sql new file mode 100644 index 00000000..d94f9575 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/004_track_title_index.sql @@ -0,0 +1,4 @@ +-- Browse / sort-by-title without sorting the full server slice on every page. +CREATE INDEX IF NOT EXISTS idx_track_title + ON track(server_id, title COLLATE NOCASE) + WHERE deleted = 0; diff --git a/src-tauri/crates/psysonic-library/migrations/005_track_genre_year_indexes.sql b/src-tauri/crates/psysonic-library/migrations/005_track_genre_year_indexes.sql new file mode 100644 index 00000000..b0fc60c3 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/005_track_genre_year_indexes.sql @@ -0,0 +1,8 @@ +-- Advanced search filters on genre and year (partial indexes — only non-null rows). +CREATE INDEX IF NOT EXISTS idx_track_genre + ON track(server_id, genre COLLATE NOCASE) + WHERE deleted = 0 AND genre IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_track_year + ON track(server_id, year) + WHERE deleted = 0 AND year IS NOT NULL; diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs new file mode 100644 index 00000000..a707496c --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -0,0 +1,1471 @@ +//! Advanced Search SQL builder (spec §5.13). PR-5d ships the backend only — +//! the `AdvancedSearch.tsx` UI wiring stays PR-7 (F2). Cross-server search +//! (§5.5B) lives in the sibling `cross_server` module. +//! +//! The builder turns a `LibraryAdvancedSearchRequest` into one parameterised +//! query per requested entity (track / album / artist), each sharing a WHERE +//! built from the `FilterFieldRegistry` resolution in `filter.rs`. Only +//! builder-supplied column expressions ever reach the SQL string; every value +//! is bound (§5.13.5: parameterised only). + +use std::collections::{BTreeSet, HashSet}; + +use rusqlite::types::Value as SqlValue; +use serde_json::Value; + +use crate::dto::{ + LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto, LibraryArtistDto, + LibraryFilterClause, LibrarySearchTotals, LibrarySortClause, LibraryTrackDto, SortDir, +}; +use crate::filter::{self, EntityKind, FilterOp, SqlFragment}; +use crate::repos; +use crate::search::{ + aliased_track_columns, fts_album_prefix_match_query, fts_column_prefix_query, + fts_query_meets_min_len, fts_track_prefix_match_query, library_scope_equals_sql, + like_contains, PAGE_LIMIT_MAX, +}; +use crate::store::LibraryStore; + +/// `bpm` dual-storage resolution (§5.13.4): prefer the hot `track.bpm` +/// column, fall back to the highest-priority `track_fact(bpm)` value. The +/// spec's `not_found = 0` guard is dropped — the live `track_fact` schema +/// has no such column (that lives on `track_artifact`). +const BPM_RESOLVED_EXPR: &str = "COALESCE(t.bpm, (SELECT f.value_int FROM track_fact f \ + WHERE f.server_id = t.server_id AND f.track_id = t.id AND f.fact_kind = 'bpm' \ + ORDER BY CASE f.source_kind WHEN 'user' THEN 0 WHEN 'server_tag' THEN 1 \ + WHEN 'analysis' THEN 2 ELSE 3 END LIMIT 1))"; + +const ALBUM_COLUMNS: &str = "a.server_id, a.id, a.name, a.artist, a.artist_id, \ + a.song_count, a.duration_sec, a.year, a.genre, a.cover_art_id, a.starred_at, \ + a.synced_at, a.raw_json"; + +const ARTIST_COLUMNS: &str = "ar.server_id, ar.id, ar.name, ar.album_count, \ + ar.synced_at, ar.raw_json"; + +/// Flat track projection used when browsing albums in advanced search. +type AlbumBrowseTrackRow = ( + String, + String, + String, + Option, + Option, + Option, + Option, + Option, + Option, + i64, +); + +fn fts_candidate_pool_size(limit: u32, offset: u32) -> i64 { + let need = limit.saturating_add(offset) as i64; + need.saturating_mul(20).clamp(256, 10_000) +} + +/// `library_advanced_search` (§5.13). Runs only the queries named in +/// `entityTypes`; absent entities return empty + zero totals. +pub fn run_advanced_search( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, +) -> Result { + // `query` shorthand → text input; a `text` filter clause is an alias for + // the same thing. Everything else is a scalar filter. + let mut text_input: Option = trimmed_nonempty(req.query.as_deref()); + let mut scalar: Vec<&LibraryFilterClause> = Vec::new(); + for c in &req.filters { + if c.field == "text" { + if text_input.is_none() { + if let Some(Value::String(s)) = &c.value { + text_input = trimmed_nonempty(Some(s)); + } + } + } else { + scalar.push(c); + } + } + + // Up-front validation: an unknown field or an op the registry doesn't + // declare is an error regardless of entity routing (§5.13.5). + for c in &scalar { + let field = filter::lookup(&c.field) + .ok_or_else(|| filter::FilterError::UnknownField(c.field.clone()).to_string())?; + if !field.ops.contains(&c.op) { + return Err(filter::FilterError::UnsupportedOp { + field: c.field.clone(), + op: c.op.as_str(), + } + .to_string()); + } + } + + if text_input + .as_deref() + .is_some_and(|t| !fts_query_meets_min_len(t)) + { + return Ok(LibraryAdvancedSearchResponse { + artists: Vec::new(), + albums: Vec::new(), + tracks: Vec::new(), + totals: LibrarySearchTotals { + artists: 0, + albums: 0, + tracks: 0, + }, + applied_filters: Vec::new(), + source: "local".to_string(), + }); + } + + let limit = req.limit.clamp(1, PAGE_LIMIT_MAX); + let offset = req.offset; + let skip_totals = req.skip_totals; + let text = text_input.as_deref(); + let want = |k: EntityKind| req.entity_types.contains(&k); + let mut applied: BTreeSet = BTreeSet::new(); + + let (artists, artists_total) = if want(EntityKind::Artist) { + build_artist(store, req, text, &scalar, limit, offset, skip_totals, &mut applied)? + } else { + (Vec::new(), 0) + }; + let (albums, albums_total) = if want(EntityKind::Album) { + build_album(store, req, text, &scalar, limit, offset, skip_totals, &mut applied)? + } else { + (Vec::new(), 0) + }; + let (tracks, tracks_total) = if want(EntityKind::Track) { + build_track(store, req, text, &scalar, limit, offset, skip_totals, &mut applied)? + } else { + (Vec::new(), 0) + }; + + Ok(LibraryAdvancedSearchResponse { + artists, + albums, + tracks, + totals: LibrarySearchTotals { + artists: artists_total, + albums: albums_total, + tracks: tracks_total, + }, + applied_filters: applied.into_iter().collect(), + source: "local".to_string(), + }) +} + +// ── per-entity builders ──────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +fn build_track( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let mut w = WhereBuilder::new(); + w.push_raw("t.deleted = 0"); + w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); + if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + let clause = library_scope_equals_sql("t"); + w.push_param(&clause, SqlValue::Text(scope)); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Track)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + if req.starred_only == Some(true) { + w.push_raw("t.starred_at IS NOT NULL"); + applied.insert("starred".to_string()); + } + + let cols = aliased_track_columns("t"); + if let Some(q) = text.and_then(fts_track_prefix_match_query) { + applied.insert("text".to_string()); + let pool = fts_candidate_pool_size(limit, offset); + let from = format!( + "track t INNER JOIN (\ + SELECT rowid, bm25(track_fts) AS fts_rank FROM track_fts \ + WHERE track_fts MATCH ? ORDER BY fts_rank LIMIT {pool}\ + ) fts_pick ON t.rowid = fts_pick.rowid" + ); + let order = order_clause(&req.sort, EntityKind::Track) + .unwrap_or_else(|| "ORDER BY fts_pick.fts_rank".to_string()); + return query_rows_fts( + store, + &cols, + &from, + &q, + &w, + &order, + limit, + offset, + skip_totals, + |r| repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row)), + ); + } + + let order = order_clause(&req.sort, EntityKind::Track) + .unwrap_or_else(|| "ORDER BY t.title COLLATE NOCASE ASC, t.id ASC".to_string()); + query_rows( + store, + &cols, + "track t", + &w, + &order, + limit, + offset, + skip_totals, + |r| repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row)), + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_album( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?; + if !table.0.is_empty() || table.1 > 0 { + return Ok(table); + } + if let Some(q) = text.and_then(fts_album_prefix_match_query) { + return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied); + } + build_album_from_tracks(store, req, text, scalar, limit, offset, skip_totals, applied) +} + +#[allow(clippy::too_many_arguments)] +fn build_album_from_table( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + // `album` has no `library_id` / `deleted` columns, so `libraryScope` is + // a track-only filter (P20) and does not narrow album-table results. + let mut w = WhereBuilder::new(); + w.push_param("a.server_id = ?", SqlValue::Text(req.server_id.clone())); + if let Some(t) = text { + w.push_param("a.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); + applied.insert("text".to_string()); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Album)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + if req.starred_only == Some(true) { + w.push_raw("a.starred_at IS NOT NULL"); + applied.insert("starred".to_string()); + } + + let order = order_clause(&req.sort, EntityKind::Album) + .unwrap_or_else(|| "ORDER BY a.name COLLATE NOCASE ASC, a.id ASC".to_string()); + query_rows( + store, + ALBUM_COLUMNS, + "album a", + &w, + &order, + limit, + offset, + skip_totals, + map_album, + ) +} + +/// Album rows derived from synced tracks when the dedicated `album` table +/// has no matching rows (N1 / S1 ingest only writes tracks today). +#[allow(clippy::too_many_arguments)] +fn build_album_from_tracks( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let mut w = WhereBuilder::new(); + w.push_raw("t.deleted = 0"); + w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); + w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''"); + w.push_raw( + "NOT EXISTS (SELECT 1 FROM album a WHERE a.server_id = t.server_id AND a.id = t.album_id)", + ); + if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + let clause = library_scope_equals_sql("t"); + w.push_param(&clause, SqlValue::Text(scope)); + } + if let Some(t) = text { + w.push_param("t.album LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); + applied.insert("text".to_string()); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Track)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + if req.starred_only == Some(true) { + w.push_raw("t.starred_at IS NOT NULL"); + applied.insert("starred".to_string()); + } + + let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \ + COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \ + MAX(t.starred_at), MAX(t.synced_at)"; + let order = order_clause(&req.sort, EntityKind::Album).unwrap_or_else(|| { + "ORDER BY MAX(t.album) COLLATE NOCASE ASC, t.album_id ASC".to_string() + }); + query_grouped_rows( + store, + select, + "track t", + &w, + "GROUP BY t.album_id", + &order, + limit, + offset, + skip_totals, + map_album_from_tracks, + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_artist( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let table = build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?; + if !table.0.is_empty() || table.1 > 0 { + return Ok(table); + } + if let Some(q) = text.and_then(|t| fts_column_prefix_query("artist", t)) { + return build_artist_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied); + } + build_artist_from_tracks(store, req, text, scalar, limit, offset, skip_totals, applied) +} + +#[allow(clippy::too_many_arguments)] +fn build_artist_from_table( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let mut w = WhereBuilder::new(); + w.push_param("ar.server_id = ?", SqlValue::Text(req.server_id.clone())); + if let Some(t) = text { + w.push_param("ar.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); + applied.insert("text".to_string()); + } + // Only `text` routes to artist with a real column; other registered + // fields resolve to `None` (skip). `starredOnly` has no artist column. + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Artist)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + + let order = order_clause(&req.sort, EntityKind::Artist) + .unwrap_or_else(|| "ORDER BY ar.name COLLATE NOCASE ASC, ar.id ASC".to_string()); + query_rows( + store, + ARTIST_COLUMNS, + "artist ar", + &w, + &order, + limit, + offset, + skip_totals, + map_artist, + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_artist_from_tracks( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let mut w = WhereBuilder::new(); + w.push_raw("t.deleted = 0"); + w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); + w.push_raw("t.artist_id IS NOT NULL AND t.artist_id != ''"); + w.push_raw( + "NOT EXISTS (SELECT 1 FROM artist ar WHERE ar.server_id = t.server_id AND ar.id = t.artist_id)", + ); + if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + let clause = library_scope_equals_sql("t"); + w.push_param(&clause, SqlValue::Text(scope)); + } + if let Some(t) = text { + w.push_param("t.artist LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); + applied.insert("text".to_string()); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Track)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + + let select = "t.server_id, t.artist_id, MAX(t.artist), COUNT(DISTINCT t.album_id), MAX(t.synced_at)"; + let order = order_clause(&req.sort, EntityKind::Artist).unwrap_or_else(|| { + "ORDER BY MAX(t.artist) COLLATE NOCASE ASC, t.artist_id ASC".to_string() + }); + query_grouped_rows( + store, + select, + "track t", + &w, + "GROUP BY t.artist_id", + &order, + limit, + offset, + skip_totals, + map_artist_from_tracks, + ) +} + +/// Text search for albums when the `album` table is empty — one FTS pass + +/// in-memory dedupe by `album_id` (same strategy as live search / §5.9). +#[allow(clippy::too_many_arguments)] +fn build_album_from_fts( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + fts: &str, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + applied.insert("text".to_string()); + let need = limit.saturating_add(offset) as i64; + let pool = (need.saturating_mul(8)).clamp(64, 2_000); + + let mut w = WhereBuilder::new(); + w.push_param( + &format!( + "t.rowid IN (SELECT rowid FROM track_fts WHERE track_fts MATCH ? ORDER BY bm25(track_fts) LIMIT {pool})" + ), + SqlValue::Text(fts.to_string()), + ); + w.push_raw("t.deleted = 0"); + w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); + w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''"); + if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + let clause = library_scope_equals_sql("t"); + w.push_param(&clause, SqlValue::Text(scope)); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Track)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + if req.starred_only == Some(true) { + w.push_raw("t.starred_at IS NOT NULL"); + applied.insert("starred".to_string()); + } + + let where_sql = w.where_sql(); + store.with_read_conn(|conn| { + let sql = format!( + "SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \ + t.genre, t.cover_art_id, t.starred_at, t.synced_at \ + FROM track t \ + WHERE {where_sql}" + ); + let params = w.params.clone(); + let mut stmt = conn.prepare(&sql)?; + let rows: Vec = + stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + r.get(8)?, + r.get(9)?, + )) + })? + .collect::>>()?; + + let mut seen = HashSet::new(); + let mut deduped: Vec = Vec::new(); + for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows { + if !seen.insert(album_id.clone()) { + continue; + } + deduped.push(LibraryAlbumDto { + server_id, + id: album_id, + name: album, + artist, + artist_id, + song_count: None, + duration_sec: None, + year, + genre, + cover_art_id, + starred_at, + synced_at, + raw_json: Value::Null, + }); + if deduped.len() >= need as usize { + break; + } + } + + let total = if skip_totals { + 0 + } else { + deduped.len() as u32 + }; + let page = deduped + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) + }) +} + +/// Text search for artists when the `artist` table is empty — FTS + dedupe. +#[allow(clippy::too_many_arguments)] +fn build_artist_from_fts( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + fts: &str, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + applied.insert("text".to_string()); + let need = limit.saturating_add(offset) as i64; + let pool = (need.saturating_mul(8)).clamp(64, 2_000); + + let mut w = WhereBuilder::new(); + w.push_param( + &format!( + "t.rowid IN (SELECT rowid FROM track_fts WHERE track_fts MATCH ? ORDER BY bm25(track_fts) LIMIT {pool})" + ), + SqlValue::Text(fts.to_string()), + ); + w.push_raw("t.deleted = 0"); + w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); + w.push_raw("t.artist_id IS NOT NULL AND t.artist_id != ''"); + if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + let clause = library_scope_equals_sql("t"); + w.push_param(&clause, SqlValue::Text(scope)); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Track)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + + let where_sql = w.where_sql(); + store.with_read_conn(|conn| { + let sql = format!( + "SELECT t.server_id, t.artist_id, t.artist, t.synced_at \ + FROM track t \ + WHERE {where_sql}" + ); + let params = w.params.clone(); + let mut stmt = conn.prepare(&sql)?; + let rows: Vec<(String, String, Option, i64)> = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) + })? + .collect::>>()?; + + let mut seen = HashSet::new(); + let mut deduped: Vec = Vec::new(); + for (server_id, artist_id, artist, synced_at) in rows { + if !seen.insert(artist_id.clone()) { + continue; + } + deduped.push(LibraryArtistDto { + server_id, + id: artist_id, + name: artist.unwrap_or_default(), + album_count: None, + synced_at, + raw_json: Value::Null, + }); + if deduped.len() >= need as usize { + break; + } + } + + let total = if skip_totals { + 0 + } else { + deduped.len() as u32 + }; + let page = deduped + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) + }) +} + +// ── clause resolution ────────────────────────────────────────────────── + +/// Resolve one scalar clause to a WHERE fragment for `entity`. `Ok(None)` +/// means the field is known but doesn't route to this entity (§5.13.3 skip). +fn resolve_clause( + c: &LibraryFilterClause, + entity: EntityKind, +) -> Result, String> { + let applies = filter::validate_for_entity(&c.field, c.op, entity).map_err(|e| e.to_string())?; + if !applies { + return Ok(None); + } + let col = match (c.field.as_str(), entity) { + ("genre", EntityKind::Track) => "t.genre", + ("genre", EntityKind::Album) => "a.genre", + ("year", EntityKind::Track) => "t.year", + ("year", EntityKind::Album) => "a.year", + ("starred", EntityKind::Track) => "t.starred_at", + ("starred", EntityKind::Album) => "a.starred_at", + // `starred` routes to artist in the registry, but the `artist` + // table has no `starred_at` column — skip rather than error. + ("starred", EntityKind::Artist) => return Ok(None), + ("bpm", EntityKind::Track) => BPM_RESOLVED_EXPR, + // `text` is handled by the entity builder (FTS / LIKE), never here. + ("text", _) => return Ok(None), + // Registered but no v1 SQL builder (user_rating / suffix / bit_rate). + _ => return Err(filter::FilterError::NotQueryable(c.field.clone()).to_string()), + }; + + if c.field == "genre" { + let v = json_to_text(&c.field, c.value.as_ref())?; + return Ok(Some(SqlFragment { + sql: format!("{col} = ? COLLATE NOCASE"), + params: vec![v], + })); + } + if c.field == "starred" { + return filter::compare_fragment(&c.field, col, FilterOp::IsTrue, None, None) + .map(Some) + .map_err(|e| e.to_string()); + } + // Numeric fields: year / bpm. + let value = json_to_opt_i64(&c.field, c.value.as_ref())?; + let value_to = json_to_opt_i64(&c.field, c.value_to.as_ref())?; + filter::compare_fragment(&c.field, col, c.op, value, value_to) + .map(Some) + .map_err(|e| e.to_string()) +} + +// ── query execution ──────────────────────────────────────────────────── + +/// Cap full-table FTS counts — exact totals on 100k+ hits are not worth +/// blocking the UI for tens of seconds (§5.9 p95 budget). +const FTS_MATCH_COUNT_CAP: i64 = 10_001; + +fn count_matching_rows( + conn: &rusqlite::Connection, + from: &str, + where_sql: &str, + params: &[SqlValue], + skip_totals: bool, +) -> Result { + if skip_totals { + return Ok(0); + } + if from.contains("track_fts") { + let mut bound: Vec = params.to_vec(); + bound.push(SqlValue::Integer(FTS_MATCH_COUNT_CAP)); + let count_sql = format!( + "SELECT COUNT(*) FROM (SELECT 1 FROM {from} WHERE {where_sql} LIMIT ?)" + ); + let n: i64 = conn.query_row( + &count_sql, + rusqlite::params_from_iter(bound.iter()), + |r| r.get(0), + )?; + return Ok(n.max(0) as u32); + } + let count_sql = format!("SELECT COUNT(*) FROM {from} WHERE {where_sql}"); + let n: i64 = conn.query_row( + &count_sql, + rusqlite::params_from_iter(params.iter()), + |r| r.get(0), + )?; + Ok(n.max(0) as u32) +} + +/// Accumulates `AND`-joined WHERE clauses and their positional params in +/// lockstep so anonymous `?` placeholders bind left-to-right. +struct WhereBuilder { + clauses: Vec, + params: Vec, +} + +impl WhereBuilder { + fn new() -> Self { + Self { + clauses: Vec::new(), + params: Vec::new(), + } + } + fn push(&mut self, frag: SqlFragment) { + self.clauses.push(frag.sql); + self.params.extend(frag.params); + } + fn push_raw(&mut self, sql: &str) { + self.clauses.push(sql.to_string()); + } + fn push_param(&mut self, sql: &str, param: SqlValue) { + self.clauses.push(sql.to_string()); + self.params.push(param); + } + fn where_sql(&self) -> String { + self.clauses.join(" AND ") + } +} + +/// Run the COUNT (full match total) + the paged SELECT in one connection +/// borrow. Both share `where`'s params; the page appends `LIMIT ? OFFSET ?`. +#[allow(clippy::too_many_arguments)] +fn query_rows( + store: &LibraryStore, + select_cols: &str, + from: &str, + w: &WhereBuilder, + order_sql: &str, + limit: u32, + offset: u32, + skip_totals: bool, + map: F, +) -> Result<(Vec, u32), String> +where + F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + let where_sql = w.where_sql(); + store.with_read_conn(|conn| { + let total = count_matching_rows(conn, from, &where_sql, &w.params, skip_totals)?; + + let page_sql = format!( + "SELECT {select_cols} FROM {from} WHERE {where_sql} {order_sql} LIMIT ? OFFSET ?" + ); + let mut page_params: Vec = w.params.clone(); + page_params.push(SqlValue::Integer(limit as i64)); + page_params.push(SqlValue::Integer(offset as i64)); + let mut stmt = conn.prepare(&page_sql)?; + let collected: rusqlite::Result> = stmt + .query_map(rusqlite::params_from_iter(page_params.iter()), |r| map(r))? + .collect(); + let rows = collected?; + Ok((rows, total)) + }) +} + +/// Track search with FTS rowid prefilter — MATCH param is bound first (subquery in `from`). +#[allow(clippy::too_many_arguments)] +fn query_rows_fts( + store: &LibraryStore, + select_cols: &str, + from: &str, + fts_match: &str, + w: &WhereBuilder, + order_sql: &str, + limit: u32, + offset: u32, + skip_totals: bool, + map: F, +) -> Result<(Vec, u32), String> +where + F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + let where_sql = w.where_sql(); + store.with_read_conn(|conn| { + let mut bind: Vec = vec![SqlValue::Text(fts_match.to_string())]; + bind.extend(w.params.iter().cloned()); + + let total = count_matching_rows(conn, from, &where_sql, &bind, skip_totals)?; + + let page_sql = format!( + "SELECT {select_cols} FROM {from} WHERE {where_sql} {order_sql} LIMIT ? OFFSET ?" + ); + bind.push(SqlValue::Integer(limit as i64)); + bind.push(SqlValue::Integer(offset as i64)); + let mut stmt = conn.prepare(&page_sql)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(bind.iter()), |r| map(r))? + .collect::>>()?; + Ok((rows, total)) + }) +} + +/// Grouped SELECT (album/artist rows derived from `track`). Skips COUNT when +/// `skip_totals` — Live Search only needs the first page. +#[allow(clippy::too_many_arguments)] +fn query_grouped_rows( + store: &LibraryStore, + select_cols: &str, + from: &str, + w: &WhereBuilder, + group_sql: &str, + order_sql: &str, + limit: u32, + offset: u32, + skip_totals: bool, + map: F, +) -> Result<(Vec, u32), String> +where + F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + let where_sql = w.where_sql(); + store.with_read_conn(|conn| { + let total = if skip_totals { + 0u32 + } else { + count_matching_rows(conn, from, &where_sql, &w.params, false)? + }; + + let page_sql = format!( + "SELECT {select_cols} FROM {from} WHERE {where_sql} {group_sql} {order_sql} LIMIT ? OFFSET ?" + ); + let mut page_params: Vec = w.params.clone(); + page_params.push(SqlValue::Integer(limit as i64)); + page_params.push(SqlValue::Integer(offset as i64)); + let mut stmt = conn.prepare(&page_sql)?; + let collected: rusqlite::Result> = stmt + .query_map(rusqlite::params_from_iter(page_params.iter()), |r| map(r))? + .collect(); + let rows = collected?; + Ok((rows, total)) + }) +} + +// ── row mappers ──────────────────────────────────────────────────────── + +fn map_album(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let raw: Option = r.get(12)?; + Ok(LibraryAlbumDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + artist: r.get(3)?, + artist_id: r.get(4)?, + song_count: r.get(5)?, + duration_sec: r.get(6)?, + year: r.get(7)?, + genre: r.get(8)?, + cover_art_id: r.get(9)?, + starred_at: r.get(10)?, + synced_at: r.get(11)?, + raw_json: parse_raw_json(raw), + }) +} + +fn map_artist(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let raw: Option = r.get(5)?; + Ok(LibraryArtistDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + album_count: r.get(3)?, + synced_at: r.get(4)?, + raw_json: parse_raw_json(raw), + }) +} + +fn map_album_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(LibraryAlbumDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + artist: r.get(3)?, + artist_id: r.get(4)?, + song_count: Some(r.get(5)?), + duration_sec: Some(r.get(6)?), + year: r.get(7)?, + genre: r.get(8)?, + cover_art_id: r.get(9)?, + starred_at: r.get(10)?, + synced_at: r.get(11)?, + raw_json: Value::Null, + }) +} + +fn map_artist_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(LibraryArtistDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + album_count: Some(r.get(3)?), + synced_at: r.get(4)?, + raw_json: Value::Null, + }) +} + +fn parse_raw_json(raw: Option) -> Value { + raw.and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(Value::Null) +} + +// ── small helpers ────────────────────────────────────────────────────── + +fn trimmed_nonempty(s: Option<&str>) -> Option { + s.map(str::trim).filter(|s| !s.is_empty()).map(String::from) +} + +fn order_clause(sort: &[LibrarySortClause], entity: EntityKind) -> Option { + let mut keys: Vec = Vec::new(); + for s in sort { + if let Some(col) = sort_column(&s.field, entity) { + let dir = match s.dir { + SortDir::Asc => "ASC", + SortDir::Desc => "DESC", + }; + keys.push(format!("{col} {dir}")); + } + } + if keys.is_empty() { + None + } else { + Some(format!("ORDER BY {}", keys.join(", "))) + } +} + +/// Allowlist of sortable fields per entity → trusted column expression. +/// Unknown sort fields are ignored (fall back to the default order). +fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> { + match (field, entity) { + ("title", EntityKind::Track) => Some("t.title COLLATE NOCASE"), + ("year", EntityKind::Track) => Some("t.year"), + ("duration", EntityKind::Track) => Some("t.duration_sec"), + ("artist", EntityKind::Track) => Some("t.artist COLLATE NOCASE"), + ("album", EntityKind::Track) => Some("t.album COLLATE NOCASE"), + ("track_number", EntityKind::Track) => Some("t.track_number"), + ("play_count", EntityKind::Track) => Some("t.play_count"), + ("name", EntityKind::Album) => Some("a.name COLLATE NOCASE"), + ("year", EntityKind::Album) => Some("a.year"), + ("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"), + ("name", EntityKind::Artist) => Some("ar.name COLLATE NOCASE"), + _ => None, + } +} + +fn json_to_text(field: &str, v: Option<&Value>) -> Result { + match v { + Some(Value::String(s)) => Ok(SqlValue::Text(s.clone())), + _ => Err(filter::FilterError::BadValue { + field: field.to_string(), + detail: "expected a string value".to_string(), + } + .to_string()), + } +} + +fn json_to_opt_i64(field: &str, v: Option<&Value>) -> Result, String> { + match v { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(n)) => n + .as_i64() + .map(|i| Some(SqlValue::Integer(i))) + .ok_or_else(|| { + filter::FilterError::BadValue { + field: field.to_string(), + detail: "expected an integer value".to_string(), + } + .to_string() + }), + _ => Err(filter::FilterError::BadValue { + field: field.to_string(), + detail: "expected a numeric value".to_string(), + } + .to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::SortDir; + use crate::repos::{TrackRepository, TrackRow}; + use serde_json::json; + + // ── fixtures ─────────────────────────────────────────────────────── + + fn track(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: title.into(), + title_sort: None, + artist: Some(artist.into()), + artist_id: Some(format!("ar_{artist}")), + album: album.into(), + album_id: Some(format!("al_{album}")), + album_artist: Some(artist.into()), + duration_sec: 200, + track_number: Some(1), + disc_number: Some(1), + 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: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + fn insert_album(store: &LibraryStore, server: &str, id: &str, name: &str, year: Option, genre: Option<&str>) { + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO album (server_id, id, name, year, genre, synced_at, raw_json) \ + VALUES (?1, ?2, ?3, ?4, ?5, 1, '{}')", + rusqlite::params![server, id, name, year, genre], + ) + }) + .unwrap(); + } + + fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) { + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO artist (server_id, id, name, synced_at, raw_json) \ + VALUES (?1, ?2, ?3, 1, '{}')", + rusqlite::params![server, id, name], + ) + }) + .unwrap(); + } + + fn req(server: &str, entities: &[EntityKind]) -> LibraryAdvancedSearchRequest { + LibraryAdvancedSearchRequest { + server_id: server.into(), + library_scope: None, + query: None, + entity_types: entities.to_vec(), + filters: Vec::new(), + starred_only: None, + sort: Vec::new(), + limit: 50, + offset: 0, + skip_totals: false, + } + } + + fn clause(field: &str, op: FilterOp, value: Option, value_to: Option) -> LibraryFilterClause { + LibraryFilterClause { + field: field.into(), + op, + value, + value_to, + } + } + + // ── text / FTS ───────────────────────────────────────────────────── + + #[test] + fn text_prefix_query_matches_partial_artist_name() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Enter Sandman", "Metallica", "Metallica"), + track("s1", "t2", "Other", "Other Artist", "Other Album"), + ]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.query = Some("metal".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].artist.as_deref(), Some("Metallica")); + } + + #[test] + fn text_query_matches_track_via_fts() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Aurora", "Anna", "Skylines"), + track("s1", "t2", "Sunset", "Beth", "Skylines"), + ]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.query = Some("aurora".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + assert_eq!(resp.totals.tracks, 1); + assert!(resp.applied_filters.contains(&"text".to_string())); + assert_eq!(resp.source, "local"); + } + + #[test] + fn text_query_matches_album_and_artist_via_like() { + let store = LibraryStore::open_in_memory(); + insert_album(&store, "s1", "al1", "Aurora Nights", None, None); + insert_album(&store, "s1", "al2", "Other", None, None); + insert_artist(&store, "s1", "ar1", "Aurora Quartet"); + let mut r = req("s1", &[EntityKind::Album, EntityKind::Artist]); + r.query = Some("aurora".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].id, "al1"); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].id, "ar1"); + } + + #[test] + fn text_query_derives_album_and_artist_from_tracks_when_tables_empty() { + let store = LibraryStore::open_in_memory(); + let mut t1 = track("s1", "t1", "Song One", "Aurora Quartet", "Aurora Nights"); + t1.cover_art_id = Some("cv1".into()); + TrackRepository::new(&store) + .upsert_batch(&[ + t1, + track("s1", "t2", "Song Two", "Other Artist", "Other Album"), + ]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Album, EntityKind::Artist]); + r.query = Some("aurora".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].id, "al_Aurora Nights"); + assert_eq!(resp.albums[0].cover_art_id.as_deref(), Some("cv1")); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].id, "ar_Aurora Quartet"); + } + + #[test] + fn special_chars_in_query_do_not_crash_fts() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "Hello World", "A", "B")]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + // Each of these is a raw FTS5 syntax error if passed unescaped; the + // builder must quote them into safe terms so the call returns Ok. + for q in ["\"", "AND", "foo*", "a OR b", "((", "near/"] { + r.query = Some(q.to_string()); + assert!( + run_advanced_search(&store, &r).is_ok(), + "query `{q}` must not raise an FTS syntax error" + ); + } + } + + #[test] + fn quoted_token_query_still_matches_clean_terms() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "Hello World", "A", "B")]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + // Multi-token query AND-s its terms — both present → one hit. + r.query = Some("hello world".into()); + assert_eq!(run_advanced_search(&store, &r).unwrap().tracks.len(), 1); + } + + // ── genre / year / starred ───────────────────────────────────────── + + #[test] + fn genre_filter_is_case_insensitive() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.genre = Some("Ambient".into()); + let mut b = track("s1", "t2", "B", "X", "Alb"); + b.genre = Some("Techno".into()); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("genre", FilterOp::Eq, Some(json!("ambient")), None)]; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + assert!(resp.applied_filters.contains(&"genre".to_string())); + } + + #[test] + fn year_between_is_inclusive() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.year = Some(2000); + let mut b = track("s1", "t2", "B", "X", "Alb"); + b.year = Some(2010); + let mut c = track("s1", "t3", "C", "X", "Alb"); + c.year = Some(2011); + TrackRepository::new(&store).upsert_batch(&[a, b, c]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("year", FilterOp::Between, Some(json!(2000)), Some(json!(2010)))]; + let resp = run_advanced_search(&store, &r).unwrap(); + let ids: Vec<&str> = resp.tracks.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["t1", "t2"]); + } + + #[test] + fn year_only_branch_runs_without_fts() { + // Genre/year-only (no query) must not require an FTS join (§5.13.7). + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.year = Some(1999); + TrackRepository::new(&store).upsert_batch(&[a]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("year", FilterOp::Gte, Some(json!(1999)), None)]; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert!(!resp.applied_filters.contains(&"text".to_string())); + } + + #[test] + fn starred_only_filters_tracks() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.starred_at = Some(123); + let b = track("s1", "t2", "B", "X", "Alb"); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.starred_only = Some(true); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + } + + // ── bpm dual storage ─────────────────────────────────────────────── + + #[test] + fn bpm_filter_matches_hot_column() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.bpm = Some(125); + let mut b = track("s1", "t2", "B", "X", "Alb"); + b.bpm = Some(90); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(120)), Some(json!(130)))]; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + } + + #[test] + fn bpm_filter_falls_back_to_track_fact() { + let store = LibraryStore::open_in_memory(); + // No hot `bpm`; an analysis fact carries it instead. + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "A", "X", "Alb")]) + .unwrap(); + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO track_fact \ + (server_id, track_id, fact_kind, value_int, source_kind, source_id, confidence, fetched_at) \ + VALUES ('s1', 't1', 'bpm', 128, 'analysis', 'seed', 1.0, 1)", + [], + ) + }) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(125)), Some(json!(130)))]; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1, "bpm should resolve via track_fact fallback"); + } + + // ── entity routing / errors ──────────────────────────────────────── + + #[test] + fn track_only_filter_is_ignored_for_album_entity_no_error() { + let store = LibraryStore::open_in_memory(); + insert_album(&store, "s1", "al1", "Some Album", Some(2001), None); + let mut r = req("s1", &[EntityKind::Album]); + // bpm is track-only; for an album query it must be skipped, not error. + r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(120)), Some(json!(130)))]; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert!(!resp.applied_filters.contains(&"bpm".to_string())); + } + + #[test] + fn unknown_field_is_an_error() { + let store = LibraryStore::open_in_memory(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("nope", FilterOp::Eq, Some(json!("x")), None)]; + let err = run_advanced_search(&store, &r).unwrap_err(); + assert!(err.contains("unknown filter field"), "got: {err}"); + } + + #[test] + fn planned_but_unbuilt_field_is_an_error() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "A", "X", "Alb")]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + // `suffix` is registered (Planned) but has no v1 SQL builder. + r.filters = vec![clause("suffix", FilterOp::Eq, Some(json!("flac")), None)]; + let err = run_advanced_search(&store, &r).unwrap_err(); + assert!(err.contains("not queryable"), "got: {err}"); + } + + #[test] + fn undeclared_op_for_known_field_is_an_error() { + let store = LibraryStore::open_in_memory(); + let mut r = req("s1", &[EntityKind::Track]); + // `genre` only declares `eq`. + r.filters = vec![clause("genre", FilterOp::Gte, Some(json!("rock")), None)]; + let err = run_advanced_search(&store, &r).unwrap_err(); + assert!(err.contains("not supported"), "got: {err}"); + } + + // ── scope / pagination / totals ──────────────────────────────────── + + #[test] + fn library_scope_narrows_track_results() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.library_id = Some("lib1".into()); + let mut b = track("s1", "t2", "B", "X", "Alb"); + b.library_id = Some("lib2".into()); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.library_scope = Some("lib1".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + } + + #[test] + fn library_scope_reads_library_id_from_raw_json_when_column_null() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.raw_json = serde_json::json!({"libraryId": 3}).to_string(); + TrackRepository::new(&store).upsert_batch(&[a]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.library_scope = Some("3".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + } + + #[test] + fn totals_reflect_full_match_count_not_page_size() { + let store = LibraryStore::open_in_memory(); + let rows: Vec = (0..10) + .map(|i| track("s1", &format!("t{i}"), "Common Title", "X", "Alb")) + .collect(); + TrackRepository::new(&store).upsert_batch(&rows).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.query = Some("common".into()); + r.limit = 3; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 3, "page is capped by limit"); + assert_eq!(resp.totals.tracks, 10, "total is the full match count"); + } + + #[test] + fn offset_pages_through_results() { + let store = LibraryStore::open_in_memory(); + let rows: Vec = (0..5) + .map(|i| track("s1", &format!("t{i}"), &format!("Title {i}"), "X", "Alb")) + .collect(); + TrackRepository::new(&store).upsert_batch(&rows).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.sort = vec![LibrarySortClause { field: "title".into(), dir: SortDir::Asc }]; + r.limit = 2; + r.offset = 2; + let resp = run_advanced_search(&store, &r).unwrap(); + let ids: Vec<&str> = resp.tracks.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["t2", "t3"]); + assert_eq!(resp.totals.tracks, 5); + } + + #[test] + fn unrequested_entities_are_empty() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "A", "X", "Alb")]) + .unwrap(); + insert_album(&store, "s1", "al1", "Alb", None, None); + let resp = run_advanced_search(&store, &req("s1", &[EntityKind::Track])).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert!(resp.albums.is_empty()); + assert!(resp.artists.is_empty()); + assert_eq!(resp.totals.albums, 0); + } + + #[test] + fn sort_desc_orders_results() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.year = Some(2000); + let mut b = track("s1", "t2", "B", "X", "Alb"); + b.year = Some(2020); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.sort = vec![LibrarySortClause { field: "year".into(), dir: SortDir::Desc }]; + let resp = run_advanced_search(&store, &r).unwrap(); + let ids: Vec<&str> = resp.tracks.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["t2", "t1"]); + } +} diff --git a/src-tauri/crates/psysonic-library/src/bulk_ingest.rs b/src-tauri/crates/psysonic-library/src/bulk_ingest.rs new file mode 100644 index 00000000..428d4fe3 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/bulk_ingest.rs @@ -0,0 +1,84 @@ +//! IS-3 bulk ingest tuning — drop hot-path indexes, restore at end. +//! +//! Secondary `track` indexes (album/artist/remap/…) are useless during the +//! initial upsert-only pass but cost several index inserts per row. Dropping +//! them for IS-3 keeps batch writes flat into the tens-of-ms range on large +//! libraries; they are recreated once before FTS rebuild. + +use rusqlite::Connection; + +const DROP_TRACK_SECONDARY_INDEXES: &str = r#" +DROP INDEX IF EXISTS idx_track_album; +DROP INDEX IF EXISTS idx_track_artist; +DROP INDEX IF EXISTS idx_track_updated; +DROP INDEX IF EXISTS idx_track_starred; +DROP INDEX IF EXISTS idx_track_library; +DROP INDEX IF EXISTS idx_track_bpm; +DROP INDEX IF EXISTS idx_track_isrc; +DROP INDEX IF EXISTS idx_track_remap_path; +DROP INDEX IF EXISTS idx_track_remap_hash; +DROP INDEX IF EXISTS idx_track_title; +"#; + +const RESTORE_TRACK_SECONDARY_INDEXES: &str = r#" +CREATE INDEX IF NOT EXISTS idx_track_album ON track(server_id, album_id) WHERE deleted = 0; +CREATE INDEX IF NOT EXISTS idx_track_artist ON track(server_id, artist_id) WHERE deleted = 0; +CREATE INDEX IF NOT EXISTS idx_track_updated ON track(server_id, server_updated_at DESC) WHERE deleted = 0; +CREATE INDEX IF NOT EXISTS idx_track_starred ON track(server_id, starred_at) WHERE deleted = 0 AND starred_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_track_library ON track(server_id, library_id) WHERE deleted = 0; +CREATE INDEX IF NOT EXISTS idx_track_bpm ON track(server_id, bpm) WHERE deleted = 0 AND bpm IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_track_isrc ON track(isrc) WHERE deleted = 0 AND isrc IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_track_remap_path + ON track(server_id, server_path) + WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != ''; +CREATE INDEX IF NOT EXISTS idx_track_remap_hash + ON track(server_id, content_hash) + WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != ''; +CREATE INDEX IF NOT EXISTS idx_track_title + ON track(server_id, title COLLATE NOCASE) + WHERE deleted = 0; +"#; + +/// Drop secondary indexes on `track` so bulk upserts only touch the PK. +pub fn suspend_track_secondary_indexes(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch(DROP_TRACK_SECONDARY_INDEXES) +} + +/// Recreate secondary indexes after bulk ingest (may take tens of seconds on +/// very large libraries — runs once at the end of IS-3, not per batch). +pub fn restore_track_secondary_indexes(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch(RESTORE_TRACK_SECONDARY_INDEXES) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::LibraryStore; + + #[test] + fn suspend_and_restore_track_indexes_roundtrip() { + let store = LibraryStore::open_in_memory(); + store + .with_conn_mut("misc", |conn| { + suspend_track_secondary_indexes(conn)?; + conn.execute( + "INSERT INTO track (server_id, id, title, album, album_id, artist_id, \ + duration_sec, deleted, synced_at, raw_json) \ + VALUES ('s1', 't1', 'T', 'Al', 'al1', 'ar1', 1, 0, 1, '{}')", + [], + )?; + restore_track_secondary_indexes(conn) + }) + .unwrap(); + let n: i64 = store + .with_read_conn(|c| { + c.query_row( + "SELECT COUNT(*) FROM track WHERE server_id = 's1' AND album_id = 'al1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(n, 1); + } +} diff --git a/src-tauri/crates/psysonic-library/src/canonical.rs b/src-tauri/crates/psysonic-library/src/canonical.rs new file mode 100644 index 00000000..762a0405 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/canonical.rs @@ -0,0 +1,264 @@ +//! H1 — `CanonicalMatcher`: cross-server track identity by **strong keys +//! only** (ISRC, then MBID recording), per spec §5.5A / P13. +//! +//! Runs inline on ingest and is O(1) per track — a deterministic canonical +//! id (`{kind}:{value}`) plus `INSERT … ON CONFLICT DO NOTHING`, so there's +//! no lookup-then-create race and no fuzzy loop on the 500k upsert path +//! (the fuzzy / album-similarity layer is search-time only — §5.5B / H3). +//! +//! v1 does **not** merge across kinds (a recording seen with an ISRC on one +//! server and only an MBID on another yields two canonical rows). Spec §5.5A +//! accepts this; a future merge layer would remap ids without a schema break. + +use rusqlite::{params, OptionalExtension, Transaction}; + +pub const KIND_ISRC: &str = "isrc"; +pub const KIND_MBID: &str = "mbid_recording"; + +/// Resolve (create-if-absent) the canonical id for one strong identity. +/// Idempotent: the deterministic id means re-running over an existing +/// identity is a pair of no-op upserts. +pub fn resolve_canonical_id( + tx: &Transaction<'_>, + kind: &str, + value: &str, + now: i64, +) -> rusqlite::Result { + let canonical_id = format!("{kind}:{value}"); + tx.execute( + "INSERT INTO canonical_track (id, created_at, updated_at) VALUES (?1, ?2, ?2) \ + ON CONFLICT(id) DO NOTHING", + params![canonical_id, now], + )?; + tx.execute( + "INSERT INTO canonical_identity (canonical_id, kind, value, confidence) \ + VALUES (?1, ?2, ?3, 1.0) \ + ON CONFLICT(kind, value) DO NOTHING", + params![canonical_id, kind, value], + )?; + Ok(canonical_id) +} + +/// Link `(server_id, track_id)` to its canonical id for whichever strong key +/// it carries — ISRC preferred, then MBID recording (§5.5A). No-op (returns +/// `Ok(None)`) when the track has neither. Idempotent on re-ingest. +pub fn link_track( + tx: &Transaction<'_>, + server_id: &str, + track_id: &str, + isrc: Option<&str>, + mbid_recording: Option<&str>, + now: i64, +) -> rusqlite::Result> { + let (kind, value) = match ( + isrc.filter(|s| !s.is_empty()), + mbid_recording.filter(|s| !s.is_empty()), + ) { + (Some(isrc), _) => (KIND_ISRC, isrc), + (None, Some(mbid)) => (KIND_MBID, mbid), + (None, None) => return Ok(None), + }; + + let canonical_id = resolve_canonical_id(tx, kind, value, now)?; + tx.execute( + "INSERT INTO track_canonical_link \ + (server_id, track_id, canonical_id, match_method, confidence, linked_at) \ + VALUES (?1, ?2, ?3, ?4, 1.0, ?5) \ + ON CONFLICT(server_id, track_id) DO UPDATE SET \ + canonical_id = excluded.canonical_id, \ + match_method = excluded.match_method, \ + confidence = excluded.confidence, \ + linked_at = excluded.linked_at", + params![server_id, track_id, canonical_id, kind, now], + )?; + Ok(Some(canonical_id)) +} + +/// Link every track on `server_id` that carries a strong identity key. +/// Used once after IS-3 bulk ingest instead of per-row inline linking. +pub fn link_all_tracks_for_server( + store: &crate::store::LibraryStore, + server_id: &str, + now: i64, +) -> Result { + store.with_conn_mut("misc", |conn| { + let tx = conn.transaction()?; + let mut stmt = tx.prepare( + "SELECT id, isrc, mbid_recording FROM track \ + WHERE server_id = ?1 AND deleted = 0 \ + AND (\ + (isrc IS NOT NULL AND isrc != '') \ + OR (mbid_recording IS NOT NULL AND mbid_recording != '')\ + )", + )?; + let rows: Vec<(String, Option, Option)> = stmt + .query_map(params![server_id], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + })? + .collect::>>()?; + drop(stmt); + let mut linked = 0u32; + for (track_id, isrc, mbid) in rows { + if link_track( + &tx, + server_id, + &track_id, + isrc.as_deref(), + mbid.as_deref(), + now, + )? + .is_some() + { + linked = linked.saturating_add(1); + } + } + tx.commit()?; + Ok(linked) + }) +} + +/// Read a track's canonical id, if linked. Convenience for tests / callers. +pub fn canonical_id_for( + tx: &Transaction<'_>, + server_id: &str, + track_id: &str, +) -> rusqlite::Result> { + tx.query_row( + "SELECT canonical_id FROM track_canonical_link WHERE server_id = ?1 AND track_id = ?2", + params![server_id, track_id], + |r| r.get(0), + ) + .optional() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::LibraryStore; + + fn with_tx(store: &LibraryStore, f: impl FnOnce(&Transaction<'_>) -> R) -> R { + store + .with_conn_mut("misc", |conn| { + let tx = conn.transaction()?; + let out = f(&tx); + tx.commit()?; + Ok(out) + }) + .unwrap() + } + + /// `track_canonical_link` has an FK to `track` — seed a minimal row so + /// the link insert is valid (foreign_keys are ON in-memory). + fn seed_track(tx: &Transaction<'_>, server: &str, id: &str) { + tx.execute( + "INSERT INTO track (server_id, id, title, synced_at, raw_json) \ + VALUES (?1, ?2, 'T', 1, '{}')", + params![server, id], + ) + .unwrap(); + } + + #[test] + fn resolve_is_deterministic_and_idempotent() { + let store = LibraryStore::open_in_memory(); + let (a, b) = with_tx(&store, |tx| { + let a = resolve_canonical_id(tx, KIND_ISRC, "USRC123", 1).unwrap(); + let b = resolve_canonical_id(tx, KIND_ISRC, "USRC123", 2).unwrap(); + (a, b) + }); + assert_eq!(a, "isrc:USRC123"); + assert_eq!(a, b, "same identity → same canonical id"); + + let (tracks, identities): (i64, i64) = store + .with_conn("misc", |c| { + Ok(( + c.query_row("SELECT COUNT(*) FROM canonical_track", [], |r| r.get(0))?, + c.query_row("SELECT COUNT(*) FROM canonical_identity", [], |r| r.get(0))?, + )) + }) + .unwrap(); + assert_eq!(tracks, 1, "idempotent — no duplicate canonical_track"); + assert_eq!(identities, 1); + } + + #[test] + fn link_prefers_isrc_over_mbid() { + let store = LibraryStore::open_in_memory(); + let cid = with_tx(&store, |tx| { + seed_track(tx, "s1", "t1"); + link_track(tx, "s1", "t1", Some("USRC1"), Some("mbid-1"), 1).unwrap() + }); + assert_eq!(cid.as_deref(), Some("isrc:USRC1")); + let stored = + with_tx(&store, |tx| canonical_id_for(tx, "s1", "t1").unwrap()); + assert_eq!(stored.as_deref(), Some("isrc:USRC1")); + } + + #[test] + fn link_uses_mbid_when_no_isrc() { + let store = LibraryStore::open_in_memory(); + let cid = with_tx(&store, |tx| { + seed_track(tx, "s1", "t1"); + link_track(tx, "s1", "t1", None, Some("mbid-9"), 1).unwrap() + }); + assert_eq!(cid.as_deref(), Some("mbid_recording:mbid-9")); + } + + #[test] + fn link_is_noop_without_strong_keys() { + let store = LibraryStore::open_in_memory(); + let cid = with_tx(&store, |tx| { + // empty strings count as absent + link_track(tx, "s1", "t1", Some(""), None, 1).unwrap() + }); + assert!(cid.is_none()); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_canonical_link", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn same_isrc_across_servers_shares_one_canonical_id() { + let store = LibraryStore::open_in_memory(); + let (a, b) = with_tx(&store, |tx| { + seed_track(tx, "s1", "t1"); + seed_track(tx, "s2", "t9"); + let a = link_track(tx, "s1", "t1", Some("USRC7"), None, 1).unwrap(); + let b = link_track(tx, "s2", "t9", Some("USRC7"), None, 1).unwrap(); + (a, b) + }); + assert_eq!(a, b); + let canon_count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM canonical_track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(canon_count, 1, "one canonical row shared across two servers"); + } + + #[test] + fn relink_updates_existing_row_in_place() { + let store = LibraryStore::open_in_memory(); + with_tx(&store, |tx| { + seed_track(tx, "s1", "t1"); + link_track(tx, "s1", "t1", None, Some("mbid-old"), 1).unwrap(); + // Later ingest now carries an ISRC — re-link overwrites. + link_track(tx, "s1", "t1", Some("USRCnew"), Some("mbid-old"), 2).unwrap(); + }); + let (cid, method): (String, String) = store + .with_conn("misc", |c| { + c.query_row( + "SELECT canonical_id, match_method FROM track_canonical_link \ + WHERE server_id = 's1' AND track_id = 't1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + }) + .unwrap(); + assert_eq!(cid, "isrc:USRCnew"); + assert_eq!(method, "isrc"); + let link_count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_canonical_link", [], |r| r.get(0))) + .unwrap(); + assert_eq!(link_count, 1, "re-link updates in place, no duplicate"); + } +} diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs new file mode 100644 index 00000000..7e10307b --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -0,0 +1,1397 @@ +//! Tauri commands — read-only surface for PR-5a (spec §7.1). Mutating +//! commands + sync lifecycle land in PR-5b. All commands take a +//! `State` so the top crate's `setup()` can wire one +//! shared `Arc` across the whole IPC surface. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use rusqlite::params; +use serde_json::Value; +use tauri::{AppHandle, Emitter, Manager, State}; + +use psysonic_integration::navidrome::navidrome_token; +use psysonic_integration::subsonic::SubsonicClient; + +use crate::advanced_search; +use crate::cross_server; +use crate::dto::{ + count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto, + FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, + LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto, + LibraryTracksEnvelope, OfflinePathDto, PurgeReportDto, SyncJobDto, SyncStateDto, + TrackArtifactDto, TrackFactDto, TrackRefDto, +}; +use crate::live_search; +use crate::payload::LibrarySyncProgressPayload; +use crate::repos::{SyncStateRepository, TrackRepository}; +use crate::runtime::{CurrentJob, LibraryRuntime, SyncSession}; +use crate::search::search_tracks; +use crate::store::LibraryStore; +use crate::sync::bandwidth::PlaybackHint; +use crate::sync::bandwidth::ParallelismBudget; +use crate::sync::capability::{probe_and_persist, CapabilityFlags, NavidromeProbeCredentials}; +use crate::sync::delta::DeltaSyncRunner; +use crate::sync::error::SyncError; +use crate::sync::initial::InitialSyncRunner; +use crate::sync::progress::{ChannelProgress, Progress, ProgressEvent}; +use crate::sync::tombstone::should_auto_reconcile; + +/// Cap for `library_get_tracks_batch` per spec §7.1 ("max 100 refs/call"). +const TRACKS_BATCH_LIMIT: usize = 100; + +#[tauri::command] +pub async fn library_get_status( + runtime: State<'_, LibraryRuntime>, + server_id: String, + library_scope: Option, +) -> Result { + let scope = library_scope.unwrap_or_default(); + let row: Option = runtime + .store + .with_read_conn(|conn| { + conn.query_row( + "SELECT sync_phase, capability_flags, library_tier, last_full_sync_at, \ + last_delta_sync_at, next_poll_at, server_last_scan_iso, \ + indexes_last_modified_ms, artists_last_modified_ms, local_track_count, \ + server_track_count, last_error \ + FROM sync_state WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, scope], + |r| { + Ok(SyncStateRow { + sync_phase: r.get(0)?, + capability_flags: r.get::<_, i64>(1)?.max(0) as u32, + library_tier: r.get(2)?, + last_full_sync_at: r.get(3)?, + last_delta_sync_at: r.get(4)?, + next_poll_at: r.get(5)?, + server_last_scan_iso: r.get(6)?, + indexes_last_modified_ms: r.get(7)?, + artists_last_modified_ms: r.get(8)?, + local_track_count: r.get(9)?, + server_track_count: r.get(10)?, + last_error: r.get(11)?, + }) + }, + ) + .optional() + }) + .map_err(|e| e.to_string())?; + + let local_tracks_max_updated_ms = if row.as_ref().is_some_and(|r| r.sync_phase == "initial_sync") { + None + } else { + local_tracks_max_updated_ms(&runtime.store, &server_id)? + }; + let has_local_tracks = track_index_nonempty(&runtime.store, &server_id).unwrap_or(false); + let sync_state = SyncStateRepository::new(&runtime.store); + let (ingest_strategy, ingest_phase, cursor_ingested_count) = sync_state + .get_initial_sync_cursor(&server_id, &scope) + .ok() + .flatten() + .map(|v| parse_ingest_cursor(&v)) + .unwrap_or((None, None, None)); + let n1_bulk_unreliable = sync_state + .get_n1_bulk_unreliable(&server_id, &scope) + .ok() + .flatten(); + let row = row.unwrap_or_default(); + let local_track_count = resolve_local_track_count( + &row, + cursor_ingested_count, + has_local_tracks, + &runtime.store, + &server_id, + ); + // `SyncStateRepository::ensure` is intentionally NOT called from + // the read path — `library_get_status` on a fresh server returns + // an "idle / unknown" stub without writing a row. PR-5b writes + // the row when `bind_session` lands. + Ok(SyncStateDto { + server_id, + library_scope: scope, + sync_phase: row.sync_phase, + capability_flags: row.capability_flags, + library_tier: row.library_tier, + last_full_sync_at: row.last_full_sync_at, + last_delta_sync_at: row.last_delta_sync_at, + next_poll_at: row.next_poll_at, + server_last_scan_iso: row.server_last_scan_iso, + indexes_last_modified_ms: row.indexes_last_modified_ms, + artists_last_modified_ms: row.artists_last_modified_ms, + local_track_count, + server_track_count: row.server_track_count, + last_error: row.last_error, + local_tracks_max_updated_ms, + has_local_tracks, + ingest_strategy, + ingest_phase, + cursor_ingested_count, + n1_bulk_unreliable, + }) +} + +fn parse_ingest_cursor(raw: &Value) -> (Option, Option, Option) { + if raw.as_object().is_none_or(|o| o.is_empty()) { + return (None, None, None); + } + let strategy = raw + .get("strategy") + .and_then(|v| v.as_str()) + .map(str::to_string); + let phase = raw + .get("phase") + .and_then(|v| v.as_str()) + .map(str::to_string); + let ingested = raw + .get("ingested_count") + .and_then(|v| v.as_u64()) + .map(|n| n.min(u32::MAX as u64) as u32); + (strategy, phase, ingested) +} + +/// Avoid full-table `COUNT(*)` while `initial_sync` is writing — use the +/// cheap cursor / snapshot counters updated on each cursor persist instead. +fn resolve_local_track_count( + row: &SyncStateRow, + cursor_ingested_count: Option, + has_local_tracks: bool, + store: &LibraryStore, + server_id: &str, +) -> Option { + if row.sync_phase == "initial_sync" { + let snapshot = row.local_track_count.unwrap_or(0); + let cursor = cursor_ingested_count.map(i64::from).unwrap_or(0); + let best = snapshot.max(cursor); + return if best > 0 { Some(best) } else { row.local_track_count }; + } + match row.local_track_count { + Some(n) if n > 0 => Some(n), + _ if has_local_tracks => count_local_tracks(store, server_id).ok(), + _ => row.local_track_count, + } +} + +#[tauri::command] +pub async fn library_search( + runtime: State<'_, LibraryRuntime>, + server_id: String, + query: String, + limit: Option, + offset: Option, + library_scope: Option, +) -> Result { + let _ = library_scope; // PR-5a accepts the arg for forward-compat; filter is wired in §5.13 + let limit = limit.unwrap_or(100).clamp(1, 500); + let offset = offset.unwrap_or(0); + // `search_tracks` returns lean `TrackHit` rows for FTS; PR-5a + // re-fetches the full `TrackRow` per hit so the DTO carries every + // hot column. Acceptable for `limit ≤ 100`; PR-5d wires a single- + // statement SQL builder via the FilterRegistry. + let hits = search_tracks(&runtime.store, &server_id, &query, limit as i64 + offset as i64)?; + let mut paged: Vec = hits + .into_iter() + .skip(offset as usize) + .map(|h| TrackRefDto { + server_id: h.server_id, + track_id: h.id, + content_hash: None, + }) + .collect(); + paged.truncate(limit as usize); + + let total = paged.len() as u32; + let tracks = hydrate_refs(&runtime, &paged)?; + Ok(LibraryTracksEnvelope { tracks, total }) +} + +#[tauri::command] +pub async fn library_get_track( + runtime: State<'_, LibraryRuntime>, + app: AppHandle, + server_id: String, + track_id: String, +) -> Result, String> { + let repo = TrackRepository::new(&runtime.store); + let Some(row) = repo.find_one(&server_id, &track_id)? else { + return Ok(None); + }; + let mut dto = LibraryTrackDto::from_row(&row); + + // E3 enrichment (read-only, per-server, best-effort — never blocks on the + // network). Only the single-track read pays for this; list/batch projections + // leave `enrichment = None`. + let now = now_unix_ms(); + let lyrics_cached = crate::repos::ArtifactRepository::new(&runtime.store) + .lyrics_cached(&server_id, &track_id, now) + .unwrap_or(false); + // waveform/loudness readiness is gated on a known content_hash (md5_16kb, + // populated by E2) and probed via the analysis-readiness port — exact key + // then legacy '' fallback, no re-tag. Absent port or hash ⇒ not ready. + let (waveform_ready, loudness_ready) = + match row.content_hash.as_deref().filter(|s| !s.is_empty()) { + Some(md5) => app + .try_state::() + .map(|q| q.readiness(&server_id, &track_id, md5)) + .unwrap_or((false, false)), + None => (false, false), + }; + dto.enrichment = Some(crate::dto::TrackEnrichmentDto { + waveform_ready, + loudness_ready, + lyrics_cached, + }); + Ok(Some(dto)) +} + +#[tauri::command] +pub async fn library_get_tracks_batch( + runtime: State<'_, LibraryRuntime>, + refs: Vec, +) -> Result, String> { + if refs.len() > TRACKS_BATCH_LIMIT { + return Err(format!( + "library_get_tracks_batch: refs exceeds cap ({} > {})", + refs.len(), + TRACKS_BATCH_LIMIT + )); + } + hydrate_refs(&runtime, &refs) +} + +#[tauri::command] +pub async fn library_get_tracks_by_album( + runtime: State<'_, LibraryRuntime>, + server_id: String, + album_id: String, +) -> Result, String> { + let rows = TrackRepository::new(&runtime.store).find_by_album(&server_id, &album_id)?; + Ok(rows.iter().map(LibraryTrackDto::from_row).collect()) +} + +#[tauri::command] +pub async fn library_get_artifact( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, + artifact_kind: String, + source_kind: Option, + source_id: Option, + format: Option, +) -> Result, String> { + // E4: typed repo owns the §5.12 lazy-expiry + flexible lookup. + crate::repos::ArtifactRepository::new(&runtime.store).get( + &server_id, + &track_id, + &artifact_kind, + source_kind.as_deref(), + source_id.as_deref(), + format.as_deref(), + now_unix_ms(), + ) +} + +#[tauri::command] +pub async fn library_get_facts( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, + fact_kinds: Option>, +) -> Result, String> { + // E4: typed repo owns the §5.12 lazy-expiry + provenance rules. + crate::repos::FactRepository::new(&runtime.store).get( + &server_id, + &track_id, + &fact_kinds.unwrap_or_default(), + now_unix_ms(), + ) +} + +#[tauri::command] +pub async fn library_get_offline_path( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, +) -> Result { + let path = runtime + .store + .with_conn("misc", |conn| { + conn.query_row( + "SELECT local_path FROM track_offline \ + WHERE server_id = ?1 AND track_id = ?2", + params![server_id, track_id], + |row| row.get::<_, String>(0), + ) + .optional() + }) + .map_err(|e| e.to_string())?; + Ok(OfflinePathDto { + server_id, + track_id, + missing: path.is_none(), + local_path: path, + }) +} + +// ────────────────────────────────────────────────────────────────────── +// PR-5d — Advanced Search (§5.13) + cross-server search (§5.5B) +// ────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn library_advanced_search( + runtime: State<'_, LibraryRuntime>, + request: LibraryAdvancedSearchRequest, +) -> Result { + advanced_search::run_advanced_search(&runtime.store, &request) +} + +#[tauri::command] +pub async fn library_live_search( + runtime: State<'_, LibraryRuntime>, + request: LibraryLiveSearchRequest, +) -> Result { + let empty = || LibraryLiveSearchResponse { + artists: Vec::new(), + albums: Vec::new(), + tracks: Vec::new(), + source: "local".to_string(), + }; + if let Some(epoch) = request.request_epoch { + runtime.register_live_search_epoch(epoch); + if !runtime.live_search_still_current(epoch) { + return Ok(empty()); + } + } + let result = live_search::run_live_search( + &runtime.store, + &request.server_id, + &request.query, + request.library_scope.as_deref(), + request.artist_limit.unwrap_or(5), + request.album_limit.unwrap_or(5), + request.song_limit.unwrap_or(10), + )?; + if request + .request_epoch + .is_some_and(|epoch| !runtime.live_search_still_current(epoch)) + { + return Ok(empty()); + } + Ok(result) +} + +#[tauri::command] +pub async fn library_search_cross_server( + runtime: State<'_, LibraryRuntime>, + query: String, + limit: Option, + servers: Option>, +) -> Result { + let limit = limit.unwrap_or(100); + cross_server::run_cross_server_search(&runtime.store, &query, limit, servers.as_deref()) +} + +// ── helpers ────────────────────────────────────────────────────────── + +fn hydrate_refs( + runtime: &LibraryRuntime, + refs: &[TrackRefDto], +) -> Result, String> { + let pairs: Vec<(String, String)> = refs + .iter() + .map(|r| (r.server_id.clone(), r.track_id.clone())) + .collect(); + let rows = TrackRepository::new(&runtime.store).find_batch(&pairs)?; + Ok(rows.iter().map(LibraryTrackDto::from_row).collect()) +} + +#[derive(Default)] +struct SyncStateRow { + sync_phase: String, + capability_flags: u32, + library_tier: String, + last_full_sync_at: Option, + last_delta_sync_at: Option, + next_poll_at: Option, + server_last_scan_iso: Option, + indexes_last_modified_ms: Option, + artists_last_modified_ms: Option, + local_track_count: Option, + server_track_count: Option, + last_error: Option, +} + +use rusqlite::OptionalExtension; + +// ────────────────────────────────────────────────────────────────────── +// PR-5b — session / lifecycle / mutate / purge +// ────────────────────────────────────────────────────────────────────── + +/// Normalise a server URL the same way the frontend's +/// `authStore.getBaseUrl()` does — prepend `http://` when no scheme is +/// present and strip the trailing slash. `server.url` is stored bare +/// (e.g. `nas.example.com`); without this reqwest rejects the request +/// with "relative URL without a base". +fn normalize_base_url(raw: &str) -> String { + let trimmed = raw.trim(); + let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + with_scheme.trim_end_matches('/').to_string() +} + +/// Acquire a Navidrome native-API bearer with a few retries. `/auth/login` +/// is occasionally flaky; one transient miss must not strip N1 for the whole +/// session (R7-15 Q3). Returns `None` only after every attempt fails — the +/// caller falls back to a cached bearer / the Subsonic-only path. Never logs +/// the token or credentials. +async fn navidrome_token_with_retry( + base_url: &str, + username: &str, + password: &str, +) -> Option { + const ATTEMPTS: u32 = 3; + for attempt in 1..=ATTEMPTS { + match navidrome_token(base_url, username, password).await { + Ok(tok) => return Some(tok), + Err(_) if attempt < ATTEMPTS => { + tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await; + } + Err(_) => return None, + } + } + None +} + +#[tauri::command] +pub async fn library_sync_bind_session( + runtime: State<'_, LibraryRuntime>, + server_id: String, + base_url: String, + username: String, + password: String, + library_scope: Option, +) -> Result<(), String> { + let base_url = normalize_base_url(&base_url); + // Prime the Navidrome native-API bearer at bind time (spec §6.1 + PR-5 + // kickoff Q5) so N1 probe / ingest works without every command passing a + // token. `/auth/login` is flaky, so retry a few times; if it still fails, + // keep a bearer cached from a prior bind rather than dropping to + // Subsonic-only — a transient miss must not strip an N1-capable server + // (R7-15 Q3). Non-Navidrome servers stay `None` and sync via Subsonic. + let navidrome_token_cached = match navidrome_token_with_retry(&base_url, &username, &password) + .await + { + Some(tok) => Some(tok), + None => runtime.get_session(&server_id).and_then(|s| s.navidrome_token), + }; + + let session = SyncSession { + server_id: server_id.clone(), + base_url: base_url.clone(), + username: username.clone(), + password: password.clone(), + navidrome_token: navidrome_token_cached.clone(), + library_scope: library_scope.clone(), + }; + runtime.set_session(session); + + // Run the probe + persist capability flags. Failure to probe is a + // bind-time error — caller should fix credentials / URL. + let subsonic = SubsonicClient::new(base_url, username, password); + let navidrome_creds = navidrome_token_cached.map(|tok| NavidromeProbeCredentials { + server_url: subsonic_base_url_from(&runtime, &server_id), + bearer_token: tok, + }); + let scope = library_scope.as_deref().unwrap_or_default(); + probe_and_persist( + &runtime.store, + &subsonic, + navidrome_creds.as_ref(), + &server_id, + scope, + ) + .await + .map_err(|e| format!("bind probe failed: {e}"))?; + Ok(()) +} + +fn subsonic_base_url_from(runtime: &LibraryRuntime, server_id: &str) -> String { + runtime + .get_session(server_id) + .map(|s| s.base_url) + .unwrap_or_default() +} + +#[tauri::command] +pub fn library_sync_clear_session( + runtime: State<'_, LibraryRuntime>, + server_id: String, +) -> Result<(), String> { + runtime.clear_session(&server_id); + Ok(()) +} + +#[tauri::command] +pub fn library_set_playback_hint( + runtime: State<'_, LibraryRuntime>, + hint: String, +) -> Result<(), String> { + let parsed = match hint.as_str() { + "idle" => PlaybackHint::Idle, + "playing" => PlaybackHint::Playing, + "prefetch_active" => PlaybackHint::PrefetchActive, + other => return Err(format!("unknown playback hint: `{other}`")), + }; + runtime.set_playback_hint(parsed); + Ok(()) +} + +#[tauri::command] +pub fn library_get_playback_hint(runtime: State<'_, LibraryRuntime>) -> Result { + Ok(match runtime.current_playback_hint() { + PlaybackHint::Idle => "idle".to_string(), + PlaybackHint::Playing => "playing".to_string(), + PlaybackHint::PrefetchActive => "prefetch_active".to_string(), + }) +} + +#[tauri::command] +pub async fn library_sync_start( + app: AppHandle, + runtime: State<'_, LibraryRuntime>, + server_id: String, + mode: String, + library_scope: Option, +) -> Result { + library_sync_start_inner(app, runtime, server_id, mode, library_scope, false).await +} + +/// Map a runner result for the sync-idle event. Cancellation is expected — +/// the user cancelled, or a newer `library_sync_start` superseded this job +/// (e.g. a server switch, or the startup resume) — and must never surface as +/// a failure toast (error.rs: "Cancelled is silent"). +fn sync_outcome_to_result(r: Result) -> Result<(), String> { + match r { + Ok(_) => Ok(()), + Err(SyncError::Cancelled) => Ok(()), + Err(e) => Err(e.to_string()), + } +} + +async fn library_sync_start_inner( + app: AppHandle, + runtime: State<'_, LibraryRuntime>, + server_id: String, + mode: String, + library_scope: Option, + force_full_tombstone: bool, +) -> Result { + let session = runtime.get_session(&server_id).ok_or_else(|| { + format!("no bound session for server `{server_id}` — call library_sync_bind_session first") + })?; + let scope = library_scope.clone().or(session.library_scope.clone()).unwrap_or_default(); + let mut capability_flags = load_capability_flags(&runtime, &server_id, &scope)?; + // N1 needs the Navidrome bearer. Without a cached token this run is + // Subsonic-only even on an N1-capable server — mask the flag for *this* + // run's strategy selection (R7-15 Q3 "proceed as Subsonic-only"). The + // persisted server capability stays untouched, so a later bind that + // recovers the token can use N1 again. + if session.navidrome_token.is_none() { + capability_flags.remove(CapabilityFlags::NAVIDROME_NATIVE_BULK); + } + + let kind = match mode.as_str() { + "full" => "initial_sync", + "delta" => "delta_sync", + other => return Err(format!("unknown sync mode: `{other}`")), + }; + if let Some(existing) = runtime.current_job() { + if existing.kind == "initial_sync" { + match kind { + "initial_sync" if existing.server_id == server_id => { + // Same-server full resync: cancel and drain the in-flight + // runner so its cursor writes can't race the replacement. + let done = Arc::clone(&existing.done); + existing + .cancel + .store(true, std::sync::atomic::Ordering::SeqCst); + drop(existing); + done.notified().await; + } + "initial_sync" => { + return Err(format!( + "initial sync already running for `{}` — wait for it to finish", + existing.server_id + )); + } + _ => { + return Err(format!( + "initial sync in progress for `{}` — try again later", + existing.server_id + )); + } + } + } + } + let job_id = format!("{}_{}", server_id, now_unix_ms()); + let cancel = Arc::new(AtomicBool::new(false)); + let done = Arc::new(tokio::sync::Notify::new()); + let job = CurrentJob { + job_id: job_id.clone(), + server_id: server_id.clone(), + kind: kind.to_string(), + cancel: Arc::clone(&cancel), + done: Arc::clone(&done), + }; + runtime.set_current_job(job); + + // Spawn the runner in a detached task. Progress events flow + // through an mpsc channel to the orchestrator that emits Tauri + // events; the runner doesn't need an AppHandle. + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let progress: Arc = + Arc::new(ChannelProgress::new(tx)); + + let store = Arc::clone(&runtime.store); + let session_clone = session.clone(); + let scope_for_task = scope.clone(); + let kind_for_task = kind.to_string(); + let cancel_for_task = Arc::clone(&cancel); + let job_id_for_task = job_id.clone(); + let parallelism = ParallelismBudget::resolve(runtime.current_playback_hint()); + + let runner_handle: tokio::task::JoinHandle> = tokio::task::spawn(async move { + let subsonic = SubsonicClient::new( + session_clone.base_url.clone(), + session_clone.username.clone(), + session_clone.password.clone(), + ); + let navidrome_creds = session_clone.navidrome_token.clone().map(|tok| { + NavidromeProbeCredentials { + server_url: session_clone.base_url.clone(), + bearer_token: tok, + } + }); + + let result: Result<(), String> = if kind_for_task == "initial_sync" { + let mut runner = InitialSyncRunner::new( + &store, + &subsonic, + session_clone.server_id.clone(), + scope_for_task.clone(), + capability_flags, + ) + .with_cancellation(Arc::clone(&cancel_for_task)) + .with_progress(Arc::clone(&progress)) + .with_parallelism_budget(parallelism); + if let Some(creds) = navidrome_creds.clone() { + runner = runner.with_navidrome_credentials(creds); + } + sync_outcome_to_result(runner.run().await) + } else { + // Delta — Mode A manual integrity uses the DeltaMismatch + // budget for tombstones when the local/server count gap + // is over threshold; otherwise a small budget keeps the + // background-like pass cheap. Manual «Verify integrity» + // forces the full budget regardless of threshold. + let tombstone_budget = if force_full_tombstone { + crate::sync::budget::RequestBudget::DELTA_MISMATCH_CAP + } else { + compute_tombstone_budget(&store, &session_clone.server_id, &scope_for_task) + }; + let mut runner = DeltaSyncRunner::new( + &store, + &subsonic, + session_clone.server_id.clone(), + scope_for_task.clone(), + capability_flags, + ) + .with_cancellation(Arc::clone(&cancel_for_task)) + .with_progress(Arc::clone(&progress)); + if tombstone_budget > 0 { + runner = runner.with_tombstone_budget(tombstone_budget); + } + if let Some(creds) = navidrome_creds.clone() { + runner = runner.with_navidrome_credentials(creds); + } + sync_outcome_to_result(runner.run().await) + }; + + // Closing the mpsc sender by dropping `progress` so the + // orchestrator's drain loop terminates. + drop(progress); + let _ = job_id_for_task; // silence unused on Err + result + }); + + // Orchestrator: drain progress + emit Tauri events, then emit + // sync-idle when the runner exits. + let app_for_emit = app.clone(); + let server_id_for_emit = server_id.clone(); + let scope_for_emit = scope.clone(); + let kind_for_emit = kind.to_string(); + let job_id_for_emit = job_id.clone(); + tokio::task::spawn(async move { + // Drain progress events; loop ends when sender is dropped. + while let Some(event) = rx.recv().await { + let payload = LibrarySyncProgressPayload::from_event( + &event, + &server_id_for_emit, + &scope_for_emit, + ); + let _ = app_for_emit + .emit(LibrarySyncProgressPayload::PROGRESS_EVENT_NAME, &payload); + } + // Wait for the runner to finish + emit sync-idle. + let outcome = match runner_handle.await { + Ok(Ok(())) => SyncIdleAck::ok(&server_id_for_emit, &scope_for_emit, &kind_for_emit), + Ok(Err(msg)) => SyncIdleAck::err(&server_id_for_emit, &scope_for_emit, &kind_for_emit, &msg), + Err(join_err) => SyncIdleAck::err( + &server_id_for_emit, + &scope_for_emit, + &kind_for_emit, + &format!("sync task panicked: {join_err}"), + ), + }; + let _ = app_for_emit.emit(LibrarySyncProgressPayload::IDLE_EVENT_NAME, &outcome); + + // Clear the slot only if it still names us — sync_start may + // have already overwritten with a newer job. + if let Some(state) = app_for_emit.try_state::() { + if let Some(job) = state.current_job() { + if job.job_id == job_id_for_emit { + job.done.notify_one(); + } + } + state.clear_current_job_if_matches(&job_id_for_emit); + } + }); + + Ok(SyncJobDto { + job_id, + server_id, + kind: kind.to_string(), + }) +} + +/// Manual «Verify library integrity» — same dispatch shape as +/// `library_sync_start { mode: 'delta' }` but always sets the full +/// `DELTA_MISMATCH_CAP` tombstone budget regardless of the +/// local/server count gap. Per PR-5b review §5 note 2: spec §6.7 +/// Mode A user-initiated full reconcile bypasses the threshold +/// check. +#[tauri::command] +pub async fn library_sync_verify_integrity( + app: AppHandle, + runtime: State<'_, LibraryRuntime>, + server_id: String, + library_scope: Option, +) -> Result { + library_sync_start_inner( + app, + runtime, + server_id, + "delta".to_string(), + library_scope, + /* force_full_tombstone */ true, + ) + .await +} + +#[tauri::command] +pub fn library_sync_cancel( + runtime: State<'_, LibraryRuntime>, + job_id: Option, +) -> Result<(), String> { + // `job_id` is informational — there's at most one in-flight job + // per `LibraryRuntime` at a time. If it's supplied and doesn't + // match, treat as no-op (the named job already finished). + if let Some(id) = &job_id { + if runtime.current_job().is_none_or(|j| &j.job_id != id) { + return Ok(()); + } + } + runtime.cancel_current_job(); + Ok(()) +} + +/// Record the playback-derived `md5_16kb` as `track.content_hash` for +/// `(server_id, track_id)` (E2). A no-op when the value is empty or the library +/// has no row for that pair (index off for the server). Shared by the +/// analysis→library content_hash bridge (registered in the shell crate) and by +/// [`library_patch_track`]'s `contentHash` field. The playback hash is +/// authoritative, so this overwrites unconditionally; sync ingest preserves it +/// via `COALESCE(NULLIF(excluded.content_hash,''), …)` in the upsert. +pub fn patch_content_hash( + runtime: &LibraryRuntime, + server_id: &str, + track_id: &str, + md5_16kb: &str, +) -> Result<(), String> { + if md5_16kb.is_empty() { + return Ok(()); + } + runtime + .store + .with_conn("misc", |conn| { + conn.execute( + "UPDATE track SET content_hash = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, md5_16kb], + )?; + Ok(()) + }) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn library_patch_track( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, + patch: Value, +) -> Result<(), String> { + apply_track_patch(&runtime, &server_id, &track_id, &patch) +} + +/// Apply a sparse `library_patch_track` JSON patch (extracted from the command +/// so it is unit-testable without a Tauri `State`). Only fields explicitly +/// present in `patch` are applied; absent keys leave the column untouched. For +/// the nullable integer fields, an explicit `null` clears the column (e.g. +/// `unstar` → `starredAt: null`): `.map` keeps the present/absent distinction +/// (outer `Some` = key present), `as_i64()` yields the value or `None` → bound +/// as SQL NULL. Spec §6.5 patch-on-use: `starred_at`, `user_rating`, +/// `play_count`, `played_at`; §8.1 E2: `content_hash`. All UPDATEs no-op when +/// the library has no row for `(server_id, track_id)`. +pub(crate) fn apply_track_patch( + runtime: &LibraryRuntime, + server_id: &str, + track_id: &str, + patch: &Value, +) -> Result<(), String> { + let starred_at = patch.get("starredAt").map(|v| v.as_i64()); + let user_rating = patch.get("userRating").map(|v| v.as_i64()); + let play_count = patch.get("playCount").map(|v| v.as_i64()); + let played_at = patch.get("playedAt").map(|v| v.as_i64()); + let content_hash = patch + .get("contentHash") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + + runtime + .store + .with_conn("misc", |conn| { + // One UPDATE per field present — keeps SQL simple and + // matches the spec's per-field patch semantics. + if let Some(v) = starred_at { + conn.execute( + "UPDATE track SET starred_at = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, v], + )?; + } + if let Some(v) = user_rating { + conn.execute( + "UPDATE track SET user_rating = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, v], + )?; + } + if let Some(v) = play_count { + conn.execute( + "UPDATE track SET play_count = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, v], + )?; + } + if let Some(v) = played_at { + conn.execute( + "UPDATE track SET played_at = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, v], + )?; + } + if let Some(v) = content_hash { + conn.execute( + "UPDATE track SET content_hash = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, v], + )?; + } + Ok(()) + }) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn library_put_artifact( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, + artifact: ArtifactInputDto, +) -> Result<(), String> { + // E4: typed repo owns the upsert + the §5.12 512 KB size cap. + crate::repos::ArtifactRepository::new(&runtime.store).put( + &server_id, + &track_id, + &artifact, + now_unix_ms(), + ) +} + +#[tauri::command] +pub fn library_put_fact( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, + fact: FactInputDto, +) -> Result<(), String> { + // E4: typed repo owns the upsert + the §5.12 user-override rule + // (a `user` bpm fact also writes the hot `track.bpm` column). + crate::repos::FactRepository::new(&runtime.store).put(&server_id, &track_id, &fact, now_unix_ms()) +} + +#[tauri::command] +pub fn library_purge_server( + runtime: State<'_, LibraryRuntime>, + server_id: String, + include_analysis: Option, + include_offline: Option, +) -> Result { + // R7-16 Q7: `includeAnalysis` is a deliberate v1 no-op — analysis blobs are + // expensive to rebuild (full-file decode) and the same host may return under + // a new login / app server_id with identical file content, so a purge or + // server-remove never deletes waveform/loudness rows. Kept on the surface for + // forward compat; explicit cleanup stays Settings → Storage + queue reseed. + let _ = include_analysis; + let include_offline = include_offline.unwrap_or(false); + + let mut report = PurgeReportDto::default(); + runtime + .store + .with_conn_mut("misc", |conn| { + let tx = conn.transaction()?; + let track_count: i64 = + tx.query_row("SELECT COUNT(*) FROM track WHERE server_id = ?1", params![server_id], |r| r.get(0))?; + let album_count: i64 = + tx.query_row("SELECT COUNT(*) FROM album WHERE server_id = ?1", params![server_id], |r| r.get(0))?; + let artist_count: i64 = + tx.query_row("SELECT COUNT(*) FROM artist WHERE server_id = ?1", params![server_id], |r| r.get(0))?; + let offline_count: i64 = + tx.query_row("SELECT COUNT(*) FROM track_offline WHERE server_id = ?1", params![server_id], |r| r.get(0))?; + let offline_bytes: Option = tx + .query_row( + "SELECT SUM(file_size_bytes) FROM track_offline WHERE server_id = ?1", + params![server_id], + |r| r.get(0), + ) + .ok(); + + // Tear down child rows first (no cascade configured) so + // the FK constraints on track stay happy. + tx.execute( + "DELETE FROM track_extension WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM track_fact WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM track_artifact WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM track_canonical_link WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM track_id_history WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM track WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM album WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM artist WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM sync_state WHERE server_id = ?1", + params![server_id], + )?; + if include_offline { + tx.execute( + "DELETE FROM track_offline WHERE server_id = ?1", + params![server_id], + )?; + } + tx.commit()?; + + report.tracks_deleted = track_count.max(0) as u32; + report.albums_deleted = album_count.max(0) as u32; + report.artists_deleted = artist_count.max(0) as u32; + report.offline_rows_deleted = if include_offline { + offline_count.max(0) as u32 + } else { + 0 + }; + report.bytes_freed = if include_offline { + offline_bytes.unwrap_or(0).max(0) + } else { + 0 + }; + Ok(()) + }) + .map_err(|e| e.to_string())?; + + // Drop any bound session / current job for this server — credentials + // out of memory, ongoing job cancelled. + runtime.clear_session(&server_id); + if let Some(job) = runtime.current_job() { + if job.server_id == server_id { + job.cancel.store(true, Ordering::SeqCst); + } + } + Ok(report) +} + +#[tauri::command] +pub fn library_delete_server_data( + runtime: State<'_, LibraryRuntime>, + server_id: String, +) -> Result<(), String> { + library_purge_server(runtime, server_id, Some(false), Some(true)).map(|_| ()) +} + +// ── helpers ────────────────────────────────────────────────────────── + +fn load_capability_flags( + runtime: &LibraryRuntime, + server_id: &str, + library_scope: &str, +) -> Result { + let bits = SyncStateRepository::new(&runtime.store) + .get_capability_flags(server_id, library_scope)? + .unwrap_or(0); + Ok(CapabilityFlags::new(bits)) +} + +fn compute_tombstone_budget( + store: &crate::store::LibraryStore, + server_id: &str, + library_scope: &str, +) -> u32 { + let sync_state = SyncStateRepository::new(store); + let local = sync_state + .get_local_track_count(server_id, library_scope) + .ok() + .flatten() + .unwrap_or(0) + .max(0) as u32; + let server = sync_state + .get_server_track_count(server_id, library_scope) + .ok() + .flatten() + .unwrap_or(0) + .max(0) as u32; + if should_auto_reconcile(local, server, crate::sync::scheduler::DEFAULT_TOMBSTONE_THRESHOLD_PCT) { + crate::sync::budget::RequestBudget::DELTA_MISMATCH_CAP + } else { + 0 + } +} + +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) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct SyncIdleAck { + server_id: String, + library_scope: String, + kind: String, + ok: bool, + error: Option, +} + +impl SyncIdleAck { + fn ok(server_id: &str, scope: &str, kind: &str) -> Self { + Self { + server_id: server_id.to_string(), + library_scope: scope.to_string(), + kind: kind.to_string(), + ok: true, + error: None, + } + } + fn err(server_id: &str, scope: &str, kind: &str, message: &str) -> Self { + Self { + server_id: server_id.to_string(), + library_scope: scope.to_string(), + kind: kind.to_string(), + ok: false, + error: Some(message.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::TrackRow; + use crate::store::LibraryStore; + use std::sync::Arc; + + fn make_row(server: &str, id: &str, album_id: &str, track_no: i64) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: format!("Track {id}"), + title_sort: None, + artist: Some("A".into()), + artist_id: Some("ar1".into()), + album: "Album".into(), + album_id: Some(album_id.into()), + album_artist: Some("A".into()), + duration_sec: 240, + track_number: Some(track_no), + disc_number: Some(1), + 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: Some(format!("/path/{id}.flac")), + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: Some(format!("hash-{id}")), + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + // The command functions take `tauri::State` which we can't easily + // construct in unit tests without a Tauri runtime. The tests below + // exercise the *underlying* logic by calling the equivalent + // `LibraryRuntime` + repo paths directly. Integration coverage with + // a real Tauri app lives outside this crate (PR-5c devtools test). + + fn runtime(store: Arc) -> LibraryRuntime { + LibraryRuntime::new(store) + } + + #[test] + fn get_status_returns_defaults_when_no_row_exists() { + let store = Arc::new(LibraryStore::open_in_memory()); + let rt = runtime(store); + // Simulate command body — same logic as `library_get_status`. + let local_max = local_tracks_max_updated_ms(&rt.store, "s1").unwrap(); + assert!(local_max.is_none()); + } + + #[test] + fn library_track_dto_from_row_preserves_hot_columns() { + let store = Arc::new(LibraryStore::open_in_memory()); + TrackRepository::new(&store) + .upsert_batch(&[make_row("s1", "tr_1", "al_1", 5)]) + .unwrap(); + let found = TrackRepository::new(&store).find_one("s1", "tr_1").unwrap().unwrap(); + let dto = LibraryTrackDto::from_row(&found); + assert_eq!(dto.id, "tr_1"); + assert_eq!(dto.album_id.as_deref(), Some("al_1")); + assert_eq!(dto.track_number, Some(5)); + } + + #[test] + fn patch_content_hash_sets_value_and_noops_on_absent_or_empty() { + let store = Arc::new(LibraryStore::open_in_memory()); + TrackRepository::new(&store) + .upsert_batch(&[make_row("s1", "tr_1", "al_1", 1)]) + .unwrap(); + let rt = runtime(store.clone()); + + let read = |store: &LibraryStore| -> Option { + store + .with_conn("misc", |c| { + c.query_row( + "SELECT content_hash FROM track WHERE server_id='s1' AND id='tr_1'", + [], + |r| r.get(0), + ) + }) + .unwrap() + }; + + // No-ops leave the existing value untouched: empty md5, and a row that + // doesn't exist (the absent-row case is how "index off" stays a no-op). + patch_content_hash(&rt, "s1", "tr_1", "").unwrap(); + patch_content_hash(&rt, "s1", "missing", "deadbeef").unwrap(); + assert_eq!(read(&store).as_deref(), Some("hash-tr_1")); + + patch_content_hash(&rt, "s1", "tr_1", "md5-playback").unwrap(); + assert_eq!(read(&store).as_deref(), Some("md5-playback")); + } + + #[test] + fn apply_track_patch_sets_clears_and_leaves_fields() { + // §6.5 patch-on-use: present value sets, explicit null clears, absent key + // leaves the column untouched — so `unstar` ({starredAt:null}) actually + // un-stars the local row. + let store = Arc::new(LibraryStore::open_in_memory()); + TrackRepository::new(&store) + .upsert_batch(&[make_row("s1", "tr_1", "al_1", 1)]) + .unwrap(); + let rt = runtime(store.clone()); + let read = |store: &LibraryStore| -> (Option, Option) { + store + .with_conn("misc", |c| { + c.query_row( + "SELECT starred_at, user_rating FROM track WHERE server_id='s1' AND id='tr_1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + }) + .unwrap() + }; + + apply_track_patch(&rt, "s1", "tr_1", &serde_json::json!({ "starredAt": 1700, "userRating": 4 })) + .unwrap(); + assert_eq!(read(&store), (Some(1700), Some(4))); + + // Explicit null clears starred_at; absent userRating stays. + apply_track_patch(&rt, "s1", "tr_1", &serde_json::json!({ "starredAt": null })).unwrap(); + assert_eq!(read(&store), (None, Some(4)), "null clears, absent key untouched"); + + // Empty patch is a no-op. + apply_track_patch(&rt, "s1", "tr_1", &serde_json::json!({})).unwrap(); + assert_eq!(read(&store), (None, Some(4))); + } + + #[test] + fn find_by_album_orders_by_disc_then_track_then_id() { + let store = Arc::new(LibraryStore::open_in_memory()); + TrackRepository::new(&store) + .upsert_batch(&[ + make_row("s1", "tr_b", "al_1", 2), + make_row("s1", "tr_a", "al_1", 1), + make_row("s1", "tr_c", "al_2", 1), + ]) + .unwrap(); + let album1 = TrackRepository::new(&store).find_by_album("s1", "al_1").unwrap(); + let ids: Vec<&str> = album1.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["tr_a", "tr_b"]); + } + + #[test] + fn find_batch_preserves_input_order_and_drops_unknowns() { + let store = Arc::new(LibraryStore::open_in_memory()); + TrackRepository::new(&store) + .upsert_batch(&[ + make_row("s1", "tr_1", "al_1", 1), + make_row("s1", "tr_2", "al_1", 2), + ]) + .unwrap(); + let pairs = vec![ + ("s1".to_string(), "tr_2".to_string()), + ("s1".to_string(), "tr_missing".to_string()), + ("s1".to_string(), "tr_1".to_string()), + ]; + let rows = TrackRepository::new(&store).find_batch(&pairs).unwrap(); + let ids: Vec<&str> = rows.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["tr_2", "tr_1"]); + } + + #[test] + fn batch_limit_constant_matches_spec_cap() { + assert_eq!(TRACKS_BATCH_LIMIT, 100); + } + + #[test] + fn normalize_base_url_adds_scheme_and_strips_trailing_slash() { + assert_eq!(normalize_base_url("nas.example.com"), "http://nas.example.com"); + assert_eq!(normalize_base_url("nas.example.com/"), "http://nas.example.com"); + assert_eq!(normalize_base_url("192.168.1.5:4533"), "http://192.168.1.5:4533"); + } + + #[test] + fn normalize_base_url_preserves_existing_scheme() { + assert_eq!(normalize_base_url("https://nas.example.com"), "https://nas.example.com"); + assert_eq!(normalize_base_url("https://nas.example.com/"), "https://nas.example.com"); + assert_eq!(normalize_base_url("http://localhost:4533/"), "http://localhost:4533"); + } + + #[test] + fn normalize_base_url_trims_whitespace() { + assert_eq!(normalize_base_url(" nas.example.com "), "http://nas.example.com"); + } + + #[test] + fn sync_outcome_treats_cancellation_as_silent_success() { + // Cancellation (user cancel, or a newer sync_start superseding this + // job) must not surface as a failure on the sync-idle event. + assert!(sync_outcome_to_result::<()>(Ok(())).is_ok()); + assert!(sync_outcome_to_result::<()>(Err(SyncError::Cancelled)).is_ok()); + let err = sync_outcome_to_result::<()>(Err(SyncError::Transport("boom".into()))); + assert_eq!(err, Err("sync transport: boom".to_string())); + } + + #[tokio::test(flavor = "multi_thread")] + async fn navidrome_token_with_retry_returns_token_on_success() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "token": "nd-tok", "userId": "u1" + }))) + .mount(&server) + .await; + let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await; + assert_eq!(tok.as_deref(), Some("nd-tok")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn navidrome_token_with_retry_returns_none_after_exhausting_attempts() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + // No `token` field → navidrome_token errors on every attempt; after + // the retries are exhausted the helper yields None (caller then falls + // back to a cached bearer / Subsonic-only). + Mock::given(method("POST")) + .and(path("/auth/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&server) + .await; + let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await; + assert!(tok.is_none()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/cross_server.rs b/src-tauri/crates/psysonic-library/src/cross_server.rs new file mode 100644 index 00000000..ad930073 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/cross_server.rs @@ -0,0 +1,376 @@ +//! Cross-server search (spec §5.5B / §5.9). Primary FTS union (`hits`, +//! bm25-ordered, deduped by canonical id) plus the H3 fuzzy fallback +//! (`fuzzy`): per-server `title LIKE` matches the exact FTS pass missed. +//! UI wiring stays PR-7. + +use std::collections::HashSet; + +use rusqlite::types::Value as SqlValue; + +use crate::dto::{LibraryCrossServerSearchResponse, LibraryTrackDto}; +use crate::repos; +use crate::search::{aliased_track_columns, fts_query, like_contains, PAGE_LIMIT_MAX}; +use crate::store::LibraryStore; + +/// §5.9 caps the fuzzy fallback at "top 20 per server" so a `LIKE %…%` scan +/// (no index) stays bounded. +const FUZZY_PER_SERVER_CAP: usize = 20; + +/// `library_search_cross_server` (§5.5B / §5.9 A′). Primary FTS union over +/// the requested servers (or all `ready` servers), bm25-ordered, deduped by +/// canonical id where a `track_canonical_link` row exists. +pub fn run_cross_server_search( + store: &LibraryStore, + query: &str, + limit: u32, + servers: Option<&[String]>, +) -> Result { + let limit = limit.clamp(1, PAGE_LIMIT_MAX); + let Some(fts) = fts_query(query) else { + return Ok(LibraryCrossServerSearchResponse::default()); + }; + + // Explicit `servers` is an override (caller's choice); otherwise default + // to every server whose index is `ready` (§5.9). + let targets: Vec = match servers { + Some(list) if !list.is_empty() => list.to_vec(), + _ => ready_servers(store)?, + }; + if targets.is_empty() { + return Ok(LibraryCrossServerSearchResponse::default()); + } + + let placeholders = vec!["?"; targets.len()].join(", "); + let cols = aliased_track_columns("t"); + let sql = format!( + "SELECT {cols}, l.canonical_id \ + FROM track_fts f \ + JOIN track t ON t.rowid = f.rowid \ + LEFT JOIN track_canonical_link l ON l.server_id = t.server_id AND l.track_id = t.id \ + WHERE track_fts MATCH ? AND t.deleted = 0 AND t.server_id IN ({placeholders}) \ + ORDER BY bm25(track_fts) LIMIT ?" + ); + + let mut params: Vec = Vec::with_capacity(targets.len() + 2); + params.push(SqlValue::Text(fts)); + for s in &targets { + params.push(SqlValue::Text(s.clone())); + } + params.push(SqlValue::Integer(limit as i64)); + + let canonical_idx = repos::track_columns().split(',').count(); + let rows: Vec<(LibraryTrackDto, Option)> = store.with_read_conn(|conn| { + let mut stmt = conn.prepare(&sql)?; + // Bind the collected `Result` before unwrapping so the `MappedRows` + // borrow of `stmt` ends inside the block (rusqlite borrow quirk). + let collected: rusqlite::Result)>> = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |r| { + let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?; + let canonical: Option = r.get(canonical_idx)?; + Ok((track, canonical)) + })? + .collect(); + collected + })?; + + // Dedup the exact hits by canonical id (§5.5B step 2). Rows with no + // canonical link are always kept — the link table is sparse for tracks + // lacking ISRC/MBID. + let mut seen: HashSet = HashSet::new(); + let mut hits: Vec = Vec::with_capacity(rows.len()); + for (track, canonical) in rows { + if let Some(cid) = canonical { + if !seen.insert(cid) { + continue; + } + } + hits.push(track); + } + + // H3 fuzzy fallback (§5.9): catch what the exact FTS pass missed. + let hit_keys: HashSet<(String, String)> = hits + .iter() + .map(|t| (t.server_id.clone(), t.id.clone())) + .collect(); + let fuzzy = fuzzy_matches(store, &targets, query.trim(), &mut seen, &hit_keys, limit as usize)?; + + Ok(LibraryCrossServerSearchResponse { + hits, + fuzzy, + servers_searched: targets, + }) +} + +/// §5.9 fuzzy fallback: per target server, `title LIKE %query%` for matches +/// the exact FTS pass missed (diacritics, partial words). Skips rows already +/// in `hit_keys` and dedupes by canonical id against `seen` (which holds the +/// exact hits' canonical ids). Capped per server (`FUZZY_PER_SERVER_CAP`) and +/// overall (`overall_cap`). +fn fuzzy_matches( + store: &LibraryStore, + targets: &[String], + query: &str, + seen: &mut HashSet, + hit_keys: &HashSet<(String, String)>, + overall_cap: usize, +) -> Result, String> { + let like = like_contains(query); + let cols = aliased_track_columns("t"); + let canonical_idx = repos::track_columns().split(',').count(); + let sql = format!( + "SELECT {cols}, l.canonical_id \ + FROM track t \ + LEFT JOIN track_canonical_link l ON l.server_id = t.server_id AND l.track_id = t.id \ + WHERE t.server_id = ? AND t.deleted = 0 AND t.title LIKE ? ESCAPE '\\' \ + ORDER BY t.title COLLATE NOCASE ASC LIMIT ?" + ); + + let mut out: Vec = Vec::new(); + for server in targets { + if out.len() >= overall_cap { + break; + } + let bound = [ + SqlValue::Text(server.clone()), + SqlValue::Text(like.clone()), + SqlValue::Integer(FUZZY_PER_SERVER_CAP as i64), + ]; + let rows: Vec<(LibraryTrackDto, Option)> = store.with_read_conn(|conn| { + let mut stmt = conn.prepare(&sql)?; + let collected: rusqlite::Result)>> = stmt + .query_map(rusqlite::params_from_iter(bound.iter()), |r| { + let track = + repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?; + let canonical: Option = r.get(canonical_idx)?; + Ok((track, canonical)) + })? + .collect(); + collected + })?; + + for (track, canonical) in rows { + if out.len() >= overall_cap { + break; + } + if hit_keys.contains(&(track.server_id.clone(), track.id.clone())) { + continue; // already an exact hit + } + if let Some(cid) = canonical { + if !seen.insert(cid) { + continue; // canonical already represented (hit or earlier fuzzy) + } + } + out.push(track); + } + } + Ok(out) +} + +fn ready_servers(store: &LibraryStore) -> Result, String> { + store.with_read_conn(|conn| { + let mut stmt = + conn.prepare("SELECT DISTINCT server_id FROM sync_state WHERE sync_phase = 'ready'")?; + let collected: rusqlite::Result> = + stmt.query_map([], |r| r.get(0))?.collect(); + collected + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::{TrackRepository, TrackRow}; + + fn track(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: title.into(), + title_sort: None, + artist: Some(artist.into()), + artist_id: Some(format!("ar_{artist}")), + album: album.into(), + album_id: Some(format!("al_{album}")), + album_artist: Some(artist.into()), + duration_sec: 200, + track_number: Some(1), + disc_number: Some(1), + 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: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + fn set_phase(store: &LibraryStore, server: &str, phase: &str) { + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO sync_state (server_id, library_scope, sync_phase) \ + VALUES (?1, '', ?2) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET sync_phase = excluded.sync_phase", + rusqlite::params![server, phase], + ) + }) + .unwrap(); + } + + #[test] + fn union_searches_ready_servers_only() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Aurora", "Anna", "Alb"), + track("s2", "t2", "Aurora", "Beth", "Alb"), + track("s3", "t3", "Aurora", "Cara", "Alb"), + ]) + .unwrap(); + set_phase(&store, "s1", "ready"); + set_phase(&store, "s2", "ready"); + set_phase(&store, "s3", "idle"); // not ready → excluded + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + let servers: HashSet<&str> = resp.hits.iter().map(|t| t.server_id.as_str()).collect(); + assert_eq!(servers, HashSet::from(["s1", "s2"])); + assert_eq!(resp.servers_searched.len(), 2); + } + + #[test] + fn explicit_servers_override_ready_gate() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s9", "t1", "Aurora", "Anna", "Alb")]) + .unwrap(); + // s9 is not marked ready, but an explicit servers list overrides. + let resp = run_cross_server_search(&store, "aurora", 50, Some(&["s9".to_string()])).unwrap(); + assert_eq!(resp.hits.len(), 1); + assert_eq!(resp.servers_searched, vec!["s9".to_string()]); + } + + #[test] + fn dedups_by_canonical_id() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Aurora", "Anna", "Alb"), + track("s2", "t2", "Aurora", "Anna", "Alb"), + ]) + .unwrap(); + // Both tracks link to the same canonical id → one survives. + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO canonical_track (id, created_at, updated_at) VALUES ('can1', 1, 1)", + [], + )?; + for (s, t) in [("s1", "t1"), ("s2", "t2")] { + c.execute( + "INSERT INTO track_canonical_link \ + (server_id, track_id, canonical_id, match_method, confidence, linked_at) \ + VALUES (?1, ?2, 'can1', 'isrc', 1.0, 1)", + rusqlite::params![s, t], + )?; + } + Ok(()) + }) + .unwrap(); + set_phase(&store, "s1", "ready"); + set_phase(&store, "s2", "ready"); + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + assert_eq!(resp.hits.len(), 1, "duplicate canonical id collapses to one hit"); + } + + #[test] + fn unlinked_rows_are_never_deduped() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Aurora", "Anna", "Alb"), + track("s2", "t2", "Aurora", "Beth", "Alb"), + ]) + .unwrap(); + set_phase(&store, "s1", "ready"); + set_phase(&store, "s2", "ready"); + // No canonical links → both kept even though titles match. + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + assert_eq!(resp.hits.len(), 2); + } + + #[test] + fn empty_query_returns_empty() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "Aurora", "Anna", "Alb")]) + .unwrap(); + set_phase(&store, "s1", "ready"); + let resp = run_cross_server_search(&store, " ", 50, None).unwrap(); + assert!(resp.hits.is_empty()); + assert!(resp.servers_searched.is_empty()); + } + + #[test] + fn no_ready_servers_returns_empty() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "Aurora", "Anna", "Alb")]) + .unwrap(); + // No sync_state row marked ready, and no explicit servers given. + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + assert!(resp.hits.is_empty()); + assert!(resp.servers_searched.is_empty()); + } + + // ── H3: fuzzy fallback (§5.9) ────────────────────────────────────── + + #[test] + fn fuzzy_catches_titles_the_exact_pass_missed() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + // exact FTS term hit + track("s1", "t1", "Aurora", "Anna", "Alb"), + // FTS tokenizes to "auroras"/"borealis" → misses term "aurora"; + // LIKE %aurora% still catches it. + track("s2", "t2", "Auroras Borealis", "Beth", "Alb"), + ]) + .unwrap(); + set_phase(&store, "s1", "ready"); + set_phase(&store, "s2", "ready"); + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + assert_eq!(resp.hits.len(), 1, "exact FTS hit"); + assert_eq!(resp.hits[0].id, "t1"); + assert_eq!(resp.fuzzy.len(), 1, "fuzzy catches the FTS miss"); + assert_eq!(resp.fuzzy[0].id, "t2"); + } + + #[test] + fn fuzzy_excludes_exact_hits() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "Aurora", "Anna", "Alb")]) + .unwrap(); + set_phase(&store, "s1", "ready"); + // "Aurora" is both an FTS hit and a LIKE match — must appear only once. + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + assert_eq!(resp.hits.len(), 1); + assert!(resp.fuzzy.is_empty(), "exact hits are not repeated in fuzzy"); + } +} diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs new file mode 100644 index 00000000..377672bc --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -0,0 +1,690 @@ +//! Public DTOs the Tauri command surface returns. camelCase wire shape +//! per `src-tauri/CLAUDE.md`. PR-5a only defines what the read-only +//! commands need; PR-5b adds sync-progress / cancel-ack shapes. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::filter::{EntityKind, FilterOp}; +use crate::repos::TrackRow; +use crate::store::LibraryStore; + +/// `library_get_status` payload — mirrors the `sync_state` row plus a +/// few derived counters from `track`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SyncStateDto { + pub server_id: String, + pub library_scope: String, + #[serde(default)] + pub sync_phase: String, + #[serde(default)] + pub capability_flags: u32, + #[serde(default)] + pub library_tier: String, + pub last_full_sync_at: Option, + pub last_delta_sync_at: Option, + pub next_poll_at: Option, + pub server_last_scan_iso: Option, + pub indexes_last_modified_ms: Option, + pub artists_last_modified_ms: Option, + pub local_track_count: Option, + pub server_track_count: Option, + pub last_error: Option, + /// `MAX(server_updated_at)` over local non-deleted tracks — the + /// implicit "tracks watermark" the N1-delta uses. + pub local_tracks_max_updated_ms: Option, + /// Cheap `EXISTS` over `track` — avoids a full `COUNT(*)` on every status read. + #[serde(default)] + pub has_local_tracks: bool, + /// Active/resumed initial-sync ingest strategy (`n1` / `s1` / `s2`), if any. + #[serde(default)] + pub ingest_strategy: Option, + /// Cursor phase during initial sync (`ingest`, `artist_pass`, …). + #[serde(default)] + pub ingest_phase: Option, + /// Tracks ingested so far per persisted cursor (informational during IS-3). + #[serde(default)] + pub cursor_ingested_count: Option, + /// Server flagged after N1 deep-offset failure — prefers S1/S2 on next run. + #[serde(default)] + pub n1_bulk_unreliable: Option, +} + +/// E3 readiness summary attached to a single-track `library_get_track` read. +/// Per-server, read-only, best-effort — never blocks on the network. Omitted +/// from list/batch reads (would be one analysis probe per row). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct TrackEnrichmentDto { + pub waveform_ready: bool, + pub loudness_ready: bool, + pub lyrics_cached: bool, +} + +/// `library_get_track` / `library_search` row shape — flat projection +/// over `track`'s hot columns plus the raw JSON sub-tree. Frontend +/// re-assembles its own `LibraryTrack` shape from this. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryTrackDto { + // Ref + pub server_id: String, + pub id: String, + pub content_hash: Option, + + // Hot columns + pub title: String, + pub title_sort: Option, + pub artist: Option, + pub artist_id: Option, + pub album: String, + pub album_id: Option, + pub album_artist: Option, + pub duration_sec: i64, + pub track_number: Option, + pub disc_number: Option, + pub year: Option, + pub genre: Option, + pub suffix: Option, + pub bit_rate: Option, + pub size_bytes: Option, + pub cover_art_id: Option, + pub starred_at: Option, + pub user_rating: Option, + pub play_count: Option, + pub played_at: Option, + pub server_path: Option, + pub library_id: Option, + pub isrc: Option, + pub mbid_recording: Option, + pub bpm: Option, + pub replay_gain_track_db: Option, + pub replay_gain_album_db: Option, + + pub server_updated_at: Option, + pub server_created_at: Option, + pub synced_at: i64, + + /// E3 readiness summary. Only populated by `library_get_track`; `None` + /// (omitted on the wire) for list/batch projections via `from_row`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enrichment: Option, + + /// Original Subsonic / Navidrome song JSON the sync engine stored. + /// Frontend parses this lazily when it needs OpenSubsonic extras + /// (contributors, replayGain detail, …). + pub raw_json: Value, +} + +impl LibraryTrackDto { + pub fn from_row(row: &TrackRow) -> Self { + let raw_json: Value = serde_json::from_str(&row.raw_json).unwrap_or(Value::Null); + Self { + server_id: row.server_id.clone(), + id: row.id.clone(), + content_hash: row.content_hash.clone(), + title: row.title.clone(), + title_sort: row.title_sort.clone(), + artist: row.artist.clone(), + artist_id: row.artist_id.clone(), + album: row.album.clone(), + album_id: row.album_id.clone(), + album_artist: row.album_artist.clone(), + duration_sec: row.duration_sec, + track_number: row.track_number, + disc_number: row.disc_number, + year: row.year, + genre: row.genre.clone(), + suffix: row.suffix.clone(), + bit_rate: row.bit_rate, + size_bytes: row.size_bytes, + cover_art_id: row.cover_art_id.clone(), + starred_at: row.starred_at, + user_rating: row.user_rating, + play_count: row.play_count, + played_at: row.played_at, + server_path: row.server_path.clone(), + library_id: row.library_id.clone(), + isrc: row.isrc.clone(), + mbid_recording: row.mbid_recording.clone(), + bpm: row.bpm, + replay_gain_track_db: row.replay_gain_track_db, + replay_gain_album_db: row.replay_gain_album_db, + server_updated_at: row.server_updated_at, + server_created_at: row.server_created_at, + synced_at: row.synced_at, + enrichment: None, + raw_json, + } + } +} + +/// `library_get_tracks_batch` / `library_search` envelope. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryTracksEnvelope { + pub tracks: Vec, + pub total: u32, +} + +/// `library_get_artifact` payload — one row of `track_artifact`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TrackArtifactDto { + pub server_id: String, + pub track_id: String, + pub artifact_kind: String, + pub format: String, + pub source_kind: String, + pub source_id: String, + pub language: Option, + pub content_text: Option, + pub content_bytes: i64, + pub not_found: bool, + pub content_hash: Option, + pub fetched_at: i64, + pub expires_at: Option, +} + +/// `library_get_facts` row. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TrackFactDto { + pub server_id: String, + pub track_id: String, + pub fact_kind: String, + pub value_real: Option, + pub value_int: Option, + pub value_text: Option, + pub unit: Option, + pub source_kind: String, + pub source_id: String, + pub confidence: f64, + pub content_hash: Option, + pub fetched_at: i64, + pub expires_at: Option, +} + +/// `library_get_offline_path` outcome — either a path string or a +/// `missing` flag so the frontend can show a hint without polling. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct OfflinePathDto { + pub server_id: String, + pub track_id: String, + pub local_path: Option, + pub missing: bool, +} + +/// Compact track reference used as input by `library_get_tracks_batch`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct TrackRefDto { + pub server_id: String, + pub track_id: String, + #[serde(default)] + pub content_hash: Option, +} + +/// Input to `library_put_artifact`. Same shape as `TrackArtifactDto` +/// minus the server-supplied `server_id` / `track_id` (provided as +/// command args) and `fetched_at` (stamped server-side from `now`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactInputDto { + pub artifact_kind: String, + pub format: String, + pub source_kind: String, + pub source_id: String, + #[serde(default)] + pub language: Option, + #[serde(default)] + pub content_text: Option, + #[serde(default)] + pub content_blob: Option>, + #[serde(default)] + pub content_bytes: i64, + #[serde(default)] + pub not_found: bool, + #[serde(default)] + pub content_hash: Option, + #[serde(default)] + pub expires_at: Option, +} + +/// Input to `library_put_fact`. Shape matches `TrackFactDto` minus the +/// indices. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FactInputDto { + pub fact_kind: String, + #[serde(default)] + pub value_real: Option, + #[serde(default)] + pub value_int: Option, + #[serde(default)] + pub value_text: Option, + #[serde(default)] + pub unit: Option, + pub source_kind: String, + pub source_id: String, + #[serde(default = "default_confidence")] + pub confidence: f64, + #[serde(default)] + pub content_hash: Option, + #[serde(default)] + pub expires_at: Option, +} + +fn default_confidence() -> f64 { + 1.0 +} + +/// `library_purge_server` outcome. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PurgeReportDto { + pub tracks_deleted: u32, + pub albums_deleted: u32, + pub artists_deleted: u32, + pub offline_rows_deleted: u32, + /// Total bytes freed across the purged scopes (best-effort). + pub bytes_freed: i64, +} + +/// `library_sync_start` ack. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SyncJobDto { + pub job_id: String, + pub server_id: String, + /// `"initial_sync"` or `"delta_sync"`. + pub kind: String, +} + +// ────────────────────────────────────────────────────────────────────── +// PR-5d — Advanced Search (§5.13) + cross-server search (§5.5B) +// ────────────────────────────────────────────────────────────────────── + +/// `library_advanced_search` row shape for an album. Flat projection over +/// the `album` hot columns plus the raw JSON sub-tree (mirrors +/// `LibraryTrackDto`'s lazy-parse contract). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryAlbumDto { + pub server_id: String, + pub id: String, + pub name: String, + pub artist: Option, + pub artist_id: Option, + pub song_count: Option, + pub duration_sec: Option, + pub year: Option, + pub genre: Option, + pub cover_art_id: Option, + pub starred_at: Option, + pub synced_at: i64, + pub raw_json: Value, +} + +/// `library_advanced_search` row shape for an artist. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryArtistDto { + pub server_id: String, + pub id: String, + pub name: String, + pub album_count: Option, + pub synced_at: i64, + pub raw_json: Value, +} + +/// One filter predicate. `field` is a `FilterFieldRegistry` id (§5.13.3), +/// `op` the comparison, `value` / `valueTo` the operands. `between` uses +/// both bounds (inclusive); scalar ops use `value` only; `isTrue` ignores +/// the value side. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryFilterClause { + pub field: String, + pub op: FilterOp, + #[serde(default)] + pub value: Option, + #[serde(default)] + pub value_to: Option, +} + +/// One sort key. `field` is a registry id; `dir` is `asc` / `desc`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibrarySortClause { + pub field: String, + pub dir: SortDir, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SortDir { + Asc, + Desc, +} + +/// `library_advanced_search` request (§5.13.2). `query` is shorthand for an +/// `fts` clause on the text fields; `entityTypes` controls which of the +/// three queries run; `filters` are combined with AND. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryAdvancedSearchRequest { + pub server_id: String, + #[serde(default)] + pub library_scope: Option, + #[serde(default)] + pub query: Option, + pub entity_types: Vec, + #[serde(default)] + pub filters: Vec, + #[serde(default)] + pub starred_only: Option, + #[serde(default)] + pub sort: Vec, + pub limit: u32, + #[serde(default)] + pub offset: u32, + /// When true, skip per-entity COUNT queries (Live Search / small pages). + #[serde(default)] + pub skip_totals: bool, +} + +/// Per-entity result counts (full match count, not page size). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LibrarySearchTotals { + pub artists: u32, + pub albums: u32, + pub tracks: u32, +} + +/// `library_advanced_search` response (§5.13.2). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryAdvancedSearchResponse { + pub artists: Vec, + pub albums: Vec, + pub tracks: Vec, + pub totals: LibrarySearchTotals, + /// Distinct registry field ids that were actually applied — UI chips / + /// debug. Includes `starred` when `starredOnly` is set. + pub applied_filters: Vec, + /// Always `"local"` from this command (it queries the local index); the + /// frontend's fallback decides local vs network (§5.13.6). + pub source: String, +} + +/// `library_live_search` response — lean FTS dropdown (§5.9 / P24). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryLiveSearchRequest { + pub server_id: String, + pub query: String, + #[serde(default)] + pub library_scope: Option, + #[serde(default)] + pub artist_limit: Option, + #[serde(default)] + pub album_limit: Option, + #[serde(default)] + pub song_limit: Option, + #[serde(default)] + pub request_epoch: Option, +} + +/// `library_live_search` response — lean FTS dropdown (§5.9 / P24). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryLiveSearchResponse { + pub artists: Vec, + pub albums: Vec, + pub tracks: Vec, + pub source: String, +} + +/// `library_search_cross_server` response (§5.5B / §5.9). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryCrossServerSearchResponse { + /// Primary FTS-union hits, deduped by canonical id where a link exists. + pub hits: Vec, + /// Fuzzy fallback (§5.9 / H3): per-server `title LIKE` matches that the + /// exact FTS pass missed (diacritics, partial words). Excludes anything + /// already in `hits` and dedupes by canonical id against them. + pub fuzzy: Vec, + /// The server ids that were actually searched (resolved from the + /// request's `servers` or all `ready` servers). + pub servers_searched: Vec, +} + +/// Read `MAX(server_updated_at)` for non-deleted tracks on this server +/// — used by `SyncStateDto` so callers can show "tracks watermark" in +/// Settings without a separate column. +pub fn local_tracks_max_updated_ms( + store: &LibraryStore, + server_id: &str, +) -> Result, String> { + store + .with_read_conn(|c| { + c.query_row( + "SELECT MAX(server_updated_at) FROM track \ + WHERE server_id = ?1 AND deleted = 0", + rusqlite::params![server_id], + |row| row.get::<_, Option>(0), + ) + }) + .map_err(|e| e.to_string()) +} + +/// Cheap `EXISTS` — true when at least one non-deleted track is indexed. +pub fn track_index_nonempty(store: &LibraryStore, server_id: &str) -> Result { + store + .with_read_conn(|c| { + c.query_row( + "SELECT EXISTS(SELECT 1 FROM track WHERE server_id = ?1 AND deleted = 0 LIMIT 1)", + rusqlite::params![server_id], + |row| row.get(0), + ) + }) + .map_err(|e| e.to_string()) +} + +/// Live non-deleted track count for a server (used when the sync_state +/// snapshot is missing or stale). +pub fn count_local_tracks(store: &LibraryStore, server_id: &str) -> Result { + store + .with_read_conn(|c| { + c.query_row( + "SELECT COUNT(*) FROM track WHERE server_id = ?1 AND deleted = 0", + rusqlite::params![server_id], + |row| row.get(0), + ) + }) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::TrackRepository; + + fn sample_row() -> TrackRow { + TrackRow { + server_id: "s1".into(), + id: "tr_1".into(), + title: "Hello".into(), + title_sort: None, + artist: Some("World".into()), + artist_id: Some("ar_1".into()), + album: "An Album".into(), + album_id: Some("al_1".into()), + album_artist: Some("World".into()), + duration_sec: 240, + track_number: Some(3), + disc_number: Some(1), + year: Some(2024), + genre: Some("Ambient".into()), + suffix: Some("flac".into()), + bit_rate: Some(1000), + size_bytes: Some(32_000_000), + cover_art_id: Some("cv_1".into()), + starred_at: None, + user_rating: None, + play_count: Some(0), + played_at: None, + server_path: Some("/path/x.flac".into()), + library_id: Some("1".into()), + isrc: Some("USRC17607839".into()), + mbid_recording: Some("mb-1".into()), + bpm: Some(120), + replay_gain_track_db: Some(-1.2), + replay_gain_album_db: Some(-0.8), + content_hash: Some("deadbeef".into()), + server_updated_at: Some(1_700_000_000), + server_created_at: Some(1_699_000_000), + deleted: false, + synced_at: 1_700_000_500, + raw_json: r#"{"replayGain":{"trackGain":-1.2}}"#.into(), + } + } + + #[test] + fn library_track_dto_serializes_field_names_camel_case() { + let dto = LibraryTrackDto::from_row(&sample_row()); + let json = serde_json::to_value(&dto).unwrap(); + // Spot-check critical wire keys — IPC contract. + for key in [ + "serverId", + "contentHash", + "albumArtist", + "durationSec", + "trackNumber", + "discNumber", + "coverArtId", + "userRating", + "playCount", + "playedAt", + "serverPath", + "libraryId", + "mbidRecording", + "replayGainTrackDb", + "replayGainAlbumDb", + "serverUpdatedAt", + "syncedAt", + "rawJson", + ] { + assert!( + json.get(key).is_some(), + "expected camelCase key `{key}` in serialized DTO, got {json}" + ); + } + } + + #[test] + fn library_track_dto_parses_raw_json_into_value() { + let dto = LibraryTrackDto::from_row(&sample_row()); + let rg = dto + .raw_json + .get("replayGain") + .and_then(|v| v.get("trackGain")) + .and_then(|v| v.as_f64()) + .unwrap(); + assert!((rg - -1.2).abs() < 0.001); + } + + #[test] + fn library_track_dto_falls_back_to_null_on_invalid_raw_json() { + let mut row = sample_row(); + row.raw_json = "{not valid json}".into(); + let dto = LibraryTrackDto::from_row(&row); + assert!(dto.raw_json.is_null(), "invalid JSON must surface as Value::Null"); + } + + #[test] + fn local_tracks_max_updated_returns_max_over_non_deleted_rows() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + let mut r1 = sample_row(); + r1.server_updated_at = Some(1_000); + let mut r2 = sample_row(); + r2.id = "tr_2".into(); + r2.server_updated_at = Some(3_000); + let mut r3 = sample_row(); + r3.id = "tr_3".into(); + r3.server_updated_at = Some(5_000); + r3.deleted = true; + repo.upsert_batch(&[r1, r2, r3]).unwrap(); + + assert_eq!( + local_tracks_max_updated_ms(&store, "s1").unwrap(), + Some(3_000), + "deleted rows must be excluded" + ); + } + + #[test] + fn count_local_tracks_matches_non_deleted_rows() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + let mut deleted = sample_row(); + deleted.id = "tr_del".into(); + deleted.deleted = true; + repo.upsert_batch(&[sample_row(), deleted]).unwrap(); + assert_eq!(count_local_tracks(&store, "s1").unwrap(), 1); + } + + #[test] + fn track_ref_dto_roundtrips_through_json() { + let r = TrackRefDto { + server_id: "s1".into(), + track_id: "tr_1".into(), + content_hash: Some("h".into()), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json.get("serverId").and_then(|v| v.as_str()), Some("s1")); + let back: TrackRefDto = serde_json::from_value(json).unwrap(); + assert_eq!(back, r); + } + + #[test] + fn sync_state_dto_omits_null_optionals_cleanly() { + let dto = SyncStateDto { + server_id: "s1".into(), + library_scope: "".into(), + sync_phase: "idle".into(), + capability_flags: 0, + library_tier: "unknown".into(), + last_full_sync_at: None, + last_delta_sync_at: None, + next_poll_at: None, + server_last_scan_iso: None, + indexes_last_modified_ms: None, + artists_last_modified_ms: None, + local_track_count: None, + server_track_count: None, + last_error: None, + local_tracks_max_updated_ms: None, + has_local_tracks: false, + ingest_strategy: None, + ingest_phase: None, + cursor_ingested_count: None, + n1_bulk_unreliable: None, + }; + let json = serde_json::to_value(dto).unwrap(); + assert_eq!( + json.get("syncPhase").and_then(|v| v.as_str()), + Some("idle") + ); + // `null` survives as JSON null, not omitted — explicit shape + // for the WebView so it can distinguish "missing" from + // "unset". + assert!(json.get("lastFullSyncAt").unwrap().is_null()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/filter.rs b/src-tauri/crates/psysonic-library/src/filter.rs new file mode 100644 index 00000000..8254c4ca --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/filter.rs @@ -0,0 +1,415 @@ +//! `FilterFieldRegistry` — Rust source of truth for Advanced Search filter +//! fields (spec §5.13.3 / P38). The full SQL builder (`AdvancedSearchQuery`, +//! §5.13.5) and the Tauri command surface (§5.13.6) come later; PR-1a +//! ships the registry shape + the v1 fields + the entity-routing rule. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EntityKind { + Track, + Album, + Artist, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FilterOp { + /// FTS5 MATCH (`track_fts`). Only valid on the `text` field in v1. + Fts, + Eq, + /// Membership test against a list of values. (Not yet in v1; reserved.) + In, + Gte, + Lte, + Between, + /// Boolean field — value side is ignored, presence = `IS NOT NULL`. + IsTrue, +} + +impl FilterOp { + /// Wire spelling — matches the TypeScript `FilterOperator` union and the + /// `FilterOp::from_wire` parser. Used in error messages too. + pub fn as_str(self) -> &'static str { + match self { + FilterOp::Fts => "fts", + FilterOp::Eq => "eq", + FilterOp::In => "in", + FilterOp::Gte => "gte", + FilterOp::Lte => "lte", + FilterOp::Between => "between", + FilterOp::IsTrue => "is_true", + } + } + + /// Parse the wire operator. Spec §5.13.2 lists a few operators + /// (`neq` / `contains` / `is_false`) the v1 builder doesn't implement + /// yet — those return `None` so the caller can raise a clear error + /// rather than silently dropping the clause. + pub fn from_wire(wire: &str) -> Option { + match wire { + "fts" => Some(FilterOp::Fts), + "eq" => Some(FilterOp::Eq), + "in" => Some(FilterOp::In), + "gte" => Some(FilterOp::Gte), + "lte" => Some(FilterOp::Lte), + "between" => Some(FilterOp::Between), + "is_true" => Some(FilterOp::IsTrue), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FilterStatus { + /// v1: built into the SQL builder and exercised by parity tests. + V1, + /// Schema + SQL builder present (the request and registry accept it), + /// but hidden from the v1 UI — see §5.13.4 (`bpm` dual-storage). + SchemaV1UiLater, + /// Reserved column / planned-but-not-built. + Planned, + /// Out of scope for v1 entirely. + Future, +} + +#[derive(Debug, Clone, Copy)] +pub struct FilterField { + pub id: &'static str, + pub entities: &'static [EntityKind], + pub ops: &'static [FilterOp], + pub status: FilterStatus, +} + +/// Static v1 registry. Adding a row here is the only thing required to expose +/// a new filter field (plus, when the storage isn't yet a hot column / index, +/// a separate `00X_*.sql` migration — see §5.7). No new invoke is needed. +pub const FILTER_FIELD_REGISTRY: &[FilterField] = &[ + FilterField { + id: "text", + entities: &[EntityKind::Track, EntityKind::Album, EntityKind::Artist], + ops: &[FilterOp::Fts], + status: FilterStatus::V1, + }, + FilterField { + id: "genre", + entities: &[EntityKind::Track, EntityKind::Album], + ops: &[FilterOp::Eq], + status: FilterStatus::V1, + }, + FilterField { + id: "year", + entities: &[EntityKind::Track, EntityKind::Album], + ops: &[FilterOp::Gte, FilterOp::Lte, FilterOp::Between], + status: FilterStatus::V1, + }, + FilterField { + id: "starred", + entities: &[EntityKind::Track, EntityKind::Album, EntityKind::Artist], + ops: &[FilterOp::IsTrue], + status: FilterStatus::V1, + }, + FilterField { + id: "user_rating", + entities: &[EntityKind::Track], + ops: &[FilterOp::Gte, FilterOp::Eq], + status: FilterStatus::Planned, + }, + FilterField { + id: "suffix", + entities: &[EntityKind::Track], + ops: &[FilterOp::Eq, FilterOp::In], + status: FilterStatus::Planned, + }, + FilterField { + id: "bit_rate", + entities: &[EntityKind::Track], + ops: &[FilterOp::Gte, FilterOp::Lte, FilterOp::Between], + status: FilterStatus::Planned, + }, + FilterField { + id: "bpm", + entities: &[EntityKind::Track], + ops: &[FilterOp::Gte, FilterOp::Lte, FilterOp::Between], + status: FilterStatus::SchemaV1UiLater, + }, +]; + +pub fn lookup(id: &str) -> Option<&'static FilterField> { + FILTER_FIELD_REGISTRY.iter().find(|f| f.id == id) +} + +/// `true` when this filter field is applicable for a request that targets +/// the given entity. Per §5.13.3 the routing rule is a *skip*, not an error: +/// if the request asks for `entityTypes = [album, artist]` and a clause names +/// a track-only field, the clause is silently dropped. +pub fn applies_to(field: &FilterField, entity: EntityKind) -> bool { + field.entities.contains(&entity) +} + +// ── SQL fragment resolution (§5.13.5) ───────────────────────────────── + +/// A resolved WHERE-clause snippet plus the values it binds. The builder +/// appends fragments in order and binds their params left-to-right against +/// anonymous `?` placeholders (`params_from_iter`), so a fragment must keep +/// its `sql` placeholders and `params` in the same order. +/// +/// `sql` only ever contains builder-supplied column expressions and literal +/// operators — never user input (spec §5.13.5: parameterised only). +#[derive(Debug, Clone, PartialEq)] +pub struct SqlFragment { + pub sql: String, + pub params: Vec, +} + +/// Why a `LibraryFilterClause` could not be turned into SQL. Surfaced to the +/// command as a human-readable string; `UnknownField` carries the known-field +/// list for dev builds (§5.13.5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FilterError { + /// `field` is not in the registry at all (typo / wrong client). + UnknownField(String), + /// `field` is registered but has no v1 SQL builder yet (`user_rating`, + /// `suffix`, `bit_rate`, …). Distinct from `UnknownField` so the caller + /// can tell a typo from a planned-but-unbuilt field. + NotQueryable(String), + /// `op` is not declared for `field` in the registry. + UnsupportedOp { field: String, op: &'static str }, + /// The value side was missing or the wrong type for the operator. + BadValue { field: String, detail: String }, +} + +impl std::fmt::Display for FilterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FilterError::UnknownField(field) => { + let known = FILTER_FIELD_REGISTRY + .iter() + .map(|x| x.id) + .collect::>() + .join(", "); + write!(f, "unknown filter field `{field}` (known: {known})") + } + FilterError::NotQueryable(field) => { + write!(f, "filter field `{field}` is registered but not queryable in v1") + } + FilterError::UnsupportedOp { field, op } => { + write!(f, "operator `{op}` is not supported for filter field `{field}`") + } + FilterError::BadValue { field, detail } => { + write!(f, "bad value for filter field `{field}`: {detail}") + } + } + } +} + +/// Validate that `field` exists, applies to `entity`, and declares `op`. +/// +/// Returns `Ok(true)` when the clause is applicable, `Ok(false)` when the +/// field is known but doesn't route to this entity (§5.13.3: skip, don't +/// error), and `Err` when the field is unknown or the op is undeclared. +pub fn validate_for_entity( + field_id: &str, + op: FilterOp, + entity: EntityKind, +) -> Result { + let field = lookup(field_id).ok_or_else(|| FilterError::UnknownField(field_id.to_string()))?; + if !field.ops.contains(&op) { + return Err(FilterError::UnsupportedOp { + field: field_id.to_string(), + op: op.as_str(), + }); + } + Ok(applies_to(field, entity)) +} + +/// Build a comparison fragment for a builder-supplied column expression. +/// `col` is trusted SQL (never user input); only the bound values come from +/// the request. `eq`/`gte`/`lte`/`between` parameterise their operands; +/// `is_true` ignores the value and tests `IS NOT NULL`. `between` is +/// inclusive on both ends (matches the year UI — §5.13.5). +pub fn compare_fragment( + field: &str, + col: &str, + op: FilterOp, + value: Option, + value_to: Option, +) -> Result { + let need_value = |v: Option| { + v.ok_or_else(|| FilterError::BadValue { + field: field.to_string(), + detail: format!("operator `{}` requires a value", op.as_str()), + }) + }; + match op { + FilterOp::Eq => Ok(SqlFragment { + sql: format!("{col} = ?"), + params: vec![need_value(value)?], + }), + FilterOp::Gte => Ok(SqlFragment { + sql: format!("{col} >= ?"), + params: vec![need_value(value)?], + }), + FilterOp::Lte => Ok(SqlFragment { + sql: format!("{col} <= ?"), + params: vec![need_value(value)?], + }), + FilterOp::Between => { + let lo = need_value(value)?; + let hi = value_to.ok_or_else(|| FilterError::BadValue { + field: field.to_string(), + detail: "operator `between` requires `valueTo`".to_string(), + })?; + Ok(SqlFragment { + sql: format!("{col} BETWEEN ? AND ?"), + params: vec![lo, hi], + }) + } + FilterOp::IsTrue => Ok(SqlFragment { + sql: format!("{col} IS NOT NULL"), + params: vec![], + }), + FilterOp::Fts | FilterOp::In => Err(FilterError::UnsupportedOp { + field: field.to_string(), + op: op.as_str(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_contains_all_v1_fields() { + for id in ["text", "genre", "year", "starred"] { + let f = lookup(id).unwrap_or_else(|| panic!("missing v1 field `{id}`")); + assert_eq!(f.status, FilterStatus::V1, "`{id}` must be V1"); + } + } + + #[test] + fn bpm_is_schema_v1_but_ui_later() { + // §5.13.3: bpm has a hot column + index from day one, but is hidden + // from the v1 UI until §5.13.4 dual-storage resolution lands. + assert_eq!(lookup("bpm").unwrap().status, FilterStatus::SchemaV1UiLater); + } + + #[test] + fn text_routes_to_all_three_entities() { + let f = lookup("text").unwrap(); + assert!(applies_to(f, EntityKind::Track)); + assert!(applies_to(f, EntityKind::Album)); + assert!(applies_to(f, EntityKind::Artist)); + } + + #[test] + fn track_only_field_is_skipped_for_album_entity() { + let f = lookup("user_rating").unwrap(); + assert!(applies_to(f, EntityKind::Track)); + assert!(!applies_to(f, EntityKind::Album)); + assert!(!applies_to(f, EntityKind::Artist)); + } + + #[test] + fn unknown_field_lookup_returns_none() { + assert!(lookup("nope").is_none()); + } + + #[test] + fn registry_has_no_duplicate_ids() { + let mut ids: Vec<&str> = FILTER_FIELD_REGISTRY.iter().map(|f| f.id).collect(); + ids.sort(); + let len_before = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), len_before, "duplicate filter field id detected"); + } + + // ── SQL fragment resolution ─────────────────────────────────────── + + #[test] + fn op_wire_roundtrips() { + for op in [ + FilterOp::Fts, + FilterOp::Eq, + FilterOp::In, + FilterOp::Gte, + FilterOp::Lte, + FilterOp::Between, + FilterOp::IsTrue, + ] { + assert_eq!(FilterOp::from_wire(op.as_str()), Some(op)); + } + } + + #[test] + fn op_from_wire_rejects_unbuilt_operators() { + // Spec §5.13.2 lists these but the v1 builder doesn't implement them. + for wire in ["neq", "contains", "is_false", "nope"] { + assert!(FilterOp::from_wire(wire).is_none(), "`{wire}` must not parse"); + } + } + + #[test] + fn validate_unknown_field_errors() { + let err = validate_for_entity("nope", FilterOp::Eq, EntityKind::Track).unwrap_err(); + assert!(matches!(err, FilterError::UnknownField(f) if f == "nope")); + } + + #[test] + fn validate_undeclared_op_errors() { + // `genre` only declares `eq` in v1. + let err = validate_for_entity("genre", FilterOp::Gte, EntityKind::Track).unwrap_err(); + assert!(matches!(err, FilterError::UnsupportedOp { .. })); + } + + #[test] + fn validate_known_field_off_entity_is_skip_not_error() { + // `bpm` is track-only — for an album query it routes to "skip". + assert_eq!( + validate_for_entity("bpm", FilterOp::Between, EntityKind::Album), + Ok(false) + ); + assert_eq!( + validate_for_entity("bpm", FilterOp::Between, EntityKind::Track), + Ok(true) + ); + } + + #[test] + fn compare_fragment_between_is_inclusive_both_ends() { + use rusqlite::types::Value; + let frag = compare_fragment( + "year", + "t.year", + FilterOp::Between, + Some(Value::Integer(2000)), + Some(Value::Integer(2010)), + ) + .unwrap(); + assert_eq!(frag.sql, "t.year BETWEEN ? AND ?"); + assert_eq!(frag.params, vec![Value::Integer(2000), Value::Integer(2010)]); + } + + #[test] + fn compare_fragment_between_without_value_to_errors() { + use rusqlite::types::Value; + let err = compare_fragment( + "year", + "t.year", + FilterOp::Between, + Some(Value::Integer(2000)), + None, + ) + .unwrap_err(); + assert!(matches!(err, FilterError::BadValue { .. })); + } + + #[test] + fn compare_fragment_is_true_ignores_value() { + let frag = compare_fragment("starred", "t.starred_at", FilterOp::IsTrue, None, None).unwrap(); + assert_eq!(frag.sql, "t.starred_at IS NOT NULL"); + assert!(frag.params.is_empty()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs new file mode 100644 index 00000000..abbdb5d6 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -0,0 +1,32 @@ +//! `psysonic-library` — unified track store and (future) sync engine. +//! +//! v1 scope (this crate, across PR-1..PR-7): +//! - `store` — SQLite connection, WAL config, versioned migration runner +//! - `repos` — typed repositories over the v1 schema (track, album, artist, …) +//! - `search` — FTS5 query helpers +//! - `filter` — `FilterFieldRegistry` (Rust source of truth for Advanced Search) +//! - `sync` — capability probe + orchestrator (PR-3*) + +pub(crate) mod bulk_ingest; +pub mod advanced_search; +pub mod canonical; +pub mod commands; +pub mod cross_server; +pub mod dto; +pub mod filter; +pub mod live_search; +pub mod payload; +pub mod repos; +pub mod runtime; +pub mod search; +pub mod store; +pub mod sync; +pub(crate) mod track_fts; + +pub use payload::LibrarySyncProgressPayload; +pub use runtime::LibraryRuntime; + +pub use store::{LibraryStore, LIBRARY_DB_SCHEMA_VERSION}; + +// Re-export logging facade so submodules can write `crate::app_eprintln!()`. +pub use psysonic_core::{app_deprintln, app_eprintln, logging}; diff --git a/src-tauri/crates/psysonic-library/src/live_search.rs b/src-tauri/crates/psysonic-library/src/live_search.rs new file mode 100644 index 00000000..aa2e5e38 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/live_search.rs @@ -0,0 +1,660 @@ +//! Live Search dropdown (spec §5.9 / P24) — column-scoped FTS with LIMIT inside +//! the FTS subquery (bm25 on ≤N rowids), then a cheap join to `track`. +//! Avoids the SQLite pitfall where `JOIN track … ORDER BY bm25` on an OR +//! MATCH scans/ranks the whole hit set on 100k+ libraries (10–20s queries). + +use std::collections::{HashMap, HashSet}; + +use rusqlite::params; + +use crate::dto::{LibraryAlbumDto, LibraryArtistDto, LibraryLiveSearchResponse, LibraryTrackDto}; +use crate::search::{fts_column_prefix_query, fts_query_meets_min_len, library_scope_equals_sql}; +use crate::store::LibraryStore; + +const SONG_FTS_COLUMNS: [&str; 4] = ["title", "artist", "album", "album_artist"]; +const ALBUM_FTS_COLUMNS: [&str; 2] = ["album", "album_artist"]; + +struct LiveHit { + track: LibraryTrackDto, +} + +/// `library_live_search` — read connection, scoped FTS rowid picks + join. +pub fn run_live_search( + store: &LibraryStore, + server_id: &str, + query: &str, + library_scope: Option<&str>, + artist_limit: u32, + album_limit: u32, + song_limit: u32, +) -> Result { + if !fts_query_meets_min_len(query) { + return Ok(LibraryLiveSearchResponse { + artists: Vec::new(), + albums: Vec::new(), + tracks: Vec::new(), + source: "local".to_string(), + }); + } + + store.with_read_conn(|conn| { + let scope = trimmed_scope(library_scope); + let songs = query_songs(conn, query, server_id, scope.as_deref(), song_limit)?; + let artists = query_artists(conn, query, server_id, scope.as_deref(), artist_limit)?; + let albums = query_albums(conn, query, server_id, scope.as_deref(), album_limit)?; + Ok(LibraryLiveSearchResponse { + artists, + albums, + tracks: songs, + source: "local".to_string(), + }) + }) +} + +/// Top FTS rowids for one or more column-scoped MATCH strings (deduped, ordered). +fn collect_fts_rowids( + conn: &rusqlite::Connection, + match_queries: &[String], + per_query_limit: i64, + total_limit: usize, +) -> rusqlite::Result> { + let sql = + "SELECT rowid FROM track_fts WHERE track_fts MATCH ?1 ORDER BY bm25(track_fts) LIMIT ?2"; + let mut stmt = conn.prepare(sql)?; + let mut seen = HashSet::new(); + let mut rowids = Vec::new(); + for mq in match_queries { + let rows = stmt.query_map(params![mq, per_query_limit], |r| r.get(0))?; + for rowid in rows { + let rowid = rowid?; + if seen.insert(rowid) { + rowids.push(rowid); + if rowids.len() >= total_limit { + return Ok(rowids); + } + } + } + } + Ok(rowids) +} + +fn column_matches(query: &str, columns: &[&str]) -> Result, String> { + columns + .iter() + .map(|col| { + fts_column_prefix_query(col, query).ok_or_else(|| "empty query".to_string()) + }) + .collect() +} + +fn trimmed_scope(scope: Option<&str>) -> Option { + scope + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn append_library_scope( + sql: &mut String, + params: &mut Vec, + library_scope: Option<&str>, +) { + if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) { + sql.push_str(" AND "); + sql.push_str(&library_scope_equals_sql("t")); + params.push(rusqlite::types::Value::Text(scope.to_string())); + } +} + +fn query_songs( + conn: &rusqlite::Connection, + query: &str, + server_id: &str, + library_scope: Option<&str>, + limit: u32, +) -> rusqlite::Result> { + let matches = column_matches(query, &SONG_FTS_COLUMNS).map_err(|e| { + rusqlite::Error::ToSqlConversionFailure(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + e, + ))) + })?; + let per_col = i64::from(limit.max(4)); + let rowids = collect_fts_rowids(conn, &matches, per_col, limit as usize)?; + if rowids.is_empty() { + return Ok(Vec::new()); + } + fetch_tracks_by_rowids(conn, &rowids, server_id, library_scope) +} + +fn query_artists( + conn: &rusqlite::Connection, + query: &str, + server_id: &str, + library_scope: Option<&str>, + limit: u32, +) -> rusqlite::Result> { + let Some(artist_fts) = fts_column_prefix_query("artist", query) else { + return Ok(Vec::new()); + }; + let fetch = limit.saturating_mul(3).clamp(limit, 24); + let rowids = collect_fts_rowids(conn, &[artist_fts], i64::from(fetch), fetch as usize)?; + if rowids.is_empty() { + return Ok(Vec::new()); + } + let placeholders = rowid_placeholders(rowids.len()); + let sql = format!( + "SELECT t.server_id, t.artist_id, t.artist, t.synced_at, t.rowid \ + FROM track t \ + WHERE t.rowid IN ({placeholders}) \ + AND t.server_id = ? \ + AND t.deleted = 0 \ + AND t.artist_id IS NOT NULL AND t.artist_id != ''" + ); + let mut params: Vec = rowids + .iter() + .copied() + .map(rusqlite::types::Value::Integer) + .collect(); + params.push(rusqlite::types::Value::Text(server_id.to_string())); + let mut sql = sql; + append_library_scope(&mut sql, &mut params, library_scope); + let rank: HashMap = rowids + .iter() + .enumerate() + .map(|(i, &rid)| (rid, i)) + .collect(); + let mut ranked: Vec<(usize, LibraryArtistDto)> = Vec::new(); + let mut stmt = conn.prepare(&sql)?; + for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(( + r.get::<_, i64>(4)?, + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, Option>(2)?, + r.get::<_, i64>(3)?, + )) + })? { + let (rowid, server_id, artist_id, artist, synced_at) = row?; + let fts_rank = rank.get(&rowid).copied().unwrap_or(usize::MAX); + ranked.push(( + fts_rank, + LibraryArtistDto { + server_id, + id: artist_id, + name: artist.unwrap_or_default(), + album_count: None, + synced_at, + raw_json: serde_json::Value::Null, + }, + )); + } + ranked.sort_by_key(|(r, _)| *r); + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for (_, dto) in ranked { + if !seen.insert(dto.id.clone()) { + continue; + } + out.push(dto); + if out.len() >= limit as usize { + break; + } + } + Ok(out) +} + +fn query_albums( + conn: &rusqlite::Connection, + query: &str, + server_id: &str, + library_scope: Option<&str>, + limit: u32, +) -> rusqlite::Result> { + let matches = column_matches(query, &ALBUM_FTS_COLUMNS).map_err(|e| { + rusqlite::Error::ToSqlConversionFailure(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + e, + ))) + })?; + let fetch = limit.saturating_mul(3).clamp(limit, 24); + let rowids = collect_fts_rowids(conn, &matches, i64::from(fetch), fetch as usize)?; + if rowids.is_empty() { + return Ok(Vec::new()); + } + let placeholders = rowid_placeholders(rowids.len()); + let sql = format!( + "SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \ + t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.rowid \ + FROM track t \ + WHERE t.rowid IN ({placeholders}) \ + AND t.server_id = ? \ + AND t.deleted = 0 \ + AND t.album_id IS NOT NULL AND t.album_id != ''" + ); + let mut params: Vec = rowids + .iter() + .copied() + .map(rusqlite::types::Value::Integer) + .collect(); + params.push(rusqlite::types::Value::Text(server_id.to_string())); + let mut sql = sql; + append_library_scope(&mut sql, &mut params, library_scope); + let rank: HashMap = rowids + .iter() + .enumerate() + .map(|(i, &rid)| (rid, i)) + .collect(); + let mut ranked: Vec<(usize, LibraryAlbumDto)> = Vec::new(); + let mut stmt = conn.prepare(&sql)?; + for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(( + r.get::<_, i64>(10)?, + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, + r.get::<_, Option>(5)?, + r.get::<_, Option>(6)?, + r.get::<_, Option>(7)?, + r.get::<_, Option>(8)?, + r.get::<_, i64>(9)?, + )) + })? { + let ( + rowid, + server_id, + album_id, + album, + artist, + artist_id, + year, + genre, + cover_art_id, + starred_at, + synced_at, + ) = row?; + let fts_rank = rank.get(&rowid).copied().unwrap_or(usize::MAX); + ranked.push(( + fts_rank, + LibraryAlbumDto { + server_id, + id: album_id, + name: album, + artist, + artist_id, + song_count: None, + duration_sec: None, + year, + genre, + cover_art_id, + starred_at, + synced_at, + raw_json: serde_json::Value::Null, + }, + )); + } + ranked.sort_by_key(|(r, _)| *r); + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for (_, dto) in ranked { + if !seen.insert(dto.id.clone()) { + continue; + } + out.push(dto); + if out.len() >= limit as usize { + break; + } + } + Ok(out) +} + +fn rowid_placeholders(n: usize) -> String { + (0..n).map(|_| "?").collect::>().join(", ") +} + +fn fetch_tracks_by_rowids( + conn: &rusqlite::Connection, + rowids: &[i64], + server_id: &str, + library_scope: Option<&str>, +) -> rusqlite::Result> { + let placeholders = rowid_placeholders(rowids.len()); + let sql = format!( + "SELECT \ + t.rowid, \ + t.server_id, t.id, t.title, t.artist, t.artist_id, t.album, t.album_id, \ + t.album_artist, t.duration_sec, t.track_number, t.disc_number, t.year, \ + t.genre, t.suffix, t.bit_rate, t.size_bytes, t.cover_art_id, \ + t.starred_at, t.user_rating, t.play_count, t.bpm, t.synced_at \ + FROM track t \ + WHERE t.rowid IN ({placeholders}) \ + AND t.server_id = ? \ + AND t.deleted = 0" + ); + let mut params: Vec = rowids + .iter() + .copied() + .map(rusqlite::types::Value::Integer) + .collect(); + params.push(rusqlite::types::Value::Text(server_id.to_string())); + let mut sql = sql; + append_library_scope(&mut sql, &mut params, library_scope); + let mut stmt = conn.prepare(&sql)?; + let mut by_rowid: HashMap = HashMap::new(); + for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + let rowid: i64 = r.get(0)?; + let hit = map_live_hit_row(r, 1)?; + Ok((rowid, hit.track)) + })? { + let (rowid, track) = row?; + by_rowid.insert(rowid, track); + } + Ok(rowids + .iter() + .filter_map(|rid| by_rowid.get(rid).cloned()) + .collect()) +} + +fn map_live_hit_row(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result { + Ok(LiveHit { + track: LibraryTrackDto { + server_id: row.get(offset)?, + id: row.get(offset + 1)?, + content_hash: None, + title: row.get(offset + 2)?, + title_sort: None, + artist: row.get(offset + 3)?, + artist_id: row.get(offset + 4)?, + album: row.get(offset + 5)?, + album_id: row.get(offset + 6)?, + album_artist: row.get(offset + 7)?, + duration_sec: row.get(offset + 8)?, + track_number: row.get(offset + 9)?, + disc_number: row.get(offset + 10)?, + year: row.get(offset + 11)?, + genre: row.get(offset + 12)?, + suffix: row.get(offset + 13)?, + bit_rate: row.get(offset + 14)?, + size_bytes: row.get(offset + 15)?, + cover_art_id: row.get(offset + 16)?, + starred_at: row.get(offset + 17)?, + user_rating: row.get(offset + 18)?, + play_count: row.get(offset + 19)?, + bpm: row.get(offset + 20)?, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + server_updated_at: None, + server_created_at: None, + synced_at: row.get(offset + 21)?, + enrichment: None, + raw_json: serde_json::Value::Null, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::{TrackRepository, TrackRow}; + use serde_json::json; + + fn track( + server: &str, + id: &str, + title: &str, + artist: &str, + album: &str, + album_id: &str, + artist_id: &str, + ) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: title.into(), + title_sort: None, + artist: Some(artist.into()), + artist_id: Some(artist_id.into()), + album: album.into(), + album_id: Some(album_id.into()), + album_artist: Some(artist.into()), + duration_sec: 200, + track_number: Some(1), + disc_number: Some(1), + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: Some(format!("cv_{album_id}")), + 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: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + #[test] + fn live_search_prefix_matches_partial_artist_name() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track( + "s1", + "t1", + "Enter Sandman", + "Metallica", + "Metallica", + "al1", + "ar_meta", + ), + track("s1", "t2", "Other", "Other Artist", "Other Album", "al2", "ar2"), + ]) + .unwrap(); + let resp = run_live_search(&store, "s1", "metal", None, 5, 5, 10).unwrap(); + assert!( + resp.artists.iter().any(|a| a.name == "Metallica"), + "expected Metallica from prefix query metal" + ); + assert!(resp.tracks.iter().any(|t| t.artist.as_deref() == Some("Metallica"))); + } + + #[test] + fn live_search_returns_songs_albums_artists_from_scoped_fts() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Aurora Song", "Aurora Quartet", "Aurora Nights", "al1", "ar1"), + track("s1", "t2", "Other", "Other Artist", "Other Album", "al2", "ar2"), + ]) + .unwrap(); + let resp = run_live_search(&store, "s1", "aurora", None, 5, 5, 10).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].id, "al1"); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].id, "ar1"); + assert!(resp.tracks[0].raw_json.is_null()); + } + + #[test] + fn live_search_does_not_surface_artist_from_unrelated_track_hit() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track( + "s1", + "t1", + "Battle Hymn", + "Arch Enemy", + "Manowar Covers Vol 1", + "al1", + "ar_arch", + ), + track( + "s1", + "t2", + "Heart Of Steel", + "Manowar", + "Fighting the World", + "al2", + "ar_mano", + ), + ]) + .unwrap(); + let resp = run_live_search(&store, "s1", "manowar", None, 5, 5, 10).unwrap(); + assert!( + resp.artists.iter().any(|a| a.name == "Manowar"), + "expected Manowar artist" + ); + assert!( + !resp.artists.iter().any(|a| a.name == "Arch Enemy"), + "Arch Enemy must not appear when only the album title mentions Manowar" + ); + assert!(resp.albums.iter().any(|a| a.name.contains("Manowar"))); + } + + #[test] + fn live_search_short_query_returns_empty_without_scanning() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track( + "s1", + "t1", + "Аура", + "Artist", + "Album", + "al1", + "ar1", + )]) + .unwrap(); + let resp = run_live_search(&store, "s1", "а", None, 5, 5, 10).unwrap(); + assert!(resp.tracks.is_empty()); + assert!(resp.artists.is_empty()); + assert!(resp.albums.is_empty()); + } + + #[test] + fn live_search_library_scope_narrows_results() { + let store = LibraryStore::open_in_memory(); + let mut in_lib = track( + "s1", + "t1", + "Scoped Song", + "Scoped Artist", + "Scoped Album", + "al1", + "ar1", + ); + in_lib.library_id = Some("lib1".into()); + let mut other = track( + "s1", + "t2", + "Scoped Song", + "Other Artist", + "Other Album", + "al2", + "ar2", + ); + other.library_id = Some("lib2".into()); + TrackRepository::new(&store) + .upsert_batch(&[in_lib, other]) + .unwrap(); + let resp = run_live_search(&store, "s1", "scoped", Some("lib1"), 5, 5, 10).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].name, "Scoped Artist"); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].name, "Scoped Album"); + } + + #[test] + fn live_search_library_scope_matches_raw_json_when_column_null() { + let store = LibraryStore::open_in_memory(); + let mut row = track( + "s1", + "t1", + "Scoped Song", + "Scoped Artist", + "Scoped Album", + "al1", + "ar1", + ); + row.raw_json = json!({"libraryId": 3}).to_string(); + TrackRepository::new(&store).upsert_batch(&[row]).unwrap(); + let resp = run_live_search(&store, "s1", "scoped", Some("3"), 5, 5, 10).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + } + + /// Manual: `cargo test -p psysonic-library bench_disk_live_search --release -- --ignored --nocapture` + #[test] + #[ignore] + fn bench_disk_live_search() { + use std::path::PathBuf; + use std::time::Instant; + + let path: PathBuf = std::env::var("HOME") + .map(|h| PathBuf::from(h).join(".local/share/dev.psysonic.player/library.sqlite")) + .expect("HOME"); + if !path.exists() { + eprintln!("skip: no db at {}", path.display()); + return; + } + let conn = rusqlite::Connection::open_with_flags( + &path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .expect("open db"); + conn.pragma_update(None, "cache_size", -64000).unwrap(); + + let server_id = std::env::var("PSYSONIC_BENCH_SERVER_ID").unwrap_or_else(|_| { + conn.query_row( + "SELECT server_id FROM track WHERE deleted = 0 LIMIT 1", + [], + |r| r.get::<_, String>(0), + ) + .expect("server_id") + }); + + for q in ["manowar", "metallica", "arch enemy", "metal", "meta"] { + let t0 = Instant::now(); + let songs = query_songs(&conn, q, &server_id, None, 10).unwrap(); + let t1 = Instant::now(); + let artists = query_artists(&conn, q, &server_id, None, 5).unwrap(); + let t2 = Instant::now(); + let albums = query_albums(&conn, q, &server_id, None, 5).unwrap(); + let t3 = Instant::now(); + eprintln!( + "{q:?}: songs={} ({:.1}ms) artists={} ({:.1}ms) albums={} ({:.1}ms) total={:.1}ms", + songs.len(), + t1.duration_since(t0).as_secs_f64() * 1000.0, + artists.len(), + t2.duration_since(t1).as_secs_f64() * 1000.0, + albums.len(), + t3.duration_since(t2).as_secs_f64() * 1000.0, + t3.duration_since(t0).as_secs_f64() * 1000.0, + ); + } + } +} diff --git a/src-tauri/crates/psysonic-library/src/payload.rs b/src-tauri/crates/psysonic-library/src/payload.rs new file mode 100644 index 00000000..cc5fb800 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/payload.rs @@ -0,0 +1,240 @@ +//! `ProgressEvent` → Tauri event payload mapper. PR-5a ships the +//! transformation as a pure function so it's unit-testable without +//! Tauri / supervisor wiring; PR-5b plugs the mpsc receiver into +//! `AppHandle::emit("library:sync-progress", payload)`. + +use serde::{Deserialize, Serialize}; + +use crate::sync::progress::ProgressEvent; + +/// Wire shape for the `library:sync-progress` / `library:sync-idle` +/// Tauri events. Carries the `serverId` + `libraryScope` so the +/// frontend can demultiplex across multiple servers. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibrarySyncProgressPayload { + pub server_id: String, + pub library_scope: String, + /// Discriminator: `"phase_changed"` / `"ingest_page"` / + /// `"remapped"` / `"tombstoned"` / `"completed"` / `"error"`. + pub kind: String, + pub phase: Option, + pub ingested_total: Option, + pub batch_count: Option, + pub remapped_count: Option, + pub tombstones_checked: Option, + pub tombstones_deleted: Option, + pub completed_kind: Option, + pub message: Option, + /// Per-batch ingest timings (S1 initial sync). + pub ingest_metrics: Option, +} + +impl LibrarySyncProgressPayload { + pub fn from_event(event: &ProgressEvent, server_id: &str, library_scope: &str) -> Self { + let mut payload = Self { + server_id: server_id.to_string(), + library_scope: library_scope.to_string(), + kind: String::new(), + phase: None, + ingested_total: None, + batch_count: None, + remapped_count: None, + tombstones_checked: None, + tombstones_deleted: None, + completed_kind: None, + message: None, + ingest_metrics: None, + }; + match event { + ProgressEvent::PhaseChanged { phase } => { + payload.kind = "phase_changed".into(); + payload.phase = Some(phase.clone()); + } + ProgressEvent::IngestPage { + ingested_total, + batch_count, + metrics, + } => { + payload.kind = "ingest_page".into(); + payload.ingested_total = Some(*ingested_total); + payload.batch_count = Some(*batch_count); + payload.ingest_metrics = metrics.clone(); + } + ProgressEvent::Remapped { entries } => { + payload.kind = "remapped".into(); + payload.remapped_count = Some(entries.len() as u32); + } + ProgressEvent::Tombstoned { + deleted_count, + checked_count, + } => { + payload.kind = "tombstoned".into(); + payload.tombstones_deleted = Some(*deleted_count); + payload.tombstones_checked = Some(*checked_count); + } + ProgressEvent::Completed { kind } => { + payload.kind = "completed".into(); + payload.completed_kind = Some(kind.clone()); + } + ProgressEvent::Error { message } => { + payload.kind = "error".into(); + payload.message = Some(message.clone()); + } + } + payload + } + + /// Convenience constant for the event-name Tauri emits. + pub const PROGRESS_EVENT_NAME: &'static str = "library:sync-progress"; + pub const IDLE_EVENT_NAME: &'static str = "library:sync-idle"; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::RemapEntry; + + #[test] + fn phase_changed_maps_to_phase_kind() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::PhaseChanged { phase: "ingest".into() }, + "s1", + "", + ); + assert_eq!(p.kind, "phase_changed"); + assert_eq!(p.phase.as_deref(), Some("ingest")); + assert_eq!(p.server_id, "s1"); + } + + #[test] + fn ingest_page_carries_total_and_batch_count() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::IngestPage { + ingested_total: 2500, + batch_count: 5, + metrics: None, + }, + "s1", + "lib-1", + ); + assert_eq!(p.kind, "ingest_page"); + assert_eq!(p.ingested_total, Some(2500)); + assert_eq!(p.batch_count, Some(5)); + assert_eq!(p.library_scope, "lib-1"); + } + + #[test] + fn remapped_count_reflects_entries_length() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::Remapped { + entries: vec![ + RemapEntry { + server_id: "s1".into(), + old_id: "a".into(), + new_id: "b".into(), + }, + RemapEntry { + server_id: "s1".into(), + old_id: "c".into(), + new_id: "d".into(), + }, + ], + }, + "s1", + "", + ); + assert_eq!(p.kind, "remapped"); + assert_eq!(p.remapped_count, Some(2)); + } + + #[test] + fn tombstoned_carries_checked_and_deleted() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::Tombstoned { + deleted_count: 3, + checked_count: 10, + }, + "s1", + "", + ); + assert_eq!(p.kind, "tombstoned"); + assert_eq!(p.tombstones_deleted, Some(3)); + assert_eq!(p.tombstones_checked, Some(10)); + } + + #[test] + fn completed_event_records_kind_string() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::Completed { kind: "initial_sync".into() }, + "s1", + "", + ); + assert_eq!(p.kind, "completed"); + assert_eq!(p.completed_kind.as_deref(), Some("initial_sync")); + } + + #[test] + fn error_event_records_message() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::Error { message: "timeout".into() }, + "s1", + "", + ); + assert_eq!(p.kind, "error"); + assert_eq!(p.message.as_deref(), Some("timeout")); + } + + #[test] + fn ingest_metrics_serialize_camel_case() { + use crate::sync::progress::IngestBatchMetrics; + + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::IngestPage { + ingested_total: 500, + batch_count: 1, + metrics: Some(IngestBatchMetrics { + offset: 4500, + strategy: "s1".into(), + fetch_ms: 120, + write_ms: 8, + lock_wait_ms: 0, + sql_exec_ms: 7, + persist_ms: 1, + row_count: 500, + bulk_ingest_active: true, + }), + }, + "s1", + "", + ); + let json = serde_json::to_value(&p).unwrap(); + let metrics = json.get("ingestMetrics").unwrap(); + assert_eq!(metrics.get("fetchMs").and_then(|v| v.as_u64()), Some(120)); + assert_eq!(metrics.get("lockWaitMs").and_then(|v| v.as_u64()), Some(0)); + assert_eq!(metrics.get("bulkIngestActive").and_then(|v| v.as_bool()), Some(true)); + } + + #[test] + fn serialization_uses_camel_case_keys() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::IngestPage { + ingested_total: 1, + batch_count: 1, + metrics: None, + }, + "s1", + "", + ); + let json = serde_json::to_value(&p).unwrap(); + for key in [ + "serverId", + "libraryScope", + "kind", + "ingestedTotal", + "batchCount", + ] { + assert!(json.get(key).is_some(), "missing camelCase key `{key}`"); + } + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/artifact.rs b/src-tauri/crates/psysonic-library/src/repos/artifact.rs new file mode 100644 index 00000000..54a2beeb --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/artifact.rs @@ -0,0 +1,453 @@ +//! E4 — `ArtifactRepository`: typed CRUD over `track_artifact` with the +//! §5.11 / §5.12 storage rules. +//! +//! - **Lazy expiry (P34):** no background GC; a `get` first deletes the +//! track's expired artifacts of that kind (negative-cache rows included), +//! then returns the best surviving match. +//! - **Size cap (§5.12 write path):** an artifact over 512 KB is rejected +//! unless it is a user import (`source_kind == "user"`), so a runaway +//! lyrics/blob fetch can't bloat `library.sqlite`. +//! +//! The Tauri commands (`library_get_artifact` / `library_put_artifact`) +//! delegate here so there's one source of truth for the storage rules. + +use rusqlite::types::Value; +use rusqlite::{params, OptionalExtension}; + +use crate::dto::{ArtifactInputDto, TrackArtifactDto}; +use crate::store::LibraryStore; + +/// §5.12: cap a single artifact at 512 KB unless the user imported it. +pub const MAX_ARTIFACT_BYTES: usize = 512 * 1024; + +pub struct ArtifactRepository<'a> { + store: &'a LibraryStore, +} + +impl<'a> ArtifactRepository<'a> { + pub fn new(store: &'a LibraryStore) -> Self { + Self { store } + } + + /// Lazily drop the track's expired artifacts of this kind (§5.12 + /// read-path GC — negative-cache rows expire too), then return the most + /// recent surviving match. `source_kind` / `source_id` / `format` narrow + /// the lookup when provided so the lyrics path can resolve a specific + /// source or take the first match. + // Mirrors the flat parameter list of the `library_get_artifact` Tauri + // command (a fixed public contract); grouping them here would only + // diverge the two signatures. + #[allow(clippy::too_many_arguments)] + pub fn get( + &self, + server_id: &str, + track_id: &str, + artifact_kind: &str, + source_kind: Option<&str>, + source_id: Option<&str>, + format: Option<&str>, + now: i64, + ) -> Result, String> { + if self.store.bulk_ingest_active() { + return self.get_readonly( + server_id, + track_id, + artifact_kind, + source_kind, + source_id, + format, + ); + } + self.store + .with_conn_mut("artifact.get_gc", |conn| { + // Lazy TTL cleanup, scoped to the looked-up kind. + conn.execute( + "DELETE FROM track_artifact \ + WHERE server_id = ?1 AND track_id = ?2 AND artifact_kind = ?3 \ + AND expires_at IS NOT NULL AND expires_at < ?4", + params![server_id, track_id, artifact_kind, now], + )?; + + Self::query_one( + conn, + server_id, + track_id, + artifact_kind, + source_kind, + source_id, + format, + ) + }) + .map_err(|e| e.to_string()) + } + + fn get_readonly( + &self, + server_id: &str, + track_id: &str, + artifact_kind: &str, + source_kind: Option<&str>, + source_id: Option<&str>, + format: Option<&str>, + ) -> Result, String> { + self.store + .with_read_conn(|conn| { + Self::query_one( + conn, + server_id, + track_id, + artifact_kind, + source_kind, + source_id, + format, + ) + }) + .map_err(|e| e.to_string()) + } + + fn query_one( + conn: &rusqlite::Connection, + server_id: &str, + track_id: &str, + artifact_kind: &str, + source_kind: Option<&str>, + source_id: Option<&str>, + format: Option<&str>, + ) -> rusqlite::Result> { + let mut sql = String::from( + "SELECT server_id, track_id, artifact_kind, format, source_kind, source_id, \ + language, content_text, content_bytes, not_found, content_hash, fetched_at, \ + expires_at FROM track_artifact \ + WHERE server_id = ?1 AND track_id = ?2 AND artifact_kind = ?3", + ); + let mut bound: Vec = vec![ + Value::Text(server_id.to_string()), + Value::Text(track_id.to_string()), + Value::Text(artifact_kind.to_string()), + ]; + let mut next = 4; + if let Some(sk) = source_kind { + sql.push_str(&format!(" AND source_kind = ?{next}")); + bound.push(Value::Text(sk.to_string())); + next += 1; + } + if let Some(si) = source_id { + sql.push_str(&format!(" AND source_id = ?{next}")); + bound.push(Value::Text(si.to_string())); + next += 1; + } + if let Some(fmt) = format { + sql.push_str(&format!(" AND format = ?{next}")); + bound.push(Value::Text(fmt.to_string())); + } + sql.push_str(" ORDER BY fetched_at DESC LIMIT 1"); + + let mut stmt = conn.prepare(&sql)?; + stmt.query_row(rusqlite::params_from_iter(bound.iter()), row_to_artifact_dto) + .optional() + } + + /// E3 readiness: is there a valid (non-expired, non-`not_found`) lyrics + /// artifact for `(server_id, track_id)`? Pure read — no lazy GC, no writes — + /// so the `library_get_track` enrichment summary stays read-only. + pub fn lyrics_cached(&self, server_id: &str, track_id: &str, now: i64) -> Result { + let query = |conn: &rusqlite::Connection| { + conn.query_row( + "SELECT EXISTS ( \ + SELECT 1 FROM track_artifact \ + WHERE server_id = ?1 AND track_id = ?2 \ + AND artifact_kind = 'lyrics' \ + AND not_found = 0 \ + AND (expires_at IS NULL OR expires_at >= ?3) \ + )", + params![server_id, track_id, now], + |r| r.get::<_, i64>(0), + ) + }; + let exists = if self.store.bulk_ingest_active() { + self.store.with_read_conn(query)? + } else { + self.store.with_conn("artifact.lyrics_cached", query)? + }; + Ok(exists != 0) + } + + /// Upsert an artifact. Rejects content over [`MAX_ARTIFACT_BYTES`] unless + /// it is a user import (§5.12). A negative-cache row (`not_found`) carries + /// no content, so it always fits. + pub fn put( + &self, + server_id: &str, + track_id: &str, + artifact: &ArtifactInputDto, + now: i64, + ) -> Result<(), String> { + let actual = artifact.content_text.as_ref().map_or(0, |s| s.len()) + + artifact.content_blob.as_ref().map_or(0, |b| b.len()); + let declared = usize::try_from(artifact.content_bytes).unwrap_or(0); + let size = actual.max(declared); + if size > MAX_ARTIFACT_BYTES && artifact.source_kind != "user" { + return Err(format!( + "artifact too large: {size} bytes exceeds {MAX_ARTIFACT_BYTES} cap \ + (source_kind={})", + artifact.source_kind + )); + } + + self.store + .with_conn("artifact.put", |conn| { + conn.execute( + UPSERT_ARTIFACT, + params![ + server_id, + track_id, + artifact.artifact_kind, + artifact.format, + artifact.language, + artifact.source_kind, + artifact.source_id, + artifact.content_text, + artifact.content_blob, + artifact.content_bytes, + if artifact.not_found { 1_i64 } else { 0 }, + artifact.content_hash, + now, + artifact.expires_at, + ], + )?; + Ok(()) + }) + .map_err(|e| e.to_string()) + } +} + +fn row_to_artifact_dto(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(TrackArtifactDto { + server_id: row.get(0)?, + track_id: row.get(1)?, + artifact_kind: row.get(2)?, + format: row.get(3)?, + source_kind: row.get(4)?, + source_id: row.get(5)?, + language: row.get(6)?, + content_text: row.get(7)?, + content_bytes: row.get(8)?, + not_found: row.get::<_, i64>(9)? != 0, + content_hash: row.get(10)?, + fetched_at: row.get(11)?, + expires_at: row.get(12)?, + }) +} + +const UPSERT_ARTIFACT: &str = "INSERT INTO track_artifact \ + (server_id, track_id, artifact_kind, format, language, source_kind, source_id, \ + content_text, content_blob, content_bytes, not_found, content_hash, fetched_at, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) \ + ON CONFLICT(server_id, track_id, artifact_kind, source_kind, source_id, format) DO UPDATE SET \ + language = excluded.language, \ + content_text = excluded.content_text, \ + content_blob = excluded.content_blob, \ + content_bytes = excluded.content_bytes, \ + not_found = excluded.not_found, \ + content_hash = excluded.content_hash, \ + fetched_at = excluded.fetched_at, \ + expires_at = excluded.expires_at"; + +#[cfg(test)] +mod tests { + use super::*; + + fn artifact( + kind: &str, + format: &str, + source_kind: &str, + source_id: &str, + ) -> ArtifactInputDto { + ArtifactInputDto { + artifact_kind: kind.into(), + format: format.into(), + source_kind: source_kind.into(), + source_id: source_id.into(), + language: None, + content_text: Some("la la la".into()), + content_blob: None, + content_bytes: 0, + not_found: false, + content_hash: None, + expires_at: None, + } + } + + fn seed_track(store: &LibraryStore, server: &str, id: &str) { + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO track (server_id, id, title, synced_at, raw_json) \ + VALUES (?1, ?2, 'T', 1, '{}')", + params![server, id], + ) + }) + .unwrap(); + } + + #[test] + fn put_then_get_roundtrips() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + repo.put("s1", "t1", &artifact("lyrics", "plain", "lrclib", "lrclib"), 100) + .unwrap(); + let got = repo + .get("s1", "t1", "lyrics", None, None, None, 200) + .unwrap() + .expect("artifact present"); + assert_eq!(got.artifact_kind, "lyrics"); + assert_eq!(got.content_text.as_deref(), Some("la la la")); + } + + #[test] + fn get_lazily_deletes_expired_artifact() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + let mut a = artifact("lyrics", "plain", "lrclib", "lrclib"); + a.expires_at = Some(150); + repo.put("s1", "t1", &a, 100).unwrap(); + + // read at t=200 → expired, dropped + treated as a miss. + let got = repo.get("s1", "t1", "lyrics", None, None, None, 200).unwrap(); + assert!(got.is_none()); + + let total: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_artifact", [], |r| r.get(0))) + .unwrap(); + assert_eq!(total, 0, "expired row deleted, not just filtered"); + } + + #[test] + fn negative_cache_row_is_returned_until_it_expires() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + let mut miss = artifact("lyrics", "plain", "lrclib", "lrclib"); + miss.content_text = None; + miss.not_found = true; + miss.expires_at = Some(1000); + repo.put("s1", "t1", &miss, 100).unwrap(); + + let got = repo + .get("s1", "t1", "lyrics", None, None, None, 500) + .unwrap() + .expect("negative-cache row still live"); + assert!(got.not_found, "caller sees the cached miss instead of refetching"); + } + + #[test] + fn lyrics_cached_only_for_live_found_row() { + // Live lyrics row → cached; nothing / negative-cache / expired → not. + let live = LibraryStore::open_in_memory(); + seed_track(&live, "s1", "t1"); + let repo = ArtifactRepository::new(&live); + assert!(!repo.lyrics_cached("s1", "t1", 200).unwrap(), "no row → not cached"); + repo.put("s1", "t1", &artifact("lyrics", "plain", "lrclib", "lrclib"), 100) + .unwrap(); + assert!(repo.lyrics_cached("s1", "t1", 200).unwrap(), "live row → cached"); + + let neg = LibraryStore::open_in_memory(); + seed_track(&neg, "s1", "t1"); + let repo_neg = ArtifactRepository::new(&neg); + let mut miss = artifact("lyrics", "plain", "lrclib", "lrclib"); + miss.content_text = None; + miss.not_found = true; + miss.expires_at = Some(1000); + repo_neg.put("s1", "t1", &miss, 100).unwrap(); + assert!( + !repo_neg.lyrics_cached("s1", "t1", 500).unwrap(), + "negative-cache row is not 'cached'" + ); + + let exp = LibraryStore::open_in_memory(); + seed_track(&exp, "s1", "t1"); + let repo_exp = ArtifactRepository::new(&exp); + let mut expired = artifact("lyrics", "plain", "lrclib", "lrclib"); + expired.expires_at = Some(150); + repo_exp.put("s1", "t1", &expired, 100).unwrap(); + assert!( + !repo_exp.lyrics_cached("s1", "t1", 200).unwrap(), + "expired lyrics not cached" + ); + } + + #[test] + fn get_filters_by_source_id_only() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + repo.put("s1", "t1", &artifact("lyrics", "plain", "lrclib", "lrclib"), 1) + .unwrap(); + repo.put("s1", "t1", &artifact("lyrics", "plain", "netease", "netease"), 2) + .unwrap(); + + // source_id without source_kind must bind correctly (running indices). + let got = repo + .get("s1", "t1", "lyrics", None, Some("netease"), None, 3) + .unwrap() + .expect("netease row"); + assert_eq!(got.source_id, "netease"); + } + + #[test] + fn get_returns_most_recent_when_unfiltered() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + repo.put("s1", "t1", &artifact("lyrics", "plain", "lrclib", "lrclib"), 1) + .unwrap(); + repo.put("s1", "t1", &artifact("lyrics", "plain", "netease", "netease"), 9) + .unwrap(); + let got = repo + .get("s1", "t1", "lyrics", None, None, None, 10) + .unwrap() + .unwrap(); + assert_eq!(got.source_id, "netease", "ORDER BY fetched_at DESC"); + } + + #[test] + fn put_rejects_oversized_non_user_artifact() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + let mut big = artifact("lyrics", "plain", "lrclib", "lrclib"); + big.content_text = Some("x".repeat(MAX_ARTIFACT_BYTES + 1)); + let err = repo.put("s1", "t1", &big, 1).unwrap_err(); + assert!(err.contains("too large"), "got: {err}"); + } + + #[test] + fn put_allows_oversized_user_import() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + let mut big = artifact("lyrics", "plain", "user", "user"); + big.content_text = Some("x".repeat(MAX_ARTIFACT_BYTES + 1)); + repo.put("s1", "t1", &big, 1).expect("user import bypasses the cap"); + } + + #[test] + fn put_is_idempotent_on_same_source_key() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + repo.put("s1", "t1", &artifact("lyrics", "plain", "lrclib", "lrclib"), 1) + .unwrap(); + let mut updated = artifact("lyrics", "plain", "lrclib", "lrclib"); + updated.content_text = Some("new words".into()); + repo.put("s1", "t1", &updated, 2).unwrap(); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_artifact", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 1, "same (kind, source, format) updates in place"); + let got = repo.get("s1", "t1", "lyrics", None, None, None, 3).unwrap().unwrap(); + assert_eq!(got.content_text.as_deref(), Some("new words")); + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/fact.rs b/src-tauri/crates/psysonic-library/src/repos/fact.rs new file mode 100644 index 00000000..facee0d2 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/fact.rs @@ -0,0 +1,302 @@ +//! E4 — `FactRepository`: typed CRUD over `track_fact` with the §5.12 +//! provenance / TTL rules. +//! +//! - **Lazy expiry (P34):** there's no background GC; a `get` first deletes +//! the track's expired facts, then returns the survivors. +//! - **User override (R6-3.4):** a `user` BPM fact also writes `track.bpm` so +//! the override wins and survives a server resync. +//! +//! The Tauri commands (`library_get_facts` / `library_put_fact`) delegate +//! here so there's one source of truth for the storage rules. + +use rusqlite::params; + +use crate::dto::{FactInputDto, TrackFactDto}; +use crate::store::LibraryStore; + +pub struct FactRepository<'a> { + store: &'a LibraryStore, +} + +impl<'a> FactRepository<'a> { + pub fn new(store: &'a LibraryStore) -> Self { + Self { store } + } + + /// Lazily drop the track's expired facts (§5.12 read-path GC), then + /// return the survivors — optionally filtered to `fact_kinds`. Ordered + /// `fact_kind ASC, fetched_at DESC` so the highest-priority source per + /// kind (caller resolves) comes first within its group. + pub fn get( + &self, + server_id: &str, + track_id: &str, + fact_kinds: &[String], + now: i64, + ) -> Result, String> { + if self.store.bulk_ingest_active() { + return self.get_readonly(server_id, track_id, fact_kinds); + } + self.store + .with_conn_mut("fact.get_gc", |conn| { + // Lazy TTL cleanup for this track. + conn.execute( + "DELETE FROM track_fact \ + WHERE server_id = ?1 AND track_id = ?2 \ + AND expires_at IS NOT NULL AND expires_at < ?3", + params![server_id, track_id, now], + )?; + + Self::query_facts(conn, server_id, track_id, fact_kinds) + }) + .map_err(|e| e.to_string()) + } + + fn get_readonly( + &self, + server_id: &str, + track_id: &str, + fact_kinds: &[String], + ) -> Result, String> { + self.store + .with_read_conn(|conn| Self::query_facts(conn, server_id, track_id, fact_kinds)) + .map_err(|e| e.to_string()) + } + + fn query_facts( + conn: &rusqlite::Connection, + server_id: &str, + track_id: &str, + fact_kinds: &[String], + ) -> rusqlite::Result> { + if fact_kinds.is_empty() { + let mut stmt = conn.prepare(SELECT_FACTS)?; + let rows: rusqlite::Result> = stmt + .query_map(params![server_id, track_id], row_to_fact_dto)? + .collect(); + rows + } else { + let placeholders = (0..fact_kinds.len()) + .map(|i| format!("?{}", i + 3)) + .collect::>() + .join(", "); + let sql = format!( + "{SELECT_FACTS_BASE} AND fact_kind IN ({placeholders}) \ + ORDER BY fact_kind ASC, fetched_at DESC" + ); + let mut bound: Vec = vec![ + rusqlite::types::Value::Text(server_id.to_string()), + rusqlite::types::Value::Text(track_id.to_string()), + ]; + for k in fact_kinds { + bound.push(rusqlite::types::Value::Text(k.clone())); + } + let mut stmt = conn.prepare(&sql)?; + let rows: rusqlite::Result> = stmt + .query_map(rusqlite::params_from_iter(bound.iter()), row_to_fact_dto)? + .collect(); + rows + } + } + + /// Upsert a fact. A `user` BPM fact also writes the hot `track.bpm` + /// column so the manual override beats the server tag and survives a + /// resync (§5.12 write path / R6-3.4). + pub fn put( + &self, + server_id: &str, + track_id: &str, + fact: &FactInputDto, + now: i64, + ) -> Result<(), String> { + self.store + .with_conn("fact.put", |conn| { + conn.execute( + UPSERT_FACT, + params![ + server_id, + track_id, + fact.fact_kind, + fact.value_real, + fact.value_int, + fact.value_text, + fact.unit, + fact.source_kind, + fact.source_id, + fact.confidence, + fact.content_hash, + now, + fact.expires_at, + ], + )?; + if fact.fact_kind == "bpm" && fact.source_kind == "user" { + if let Some(bpm) = fact.value_int { + conn.execute( + "UPDATE track SET bpm = ?3 WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, bpm], + )?; + } + } + Ok(()) + }) + .map_err(|e| e.to_string()) + } +} + +fn row_to_fact_dto(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(TrackFactDto { + server_id: row.get(0)?, + track_id: row.get(1)?, + fact_kind: row.get(2)?, + value_real: row.get(3)?, + value_int: row.get(4)?, + value_text: row.get(5)?, + unit: row.get(6)?, + source_kind: row.get(7)?, + source_id: row.get(8)?, + confidence: row.get(9)?, + content_hash: row.get(10)?, + fetched_at: row.get(11)?, + expires_at: row.get(12)?, + }) +} + +const SELECT_FACTS_BASE: &str = "SELECT server_id, track_id, fact_kind, value_real, value_int, \ + value_text, unit, source_kind, source_id, confidence, content_hash, fetched_at, expires_at \ + FROM track_fact WHERE server_id = ?1 AND track_id = ?2"; + +const SELECT_FACTS: &str = "SELECT server_id, track_id, fact_kind, value_real, value_int, \ + value_text, unit, source_kind, source_id, confidence, content_hash, fetched_at, expires_at \ + FROM track_fact WHERE server_id = ?1 AND track_id = ?2 \ + ORDER BY fact_kind ASC, fetched_at DESC"; + +const UPSERT_FACT: &str = "INSERT INTO track_fact \ + (server_id, track_id, fact_kind, value_real, value_int, value_text, unit, \ + source_kind, source_id, source_detail, confidence, content_hash, fetched_at, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10, ?11, ?12, ?13) \ + ON CONFLICT(server_id, track_id, fact_kind, source_kind, source_id) DO UPDATE SET \ + value_real = excluded.value_real, \ + value_int = excluded.value_int, \ + value_text = excluded.value_text, \ + unit = excluded.unit, \ + confidence = excluded.confidence, \ + content_hash = excluded.content_hash, \ + fetched_at = excluded.fetched_at, \ + expires_at = excluded.expires_at"; + +#[cfg(test)] +mod tests { + use super::*; + + fn fact(kind: &str, source_kind: &str, value_int: Option, expires_at: Option) -> FactInputDto { + FactInputDto { + fact_kind: kind.into(), + value_real: None, + value_int, + value_text: None, + unit: None, + source_kind: source_kind.into(), + source_id: "seed".into(), + confidence: 1.0, + content_hash: None, + expires_at, + } + } + + fn seed_track(store: &LibraryStore, server: &str, id: &str) { + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO track (server_id, id, title, synced_at, raw_json) \ + VALUES (?1, ?2, 'T', 1, '{}')", + params![server, id], + ) + }) + .unwrap(); + } + + #[test] + fn put_then_get_roundtrips() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(120), None), 100).unwrap(); + let facts = repo.get("s1", "t1", &[], 200).unwrap(); + assert_eq!(facts.len(), 1); + assert_eq!(facts[0].fact_kind, "bpm"); + assert_eq!(facts[0].value_int, Some(120)); + } + + #[test] + fn get_lazily_deletes_expired_facts() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + // expires at t=150 + repo.put("s1", "t1", &fact("energy", "external_api", Some(5), Some(150)), 100).unwrap(); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(120), None), 100).unwrap(); + + // read at t=200 → energy expired, dropped + excluded; bpm survives. + let facts = repo.get("s1", "t1", &[], 200).unwrap(); + assert_eq!(facts.len(), 1); + assert_eq!(facts[0].fact_kind, "bpm"); + + // and it was actually deleted from the table (not just filtered). + let total: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_fact", [], |r| r.get(0))) + .unwrap(); + assert_eq!(total, 1); + } + + #[test] + fn get_filters_by_kind() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(120), None), 1).unwrap(); + repo.put("s1", "t1", &fact("energy", "analysis", Some(7), None), 1).unwrap(); + let facts = repo.get("s1", "t1", &["bpm".into()], 2).unwrap(); + assert_eq!(facts.len(), 1); + assert_eq!(facts[0].fact_kind, "bpm"); + } + + #[test] + fn user_bpm_fact_also_writes_hot_track_column() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + repo.put("s1", "t1", &fact("bpm", "user", Some(128), None), 1).unwrap(); + let bpm: Option = store + .with_conn("misc", |c| { + c.query_row("SELECT bpm FROM track WHERE server_id='s1' AND id='t1'", [], |r| r.get(0)) + }) + .unwrap(); + assert_eq!(bpm, Some(128), "user bpm override must write track.bpm"); + } + + #[test] + fn analysis_bpm_fact_does_not_touch_hot_track_column() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(99), None), 1).unwrap(); + let bpm: Option = store + .with_conn("misc", |c| { + c.query_row("SELECT bpm FROM track WHERE server_id='s1' AND id='t1'", [], |r| r.get(0)) + }) + .unwrap(); + assert_eq!(bpm, None, "only a user override writes the hot column (§5.12)"); + } + + #[test] + fn put_is_idempotent_on_same_source_key() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(120), None), 1).unwrap(); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(124), None), 2).unwrap(); + let facts = repo.get("s1", "t1", &[], 3).unwrap(); + assert_eq!(facts.len(), 1, "same (kind, source) updates in place"); + assert_eq!(facts[0].value_int, Some(124)); + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/mod.rs b/src-tauri/crates/psysonic-library/src/repos/mod.rs new file mode 100644 index 00000000..383edded --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/mod.rs @@ -0,0 +1,15 @@ +pub mod artifact; +pub mod fact; +pub mod sync_state; +pub mod track; +pub mod track_id_history; + +pub use artifact::ArtifactRepository; +pub use fact::FactRepository; +pub use sync_state::SyncStateRepository; +pub use track::{RemapEntry, RemapStats, TrackRepository, TrackRow}; +pub use track_id_history::TrackIdHistoryRepository; + +// Shared row-mapper + column list so the Advanced Search builder can project +// the same hot columns as the repositories without re-declaring them. +pub(crate) use track::{row_to_track_row, track_columns}; diff --git a/src-tauri/crates/psysonic-library/src/repos/sync_state.rs b/src-tauri/crates/psysonic-library/src/repos/sync_state.rs new file mode 100644 index 00000000..7e9dbedf --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/sync_state.rs @@ -0,0 +1,826 @@ +use rusqlite::{params, OptionalExtension}; +use serde_json::Value; + +use crate::store::LibraryStore; + +/// Repository over the `sync_state` row identified by `(server_id, library_scope)`. +/// PR-1b exposes just enough of the row to drive resumable initial sync — the +/// orchestrator-side helpers (poll stats, phase transitions, …) land with +/// PR-3 when there's actual sync code to consume them. +pub struct SyncStateRepository<'a> { + store: &'a LibraryStore, +} + +impl<'a> SyncStateRepository<'a> { + pub fn new(store: &'a LibraryStore) -> Self { + Self { store } + } + + /// Read-only queries — must not take the write mutex (ingest holds it for + /// long stretches during IS-3). + fn read( + &self, + f: impl FnOnce(&rusqlite::Connection) -> rusqlite::Result, + ) -> Result { + self.store.with_read_conn(f) + } + + /// Insert a default-valued row for this `(server_id, library_scope)` pair + /// if none exists. All non-PK columns fall back to their schema DEFAULTs + /// (`sync_phase='idle'`, `initial_sync_cursor_json='{}'`, …). + pub fn ensure(&self, server_id: &str, library_scope: &str) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT OR IGNORE INTO sync_state (server_id, library_scope) VALUES (?1, ?2)", + params![server_id, library_scope], + )?; + Ok(()) + }) + } + + /// Read `initial_sync_cursor_json`. Returns `None` when the row doesn't + /// exist yet, `Some(Value)` otherwise (the schema DEFAULT is `'{}'`, so + /// a freshly-ensured row reads back as `Some(Object({}))`). + pub fn get_initial_sync_cursor( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + let raw: Option = self.read(|conn| { + conn.query_row( + "SELECT initial_sync_cursor_json FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get(0), + ) + .optional() + })?; + match raw { + None => Ok(None), + Some(s) => serde_json::from_str(&s) + .map(Some) + .map_err(|e| format!("invalid initial_sync_cursor_json: {e}")), + } + } + + /// Write `initial_sync_cursor_json`. Creates the row if needed; only the + /// cursor column is touched, all other columns keep their current values + /// (or their DEFAULTs on first insert). + pub fn set_initial_sync_cursor( + &self, + server_id: &str, + library_scope: &str, + cursor: &Value, + ) -> Result<(), String> { + let json = serde_json::to_string(cursor).map_err(|e| e.to_string())?; + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, initial_sync_cursor_json) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + initial_sync_cursor_json = excluded.initial_sync_cursor_json", + params![server_id, library_scope, json], + )?; + Ok(()) + }) + } + + /// Single write-lock acquisition for cursor + local count during ingest. + pub fn set_initial_sync_cursor_and_local_track_count( + &self, + server_id: &str, + library_scope: &str, + cursor: &Value, + local_track_count: i64, + ) -> Result<(), String> { + let json = serde_json::to_string(cursor).map_err(|e| e.to_string())?; + self.store.with_conn("sync_state.persist_cursor", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, initial_sync_cursor_json, local_track_count) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + initial_sync_cursor_json = excluded.initial_sync_cursor_json, \ + local_track_count = excluded.local_track_count", + params![server_id, library_scope, json, local_track_count], + )?; + Ok(()) + }) + } + + /// Read `capability_flags` (spec §6.1.1). Returns `None` when the + /// row doesn't exist; SQL DEFAULT is 0 so a freshly-ensured row + /// reads back as `Some(0)`. + pub fn get_capability_flags( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + let raw: Option = self.read(|conn| { + conn.query_row( + "SELECT capability_flags FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get(0), + ) + .optional() + })?; + Ok(raw.map(|v| v.max(0) as u32)) + } + + /// Write `capability_flags`. Upsert scoped to that one column. + pub fn set_capability_flags( + &self, + server_id: &str, + library_scope: &str, + flags: u32, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, capability_flags) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + capability_flags = excluded.capability_flags", + params![server_id, library_scope, flags as i64], + )?; + Ok(()) + }) + } + + /// Read `sync_phase` (state-machine values per spec §6.2: + /// `idle` / `probing` / `initial_sync` / `ready` / `error`). + /// Returns `None` when the row doesn't exist; SQL DEFAULT is + /// `'idle'` so a freshly-ensured row reads back as `Some("idle")`. + pub fn get_sync_phase( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT sync_phase FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, String>(0), + ) + .optional() + }) + } + + /// True when a full sync has completed at least once. + pub fn has_last_full_sync_at( + &self, + server_id: &str, + library_scope: &str, + ) -> Result { + self.read(|conn| { + let ts: Option> = conn + .query_row( + "SELECT last_full_sync_at FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get(0), + ) + .optional()?; + Ok(ts.flatten().is_some()) + }) + } + + /// Write `sync_phase`. Upsert scoped to that one column. + pub fn set_sync_phase( + &self, + server_id: &str, + library_scope: &str, + phase: &str, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, sync_phase) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + sync_phase = excluded.sync_phase", + params![server_id, library_scope, phase], + )?; + Ok(()) + }) + } + + /// Write `server_last_scan_iso` — server-reported timestamp of the + /// last completed scan, captured from `getScanStatus.lastScan`. + pub fn set_server_last_scan_iso( + &self, + server_id: &str, + library_scope: &str, + last_scan_iso: Option<&str>, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, server_last_scan_iso) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + server_last_scan_iso = excluded.server_last_scan_iso", + params![server_id, library_scope, last_scan_iso], + )?; + Ok(()) + }) + } + + /// Write `indexes_last_modified_ms` — watermark for the file-tree + /// browse path (`getIndexes.lastModified`). + pub fn set_indexes_last_modified_ms( + &self, + server_id: &str, + library_scope: &str, + last_modified_ms: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, indexes_last_modified_ms) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + indexes_last_modified_ms = excluded.indexes_last_modified_ms", + params![server_id, library_scope, last_modified_ms], + )?; + Ok(()) + }) + } + + /// Read `artists_last_modified_ms`. Returns `None` when the row + /// doesn't exist or the column is `NULL`. DS-2 in §6.4 compares + /// the live `getArtists.lastModified` against this to decide + /// whether a delta pass is needed. + pub fn get_artists_last_modified_ms( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT artists_last_modified_ms FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, Option>(0), + ) + .optional() + }) + .map(|opt| opt.flatten()) + } + + /// Read `server_last_scan_iso`. Returns `None` when row missing + /// or column null. DS-2 uses this against `getScanStatus.lastScan` + /// for the Huge-tier short-circuit. + pub fn get_server_last_scan_iso( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT server_last_scan_iso FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, Option>(0), + ) + .optional() + }) + .map(|opt| opt.flatten()) + } + + /// Read `library_tier`. Returns `None` when row missing. DS-0 + /// picks between `getArtists` (small/medium) and `getScanStatus` + /// (huge) based on this. + pub fn get_library_tier( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT library_tier FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, String>(0), + ) + .optional() + }) + } + + /// Read `next_poll_at` — epoch ms scheduling target. `None` when + /// the row is missing or the column is `NULL` (no schedule yet). + pub fn get_next_poll_at( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT next_poll_at FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, Option>(0), + ) + .optional() + }) + .map(|opt| opt.flatten()) + } + + /// Write `next_poll_at`. Upsert scoped to that one column. + pub fn set_next_poll_at( + &self, + server_id: &str, + library_scope: &str, + epoch_ms: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, next_poll_at) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + next_poll_at = excluded.next_poll_at", + params![server_id, library_scope, epoch_ms], + )?; + Ok(()) + }) + } + + /// Read `poll_stats_json`. Returns `Some(Value)` for an existing + /// row (SQL DEFAULT is `'{}'`, so a freshly-ensured row reads + /// back as `Some(Object({}))`), `None` when the row is absent. + pub fn get_poll_stats_json( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + let raw: Option = self.read(|conn| { + conn.query_row( + "SELECT poll_stats_json FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get(0), + ) + .optional() + })?; + match raw { + None => Ok(None), + Some(s) => serde_json::from_str(&s) + .map(Some) + .map_err(|e| format!("invalid poll_stats_json: {e}")), + } + } + + /// Write `poll_stats_json`. Upsert scoped to that one column. + pub fn set_poll_stats_json( + &self, + server_id: &str, + library_scope: &str, + stats: &Value, + ) -> Result<(), String> { + let json = serde_json::to_string(stats).map_err(|e| e.to_string())?; + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, poll_stats_json) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + poll_stats_json = excluded.poll_stats_json", + params![server_id, library_scope, json], + )?; + Ok(()) + }) + } + + /// Read `local_track_count` snapshot (counts kept in sync by C8 / + /// PR-3d2 scheduler ticks). Returns `None` when unset. + pub fn get_local_track_count( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT local_track_count FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, Option>(0), + ) + .optional() + }) + .map(|opt| opt.flatten()) + } + + pub fn set_local_track_count( + &self, + server_id: &str, + library_scope: &str, + count: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, local_track_count) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + local_track_count = excluded.local_track_count", + params![server_id, library_scope, count], + )?; + Ok(()) + }) + } + + pub fn get_server_track_count( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT server_track_count FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, Option>(0), + ) + .optional() + }) + .map(|opt| opt.flatten()) + } + + pub fn set_server_track_count( + &self, + server_id: &str, + library_scope: &str, + count: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, server_track_count) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + server_track_count = excluded.server_track_count", + params![server_id, library_scope, count], + )?; + Ok(()) + }) + } + + /// Read `n1_bulk_unreliable` — per-server learned flag (R7-15). When + /// set, the strategy selector stops choosing N1 for this server (the + /// native `/api/song` endpoint 500'd beyond a deep offset). Returns + /// `None` when the row doesn't exist; SQL DEFAULT is 0 so a + /// freshly-ensured row reads back as `Some(false)`. + pub fn get_n1_bulk_unreliable( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + let raw: Option = self.read(|conn| { + conn.query_row( + "SELECT n1_bulk_unreliable FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get(0), + ) + .optional() + })?; + Ok(raw.map(|v| v != 0)) + } + + /// Write `n1_bulk_unreliable`. Upsert scoped to that one column. + pub fn set_n1_bulk_unreliable( + &self, + server_id: &str, + library_scope: &str, + unreliable: bool, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, n1_bulk_unreliable) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + n1_bulk_unreliable = excluded.n1_bulk_unreliable", + params![server_id, library_scope, unreliable as i64], + )?; + Ok(()) + }) + } + + /// Stamp `last_full_sync_at = now` (epoch ms). Called by IS-6 when + /// the initial full ingest completes successfully. + pub fn set_last_full_sync_at( + &self, + server_id: &str, + library_scope: &str, + epoch_ms: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, last_full_sync_at) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + last_full_sync_at = excluded.last_full_sync_at", + params![server_id, library_scope, epoch_ms], + )?; + Ok(()) + }) + } + + /// Stamp `last_delta_sync_at = now` (epoch ms). Called by DS-9 at + /// the end of every successful delta pass. + pub fn set_last_delta_sync_at( + &self, + server_id: &str, + library_scope: &str, + epoch_ms: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, last_delta_sync_at) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + last_delta_sync_at = excluded.last_delta_sync_at", + params![server_id, library_scope, epoch_ms], + )?; + Ok(()) + }) + } + + /// Write `artists_last_modified_ms` — watermark for the ID3 path + /// (`getArtists.lastModified`); §2.2.1 background poll keys off + /// this. + pub fn set_artists_last_modified_ms( + &self, + server_id: &str, + library_scope: &str, + last_modified_ms: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, artists_last_modified_ms) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + artists_last_modified_ms = excluded.artists_last_modified_ms", + params![server_id, library_scope, last_modified_ms], + )?; + Ok(()) + }) + } + + /// Write `library_tier` (spec §6.2.2 — `small` / `medium` / `huge` + /// / `unknown`). Drives the adaptive poll interval; PR-3d wires + /// the EWMA loop that picks this. + pub fn set_library_tier( + &self, + server_id: &str, + library_scope: &str, + tier: &str, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, library_tier) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + library_tier = excluded.library_tier", + params![server_id, library_scope, tier], + )?; + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn ensure_creates_row_with_default_cursor() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.ensure("s1", "").unwrap(); + + let cursor = repo.get_initial_sync_cursor("s1", "").unwrap(); + assert_eq!(cursor, Some(json!({})), "DEFAULT must read back as empty object"); + } + + #[test] + fn ensure_is_idempotent() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.ensure("s1", "").unwrap(); + repo.ensure("s1", "").unwrap(); + + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM sync_state", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn get_returns_none_for_missing_row() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + assert_eq!(repo.get_initial_sync_cursor("absent", "").unwrap(), None); + } + + #[test] + fn set_roundtrips_nested_cursor_value() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + let cursor = json!({ + "phase": "ingest_tracks", + "offset": 12_500, + "last_seen_id": "tr_abc", + "filters": { "library_id": "lib-1" }, + }); + repo.set_initial_sync_cursor("s1", "", &cursor).unwrap(); + let got = repo.get_initial_sync_cursor("s1", "").unwrap(); + assert_eq!(got, Some(cursor)); + } + + #[test] + fn set_overwrites_prior_cursor() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_initial_sync_cursor("s1", "", &json!({"offset": 1})).unwrap(); + repo.set_initial_sync_cursor("s1", "", &json!({"offset": 2})).unwrap(); + let got = repo.get_initial_sync_cursor("s1", "").unwrap(); + assert_eq!(got, Some(json!({"offset": 2}))); + } + + #[test] + fn set_preserves_other_columns_on_upsert() { + // The ON CONFLICT clause must only touch the cursor column. Other + // DEFAULT-backed fields stay at their initial values across upserts. + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_initial_sync_cursor("s1", "", &json!({"x": 1})).unwrap(); + + // Mutate a sibling column out-of-band to detect any accidental reset. + store + .with_conn("misc", |c| { + c.execute( + "UPDATE sync_state SET sync_phase = 'ingesting' WHERE server_id = 's1'", + [], + ) + }) + .unwrap(); + + // Second cursor write must not touch sync_phase. + repo.set_initial_sync_cursor("s1", "", &json!({"x": 2})).unwrap(); + let phase: String = store + .with_conn("misc", |c| { + c.query_row( + "SELECT sync_phase FROM sync_state WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(phase, "ingesting"); + } + + #[test] + fn library_scope_separates_rows_per_server() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_initial_sync_cursor("s1", "", &json!({"all": true})).unwrap(); + repo.set_initial_sync_cursor("s1", "lib-1", &json!({"lib": "one"})).unwrap(); + + assert_eq!( + repo.get_initial_sync_cursor("s1", "").unwrap(), + Some(json!({"all": true})) + ); + assert_eq!( + repo.get_initial_sync_cursor("s1", "lib-1").unwrap(), + Some(json!({"lib": "one"})) + ); + } + + // ── PR-3a: capability_flags, sync_phase, watermarks, library_tier ──── + + #[test] + fn capability_flags_roundtrip() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.ensure("s1", "").unwrap(); + assert_eq!(repo.get_capability_flags("s1", "").unwrap(), Some(0)); + repo.set_capability_flags("s1", "", 0x002 | 0x010).unwrap(); + assert_eq!(repo.get_capability_flags("s1", "").unwrap(), Some(0x012)); + } + + #[test] + fn capability_flags_returns_none_for_missing_row() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + assert_eq!(repo.get_capability_flags("absent", "").unwrap(), None); + } + + #[test] + fn capability_flags_set_creates_row_with_other_defaults() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_capability_flags("s1", "", 0x008).unwrap(); + // sync_phase defaulted to 'idle' on the implicit insert. + assert_eq!(repo.get_sync_phase("s1", "").unwrap().as_deref(), Some("idle")); + } + + #[test] + fn sync_phase_default_is_idle_after_ensure() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.ensure("s1", "").unwrap(); + assert_eq!(repo.get_sync_phase("s1", "").unwrap().as_deref(), Some("idle")); + } + + #[test] + fn sync_phase_transitions_through_state_machine_values() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + for phase in ["probing", "initial_sync", "ready", "error", "idle"] { + repo.set_sync_phase("s1", "", phase).unwrap(); + assert_eq!( + repo.get_sync_phase("s1", "").unwrap().as_deref(), + Some(phase) + ); + } + } + + #[test] + fn watermark_setters_preserve_each_other() { + // Each setter must scope its `ON CONFLICT … DO UPDATE` to its own + // column. Set three and read all three back unchanged. + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_server_last_scan_iso("s1", "", Some("2026-05-01T12:00:00Z")).unwrap(); + repo.set_indexes_last_modified_ms("s1", "", 1_700_000_000_000).unwrap(); + repo.set_artists_last_modified_ms("s1", "", 1_700_000_500_000).unwrap(); + + let (iso, idx_ms, art_ms): (Option, Option, Option) = store + .with_conn("misc", |c| { + c.query_row( + "SELECT server_last_scan_iso, indexes_last_modified_ms, artists_last_modified_ms \ + FROM sync_state WHERE server_id = 's1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + }) + .unwrap(); + assert_eq!(iso.as_deref(), Some("2026-05-01T12:00:00Z")); + assert_eq!(idx_ms, Some(1_700_000_000_000)); + assert_eq!(art_ms, Some(1_700_000_500_000)); + } + + #[test] + fn library_tier_roundtrip() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_library_tier("s1", "", "huge").unwrap(); + let tier: String = store + .with_conn("misc", |c| { + c.query_row( + "SELECT library_tier FROM sync_state WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(tier, "huge"); + } + + #[test] + fn n1_bulk_unreliable_defaults_false_and_roundtrips() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.ensure("s1", "").unwrap(); + // DEFAULT 0 reads back as Some(false); existing servers stay N1-eligible. + assert_eq!(repo.get_n1_bulk_unreliable("s1", "").unwrap(), Some(false)); + assert_eq!(repo.get_n1_bulk_unreliable("absent", "").unwrap(), None); + + repo.set_n1_bulk_unreliable("s1", "", true).unwrap(); + assert_eq!(repo.get_n1_bulk_unreliable("s1", "").unwrap(), Some(true)); + repo.set_n1_bulk_unreliable("s1", "", false).unwrap(); + assert_eq!(repo.get_n1_bulk_unreliable("s1", "").unwrap(), Some(false)); + } + + #[test] + fn n1_bulk_unreliable_set_does_not_clobber_cursor() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_initial_sync_cursor("s1", "", &json!({"offset": 7})).unwrap(); + repo.set_n1_bulk_unreliable("s1", "", true).unwrap(); + assert_eq!( + repo.get_initial_sync_cursor("s1", "").unwrap(), + Some(json!({"offset": 7})) + ); + } + + #[test] + fn capability_flags_set_does_not_clobber_cursor() { + // Cross-check: setting flags must not reset + // `initial_sync_cursor_json` back to '{}'. + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_initial_sync_cursor("s1", "", &json!({"offset": 42})).unwrap(); + repo.set_capability_flags("s1", "", 0x002).unwrap(); + assert_eq!( + repo.get_initial_sync_cursor("s1", "").unwrap(), + Some(json!({"offset": 42})) + ); + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/track.rs b/src-tauri/crates/psysonic-library/src/repos/track.rs new file mode 100644 index 00000000..b1889b07 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/track.rs @@ -0,0 +1,980 @@ +use rusqlite::{params, OptionalExtension}; + +use crate::store::{LibraryStore, WriteOpTiming}; + +/// One row of the `track` table — every hot column from spec §5.1 plus +/// `raw_json` (the full normalized SubsonicSong). Sync code (PR-2/PR-3) is +/// expected to project ingested payloads into this shape, not to talk SQL +/// directly. +#[derive(Debug, Clone)] +pub struct TrackRow { + pub server_id: String, + pub id: String, + pub title: String, + pub title_sort: Option, + pub artist: Option, + pub artist_id: Option, + pub album: String, + pub album_id: Option, + pub album_artist: Option, + pub duration_sec: i64, + pub track_number: Option, + pub disc_number: Option, + pub year: Option, + pub genre: Option, + pub suffix: Option, + pub bit_rate: Option, + pub size_bytes: Option, + pub cover_art_id: Option, + pub starred_at: Option, + pub user_rating: Option, + pub play_count: Option, + pub played_at: Option, + pub server_path: Option, + pub library_id: Option, + pub isrc: Option, + pub mbid_recording: Option, + pub bpm: Option, + pub replay_gain_track_db: Option, + pub replay_gain_album_db: Option, + pub content_hash: Option, + pub server_updated_at: Option, + pub server_created_at: Option, + pub deleted: bool, + pub synced_at: i64, + pub raw_json: String, +} + +/// One detected remap during an upsert batch. Sync code can use this +/// to emit `library:tracks-changed { remapped: [{from, to}] }` (spec +/// §6.9) so the UI can refresh open per-track views. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemapEntry { + pub server_id: String, + pub old_id: String, + pub new_id: String, +} + +#[derive(Debug, Clone, Default)] +pub struct RemapStats { + pub remapped: Vec, +} + +pub struct TrackRepository<'a> { + store: &'a LibraryStore, +} + +impl<'a> TrackRepository<'a> { + pub fn new(store: &'a LibraryStore) -> Self { + Self { store } + } + + /// Batch upsert without remap detection. Suitable for generic + /// Subsonic servers where `UnstableTrackIds` is clear (track ids + /// are stable across reindexing). Wrapped in a single transaction. + pub fn upsert_batch(&self, rows: &[TrackRow]) -> Result<(), String> { + self.upsert_batch_with_remap(rows, false).map(|_| ()) + } + + /// IS-3 initial-sync fast path: upsert rows only. Skips §6.9 remap + /// detection and inline canonical linking — both run on delta sync + /// or in a post-ingest canonical pass so 500-row batches stay fast. + pub fn upsert_batch_initial_ingest(&self, rows: &[TrackRow]) -> Result<(), String> { + self.upsert_batch_initial_ingest_timed(rows).map(|_| ()) + } + + pub fn upsert_batch_initial_ingest_timed( + &self, + rows: &[TrackRow], + ) -> Result { + if rows.is_empty() { + return Ok(WriteOpTiming::default()); + } + let (_, timing) = self.store.with_conn_mut_timed("track.upsert_initial_ingest", |conn| { + let tx = conn.transaction()?; + let mut upsert = tx.prepare_cached(UPSERT_SQL)?; + for r in rows { + upsert.execute(params![ + r.server_id, + r.id, + r.title, + r.title_sort, + r.artist, + r.artist_id, + r.album, + r.album_id, + r.album_artist, + r.duration_sec, + r.track_number, + r.disc_number, + r.year, + r.genre, + r.suffix, + r.bit_rate, + r.size_bytes, + r.cover_art_id, + r.starred_at, + r.user_rating, + r.play_count, + r.played_at, + r.server_path, + r.library_id, + r.isrc, + r.mbid_recording, + r.bpm, + r.replay_gain_track_db, + r.replay_gain_album_db, + r.content_hash, + r.server_updated_at, + r.server_created_at, + if r.deleted { 1_i64 } else { 0 }, + r.synced_at, + r.raw_json, + ])?; + } + drop(upsert); + tx.commit()?; + Ok(()) + })?; + Ok(timing) + } + + /// SELECT a single track by `(server_id, id)`. Returns `None` + /// when missing or deleted (`deleted = 1`). Used by + /// `library_get_track` and the offline-path command. + pub fn find_one( + &self, + server_id: &str, + track_id: &str, + ) -> Result, String> { + self.store.with_read_conn(|conn| { + let mut stmt = conn.prepare(SELECT_TRACK_BY_ID)?; + stmt.query_row(params![server_id, track_id], row_to_track_row) + .optional() + }) + } + + /// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs + /// preserve their order in the result; unknown / deleted refs + /// are silently dropped (frontend reads `tracks.length` against + /// `refs.length` to detect partial responses). + pub fn find_batch( + &self, + refs: &[(String, String)], + ) -> Result, String> { + if refs.is_empty() { + return Ok(Vec::new()); + } + self.store.with_read_conn(|conn| { + let mut stmt = conn.prepare(SELECT_TRACK_BY_ID)?; + let mut out: Vec = Vec::with_capacity(refs.len()); + for (server_id, track_id) in refs { + if let Some(row) = stmt + .query_row(params![server_id, track_id], row_to_track_row) + .optional()? + { + out.push(row); + } + } + Ok(out) + }) + } + + /// SELECT every non-deleted track on this album, ordered by + /// `disc_number ASC, track_number ASC` for stable display. + pub fn find_by_album( + &self, + server_id: &str, + album_id: &str, + ) -> Result, String> { + self.store.with_read_conn(|conn| { + let mut stmt = conn.prepare(SELECT_TRACKS_BY_ALBUM)?; + let rows: rusqlite::Result> = stmt + .query_map(params![server_id, album_id], row_to_track_row)? + .collect(); + rows + }) + } + + /// Batch upsert with optional §6.9 id-remap detection. When + /// `unstable_track_ids` is `true`, each incoming row is checked + /// against the existing `track` table for a collision via + /// `content_hash` or `server_path` carrying a different id. On + /// collision, child tables (`track_offline` and the FK-bound + /// extension / fact / artifact / canonical_link tables) are + /// retargeted onto the new id, a `track_id_history` row is + /// recorded, and the old `track` row is deleted — all inside the + /// same SQLite transaction so partial remaps can't leak. + pub fn upsert_batch_with_remap( + &self, + rows: &[TrackRow], + unstable_track_ids: bool, + ) -> Result { + if rows.is_empty() { + return Ok(RemapStats::default()); + } + self.store.with_conn_mut("misc", |conn| { + let tx = conn.transaction()?; + let mut remapped: Vec = Vec::new(); + let mut upsert = tx.prepare_cached(UPSERT_SQL)?; + let mut remap_lookup = if unstable_track_ids { + Some(tx.prepare_cached(REMAP_LOOKUP_SQL)?) + } else { + None + }; + + for r in rows { + // Spec §6.9: detect collision BEFORE the upsert so the + // old id is known. The upsert itself comes next; only + // then do we retarget children to the new id, since + // child tables FK→track(server_id, id) and would refuse + // an UPDATE pointing at an id that doesn't exist yet. + let detected_old: Option = if let Some(ref mut lookup) = remap_lookup { + detect_remap_target_cached(lookup, r)? + } else { + None + }; + + upsert.execute(params![ + r.server_id, + r.id, + r.title, + r.title_sort, + r.artist, + r.artist_id, + r.album, + r.album_id, + r.album_artist, + r.duration_sec, + r.track_number, + r.disc_number, + r.year, + r.genre, + r.suffix, + r.bit_rate, + r.size_bytes, + r.cover_art_id, + r.starred_at, + r.user_rating, + r.play_count, + r.played_at, + r.server_path, + r.library_id, + r.isrc, + r.mbid_recording, + r.bpm, + r.replay_gain_track_db, + r.replay_gain_album_db, + r.content_hash, + r.server_updated_at, + r.server_created_at, + if r.deleted { 1_i64 } else { 0 }, + r.synced_at, + r.raw_json, + ])?; + + if let Some(old_id) = detected_old { + remap_existing_to_new( + &tx, + &r.server_id, + &old_id, + &r.id, + r.content_hash.as_deref(), + r.server_path.as_deref(), + r.synced_at, + )?; + remapped.push(RemapEntry { + server_id: r.server_id.clone(), + old_id, + new_id: r.id.clone(), + }); + } + + // H2 (§5.5A): link this track to its canonical id by its + // strong key (ISRC, else MBID recording). Inline + O(1); + // a no-op for tracks that carry neither. + crate::canonical::link_track( + &tx, + &r.server_id, + &r.id, + r.isrc.as_deref(), + r.mbid_recording.as_deref(), + r.synced_at, + )?; + } + + drop(upsert); + drop(remap_lookup); + + tx.commit()?; + Ok(RemapStats { remapped }) + }) + } +} + +const REMAP_LOOKUP_SQL: &str = r#" +SELECT id FROM track + WHERE server_id = ?1 + AND deleted = 0 + AND id != ?2 + AND ( + (?3 IS NOT NULL AND content_hash = ?3) + OR (?4 IS NOT NULL AND server_path = ?4) + ) + LIMIT 1 +"#; + +/// Run the `SELECT old.id` half of §6.9 — returns `Some(old_id)` if a +/// non-deleted row with a different id on this server matches the +/// incoming row's `content_hash` or `server_path`. +fn detect_remap_target_cached( + lookup: &mut rusqlite::Statement<'_>, + incoming: &TrackRow, +) -> rusqlite::Result> { + // Empty-string sentinels are *not* eligible — spec §6.9 explicitly + // excludes them so the file-tree default never collides. + let hash = incoming.content_hash.as_deref().filter(|s| !s.is_empty()); + let path = incoming.server_path.as_deref().filter(|s| !s.is_empty()); + if hash.is_none() && path.is_none() { + return Ok(None); + } + lookup + .query_row( + params![incoming.server_id, incoming.id, hash, path], + |row| row.get::<_, String>(0), + ) + .optional() +} + +/// Run the §6.9 retarget half — UPDATE every FK-bound child to the +/// new id, INSERT into `track_id_history`, DELETE the old `track` row. +/// `track_offline` has no FK to `track` (spec §5.14) but still needs +/// its row retargeted so the cached file resolves under the new id. +fn remap_existing_to_new( + tx: &rusqlite::Transaction<'_>, + server_id: &str, + old_id: &str, + new_id: &str, + content_hash: Option<&str>, + server_path: Option<&str>, + remapped_at: i64, +) -> rusqlite::Result<()> { + for table in [ + "track_offline", + "track_extension", + "track_fact", + "track_artifact", + "track_canonical_link", + ] { + tx.execute( + &format!( + "UPDATE {table} SET track_id = ?1 \ + WHERE server_id = ?2 AND track_id = ?3" + ), + params![new_id, server_id, old_id], + )?; + } + tx.execute( + "INSERT INTO track_id_history \ + (server_id, old_id, new_id, content_hash, server_path, remapped_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(server_id, old_id) DO UPDATE SET \ + new_id = excluded.new_id, \ + content_hash = excluded.content_hash, \ + server_path = excluded.server_path, \ + remapped_at = excluded.remapped_at", + params![server_id, old_id, new_id, content_hash, server_path, remapped_at], + )?; + tx.execute( + "DELETE FROM track WHERE server_id = ?1 AND id = ?2", + params![server_id, old_id], + )?; + Ok(()) +} + +/// Column list mirroring the `track` schema (§5.1) — used by every +/// `SELECT … FROM track` so the row-mapper can index by position. +const TRACK_COLUMNS: &str = "\ + server_id, id, title, title_sort, artist, artist_id, album, album_id, \ + album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \ + bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, \ + played_at, server_path, library_id, isrc, mbid_recording, bpm, \ + replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at, \ + server_created_at, deleted, synced_at, raw_json"; + +const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \ + album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \ + bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \ + server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \ + content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \ + FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0"; + +const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \ + album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \ + bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \ + server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \ + content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \ + FROM track WHERE server_id = ?1 AND album_id = ?2 AND deleted = 0 \ + ORDER BY disc_number ASC NULLS LAST, track_number ASC NULLS LAST, id ASC"; + +pub(crate) fn track_columns() -> &'static str { + TRACK_COLUMNS +} + +pub(crate) fn row_to_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(TrackRow { + server_id: row.get(0)?, + id: row.get(1)?, + title: row.get(2)?, + title_sort: row.get(3)?, + artist: row.get(4)?, + artist_id: row.get(5)?, + album: row.get(6)?, + album_id: row.get(7)?, + album_artist: row.get(8)?, + duration_sec: row.get(9)?, + track_number: row.get(10)?, + disc_number: row.get(11)?, + year: row.get(12)?, + genre: row.get(13)?, + suffix: row.get(14)?, + bit_rate: row.get(15)?, + size_bytes: row.get(16)?, + cover_art_id: row.get(17)?, + starred_at: row.get(18)?, + user_rating: row.get(19)?, + play_count: row.get(20)?, + played_at: row.get(21)?, + server_path: row.get(22)?, + library_id: row.get(23)?, + isrc: row.get(24)?, + mbid_recording: row.get(25)?, + bpm: row.get(26)?, + replay_gain_track_db: row.get(27)?, + replay_gain_album_db: row.get(28)?, + content_hash: row.get(29)?, + server_updated_at: row.get(30)?, + server_created_at: row.get(31)?, + deleted: row.get::<_, i64>(32)? != 0, + synced_at: row.get(33)?, + raw_json: row.get(34)?, + }) +} + +const UPSERT_SQL: &str = r#" +INSERT INTO track ( + server_id, id, title, title_sort, artist, artist_id, album, album_id, + album_artist, duration_sec, track_number, disc_number, year, genre, suffix, + bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, + played_at, server_path, library_id, isrc, mbid_recording, bpm, + replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at, + server_created_at, deleted, synced_at, raw_json +) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, + ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, + ?33, ?34, ?35 +) +ON CONFLICT(server_id, id) DO UPDATE SET + title = excluded.title, + title_sort = excluded.title_sort, + artist = excluded.artist, + artist_id = excluded.artist_id, + album = excluded.album, + album_id = excluded.album_id, + album_artist = excluded.album_artist, + duration_sec = excluded.duration_sec, + track_number = excluded.track_number, + disc_number = excluded.disc_number, + year = excluded.year, + genre = excluded.genre, + suffix = excluded.suffix, + bit_rate = excluded.bit_rate, + size_bytes = excluded.size_bytes, + cover_art_id = excluded.cover_art_id, + starred_at = excluded.starred_at, + user_rating = excluded.user_rating, + play_count = excluded.play_count, + played_at = excluded.played_at, + server_path = excluded.server_path, + library_id = excluded.library_id, + isrc = excluded.isrc, + mbid_recording = excluded.mbid_recording, + bpm = excluded.bpm, + replay_gain_track_db = excluded.replay_gain_track_db, + replay_gain_album_db = excluded.replay_gain_album_db, + -- E2: never let a sync (which passes NULL content_hash) clobber the + -- playback-derived md5_16kb written via library_patch_track / the analysis + -- bridge. A non-empty incoming hash still wins. + content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash), + server_updated_at = excluded.server_updated_at, + server_created_at = excluded.server_created_at, + deleted = excluded.deleted, + synced_at = excluded.synced_at, + raw_json = excluded.raw_json +"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn row(server: &str, id: &str, title: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: title.into(), + title_sort: None, + artist: Some("The Artist".into()), + artist_id: Some("ar1".into()), + album: "An Album".into(), + album_id: Some("al1".into()), + album_artist: Some("The Artist".into()), + duration_sec: 240, + track_number: Some(3), + disc_number: Some(1), + year: Some(2024), + genre: Some("Ambient".into()), + suffix: Some("flac".into()), + bit_rate: Some(1000), + size_bytes: Some(32_000_000), + cover_art_id: Some("cv1".into()), + starred_at: None, + user_rating: None, + play_count: Some(0), + played_at: None, + server_path: Some("Artist/Album/03.flac".into()), + library_id: Some("lib-1".into()), + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: Some("hash-abc".into()), + server_updated_at: Some(1_700_000_000), + server_created_at: Some(1_699_000_000), + deleted: false, + synced_at: 1_700_000_500, + raw_json: r#"{"id":"t1"}"#.into(), + } + } + + #[test] + fn resync_does_not_clobber_playback_content_hash() { + // E2 safety property: a sync (which passes content_hash = None) must + // never wipe the playback-derived md5 written via patch / the bridge. + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + + let mut initial = row("s1", "t1", "First"); + initial.content_hash = None; + repo.upsert_batch(&[initial]).unwrap(); + + // Playback records the content fingerprint. + store + .with_conn("misc", |c| { + c.execute( + "UPDATE track SET content_hash = 'playback-md5' WHERE server_id='s1' AND id='t1'", + [], + ) + }) + .unwrap(); + + let read = |store: &LibraryStore| -> Option { + store + .with_conn("misc", |c| { + c.query_row( + "SELECT content_hash FROM track WHERE server_id='s1' AND id='t1'", + [], + |r| r.get(0), + ) + }) + .unwrap() + }; + + // Resync with a NULL hash preserves the playback value. + let mut resync = row("s1", "t1", "First (resynced)"); + resync.content_hash = None; + repo.upsert_batch(&[resync]).unwrap(); + assert_eq!(read(&store).as_deref(), Some("playback-md5")); + + // A non-empty incoming hash still wins. + let mut with_hash = row("s1", "t1", "First"); + with_hash.content_hash = Some("server-hash".into()); + repo.upsert_batch(&[with_hash]).unwrap(); + assert_eq!(read(&store).as_deref(), Some("server-hash")); + } + + #[test] + fn upsert_inserts_new_rows() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "First"), row("s1", "t2", "Second")]) + .unwrap(); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 2); + } + + #[test] + fn upsert_updates_existing_rows() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "Original")]).unwrap(); + + let mut updated = row("s1", "t1", "Updated"); + updated.bpm = Some(128); + updated.starred_at = Some(1_700_000_999); + repo.upsert_batch(&[updated]).unwrap(); + + let (title, bpm, starred): (String, Option, Option) = store + .with_conn("misc", |c| { + c.query_row( + "SELECT title, bpm, starred_at FROM track WHERE server_id='s1' AND id='t1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + }) + .unwrap(); + assert_eq!(title, "Updated"); + assert_eq!(bpm, Some(128)); + assert_eq!(starred, Some(1_700_000_999)); + + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 1, "upsert must not duplicate the row"); + } + + #[test] + fn upsert_empty_batch_is_noop() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[]).unwrap(); + } + + #[test] + fn upsert_keeps_server_scope_separate() { + // Same `id` on two different servers must produce two rows + // (PRIMARY KEY is composite). + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "From S1"), row("s2", "t1", "From S2")]) + .unwrap(); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 2); + } + + #[test] + fn upsert_populates_fts_via_trigger() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "Aurora Boreal")]).unwrap(); + let fts_hit: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM track_fts WHERE track_fts MATCH 'aurora'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(fts_hit, 1); + } + + #[test] + fn upsert_update_refreshes_fts_via_trigger() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "Old Title")]).unwrap(); + repo.upsert_batch(&[row("s1", "t1", "Brand New Title")]).unwrap(); + + let old_hit: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM track_fts WHERE track_fts MATCH 'old'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + let new_hit: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM track_fts WHERE track_fts MATCH 'brand'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(old_hit, 0, "delete-trigger must drop the stale FTS row"); + assert_eq!(new_hit, 1); + } + + #[test] + fn initial_ingest_batch_skips_remap_and_canonical() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + let rows: Vec = (0..500) + .map(|i| { + let mut r = row("s1", &format!("t{i:04}"), &format!("Track {i:04}")); + r.server_path = Some(format!("/music/track{i:04}.flac")); + r.isrc = Some(format!("USRC{i:06}")); + r.raw_json = format!(r#"{{"id":"t{i:04}","payload":"#) + + &"x".repeat(512) + + r#""}"#; + r + }) + .collect(); + let start = std::time::Instant::now(); + repo.upsert_batch_initial_ingest(&rows).unwrap(); + let elapsed = start.elapsed(); + assert!( + elapsed < std::time::Duration::from_millis(500), + "initial ingest batch(500) took {elapsed:?}" + ); + } + + #[test] + fn upsert_500_rows_completes_well_under_perf_budget() { + // Spec §5.1 / AC A3: `upsert_batch` should land 500 rows under 100ms + // typical. The CI threshold is 5× that to absorb slow runners and + // the difference between debug and release; any regression past it + // is real signal. + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + let rows: Vec = (0..500) + .map(|i| row("s1", &format!("t{i:04}"), &format!("Track {i:04}"))) + .collect(); + + let start = std::time::Instant::now(); + repo.upsert_batch(&rows).unwrap(); + let elapsed = start.elapsed(); + + let stored: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(stored, 500); + + assert!( + elapsed < std::time::Duration::from_millis(500), + "upsert_batch(500 rows) took {elapsed:?}; AC A3 target is <100ms typical, \ + test fails past 5× that" + ); + } + + // ── PR-3b: §6.9 id remap detection ──────────────────────────────────── + + fn row_with_id_hash(server: &str, id: &str, hash: &str, path: &str) -> TrackRow { + let mut r = row(server, id, "Title"); + r.content_hash = if hash.is_empty() { None } else { Some(hash.into()) }; + r.server_path = if path.is_empty() { None } else { Some(path.into()) }; + r + } + + #[test] + fn remap_disabled_never_records_history_even_on_hash_collision() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row_with_id_hash("s1", "tr_old", "deadbeef", "")]) + .unwrap(); + + // Generic Subsonic path: caller passes `unstable_track_ids = false`. + let stats = repo + .upsert_batch_with_remap( + &[row_with_id_hash("s1", "tr_new", "deadbeef", "")], + false, + ) + .unwrap(); + assert!(stats.remapped.is_empty()); + + let track_count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + let hist_count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_id_history", [], |r| r.get(0))) + .unwrap(); + assert_eq!(track_count, 2, "both ids coexist when remap is off"); + assert_eq!(hist_count, 0); + } + + #[test] + fn remap_via_content_hash_replaces_old_row_and_records_history() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + // Seed with the old id; child tables get a row each that must + // follow the remap. + repo.upsert_batch(&[row_with_id_hash("s1", "tr_old", "deadbeef", "/path/x.flac")]) + .unwrap(); + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO track_offline \ + (server_id, track_id, local_path, cached_at) \ + VALUES ('s1', 'tr_old', '/local/x.flac', 1)", + [], + )?; + c.execute( + "INSERT INTO track_extension \ + (server_id, track_id, kind, payload, updated_at) \ + VALUES ('s1', 'tr_old', 'user_note', X'7B7D', 1)", + [], + )?; + Ok(()) + }) + .unwrap(); + + let stats = repo + .upsert_batch_with_remap( + &[row_with_id_hash("s1", "tr_new", "deadbeef", "/path/x.flac")], + true, + ) + .unwrap(); + assert_eq!(stats.remapped.len(), 1); + assert_eq!(stats.remapped[0].old_id, "tr_old"); + assert_eq!(stats.remapped[0].new_id, "tr_new"); + + // Old track row gone, new one in place. + let ids: Vec = store + .with_conn("misc", |c| { + let mut stmt = c.prepare("SELECT id FROM track WHERE server_id = 's1'")?; + let r: rusqlite::Result> = stmt.query_map([], |r| r.get(0))?.collect(); + r + }) + .unwrap(); + assert_eq!(ids, vec!["tr_new"]); + + // Child tables follow the new id. + let offline_id: String = store + .with_conn("misc", |c| { + c.query_row( + "SELECT track_id FROM track_offline WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(offline_id, "tr_new"); + let ext_id: String = store + .with_conn("misc", |c| { + c.query_row( + "SELECT track_id FROM track_extension WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(ext_id, "tr_new"); + + // History row recorded. + let hist = crate::repos::TrackIdHistoryRepository::new(&store); + assert_eq!( + hist.lookup_new_id("s1", "tr_old").unwrap().as_deref(), + Some("tr_new") + ); + } + + #[test] + fn remap_via_server_path_only_works_when_hash_missing() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row_with_id_hash("s1", "tr_old", "", "/path/y.mp3")]) + .unwrap(); + // Server only ships server_path on the new row — no hash yet. + let stats = repo + .upsert_batch_with_remap( + &[row_with_id_hash("s1", "tr_new", "", "/path/y.mp3")], + true, + ) + .unwrap(); + assert_eq!(stats.remapped.len(), 1, "path-based remap must trigger"); + } + + #[test] + fn remap_skips_when_neither_hash_nor_path_present() { + // Defensive: empty-string sentinels must not cause spurious + // remaps across unrelated rows that happen to lack hash + path. + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row_with_id_hash("s1", "tr_old", "", "")]).unwrap(); + let stats = repo + .upsert_batch_with_remap(&[row_with_id_hash("s1", "tr_new", "", "")], true) + .unwrap(); + assert!(stats.remapped.is_empty()); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 2, "both rows kept; identity-less rows can't shadow"); + } + + #[test] + fn remap_is_noop_when_new_id_matches_existing_id() { + // Standard delta-sync: same id, same hash. Must not trigger + // remap (SELECT excludes id = T.id). + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row_with_id_hash("s1", "tr_1", "h", "/p")]).unwrap(); + let stats = repo + .upsert_batch_with_remap(&[row_with_id_hash("s1", "tr_1", "h", "/p")], true) + .unwrap(); + assert!(stats.remapped.is_empty()); + } + + // ── H2: canonical linking on the upsert path (§5.5A) ─────────────── + + #[test] + fn upsert_links_track_to_canonical_by_isrc() { + let store = LibraryStore::open_in_memory(); + let mut r = row("s1", "t1", "Title"); + r.isrc = Some("USRC100".into()); + TrackRepository::new(&store).upsert_batch(&[r]).unwrap(); + let cid: Option = store + .with_conn("misc", |c| { + c.query_row( + "SELECT canonical_id FROM track_canonical_link \ + WHERE server_id='s1' AND track_id='t1'", + [], + |r| r.get(0), + ) + .optional() + }) + .unwrap(); + assert_eq!(cid.as_deref(), Some("isrc:USRC100")); + } + + #[test] + fn upsert_shares_canonical_across_servers_with_same_isrc() { + let store = LibraryStore::open_in_memory(); + let mut a = row("s1", "t1", "T"); + a.isrc = Some("USRC200".into()); + let mut b = row("s2", "t9", "T"); + b.isrc = Some("USRC200".into()); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let distinct: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(DISTINCT canonical_id) FROM track_canonical_link", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(distinct, 1, "same ISRC on two servers → one canonical id"); + } + + #[test] + fn upsert_without_strong_key_creates_no_canonical_link() { + let store = LibraryStore::open_in_memory(); + // `row(...)` leaves isrc / mbid_recording as None. + TrackRepository::new(&store) + .upsert_batch(&[row("s1", "t1", "T")]) + .unwrap(); + let count: i64 = store + .with_conn("misc", |c| { + c.query_row("SELECT COUNT(*) FROM track_canonical_link", [], |r| r.get(0)) + }) + .unwrap(); + assert_eq!(count, 0); + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/track_id_history.rs b/src-tauri/crates/psysonic-library/src/repos/track_id_history.rs new file mode 100644 index 00000000..cf4f1904 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/track_id_history.rs @@ -0,0 +1,123 @@ +//! Read-side helper for `track_id_history`. Writes live inside the +//! upsert transaction in `TrackRepository::upsert_batch_with_remap` +//! (spec §6.9) — keeping them there avoids splitting the remap across +//! two SQLite transactions, which would leave a window where child +//! tables point at a removed track id. + +use rusqlite::{params, OptionalExtension}; + +use crate::store::LibraryStore; + +pub struct TrackIdHistoryRepository<'a> { + store: &'a LibraryStore, +} + +impl<'a> TrackIdHistoryRepository<'a> { + pub fn new(store: &'a LibraryStore) -> Self { + Self { store } + } + + /// Resolve a remapped id forward to the current server id, if a + /// remap was recorded. Returns `None` when no row exists. Analysis + /// cache lookups (Phase E) go through this so cached waveform / + /// loudness rows stay reachable after the server's id space shifts. + pub fn lookup_new_id( + &self, + server_id: &str, + old_id: &str, + ) -> Result, String> { + self.store.with_conn("misc", |conn| { + conn.query_row( + "SELECT new_id FROM track_id_history \ + WHERE server_id = ?1 AND old_id = ?2", + params![server_id, old_id], + |row| row.get::<_, String>(0), + ) + .optional() + }) + } + + /// Count the rows recorded for this server — used by tests and by + /// post-sync diagnostics (Settings „Library index" panel later). + pub fn count_for_server(&self, server_id: &str) -> Result { + self.store.with_conn("misc", |conn| { + conn.query_row( + "SELECT COUNT(*) FROM track_id_history WHERE server_id = ?1", + params![server_id], + |row| row.get(0), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::params; + + fn seed_history(store: &LibraryStore, rows: &[(&str, &str, &str)]) { + store + .with_conn("misc", |c| { + for (server, old, new) in rows { + c.execute( + "INSERT INTO track_id_history \ + (server_id, old_id, new_id, content_hash, server_path, remapped_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![server, old, new, "hash", "path", 1_700_000_000_i64], + )?; + } + Ok(()) + }) + .unwrap(); + } + + #[test] + fn lookup_returns_none_when_no_remap_recorded() { + let store = LibraryStore::open_in_memory(); + let repo = TrackIdHistoryRepository::new(&store); + assert_eq!(repo.lookup_new_id("s1", "tr_old").unwrap(), None); + } + + #[test] + fn lookup_finds_new_id_for_recorded_remap() { + let store = LibraryStore::open_in_memory(); + seed_history(&store, &[("s1", "tr_old", "tr_new")]); + let repo = TrackIdHistoryRepository::new(&store); + assert_eq!( + repo.lookup_new_id("s1", "tr_old").unwrap().as_deref(), + Some("tr_new") + ); + } + + #[test] + fn lookup_scopes_by_server_id() { + let store = LibraryStore::open_in_memory(); + seed_history(&store, &[("s1", "tr_x", "tr_new1"), ("s2", "tr_x", "tr_new2")]); + let repo = TrackIdHistoryRepository::new(&store); + assert_eq!( + repo.lookup_new_id("s1", "tr_x").unwrap().as_deref(), + Some("tr_new1") + ); + assert_eq!( + repo.lookup_new_id("s2", "tr_x").unwrap().as_deref(), + Some("tr_new2") + ); + } + + #[test] + fn count_for_server_scopes_correctly() { + let store = LibraryStore::open_in_memory(); + seed_history( + &store, + &[ + ("s1", "a", "b"), + ("s1", "c", "d"), + ("s2", "e", "f"), + ], + ); + let repo = TrackIdHistoryRepository::new(&store); + assert_eq!(repo.count_for_server("s1").unwrap(), 2); + assert_eq!(repo.count_for_server("s2").unwrap(), 1); + assert_eq!(repo.count_for_server("absent").unwrap(), 0); + } +} diff --git a/src-tauri/crates/psysonic-library/src/runtime.rs b/src-tauri/crates/psysonic-library/src/runtime.rs new file mode 100644 index 00000000..97b9fc65 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/runtime.rs @@ -0,0 +1,258 @@ +//! `LibraryRuntime` — Tauri State shared by every library command. +//! +//! PR-5a held only the store. PR-5b extends with the per-server sync +//! session map (credentials live in process memory only — same trust +//! boundary as today's WebView-held passwords), the current playback +//! hint, an `Option` for in-flight start/cancel, and +//! a long-lived cancellation flag for the background-scheduler task +//! the top crate spawns in `setup()`. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use tokio::sync::Notify; + +use crate::store::LibraryStore; +use crate::sync::bandwidth::PlaybackHint; + +/// Per-server credentials cache for the sync runner. Lives only in +/// `LibraryRuntime` process memory; `library_sync_clear_session` +/// removes it on logout / index disable / purge. +#[derive(Debug, Clone)] +pub struct SyncSession { + pub server_id: String, + pub base_url: String, + pub username: String, + pub password: String, + /// Navidrome native API bearer cached from the `/auth/login` + /// response at bind time. `None` when the server isn't Navidrome + /// or the optional Navidrome auth failed (Subsonic-only path). + pub navidrome_token: Option, + pub library_scope: Option, +} + +/// Currently-running initial / delta / manual integrity job +/// metadata. Holding the `SyncSupervisor` in the mutex (as the +/// PR-5 kickoff sketch suggested) would block `library_sync_cancel` +/// behind whoever's running the supervisor's join — instead we keep +/// just the cancel handle + identity, and the job-orchestrator task +/// owns the supervisor / receiver / join. +#[derive(Debug, Clone)] +pub struct CurrentJob { + pub job_id: String, + pub server_id: String, + /// `"initial_sync"` or `"delta_sync"`. + pub kind: String, + pub cancel: Arc, + /// Signaled when this job's runner task finishes (success, error, or cancel). + pub done: Arc, +} + +pub struct LibraryRuntime { + pub store: Arc, + /// Per-`server_id` sync session. Mutex over a `HashMap` — single + /// writer at a time is fine for the command surface; the + /// background scheduler tick reads a snapshot. + pub sync_sessions: Mutex>, + pub playback_hint: Mutex, + /// Currently running initial / delta / manual integrity job, if + /// any. `library_sync_start` populates, `library_sync_cancel` + /// trips `cancel`; the orchestrator task clears the slot when + /// the job's `join` returns. + pub current_job: Mutex>, + /// Top-crate scheduler tick task watches this flag; set true on + /// app shutdown / library index disabled. + pub scheduler_cancel: Arc, + /// Latest `library_live_search` epoch from the UI — stale commands + /// skip FTS when a newer keystroke generation was registered. + live_search_epoch: AtomicU64, +} + +impl LibraryRuntime { + pub fn new(store: Arc) -> Self { + Self { + store, + sync_sessions: Mutex::new(HashMap::new()), + playback_hint: Mutex::new(PlaybackHint::default()), + current_job: Mutex::new(None), + scheduler_cancel: Arc::new(AtomicBool::new(false)), + live_search_epoch: AtomicU64::new(0), + } + } + + /// UI bumps `epoch` on every debounced search start / cancel. + pub fn register_live_search_epoch(&self, epoch: u64) { + let _ = self.live_search_epoch.fetch_max(epoch, Ordering::SeqCst); + } + + pub fn live_search_still_current(&self, epoch: u64) -> bool { + self.live_search_epoch.load(Ordering::Acquire) == epoch + } + + pub fn set_current_job(&self, job: CurrentJob) { + if let Ok(mut slot) = self.current_job.lock() { + // Best-effort cancel any in-flight job before we replace. + if let Some(prev) = slot.as_ref() { + prev.cancel.store(true, std::sync::atomic::Ordering::SeqCst); + } + *slot = Some(job); + } + } + + pub fn current_job(&self) -> Option { + self.current_job.lock().ok().and_then(|s| s.clone()) + } + + pub fn clear_current_job_if_matches(&self, job_id: &str) { + if let Ok(mut slot) = self.current_job.lock() { + if slot.as_ref().is_some_and(|j| j.job_id == job_id) { + *slot = None; + } + } + } + + pub fn cancel_current_job(&self) -> bool { + if let Ok(slot) = self.current_job.lock() { + if let Some(job) = slot.as_ref() { + job.cancel.store(true, std::sync::atomic::Ordering::SeqCst); + return true; + } + } + false + } + + /// Snapshot all bound sessions — used by the scheduler tick task + /// in the top crate so it doesn't hold the mutex across an `await`. + pub fn snapshot_sessions(&self) -> Vec { + self.sync_sessions + .lock() + .map(|sessions| sessions.values().cloned().collect()) + .unwrap_or_default() + } + + pub fn get_session(&self, server_id: &str) -> Option { + self.sync_sessions + .lock() + .ok() + .and_then(|s| s.get(server_id).cloned()) + } + + pub fn set_session(&self, session: SyncSession) { + if let Ok(mut sessions) = self.sync_sessions.lock() { + sessions.insert(session.server_id.clone(), session); + } + } + + pub fn clear_session(&self, server_id: &str) { + if let Ok(mut sessions) = self.sync_sessions.lock() { + sessions.remove(server_id); + } + } + + pub fn current_playback_hint(&self) -> PlaybackHint { + self.playback_hint + .lock() + .map(|h| *h) + .unwrap_or_default() + } + + pub fn set_playback_hint(&self, hint: PlaybackHint) { + if let Ok(mut h) = self.playback_hint.lock() { + *h = hint; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_session(server_id: &str) -> SyncSession { + SyncSession { + server_id: server_id.into(), + base_url: "https://nas.example.com".into(), + username: "u".into(), + password: "p".into(), + navidrome_token: None, + library_scope: None, + } + } + + #[test] + fn new_runtime_has_empty_sessions_and_idle_hint() { + let store = Arc::new(LibraryStore::open_in_memory()); + let rt = LibraryRuntime::new(store); + assert!(rt.snapshot_sessions().is_empty()); + assert_eq!(rt.current_playback_hint(), PlaybackHint::Idle); + assert!(!rt + .scheduler_cancel + .load(std::sync::atomic::Ordering::SeqCst)); + } + + #[test] + fn set_and_get_session_roundtrip() { + let store = Arc::new(LibraryStore::open_in_memory()); + let rt = LibraryRuntime::new(store); + rt.set_session(sample_session("s1")); + let got = rt.get_session("s1").unwrap(); + assert_eq!(got.base_url, "https://nas.example.com"); + assert_eq!(got.username, "u"); + } + + #[test] + fn clear_session_removes_one_server_only() { + let store = Arc::new(LibraryStore::open_in_memory()); + let rt = LibraryRuntime::new(store); + rt.set_session(sample_session("s1")); + rt.set_session(sample_session("s2")); + rt.clear_session("s1"); + assert!(rt.get_session("s1").is_none()); + assert!(rt.get_session("s2").is_some()); + } + + #[test] + fn snapshot_returns_clones_so_lock_drops_after_call() { + let store = Arc::new(LibraryStore::open_in_memory()); + let rt = LibraryRuntime::new(store); + rt.set_session(sample_session("s1")); + let snap = rt.snapshot_sessions(); + // Should be free to mutate after the snapshot. + rt.set_session(sample_session("s2")); + assert_eq!(snap.len(), 1); + assert_eq!(rt.snapshot_sessions().len(), 2); + } + + #[test] + fn playback_hint_default_is_idle_and_setter_updates() { + let store = Arc::new(LibraryStore::open_in_memory()); + let rt = LibraryRuntime::new(store); + assert_eq!(rt.current_playback_hint(), PlaybackHint::Idle); + rt.set_playback_hint(PlaybackHint::Playing); + assert_eq!(rt.current_playback_hint(), PlaybackHint::Playing); + rt.set_playback_hint(PlaybackHint::PrefetchActive); + assert_eq!(rt.current_playback_hint(), PlaybackHint::PrefetchActive); + } + + #[tokio::test] + async fn job_done_notify_one_survives_early_signal_before_await() { + let done = Arc::new(Notify::new()); + done.notify_one(); + tokio::time::timeout(std::time::Duration::from_millis(50), done.notified()) + .await + .expect("notify_one must store a permit for a later waiter"); + } + + #[tokio::test] + async fn job_done_notify_waiters_loses_early_signal_before_await() { + let done = Arc::new(Notify::new()); + done.notify_waiters(); + let waited = tokio::time::timeout(std::time::Duration::from_millis(20), done.notified()) + .await + .is_ok(); + assert!( + !waited, + "notify_waiters must not store a permit — resync drain uses notify_one instead" + ); + } +} diff --git a/src-tauri/crates/psysonic-library/src/search.rs b/src-tauri/crates/psysonic-library/src/search.rs new file mode 100644 index 00000000..f0a12466 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/search.rs @@ -0,0 +1,338 @@ +//! FTS5-backed track search. Skeleton landed in PR-1a — the multi-server + +//! libraryScope + bm25 ranking shape from spec §5.9 will be filled in by the +//! sync / search PRs. + +use rusqlite::params; + +use crate::store::LibraryStore; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrackHit { + pub server_id: String, + pub id: String, + pub title: String, + pub artist: Option, + pub album: String, +} + +/// Run a single-server FTS5 match against `track_fts`, returning rows in +/// bm25 order. `query` is passed straight to FTS5 — callers are expected to +/// sanitise / quote user input (see §5.13.5: parameterised only). +pub fn search_tracks( + store: &LibraryStore, + server_id: &str, + query: &str, + limit: i64, +) -> Result, String> { + if !fts_query_meets_min_len(query) { + return Ok(Vec::new()); + } + let fts = fts_track_match_query(query).ok_or_else(|| "empty query".to_string())?; + store.with_read_conn(|conn| { + let mut stmt = conn.prepare( + r#" + SELECT t.server_id, t.id, t.title, t.artist, t.album + FROM track_fts f + JOIN track t ON t.rowid = f.rowid + WHERE track_fts MATCH ?1 + AND t.server_id = ?2 + AND t.deleted = 0 + ORDER BY bm25(track_fts) + LIMIT ?3 + "#, + )?; + let rows = stmt + .query_map(params![fts, server_id, limit], |r| { + Ok(TrackHit { + server_id: r.get(0)?, + id: r.get(1)?, + title: r.get(2)?, + artist: r.get(3)?, + album: r.get(4)?, + }) + })? + .collect::>>()?; + Ok(rows) + }) +} + +// ── shared search SQL helpers (Advanced Search §5.13 + cross-server §5.5B) ── + +/// Hard ceiling on a single search page — keeps the FTS5 p95 budget (§5.9). +/// Callers clamp their requested `limit` into `1..=PAGE_LIMIT_MAX`. +pub(crate) const PAGE_LIMIT_MAX: u32 = 500; + +/// Local FTS is skipped below this length — single-character queries (e.g. Cyrillic +/// «а», Latin «a») match huge fractions of a large library and bm25+LIMIT can +/// take tens of seconds (§5.9: no heavy work on every keystroke). +pub const LOCAL_FTS_MIN_QUERY_CHARS: usize = 2; + +/// True when `raw` has enough graphemes for a scoped FTS MATCH. +pub fn fts_query_meets_min_len(raw: &str) -> bool { + raw.trim().chars().count() >= LOCAL_FTS_MIN_QUERY_CHARS +} + +/// Build a safe FTS5 MATCH string: each whitespace token is quoted (and its +/// internal `"` doubled) so arbitrary user input can't trip FTS5 query +/// syntax. Tokens are implicitly AND-ed. `None` when the input has no tokens. +pub(crate) fn fts_query(raw: &str) -> Option { + let tokens = fts_token_expr(raw)?; + Some(tokens) +} + +/// Token expression only (`"a" "b"`), shared by column-scoped builders. +pub(crate) fn fts_token_expr(raw: &str) -> Option { + fts_token_expr_with(raw, false) +} + +/// Prefix token expression (`"a"* "b"*`) for Live Search as-you-type matching. +pub(crate) fn fts_prefix_token_expr(raw: &str) -> Option { + fts_token_expr_with(raw, true) +} + +fn fts_token_expr_with(raw: &str, prefix: bool) -> Option { + let tokens: Vec = raw + .split_whitespace() + .map(|t| { + let quoted = format!("\"{}\"", t.replace('"', "\"\"")); + if prefix { + format!("{quoted}*") + } else { + quoted + } + }) + .collect(); + if tokens.is_empty() { + None + } else { + Some(tokens.join(" ")) + } +} + +/// Column-scoped prefix match (`artist : "met"*` → Metallica). +pub(crate) fn fts_column_prefix_query(column: &str, raw: &str) -> Option { + fts_prefix_token_expr(raw).map(|tokens| format!("{column} : {tokens}")) +} + +/// Prefix variants for Live Search / Advanced Search as-you-type matching. +pub(crate) fn fts_track_prefix_match_query(raw: &str) -> Option { + fts_prefix_token_expr(raw).map(|tokens| { + ["title", "artist", "album", "album_artist"] + .iter() + .map(|col| format!("{col} : {tokens}")) + .collect::>() + .join(" OR ") + }) +} + +pub(crate) fn fts_album_prefix_match_query(raw: &str) -> Option { + fts_prefix_token_expr(raw).map(|tokens| { + format!("(album : {tokens} OR album_artist : {tokens})") + }) +} + +/// Song / track entity: match primary display fields (excludes `genre` to cut +/// noise and FTS fan-out on large libraries). +pub(crate) fn fts_track_match_query(raw: &str) -> Option { + fts_token_expr(raw).map(|tokens| { + ["title", "artist", "album", "album_artist"] + .iter() + .map(|col| format!("{col} : {tokens}")) + .collect::>() + .join(" OR ") + }) +} + +/// Project the `track` hot columns prefixed with `alias` (e.g. `t.title`), +/// in `repos::row_to_track_row`'s positional order so the Advanced Search / +/// cross-server builders can reuse the shared row mapper. +/// Effective library id for scoped search — hot column first, then common +/// OpenSubsonic / Navidrome keys in `raw_json` (legacy rows may only have JSON). +pub(crate) fn library_scope_match_sql(table_alias: &str) -> String { + format!( + "COALESCE(NULLIF({table_alias}.library_id, ''), \ + CAST(json_extract({table_alias}.raw_json, '$.libraryId') AS TEXT), \ + CAST(json_extract({table_alias}.raw_json, '$.library_id') AS TEXT), \ + CAST(json_extract({table_alias}.raw_json, '$.musicFolderId') AS TEXT))" + ) +} + +pub(crate) fn library_scope_equals_sql(table_alias: &str) -> String { + format!("{} = ?", library_scope_match_sql(table_alias)) +} + +pub(crate) fn aliased_track_columns(alias: &str) -> String { + crate::repos::track_columns() + .split(',') + .map(|c| format!("{alias}.{}", c.trim())) + .collect::>() + .join(", ") +} + +/// Build a `%…%` LIKE pattern with the LIKE wildcards (`%`, `_`) and the +/// `\` escape char escaped, for use with `LIKE ? ESCAPE '\'`. Shared by the +/// Advanced Search album/artist name match and the cross-server fuzzy +/// title fallback (§5.9). +pub(crate) fn like_contains(raw: &str) -> String { + let escaped = raw + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + format!("%{escaped}%") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::{TrackRepository, TrackRow}; + + fn row(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: title.into(), + title_sort: None, + artist: Some(artist.into()), + artist_id: None, + album: album.into(), + album_id: None, + album_artist: Some(artist.into()), + duration_sec: 200, + 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: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + #[test] + fn match_finds_track_by_title() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + row("s1", "t1", "Aurora", "Anna", "Skylines"), + row("s1", "t2", "Sunset", "Beth", "Skylines"), + ]) + .unwrap(); + let hits = search_tracks(&store, "s1", "aurora", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].id, "t1"); + } + + #[test] + fn match_filters_by_server_id() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + row("s1", "t1", "Aurora", "Anna", "Skylines"), + row("s2", "t1", "Aurora", "Anna", "Skylines"), + ]) + .unwrap(); + let hits = search_tracks(&store, "s2", "aurora", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].server_id, "s2"); + } + + #[test] + fn match_skips_deleted_rows() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "Aurora", "Anna", "Skylines")]) + .unwrap(); + let mut gone = row("s1", "t1", "Aurora", "Anna", "Skylines"); + gone.deleted = true; + repo.upsert_batch(&[gone]).unwrap(); + let hits = search_tracks(&store, "s1", "aurora", 10).unwrap(); + assert!(hits.is_empty()); + } + + #[test] + fn fts_column_prefix_query_scopes_to_one_column() { + assert_eq!( + fts_column_prefix_query("artist", "metal").as_deref(), + Some("artist : \"metal\"*") + ); + } + + #[test] + fn fts_prefix_token_expr_ands_multiword_prefixes() { + assert_eq!( + fts_prefix_token_expr("arch enemy").as_deref(), + Some("\"arch\"* \"enemy\"*") + ); + } + + #[test] + fn fts_track_prefix_match_query_or_across_display_columns() { + let q = fts_track_prefix_match_query("metal").unwrap(); + assert!(q.contains("title : \"metal\"*")); + assert!(q.contains("artist : \"metal\"*")); + assert!(!q.contains("genre")); + } + + #[test] + fn fts_album_prefix_match_query_includes_album_artist() { + assert_eq!( + fts_album_prefix_match_query("metal").as_deref(), + Some("(album : \"metal\"* OR album_artist : \"metal\"*)") + ); + } + + #[test] + fn fts_track_match_query_or_across_display_columns() { + let q = fts_track_match_query("manowar").unwrap(); + assert!(q.contains("title : \"manowar\"")); + assert!(q.contains("artist : \"manowar\"")); + assert!(!q.contains("genre")); + } + + #[test] + fn fts_query_meets_min_len_requires_two_graphemes() { + assert!(!fts_query_meets_min_len("a")); + assert!(!fts_query_meets_min_len("а")); + assert!(fts_query_meets_min_len("ab")); + assert!(fts_query_meets_min_len("ма")); + } + + #[test] + fn fts_query_quotes_tokens_and_doubles_inner_quotes() { + assert_eq!(fts_query("hello world").as_deref(), Some("\"hello\" \"world\"")); + assert_eq!(fts_query("a\"b").as_deref(), Some("\"a\"\"b\"")); + } + + #[test] + fn fts_query_is_none_for_blank_input() { + assert!(fts_query("").is_none()); + assert!(fts_query(" ").is_none()); + } + + #[test] + fn aliased_track_columns_prefixes_every_column() { + let cols = aliased_track_columns("t"); + assert!(cols.starts_with("t.server_id, t.id, t.title")); + assert!(cols.ends_with("t.raw_json")); + // One alias per column — count matches the shared column list. + assert_eq!(cols.matches("t.").count(), crate::repos::track_columns().split(',').count()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/store.rs b/src-tauri/crates/psysonic-library/src/store.rs new file mode 100644 index 00000000..d6938b1c --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/store.rs @@ -0,0 +1,532 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use rusqlite::{params, Connection, OpenFlags}; +use tauri::Manager; + +/// Current head of the embedded migrations. Bump each time a new +/// `migrations/NNN_*.sql` is added. +pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 5; + +/// Lowest applied schema version the current code can advance from purely +/// additively. If a DB carries a version below this, the breaking-bump hook +/// fires (spec §5.7 / P22): the library is treated as incompatible, must be +/// dropped, and initial sync must restart. +/// +/// At v1 launch this equals `LIBRARY_DB_SCHEMA_VERSION` — no real DB can +/// trip the hook. Bump independently of `SCHEMA_VERSION` only when a +/// migration cannot be expressed additively. +pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1; + +pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql"); +const MIGRATION_002_N1_BULK_UNRELIABLE: &str = + include_str!("../migrations/002_n1_bulk_unreliable.sql"); +const MIGRATION_003_TRACK_REMAP_INDEXES: &str = + include_str!("../migrations/003_track_remap_indexes.sql"); +const MIGRATION_004_TRACK_TITLE_INDEX: &str = + include_str!("../migrations/004_track_title_index.sql"); +const MIGRATION_005_TRACK_GENRE_YEAR_INDEXES: &str = + include_str!("../migrations/005_track_genre_year_indexes.sql"); + +/// Embedded migrations. Ordered ascending by `version`; the runner sorts +/// defensively before applying so the source order can stay readable. +const MIGRATIONS: &[(i64, &str)] = &[ + (1, INITIAL_SQL), + (2, MIGRATION_002_N1_BULK_UNRELIABLE), + (3, MIGRATION_003_TRACK_REMAP_INDEXES), + (4, MIGRATION_004_TRACK_TITLE_INDEX), + (5, MIGRATION_005_TRACK_GENRE_YEAR_INDEXES), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MigrationOutcome { + /// Every missing migration was applied (or the DB was already at head). + Applied, + /// The DB carried a schema below `LIBRARY_DB_MIN_COMPATIBLE_VERSION`, + /// so the breaking-bump hook fired. Callers should treat the library + /// data as discarded and trigger a fresh initial sync (P22). + BreakingBump, +} + +/// In-memory tests share one DB across the read/write pair in a single store. +static IN_MEMORY_DB_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn in_memory_uri() -> String { + let n = IN_MEMORY_DB_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("file:psysonic_library_mem_{n}?mode=memory&cache=shared") +} + +pub struct LibraryStore { + /// Writes, migrations, and sync ingest (single writer). + write_conn: Mutex, + /// Read-only handle for search / status / hydrate while sync writes (WAL). + read_conn: Mutex, + /// IS-3 bulk ingest in progress — read paths skip write-lock work. + bulk_ingest_active: AtomicBool, +} + +impl LibraryStore { + pub fn init(app: &tauri::AppHandle) -> Result { + let db_path = library_db_path(app)?; + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + Self::open_file(&db_path) + } + + fn open_file(db_path: &Path) -> Result { + let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?; + configure_write_connection(&write_conn).map_err(|e| e.to_string())?; + run_migrations(&write_conn).map_err(|e| e.to_string())?; + let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| e.to_string())?; + configure_read_connection(&read_conn).map_err(|e| e.to_string())?; + Ok(Self { + write_conn: Mutex::new(write_conn), + read_conn: Mutex::new(read_conn), + bulk_ingest_active: AtomicBool::new(false), + }) + } + + /// Build an in-memory DB with the production schema applied. + pub fn open_in_memory() -> Self { + let uri = in_memory_uri(); + let write_conn = Connection::open(&uri).expect("in-memory write connection"); + configure_write_connection(&write_conn).expect("write pragmas"); + run_migrations(&write_conn).expect("schema migration"); + let read_conn = Connection::open(&uri).expect("in-memory read connection"); + configure_read_connection(&read_conn).expect("read pragmas"); + Self { + write_conn: Mutex::new(write_conn), + read_conn: Mutex::new(read_conn), + bulk_ingest_active: AtomicBool::new(false), + } + } + + pub(crate) fn set_bulk_ingest_active(&self, active: bool) { + self.bulk_ingest_active + .store(active, Ordering::Release); + } + + pub(crate) fn bulk_ingest_active(&self) -> bool { + self.bulk_ingest_active.load(Ordering::Acquire) + } + + /// Writer connection — sync ingest, migrations, mutations. + pub(crate) fn with_conn( + &self, + op: &'static str, + f: impl FnOnce(&Connection) -> rusqlite::Result, + ) -> Result { + let lock_start = std::time::Instant::now(); + let conn = self + .write_conn + .lock() + .map_err(|_| "library store write lock poisoned".to_string())?; + let lock_wait_ms = lock_start.elapsed().as_millis(); + let exec_start = std::time::Instant::now(); + let out = f(&conn).map_err(|e| e.to_string()); + let exec_ms = exec_start.elapsed().as_millis(); + log_write_op(op, lock_wait_ms, exec_ms); + out + } + + /// Read-only connection — search, status, hydrate; does not block on sync writes. + pub(crate) fn with_read_conn( + &self, + f: impl FnOnce(&Connection) -> rusqlite::Result, + ) -> Result { + let conn = self + .read_conn + .lock() + .map_err(|_| "library store read lock poisoned".to_string())?; + f(&conn).map_err(|e| e.to_string()) + } + + pub(crate) fn with_conn_mut( + &self, + op: &'static str, + f: impl FnOnce(&mut Connection) -> rusqlite::Result, + ) -> Result { + self.with_conn_mut_timed(op, f).map(|(value, _)| value) + } + + pub(crate) fn with_conn_mut_timed( + &self, + op: &'static str, + f: impl FnOnce(&mut Connection) -> rusqlite::Result, + ) -> Result<(R, WriteOpTiming), String> { + let lock_start = std::time::Instant::now(); + let mut conn = self + .write_conn + .lock() + .map_err(|_| "library store write lock poisoned".to_string())?; + let lock_wait_ms = lock_start.elapsed().as_millis() as u64; + let exec_start = std::time::Instant::now(); + let out = f(&mut conn).map_err(|e| e.to_string())?; + let exec_ms = exec_start.elapsed().as_millis() as u64; + log_write_op(op, lock_wait_ms as u128, exec_ms as u128); + Ok((out, WriteOpTiming { lock_wait_ms, exec_ms })) + } +} + +/// Timing split returned to ingest progress (DevTools / terminal). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct WriteOpTiming { + pub lock_wait_ms: u64, + pub exec_ms: u64, +} + +impl WriteOpTiming { + pub fn total_ms(&self) -> u64 { + self.lock_wait_ms.saturating_add(self.exec_ms) + } +} + +fn log_write_op(op: &str, lock_wait_ms: u128, exec_ms: u128) { + if lock_wait_ms >= 1000 || exec_ms >= 1000 { + crate::app_eprintln!( + "[library-db] SLOW write op={op} lock_wait_ms={lock_wait_ms} exec_ms={exec_ms}" + ); + } else if lock_wait_ms >= 50 || exec_ms >= 200 { + crate::app_eprintln!("[library-db] write op={op} lock_wait_ms={lock_wait_ms} exec_ms={exec_ms}"); + } +} + +fn library_db_path(app: &tauri::AppHandle) -> Result { + let base = app.path().app_data_dir().map_err(|e| e.to_string())?; + Ok(base.join("library.sqlite")) +} + +fn configure_write_connection(conn: &Connection) -> rusqlite::Result<()> { + conn.busy_timeout(Duration::from_secs(30))?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.pragma_update(None, "temp_store", "MEMORY")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + Ok(()) +} + +fn configure_read_connection(conn: &Connection) -> rusqlite::Result<()> { + conn.busy_timeout(Duration::from_secs(5))?; + conn.pragma_update(None, "foreign_keys", "ON")?; + // Search / browse hot path on large libraries (read-only handle). + conn.pragma_update(None, "cache_size", -64_000)?; + Ok(()) +} + +fn run_migrations(conn: &Connection) -> rusqlite::Result { + run_migrations_with( + conn, + MIGRATIONS, + LIBRARY_DB_MIN_COMPATIBLE_VERSION, + handle_breaking_schema_bump, + ) +} + +/// Test-friendly entry point. Production code goes through `run_migrations`, +/// which fixes `migrations`, `min_compatible`, and `hook` to the prod values. +pub(crate) fn run_migrations_with( + conn: &Connection, + migrations: &[(i64, &str)], + min_compatible: i64, + hook: fn(&Connection, i64, i64) -> rusqlite::Result<()>, +) -> rusqlite::Result { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + );", + )?; + + // Breaking-bump detection only meaningful for already-initialised DBs. + let max_applied: Option = conn.query_row( + "SELECT MAX(version) FROM schema_migrations", + [], + |row| row.get::<_, Option>(0), + )?; + if let Some(max_applied) = max_applied { + if max_applied < min_compatible { + hook(conn, max_applied, LIBRARY_DB_SCHEMA_VERSION)?; + return Ok(MigrationOutcome::BreakingBump); + } + } + + let mut ordered: Vec<(i64, &str)> = migrations.iter().map(|(v, s)| (*v, *s)).collect(); + ordered.sort_by_key(|(v, _)| *v); + for (version, sql) in ordered { + let already: i64 = conn.query_row( + "SELECT COUNT(*) FROM schema_migrations WHERE version = ?1", + params![version], + |row| row.get(0), + )?; + if already > 0 { + continue; + } + conn.execute_batch(sql)?; + conn.execute( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, strftime('%s','now'))", + params![version], + )?; + } + Ok(MigrationOutcome::Applied) +} + +/// P22 breaking-schema-bump hook. PR-1b ships a no-op stub: the function +/// signature, call site, and `MigrationOutcome::BreakingBump` signal are in +/// place, but the actual library-drop + sync-reset logic lands when the +/// first real breaking bump happens. Until then the constants guarantee the +/// hook never fires on production data. +fn handle_breaking_schema_bump( + _conn: &Connection, + _max_applied: i64, + _target_version: i64, +) -> rusqlite::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_conn_sees_committed_writes_from_write_conn() { + let store = LibraryStore::open_in_memory(); + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO sync_state (server_id, library_scope, sync_phase) \ + VALUES ('s1', '', 'ready')", + [], + ) + }) + .unwrap(); + let phase: String = store + .with_read_conn(|c| { + c.query_row( + "SELECT sync_phase FROM sync_state WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(phase, "ready"); + } + + #[test] + fn open_in_memory_creates_all_expected_tables() { + let store = LibraryStore::open_in_memory(); + let tables = store + .with_conn("misc", |c| { + let mut stmt = + c.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")?; + let rows: rusqlite::Result> = + stmt.query_map([], |r| r.get::<_, String>(0))?.collect(); + rows + }) + .unwrap(); + + for expected in [ + "album", + "artist", + "canonical_enrichment_link", + "canonical_identity", + "canonical_track", + "schema_migrations", + "sync_state", + "track", + "track_artifact", + "track_canonical_link", + "track_extension", + "track_fact", + "track_id_history", + "track_offline", + ] { + assert!( + tables.iter().any(|t| t == expected), + "missing table `{expected}` — got {tables:?}" + ); + } + } + + #[test] + fn schema_migrations_records_head_version() { + let store = LibraryStore::open_in_memory(); + let versions: Vec = store + .with_conn("misc", |c| { + let mut stmt = + c.prepare("SELECT version FROM schema_migrations ORDER BY version")?; + let rows: rusqlite::Result> = + stmt.query_map([], |r| r.get(0))?.collect(); + rows + }) + .unwrap(); + // Embedded migrations are numbered 1..=head, all applied on a fresh DB. + let expected: Vec = (1..=LIBRARY_DB_SCHEMA_VERSION).collect(); + assert_eq!(versions, expected); + } + + #[test] + fn run_migrations_is_idempotent_across_reopens() { + let store = LibraryStore::open_in_memory(); + let outcome = store + .with_conn("migrate", run_migrations) + .expect("second migration pass must be a no-op"); + assert_eq!(outcome, MigrationOutcome::Applied); + let count: i64 = store + .with_conn("misc", |c| { + c.query_row("SELECT COUNT(*) FROM schema_migrations", [], |r| r.get(0)) + }) + .unwrap(); + assert_eq!( + count, LIBRARY_DB_SCHEMA_VERSION, + "one schema_migrations row per embedded migration, no duplicates" + ); + } + + #[test] + fn fts_virtual_table_exists() { + let store = LibraryStore::open_in_memory(); + let count: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name='track_fts'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(count, 1); + } + + // ── PR-1b: edge-case tests via the test-only `run_migrations_with` ───── + + /// `ALTER TABLE artist ADD COLUMN bio TEXT;` — minimal additive fixture, + /// nullable column with no default. Mirrors the §5.7 additive-first rule. + /// Numbered above the real embedded head so it stacks on a migrated DB. + const FIXTURE_ADD_BIO: &str = "ALTER TABLE artist ADD COLUMN bio TEXT;"; + const FIXTURE_ADD_BIO_VERSION: i64 = LIBRARY_DB_SCHEMA_VERSION + 1; + + fn no_op_hook(_c: &Connection, _from: i64, _to: i64) -> rusqlite::Result<()> { + Ok(()) + } + + fn always_fail_hook(_c: &Connection, _from: i64, _to: i64) -> rusqlite::Result<()> { + panic!("breaking-bump hook must NOT fire in this test"); + } + + #[test] + fn additive_migration_preserves_existing_data() { + let store = LibraryStore::open_in_memory(); + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO artist (server_id, id, name, synced_at) \ + VALUES ('s1', 'a1', 'Existing Artist', 1)", + [], + ) + }) + .unwrap(); + + let outcome = store + .with_conn("misc", |c| { + run_migrations_with( + c, + &[(1, INITIAL_SQL), (FIXTURE_ADD_BIO_VERSION, FIXTURE_ADD_BIO)], + LIBRARY_DB_MIN_COMPATIBLE_VERSION, + always_fail_hook, + ) + }) + .unwrap(); + assert_eq!(outcome, MigrationOutcome::Applied); + + let (name, bio): (String, Option) = store + .with_conn("misc", |c| { + c.query_row( + "SELECT name, bio FROM artist WHERE id = 'a1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + }) + .unwrap(); + assert_eq!(name, "Existing Artist"); + assert!(bio.is_none()); + + let versions: Vec = store + .with_conn("misc", |c| { + let mut stmt = + c.prepare("SELECT version FROM schema_migrations ORDER BY version")?; + let rows: rusqlite::Result> = + stmt.query_map([], |r| r.get(0))?.collect(); + rows + }) + .unwrap(); + // Real embedded migrations (1..=head) plus the additive fixture. + let mut expected: Vec = (1..=LIBRARY_DB_SCHEMA_VERSION).collect(); + expected.push(FIXTURE_ADD_BIO_VERSION); + assert_eq!(versions, expected); + } + + #[test] + fn runner_sorts_unsorted_migration_slice_before_applying() { + // If a future contributor lists migrations out of order in the + // source slice, the runner must still apply them ascending. + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + + let outcome = run_migrations_with( + &conn, + &[(2, FIXTURE_ADD_BIO), (1, INITIAL_SQL)], + LIBRARY_DB_MIN_COMPATIBLE_VERSION, + always_fail_hook, + ) + .unwrap(); + assert_eq!(outcome, MigrationOutcome::Applied); + + let versions: Vec = { + let mut stmt = conn + .prepare("SELECT version FROM schema_migrations ORDER BY applied_at, version") + .unwrap(); + let rows: rusqlite::Result> = + stmt.query_map([], |r| r.get(0)).unwrap().collect(); + rows.unwrap() + }; + assert_eq!(versions, vec![1, 2]); + } + + #[test] + fn breaking_bump_hook_fires_when_db_below_min_compatible() { + // Simulate a future code release where MIN_COMPATIBLE was bumped past + // the version the DB currently carries (the real embedded head). + let store = LibraryStore::open_in_memory(); + let outcome = store + .with_conn("misc", |c| { + run_migrations_with( + c, + MIGRATIONS, + LIBRARY_DB_SCHEMA_VERSION + 1, // bumped past current applied + no_op_hook, + ) + }) + .unwrap(); + assert_eq!(outcome, MigrationOutcome::BreakingBump); + } + + #[test] + fn breaking_bump_hook_does_not_fire_on_fresh_db() { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + let outcome = run_migrations_with( + &conn, + MIGRATIONS, + // Even a wildly future min_compatible must not trip on a fresh DB: + // no rows in schema_migrations means "nothing to migrate from". + 999, + always_fail_hook, + ) + .unwrap(); + assert_eq!(outcome, MigrationOutcome::Applied); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/backoff.rs b/src-tauri/crates/psysonic-library/src/sync/backoff.rs new file mode 100644 index 00000000..4030e334 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/backoff.rs @@ -0,0 +1,151 @@ +//! C12 — HTTP backoff for ingest batches (spec §6.8). +//! +//! The runner does not advance its cursor on transient failure; it +//! sleeps via `Backoff::next_delay`, retries the same batch, and +//! resets the counter on success. The cap is 120 s — the user is +//! waiting for sync to finish, longer hangs become indistinguishable +//! from "stuck" without progress events to reassure them. + +use std::time::Duration; + +/// Exponential schedule per §6.8: `2s → 4s → 8s → … cap 120s`. The +/// caller adds jitter on top to avoid herd-sync against a recovering +/// upstream proxy. +#[derive(Debug, Clone)] +pub struct Backoff { + attempt: u32, + base: Duration, + cap: Duration, +} + +impl Default for Backoff { + fn default() -> Self { + Self::new(Duration::from_secs(2), Duration::from_secs(120)) + } +} + +impl Backoff { + pub fn new(base: Duration, cap: Duration) -> Self { + Self { attempt: 0, base, cap } + } + + /// Reset after a successful batch — the next failure starts at + /// the base delay again. + pub fn reset(&mut self) { + self.attempt = 0; + } + + /// Compute the next sleep duration and bump the attempt counter. + /// Doubles each call (2 → 4 → 8 → …) and clamps to `cap`. Caller + /// adds jitter via `with_jitter` if desired. + pub fn next_delay(&mut self) -> Duration { + let n = self.attempt; + self.attempt = self.attempt.saturating_add(1); + let scaled = self.base.saturating_mul(1u32 << n.min(31)); + scaled.min(self.cap) + } + + pub fn attempt(&self) -> u32 { + self.attempt + } +} + +/// Salt for production jitter: attempt plus sub-second clock noise so +/// concurrent retries don't share the same jitter slot. +pub fn jitter_salt(attempt: u32) -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos() as u64) + .unwrap_or(0); + u64::from(attempt).saturating_add(nanos) +} + +/// Add ±25% jitter to a planned sleep — deterministic per `salt` so +/// tests can pin the result without pulling in `rand`. Production +/// code passes `jitter_salt(attempt)` as the salt; tests pass a fixed +/// value to assert the formula. +pub fn with_jitter(base: Duration, salt: u64) -> Duration { + let millis = base.as_millis().min(u128::from(u64::MAX)) as u64; + if millis == 0 { + return base; + } + let span = millis / 2; // ±25% = total span of 50% of base + if span == 0 { + return base; + } + // map salt into [-span/2, +span/2] + let pct = (salt % span) as i64 - (span as i64 / 2); + let jittered = (millis as i64).saturating_add(pct).max(1) as u64; + Duration::from_millis(jittered) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_delay_doubles_until_cap() { + let mut b = Backoff::default(); + assert_eq!(b.next_delay(), Duration::from_secs(2)); + assert_eq!(b.next_delay(), Duration::from_secs(4)); + assert_eq!(b.next_delay(), Duration::from_secs(8)); + assert_eq!(b.next_delay(), Duration::from_secs(16)); + assert_eq!(b.next_delay(), Duration::from_secs(32)); + assert_eq!(b.next_delay(), Duration::from_secs(64)); + // Next would be 128 — clamps to 120 cap. + assert_eq!(b.next_delay(), Duration::from_secs(120)); + // Stays at cap from here on. + assert_eq!(b.next_delay(), Duration::from_secs(120)); + } + + #[test] + fn reset_brings_schedule_back_to_base() { + let mut b = Backoff::default(); + b.next_delay(); + b.next_delay(); + assert!(b.attempt() > 0); + b.reset(); + assert_eq!(b.attempt(), 0); + assert_eq!(b.next_delay(), Duration::from_secs(2)); + } + + #[test] + fn next_delay_handles_custom_base_and_cap() { + let mut b = Backoff::new(Duration::from_millis(50), Duration::from_secs(1)); + assert_eq!(b.next_delay(), Duration::from_millis(50)); + assert_eq!(b.next_delay(), Duration::from_millis(100)); + assert_eq!(b.next_delay(), Duration::from_millis(200)); + assert_eq!(b.next_delay(), Duration::from_millis(400)); + assert_eq!(b.next_delay(), Duration::from_millis(800)); + // 1600 would exceed 1s cap. + assert_eq!(b.next_delay(), Duration::from_secs(1)); + } + + #[test] + fn next_delay_does_not_overflow_at_extreme_attempts() { + let mut b = Backoff::default(); + for _ in 0..100 { + // Should saturate at the cap, never panic on shift overflow. + let _ = b.next_delay(); + } + assert_eq!(b.next_delay(), Duration::from_secs(120)); + } + + #[test] + fn jitter_stays_within_plus_minus_half_of_span() { + let base = Duration::from_secs(8); + let span_ms = base.as_millis() as u64 / 2; // 4000 ms + let lo = base.as_millis() as u64 - span_ms / 2; // 6000 ms + let hi = base.as_millis() as u64 + span_ms / 2; // 10000 ms + for salt in 0u64..1000 { + let j = with_jitter(base, salt).as_millis() as u64; + assert!(j >= lo && j <= hi, "salt {salt} → {j}ms outside [{lo},{hi}]"); + } + } + + #[test] + fn jitter_with_zero_base_returns_zero() { + assert_eq!(with_jitter(Duration::ZERO, 12345), Duration::ZERO); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/bandwidth.rs b/src-tauri/crates/psysonic-library/src/sync/bandwidth.rs new file mode 100644 index 00000000..e7793122 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/bandwidth.rs @@ -0,0 +1,86 @@ +//! C11 — bandwidth priority lane (spec §6.2.4). +//! +//! PR-3d2 ships the data types + parallelism resolver. The signal +//! itself is pushed in from the top crate (PR-5 hooks audio engine +//! events from `psysonic-audio` into a shared `PlaybackHint` cell); +//! the library side just consumes it. + +/// What the player is currently doing — drives crawl parallelism. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PlaybackHint { + /// Nothing playing, queue idle → bulk crawl runs at normal + /// parallelism. + #[default] + Idle, + /// Active stream (user listening). Bulk drops to single-request + /// crawling and increases inter-request delay. + Playing, + /// Queue prefetch / waveform analysis hot — priority lane only; + /// bulk pauses entirely. + PrefetchActive, +} + +/// Parallelism budget the bulk crawl uses for this tick. `0` means +/// bulk is suspended this tick (PR-3d2 returns the value; the runner +/// honoring it is wired in PR-3d2 too via `BackgroundScheduler`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParallelismBudget { + pub max_concurrent: u32, + /// Minimum gap between successive bulk requests, in ms. + pub min_request_gap_ms: u32, +} + +impl ParallelismBudget { + pub fn resolve(hint: PlaybackHint) -> Self { + match hint { + PlaybackHint::Idle => Self { + max_concurrent: 4, + min_request_gap_ms: 0, + }, + PlaybackHint::Playing => Self { + max_concurrent: 1, + min_request_gap_ms: 250, + }, + PlaybackHint::PrefetchActive => Self { + max_concurrent: 0, + min_request_gap_ms: 0, + }, + } + } + + pub fn bulk_paused(&self) -> bool { + self.max_concurrent == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idle_resolves_to_normal_parallelism() { + let b = ParallelismBudget::resolve(PlaybackHint::Idle); + assert_eq!(b.max_concurrent, 4); + assert_eq!(b.min_request_gap_ms, 0); + assert!(!b.bulk_paused()); + } + + #[test] + fn playing_caps_parallelism_to_one_with_inter_request_gap() { + let b = ParallelismBudget::resolve(PlaybackHint::Playing); + assert_eq!(b.max_concurrent, 1); + assert!(b.min_request_gap_ms >= 100, "playing must space requests out"); + assert!(!b.bulk_paused()); + } + + #[test] + fn prefetch_active_pauses_bulk_crawl_entirely() { + let b = ParallelismBudget::resolve(PlaybackHint::PrefetchActive); + assert!(b.bulk_paused()); + } + + #[test] + fn playback_hint_default_is_idle() { + assert_eq!(PlaybackHint::default(), PlaybackHint::Idle); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/budget.rs b/src-tauri/crates/psysonic-library/src/sync/budget.rs new file mode 100644 index 00000000..a5139029 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/budget.rs @@ -0,0 +1,93 @@ +//! C9 — request budget per spec §6.2.5. +//! +//! Soft caps the scheduler declares before each pass; runners +//! consume them to decide when to defer the remainder to the next +//! tick. PR-3d2 ships the data type + lookups; PR-5 / the runner +//! wires the actual consumption (delta runner can already be capped +//! via batch_size — the budget is a higher-level abstraction). + +/// Per-pass HTTP call budgets matching the spec table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PassKind { + /// `poll_tick (unchanged watermark)` — 1 call (`getArtists` only). + PollTick, + /// `delta_sync (light)` — small targeted delta, 50 calls. + DeltaLight, + /// `delta_sync (count mismatch / tombstone)` — 200 calls, + /// split across ticks if exhausted. + DeltaMismatch, + /// Initial sync — unlimited (only user cancel stops it). + InitialSync, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequestBudget { + pub kind: PassKind, + /// `None` = unlimited. + pub cap: Option, +} + +impl RequestBudget { + pub const POLL_TICK_CAP: u32 = 1; + pub const DELTA_LIGHT_CAP: u32 = 50; + pub const DELTA_MISMATCH_CAP: u32 = 200; + + pub fn for_pass(kind: PassKind) -> Self { + let cap = match kind { + PassKind::PollTick => Some(Self::POLL_TICK_CAP), + PassKind::DeltaLight => Some(Self::DELTA_LIGHT_CAP), + PassKind::DeltaMismatch => Some(Self::DELTA_MISMATCH_CAP), + PassKind::InitialSync => None, + }; + Self { kind, cap } + } + + pub fn is_unlimited(&self) -> bool { + self.cap.is_none() + } + + /// Returns `true` when `used` requests still fit inside the cap. + /// Unlimited budgets always return `true`. + pub fn has_room(&self, used: u32) -> bool { + match self.cap { + None => true, + Some(cap) => used < cap, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn poll_tick_cap_is_one_request() { + let b = RequestBudget::for_pass(PassKind::PollTick); + assert_eq!(b.cap, Some(1)); + assert!(b.has_room(0)); + assert!(!b.has_room(1)); + } + + #[test] + fn delta_light_caps_at_fifty() { + let b = RequestBudget::for_pass(PassKind::DeltaLight); + assert_eq!(b.cap, Some(50)); + assert!(b.has_room(49)); + assert!(!b.has_room(50)); + } + + #[test] + fn delta_mismatch_caps_at_two_hundred() { + let b = RequestBudget::for_pass(PassKind::DeltaMismatch); + assert_eq!(b.cap, Some(200)); + assert!(b.has_room(199)); + assert!(!b.has_room(200)); + } + + #[test] + fn initial_sync_is_unlimited() { + let b = RequestBudget::for_pass(PassKind::InitialSync); + assert!(b.is_unlimited()); + assert!(b.has_room(u32::MAX)); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/capability.rs b/src-tauri/crates/psysonic-library/src/sync/capability.rs new file mode 100644 index 00000000..f7e47071 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/capability.rs @@ -0,0 +1,680 @@ +//! C1 — Capability probe (spec §6.1 / §6.1.1). +//! +//! Drives the Subsonic client + an optional Navidrome native probe to +//! populate `sync_state.capability_flags` before initial sync picks its +//! ingest strategy (§6.3). PR-3a only writes flags from the responses; +//! interpretation lives in PR-3b's `IngestStrategy` selector. + +use psysonic_integration::navidrome::probe::native_bulk_available; +use psysonic_integration::subsonic::{ServerInfo, SubsonicClient, SubsonicError}; + +/// Bitfield matching spec §6.1.1. `u32` storage so the `sync_state` +/// table can keep it as a single integer column (`capability_flags +/// INTEGER NOT NULL DEFAULT 0`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CapabilityFlags(u32); + +impl CapabilityFlags { + /// N1 — Navidrome native `/api/song` paginated ingest. + pub const NAVIDROME_NATIVE_BULK: u32 = 0x001; + /// S1 — Subsonic `search3` empty-query bulk ingest. + pub const SUBSONIC_SEARCH3_BULK: u32 = 0x002; + /// `getScanStatus` available (Subsonic 1.15+); cheap-poll tier signal. + pub const SCAN_STATUS_AVAILABLE: u32 = 0x004; + /// Server advertises OpenSubsonic extensions (`isrc`, `played`, + /// `bpm`, contributor arrays, …). + pub const OPEN_SUBSONIC: u32 = 0x008; + /// Track ids may shift across server re-indexing — sync engine must + /// run the `track_id_history` remap pass (§6.9, P33). Always set + /// for Navidrome. + pub const UNSTABLE_TRACK_IDS: u32 = 0x010; + /// S3 — `getIndexes` / `getMusicDirectory` available (file-tree + /// fallback when ID3 endpoints are missing entirely). + pub const FILE_TREE_BROWSE: u32 = 0x020; + + pub fn new(bits: u32) -> Self { + Self(bits) + } + + pub fn bits(self) -> u32 { + self.0 + } + + pub fn contains(self, flag: u32) -> bool { + self.0 & flag == flag + } + + pub fn insert(&mut self, flag: u32) { + self.0 |= flag; + } + + pub fn remove(&mut self, flag: u32) { + self.0 &= !flag; + } +} + +/// Optional input for `CapabilityProbe::run` — Navidrome native API +/// needs its own bearer token (separate from the Subsonic salted-md5 +/// auth). When `None`, the `NavidromeNativeBulk` bit stays clear and +/// sync falls back to Subsonic strategies. +#[derive(Debug, Clone)] +pub struct NavidromeProbeCredentials { + pub server_url: String, + pub bearer_token: String, +} + +/// Outcome of the capability probe — both the bitfield (stored in +/// `sync_state.capability_flags`) and the raw `ServerInfo` envelope +/// metadata (callers may want to log `serverVersion` etc.). +#[derive(Debug, Clone)] +pub struct CapabilityProbeResult { + pub flags: CapabilityFlags, + pub server_info: ServerInfo, + /// Server-reported track count from `getScanStatus.count`, when the + /// server exposes it. `None` when `getScanStatus` is unavailable or + /// reports no count. Persisted as the `server_track_count` watermark so + /// the strategy selector can route large catalogs to S1 at IS-1 without + /// first hitting N1's deep-offset wall (R7-15 Q4). + pub server_track_count: Option, +} + +/// Run `CapabilityProbe::run` and persist the resulting flags + +/// transition `sync_phase` from whatever it was to `probing` → +/// `idle` (caller is responsible for advancing to `initial_sync` / +/// `ready` once the appropriate runner starts). +/// +/// PR-3d wires this in front of every initial / delta run so the +/// stored `capability_flags` always reflects the current server. +/// Returns the freshly resolved `(flags, server_info)` so callers +/// can pick their `IngestStrategy` without re-reading SQLite. +pub async fn probe_and_persist( + store: &crate::store::LibraryStore, + subsonic: &psysonic_integration::subsonic::SubsonicClient, + navidrome: Option<&NavidromeProbeCredentials>, + server_id: &str, + library_scope: &str, +) -> Result { + let sync_state = crate::repos::SyncStateRepository::new(store); + sync_state + .ensure(server_id, library_scope) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + let phase_before = sync_state + .get_sync_phase(server_id, library_scope) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + sync_state + .set_sync_phase(server_id, library_scope, "probing") + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + + let existing_flags = sync_state + .get_capability_flags(server_id, library_scope) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)? + .unwrap_or(0); + + let mut result = CapabilityProbe::run(subsonic, navidrome).await?; + + // R7-15 Q3: a probe run without a Navidrome bearer can't test N1, so it + // must not drop a previously-learned NavidromeNativeBulk capability — the + // server still supports `/api/song`; only the token is missing this bind. + // Token availability gates actual N1 use per run (see library_sync_start). + if navidrome.is_none() + && existing_flags & CapabilityFlags::NAVIDROME_NATIVE_BULK != 0 + { + result.flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK); + } + + sync_state + .set_capability_flags(server_id, library_scope, result.flags.bits()) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + // Refresh the track-count watermark only when the probe learned one — a + // missing `getScanStatus.count` must not clobber a count from a prior run. + if let Some(count) = result.server_track_count { + sync_state + .set_server_track_count(server_id, library_scope, count) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + } + sync_state + .set_sync_phase( + server_id, + library_scope, + match phase_before.as_deref() { + // Re-bind on app restart must not clobber a finished index — + // callers gate local search on `ready` (§9.3 / P8). + Some("ready") => "ready", + Some("initial_sync") => "initial_sync", + Some("error") => "error", + _ => { + if sync_state + .has_last_full_sync_at(server_id, library_scope) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)? + { + "ready" + } else { + "idle" + } + } + }, + ) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + + Ok(result) +} + +pub struct CapabilityProbe; + +impl CapabilityProbe { + /// Run the §6.1 probe chain. Returns the resolved flags plus the + /// envelope metadata captured from the Subsonic ping. + /// + /// The Subsonic ping is the only failure-blocking probe — if it + /// returns `Err`, the server is unreachable / wrong creds / wrong + /// URL, and no other capability can be determined. Every other + /// probe is best-effort: it sets its flag on success and leaves it + /// clear on any error. + pub async fn run( + subsonic: &SubsonicClient, + navidrome: Option<&NavidromeProbeCredentials>, + ) -> Result { + let server_info = subsonic.server_info().await?; + + let mut flags = CapabilityFlags::default(); + + if server_info.open_subsonic { + flags.insert(CapabilityFlags::OPEN_SUBSONIC); + } + // Navidrome rebuilds its track id space on full re-scan; spec + // §6.9 / P33 makes the remap pass mandatory for those servers. + if matches!(server_info.server_type.as_deref(), Some("navidrome")) { + flags.insert(CapabilityFlags::UNSTABLE_TRACK_IDS); + } + + // `search3` with songCount=1 is the cheapest way to confirm the + // bulk-ingest endpoint is usable on this server (Navidrome + // accepts empty query; some forks reject it). + if subsonic.search3("", 1, 0, None).await.is_ok() { + flags.insert(CapabilityFlags::SUBSONIC_SEARCH3_BULK); + } + + let mut server_track_count = None; + if let Ok(scan) = subsonic.get_scan_status().await { + flags.insert(CapabilityFlags::SCAN_STATUS_AVAILABLE); + // Only a positive count is a usable watermark; a scan in progress + // can report 0, which we treat as "unknown" rather than "empty". + server_track_count = scan.count.filter(|&c| c > 0); + } + + if subsonic.get_indexes(None, None).await.is_ok() { + flags.insert(CapabilityFlags::FILE_TREE_BROWSE); + } + + if let Some(creds) = navidrome { + match native_bulk_available(&creds.server_url, &creds.bearer_token).await { + Ok(true) => flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK), + Ok(false) => {} + Err(_) => { + // Probe transport failed but Subsonic ping worked — + // assume the native endpoint is unavailable for this + // setup and let sync fall back to S1/S2. + } + } + } + + Ok(CapabilityProbeResult { + flags, + server_info, + server_track_count, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials}; + use serde_json::json; + use wiremock::matchers::{header, method as wm_method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // ── CapabilityFlags bitfield ───────────────────────────────────────── + + #[test] + fn capability_flags_contains_respects_individual_bits() { + let mut f = CapabilityFlags::default(); + assert!(!f.contains(CapabilityFlags::OPEN_SUBSONIC)); + f.insert(CapabilityFlags::OPEN_SUBSONIC); + assert!(f.contains(CapabilityFlags::OPEN_SUBSONIC)); + assert!(!f.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); + } + + #[test] + fn capability_flags_insert_is_idempotent() { + let mut f = CapabilityFlags::default(); + f.insert(CapabilityFlags::SUBSONIC_SEARCH3_BULK); + let after_first = f.bits(); + f.insert(CapabilityFlags::SUBSONIC_SEARCH3_BULK); + assert_eq!(f.bits(), after_first); + } + + #[test] + fn capability_flags_remove_clears_only_the_named_bit() { + let mut f = CapabilityFlags::new( + CapabilityFlags::OPEN_SUBSONIC | CapabilityFlags::UNSTABLE_TRACK_IDS, + ); + f.remove(CapabilityFlags::OPEN_SUBSONIC); + assert!(!f.contains(CapabilityFlags::OPEN_SUBSONIC)); + assert!(f.contains(CapabilityFlags::UNSTABLE_TRACK_IDS)); + } + + #[test] + fn capability_flags_bit_values_match_spec_table() { + // Spec §6.1.1 hex values — pin the wire format so future + // schema-migration writers don't shift them silently. + assert_eq!(CapabilityFlags::NAVIDROME_NATIVE_BULK, 0x001); + assert_eq!(CapabilityFlags::SUBSONIC_SEARCH3_BULK, 0x002); + assert_eq!(CapabilityFlags::SCAN_STATUS_AVAILABLE, 0x004); + assert_eq!(CapabilityFlags::OPEN_SUBSONIC, 0x008); + assert_eq!(CapabilityFlags::UNSTABLE_TRACK_IDS, 0x010); + assert_eq!(CapabilityFlags::FILE_TREE_BROWSE, 0x020); + } + + // ── CapabilityProbe wiremock harness ───────────────────────────────── + + fn test_subsonic_client(uri: &str) -> SubsonicClient { + SubsonicClient::with_static_credentials( + uri, + SubsonicCredentials::with_static("user", "tok", "salt"), + reqwest::Client::new(), + ) + } + + fn ok_envelope(body_key: &str, body: serde_json::Value) -> serde_json::Value { + json!({ + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + body_key: body, + } + }) + } + + async fn mount_subsonic_full_navidrome(server: &MockServer) { + // ping → navidrome + openSubsonic + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "0.55.2", + "openSubsonic": true + } + }))) + .mount(server) + .await; + // search3 empty query + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "searchResult3", + json!({ "song": [{ "id": "x", "title": "y" }] }), + ))) + .mount(server) + .await; + // getScanStatus + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getScanStatus.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "scanStatus", + json!({ "scanning": false }), + ))) + .mount(server) + .await; + // getIndexes + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getIndexes.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "indexes", + json!({ "lastModified": 0, "ignoredArticles": "", "index": [] }), + ))) + .mount(server) + .await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_sets_all_subsonic_bits_on_a_fully_capable_navidrome_server() { + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None) + .await + .unwrap(); + assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK)); + assert!(result.flags.contains(CapabilityFlags::SCAN_STATUS_AVAILABLE)); + assert!(result.flags.contains(CapabilityFlags::FILE_TREE_BROWSE)); + assert!(result.flags.contains(CapabilityFlags::OPEN_SUBSONIC)); + assert!(result.flags.contains(CapabilityFlags::UNSTABLE_TRACK_IDS)); + // No navidrome probe creds passed → N1 stays clear. + assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); + assert_eq!(result.server_info.server_type.as_deref(), Some("navidrome")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_returns_err_when_subsonic_ping_fails() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 40, "message": "Wrong credentials" } + } + }))) + .mount(&server) + .await; + + let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None) + .await + .unwrap_err(); + assert!(matches!(err, SubsonicError::Api { code: 40, .. })); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_keeps_optional_bits_clear_when_their_endpoint_fails() { + // Minimal Subsonic-like server: ping ok, search3 ok, but + // scanStatus + getIndexes 4xx. UnstableTrackIds + OpenSubsonic + // stay clear because the ping envelope omits them. + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "version": "1.13" } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "searchResult3", + json!({}), + ))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getScanStatus.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 30, "message": "Method not available" } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getIndexes.view")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None) + .await + .unwrap(); + assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK)); + assert!(!result.flags.contains(CapabilityFlags::SCAN_STATUS_AVAILABLE)); + assert!(!result.flags.contains(CapabilityFlags::FILE_TREE_BROWSE)); + assert!(!result.flags.contains(CapabilityFlags::OPEN_SUBSONIC)); + assert!(!result.flags.contains(CapabilityFlags::UNSTABLE_TRACK_IDS)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_sets_navidrome_native_bulk_when_credentials_succeed() { + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .and(header("X-ND-Authorization", "Bearer nd-tok")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&server) + .await; + + let nav = NavidromeProbeCredentials { + server_url: server.uri(), + bearer_token: "nd-tok".into(), + }; + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav)) + .await + .unwrap(); + assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); + } + + // ── probe_and_persist round-trip ────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn probe_and_persist_writes_flags_and_resets_phase_to_idle() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + + let store = LibraryStore::open_in_memory(); + let result = super::probe_and_persist( + &store, + &test_subsonic_client(&server.uri()), + None, + "s1", + "", + ) + .await + .unwrap(); + + let sync_state = SyncStateRepository::new(&store); + let flags = sync_state.get_capability_flags("s1", "").unwrap().unwrap(); + assert_eq!(flags, result.flags.bits()); + assert!(flags & CapabilityFlags::OPEN_SUBSONIC != 0); + assert!(flags & CapabilityFlags::UNSTABLE_TRACK_IDS != 0); + + // Fresh server ends at `idle` so the caller can transition to + // `initial_sync` / `ready` based on whether a sync is needed. + assert_eq!( + sync_state.get_sync_phase("s1", "").unwrap().as_deref(), + Some("idle") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_and_persist_preserves_ready_phase_on_rebind() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state.set_sync_phase("s1", "", "ready").unwrap(); + + super::probe_and_persist( + &store, + &test_subsonic_client(&server.uri()), + None, + "s1", + "", + ) + .await + .unwrap(); + + assert_eq!( + sync_state.get_sync_phase("s1", "").unwrap().as_deref(), + Some("ready") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_and_persist_promotes_idle_to_ready_when_full_sync_stamped() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state.set_sync_phase("s1", "", "idle").unwrap(); + sync_state + .set_last_full_sync_at("s1", "", 1_716_000_000_000) + .unwrap(); + + super::probe_and_persist( + &store, + &test_subsonic_client(&server.uri()), + None, + "s1", + "", + ) + .await + .unwrap(); + + assert_eq!( + sync_state.get_sync_phase("s1", "").unwrap().as_deref(), + Some("ready") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_captures_and_persists_scan_status_track_count() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let server = MockServer::start().await; + // ping + search3 + getIndexes from the shared helper, then override + // getScanStatus with a populated `count` (large library). + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "version": "1.16.1", "type": "navidrome" } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "searchResult3", + json!({ "song": [{ "id": "x", "title": "y" }] }), + ))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getScanStatus.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "scanStatus", + json!({ "scanning": false, "count": 170_000 }), + ))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getIndexes.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "indexes", + json!({ "lastModified": 0, "ignoredArticles": "", "index": [] }), + ))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + let result = + super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "") + .await + .unwrap(); + assert_eq!(result.server_track_count, Some(170_000)); + + let sync_state = SyncStateRepository::new(&store); + assert_eq!( + sync_state.get_server_track_count("s1", "").unwrap(), + Some(170_000) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_preserves_navidrome_native_bulk_when_no_token_supplied() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + // A prior bind (with a working bearer) already learned N1. + sync_state + .set_capability_flags("s1", "", CapabilityFlags::NAVIDROME_NATIVE_BULK) + .unwrap(); + + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + + // Re-probe without a Navidrome token (transient /auth/login failure). + // R7-15 Q3: the server still supports /api/song — the flag must stay. + let result = super::probe_and_persist( + &store, + &test_subsonic_client(&server.uri()), + None, + "s1", + "", + ) + .await + .unwrap(); + assert!( + result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK), + "result must keep the previously-learned N1 capability" + ); + let persisted = sync_state.get_capability_flags("s1", "").unwrap().unwrap(); + assert!( + persisted & CapabilityFlags::NAVIDROME_NATIVE_BULK != 0, + "persisted flags must keep N1 across a token-less probe" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_does_not_clobber_track_count_when_scan_status_omits_it() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + // A prior run already learned the count. + sync_state.set_server_track_count("s1", "", 52_000).unwrap(); + + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; // scanStatus has no count + + let result = + super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "") + .await + .unwrap(); + assert_eq!(result.server_track_count, None); + // Watermark from the prior run survives the count-less probe. + assert_eq!( + sync_state.get_server_track_count("s1", "").unwrap(), + Some(52_000) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_leaves_navidrome_native_bulk_clear_when_endpoint_404s() { + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let nav = NavidromeProbeCredentials { + server_url: server.uri(), + bearer_token: "nd-tok".into(), + }; + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav)) + .await + .unwrap(); + assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/cursor.rs b/src-tauri/crates/psysonic-library/src/sync/cursor.rs new file mode 100644 index 00000000..eebcedcc --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/cursor.rs @@ -0,0 +1,157 @@ +//! Cursor shape persisted in `sync_state.initial_sync_cursor_json`. +//! Resume after a restart, kill, or app crash deserializes this value +//! and tells the runner where to pick up. Strategy is recorded so a +//! cap-flag change between runs is detected and the stale cursor is +//! reset — the runner restarts ingest under the newly-selected strategy +//! rather than resuming the wrong loop. + +use serde::{Deserialize, Serialize}; + +use super::strategy::IngestStrategy; + +/// Top-level cursor. `phase` advances `Ingest → ArtistPass → Watermarks +/// → Done`; each phase only reads the fields it owns. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InitialSyncCursor { + /// Ingest strategy in flight — stored as the tag string so the JSON + /// is human-readable on disk. + pub strategy: String, + pub phase: CursorPhase, + /// Scope this run operates on (Navidrome `library_id` / Subsonic + /// `musicFolderId`). `None` means "all libraries on this server". + #[serde(default)] + pub library_scope: Option, + /// Tracks ingested across the entire run so far — informational, + /// matches the §6 progress event payload. + #[serde(default)] + pub ingested_count: u32, + /// Per-strategy offset state. Discriminated by `strategy` so a + /// future field add doesn't break old cursors. + #[serde(default)] + pub strategy_state: StrategyState, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CursorPhase { + Ingest, + ArtistPass, + Watermarks, + Done, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum StrategyState { + /// N1 / S1 — single linear offset. + LinearOffset { offset: u32 }, + /// S2 — outer album-list offset plus optional in-flight album we + /// were halfway through when interrupted. + AlbumCrawl { + album_offset: u32, + #[serde(default)] + current_album_id: Option, + }, + /// Fresh cursor with no progress yet. + #[default] + Empty, +} + +impl InitialSyncCursor { + pub fn fresh(strategy: IngestStrategy, library_scope: Option) -> Self { + let strategy_state = match strategy { + IngestStrategy::N1 | IngestStrategy::S1 => StrategyState::LinearOffset { offset: 0 }, + IngestStrategy::S2 => StrategyState::AlbumCrawl { + album_offset: 0, + current_album_id: None, + }, + IngestStrategy::S3 => StrategyState::Empty, + }; + Self { + strategy: strategy.as_tag().to_string(), + phase: CursorPhase::Ingest, + library_scope, + ingested_count: 0, + strategy_state, + } + } + + pub fn strategy_tag(&self) -> &str { + &self.strategy + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn fresh_n1_starts_at_offset_zero() { + let c = InitialSyncCursor::fresh(IngestStrategy::N1, None); + assert_eq!(c.phase, CursorPhase::Ingest); + assert_eq!(c.ingested_count, 0); + match c.strategy_state { + StrategyState::LinearOffset { offset } => assert_eq!(offset, 0), + other => panic!("expected LinearOffset, got {other:?}"), + } + } + + #[test] + fn fresh_s2_uses_album_crawl_state() { + let c = InitialSyncCursor::fresh(IngestStrategy::S2, Some("lib-1".into())); + assert_eq!(c.library_scope.as_deref(), Some("lib-1")); + match c.strategy_state { + StrategyState::AlbumCrawl { album_offset, current_album_id } => { + assert_eq!(album_offset, 0); + assert!(current_album_id.is_none()); + } + other => panic!("expected AlbumCrawl, got {other:?}"), + } + } + + #[test] + fn cursor_roundtrips_through_json() { + // The cursor lives as TEXT in `sync_state.initial_sync_cursor_json`; + // serde must keep the round trip stable for resume to work. + let c = InitialSyncCursor { + strategy: "n1".into(), + phase: CursorPhase::Ingest, + library_scope: Some("lib-1".into()), + ingested_count: 2500, + strategy_state: StrategyState::LinearOffset { offset: 2500 }, + }; + let json = serde_json::to_value(&c).unwrap(); + let back: InitialSyncCursor = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(c, back); + + // strategy_state internally tagged with `"kind"`. + assert_eq!( + json.get("strategy_state").and_then(|s| s.get("kind")), + Some(&json!("linear_offset")) + ); + } + + #[test] + fn cursor_deserialize_tolerates_omitted_defaults() { + // Minimal cursor produced by a much older client must still + // deserialize as a "fresh ingest" state. + let raw = json!({ + "strategy": "s1", + "phase": "ingest" + }); + let c: InitialSyncCursor = serde_json::from_value(raw).unwrap(); + assert_eq!(c.ingested_count, 0); + assert_eq!(c.library_scope, None); + assert!(matches!(c.strategy_state, StrategyState::Empty)); + } + + #[test] + fn empty_object_does_not_parse_as_cursor() { + // sync_state.initial_sync_cursor_json default is `'{}'`; that + // value is not a valid cursor (no strategy / phase). Runner + // must treat it as "no cursor → start fresh". + let raw = json!({}); + assert!(serde_json::from_value::(raw).is_err()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/delta.rs b/src-tauri/crates/psysonic-library/src/sync/delta.rs new file mode 100644 index 00000000..4e1c5fb1 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/delta.rs @@ -0,0 +1,1049 @@ +//! 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::{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, + /// `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, + server_id: String, + library_scope: String, + capability_flags: CapabilityFlags, + cancel: Option>, + 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, + progress: Arc, +} + +impl<'a> DeltaSyncRunner<'a> { + pub fn new( + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + server_id: impl Into, + library_scope: impl Into, + 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) -> 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) -> 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 { + 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, SyncError> { + self.store + .with_conn("misc", |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>(0), + ) + }) + .map_err(SyncError::Storage) + } + + fn local_album_ids(&self) -> Result, SyncError> { + self.store + .with_conn("misc", |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> = 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 { + 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 = 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 = 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 = Vec::with_capacity(album.song.len()); + for (i, song) in album.song.iter().enumerate() { + let raw = raw_songs + .get(i) + .cloned() + .unwrap_or_else(|| serde_json::to_value(song).unwrap_or(Value::Null)); + 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, + next_artists_watermark: Option, +} + +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 +where + F: FnMut() -> FFut, + FFut: std::future::Future>, +{ + 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,) = 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 + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/error.rs b/src-tauri/crates/psysonic-library/src/sync/error.rs new file mode 100644 index 00000000..27189962 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/error.rs @@ -0,0 +1,149 @@ +//! Errors surfaced by the sync orchestrator. Designed so callers +//! (PR-5 Tauri command surface) can pattern-match on the variant for +//! UI affordances — `Cancelled` is silent, `Transport`/`HttpStatus` +//! retry, `Subsonic { code: 70, .. }` flows into the tombstone path, +//! and `StrategyUnsupported` surfaces a Settings hint. + +use std::fmt; + +use psysonic_integration::subsonic::SubsonicError; + +#[derive(Debug)] +pub enum SyncError { + /// Network / TLS / DNS failure surfaced by the Subsonic or + /// Navidrome HTTP client. Retryable per §6.8. + Transport(String), + + /// Subsonic-level error after the envelope parsed cleanly. The + /// dedicated `NotFound` (error code 70) is kept inline rather + /// than collapsed into a string so the tombstone path can match. + Subsonic { code: i32, message: String }, + + /// Subsonic returned error code 70 — track missing from the + /// server. Tombstone reconciler matches on this variant directly. + NotFound, + + /// Navidrome native REST returned a non-success status; carries + /// the flattened HTTP error string from `nd_err`. + Navidrome(String), + + /// Persistence layer (SQLite) failure. + Storage(String), + + /// Strategy is enumerated but not implemented for v1 + /// (currently only `S3`). + StrategyUnsupported { + strategy: &'static str, + }, + + /// Cancellation token tripped — caller asked us to abort. + /// Cursor stays where it was so the next run resumes the batch. + Cancelled, + + /// Cursor JSON in `sync_state` is from an incompatible strategy + /// (e.g. switching from N1 to S2 mid-sync). Caller should clear + /// the cursor and restart initial sync. + CursorIncompatible { + expected: &'static str, + actual: String, + }, +} + +impl fmt::Display for SyncError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Transport(m) => write!(f, "sync transport: {m}"), + Self::Subsonic { code, message } => write!(f, "subsonic {code}: {message}"), + Self::NotFound => write!(f, "subsonic: not found (code 70)"), + Self::Navidrome(m) => write!(f, "navidrome: {m}"), + Self::Storage(m) => write!(f, "storage: {m}"), + Self::StrategyUnsupported { strategy } => { + write!(f, "ingest strategy not supported: {strategy}") + } + Self::Cancelled => write!(f, "sync cancelled"), + Self::CursorIncompatible { expected, actual } => write!( + f, + "sync cursor strategy mismatch: cursor says `{actual}`, runner is `{expected}`" + ), + } + } +} + +impl std::error::Error for SyncError {} + +impl SyncError { + /// Parsed HTTP status when this is a Navidrome native REST failure + /// shaped like `HTTP 500` or `HTTP 500: body`. + pub fn navidrome_http_status(&self) -> Option { + let SyncError::Navidrome(msg) = self else { + return None; + }; + let rest = msg.strip_prefix("HTTP ")?.split(':').next()?.trim(); + rest.split_whitespace().next()?.parse().ok() + } +} + +impl From for SyncError { + fn from(e: SubsonicError) -> Self { + match e { + SubsonicError::Transport(m) => Self::Transport(m), + SubsonicError::HttpStatus(s) => Self::Transport(format!("http {s}")), + SubsonicError::Api { code, message } => Self::Subsonic { code, message }, + SubsonicError::NotFound => Self::NotFound, + SubsonicError::Decode(m) => Self::Transport(format!("decode: {m}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subsonic_not_found_maps_to_sync_not_found() { + let e: SyncError = SubsonicError::NotFound.into(); + assert!(matches!(e, SyncError::NotFound)); + } + + #[test] + fn subsonic_api_error_carries_code_through() { + let e: SyncError = SubsonicError::Api { code: 40, message: "bad creds".into() }.into(); + match e { + SyncError::Subsonic { code, message } => { + assert_eq!(code, 40); + assert!(message.contains("bad creds")); + } + other => panic!("expected Subsonic, got {other:?}"), + } + } + + #[test] + fn http_status_collapses_into_transport() { + let e: SyncError = SubsonicError::HttpStatus(reqwest::StatusCode::SERVICE_UNAVAILABLE).into(); + assert!(matches!(e, SyncError::Transport(ref m) if m.contains("503"))); + } + + #[test] + fn navidrome_http_status_parses_status_line() { + let e = SyncError::Navidrome("HTTP 500".into()); + assert_eq!(e.navidrome_http_status(), Some(500)); + let with_body = SyncError::Navidrome("HTTP 503: upstream timeout".into()); + assert_eq!(with_body.navidrome_http_status(), Some(503)); + let with_reason = SyncError::Navidrome("HTTP 500 Internal Server Error".into()); + assert_eq!(with_reason.navidrome_http_status(), Some(500)); + assert_eq!( + SyncError::Transport("http 500".into()).navidrome_http_status(), + None + ); + } + + #[test] + fn cursor_incompatible_renders_both_strategies() { + let s = SyncError::CursorIncompatible { + expected: "n1", + actual: "s2".into(), + } + .to_string(); + assert!(s.contains("n1") && s.contains("s2")); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/ingest_parallel.rs b/src-tauri/crates/psysonic-library/src/sync/ingest_parallel.rs new file mode 100644 index 00000000..12e7cc07 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/ingest_parallel.rs @@ -0,0 +1,226 @@ +//! Concurrent fetch helpers for initial-sync ingest (C11 parallelism budget). + +use std::collections::BTreeMap; +use std::future::Future; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use psysonic_integration::subsonic::{Album, SubsonicClient}; +use serde_json::Value; +use tokio::sync::Semaphore; +use tokio::task::JoinHandle; + +use super::backoff::{jitter_salt, with_jitter, Backoff}; +use super::bandwidth::ParallelismBudget; +use super::error::SyncError; + +const MAX_ATTEMPTS_PER_BATCH: u32 = 5; + +pub fn check_cancel_flag(cancel: &Option>) -> Result<(), SyncError> { + if cancel.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) { + return Err(SyncError::Cancelled); + } + Ok(()) +} + +fn is_retryable(e: &SyncError) -> bool { + matches!(e, SyncError::Transport(_) | SyncError::Navidrome(_)) +} + +/// How many linear pages to keep in flight (`1` = sequential). +pub fn linear_prefetch_depth(budget: &ParallelismBudget) -> usize { + if budget.bulk_paused() { + return 1; + } + budget.max_concurrent.max(1) as usize +} + +pub async fn sleep_request_gap(budget: &ParallelismBudget, sleep_enabled: bool) { + if sleep_enabled && budget.min_request_gap_ms > 0 { + tokio::time::sleep(Duration::from_millis(budget.min_request_gap_ms as u64)).await; + } +} + +pub async fn wait_while_bulk_paused( + budget: &ParallelismBudget, + sleep_enabled: bool, + mut check_cancel: impl FnMut() -> Result<(), SyncError>, +) -> Result<(), SyncError> { + while budget.bulk_paused() { + check_cancel()?; + if sleep_enabled { + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + Ok(()) +} + +/// Standalone retry loop for spawned prefetch tasks (no `InitialSyncRunner` ref). +pub async fn retry_fetch( + sleep_enabled: bool, + mut check_cancel: impl FnMut() -> Result<(), SyncError>, + mut build: F, + map_err: impl Fn(E) -> SyncError, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut backoff = Backoff::default(); + let mut attempt = 0u32; + loop { + check_cancel()?; + 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)); + if sleep_enabled && !jittered.is_zero() { + tokio::time::sleep(jittered).await; + } + } + } + } +} + +/// Ordered in-flight page queue for N1/S1 linear ingest. +pub struct LinearPrefetchQueue { + depth: usize, + batch_size: u32, + next_enqueue_offset: u32, + exhausted: bool, + inflight: BTreeMap>>, +} + +impl LinearPrefetchQueue { + pub fn new(budget: &ParallelismBudget, batch_size: u32, start_offset: u32) -> Self { + Self { + depth: linear_prefetch_depth(budget), + batch_size, + next_enqueue_offset: start_offset, + exhausted: false, + inflight: BTreeMap::new(), + } + } + + pub fn mark_exhausted(&mut self) { + self.exhausted = true; + } + + pub fn pump( + &mut self, + mut check_cancel: impl FnMut() -> Result<(), SyncError>, + mut spawn: F, + ) -> Result<(), SyncError> + where + F: FnMut(u32) -> JoinHandle>, + { + while !self.exhausted && self.inflight.len() < self.depth { + check_cancel()?; + let offset = self.next_enqueue_offset; + self.next_enqueue_offset = offset.saturating_add(self.batch_size); + let handle = spawn(offset); + self.inflight.insert(offset, handle); + } + Ok(()) + } + + pub async fn take_at( + &mut self, + offset: u32, + mut check_cancel: impl FnMut() -> Result<(), SyncError>, + ) -> Result, SyncError> { + check_cancel()?; + let Some(handle) = self.inflight.remove(&offset) else { + return Ok(None); + }; + let value = handle + .await + .map_err(|e| SyncError::Transport(format!("prefetch join: {e}")))??; + Ok(Some(value)) + } +} + +/// Fetch `getAlbum` bodies for a page with at most `budget.max_concurrent` +/// requests in flight. Results preserve input order. +#[derive(Clone)] +pub struct ParallelAlbumFetchOpts { + pub budget: ParallelismBudget, + pub sleep_enabled: bool, + pub cancel: Option>, +} + +pub async fn fetch_albums_parallel( + subsonic: &SubsonicClient, + album_ids: &[String], + opts: ParallelAlbumFetchOpts, +) -> Result, SyncError> { + if album_ids.is_empty() { + return Ok(Vec::new()); + } + wait_while_bulk_paused(&opts.budget, opts.sleep_enabled, || check_cancel_flag(&opts.cancel)).await?; + let max = opts.budget.max_concurrent.max(1) as usize; + let client = subsonic.clone(); + let sem = Arc::new(Semaphore::new(max)); + let mut handles: Vec>> = + Vec::with_capacity(album_ids.len()); + + for id in album_ids { + check_cancel_flag(&opts.cancel)?; + sleep_request_gap(&opts.budget, opts.sleep_enabled).await; + let permit = sem + .clone() + .acquire_owned() + .await + .map_err(|_| SyncError::Transport("parallel fetch semaphore closed".into()))?; + let client = client.clone(); + let id = id.clone(); + let cancel = opts.cancel.clone(); + let sleep_enabled = opts.sleep_enabled; + handles.push(tokio::spawn(async move { + let _permit = permit; + retry_fetch( + sleep_enabled, + || check_cancel_flag(&cancel), + || async { + client + .get_album_with_raw(&id) + .await + .map_err(SyncError::from) + }, + |e| e, + ) + .await + })); + } + + let mut out = Vec::with_capacity(handles.len()); + for handle in handles { + check_cancel_flag(&opts.cancel)?; + out.push( + handle + .await + .map_err(|e| SyncError::Transport(format!("parallel album fetch join: {e}")))?? + ); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linear_prefetch_depth_respects_budget() { + let idle = ParallelismBudget::resolve(super::super::bandwidth::PlaybackHint::Idle); + assert_eq!(linear_prefetch_depth(&idle), 4); + let playing = ParallelismBudget::resolve(super::super::bandwidth::PlaybackHint::Playing); + assert_eq!(linear_prefetch_depth(&playing), 1); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/initial.rs b/src-tauri/crates/psysonic-library/src/sync/initial.rs new file mode 100644 index 00000000..461a0b70 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/initial.rs @@ -0,0 +1,2210 @@ +//! `InitialSyncRunner` — spec §6.3 IS-1 … IS-6. PR-3b lands the runner, +//! cursor persistence, and the N1/S1/S2 ingest loops. S3 (file-tree) +//! is enumerated but returns `StrategyUnsupported`. IS-4 artist pass + +//! IS-5 watermarks run after the bulk loop completes. +//! +//! The runner is pure Rust — no Tauri events, no background task +//! lifecycle. PR-3d wires it into a `tokio::task::spawn` shell with +//! progress emit + the cancellation token; PR-3b only ships the +//! library-side function the shell will call. + +use std::sync::atomic::{AtomicBool, 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::bandwidth::ParallelismBudget; +use super::capability::{CapabilityFlags, NavidromeProbeCredentials}; +use super::cursor::{CursorPhase, InitialSyncCursor, StrategyState}; +use super::error::SyncError; +use super::ingest_parallel::{ + check_cancel_flag, fetch_albums_parallel, linear_prefetch_depth, retry_fetch, + sleep_request_gap, wait_while_bulk_paused, LinearPrefetchQueue, ParallelAlbumFetchOpts, +}; +use super::mapping::{navidrome_song_to_track_row, subsonic_song_to_track_row}; +use super::progress::{IngestBatchMetrics, NoopProgress, Progress, ProgressEvent}; +use super::strategy::IngestStrategy; +use crate::bulk_ingest::{restore_track_secondary_indexes, suspend_track_secondary_indexes}; +use crate::repos::{RemapStats, SyncStateRepository, TrackRepository, TrackRow}; +use crate::store::LibraryStore; +use crate::store::WriteOpTiming; +use crate::track_fts::{ + rebuild_track_fts_from_content, restore_track_fts_triggers, suspend_track_fts_triggers, +}; + +/// Bulk ingest batch size per spec §6.3 (`batch=500`). +const DEFAULT_BATCH_SIZE: u32 = 500; + +/// Persist initial-sync cursor every N ingest batches (not every batch). +/// S2 already persists once per album-list page; N1/S1 match ~prefetch depth. +const CURSOR_PERSIST_EVERY_BATCHES: u32 = 4; + +/// Maximum attempts per batch before `SyncError::Transport` propagates. +/// Caller (Settings „retry" / PR-3d scheduler) can wrap and retry the +/// whole run if needed. +const MAX_ATTEMPTS_PER_BATCH: u32 = 5; + +/// Suspends FTS + secondary indexes for IS-3; restores on drop. +struct BulkIngestGuard<'a> { + store: &'a LibraryStore, +} + +impl Drop for BulkIngestGuard<'_> { + fn drop(&mut self) { + self.store.set_bulk_ingest_active(false); + let start = std::time::Instant::now(); + match self.store.with_conn_mut("bulk.finalize", |conn| { + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.pragma_update(None, "wal_autocheckpoint", 1000)?; + restore_track_secondary_indexes(conn)?; + let _: (i32, i32, i32) = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + rebuild_track_fts_from_content(conn)?; + restore_track_fts_triggers(conn) + }) { + Ok(()) => crate::app_eprintln!( + "[library-sync] bulk ingest finalized in {}ms (indexes + WAL + FTS)", + start.elapsed().as_millis() + ), + Err(e) => { + crate::app_eprintln!("[library-sync] bulk ingest finalize failed: {e}") + } + } + } +} + +/// N1 deep-offset safety line (R7-15 Q1/Q5). A `GET /api/song` HTTP 500 at +/// or beyond this offset is treated as Navidrome's server-side deep-offset +/// wall (observed past ~50k), not a transient error: the run learns +/// `n1_bulk_unreliable` and falls back to S1 rather than retrying smaller +/// windows that cannot recover. +const N1_DEEP_OFFSET_SAFE: u32 = 50_000; + +/// Summary returned from `InitialSyncRunner::run`. Caller emits a +/// completion event with these numbers (PR-3d). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InitialSyncReport { + pub strategy: Option, + pub ingested_count: u32, + pub remapped_count: u32, +} + +struct IngestPageCtx<'a> { + cursor: &'a mut InitialSyncCursor, + report: &'a mut InitialSyncReport, + sync_state: &'a SyncStateRepository<'a>, + batch_count: &'a mut u32, + force_persist: bool, +} + +pub struct InitialSyncRunner<'a> { + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + navidrome: Option, + server_id: String, + library_scope: String, + capability_flags: CapabilityFlags, + cancel: Option>, + batch_size: u32, + n1_deep_offset_safe: u32, + sleep_enabled: bool, + progress: Arc, + parallelism: ParallelismBudget, +} + +impl<'a> InitialSyncRunner<'a> { + pub fn new( + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + server_id: impl Into, + library_scope: impl Into, + 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, + n1_deep_offset_safe: N1_DEEP_OFFSET_SAFE, + sleep_enabled: true, + progress: Arc::new(NoopProgress), + parallelism: ParallelismBudget::resolve(super::bandwidth::PlaybackHint::Idle), + } + } + + pub fn with_progress(mut self, progress: Arc) -> Self { + self.progress = progress; + self + } + + pub fn with_navidrome_credentials(mut self, creds: NavidromeProbeCredentials) -> Self { + self.navidrome = Some(creds); + self + } + + pub fn with_cancellation(mut self, flag: Arc) -> Self { + self.cancel = Some(flag); + self + } + + pub fn with_batch_size(mut self, n: u32) -> Self { + if n > 0 { + self.batch_size = n; + } + self + } + + /// Override the N1 deep-offset wall line. Tests pin this low so the + /// N1→S1 fallback can be exercised without 50k rows of fixture data; + /// production uses the `N1_DEEP_OFFSET_SAFE` default. + pub fn with_n1_deep_offset_safe(mut self, n: u32) -> Self { + self.n1_deep_offset_safe = n; + self + } + + /// Disable real sleep between backoff attempts. Tests pin this so + /// `503 → success on retry` exercises the retry loop in + /// milliseconds instead of seconds. Production code leaves it on. + pub fn with_sleep_disabled(mut self) -> Self { + self.sleep_enabled = false; + self + } + + /// C11 — bulk crawl parallelism from the runtime playback hint. + pub fn with_parallelism_budget(mut self, budget: ParallelismBudget) -> Self { + self.parallelism = budget; + self + } + + fn parallelism_budget(&self) -> ParallelismBudget { + self.parallelism + } + + /// IS-1 → IS-6. Resumes from `sync_state.initial_sync_cursor_json` + /// when a cursor is already persisted; otherwise picks a strategy + /// from `capability_flags` and starts fresh. + pub async fn run(&self) -> Result { + let sync_state = SyncStateRepository::new(self.store); + sync_state + .ensure(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + + // IS-1 — phase=initial_sync. + sync_state + .set_sync_phase(&self.server_id, &self.library_scope, "initial_sync") + .map_err(SyncError::Storage)?; + self.progress.emit(ProgressEvent::PhaseChanged { + phase: "initial_sync".into(), + }); + + let mut cursor = self.load_or_init_cursor(&sync_state)?; + let mut report = InitialSyncReport { + strategy: Some(cursor.strategy.clone()), + ingested_count: cursor.ingested_count, + remapped_count: 0, + }; + let strategy = IngestStrategy::from_tag(&cursor.strategy).ok_or_else(|| { + SyncError::CursorIncompatible { + expected: "n1|s1|s2|s3", + actual: cursor.strategy.clone(), + } + })?; + + // IS-3 — bulk ingest per strategy. + if cursor.phase == CursorPhase::Ingest { + self.store.set_bulk_ingest_active(true); + self.store + .with_conn_mut("bulk.begin", |conn| { + suspend_track_fts_triggers(conn)?; + suspend_track_secondary_indexes(conn)?; + conn.pragma_update(None, "synchronous", "OFF")?; + conn.pragma_update(None, "wal_autocheckpoint", 0)?; + conn.pragma_update(None, "cache_size", -128_000)?; + Ok(()) + }) + .map_err(SyncError::Storage)?; + crate::app_eprintln!( + "[library-sync] IS-3 bulk ingest: FTS/indexes suspended, sync=OFF" + ); + let _bulk = BulkIngestGuard { store: self.store }; + + match strategy { + IngestStrategy::N1 => self.run_n1(&mut cursor, &mut report, &sync_state).await?, + IngestStrategy::S1 => self.run_s1(&mut cursor, &mut report, &sync_state).await?, + IngestStrategy::S2 => self.run_s2(&mut cursor, &mut report, &sync_state).await?, + IngestStrategy::S3 => { + return Err(SyncError::StrategyUnsupported { strategy: "s3" }) + } + } + self.link_canonical_after_bulk_ingest()?; + cursor.phase = CursorPhase::ArtistPass; + self.persist_cursor(&sync_state, &cursor)?; + } + + // IS-4 — optional artist/album index pass via `getArtists`. + if cursor.phase == CursorPhase::ArtistPass { + self.run_artist_pass(&sync_state).await?; + cursor.phase = CursorPhase::Watermarks; + self.persist_cursor(&sync_state, &cursor)?; + } + + // IS-5 — watermarks (server_last_scan_iso, server_track_count, + // artists_last_modified_ms) so DS-0 polls can short-circuit. + if cursor.phase == CursorPhase::Watermarks { + self.run_watermark_pass(&sync_state).await?; + cursor.phase = CursorPhase::Done; + self.persist_cursor(&sync_state, &cursor)?; + } + + // IS-6 — phase=ready, clear cursor, stamp watermarks. + let finished_at = now_unix_ms(); + let local_count = crate::dto::count_local_tracks(self.store, &self.server_id) + .map_err(SyncError::Storage)?; + sync_state + .set_local_track_count(&self.server_id, &self.library_scope, local_count) + .map_err(SyncError::Storage)?; + sync_state + .set_last_full_sync_at(&self.server_id, &self.library_scope, finished_at) + .map_err(SyncError::Storage)?; + sync_state + .set_sync_phase(&self.server_id, &self.library_scope, "ready") + .map_err(SyncError::Storage)?; + sync_state + .set_initial_sync_cursor( + &self.server_id, + &self.library_scope, + &Value::Object(serde_json::Map::new()), + ) + .map_err(SyncError::Storage)?; + self.progress.emit(ProgressEvent::Completed { + kind: "initial_sync".into(), + }); + + Ok(report) + } + + // ── cursor / persistence ─────────────────────────────────────────── + + fn load_or_init_cursor( + &self, + sync_state: &SyncStateRepository<'_>, + ) -> Result { + let raw = sync_state + .get_initial_sync_cursor(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + // R7-15 Q4: pick with the large-library policy, not just the cap + // flags. `server_track_count` (probe `getScanStatus` count or a prior + // watermark) and the learned `n1_bulk_unreliable` flag steer large + // catalogs onto S1 instead of N1's deep-offset wall. + let server_track_count = sync_state + .get_server_track_count(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + let n1_bulk_unreliable = sync_state + .get_n1_bulk_unreliable(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)? + .unwrap_or(false); + let selected_strategy = IngestStrategy::select_initial_strategy( + self.capability_flags, + server_track_count, + n1_bulk_unreliable, + ); + if let Some(raw) = raw { + if !is_empty_cursor(&raw) { + match serde_json::from_value::(raw) { + Ok(parsed) => { + let has_progress = parsed.ingested_count > 0 + || parsed.phase != CursorPhase::Ingest; + // R7-15 Q3: freeze the in-flight strategy on resume. + // Once a run has made progress, a re-probe that now + // picks a different strategy (the Navidrome bearer + // flapped, or the large-library gate resolves + // differently) must NOT reset the cursor — that + // restarts ingest from offset 0 on every launch, which + // is exactly why large syncs never completed. Resume + // under the cursor's own strategy and ignore the + // probe's pick. Exception: a cursor still on N1 after + // the server was learned `n1_bulk_unreliable` is + // known-broken — fall through and re-select (the + // mid-run N1→S1 fallback normally rewrites such a + // cursor in place, preserving progress). + let frozen_strategy_known_broken = n1_bulk_unreliable + && parsed.strategy == IngestStrategy::N1.as_tag(); + if has_progress && !frozen_strategy_known_broken { + return Ok(parsed); + } + // No resumable progress (offset 0) or a known-broken + // N1 cursor: adopting the freshly-selected strategy + // costs nothing, so take it. Re-ingest is idempotent + // (upsert) and the tombstone pass reconciles leftovers. + if parsed.strategy == selected_strategy.as_tag() { + return Ok(parsed); + } + crate::app_eprintln!( + "[library-sync] re-selecting initial-sync strategy for server \ + `{}`: was `{}` (no resumable progress), now `{}`", + self.server_id, + parsed.strategy, + selected_strategy.as_tag() + ); + } + Err(e) => { + // A corrupt/unreadable cursor can't drive resume; reset + // rather than hard-error (which would brick every future + // sync with no UI recovery path). + crate::app_eprintln!( + "[library-sync] resetting unreadable initial-sync cursor for \ + server `{}` ({e}); starting fresh", + self.server_id + ); + } + } + } + } + let scope = if self.library_scope.is_empty() { + None + } else { + Some(self.library_scope.clone()) + }; + let fresh = InitialSyncCursor::fresh(selected_strategy, scope); + self.persist_cursor(sync_state, &fresh)?; + Ok(fresh) + } + + fn persist_cursor( + &self, + sync_state: &SyncStateRepository<'_>, + cursor: &InitialSyncCursor, + ) -> Result<(), SyncError> { + let value = serde_json::to_value(cursor) + .map_err(|e| SyncError::Storage(format!("serialize cursor: {e}")))?; + sync_state + .set_initial_sync_cursor_and_local_track_count( + &self.server_id, + &self.library_scope, + &value, + i64::from(cursor.ingested_count), + ) + .map_err(SyncError::Storage) + } + + fn check_cancellation(&self) -> Result<(), SyncError> { + if let Some(flag) = &self.cancel { + if flag.load(Ordering::SeqCst) { + return Err(SyncError::Cancelled); + } + } + Ok(()) + } + + 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_timed(&self, rows: &[TrackRow]) -> Result { + TrackRepository::new(self.store) + .upsert_batch_initial_ingest_timed(rows) + .map_err(SyncError::Storage) + } + + fn write_batch_logged( + &self, + rows: &[TrackRow], + label: &str, + offset: u32, + ) -> Result<(RemapStats, WriteOpTiming), SyncError> { + let timing = self.write_batch_timed(rows)?; + let total_ms = timing.total_ms(); + if total_ms >= 500 { + crate::app_eprintln!( + "[library-sync] {label} offset={offset} rows={} write_ms={total_ms} lock_wait_ms={} sql_exec_ms={} (slow batch)", + rows.len(), + timing.lock_wait_ms, + timing.exec_ms, + ); + } else { + crate::app_eprintln!( + "[library-sync] {label} offset={offset} rows={} write_ms={total_ms} lock_wait_ms={} sql_exec_ms={}", + rows.len(), + timing.lock_wait_ms, + timing.exec_ms, + ); + } + Ok((RemapStats::default(), timing)) + } + + fn link_canonical_after_bulk_ingest(&self) -> Result<(), SyncError> { + let start = std::time::Instant::now(); + let linked = crate::canonical::link_all_tracks_for_server( + self.store, + &self.server_id, + now_unix_ms(), + ) + .map_err(SyncError::Storage)?; + crate::app_eprintln!( + "[library-sync] canonical bulk link server `{}`: {linked} tracks in {}ms", + self.server_id, + start.elapsed().as_millis() + ); + Ok(()) + } + + // ── N1 (Navidrome native /api/song) ──────────────────────────────── + + async fn run_n1( + &self, + cursor: &mut InitialSyncCursor, + report: &mut InitialSyncReport, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + let creds = self.navidrome.as_ref().ok_or_else(|| SyncError::Transport( + "n1 strategy selected but no Navidrome credentials supplied".into(), + ))?; + let mut offset = match cursor.strategy_state { + StrategyState::LinearOffset { offset } => offset, + ref other => { + return Err(SyncError::Storage(format!( + "n1 expected linear-offset cursor, got {other:?}" + ))) + } + }; + + let budget = self.parallelism_budget(); + let prefetch = linear_prefetch_depth(&budget); + crate::app_eprintln!( + "[library-sync] N1 ingest server `{}`: prefetch_depth={} max_concurrent={} batch_size={}", + self.server_id, + prefetch, + budget.max_concurrent, + self.batch_size + ); + let mut batch_count: u32 = 0; + if prefetch <= 1 { + loop { + wait_while_bulk_paused(&budget, self.sleep_enabled, || self.check_cancellation()) + .await?; + self.check_cancellation()?; + sleep_request_gap(&budget, self.sleep_enabled).await; + let array = match self.fetch_n1_page(creds, offset).await { + Err(e) if self.n1_hit_deep_offset_wall(&e, offset) => { + return self.fall_back_n1_to_s1(cursor, report, sync_state).await; + } + other => other?, + }; + if array.is_empty() { + break; + } + offset = self + .ingest_n1_page( + &array, + offset, + &mut IngestPageCtx { + cursor, + report, + sync_state, + batch_count: &mut batch_count, + force_persist: (array.len() as u32) < self.batch_size, + }, + ) + .await?; + if (array.len() as u32) < self.batch_size { + break; + } + } + self.persist_cursor(sync_state, cursor)?; + return Ok(()); + } + + let batch_size = self.batch_size; + let cancel = self.cancel.clone(); + let sleep_enabled = self.sleep_enabled; + let creds = creds.clone(); + let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset); + + loop { + wait_while_bulk_paused(&budget, self.sleep_enabled, || self.check_cancellation()) + .await?; + self.check_cancellation()?; + + queue.pump(|| self.check_cancellation(), |off| { + let creds = creds.clone(); + let cancel = cancel.clone(); + tokio::spawn(async move { + retry_fetch( + sleep_enabled, + || check_cancel_flag(&cancel), + || async { + let end = off.saturating_add(batch_size); + let response = nd_list_songs_internal( + &creds.server_url, + &creds.bearer_token, + "id", + "ASC", + off, + end, + ) + .await + .map_err(SyncError::Navidrome)?; + Ok(response.as_array().cloned().unwrap_or_default()) + }, + |e| e, + ) + .await + }) + })?; + + let array = match queue + .take_at(offset, || self.check_cancellation()) + .await + { + Err(e) if self.n1_hit_deep_offset_wall(&e, offset) => { + return self.fall_back_n1_to_s1(cursor, report, sync_state).await; + } + Err(e) => return Err(e), + Ok(Some(page)) => page, + Ok(None) => { + sleep_request_gap(&budget, self.sleep_enabled).await; + match self.fetch_n1_page(&creds, offset).await { + Err(e) if self.n1_hit_deep_offset_wall(&e, offset) => { + return self.fall_back_n1_to_s1(cursor, report, sync_state).await; + } + other => other?, + } + } + }; + + if array.is_empty() { + break; + } + + offset = self + .ingest_n1_page( + &array, + offset, + &mut IngestPageCtx { + cursor, + report, + sync_state, + batch_count: &mut batch_count, + force_persist: (array.len() as u32) < self.batch_size, + }, + ) + .await?; + + if (array.len() as u32) < self.batch_size { + queue.mark_exhausted(); + break; + } + } + self.persist_cursor(sync_state, cursor)?; + Ok(()) + } + + async fn fetch_n1_page( + &self, + creds: &NavidromeProbeCredentials, + offset: u32, + ) -> Result, SyncError> { + let end = offset.saturating_add(self.batch_size); + let response = match retry_with_backoff( + self, + || { + nd_list_songs_internal( + &creds.server_url, + &creds.bearer_token, + "id", + "ASC", + offset, + end, + ) + }, + SyncError::Navidrome, + ) + .await + { + Ok(v) => v, + Err(e) if self.n1_hit_deep_offset_wall(&e, offset) => { + return Err(e); + } + Err(e) => return Err(e), + }; + Ok(response.as_array().cloned().unwrap_or_default()) + } + + async fn ingest_n1_page( + &self, + array: &[Value], + offset: u32, + ctx: &mut IngestPageCtx<'_>, + ) -> Result { + let synced_at = now_unix_ms(); + let rows: Vec = array + .iter() + .filter_map(|v| { + navidrome_song_to_track_row( + &self.server_id, + v, + synced_at, + self.library_scope_opt(), + ) + }) + .collect(); + let (_stats, _timing) = self.write_batch_logged(&rows, "N1", offset)?; + ctx.report.ingested_count = ctx.report.ingested_count.saturating_add(rows.len() as u32); + + let next_offset = offset.saturating_add(self.batch_size); + ctx.cursor.strategy_state = StrategyState::LinearOffset { + offset: next_offset, + }; + ctx.cursor.ingested_count = ctx.report.ingested_count; + *ctx.batch_count += 1; + if ctx.force_persist || ctx.batch_count.is_multiple_of(CURSOR_PERSIST_EVERY_BATCHES) { + self.persist_cursor(ctx.sync_state, ctx.cursor)?; + } + self.progress.emit(ProgressEvent::IngestPage { + ingested_total: ctx.report.ingested_count, + batch_count: *ctx.batch_count, + metrics: None, + }); + Ok(next_offset) + } + + /// True when an N1 error is the deep-offset wall: a persistent HTTP 500 + /// at or beyond the safety line (R7-15 Q5). A 500 at a shallow offset is + /// a different failure and propagates as an error instead. + fn n1_hit_deep_offset_wall(&self, e: &SyncError, offset: u32) -> bool { + offset >= self.n1_deep_offset_safe && e.navidrome_http_status() == Some(500) + } + + /// R7-15 Q5 — one-way N1→S1 fallback. Learn `n1_bulk_unreliable` for this + /// server, then restart ingest on S1. N1 (`id ASC`) and S1 (`search3` + /// default order) don't share an offset space, so resuming from the N1 + /// offset would skip songs — restart S1 from 0. Re-ingest is idempotent + /// (PK upsert); the duplicate work over rows N1 already wrote is + /// acceptable for v1. The cursor is rewritten in place, never zeroed away. + async fn fall_back_n1_to_s1( + &self, + cursor: &mut InitialSyncCursor, + report: &mut InitialSyncReport, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + crate::app_eprintln!( + "[library-sync] N1 hit the deep-offset wall for server `{}`; flagging \ + n1_bulk_unreliable and falling back to S1", + self.server_id + ); + sync_state + .set_n1_bulk_unreliable(&self.server_id, &self.library_scope, true) + .map_err(SyncError::Storage)?; + let scope = if self.library_scope.is_empty() { + None + } else { + Some(self.library_scope.clone()) + }; + *cursor = InitialSyncCursor::fresh(IngestStrategy::S1, scope); + report.ingested_count = 0; + report.strategy = Some(cursor.strategy.clone()); + self.persist_cursor(sync_state, cursor)?; + self.run_s1(cursor, report, sync_state).await + } + + // ── S1 (Subsonic search3 empty query) ────────────────────────────── + + async fn run_s1( + &self, + cursor: &mut InitialSyncCursor, + report: &mut InitialSyncReport, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + let mut offset = match cursor.strategy_state { + StrategyState::LinearOffset { offset } => offset, + ref other => { + return Err(SyncError::Storage(format!( + "s1 expected linear-offset cursor, got {other:?}" + ))) + } + }; + + let budget = self.parallelism_budget(); + let prefetch = linear_prefetch_depth(&budget); + crate::app_eprintln!( + "[library-sync] S1 ingest server `{}`: prefetch_depth={} max_concurrent={} batch_size={}", + self.server_id, + prefetch, + budget.max_concurrent, + self.batch_size + ); + let mut batch_count: u32 = 0; + if prefetch <= 1 { + loop { + wait_while_bulk_paused(&budget, self.sleep_enabled, || self.check_cancellation()) + .await?; + self.check_cancellation()?; + sleep_request_gap(&budget, self.sleep_enabled).await; + let fetch_start = std::time::Instant::now(); + let (result, raw_body) = match self.fetch_s1_page(offset).await { + Err(e) if is_fetch_failure(&e) => { + return self.fall_back_s1_to_s2(cursor, report, sync_state).await; + } + other => other?, + }; + let fetch_ms = fetch_start.elapsed().as_millis() as u32; + if result.song.is_empty() { + break; + } + offset = self + .ingest_s1_page( + &result, + &raw_body, + offset, + fetch_ms, + &mut IngestPageCtx { + cursor, + report, + sync_state, + batch_count: &mut batch_count, + force_persist: (result.song.len() as u32) < self.batch_size, + }, + ) + .await?; + if (result.song.len() as u32) < self.batch_size { + break; + } + } + self.persist_cursor(sync_state, cursor)?; + return Ok(()); + } + + let batch_size = self.batch_size; + let subsonic = self.subsonic.clone(); + let library_scope = self.library_scope.clone(); + let cancel = self.cancel.clone(); + let sleep_enabled = self.sleep_enabled; + let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset); + + loop { + wait_while_bulk_paused(&budget, self.sleep_enabled, || self.check_cancellation()) + .await?; + self.check_cancellation()?; + + queue.pump(|| self.check_cancellation(), |off| { + let subsonic = subsonic.clone(); + let library_scope = library_scope.clone(); + let cancel = cancel.clone(); + tokio::spawn(async move { + retry_fetch( + sleep_enabled, + || check_cancel_flag(&cancel), + || async { + let scope = if library_scope.is_empty() { + None + } else { + Some(library_scope.as_str()) + }; + subsonic + .search3_with_raw("", batch_size, off, scope) + .await + .map_err(SyncError::from) + }, + |e| e, + ) + .await + }) + })?; + + let fetch_start = std::time::Instant::now(); + let (result, raw_body) = match queue + .take_at(offset, || self.check_cancellation()) + .await + { + Err(e) if is_fetch_failure(&e) => { + return self.fall_back_s1_to_s2(cursor, report, sync_state).await; + } + Err(e) => return Err(e), + Ok(Some(page)) => page, + Ok(None) => { + sleep_request_gap(&budget, self.sleep_enabled).await; + match self.fetch_s1_page(offset).await { + Err(e) if is_fetch_failure(&e) => { + return self.fall_back_s1_to_s2(cursor, report, sync_state).await; + } + other => other?, + } + } + }; + let fetch_ms = fetch_start.elapsed().as_millis() as u32; + + if result.song.is_empty() { + break; + } + + offset = self + .ingest_s1_page( + &result, + &raw_body, + offset, + fetch_ms, + &mut IngestPageCtx { + cursor, + report, + sync_state, + batch_count: &mut batch_count, + force_persist: (result.song.len() as u32) < self.batch_size, + }, + ) + .await?; + + if (result.song.len() as u32) < self.batch_size { + queue.mark_exhausted(); + break; + } + } + self.persist_cursor(sync_state, cursor)?; + Ok(()) + } + + async fn fetch_s1_page( + &self, + offset: u32, + ) -> Result<(psysonic_integration::subsonic::SearchResult, Value), SyncError> { + let scope = self.library_scope_opt(); + retry_with_backoff( + self, + || self.subsonic.search3_with_raw("", self.batch_size, offset, scope), + SyncError::from, + ) + .await + } + + async fn ingest_s1_page( + &self, + result: &psysonic_integration::subsonic::SearchResult, + raw_body: &Value, + offset: u32, + fetch_ms: u32, + ctx: &mut IngestPageCtx<'_>, + ) -> Result { + let raw_songs = raw_body + .get("song") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let synced_at = now_unix_ms(); + let mut rows: Vec = Vec::with_capacity(result.song.len()); + for (i, song) in result.song.iter().enumerate() { + let raw = raw_songs + .get(i) + .cloned() + .unwrap_or_else(|| serde_json::to_value(song).unwrap_or(Value::Null)); + rows.push(subsonic_song_to_track_row( + &self.server_id, + song, + &raw, + synced_at, + self.library_scope_opt(), + )); + } + let row_count = rows.len() as u32; + let (_stats, write_timing) = self.write_batch_logged(&rows, "S1", offset)?; + ctx.report.ingested_count = ctx.report.ingested_count.saturating_add(row_count); + + let next_offset = offset.saturating_add(self.batch_size); + ctx.cursor.strategy_state = StrategyState::LinearOffset { + offset: next_offset, + }; + ctx.cursor.ingested_count = ctx.report.ingested_count; + *ctx.batch_count += 1; + let persist_start = std::time::Instant::now(); + let did_persist = + ctx.force_persist || ctx.batch_count.is_multiple_of(CURSOR_PERSIST_EVERY_BATCHES); + if did_persist { + self.persist_cursor(ctx.sync_state, ctx.cursor)?; + } + let persist_ms = if did_persist { + persist_start.elapsed().as_millis() as u32 + } else { + 0 + }; + self.progress.emit(ProgressEvent::IngestPage { + ingested_total: ctx.report.ingested_count, + batch_count: *ctx.batch_count, + metrics: Some(IngestBatchMetrics { + offset, + strategy: "s1".into(), + fetch_ms, + write_ms: write_timing.total_ms() as u32, + lock_wait_ms: write_timing.lock_wait_ms as u32, + sql_exec_ms: write_timing.exec_ms as u32, + persist_ms, + row_count, + bulk_ingest_active: self.store.bulk_ingest_active(), + }), + }); + Ok(next_offset) + } + + /// Q8 (R7-15) — fall back to the universal S2 album crawl when S1 fails + /// persistently. S1 (`search3` order) and S2 (album-list order) don't + /// share an offset space, so restart S2 from scratch; re-ingest is + /// idempotent (PK upsert). The cursor is rewritten in place, never zeroed. + /// No new artist-walk strategy is introduced (Q8 decision). + async fn fall_back_s1_to_s2( + &self, + cursor: &mut InitialSyncCursor, + report: &mut InitialSyncReport, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + crate::app_eprintln!( + "[library-sync] S1 failed persistently for server `{}`; falling back to \ + S2 album crawl", + self.server_id + ); + let scope = if self.library_scope.is_empty() { + None + } else { + Some(self.library_scope.clone()) + }; + *cursor = InitialSyncCursor::fresh(IngestStrategy::S2, scope); + report.ingested_count = 0; + report.strategy = Some(cursor.strategy.clone()); + self.persist_cursor(sync_state, cursor)?; + self.run_s2(cursor, report, sync_state).await + } + + // ── S2 (album crawl: getAlbumList2 + getAlbum) ───────────────────── + + async fn run_s2( + &self, + cursor: &mut InitialSyncCursor, + report: &mut InitialSyncReport, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + let (mut album_offset, resume_album_id) = match &cursor.strategy_state { + StrategyState::AlbumCrawl { album_offset, current_album_id } => { + (*album_offset, current_album_id.clone()) + } + ref other => { + return Err(SyncError::Storage(format!( + "s2 expected album-crawl cursor, got {other:?}" + ))) + } + }; + + let budget = self.parallelism_budget(); + crate::app_eprintln!( + "[library-sync] S2 ingest server `{}`: parallel_get_album={} batch_size={}", + self.server_id, + budget.max_concurrent, + self.batch_size + ); + let mut batch_count: u32 = 0; + let mut resume_from = resume_album_id; + + loop { + wait_while_bulk_paused(&budget, self.sleep_enabled, || self.check_cancellation()) + .await?; + self.check_cancellation()?; + let scope = self.library_scope_opt(); + sleep_request_gap(&budget, self.sleep_enabled).await; + let albums = retry_with_backoff( + self, + || { + self.subsonic.get_album_list2( + "alphabeticalByName", + self.batch_size, + album_offset, + scope, + ) + }, + SyncError::from, + ) + .await?; + if albums.is_empty() { + break; + } + + let mut album_ids: Vec = Vec::with_capacity(albums.len()); + if let Some(ref resume_after) = resume_from { + let mut past_resume = false; + for album_summary in &albums { + if !past_resume { + if resume_after == &album_summary.id { + past_resume = true; + } + continue; + } + album_ids.push(album_summary.id.clone()); + } + } else { + for album_summary in &albums { + album_ids.push(album_summary.id.clone()); + } + } + resume_from = None; + + let fetched = fetch_albums_parallel( + self.subsonic, + &album_ids, + ParallelAlbumFetchOpts { + budget, + sleep_enabled: self.sleep_enabled, + cancel: self.cancel.clone(), + }, + ) + .await?; + + for (album, raw_album) in fetched { + self.check_cancellation()?; + 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 = Vec::with_capacity(album.song.len()); + for (i, song) in album.song.iter().enumerate() { + let raw = raw_songs + .get(i) + .cloned() + .unwrap_or_else(|| serde_json::to_value(song).unwrap_or(Value::Null)); + rows.push(subsonic_song_to_track_row( + &self.server_id, + song, + &raw, + synced_at, + self.library_scope_opt(), + )); + } + if !rows.is_empty() { + let (_stats, _timing) = self.write_batch_logged(&rows, "S2", album_offset)?; + report.ingested_count = report + .ingested_count + .saturating_add(rows.len() as u32); + batch_count += 1; + self.progress.emit(ProgressEvent::IngestPage { + ingested_total: report.ingested_count, + batch_count, + metrics: None, + }); + } + cursor.strategy_state = StrategyState::AlbumCrawl { + album_offset, + current_album_id: Some(album.id.clone()), + }; + cursor.ingested_count = report.ingested_count; + self.persist_cursor(sync_state, cursor)?; + } + + album_offset = album_offset.saturating_add(self.batch_size); + cursor.strategy_state = StrategyState::AlbumCrawl { + album_offset, + current_album_id: None, + }; + cursor.ingested_count = report.ingested_count; + self.persist_cursor(sync_state, cursor)?; + + if (albums.len() as u32) < self.batch_size { + break; + } + } + Ok(()) + } + + // ── IS-4 artist pass (best-effort browse acceleration) ───────────── + + async fn run_artist_pass( + &self, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + let scope = self.library_scope_opt(); + let artists = retry_with_backoff( + self, + || self.subsonic.get_artists(scope), + SyncError::from, + ) + .await + .ok(); + if let Some(index) = artists { + if let Some(ms) = index.last_modified_ms { + sync_state + .set_artists_last_modified_ms(&self.server_id, &self.library_scope, ms) + .map_err(SyncError::Storage)?; + } + } + Ok(()) + } + + // ── IS-5 watermarks ──────────────────────────────────────────────── + + async fn run_watermark_pass( + &self, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + if self + .capability_flags + .contains(CapabilityFlags::SCAN_STATUS_AVAILABLE) + { + if let Ok(s) = self.subsonic.get_scan_status().await { + sync_state + .set_server_last_scan_iso( + &self.server_id, + &self.library_scope, + s.last_scan.as_deref(), + ) + .map_err(SyncError::Storage)?; + } + } + Ok(()) + } +} + +fn is_empty_cursor(v: &Value) -> bool { + matches!(v, Value::Object(o) if o.is_empty()) +} + +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) +} + +/// Wrap an async closure in §6.8 backoff. Retries on `SyncError::Transport` +/// up to `MAX_ATTEMPTS_PER_BATCH`, sleeping per the backoff schedule +/// (skipped when `sleep_enabled` is false — test path). +/// Cancellation is checked between attempts. +async fn retry_with_backoff<'a, F, FFut, T, E>( + runner: &InitialSyncRunner<'a>, + mut build: F, + map_err: impl Fn(E) -> SyncError, +) -> Result +where + F: FnMut() -> FFut, + FFut: std::future::Future>, +{ + 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(_) + ) +} + +/// A persistent fetch failure (network / HTTP / decode / API) that warrants +/// switching ingest strategy (Q8 S1→S2). Cancellation is user intent and +/// storage is a local problem a strategy switch can't fix — both propagate. +fn is_fetch_failure(e: &SyncError) -> bool { + matches!( + e, + SyncError::Transport(_) + | SyncError::Subsonic { .. } + | SyncError::Navidrome(_) + | SyncError::NotFound + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sync::capability::NavidromeProbeCredentials; + use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials}; + use serde_json::json; + use std::sync::Arc; + use wiremock::matchers::{header, method as wm_method, path as wm_path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn flags(bits: u32) -> CapabilityFlags { + CapabilityFlags::new(bits) + } + + fn test_subsonic(uri: &str) -> SubsonicClient { + SubsonicClient::with_static_credentials( + uri, + SubsonicCredentials::with_static("user", "tok", "salt"), + reqwest::Client::new(), + ) + } + + async fn mount_search3_pages(server: &MockServer, total: u32, batch: u32) { + // Two-page test fixture: first page returns `batch` songs, + // second page returns the remainder, third page returns empty. + for page in 0u32..=2 { + let offset = page * batch; + let body = if offset >= total { + json!({ "subsonic-response": { "status": "ok", "searchResult3": {} } }) + } else { + let remaining = (total - offset).min(batch); + let songs: Vec<_> = (0..remaining) + .map(|i| json!({ + "id": format!("tr_{:04}", offset + i), + "title": format!("Title {}", offset + i), + "duration": 200_i64 + (offset + i) as i64, + })) + .collect(); + json!({ + "subsonic-response": { + "status": "ok", + "searchResult3": { "song": songs } + } + }) + }; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .and(query_param("songOffset", offset.to_string())) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(server) + .await; + } + } + + async fn mount_minimal_artists(server: &MockServer) { + 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/getScanStatus.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "scanStatus": { + "scanning": false, + "count": 1234, + "lastScan": "2024-06-01T12:00:00Z" + } + } + }))) + .mount(server) + .await; + } + + // ── S1 happy path ────────────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s1_ingest_drains_pages_and_persists_done_phase() { + let server = MockServer::start().await; + mount_search3_pages(&server, /*total*/ 7, /*batch*/ 4).await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + let runner = InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK | CapabilityFlags::SCAN_STATUS_AVAILABLE), + ) + .with_batch_size(4) + .with_sleep_disabled(); + + let report = runner.run().await.unwrap(); + assert_eq!(report.ingested_count, 7); + assert_eq!(report.remapped_count, 0); + assert_eq!(report.strategy.as_deref(), Some("s1")); + + // sync_phase ended in "ready" and cursor cleared. + let sync_state = SyncStateRepository::new(&store); + assert_eq!( + sync_state.get_sync_phase("s1", "").unwrap().as_deref(), + Some("ready") + ); + let cur = sync_state.get_initial_sync_cursor("s1", "").unwrap(); + assert_eq!(cur, Some(json!({}))); + + // Tracks landed 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, 7); + } + + // ── Per-batch progress is emitted during ingest ─────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn initial_sync_emits_per_batch_progress() { + use crate::sync::progress::ChannelProgress; + use std::time::Duration; + + let server = MockServer::start().await; + mount_search3_pages(&server, /*total*/ 7, /*batch*/ 4).await; + mount_minimal_artists(&server).await; + + // ZERO interval so the throttle never drops a batch event in the test. + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let progress: Arc = + Arc::new(ChannelProgress::with_interval(tx, Duration::ZERO)); + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK | CapabilityFlags::SCAN_STATUS_AVAILABLE), + ) + .with_batch_size(4) + .with_sleep_disabled() + .with_progress(progress) + .run() + .await + .unwrap(); + + // Collect the per-batch ingest totals the runner emitted. + let mut totals = Vec::new(); + while let Ok(ev) = rx.try_recv() { + if let ProgressEvent::IngestPage { ingested_total, .. } = ev { + totals.push(ingested_total); + } + } + assert!(!totals.is_empty(), "initial sync must emit per-batch IngestPage progress"); + assert_eq!(*totals.last().unwrap(), 7, "final progress total must reach the full count"); + assert!( + totals.windows(2).all(|w| w[0] <= w[1]), + "ingest totals must be non-decreasing" + ); + } + + // ── S1 mid-cursor resume ────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s1_resumes_from_persisted_cursor_after_kill() { + let server = MockServer::start().await; + mount_search3_pages(&server, /*total*/ 10, /*batch*/ 4).await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + + // Seed the cursor as if a prior run completed page 0 (offset=4) + // but was killed before page 1 landed. + sync_state.ensure("s1", "").unwrap(); + let mid_cursor = json!({ + "strategy": "s1", + "phase": "ingest", + "library_scope": null, + "ingested_count": 4, + "strategy_state": { "kind": "linear_offset", "offset": 4 } + }); + sync_state + .set_initial_sync_cursor("s1", "", &mid_cursor) + .unwrap(); + + let report = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_batch_size(4) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + // Resumed at offset 4 — only 6 more rows ingested. + assert_eq!(report.ingested_count, 4 + 6); + // …but the store ends up with all 10. + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + // 6 — only the pages run by *this* invocation are persisted to + // `track` here because the cursor said offset=4 but the prior + // run never actually wrote rows in this fixture. The assertion + // documents the resume semantics: cursor controls request + // offset, not row count. + assert_eq!(count, 6); + } + + // ── Stale / unreadable cursor self-heals instead of bricking ────── + + #[test] + fn cursor_with_progress_resumes_and_ignores_reselected_strategy() { + // R7-15 Q3: a cursor that already made progress must resume under its + // own strategy even when a re-probe would now pick a different one + // (here: flags advertise N1 again, but the in-flight cursor is S1). + // Freezing the strategy is what stops the flapping-induced restart + // from offset 0 that kept large syncs from ever completing. + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state + .set_initial_sync_cursor( + "s1", + "", + &json!({ + "strategy": "s1", + "phase": "ingest", + "ingested_count": 42, + "strategy_state": { "kind": "linear_offset", "offset": 2000 } + }), + ) + .unwrap(); + + let subsonic = test_subsonic("http://127.0.0.1:1"); + let runner = InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags( + CapabilityFlags::NAVIDROME_NATIVE_BULK | CapabilityFlags::SUBSONIC_SEARCH3_BULK, + ), + ); + let cursor = runner.load_or_init_cursor(&sync_state).unwrap(); + assert_eq!(cursor.strategy, "s1", "in-flight strategy must be frozen on resume"); + assert_eq!(cursor.ingested_count, 42, "resume must preserve progress"); + } + + #[test] + fn fresh_cursor_without_progress_adopts_reselected_strategy() { + // No progress yet (offset 0): adopting the freshly-selected strategy + // is free, so a cursor written under a now-unavailable strategy is + // re-selected (not a hard error, not a needless resume). + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state + .set_initial_sync_cursor( + "s1", + "", + &json!({ + "strategy": "n1", + "phase": "ingest", + "ingested_count": 0, + "strategy_state": { "kind": "linear_offset", "offset": 0 } + }), + ) + .unwrap(); + + let subsonic = test_subsonic("http://127.0.0.1:1"); + let runner = InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ); + let cursor = runner.load_or_init_cursor(&sync_state).unwrap(); + assert_eq!(cursor.strategy, "s1", "no-progress cursor adopts the selected strategy"); + assert_eq!(cursor.ingested_count, 0); + } + + #[test] + fn n1_cursor_with_progress_reselects_when_flagged_unreliable() { + // A cursor still on N1 after the server was learned `n1_bulk_unreliable` + // is known-broken: the freeze does not apply, so it re-selects onto the + // non-N1 strategy rather than resuming a wall-bound N1 loop. (The + // mid-run N1→S1 fallback normally rewrites such a cursor in place, + // preserving progress; this is the defensive fallback.) + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state.set_n1_bulk_unreliable("s1", "", true).unwrap(); + sync_state + .set_initial_sync_cursor( + "s1", + "", + &json!({ + "strategy": "n1", + "phase": "ingest", + "ingested_count": 42, + "strategy_state": { "kind": "linear_offset", "offset": 500 } + }), + ) + .unwrap(); + + let subsonic = test_subsonic("http://127.0.0.1:1"); + let runner = InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags( + CapabilityFlags::NAVIDROME_NATIVE_BULK | CapabilityFlags::SUBSONIC_SEARCH3_BULK, + ), + ); + let cursor = runner.load_or_init_cursor(&sync_state).unwrap(); + assert_eq!(cursor.strategy, "s1", "known-broken N1 cursor must re-select to S1"); + assert_eq!(cursor.ingested_count, 0); + } + + #[test] + fn unreadable_cursor_is_reset_not_errored() { + // A corrupt cursor (missing the required `strategy` field) must + // also self-heal to a fresh cursor rather than error out. + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state + .set_initial_sync_cursor("s1", "", &json!({ "phase": "ingest", "ingested_count": 9 })) + .unwrap(); + + let subsonic = test_subsonic("http://127.0.0.1:1"); + let runner = InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ); + let cursor = runner.load_or_init_cursor(&sync_state).unwrap(); + assert_eq!(cursor.strategy, "s1"); + } + + // ── Backoff retries on 503 then succeeds ────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s1_retries_after_transient_503_then_succeeds() { + let server = MockServer::start().await; + // First request — 503. Wiremock `up_to_n_times` makes this + // simple: 1 mock that only answers once with 503, then a + // catch-all that returns the empty success envelope. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .and(query_param("songOffset", "0")) + .respond_with(ResponseTemplate::new(503)) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "searchResult3": {} } + }))) + .mount(&server) + .await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let report = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_batch_size(10) + .with_sleep_disabled() + .run() + .await + .unwrap(); + assert_eq!(report.ingested_count, 0, "all retries land before a song"); + } + + // ── Cancellation token aborts mid-run ───────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn cancellation_flag_returns_cancelled_error() { + let server = MockServer::start().await; + mount_search3_pages(&server, /*total*/ 100, /*batch*/ 4).await; + let cancel = Arc::new(AtomicBool::new(true)); // already tripped + let store = LibraryStore::open_in_memory(); + + let err = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_batch_size(4) + .with_cancellation(cancel) + .with_sleep_disabled() + .run() + .await + .unwrap_err(); + assert!(matches!(err, SyncError::Cancelled)); + } + + // ── N1 happy path via wiremock ──────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn n1_ingest_paginates_navidrome_native_endpoint() { + let server = MockServer::start().await; + // Two pages of 2 songs each, then empty. + for page in 0u32..=2 { + let start = page * 2; + let songs = if page < 2 { + vec![ + json!({"id": format!("tr_{start}"), "title": format!("t{start}"), "duration": 100}), + json!({"id": format!("tr_{}", start + 1), "title": format!("t{}", start + 1), "duration": 100}), + ] + } else { + vec![] + }; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .and(query_param("_start", start.to_string())) + .and(header("X-ND-Authorization", "Bearer nd-tok")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(songs))) + .mount(&server) + .await; + } + // Minimal Subsonic ping path for artist/watermark phases. + 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": 0, "ignoredArticles": "", "index": [] } + } + }))) + .mount(&server) + .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": false } } + }))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + let nav = NavidromeProbeCredentials { + server_url: server.uri(), + bearer_token: "nd-tok".into(), + }; + let report = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::NAVIDROME_NATIVE_BULK | CapabilityFlags::SCAN_STATUS_AVAILABLE), + ) + .with_navidrome_credentials(nav) + .with_batch_size(2) + .with_sleep_disabled() + .run() + .await + .unwrap(); + assert_eq!(report.ingested_count, 4); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 4); + + let sync_state = SyncStateRepository::new(&store); + assert_eq!(sync_state.get_local_track_count("s1", "").unwrap(), Some(4)); + assert_eq!(sync_state.get_sync_phase("s1", "").unwrap().as_deref(), Some("ready")); + let full_sync: Option = store + .with_conn("misc", |c| { + c.query_row( + "SELECT last_full_sync_at FROM sync_state WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert!(full_sync.is_some()); + } + + // ── N1 → S1 deep-offset fallback (R7-15 Q5) ─────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn n1_deep_offset_500_falls_back_to_s1_and_flags_server() { + let server = MockServer::start().await; + // N1 serves the first page, then 500s at the (test-lowered) wall. + // Ids match the S1 fixture format so the re-ingest upserts rather + // than duplicating the rows N1 already wrote. + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .and(query_param("_start", "0")) + .and(header("X-ND-Authorization", "Bearer nd-tok")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + {"id": "tr_0000", "title": "t0", "duration": 100}, + {"id": "tr_0001", "title": "t1", "duration": 100} + ]))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .and(query_param("_start", "2")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + // S1 restarts from offset 0 and ingests all 5 songs. + mount_search3_pages(&server, /*total*/ 5, /*batch*/ 2).await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let nav = NavidromeProbeCredentials { + server_url: server.uri(), + bearer_token: "nd-tok".into(), + }; + let report = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags( + CapabilityFlags::NAVIDROME_NATIVE_BULK + | CapabilityFlags::SUBSONIC_SEARCH3_BULK + | CapabilityFlags::SCAN_STATUS_AVAILABLE, + ), + ) + .with_navidrome_credentials(nav) + .with_batch_size(2) + .with_n1_deep_offset_safe(2) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + assert_eq!(report.strategy.as_deref(), Some("s1"), "run must finish on S1"); + // 5 distinct songs — N1's two rows were re-upserted, not duplicated. + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 5); + // Server learned the flag so future syncs skip N1. + let sync_state = SyncStateRepository::new(&store); + assert_eq!(sync_state.get_n1_bulk_unreliable("s1", "").unwrap(), Some(true)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn n1_shallow_500_propagates_without_fallback() { + // A 500 below the wall line is a real error, not the deep-offset + // trigger: it propagates and must NOT silently flag the server. + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + let nav = NavidromeProbeCredentials { + server_url: server.uri(), + bearer_token: "nd-tok".into(), + }; + let err = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::NAVIDROME_NATIVE_BULK), + ) + .with_navidrome_credentials(nav) + .with_batch_size(2) + .with_n1_deep_offset_safe(1000) + .with_sleep_disabled() + .run() + .await + .unwrap_err(); + assert!(matches!(err, SyncError::Navidrome(ref m) if m.contains("500"))); + let sync_state = SyncStateRepository::new(&store); + assert_eq!(sync_state.get_n1_bulk_unreliable("s1", "").unwrap(), Some(false)); + } + + // ── S1 → S2 persistent-failure fallback (R7-15 Q8) ──────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s1_persistent_failure_falls_back_to_s2() { + let server = MockServer::start().await; + // S1 (search3) fails on every attempt → persistent after retries. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + // S2 album crawl works: one album page, then empty. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .and(query_param("offset", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { "album": [{ "id": "al_1", "name": "First" }] } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .and(query_param("offset", "1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "albumList2": { "album": [] } } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbum.view")) + .and(query_param("id", "al_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "album": { + "id": "al_1", + "name": "First", + "song": [{ "id": "tr_a", "title": "song", "duration": 240 }] + } + } + }))) + .mount(&server) + .await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let report = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_batch_size(1) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + assert_eq!(report.strategy.as_deref(), Some("s2"), "run must finish on S2"); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 1, "the S2 album crawl ingested the track"); + } + + // ── S3 explicitly unsupported in v1 ─────────────────────────────── + + #[test] + fn s3_cursor_self_heals_to_selected_strategy() { + // S3 is never auto-selected, so a persisted s3 cursor (legacy / + // corrupt) can never match the chosen strategy — it must reset to + // the selected strategy rather than error. + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state + .set_initial_sync_cursor( + "s1", + "", + &json!({ + "strategy": "s3", + "phase": "ingest", + "ingested_count": 0, + "strategy_state": { "kind": "empty" } + }), + ) + .unwrap(); + + let subsonic = test_subsonic("http://127.0.0.1:1"); + // Default flags ⇒ selector resolves to s2. + let runner = InitialSyncRunner::new(&store, &subsonic, "s1", "", flags(0)); + let cursor = runner.load_or_init_cursor(&sync_state).unwrap(); + assert_eq!(cursor.strategy, "s2"); + } + + // ── S1 raw_json carries OpenSubsonic extensions verbatim ────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s1_ingest_preserves_open_subsonic_fields_in_track_raw_json() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .and(query_param("songOffset", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "searchResult3": { + "song": [ + { + "id": "tr_1", + "title": "With Extensions", + "duration": 240, + "replayGain": { "trackGain": -1.2, "albumGain": -0.8 }, + "contributors": [ + { "role": "producer", "artistId": "ar_9", "name": "Prod" } + ] + } + ] + } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "searchResult3": {} } + }))) + .mount(&server) + .await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_batch_size(10) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + // raw_json column must contain the OpenSubsonic-only fields, + // not just the typed projection — ADR-7 fidelity. + let raw: String = store + .with_conn("misc", |c| { + c.query_row( + "SELECT raw_json FROM track WHERE server_id='s1' AND id='tr_1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert!(parsed.get("replayGain").is_some(), "raw json keeps replayGain"); + assert!(parsed.get("contributors").is_some(), "raw json keeps contributors"); + + // Typed projection also picked up replayGain via the mapping + // helper — both paths agree on the hot column. + let (rg_t, rg_a): (Option, Option) = store + .with_conn("misc", |c| { + c.query_row( + "SELECT replay_gain_track_db, replay_gain_album_db \ + FROM track WHERE server_id='s1' AND id='tr_1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + }) + .unwrap(); + assert_eq!(rg_t, Some(-1.2)); + assert_eq!(rg_a, Some(-0.8)); + } + + // ── S2 happy path: getAlbumList2 → getAlbum-per-id loop ─────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s2_ingest_walks_albums_and_persists_songs() { + let server = MockServer::start().await; + // First album-list page: 2 albums, second page: 0 (loop ends). + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .and(query_param("offset", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { + "album": [ + { "id": "al_1", "name": "First" }, + { "id": "al_2", "name": "Second" } + ] + } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .and(query_param("offset", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { "album": [] } + } + }))) + .mount(&server) + .await; + // Per-album song lists. + for (album_id, song_id) in [("al_1", "tr_a"), ("al_2", "tr_b")] { + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbum.view")) + .and(query_param("id", album_id)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "album": { + "id": album_id, + "name": album_id, + "song": [ + { "id": song_id, "title": "song", "duration": 240 } + ] + } + } + }))) + .mount(&server) + .await; + } + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + let report = InitialSyncRunner::new( + &store, + &subsonic, + "s2", + "", + // Force selector to fall through to S2: clear N1 + S1 bits. + flags(0), + ) + .with_batch_size(2) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + assert_eq!(report.strategy.as_deref(), Some("s2")); + assert_eq!(report.ingested_count, 2); + + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 2); + } + + // ── Remap path (§6.9) — exercised on delta / full upsert, not IS-3 bulk ─ + + #[test] + fn remap_fires_on_unstable_track_ids_batch_upsert() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[TrackRow { + server_id: "s1".into(), + id: "tr_old".into(), + title: "Aurora".into(), + title_sort: None, + artist: Some("A".into()), + artist_id: None, + album: "An Album".into(), + album_id: None, + 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: Some("/path/aurora.flac".into()), + 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: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + }]) + .unwrap(); + + let stats = repo + .upsert_batch_with_remap( + &[TrackRow { + server_id: "s1".into(), + id: "tr_new".into(), + title: "Aurora".into(), + title_sort: None, + artist: Some("A".into()), + artist_id: None, + album: "An Album".into(), + album_id: None, + 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: Some("/path/aurora.flac".into()), + 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: None, + server_created_at: None, + deleted: false, + synced_at: 2, + raw_json: "{}".into(), + }], + true, + ) + .unwrap(); + assert_eq!(stats.remapped.len(), 1); + + let ids: Vec = store + .with_conn("misc", |c| { + let mut s = c.prepare("SELECT id FROM track WHERE server_id='s1' ORDER BY id")?; + let r: rusqlite::Result> = s.query_map([], |r| r.get(0))?.collect(); + r + }) + .unwrap(); + assert_eq!(ids, vec!["tr_new"]); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/mapping.rs b/src-tauri/crates/psysonic-library/src/sync/mapping.rs new file mode 100644 index 00000000..6c097e0c --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/mapping.rs @@ -0,0 +1,303 @@ +//! Subsonic / Navidrome song JSON → `TrackRow`. PR-3b's ingest paths +//! all feed the same upsert API, so the projection happens here once. + +use serde_json::Value; + +use crate::repos::TrackRow; +use psysonic_integration::subsonic::Song; + +/// Project a Subsonic `Song` plus its raw JSON sub-tree into a +/// `TrackRow`. `raw_value` is what `track.raw_json` stores verbatim so +/// OpenSubsonic extensions survive (spec §5.1 / ADR-7). +pub fn subsonic_song_to_track_row( + server_id: &str, + song: &Song, + raw_value: &Value, + synced_at: i64, + library_id_fallback: Option<&str>, +) -> TrackRow { + TrackRow { + server_id: server_id.to_string(), + id: song.id.clone(), + title: song.title.clone(), + title_sort: None, + artist: song.artist.clone(), + artist_id: song.artist_id.clone(), + album: song.album.clone().unwrap_or_default(), + album_id: song.album_id.clone(), + album_artist: song.album_artist.clone(), + duration_sec: song.duration.unwrap_or(0), + track_number: song.track_number, + disc_number: song.disc_number, + year: song.year, + genre: song.genre.clone(), + suffix: song.suffix.clone(), + bit_rate: song.bit_rate, + size_bytes: song.size, + cover_art_id: song.cover_art.clone(), + starred_at: parse_iso_ms(song.starred.as_deref()), + user_rating: song.user_rating, + play_count: song.play_count, + played_at: parse_iso_ms(song.played.as_deref()), + server_path: song.path.clone(), + library_id: song.library_id.clone().or_else(|| library_id_fallback.map(String::from)), + isrc: song.isrc.clone(), + mbid_recording: song.mbid_recording.clone(), + bpm: song.bpm, + replay_gain_track_db: raw_value + .get("replayGain") + .and_then(|rg| rg.get("trackGain")) + .and_then(|v| v.as_f64()), + replay_gain_album_db: raw_value + .get("replayGain") + .and_then(|rg| rg.get("albumGain")) + .and_then(|v| v.as_f64()), + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at, + raw_json: raw_value.to_string(), + } +} + +/// Project a Navidrome `/api/song` row (native REST shape) into a +/// `TrackRow`. Field names mostly overlap with Subsonic but use +/// snake_case JSON aliases — we read fields by `get(name)` rather +/// than reusing the Subsonic `Song` deserializer so a server-side +/// rename doesn't silently zero out hot columns. +pub fn navidrome_song_to_track_row( + server_id: &str, + raw: &Value, + synced_at: i64, + library_id_fallback: Option<&str>, +) -> Option { + let id = raw.get("id").and_then(|v| v.as_str())?.to_string(); + let title = raw + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let server_updated_at = raw + .get("updatedAt") + .and_then(|v| v.as_str()) + .and_then(parse_iso_ms_str); + let library_id = json_string_field(raw, "libraryId") + .or_else(|| json_string_field(raw, "library_id")) + .or_else(|| json_string_field(raw, "musicFolderId")) + .or_else(|| library_id_fallback.map(String::from)); + Some(TrackRow { + server_id: server_id.to_string(), + id, + title, + title_sort: string_field(raw, "sortTitle").or_else(|| string_field(raw, "orderTitle")), + artist: string_field(raw, "artist"), + artist_id: string_field(raw, "artistId"), + album: string_field(raw, "album").unwrap_or_default(), + album_id: string_field(raw, "albumId"), + album_artist: string_field(raw, "albumArtist"), + duration_sec: raw.get("duration").and_then(|v| v.as_i64()).unwrap_or(0), + track_number: raw.get("trackNumber").and_then(|v| v.as_i64()), + disc_number: raw.get("discNumber").and_then(|v| v.as_i64()), + year: raw.get("year").and_then(|v| v.as_i64()), + genre: string_field(raw, "genre"), + suffix: string_field(raw, "suffix"), + bit_rate: raw.get("bitRate").and_then(|v| v.as_i64()), + size_bytes: raw.get("size").and_then(|v| v.as_i64()), + cover_art_id: string_field(raw, "coverArtId").or_else(|| string_field(raw, "coverArt")), + starred_at: raw.get("starredAt").and_then(|v| v.as_str()).and_then(parse_iso_ms_str), + user_rating: raw.get("rating").and_then(|v| v.as_i64()), + play_count: raw.get("playCount").and_then(|v| v.as_i64()), + played_at: raw.get("playedAt").and_then(|v| v.as_str()).and_then(parse_iso_ms_str), + server_path: string_field(raw, "path"), + library_id, + isrc: string_field(raw, "isrc"), + mbid_recording: string_field(raw, "mbzTrackId").or_else(|| string_field(raw, "musicBrainzId")), + bpm: raw.get("bpm").and_then(|v| v.as_i64()), + replay_gain_track_db: raw.get("rgTrackGain").and_then(|v| v.as_f64()), + replay_gain_album_db: raw.get("rgAlbumGain").and_then(|v| v.as_f64()), + content_hash: None, + server_updated_at, + server_created_at: raw + .get("createdAt") + .and_then(|v| v.as_str()) + .and_then(parse_iso_ms_str), + deleted: false, + synced_at, + raw_json: raw.to_string(), + }) +} + +fn json_string_field(raw: &Value, key: &str) -> Option { + match raw.get(key)? { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +fn string_field(raw: &Value, key: &str) -> Option { + json_string_field(raw, key) +} + +fn parse_iso_ms(s: Option<&str>) -> Option { + s.and_then(parse_iso_ms_str) +} + +/// Lightweight ISO-8601 → epoch-ms parser. Supports the Navidrome / +/// OpenSubsonic shape (`2024-06-01T12:00:00Z` or +/// `2024-06-01T12:00:00.123+02:00`). Falls back to `None` on parse +/// failure — sync code never panics on a bad timestamp. +fn parse_iso_ms_str(s: &str) -> Option { + // Strip fractional + timezone before doing the manual parse — + // SQLite stores starred_at / played_at as integer ms, so we only + // need second precision rounded up from the offset. + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + // Accept either `Z`, `+HH:MM`, or no suffix. Reduce to a flat + // `YYYY-MM-DDTHH:MM:SS` core for parsing — server-side timestamps + // are already in UTC for Navidrome, and we don't track timezone + // in the schema column. + let core = trimmed + .find(|c: char| c == '.' || c == 'Z' || c == '+' || (c == '-' && trimmed.find('T').is_some_and(|t| trimmed[t..].contains(c)))) + .map(|i| &trimmed[..i]) + .unwrap_or(trimmed); + let mut parts = core.split(['T', '-', ':']); + let year: i64 = parts.next()?.parse().ok()?; + let month: i64 = parts.next()?.parse().ok()?; + let day: i64 = parts.next()?.parse().ok()?; + let hour: i64 = parts.next().unwrap_or("0").parse().ok()?; + let minute: i64 = parts.next().unwrap_or("0").parse().ok()?; + let second: i64 = parts.next().unwrap_or("0").parse().ok()?; + if !(1970..=2100).contains(&year) + || !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || !(0..=23).contains(&hour) + || !(0..=59).contains(&minute) + || !(0..=60).contains(&second) + { + return None; + } + // Days since 1970-01-01 — Howard Hinnant's civil_from_days inverse. + let y = if month <= 2 { year - 1 } else { year }; + let era = y.div_euclid(400); + let yoe = y - era * 400; // [0, 399] + let m = if month > 2 { month - 3 } else { month + 9 }; + let doy = (153 * m + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + let seconds = days * 86_400 + hour * 3600 + minute * 60 + second; + Some(seconds.saturating_mul(1000)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_iso_handles_zulu_suffix() { + // 2024-01-01T00:00:00Z = 1704067200000 ms. + let ms = parse_iso_ms_str("2024-01-01T00:00:00Z").unwrap(); + assert_eq!(ms, 1_704_067_200_000); + } + + #[test] + fn parse_iso_handles_fractional_and_offset() { + let ms = parse_iso_ms_str("2024-01-01T00:00:00.123+02:00").unwrap(); + // Truncated before offset → same epoch-second as Zulu of the + // wall-clock value. Good enough for the schema's integer-ms + // column. + assert_eq!(ms, 1_704_067_200_000); + } + + #[test] + fn parse_iso_rejects_garbage() { + assert!(parse_iso_ms_str("").is_none()); + assert!(parse_iso_ms_str("not-a-date").is_none()); + assert!(parse_iso_ms_str("9999-99-99").is_none()); + } + + #[test] + fn subsonic_song_maps_hot_columns_and_keeps_raw_json() { + let raw = json!({ + "id": "tr_1", + "title": "Hello", + "artist": "World", + "albumId": "al_1", + "duration": 240, + "track": 3, + "year": 2024, + "musicBrainzId": "mb-1", + "replayGain": { "trackGain": -1.2, "albumGain": -0.8 } + }); + let song: Song = serde_json::from_value(raw.clone()).unwrap(); + let row = subsonic_song_to_track_row("s1", &song, &raw, 1_000, Some("lib-fb")); + assert_eq!(row.id, "tr_1"); + assert_eq!(row.album_id.as_deref(), Some("al_1")); + assert_eq!(row.duration_sec, 240); + assert_eq!(row.mbid_recording.as_deref(), Some("mb-1")); + assert_eq!(row.replay_gain_track_db, Some(-1.2)); + assert_eq!(row.replay_gain_album_db, Some(-0.8)); + // Fallback library_id kicks in when the song didn't ship one. + assert_eq!(row.library_id.as_deref(), Some("lib-fb")); + assert!(row.raw_json.contains("replayGain")); + } + + #[test] + fn navidrome_song_maps_native_field_shape() { + let raw = json!({ + "id": "tr_1", + "title": "Hello", + "artist": "World", + "artistId": "ar_1", + "album": "An Album", + "albumId": "al_1", + "albumArtist": "World", + "duration": 240, + "trackNumber": 3, + "discNumber": 1, + "year": 2024, + "genre": "Ambient", + "suffix": "flac", + "bitRate": 1000, + "size": 32_000_000_i64, + "path": "World/An Album/03.flac", + "libraryId": "1", + "isrc": "USRC17607839", + "mbzTrackId": "mb-1", + "bpm": 128, + "rgTrackGain": -1.2, + "rgAlbumGain": -0.8, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-06-01T00:00:00Z" + }); + let row = navidrome_song_to_track_row("s1", &raw, 9_999, None).unwrap(); + assert_eq!(row.id, "tr_1"); + assert_eq!(row.track_number, Some(3)); + assert_eq!(row.isrc.as_deref(), Some("USRC17607839")); + assert_eq!(row.mbid_recording.as_deref(), Some("mb-1")); + assert_eq!(row.replay_gain_track_db, Some(-1.2)); + assert_eq!(row.library_id.as_deref(), Some("1")); + assert!(row.server_updated_at.unwrap_or(0) > 0); + } + + #[test] + fn navidrome_song_maps_numeric_library_id() { + let raw = json!({ + "id": "tr_1", + "title": "Hello", + "libraryId": 3 + }); + let row = navidrome_song_to_track_row("s1", &raw, 1, None).unwrap(); + assert_eq!(row.library_id.as_deref(), Some("3")); + } + + #[test] + fn navidrome_song_skips_rows_without_id() { + let row = navidrome_song_to_track_row("s1", &json!({"title": "no id"}), 1, None); + assert!(row.is_none()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/mod.rs b/src-tauri/crates/psysonic-library/src/sync/mod.rs new file mode 100644 index 00000000..a3521378 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/mod.rs @@ -0,0 +1,40 @@ +//! Sync orchestrator. +//! +//! PR-3a landed the foundation (`CapabilityProbe` C1 + +//! `SyncStateRepository` C7); PR-3b adds the initial-sync runner, +//! ingest-strategy selection, backoff, and the §6.9 id remap path. +//! `DeltaSyncRunner` / background scheduler / Tauri surface follow in +//! PR-3c / PR-3d / PR-5. + +pub mod backoff; +pub mod bandwidth; +pub mod budget; +pub mod capability; +pub mod cursor; +pub mod delta; +pub mod error; +pub mod ingest_parallel; +pub mod initial; +pub mod mapping; +pub mod poll_stats; +pub mod progress; +pub mod scheduler; +pub mod strategy; +pub mod supervisor; +pub mod tombstone; + +pub use backoff::{with_jitter, Backoff}; +pub use bandwidth::{ParallelismBudget, PlaybackHint}; +pub use budget::{PassKind, RequestBudget}; +pub use capability::{CapabilityFlags, CapabilityProbe, NavidromeProbeCredentials}; +pub use cursor::{CursorPhase, InitialSyncCursor, StrategyState}; +pub use delta::{DeltaSyncReport, DeltaSyncRunner}; +pub use error::SyncError; +pub use initial::{InitialSyncReport, InitialSyncRunner}; +pub use mapping::{navidrome_song_to_track_row, subsonic_song_to_track_row}; +pub use poll_stats::{classify_tier, next_interval_ms, LibraryTier, PollStats}; +pub use progress::{ChannelProgress, NoopProgress, Progress, ProgressEvent}; +pub use scheduler::{BackgroundScheduler, SchedulerTickReport, DEFAULT_TOMBSTONE_THRESHOLD_PCT}; +pub use strategy::IngestStrategy; +pub use supervisor::SyncSupervisor; +pub use tombstone::{should_auto_reconcile, TombstoneReconciler, TombstoneReport}; diff --git a/src-tauri/crates/psysonic-library/src/sync/poll_stats.rs b/src-tauri/crates/psysonic-library/src/sync/poll_stats.rs new file mode 100644 index 00000000..dfc3b0e1 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/poll_stats.rs @@ -0,0 +1,250 @@ +//! C10 — adaptive poll interval (spec §6.2.2). +//! +//! Rolling EWMA over the last delta pass's HTTP response stats plus +//! the artist-count signal classifies the server into a `LibraryTier`, +//! which then drives the next poll interval. Persisted in +//! `sync_state.poll_stats_json` so the scheduler picks up where it +//! left off across restarts. + +use serde::{Deserialize, Serialize}; + +/// EWMA smoothing factor — higher values weight recent samples more. +/// 0.3 matches the §6.2.2 "rolling EWMA" target without over-reacting +/// to a single slow response. +pub const EWMA_ALPHA: f64 = 0.3; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LibraryTier { + /// `<2k` artists. + Small, + /// `2k-15k` artists. + Medium, + /// `>15k` artists OR `ewma_bytes > 2 MB`. + Huge, + #[default] + Unknown, +} + +impl LibraryTier { + pub fn as_tag(self) -> &'static str { + match self { + Self::Small => "small", + Self::Medium => "medium", + Self::Huge => "huge", + Self::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub struct PollStats { + /// Last successful `getArtists.index` cardinality, or + /// `getScanStatus.count` estimate. Used as the primary tier + /// signal. + #[serde(default)] + pub artist_count: u64, + /// EWMA of the most recent poll response sizes, in bytes. + /// Decompressed (post-gzip) per §2.2.1. + #[serde(default)] + pub ewma_bytes: f64, + /// EWMA of the most recent poll wall-clock durations, in ms. + #[serde(default)] + pub ewma_duration_ms: f64, + /// Resolved tier from the inputs above. + #[serde(default)] + pub library_tier: LibraryTier, +} + +impl PollStats { + /// Fold a new sample into the EWMAs. First sample seeds the + /// averages directly so a single fresh poll yields a meaningful + /// tier classification. + pub fn observe(&mut self, bytes: u64, duration_ms: u64) { + let b = bytes as f64; + let d = duration_ms as f64; + self.ewma_bytes = if self.ewma_bytes == 0.0 { + b + } else { + EWMA_ALPHA * b + (1.0 - EWMA_ALPHA) * self.ewma_bytes + }; + self.ewma_duration_ms = if self.ewma_duration_ms == 0.0 { + d + } else { + EWMA_ALPHA * d + (1.0 - EWMA_ALPHA) * self.ewma_duration_ms + }; + } + + /// Update `artist_count` and recompute the resolved tier. + pub fn set_artist_count(&mut self, count: u64) { + self.artist_count = count; + self.library_tier = classify_tier(self.artist_count, self.ewma_bytes); + } + + /// Force a tier re-classification — call after a fresh + /// `observe()` pair if a borderline `ewma_bytes` may have tipped + /// the threshold. + pub fn reclassify(&mut self) { + self.library_tier = classify_tier(self.artist_count, self.ewma_bytes); + } +} + +/// §6.2.2 classification table. +pub fn classify_tier(artist_count: u64, ewma_bytes: f64) -> LibraryTier { + if artist_count == 0 && ewma_bytes == 0.0 { + return LibraryTier::Unknown; + } + if artist_count > 15_000 || ewma_bytes > 2_000_000.0 { + return LibraryTier::Huge; + } + if artist_count >= 2_000 { + return LibraryTier::Medium; + } + LibraryTier::Small +} + +/// Spec §6.2.2 formula. Returns the delta in milliseconds — caller +/// stamps `next_poll_at = now + this`. All arithmetic in `f64` so the +/// `load_factor` clamp produces a smooth curve. +pub fn next_interval_ms(stats: &PollStats) -> u64 { + let base_ms: u64 = match stats.library_tier { + LibraryTier::Huge => 15 * 60 * 1000, + LibraryTier::Medium => 10 * 60 * 1000, + LibraryTier::Small => 5 * 60 * 1000, + // No data yet — start short so the first real tick lands + // quickly and re-classifies. + LibraryTier::Unknown => 60 * 1000, + }; + let load_factor = if stats.ewma_duration_ms <= 0.0 { + 1.0 + } else { + (stats.ewma_duration_ms / 3000.0).clamp(1.0, 10.0) + }; + let artist_factor: f64 = if matches!(stats.library_tier, LibraryTier::Huge) { + 3.0 + } else { + 1.0 + }; + let scaled = (base_ms as f64) * load_factor * artist_factor; + scaled.min(u64::MAX as f64) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_returns_unknown_with_zero_signals() { + assert_eq!(classify_tier(0, 0.0), LibraryTier::Unknown); + } + + #[test] + fn classify_small_under_2k_artists() { + assert_eq!(classify_tier(500, 1000.0), LibraryTier::Small); + assert_eq!(classify_tier(1_999, 1000.0), LibraryTier::Small); + } + + #[test] + fn classify_medium_in_range() { + assert_eq!(classify_tier(2_000, 1000.0), LibraryTier::Medium); + assert_eq!(classify_tier(15_000, 1000.0), LibraryTier::Medium); + } + + #[test] + fn classify_huge_above_15k_artists() { + assert_eq!(classify_tier(15_001, 1000.0), LibraryTier::Huge); + } + + #[test] + fn classify_huge_when_ewma_bytes_exceed_2mb_even_with_few_artists() { + // Slow-network signal — the spec's `ewma_bytes > 2MB` override. + assert_eq!(classify_tier(500, 2_500_000.0), LibraryTier::Huge); + } + + #[test] + fn observe_first_sample_seeds_ewmas_directly() { + let mut s = PollStats::default(); + s.observe(100_000, 1500); + assert_eq!(s.ewma_bytes, 100_000.0); + assert_eq!(s.ewma_duration_ms, 1500.0); + } + + #[test] + fn observe_subsequent_samples_apply_alpha_smoothing() { + let mut s = PollStats::default(); + s.observe(100_000, 1000); + s.observe(200_000, 2000); + // 0.3 * 200_000 + 0.7 * 100_000 = 60_000 + 70_000 = 130_000 + assert!((s.ewma_bytes - 130_000.0).abs() < 0.001); + assert!((s.ewma_duration_ms - 1300.0).abs() < 0.001); + } + + #[test] + fn set_artist_count_triggers_reclassification() { + let mut s = PollStats::default(); + s.observe(50_000, 1500); + s.set_artist_count(5_000); + assert_eq!(s.library_tier, LibraryTier::Medium); + } + + #[test] + fn next_interval_unknown_tier_starts_short() { + let s = PollStats::default(); + assert_eq!(next_interval_ms(&s), 60_000); + } + + #[test] + fn next_interval_small_base_5min_at_idle_load() { + let mut s = PollStats::default(); + s.set_artist_count(1000); + // load_factor clamps at 1.0 when ewma_duration_ms = 0. + assert_eq!(next_interval_ms(&s), 5 * 60_000); + } + + #[test] + fn next_interval_huge_uses_artist_factor_3x() { + let mut s = PollStats::default(); + s.set_artist_count(20_000); + // 15 min * 3 = 45 min on idle load. + assert_eq!(next_interval_ms(&s), 45 * 60_000); + } + + #[test] + fn next_interval_load_factor_stretches_with_slow_network() { + let mut s = PollStats::default(); + s.set_artist_count(1000); + s.observe(50_000, 9_000); // 3× the 3000ms target + // 5 min * 3 (load_factor) = 15 min. + let ms = next_interval_ms(&s); + assert!( + (14 * 60_000..=16 * 60_000).contains(&ms), + "expected ~15min, got {ms}ms" + ); + } + + #[test] + fn next_interval_load_factor_clamped_at_10x() { + let mut s = PollStats::default(); + s.set_artist_count(1000); + s.observe(50_000, 60_000); // 20× target → clamps at 10 + assert_eq!(next_interval_ms(&s), 10 * 5 * 60_000); + } + + #[test] + fn poll_stats_round_trips_through_json() { + let mut s = PollStats::default(); + s.observe(123_456, 789); + s.set_artist_count(3_500); + let json = serde_json::to_value(s).unwrap(); + let back: PollStats = serde_json::from_value(json).unwrap(); + assert_eq!(back, s); + } + + #[test] + fn poll_stats_deserialize_tolerates_missing_fields() { + // Stored default is `'{}'` per spec §5.1; runner must accept it + // as a fresh stats object. + let s: PollStats = serde_json::from_str("{}").unwrap(); + assert_eq!(s, PollStats::default()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/progress.rs b/src-tauri/crates/psysonic-library/src/sync/progress.rs new file mode 100644 index 00000000..b6cd4e59 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/progress.rs @@ -0,0 +1,329 @@ +//! C6 — progress channel for the sync runners (spec §6 emit limit +//! `≤2 events/s`). The runners call `Progress::emit` at phase +//! transitions and per-batch checkpoints; the supervisor wraps an +//! `mpsc::UnboundedSender` so the top crate (PR-5) can forward events +//! to Tauri's emit surface. +//! +//! Non-terminal events are rate-limited. `IngestPage` updates are +//! **coalesced** (latest `ingested_total` wins) so fast S1/N1 batches +//! do not leave the UI stuck on a stale count between throttled emits. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +use crate::repos::RemapEntry; + +/// Per-batch ingest timings (DevTools + terminal diagnosis). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IngestBatchMetrics { + pub offset: u32, + pub strategy: String, + pub fetch_ms: u32, + pub write_ms: u32, + pub lock_wait_ms: u32, + pub sql_exec_ms: u32, + pub persist_ms: u32, + pub row_count: u32, + pub bulk_ingest_active: bool, +} + +/// Lean event union — server_id / library_scope context lives on the +/// channel side (one supervisor = one scope). Top-crate code wraps +/// these into Tauri events with their own envelope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProgressEvent { + PhaseChanged { phase: String }, + IngestPage { + ingested_total: u32, + batch_count: u32, + metrics: Option, + }, + Remapped { entries: Vec }, + Tombstoned { deleted_count: u32, checked_count: u32 }, + Completed { kind: String }, + Error { message: String }, +} + +impl ProgressEvent { + /// Terminal events always bypass the throttle so callers never + /// miss a "we're done" / "we crashed" signal. + pub fn always_emit(&self) -> bool { + matches!(self, Self::Completed { .. } | Self::Error { .. }) + } +} + +pub trait Progress: Send + Sync { + fn emit(&self, event: ProgressEvent); +} + +/// No-op implementation. Used as the default when runners are called +/// outside a supervisor (tests, future ad-hoc invocations). +pub struct NoopProgress; + +impl Progress for NoopProgress { + fn emit(&self, _event: ProgressEvent) {} +} + +/// `Progress` impl that forwards through a tokio mpsc channel, +/// throttling non-terminal events to the configured `min_interval`. +pub struct ChannelProgress { + sender: tokio::sync::mpsc::UnboundedSender, + min_interval: Duration, + last_emit: Mutex>, + /// Latest ingest checkpoint held back while the throttle gate is closed. + pending_ingest: Mutex)>>, +} + +impl ChannelProgress { + /// 500 ms gate ≈ 2 events/s per spec §6. + pub const DEFAULT_INTERVAL: Duration = Duration::from_millis(500); + + pub fn new(sender: tokio::sync::mpsc::UnboundedSender) -> Self { + Self::with_interval(sender, Self::DEFAULT_INTERVAL) + } + + pub fn with_interval( + sender: tokio::sync::mpsc::UnboundedSender, + min_interval: Duration, + ) -> Self { + Self { + sender, + min_interval, + last_emit: Mutex::new(None), + pending_ingest: Mutex::new(None), + } + } + + fn flush_pending_ingest(&self) { + let pending = self + .pending_ingest + .lock() + .expect("progress pending lock poisoned") + .take(); + if let Some((ingested_total, batch_count, metrics)) = pending { + let _ = self.sender.send(ProgressEvent::IngestPage { + ingested_total, + batch_count, + metrics, + }); + } + } + + fn throttle_open(&self) -> bool { + if self.min_interval.is_zero() { + return true; + } + let last = self.last_emit.lock().expect("progress lock poisoned"); + match *last { + None => true, + Some(prev) if prev.elapsed() >= self.min_interval => true, + Some(_) => false, + } + } + + fn mark_emitted(&self) { + if self.min_interval.is_zero() { + return; + } + *self + .last_emit + .lock() + .expect("progress lock poisoned") = Some(Instant::now()); + } +} + +impl Progress for ChannelProgress { + fn emit(&self, event: ProgressEvent) { + if event.always_emit() { + self.flush_pending_ingest(); + let _ = self.sender.send(event); + return; + } + + if let ProgressEvent::IngestPage { + ingested_total, + batch_count, + metrics, + } = event + { + let gate_open = self.throttle_open(); + { + let mut pending = self + .pending_ingest + .lock() + .expect("progress pending lock poisoned"); + if gate_open { + if let Some((total, batch, m)) = pending.take() { + let _ = self.sender.send(ProgressEvent::IngestPage { + ingested_total: total, + batch_count: batch, + metrics: m, + }); + } + } + *pending = Some((ingested_total, batch_count, metrics)); + } + if gate_open { + self.mark_emitted(); + self.flush_pending_ingest(); + } + return; + } + + if !self.min_interval.is_zero() && !self.throttle_open() { + return; + } + self.flush_pending_ingest(); + self.mark_emitted(); + let _ = self.sender.send(event); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + use std::time::Duration; + use tokio::sync::mpsc; + + #[test] + fn noop_progress_swallows_events_without_panicking() { + let p = NoopProgress; + p.emit(ProgressEvent::PhaseChanged { phase: "ingest".into() }); + p.emit(ProgressEvent::Completed { kind: "initial_sync".into() }); + } + + #[tokio::test(flavor = "multi_thread")] + async fn zero_interval_channel_emits_every_event() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let p = ChannelProgress::with_interval(tx, Duration::ZERO); + for i in 0..10 { + p.emit(ProgressEvent::IngestPage { + ingested_total: i, + batch_count: 1, + metrics: None, + }); + } + let mut received = 0; + while rx.try_recv().is_ok() { + received += 1; + } + assert_eq!(received, 10, "ZERO interval must not drop anything"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn terminal_events_bypass_throttle() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let p = ChannelProgress::with_interval(tx, Duration::from_secs(60)); + p.emit(ProgressEvent::IngestPage { + ingested_total: 999, + batch_count: 3, + metrics: None, + }); + p.emit(ProgressEvent::Completed { kind: "delta_sync".into() }); + p.emit(ProgressEvent::Error { message: "boom".into() }); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::IngestPage { + ingested_total: 999, + .. + }) + )); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::Completed { .. }) + )); + assert!(matches!(rx.try_recv(), Ok(ProgressEvent::Error { .. }))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn non_terminal_events_collapse_under_throttle() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let p = ChannelProgress::with_interval(tx, Duration::from_millis(100)); + p.emit(ProgressEvent::PhaseChanged { phase: "a".into() }); + p.emit(ProgressEvent::PhaseChanged { phase: "b".into() }); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::PhaseChanged { ref phase }) if phase == "a" + )); + assert!(rx.try_recv().is_err(), "second emit must have been dropped"); + + thread::sleep(Duration::from_millis(120)); + p.emit(ProgressEvent::PhaseChanged { phase: "c".into() }); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::PhaseChanged { ref phase }) if phase == "c" + )); + } + + #[tokio::test(flavor = "multi_thread")] + async fn ingest_pages_coalesce_to_latest_within_throttle_window() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let p = ChannelProgress::with_interval(tx, Duration::from_millis(100)); + p.emit(ProgressEvent::IngestPage { + ingested_total: 500, + batch_count: 1, + metrics: None, + }); + p.emit(ProgressEvent::IngestPage { + ingested_total: 2500, + batch_count: 5, + metrics: None, + }); + p.emit(ProgressEvent::IngestPage { + ingested_total: 5000, + batch_count: 10, + metrics: None, + }); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::IngestPage { + ingested_total: 500, + .. + }) + )); + assert!(rx.try_recv().is_err(), "bursts must coalesce, not stack"); + + thread::sleep(Duration::from_millis(120)); + p.emit(ProgressEvent::IngestPage { + ingested_total: 5500, + batch_count: 11, + metrics: None, + }); + assert!( + matches!( + rx.try_recv(), + Ok(ProgressEvent::IngestPage { + ingested_total: 5000, + .. + }) + ), + "latest pending count must flush when the gate opens" + ); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::IngestPage { + ingested_total: 5500, + .. + }) + )); + } + + #[tokio::test(flavor = "multi_thread")] + async fn closed_receiver_does_not_panic_the_sender() { + let (tx, rx) = mpsc::unbounded_channel(); + let p = ChannelProgress::with_interval(tx, Duration::ZERO); + drop(rx); + p.emit(ProgressEvent::PhaseChanged { phase: "x".into() }); + } + + #[test] + fn always_emit_true_for_terminal_events() { + assert!(ProgressEvent::Completed { kind: "k".into() }.always_emit()); + assert!(ProgressEvent::Error { message: "m".into() }.always_emit()); + assert!(!ProgressEvent::PhaseChanged { phase: "p".into() }.always_emit()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/scheduler.rs b/src-tauri/crates/psysonic-library/src/sync/scheduler.rs new file mode 100644 index 00000000..d7d64372 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/scheduler.rs @@ -0,0 +1,619 @@ +//! C8 — background scheduler (spec §6.2). +//! +//! Tick-based: the top crate (PR-5) drives the actual timer; PR-3d2 +//! ships the logic that decides "is it time?", picks the budget + +//! tombstone trigger, runs the DeltaSyncRunner, and writes back the +//! adaptive interval. +//! +//! Owns no tokio task itself — keeps testability high and lets the +//! caller decide spawn behaviour (Supervisor or inline). + +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use psysonic_integration::subsonic::SubsonicClient; + +use super::bandwidth::{ParallelismBudget, PlaybackHint}; +use super::budget::{PassKind, RequestBudget}; +use super::capability::{CapabilityFlags, NavidromeProbeCredentials}; +use super::delta::{DeltaSyncReport, DeltaSyncRunner}; +use super::error::SyncError; +use super::poll_stats::{next_interval_ms, PollStats}; +use super::progress::{NoopProgress, Progress}; +use super::tombstone::should_auto_reconcile; +use crate::repos::SyncStateRepository; +use crate::store::LibraryStore; + +/// Default Mode B threshold per §6.7 (5 % gap before auto reconcile). +pub const DEFAULT_TOMBSTONE_THRESHOLD_PCT: u32 = 5; + +/// Outcome of one scheduler tick — what happened plus the resolved +/// `next_poll_at` so the caller can re-schedule its timer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SchedulerTickReport { + pub skipped_not_due: bool, + pub skipped_bulk_paused: bool, + /// Delta/tombstone pass deferred while initial sync or capability probe + /// holds `sync_phase`, IS-3 bulk ingest is active, or a foreground sync + /// job (`LibraryRuntime::current_job`) is running for this server. + pub skipped_sync_pass_active: bool, + pub delta: Option, + pub next_poll_at_ms: i64, +} + +pub struct BackgroundScheduler<'a> { + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + navidrome: Option, + server_id: String, + library_scope: String, + capability_flags: CapabilityFlags, + playback_hint: PlaybackHint, + cancel: Option>, + progress: Arc, + tombstone_threshold_pct: u32, + sleep_enabled: bool, + /// When true, a user-triggered sync job (delta / verify / full resync) + /// already owns this server — skip the background delta pass. + foreground_sync_job_active: bool, +} + +impl<'a> BackgroundScheduler<'a> { + pub fn new( + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + server_id: impl Into, + library_scope: impl Into, + capability_flags: CapabilityFlags, + ) -> Self { + Self { + store, + subsonic, + navidrome: None, + server_id: server_id.into(), + library_scope: library_scope.into(), + capability_flags, + playback_hint: PlaybackHint::Idle, + cancel: None, + progress: Arc::new(NoopProgress), + tombstone_threshold_pct: DEFAULT_TOMBSTONE_THRESHOLD_PCT, + sleep_enabled: true, + foreground_sync_job_active: false, + } + } + + pub fn with_navidrome_credentials(mut self, creds: NavidromeProbeCredentials) -> Self { + self.navidrome = Some(creds); + self + } + + pub fn with_playback_hint(mut self, hint: PlaybackHint) -> Self { + self.playback_hint = hint; + self + } + + pub fn with_cancellation(mut self, flag: Arc) -> Self { + self.cancel = Some(flag); + self + } + + pub fn with_progress(mut self, progress: Arc) -> Self { + self.progress = progress; + self + } + + pub fn with_tombstone_threshold_pct(mut self, pct: u32) -> Self { + self.tombstone_threshold_pct = pct; + self + } + + pub fn with_sleep_disabled(mut self) -> Self { + self.sleep_enabled = false; + self + } + + pub fn with_foreground_sync_job_active(mut self, active: bool) -> Self { + self.foreground_sync_job_active = active; + self + } + + /// `true` when `next_poll_at` has passed (or no value yet). Caller + /// short-circuits its timer when this returns `false`. + pub fn is_due(&self, now_ms: i64) -> Result { + let sync_state = SyncStateRepository::new(self.store); + let next = sync_state + .get_next_poll_at(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + Ok(next.map(|n| now_ms >= n).unwrap_or(true)) + } + + /// Resolve the parallelism budget for the current playback state. + /// Bulk-paused state means the scheduler skips the tick entirely + /// and just re-schedules. + pub fn parallelism_budget(&self) -> ParallelismBudget { + ParallelismBudget::resolve(self.playback_hint) + } + + /// Run one tick — runs a delta sync if due and bulk isn't paused + /// by the playback signal, then writes the new `next_poll_at`. + pub async fn tick(&self, now_ms: i64) -> Result { + let sync_state = SyncStateRepository::new(self.store); + sync_state + .ensure(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + + let mut report = SchedulerTickReport { + skipped_not_due: false, + skipped_bulk_paused: false, + skipped_sync_pass_active: false, + delta: None, + next_poll_at_ms: now_ms, + }; + + if self.sync_pass_active(&sync_state)? { + report.skipped_sync_pass_active = true; + report.next_poll_at_ms = now_ms + 30_000; + sync_state + .set_next_poll_at(&self.server_id, &self.library_scope, report.next_poll_at_ms) + .map_err(SyncError::Storage)?; + crate::app_eprintln!( + "[library-sync] scheduler tick skipped: sync pass active (phase={:?}, bulk={})", + sync_state + .get_sync_phase(&self.server_id, &self.library_scope) + .ok() + .flatten(), + self.store.bulk_ingest_active() + ); + return Ok(report); + } + + if !self.is_due(now_ms)? { + report.skipped_not_due = true; + let stats = self.load_poll_stats(&sync_state)?; + report.next_poll_at_ms = now_ms + next_interval_ms(&stats) as i64; + return Ok(report); + } + + let parallelism = self.parallelism_budget(); + if parallelism.bulk_paused() { + // §6.2.4 PrefetchActive — skip this tick entirely, re-poll + // soon so we can catch the prefetch finishing. + report.skipped_bulk_paused = true; + report.next_poll_at_ms = now_ms + 30_000; // ~30s short retry + sync_state + .set_next_poll_at(&self.server_id, &self.library_scope, report.next_poll_at_ms) + .map_err(SyncError::Storage)?; + return Ok(report); + } + + // Decide budget + tombstone trigger. + let mut tombstone_budget: u32 = 0; + if let (Some(local), Some(server)) = ( + sync_state + .get_local_track_count(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?, + sync_state + .get_server_track_count(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?, + ) { + let (local_u, server_u) = (local.max(0) as u32, server.max(0) as u32); + if should_auto_reconcile(local_u, server_u, self.tombstone_threshold_pct) { + tombstone_budget = RequestBudget::DELTA_MISMATCH_CAP; + } + } + let _pass_budget = if tombstone_budget > 0 { + RequestBudget::for_pass(PassKind::DeltaMismatch) + } else { + RequestBudget::for_pass(PassKind::DeltaLight) + }; + // PR-3d2 doesn't enforce pass_budget against the runner yet — + // delta runner is already small (1 probe + ≤8 album-list + // pages); the budget value is recorded so PR-5 can surface it + // in Settings. Wire actual cap in the runner when DS-7 + // starred delta or other request-heavy paths land. + + // Run the delta pass. + let mut runner = DeltaSyncRunner::new( + self.store, + self.subsonic, + &self.server_id, + &self.library_scope, + self.capability_flags, + ) + .with_progress(Arc::clone(&self.progress)); + if let Some(creds) = &self.navidrome { + runner = runner.with_navidrome_credentials(creds.clone()); + } + if let Some(flag) = &self.cancel { + runner = runner.with_cancellation(Arc::clone(flag)); + } + if !self.sleep_enabled { + runner = runner.with_sleep_disabled(); + } + if tombstone_budget > 0 { + runner = runner.with_tombstone_budget(tombstone_budget); + } + let delta_report = runner.run().await?; + + // Update poll_stats: nothing measured per-request yet in + // PR-3d2 (PR-5 will plumb byte/duration via a custom HTTP + // wrapper). For now the tier signal updates from artist_count + // when the next probe lands; we just persist the artist_count + // we know from the local DB so the tier classifier has data. + let mut stats = self.load_poll_stats(&sync_state)?; + if delta_report.changed_count > 0 { + // Re-stamp the local count snapshot so the next tick's + // threshold check has fresh data. + if let Ok(local) = self.count_local_tracks() { + sync_state + .set_local_track_count(&self.server_id, &self.library_scope, local) + .map_err(SyncError::Storage)?; + } + } + stats.reclassify(); + sync_state + .set_library_tier( + &self.server_id, + &self.library_scope, + stats.library_tier.as_tag(), + ) + .map_err(SyncError::Storage)?; + sync_state + .set_poll_stats_json( + &self.server_id, + &self.library_scope, + &serde_json::to_value(stats).unwrap_or_default(), + ) + .map_err(SyncError::Storage)?; + + report.next_poll_at_ms = now_ms + next_interval_ms(&stats) as i64; + sync_state + .set_next_poll_at(&self.server_id, &self.library_scope, report.next_poll_at_ms) + .map_err(SyncError::Storage)?; + + report.delta = Some(delta_report); + Ok(report) + } + + fn load_poll_stats( + &self, + sync_state: &SyncStateRepository<'_>, + ) -> Result { + let raw = sync_state + .get_poll_stats_json(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + match raw { + None => Ok(PollStats::default()), + Some(v) => serde_json::from_value(v).map_err(|e| SyncError::Storage(e.to_string())), + } + } + + /// True while initial sync, capability probe, IS-3 bulk ingest, or a + /// foreground sync job for this server is in flight — background delta + /// must not compete for HTTP budget or tombstone probes. + fn sync_pass_active(&self, sync_state: &SyncStateRepository<'_>) -> Result { + if self.foreground_sync_job_active { + return Ok(true); + } + if self.store.bulk_ingest_active() { + return Ok(true); + } + let phase = sync_state + .get_sync_phase(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + Ok(matches!( + phase.as_deref(), + Some("initial_sync") | Some("probing") + )) + } + + fn count_local_tracks(&self) -> Result { + self.store + .with_conn("scheduler.count_local_tracks", |c| { + c.query_row( + "SELECT COUNT(*) FROM track WHERE server_id = ?1 AND deleted = 0", + rusqlite::params![self.server_id], + |row| row.get(0), + ) + }) + .map_err(SyncError::Storage) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials}; + use serde_json::json; + use wiremock::matchers::{method as wm_method, path as wm_path}; + 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) + } + + async fn empty_probe_and_albumlist(server: &MockServer, last_modified: i64) { + 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": last_modified, + "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; + } + + // ── is_due ──────────────────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn is_due_returns_true_when_no_schedule_yet() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + let sched = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ); + assert!(sched.is_due(0).unwrap()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn is_due_false_when_next_poll_in_future() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state.set_next_poll_at("s1", "", 5_000_000).unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let sched = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ); + assert!(!sched.is_due(1_000_000).unwrap()); + assert!(sched.is_due(5_000_001).unwrap()); + } + + // ── tick skips when not due ────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn tick_skips_while_initial_sync_phase_active() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state + .set_sync_phase("s1", "", "initial_sync") + .unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .tick(0) + .await + .unwrap(); + + assert!(report.skipped_sync_pass_active); + assert!(report.delta.is_none()); + assert_eq!(report.next_poll_at_ms, 30_000); + } + + #[tokio::test(flavor = "multi_thread")] + async fn tick_skips_when_foreground_sync_job_active() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .with_foreground_sync_job_active(true) + .tick(0) + .await + .unwrap(); + + assert!(report.skipped_sync_pass_active); + assert!(report.delta.is_none()); + assert_eq!(report.next_poll_at_ms, 30_000); + } + + #[tokio::test(flavor = "multi_thread")] + async fn tick_skips_when_not_due_and_reports_next_poll() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state.set_next_poll_at("s1", "", 1_000_000_000).unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .tick(500) + .await + .unwrap(); + + assert!(report.skipped_not_due); + assert!(report.delta.is_none()); + assert!(report.next_poll_at_ms > 500); + } + + // ── tick pauses when PrefetchActive ────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn tick_pauses_when_playback_hint_is_prefetch_active() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_playback_hint(PlaybackHint::PrefetchActive) + .with_sleep_disabled() + .tick(0) + .await + .unwrap(); + + assert!(report.skipped_bulk_paused); + assert!(report.delta.is_none()); + // Re-scheduled soon (≤ 60s after now) so we catch the + // prefetch finishing. + assert!(report.next_poll_at_ms > 0); + assert!(report.next_poll_at_ms <= 60_000); + } + + // ── tick runs delta and stamps next_poll_at ────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn tick_runs_delta_and_persists_next_poll_at() { + let server = MockServer::start().await; + empty_probe_and_albumlist(&server, 1_716_840_000_000).await; + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .tick(1_000) + .await + .unwrap(); + + assert!(!report.skipped_not_due); + assert!(!report.skipped_bulk_paused); + assert!(report.delta.is_some()); + let next = SyncStateRepository::new(&store) + .get_next_poll_at("s1", "") + .unwrap() + .unwrap(); + assert_eq!(next, report.next_poll_at_ms); + assert!(next > 1_000); + } + + // ── auto-tombstone trigger ────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn tick_auto_tombstones_when_count_gap_exceeds_threshold() { + let server = MockServer::start().await; + empty_probe_and_albumlist(&server, 1_716_840_000_000).await; + // Tombstone probe — empty store has nothing to probe, so we + // only need to know the runner *would* have called getSong if + // there were rows. For this test it's enough that no panic + // occurs and the delta report's tombstone counters are zero. + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + // 110 local vs 100 server → 10 % gap, threshold 5 % default. + sync_state.set_local_track_count("s1", "", 110).unwrap(); + sync_state.set_server_track_count("s1", "", 100).unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .tick(0) + .await + .unwrap(); + + let delta = report.delta.expect("delta ran"); + // Tombstone budget was set (200), but no local tracks exist → + // nothing to probe, both counters stay at 0. The important + // signal is that the runner accepted the trigger. + assert_eq!(delta.tombstones_checked, 0); + assert_eq!(delta.tombstones_deleted, 0); + } + + // ── PollStats persistence round trip ──────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn poll_stats_persist_round_trip_through_tick() { + let server = MockServer::start().await; + empty_probe_and_albumlist(&server, 1_716_840_000_000).await; + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .tick(0) + .await + .unwrap(); + + let stored = SyncStateRepository::new(&store) + .get_poll_stats_json("s1", "") + .unwrap() + .unwrap(); + // tier is recorded — runner reclassifies even with no + // observations yet, so this is "unknown" on a fresh store. + let stats: PollStats = serde_json::from_value(stored).unwrap(); + assert_eq!(stats.library_tier.as_tag(), "unknown"); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/strategy.rs b/src-tauri/crates/psysonic-library/src/sync/strategy.rs new file mode 100644 index 00000000..535e6fba --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/strategy.rs @@ -0,0 +1,216 @@ +//! C2 ingest strategy selection. Per the PR-3 kickoff answer (workdocs +//! `2026-05-19-pr3-kickoff.md` Q3) the choice is made once at initial +//! sync start from the probed capability flags; the runner does not +//! auto-switch on transient failure (C12 retries the same batch). + +use super::capability::CapabilityFlags; + +/// Server track count above which N1 is skipped in favour of S1 at initial +/// sync start (R7-15 Q4). N1's native `/api/song` deep-offset 500 wall makes +/// it unable to finish very large catalogs; S1 (`search3`) does not hit it. +/// Tunable constant — the live wall was observed past ~50k. +pub const LARGE_LIBRARY_THRESHOLD: i64 = 40_000; + +/// Spec §6.3 IS-3 strategies. Names match §6.1.1 capability bits where +/// applicable; S2 has no flag of its own — it's the universal +/// album-crawl fallback assumed available whenever the Subsonic ping +/// succeeds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IngestStrategy { + /// N1 — Navidrome native `GET /api/song` paginated. Cheapest at + /// 500k; requires `NavidromeNativeBulk` flag set by the probe. + N1, + /// S1 — Subsonic `search3` empty query, songOffset paged. Requires + /// `SubsonicSearch3Bulk`. + S1, + /// S2 — `getAlbumList2` + `getAlbum` per album. Universal Subsonic + /// fallback — assumed available whenever the ping returns ok. + S2, + /// S3 — `getIndexes` + `getMusicDirectory` recursive file-tree + /// crawl. Last resort; PR-3b does not auto-select it. + S3, +} + +impl IngestStrategy { + /// Pick the cheapest strategy supported by `flags`. Per kickoff Q3: + /// `N1 → S1 → S2`. S3 is enumerated for completeness but never + /// auto-selected — when neither N1 nor S1 is available, S2 is + /// always tried first because every Subsonic-compliant server + /// exposes `getAlbumList2` + `getAlbum`. + pub fn select_from_flags(flags: CapabilityFlags) -> Self { + if flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK) { + Self::N1 + } else if flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK) { + Self::S1 + } else { + Self::S2 + } + } + + /// Pick the initial-sync strategy with the large-library policy on top + /// of `select_from_flags` (R7-15 Q4). A large catalog — or one already + /// flagged `n1_bulk_unreliable` after an N1 deep-offset 500 — must avoid + /// N1, which cannot finish past the wall. Prefer S1 (`search3`) and fall + /// back to S2 (universal album crawl) when search3 bulk is unavailable. + /// + /// `server_track_count` is best-effort at IS-1 (probe `getScanStatus` + /// count or a prior watermark); `None` means unknown, in which case only + /// the `n1_bulk_unreliable` flag forces the non-N1 path (a first run with + /// no count still tries the cheapest strategy and learns from a 500). + pub fn select_initial_strategy( + flags: CapabilityFlags, + server_track_count: Option, + n1_bulk_unreliable: bool, + ) -> Self { + let is_large = server_track_count + .map(|c| c > LARGE_LIBRARY_THRESHOLD) + .unwrap_or(false); + if n1_bulk_unreliable || is_large { + if flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK) { + return Self::S1; + } + return Self::S2; + } + Self::select_from_flags(flags) + } + + /// String tag stored in `initial_sync_cursor_json` so the runner + /// can resume after restart without re-running capability probe. + pub fn as_tag(self) -> &'static str { + match self { + Self::N1 => "n1", + Self::S1 => "s1", + Self::S2 => "s2", + Self::S3 => "s3", + } + } + + pub fn from_tag(tag: &str) -> Option { + match tag { + "n1" => Some(Self::N1), + "s1" => Some(Self::S1), + "s2" => Some(Self::S2), + "s3" => Some(Self::S3), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_prefers_navidrome_native_when_both_n1_and_s1_present() { + // At 500k, N1 cuts request count by 10× vs S1 — selector must + // pick it whenever the flag is set, regardless of S1. + let flags = CapabilityFlags::new( + CapabilityFlags::NAVIDROME_NATIVE_BULK | CapabilityFlags::SUBSONIC_SEARCH3_BULK, + ); + assert_eq!(IngestStrategy::select_from_flags(flags), IngestStrategy::N1); + } + + #[test] + fn select_falls_back_to_s1_without_n1() { + let flags = CapabilityFlags::new(CapabilityFlags::SUBSONIC_SEARCH3_BULK); + assert_eq!(IngestStrategy::select_from_flags(flags), IngestStrategy::S1); + } + + #[test] + fn select_falls_back_to_s2_when_no_bulk_flag_set() { + // Generic Subsonic server without search3 bulk → universal + // album crawl. S3 is not auto-selected even with FileTreeBrowse. + let flags = CapabilityFlags::new(CapabilityFlags::FILE_TREE_BROWSE); + assert_eq!(IngestStrategy::select_from_flags(flags), IngestStrategy::S2); + } + + #[test] + fn select_falls_back_to_s2_with_no_flags() { + // Default-flag (`0x000`) — fresh DB before probe runs, or a + // truly minimal Subsonic implementation. Still resolves to a + // strategy; runner surfaces errors if S2 endpoints then fail. + assert_eq!( + IngestStrategy::select_from_flags(CapabilityFlags::default()), + IngestStrategy::S2 + ); + } + + // ── select_initial_strategy (R7-15 Q4 large-library policy) ────────── + + fn navidrome_full() -> CapabilityFlags { + CapabilityFlags::new( + CapabilityFlags::NAVIDROME_NATIVE_BULK | CapabilityFlags::SUBSONIC_SEARCH3_BULK, + ) + } + + #[test] + fn initial_strategy_small_library_keeps_cheapest_n1() { + // Below threshold, not flagged unreliable → unchanged N1-first chain. + let s = IngestStrategy::select_initial_strategy(navidrome_full(), Some(1_000), false); + assert_eq!(s, IngestStrategy::N1); + } + + #[test] + fn initial_strategy_large_library_avoids_n1_for_s1() { + // Over threshold → S1 even though N1 is advertised (deep-offset wall). + let s = IngestStrategy::select_initial_strategy(navidrome_full(), Some(170_000), false); + assert_eq!(s, IngestStrategy::S1); + } + + #[test] + fn initial_strategy_unreliable_flag_avoids_n1_regardless_of_size() { + // Learned `n1_bulk_unreliable` forces the non-N1 path even when the + // count is small/unknown (covers R7-15 "unknown → large if N1 failed"). + let small = + IngestStrategy::select_initial_strategy(navidrome_full(), Some(500), true); + assert_eq!(small, IngestStrategy::S1); + let unknown = IngestStrategy::select_initial_strategy(navidrome_full(), None, true); + assert_eq!(unknown, IngestStrategy::S1); + } + + #[test] + fn initial_strategy_large_without_search3_falls_back_to_s2() { + // Avoid-N1 path but no search3 bulk → universal album crawl, not N1. + let flags = CapabilityFlags::new(CapabilityFlags::NAVIDROME_NATIVE_BULK); + let s = IngestStrategy::select_initial_strategy(flags, Some(170_000), false); + assert_eq!(s, IngestStrategy::S2); + } + + #[test] + fn initial_strategy_unknown_count_uses_cheapest_when_not_flagged() { + // First run, no count yet, N1 never failed → try cheapest (N1); the + // mid-run N1→S1 fallback (R7-15 Q5) handles the wall if hit. + let s = IngestStrategy::select_initial_strategy(navidrome_full(), None, false); + assert_eq!(s, IngestStrategy::N1); + } + + #[test] + fn initial_strategy_threshold_is_strictly_greater_than() { + // Exactly at the threshold is not "large"; one above is. + let at = IngestStrategy::select_initial_strategy( + navidrome_full(), + Some(LARGE_LIBRARY_THRESHOLD), + false, + ); + assert_eq!(at, IngestStrategy::N1); + let over = IngestStrategy::select_initial_strategy( + navidrome_full(), + Some(LARGE_LIBRARY_THRESHOLD + 1), + false, + ); + assert_eq!(over, IngestStrategy::S1); + } + + #[test] + fn tag_roundtrip_is_stable_for_cursor_persistence() { + for s in [ + IngestStrategy::N1, + IngestStrategy::S1, + IngestStrategy::S2, + IngestStrategy::S3, + ] { + assert_eq!(IngestStrategy::from_tag(s.as_tag()), Some(s)); + } + assert_eq!(IngestStrategy::from_tag("unknown"), None); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/supervisor.rs b/src-tauri/crates/psysonic-library/src/sync/supervisor.rs new file mode 100644 index 00000000..5d2c8e5c --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/supervisor.rs @@ -0,0 +1,166 @@ +//! C5 — `SyncSupervisor`. Wraps the spawn / cancel / join lifecycle +//! the top crate (PR-5) will use to run an `InitialSyncRunner` or +//! `DeltaSyncRunner` from a Tauri command. The supervisor owns the +//! cancellation `AtomicBool` and the `mpsc` receiver carrying +//! progress events. +//! +//! Stays pure library — no Tauri imports here; PR-5 hooks the +//! receiver to `AppHandle::emit`. + +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use super::error::SyncError; +use super::progress::{ChannelProgress, Progress, ProgressEvent}; + +pub struct SyncSupervisor { + cancel: Arc, + handle: Option>>, + progress_rx: Option>, +} + +impl SyncSupervisor { + /// Spawn a sync workload. `task` receives the cancellation flag + /// and a `Progress` handle backed by an internal mpsc channel. + /// Caller drives the receiver via `progress_receiver()` and waits + /// for completion via `join().await`. + pub fn spawn(task: F) -> Self + where + F: FnOnce(Arc, Arc) -> Fut + Send + 'static, + Fut: std::future::Future> + Send + 'static, + { + Self::spawn_with_interval(task, ChannelProgress::DEFAULT_INTERVAL) + } + + /// Variant that overrides the throttle interval — tests pass + /// `Duration::ZERO` so every event observed in + /// `progress_receiver()` matches the runner's emit order. + pub fn spawn_with_interval(task: F, throttle: std::time::Duration) -> Self + where + F: FnOnce(Arc, Arc) -> Fut + Send + 'static, + Fut: std::future::Future> + Send + 'static, + { + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let progress: Arc = + Arc::new(ChannelProgress::with_interval(tx, throttle)); + let cancel_clone = Arc::clone(&cancel); + let handle = tokio::task::spawn(async move { task(cancel_clone, progress).await }); + Self { + cancel, + handle: Some(handle), + progress_rx: Some(rx), + } + } + + /// Trip the cancellation flag. Runners check it between batches + /// and bail out with `SyncError::Cancelled` on the next iteration. + pub fn cancel(&self) { + self.cancel.store(true, Ordering::SeqCst); + } + + pub fn is_cancelled(&self) -> bool { + self.cancel.load(Ordering::SeqCst) + } + + /// Take the progress receiver. Single consumer only — the + /// `Option::take` semantics mean later calls return `None`. + pub fn progress_receiver( + &mut self, + ) -> Option> { + self.progress_rx.take() + } + + /// Wait for the spawned task. Returns the task's `Result` — + /// `Ok(Ok(()))` on clean exit, `Ok(Err(SyncError))` on a + /// runner-level failure, `Err(JoinError)` only if the task + /// panicked, in which case we surface that as `SyncError::Storage` + /// so callers never need to know about tokio internals. + pub async fn join(mut self) -> Result<(), SyncError> { + let handle = self + .handle + .take() + .ok_or_else(|| SyncError::Storage("supervisor already joined".into()))?; + match handle.await { + Ok(inner) => inner, + Err(join_err) => Err(SyncError::Storage(format!( + "sync task panicked: {join_err}" + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[tokio::test(flavor = "multi_thread")] + async fn supervisor_runs_task_to_completion_and_emits_progress() { + let mut sup = SyncSupervisor::spawn_with_interval( + |_cancel, progress| async move { + progress.emit(ProgressEvent::PhaseChanged { phase: "ingest".into() }); + progress.emit(ProgressEvent::Completed { + kind: "initial_sync".into(), + }); + Ok(()) + }, + Duration::ZERO, + ); + let mut rx = sup.progress_receiver().expect("receiver still in place"); + let result = sup.join().await; + assert!(result.is_ok()); + let mut events = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } + assert_eq!(events.len(), 2); + assert!(matches!(events[0], ProgressEvent::PhaseChanged { .. })); + assert!(matches!(events[1], ProgressEvent::Completed { .. })); + } + + #[tokio::test(flavor = "multi_thread")] + async fn supervisor_cancel_trips_atomic_flag_before_task_joins() { + let started = Arc::new(tokio::sync::Notify::new()); + let started_clone = Arc::clone(&started); + let sup = SyncSupervisor::spawn(move |cancel, _progress| { + let started = started_clone; + async move { + started.notify_one(); + // Spin until cancelled or 2s timeout. + for _ in 0..200 { + if cancel.load(Ordering::SeqCst) { + return Err(SyncError::Cancelled); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + Ok(()) + } + }); + started.notified().await; + sup.cancel(); + assert!(sup.is_cancelled()); + let result = sup.join().await; + assert!(matches!(result, Err(SyncError::Cancelled))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn supervisor_propagates_task_panic_as_sync_error() { + let sup = SyncSupervisor::spawn(|_cancel, _progress| async move { + panic!("simulated task panic"); + }); + let result = sup.join().await; + assert!(matches!(result, Err(SyncError::Storage(_)))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn progress_receiver_take_returns_none_after_first_call() { + let mut sup = SyncSupervisor::spawn(|_cancel, _progress| async move { Ok(()) }); + let first = sup.progress_receiver(); + let second = sup.progress_receiver(); + assert!(first.is_some()); + assert!(second.is_none()); + sup.join().await.unwrap(); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/tombstone.rs b/src-tauri/crates/psysonic-library/src/sync/tombstone.rs new file mode 100644 index 00000000..6c5008f5 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/tombstone.rs @@ -0,0 +1,447 @@ +//! C4 — `TombstoneReconciler` (spec §6.7). +//! +//! Streams a chunk of local track ids, hits `getSong` per id, and +//! marks `track.deleted = 1` for every `SubsonicError::NotFound` +//! (error code 70). Designed for two callers: +//! +//! - **Mode A (manual integrity check):** Settings → "Verify library +//! integrity" loops `reconcile_chunk(N)` until it returns +//! `checked == 0`. +//! - **Mode B (auto, threshold-triggered):** the delta scheduler +//! tests `should_auto_reconcile` against the count drop, then loops +//! `reconcile_chunk(budget)` once per delta tick until the gap +//! closes. +//! +//! Streaming so memory stays bounded at 500k: `LIMIT N ORDER BY +//! synced_at ASC` picks the next chunk; PR-3c keeps the loop entirely +//! caller-driven so cancellation is checked between chunks. + +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +use psysonic_integration::subsonic::{SubsonicClient, SubsonicError}; + +use super::backoff::{jitter_salt, with_jitter, Backoff}; +use super::error::SyncError; +use crate::store::LibraryStore; + +const MAX_ATTEMPTS_PER_BATCH: u32 = 5; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TombstoneReport { + pub checked: u32, + pub deleted: u32, +} + +pub struct TombstoneReconciler<'a> { + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + server_id: String, + cancel: Option>, + sleep_enabled: bool, +} + +impl<'a> TombstoneReconciler<'a> { + pub fn new( + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + server_id: impl Into, + ) -> Self { + Self { + store, + subsonic, + server_id: server_id.into(), + cancel: None, + sleep_enabled: true, + } + } + + pub fn with_cancellation(mut self, flag: Arc) -> Self { + self.cancel = Some(flag); + self + } + + pub fn with_sleep_disabled(mut self) -> Self { + self.sleep_enabled = false; + self + } + + /// Process up to `budget` not-yet-checked tracks. Returns counts + /// for this call only — caller loops until `checked == 0` to + /// complete a Mode A pass, or stops at any budget for Mode B + /// sampled passes. Order: oldest `synced_at` first so the most + /// stale rows get re-validated soonest. + pub async fn reconcile_chunk(&self, budget: u32) -> Result { + if budget == 0 { + return Ok(TombstoneReport::default()); + } + let ids = self.next_candidates(budget)?; + let mut report = TombstoneReport::default(); + for id in ids { + self.check_cancellation()?; + report.checked = report.checked.saturating_add(1); + let outcome = retry_with_backoff( + self, + || self.subsonic.get_song(&id), + |e: SubsonicError| -> SyncError { e.into() }, + ) + .await; + match outcome { + Ok(_) => { + // Still present — stamp `synced_at` so it goes to + // the back of the queue and we don't re-probe it + // again on the next chunk. + self.mark_synced(&id)?; + } + Err(SyncError::NotFound) => { + self.mark_deleted(&id)?; + report.deleted = report.deleted.saturating_add(1); + } + Err(other) => return Err(other), + } + } + Ok(report) + } + + fn check_cancellation(&self) -> Result<(), SyncError> { + if let Some(flag) = &self.cancel { + if flag.load(Ordering::SeqCst) { + return Err(SyncError::Cancelled); + } + } + Ok(()) + } + + fn next_candidates(&self, budget: u32) -> Result, SyncError> { + self.store + .with_conn("misc", |c| { + let mut stmt = c.prepare( + "SELECT id FROM track \ + WHERE server_id = ?1 AND deleted = 0 \ + ORDER BY synced_at ASC LIMIT ?2", + )?; + let rows: rusqlite::Result> = stmt + .query_map(rusqlite::params![self.server_id, budget as i64], |r| { + r.get::<_, String>(0) + })? + .collect(); + rows + }) + .map_err(SyncError::Storage) + } + + fn mark_deleted(&self, id: &str) -> Result<(), SyncError> { + self.store + .with_conn("misc", |c| { + c.execute( + "UPDATE track SET deleted = 1, synced_at = ?3 \ + WHERE server_id = ?1 AND id = ?2", + rusqlite::params![self.server_id, id, now_unix_ms()], + )?; + Ok(()) + }) + .map_err(SyncError::Storage) + } + + fn mark_synced(&self, id: &str) -> Result<(), SyncError> { + self.store + .with_conn("misc", |c| { + c.execute( + "UPDATE track SET synced_at = ?3 \ + WHERE server_id = ?1 AND id = ?2", + rusqlite::params![self.server_id, id, now_unix_ms()], + )?; + Ok(()) + }) + .map_err(SyncError::Storage) + } + + async fn sleep(&self, d: Duration) { + if self.sleep_enabled && !d.is_zero() { + tokio::time::sleep(d).await; + } + } +} + +/// §6.7 Mode B threshold check — returns `true` when the local / +/// server count gap exceeds the configured percentage. `server_count +/// == 0` is treated as "no signal" → `false` (no spurious reconcile +/// on a fresh server response). +pub fn should_auto_reconcile(local_count: u32, server_count: u32, threshold_pct: u32) -> bool { + if server_count == 0 { + return false; + } + let gap = local_count.saturating_sub(server_count); + let ratio_x100 = gap.saturating_mul(100) / server_count; + ratio_x100 > threshold_pct +} + +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>( + reconciler: &TombstoneReconciler<'a>, + mut build: F, + map_err: impl Fn(E) -> SyncError, +) -> Result +where + F: FnMut() -> FFut, + FFut: std::future::Future>, +{ + let mut backoff = Backoff::default(); + let mut attempt = 0u32; + loop { + reconciler.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)); + reconciler.sleep(jittered).await; + } + } + } +} + +fn is_retryable(e: &SyncError) -> bool { + matches!(e, SyncError::Transport(_) | SyncError::Navidrome(_)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::{TrackRepository, TrackRow}; + use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials}; + use serde_json::json; + use wiremock::matchers::{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 seed_track(store: &LibraryStore, id: &str, synced_at: i64) { + TrackRepository::new(store) + .upsert_batch(&[TrackRow { + server_id: "s1".into(), + id: id.into(), + title: id.into(), + title_sort: None, + artist: None, + artist_id: None, + album: String::new(), + album_id: None, + album_artist: None, + duration_sec: 0, + 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: None, + server_created_at: None, + deleted: false, + synced_at, + raw_json: "{}".into(), + }]) + .unwrap(); + } + + // ── should_auto_reconcile threshold predicate ───────────────────── + + #[test] + fn threshold_fires_when_local_outpaces_server_above_pct() { + // 110 local vs 100 server → 10% gap > 5% threshold. + assert!(should_auto_reconcile(110, 100, 5)); + } + + #[test] + fn threshold_stays_silent_within_tolerance() { + // 102 local vs 100 server → 2% gap, threshold 5%. + assert!(!should_auto_reconcile(102, 100, 5)); + } + + #[test] + fn threshold_silent_when_local_is_below_or_equal_server() { + assert!(!should_auto_reconcile(100, 100, 0)); + assert!(!should_auto_reconcile(50, 100, 5)); + } + + #[test] + fn threshold_silent_when_server_count_is_zero() { + // No signal — never reconcile on a server that's still scanning. + assert!(!should_auto_reconcile(1000, 0, 5)); + } + + // ── reconcile_chunk marks deleted on code 70 ───────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn reconcile_chunk_marks_deleted_for_code_70() { + let server = MockServer::start().await; + // tr_a → still present, tr_b → 404 via code 70. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .and(query_param("id", "tr_a")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "song": { "id": "tr_a", "title": "Still here" } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .and(query_param("id", "tr_b")) + .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_a", 1); + seed_track(&store, "tr_b", 2); + + let subsonic = test_subsonic(&server.uri()); + let report = TombstoneReconciler::new(&store, &subsonic, "s1") + .with_sleep_disabled() + .reconcile_chunk(10) + .await + .unwrap(); + + assert_eq!(report.checked, 2); + assert_eq!(report.deleted, 1); + + // tr_b is marked deleted; tr_a stays live but its synced_at is + // refreshed (so it doesn't get re-picked immediately). + let (a_deleted, b_deleted): (i64, i64) = store + .with_conn("misc", |c| { + let a: i64 = c.query_row( + "SELECT deleted FROM track WHERE id='tr_a'", + [], + |r| r.get(0), + )?; + let b: i64 = c.query_row( + "SELECT deleted FROM track WHERE id='tr_b'", + [], + |r| r.get(0), + )?; + Ok((a, b)) + }) + .unwrap(); + assert_eq!(a_deleted, 0); + assert_eq!(b_deleted, 1); + } + + // ── reconcile_chunk respects budget and ordering ───────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn reconcile_chunk_processes_oldest_first_up_to_budget() { + let server = MockServer::start().await; + // Any id → ok envelope. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "song": { "id": "any", "title": "t" } + } + }))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + // Seed three tracks with distinct synced_at values; oldest first. + seed_track(&store, "tr_oldest", 100); + seed_track(&store, "tr_middle", 200); + seed_track(&store, "tr_newest", 300); + + let subsonic = test_subsonic(&server.uri()); + let report = TombstoneReconciler::new(&store, &subsonic, "s1") + .with_sleep_disabled() + .reconcile_chunk(2) + .await + .unwrap(); + assert_eq!(report.checked, 2); + + // After the chunk: the two checked rows have a refreshed + // synced_at; the un-checked tr_newest still sits at 300. + let untouched: i64 = store + .with_conn("misc", |c| c.query_row( + "SELECT synced_at FROM track WHERE id='tr_newest'", + [], + |r| r.get(0), + )) + .unwrap(); + assert_eq!(untouched, 300, "tr_newest must not be probed within budget=2"); + } + + // ── reconcile_chunk: empty store ─────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn reconcile_chunk_returns_zero_counts_on_empty_store() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + let report = TombstoneReconciler::new(&store, &subsonic, "s1") + .with_sleep_disabled() + .reconcile_chunk(50) + .await + .unwrap(); + assert_eq!(report.checked, 0); + assert_eq!(report.deleted, 0); + } + + // ── reconcile_chunk: cancellation ───────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn reconcile_chunk_returns_cancelled_when_flag_tripped() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + seed_track(&store, "tr_x", 1); + + let flag = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let subsonic = test_subsonic(&server.uri()); + let err = TombstoneReconciler::new(&store, &subsonic, "s1") + .with_cancellation(flag) + .with_sleep_disabled() + .reconcile_chunk(10) + .await + .unwrap_err(); + assert!(matches!(err, SyncError::Cancelled)); + } +} diff --git a/src-tauri/crates/psysonic-library/src/track_fts.rs b/src-tauri/crates/psysonic-library/src/track_fts.rs new file mode 100644 index 00000000..98f6279a --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/track_fts.rs @@ -0,0 +1,156 @@ +//! FTS5 maintenance for bulk initial-sync ingest. +//! +//! Per-row FTS triggers dominate N1/S1 write time at scale. IS-3 suspends +//! them, loads tracks without FTS maintenance, then rebuilds from the +//! external-content table once before restoring triggers. + +use rusqlite::Connection; + +const RESTORE_TRACK_FTS_TRIGGERS: &str = r#" +CREATE TRIGGER IF NOT EXISTS track_ai AFTER INSERT ON track BEGIN + INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre) + VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre); +END; + +CREATE TRIGGER IF NOT EXISTS track_ad AFTER DELETE ON track BEGIN + INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre); +END; + +CREATE TRIGGER IF NOT EXISTS track_au AFTER UPDATE ON track BEGIN + INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre); + INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre) + VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre); +END; +"#; + +/// Drop FTS sync triggers so bulk upserts skip per-row FTS maintenance. +pub fn suspend_track_fts_triggers(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + "DROP TRIGGER IF EXISTS track_ai; + DROP TRIGGER IF EXISTS track_ad; + DROP TRIGGER IF EXISTS track_au;", + ) +} + +/// Rebuild `track_fts` from the `track` content table (fts5 external content). +pub fn rebuild_track_fts_from_content(conn: &Connection) -> rusqlite::Result<()> { + conn.execute("INSERT INTO track_fts(track_fts) VALUES('rebuild')", [])?; + Ok(()) +} + +/// Restore FTS sync triggers after bulk ingest + rebuild. +pub fn restore_track_fts_triggers(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch(RESTORE_TRACK_FTS_TRIGGERS) +} + +/// Normalize trigger DDL for equality checks — strips `IF NOT EXISTS` +/// and collapses whitespace so migration vs restore sources compare cleanly. +#[cfg(test)] +pub(crate) fn normalize_trigger_ddl(sql: &str) -> String { + sql.replace("IF NOT EXISTS", "") + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::LibraryStore; + use std::collections::HashMap; + + const TRACK_FTS_TRIGGER_NAMES: [&str; 3] = ["track_ai", "track_ad", "track_au"]; + + fn fetch_track_fts_trigger_ddl(conn: &Connection, name: &str) -> String { + conn.query_row( + "SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1", + [name], + |r| r.get(0), + ) + .unwrap_or_else(|e| panic!("missing trigger `{name}`: {e}")) + } + + fn migration_track_fts_trigger_bodies( + conn: &Connection, + ) -> rusqlite::Result> { + Ok(TRACK_FTS_TRIGGER_NAMES + .iter() + .map(|name| { + let ddl = fetch_track_fts_trigger_ddl(conn, name); + (name.to_string(), normalize_trigger_ddl(&ddl)) + }) + .collect()) + } + + #[test] + fn restore_track_fts_triggers_match_migration_bodies() { + let store = LibraryStore::open_in_memory(); + let baseline = store + .with_conn("misc", migration_track_fts_trigger_bodies) + .unwrap(); + + store + .with_conn_mut("misc", |conn| { + suspend_track_fts_triggers(conn)?; + restore_track_fts_triggers(conn)?; + for name in TRACK_FTS_TRIGGER_NAMES { + let after = normalize_trigger_ddl(&fetch_track_fts_trigger_ddl(conn, name)); + assert_eq!( + after, + baseline[name], + "trigger `{name}` body drifted after suspend/restore" + ); + } + Ok(()) + }) + .unwrap(); + } + + #[test] + fn bulk_ingest_suspend_rebuild_restores_fts_search() { + let store = LibraryStore::open_in_memory(); + store + .with_conn_mut("misc", |conn| suspend_track_fts_triggers(conn)) + .unwrap(); + store + .with_conn_mut("misc", |conn| { + conn.execute( + "INSERT INTO track (server_id, id, title, album, duration_sec, deleted, synced_at, raw_json) \ + VALUES ('s1', 't1', 'Bulk Title', 'Album', 1, 0, 1, '{}')", + [], + ) + }) + .unwrap(); + let mid: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM track_fts WHERE track_fts MATCH 'Bulk'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(mid, 0, "FTS must not update while triggers are suspended"); + + store + .with_conn_mut("misc", |conn| { + rebuild_track_fts_from_content(conn)?; + restore_track_fts_triggers(conn) + }) + .unwrap(); + + let after: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM track_fts WHERE track_fts MATCH 'Bulk'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(after, 1); + } +} diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs index 8ff6625a..6ad7c1ee 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs @@ -40,9 +40,10 @@ pub async fn download_track_hot_cache( // prefetch worker runs invokes sequentially. let app_seed = app.clone(); let tid = track_id.clone(); + let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; }); return Ok(HotCacheDownloadResult { path: path_str, @@ -79,9 +80,10 @@ pub async fn download_track_hot_cache( let app_seed = app.clone(); let tid = track_id.clone(); + let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; }); let size = tokio::fs::metadata(&file_path) @@ -134,9 +136,10 @@ pub async fn promote_stream_cache_to_hot_cache( ); let app_seed = app.clone(); let tid = track_id.clone(); + let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; }); return Ok(Some(HotCacheDownloadResult { path: path_str, size })); } @@ -151,7 +154,7 @@ pub async fn promote_stream_cache_to_hot_cache( .await .map_err(|e| e.to_string())?; - let _ = enqueue_analysis_seed(&app, &track_id, &bytes).await; + let _ = enqueue_analysis_seed(&app, &server_id, &track_id, &bytes).await; let size = tokio::fs::metadata(&file_path) .await @@ -176,9 +179,10 @@ pub async fn promote_stream_cache_to_hot_cache( } let app_seed = app.clone(); let tid = track_id.clone(); + let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; }); let size = tokio::fs::metadata(&file_path) .await diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs b/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs index 2afcab82..1ca843b0 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs @@ -13,15 +13,16 @@ use crate::file_transfer::{finalize_streamed_download, subsonic_http_client}; pub async fn enqueue_analysis_seed_from_file( app: &tauri::AppHandle, + server_id: &str, track_id: &str, file_path: &std::path::Path, ) { let cache = app.try_state::(); let cache_ref: Option<&analysis_cache::AnalysisCache> = cache.as_ref().map(|s| s.inner()); - let Some(bytes) = read_seed_bytes_if_needed(cache_ref, track_id, file_path).await else { + let Some(bytes) = read_seed_bytes_if_needed(cache_ref, server_id, track_id, file_path).await else { return; }; - let _ = enqueue_analysis_seed(app, track_id, &bytes).await; + let _ = enqueue_analysis_seed(app, server_id, track_id, &bytes).await; } /// AppHandle-free decision: returns the file bytes when seeding is required @@ -32,11 +33,12 @@ pub async fn enqueue_analysis_seed_from_file( /// branch with `AnalysisCache::open_in_memory()` plus a `tempfile::TempDir`. pub(crate) async fn read_seed_bytes_if_needed( cache: Option<&analysis_cache::AnalysisCache>, + server_id: &str, track_id: &str, file_path: &std::path::Path, ) -> Option> { if let Some(cache) = cache { - if cache.cpu_seed_redundant_for_track(track_id).unwrap_or(false) { + if cache.cpu_seed_redundant_for_track(server_id, track_id).unwrap_or(false) { return None; } } @@ -162,7 +164,7 @@ pub async fn download_track_offline( ) .await?; - enqueue_analysis_seed_from_file(&app, &track_id, &final_path).await; + enqueue_analysis_seed_from_file(&app, &server_id, &track_id, &final_path).await; Ok(path_str) } @@ -384,6 +386,7 @@ mod tests { // cpu_seed_redundant_for_track returns true when both waveform AND // loudness rows exist for the current algo version. let key = TrackKey { + server_id: String::new(), track_id: track_id.to_string(), md5_16kb: "deadbeef".to_string(), }; @@ -420,7 +423,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("track.mp3"); std::fs::write(&file, b"audio data").unwrap(); - let bytes = read_seed_bytes_if_needed(None, "anything", &file).await; + let bytes = read_seed_bytes_if_needed(None, "", "anything", &file).await; assert_eq!(bytes.as_deref(), Some(b"audio data".as_slice())); } @@ -430,7 +433,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("track.flac"); std::fs::write(&file, b"some bytes").unwrap(); - let bytes = read_seed_bytes_if_needed(Some(&cache), "fresh-track", &file).await; + let bytes = read_seed_bytes_if_needed(Some(&cache), "", "fresh-track", &file).await; assert_eq!(bytes.as_deref(), Some(b"some bytes".as_slice())); } @@ -443,7 +446,7 @@ mod tests { let file = dir.path().join("track.mp3"); std::fs::write(&file, b"would-be-decoded bytes").unwrap(); - let bytes = read_seed_bytes_if_needed(Some(&cache), "redundant-track", &file).await; + let bytes = read_seed_bytes_if_needed(Some(&cache), "", "redundant-track", &file).await; assert!( bytes.is_none(), "redundant-cache short-circuit must skip the file read entirely" @@ -454,7 +457,7 @@ mod tests { async fn read_seed_bytes_returns_none_for_missing_file() { let dir = tempfile::tempdir().unwrap(); let phantom = dir.path().join("never-written.mp3"); - let bytes = read_seed_bytes_if_needed(None, "any", &phantom).await; + let bytes = read_seed_bytes_if_needed(None, "", "any", &phantom).await; assert!(bytes.is_none()); } @@ -463,7 +466,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("empty.flac"); std::fs::write(&file, b"").unwrap(); - let bytes = read_seed_bytes_if_needed(None, "any", &file).await; + let bytes = read_seed_bytes_if_needed(None, "", "any", &file).await; assert!(bytes.is_none(), "empty file must not trigger seeding"); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0b62e56a..e92746e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -103,6 +103,97 @@ pub fn run() { app.manage(cache); } + // ── Library track store (psysonic-library, PR-5a + PR-5b) ───── + // PR-5a brought up the read-only Tauri surface + LibraryRuntime. + // PR-5b adds the mutating commands, sync session map, current-job + // tracker, and the 30-second background scheduler tick task below + // — which sweeps every bound session through + // `BackgroundScheduler::tick` while honouring the runtime's + // `scheduler_cancel` flag. + { + let store = psysonic_library::store::LibraryStore::init(app.handle()) + .map_err(|e| format!("library store init failed: {e}"))?; + let runtime = psysonic_library::LibraryRuntime::new(std::sync::Arc::new(store)); + app.manage(runtime); + + let app_for_sched = app.handle().clone(); + tauri::async_runtime::spawn(async move { + use std::sync::atomic::Ordering; + use std::time::Duration; + use tokio::time::MissedTickBehavior; + + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { + interval.tick().await; + let Some(state) = app_for_sched + .try_state::() + else { + break; + }; + if state.scheduler_cancel.load(Ordering::SeqCst) { + break; + } + let sessions = state.snapshot_sessions(); + if sessions.is_empty() { + continue; + } + let hint = state.current_playback_hint(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0); + for session in sessions { + let scope = session.library_scope.clone().unwrap_or_default(); + let flags_bits = psysonic_library::repos::SyncStateRepository::new( + &state.store, + ) + .get_capability_flags(&session.server_id, &scope) + .ok() + .flatten() + .unwrap_or(0); + let flags = psysonic_library::sync::capability::CapabilityFlags::new( + flags_bits, + ); + let subsonic = psysonic_integration::subsonic::SubsonicClient::new( + session.base_url.clone(), + session.username.clone(), + session.password.clone(), + ); + let mut sched = + psysonic_library::sync::scheduler::BackgroundScheduler::new( + &state.store, + &subsonic, + session.server_id.clone(), + scope.clone(), + flags, + ) + .with_playback_hint(hint); + if let Some(tok) = session.navidrome_token.clone() { + sched = sched.with_navidrome_credentials( + psysonic_library::sync::capability::NavidromeProbeCredentials { + server_url: session.base_url.clone(), + bearer_token: tok, + }, + ); + } + let foreground_job = state + .current_job() + .is_some_and(|j| j.server_id == session.server_id); + if foreground_job { + sched = sched.with_foreground_sync_job_active(true); + } + let _ = sched.tick(now_ms).await; + // Background ticks stay silent in PR-5b — Tauri + // emit for the scheduler path lands when the + // Settings panel needs it (PR-5c). Manual + // `library_sync_start` already emits via its + // own orchestrator. + } + } + }); + } + audio::cleanup_orphan_stream_spill_dir(app.handle()); // ── Playback-query port (analysis → audio back-edge) ────────── @@ -127,6 +218,62 @@ pub fn run() { app.manage(handle); } + // ── Content-hash sink (analysis → library E2 back-edge) ─────── + // After a seed the analysis pipeline records the playback-derived + // md5_16kb as `track.content_hash` so id-remap can rebind a track + // when the server reassigns ids. Decoupled from psysonic-library + // via a psysonic-core port; a no-op when the library has no row for + // the (server_id, track_id) — i.e. the index is off for that server. + { + let app_for_hash = app.handle().clone(); + let sink = psysonic_core::ports::ContentHashSink::new( + move |server_id: &str, track_id: &str, md5: &str| { + if let Some(runtime) = + app_for_hash.try_state::() + { + let _ = psysonic_library::commands::patch_content_hash( + &runtime, server_id, track_id, md5, + ); + } + }, + ); + app.manage(sink); + } + + // ── Analysis-readiness query (library → analysis E3 back-edge) ── + // `library_get_track` enrichment asks whether waveform/loudness are + // cached for (server_id, track_id, content_hash). Read-only probe: + // exact key then legacy '' fallback, no re-tag. Decoupled from + // psysonic-analysis via a psysonic-core port. + { + let app_for_readiness = app.handle().clone(); + let query = psysonic_core::ports::AnalysisReadinessQuery::new( + move |server_id: &str, track_id: &str, md5: &str| { + let Some(cache) = app_for_readiness + .try_state::() + else { + return (false, false); + }; + let probe = |sid: &str| { + let key = analysis_cache::TrackKey { + server_id: sid.to_string(), + track_id: track_id.to_string(), + md5_16kb: md5.to_string(), + }; + let wf = cache.get_waveform(&key).ok().flatten().is_some(); + let ld = cache.loudness_row_exists_for_key(&key).unwrap_or(false); + (wf, ld) + }; + let (wf, ld) = probe(server_id); + // Legacy '' fallback for rows analysed before E1 wiring. + let wf = wf || (!server_id.is_empty() && probe("").0); + let ld = ld || (!server_id.is_empty() && probe("").1); + (wf, ld) + }, + ); + app.manage(query); + } + // Periodic analysis queue sizes (debug logging mode only). tauri::async_runtime::spawn(psysonic_analysis::analysis_runtime::analysis_queue_snapshot_loop()); @@ -426,6 +573,29 @@ pub fn run() { psysonic_analysis::commands::analysis_delete_all_waveforms, psysonic_analysis::commands::analysis_enqueue_seed_from_url, psysonic_analysis::commands::analysis_prune_pending_to_track_ids, + psysonic_library::commands::library_get_status, + psysonic_library::commands::library_search, + psysonic_library::commands::library_live_search, + psysonic_library::commands::library_advanced_search, + psysonic_library::commands::library_search_cross_server, + psysonic_library::commands::library_get_track, + psysonic_library::commands::library_get_tracks_batch, + psysonic_library::commands::library_get_tracks_by_album, + psysonic_library::commands::library_get_artifact, + psysonic_library::commands::library_get_facts, + psysonic_library::commands::library_get_offline_path, + psysonic_library::commands::library_sync_bind_session, + psysonic_library::commands::library_sync_clear_session, + psysonic_library::commands::library_set_playback_hint, + psysonic_library::commands::library_get_playback_hint, + psysonic_library::commands::library_sync_start, + psysonic_library::commands::library_sync_verify_integrity, + psysonic_library::commands::library_sync_cancel, + psysonic_library::commands::library_patch_track, + psysonic_library::commands::library_put_artifact, + psysonic_library::commands::library_put_fact, + psysonic_library::commands::library_purge_server, + psysonic_library::commands::library_delete_server_data, psysonic_syncfs::cache::offline::download_track_offline, psysonic_syncfs::cache::offline::cancel_offline_downloads, psysonic_syncfs::cache::offline::clear_offline_cancel, diff --git a/src/api/library.ts b/src/api/library.ts new file mode 100644 index 00000000..f0b79bf8 --- /dev/null +++ b/src/api/library.ts @@ -0,0 +1,520 @@ +/** + * Typed wrappers around the `library_*` Tauri commands (spec §7.1) plus + * subscribers for `library:sync-progress` / `library:sync-idle` events + * (§7.2). One thin file per cucadmuh's PR-5 kickoff Q1 — Settings UI + * (LibraryTab) imports from here; nothing else in the app talks to the + * backend library surface directly. + */ + +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +// ── DTO mirrors (camelCase, matching the Rust `#[serde(rename_all = "camelCase")]`) ─ + +export interface TrackRefDto { + serverId: string; + trackId: string; + contentHash?: string | null; +} + +/** E3 readiness summary — present only on single-track `libraryGetTrack` reads. */ +export interface TrackEnrichmentDto { + waveformReady: boolean; + loudnessReady: boolean; + lyricsCached: boolean; +} + +export interface LibraryTrackDto { + serverId: string; + id: string; + contentHash?: string | null; + title: string; + titleSort?: string | null; + artist?: string | null; + artistId?: string | null; + album: string; + albumId?: string | null; + albumArtist?: string | null; + durationSec: number; + trackNumber?: number | null; + discNumber?: number | null; + year?: number | null; + genre?: string | null; + suffix?: string | null; + bitRate?: number | null; + sizeBytes?: number | null; + coverArtId?: string | null; + starredAt?: number | null; + userRating?: number | null; + playCount?: number | null; + playedAt?: number | null; + serverPath?: string | null; + libraryId?: string | null; + isrc?: string | null; + mbidRecording?: string | null; + bpm?: number | null; + replayGainTrackDb?: number | null; + replayGainAlbumDb?: number | null; + serverUpdatedAt?: number | null; + serverCreatedAt?: number | null; + syncedAt: number; + /** E3: populated only by `libraryGetTrack` (omitted on list/batch reads). */ + enrichment?: TrackEnrichmentDto | null; + rawJson: unknown; +} + +export interface SyncStateDto { + serverId: string; + libraryScope: string; + syncPhase: string; + capabilityFlags: number; + libraryTier: string; + lastFullSyncAt?: number | null; + lastDeltaSyncAt?: number | null; + nextPollAt?: number | null; + serverLastScanIso?: string | null; + indexesLastModifiedMs?: number | null; + artistsLastModifiedMs?: number | null; + localTrackCount?: number | null; + serverTrackCount?: number | null; + lastError?: string | null; + localTracksMaxUpdatedMs?: number | null; + /** True when at least one non-deleted track exists locally (cheap EXISTS). */ + hasLocalTracks?: boolean; + ingestStrategy?: string | null; + ingestPhase?: string | null; + /** Tracks ingested per persisted initial-sync cursor (IS-3 progress). */ + cursorIngestedCount?: number | null; + n1BulkUnreliable?: boolean | null; +} + +export interface LibraryTracksEnvelope { + tracks: LibraryTrackDto[]; + total: number; +} + +export interface TrackArtifactDto { + serverId: string; + trackId: string; + artifactKind: string; + format: string; + sourceKind: string; + sourceId: string; + language?: string | null; + contentText?: string | null; + contentBytes: number; + notFound: boolean; + contentHash?: string | null; + fetchedAt: number; + expiresAt?: number | null; +} + +export interface ArtifactInputDto { + artifactKind: string; + format: string; + sourceKind: string; + sourceId: string; + language?: string | null; + contentText?: string | null; + contentBlob?: number[] | null; + contentBytes?: number; + notFound?: boolean; + contentHash?: string | null; + expiresAt?: number | null; +} + +export interface TrackFactDto { + serverId: string; + trackId: string; + factKind: string; + valueReal?: number | null; + valueInt?: number | null; + valueText?: string | null; + unit?: string | null; + sourceKind: string; + sourceId: string; + confidence: number; + contentHash?: string | null; + fetchedAt: number; + expiresAt?: number | null; +} + +export interface FactInputDto { + factKind: string; + valueReal?: number | null; + valueInt?: number | null; + valueText?: string | null; + unit?: string | null; + sourceKind: string; + sourceId: string; + confidence?: number; + contentHash?: string | null; + expiresAt?: number | null; +} + +export interface OfflinePathDto { + serverId: string; + trackId: string; + localPath?: string | null; + missing: boolean; +} + +export interface PurgeReportDto { + tracksDeleted: number; + albumsDeleted: number; + artistsDeleted: number; + offlineRowsDeleted: number; + bytesFreed: number; +} + +export interface SyncJobDto { + jobId: string; + serverId: string; + kind: string; // 'initial_sync' | 'delta_sync' +} + +// ── Advanced Search (PR-5d, §5.13 / §5.5B) ──────────────────────────── + +export type LibraryEntityType = 'artist' | 'album' | 'track'; + +/** v1 operator set the Rust `FilterFieldRegistry` accepts (§5.13.2). */ +export type FilterOperator = 'eq' | 'gte' | 'lte' | 'between' | 'fts' | 'is_true' | 'in'; + +export type SortDir = 'asc' | 'desc'; + +export interface LibraryFilterClause { + field: string; // registry id, e.g. 'genre' | 'year' | 'bpm' + op: FilterOperator; + value?: string | number | boolean | null; + valueTo?: number | null; // between: inclusive upper bound +} + +export interface LibrarySortClause { + field: string; + dir: SortDir; +} + +export interface LibraryAdvancedSearchRequest { + serverId: string; + libraryScope?: string | null; + query?: string | null; // shorthand → fts clause on text fields + entityTypes: LibraryEntityType[]; + filters?: LibraryFilterClause[]; + starredOnly?: boolean | null; + sort?: LibrarySortClause[]; + limit: number; + offset?: number; + /** Skip expensive COUNT queries (Live Search). */ + skipTotals?: boolean; +} + +export interface LibraryAlbumDto { + serverId: string; + id: string; + name: string; + artist?: string | null; + artistId?: string | null; + songCount?: number | null; + durationSec?: number | null; + year?: number | null; + genre?: string | null; + coverArtId?: string | null; + starredAt?: number | null; + syncedAt: number; + rawJson: unknown; +} + +export interface LibraryArtistDto { + serverId: string; + id: string; + name: string; + albumCount?: number | null; + syncedAt: number; + rawJson: unknown; +} + +export interface LibrarySearchTotals { + artists: number; + albums: number; + tracks: number; +} + +export interface LibraryAdvancedSearchResponse { + artists: LibraryArtistDto[]; + albums: LibraryAlbumDto[]; + tracks: LibraryTrackDto[]; + totals: LibrarySearchTotals; + /** Registry field ids actually applied — UI chips / debug. */ + appliedFilters: string[]; + source: 'local' | 'network' | 'mixed'; +} + +export interface LibraryCrossServerSearchResponse { + hits: LibraryTrackDto[]; + /** Fuzzy `title LIKE` matches the exact FTS pass missed (§5.9 / H3). */ + fuzzy: LibraryTrackDto[]; + serversSearched: string[]; +} + +// ── Read commands (PR-5a) ───────────────────────────────────────────── + +export function libraryGetStatus( + serverId: string, + libraryScope?: string, +): Promise { + return invoke('library_get_status', { serverId, libraryScope }); +} + +export function librarySearch( + serverId: string, + query: string, + options?: { limit?: number; offset?: number; libraryScope?: string }, +): Promise { + return invoke('library_search', { + serverId, + query, + limit: options?.limit, + offset: options?.offset, + libraryScope: options?.libraryScope, + }); +} + +/** + * Advanced Search against the local index (§5.13). The frontend fallback + * (PR-7 F2) decides local vs network and maps the same `LibraryFilterClause` + * shape onto the network path; this wrapper only talks to the local builder. + */ +export function libraryAdvancedSearch( + request: LibraryAdvancedSearchRequest, +): Promise { + return invoke('library_advanced_search', { request }); +} + +export interface LibraryLiveSearchResponse { + artists: LibraryArtistDto[]; + albums: LibraryAlbumDto[]; + tracks: LibraryTrackDto[]; + source: 'local' | 'network' | 'mixed'; +} + +/** Live Search dropdown — one lean FTS query (§5.9), not Advanced Search. */ +export interface LibraryLiveSearchRequest { + serverId: string; + query: string; + /** Subsonic `musicFolderId` / Navidrome library id — omit for all libraries. */ + libraryScope?: string | null; + artistLimit?: number; + albumLimit?: number; + songLimit?: number; + /** UI generation — stale Rust FTS passes are dropped server-side. */ + requestEpoch?: number; +} + +export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise { + return invoke('library_live_search', { request }); +} + +/** Cross-server FTS union over the given servers, or all `ready` ones (§5.5B). */ +export function librarySearchCrossServer(args: { + query: string; + limit?: number; + servers?: string[]; +}): Promise { + return invoke('library_search_cross_server', args); +} + +export function libraryGetTrack( + serverId: string, + trackId: string, +): Promise { + return invoke('library_get_track', { serverId, trackId }); +} + +export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise { + return invoke('library_get_tracks_batch', { refs }); +} + +export function libraryGetTracksByAlbum( + serverId: string, + albumId: string, +): Promise { + return invoke('library_get_tracks_by_album', { serverId, albumId }); +} + +export function libraryGetArtifact( + serverId: string, + trackId: string, + artifactKind: string, + options?: { sourceKind?: string; sourceId?: string; format?: string }, +): Promise { + return invoke('library_get_artifact', { + serverId, + trackId, + artifactKind, + sourceKind: options?.sourceKind, + sourceId: options?.sourceId, + format: options?.format, + }); +} + +export function libraryGetFacts( + serverId: string, + trackId: string, + factKinds?: string[], +): Promise { + return invoke('library_get_facts', { serverId, trackId, factKinds }); +} + +export function libraryGetOfflinePath( + serverId: string, + trackId: string, +): Promise { + return invoke('library_get_offline_path', { serverId, trackId }); +} + +// ── Session + lifecycle (PR-5b) ─────────────────────────────────────── + +export function librarySyncBindSession(args: { + serverId: string; + baseUrl: string; + username: string; + password: string; + libraryScope?: string; +}): Promise { + return invoke('library_sync_bind_session', args); +} + +export function librarySyncClearSession(serverId: string): Promise { + return invoke('library_sync_clear_session', { serverId }); +} + +export type PlaybackHint = 'idle' | 'playing' | 'prefetch_active'; + +export function libraryGetPlaybackHint(): Promise { + return invoke('library_get_playback_hint'); +} + +export function librarySetPlaybackHint(hint: PlaybackHint): Promise { + return invoke('library_set_playback_hint', { hint }); +} + +export type SyncMode = 'full' | 'delta'; + +export function librarySyncStart(args: { + serverId: string; + mode: SyncMode; + libraryScope?: string; +}): Promise { + return invoke('library_sync_start', args); +} + +/** Forced full-budget tombstone delta — Settings → «Verify integrity». */ +export function librarySyncVerifyIntegrity(args: { + serverId: string; + libraryScope?: string; +}): Promise { + return invoke('library_sync_verify_integrity', args); +} + +export function librarySyncCancel(jobId?: string): Promise { + return invoke('library_sync_cancel', { jobId }); +} + +export function libraryPatchTrack(args: { + serverId: string; + trackId: string; + patch: { + starredAt?: number | null; + userRating?: number | null; + playCount?: number | null; + playedAt?: number | null; + /** E2: playback-derived `md5_16kb` content fingerprint. Normally written + * by the Rust analysis bridge; exposed here for contract completeness. */ + contentHash?: string | null; + }; +}): Promise { + return invoke('library_patch_track', args); +} + +export function libraryPutArtifact(args: { + serverId: string; + trackId: string; + artifact: ArtifactInputDto; +}): Promise { + return invoke('library_put_artifact', args); +} + +export function libraryPutFact(args: { + serverId: string; + trackId: string; + fact: FactInputDto; +}): Promise { + return invoke('library_put_fact', args); +} + +export function libraryPurgeServer(args: { + serverId: string; + includeAnalysis?: boolean; + includeOffline?: boolean; +}): Promise { + return invoke('library_purge_server', args); +} + +export function libraryDeleteServerData(serverId: string): Promise { + return invoke('library_delete_server_data', { serverId }); +} + +// ── Event subscriptions ─────────────────────────────────────────────── + +export interface LibrarySyncProgressPayload { + serverId: string; + libraryScope: string; + /** 'phase_changed' | 'ingest_page' | 'remapped' | 'tombstoned' | 'completed' | 'error' */ + kind: string; + phase?: string | null; + ingestedTotal?: number | null; + batchCount?: number | null; + remappedCount?: number | null; + tombstonesChecked?: number | null; + tombstonesDeleted?: number | null; + completedKind?: string | null; + message?: string | null; + /** S1 per-batch timings from the Rust ingest runner (when available). */ + ingestMetrics?: IngestBatchMetrics | null; +} + +export interface IngestBatchMetrics { + offset: number; + strategy: string; + fetchMs: number; + writeMs: number; + lockWaitMs: number; + sqlExecMs: number; + persistMs: number; + rowCount: number; + bulkIngestActive: boolean; +} + +export interface LibrarySyncIdlePayload { + serverId: string; + libraryScope: string; + kind: string; // 'initial_sync' | 'delta_sync' + ok: boolean; + error?: string | null; +} + +export function subscribeLibrarySyncProgress( + handler: (payload: LibrarySyncProgressPayload) => void, +): Promise { + return listen('library:sync-progress', ({ payload }) => + handler(payload), + ); +} + +export function subscribeLibrarySyncIdle( + handler: (payload: LibrarySyncIdlePayload) => void, +): Promise { + return listen('library:sync-idle', ({ payload }) => + handler(payload), + ); +} diff --git a/src/api/subsonic.contract.test.ts b/src/api/subsonic.contract.test.ts index 2857839c..065d170c 100644 --- a/src/api/subsonic.contract.test.ts +++ b/src/api/subsonic.contract.test.ts @@ -20,7 +20,7 @@ import { } from './subsonicStreamUrl'; import { beforeEach, describe, expect, it } from 'vitest'; import { parseSubsonicEntityStarRating } from './subsonicRatings'; -import { getClient, libraryFilterParams } from './subsonicClient'; +import { getClient, libraryFilterParams, libraryScopeForServer } from './subsonicClient'; import { useAuthStore } from '@/store/authStore'; import { resetAuthStore } from '@/test/helpers/storeReset'; @@ -78,6 +78,25 @@ describe('libraryFilterParams', () => { }); }); +describe('libraryScopeForServer', () => { + it('returns undefined for all or unset filters', () => { + const serverId = setUpServer(); + expect(libraryScopeForServer(serverId)).toBeUndefined(); + useAuthStore.setState({ + musicLibraryFilterByServer: { [serverId]: 'all' }, + }); + expect(libraryScopeForServer(serverId)).toBeUndefined(); + }); + + it('returns the folder id when scoped', () => { + const serverId = setUpServer(); + useAuthStore.setState({ + musicLibraryFilterByServer: { [serverId]: 'mf-7' }, + }); + expect(libraryScopeForServer(serverId)).toBe('mf-7'); + }); +}); + describe('getClient', () => { it('throws when no server is configured', () => { expect(() => getClient()).toThrow(/no server configured/i); diff --git a/src/api/subsonicClient.ts b/src/api/subsonicClient.ts index 69707ffd..cf27d9ae 100644 --- a/src/api/subsonicClient.ts +++ b/src/api/subsonicClient.ts @@ -68,12 +68,18 @@ export async function apiForServer( return apiWithCredentials(server.url, server.username, server.password, endpoint, extra, timeout); } -export async function api(endpoint: string, extra: Record = {}, timeout = 15000): Promise { +export async function api( + endpoint: string, + extra: Record = {}, + timeout = 15000, + signal?: AbortSignal, +): Promise { const { baseUrl, params } = getClient(); const resp = await axios.get(`${baseUrl}/${endpoint}`, { params: { ...params, ...extra }, paramsSerializer: { indexes: null }, timeout, + signal, }); const data = resp.data?.['subsonic-response']; if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)'); @@ -87,9 +93,16 @@ export function libraryFilterParams(): Record { return activeServerId ? libraryFilterParamsForServer(activeServerId) : {}; } +/** Navidrome/Subsonic music folder id for the local library index, or undefined for all libraries. */ +export function libraryScopeForServer(serverId: string): string | undefined { + const f = useAuthStore.getState().musicLibraryFilterByServer[serverId]; + if (f === undefined || f === 'all') return undefined; + return f; +} + /** Library folder filter for an explicit saved server (e.g. Now Playing while browsing another). */ export function libraryFilterParamsForServer(serverId: string): Record { - const f = useAuthStore.getState().musicLibraryFilterByServer[serverId]; - if (f === undefined || f === 'all') return {}; - return { musicFolderId: f }; + const scope = libraryScopeForServer(serverId); + if (!scope) return {}; + return { musicFolderId: scope }; } diff --git a/src/api/subsonicScrobble.ts b/src/api/subsonicScrobble.ts index 830bc2df..27077337 100644 --- a/src/api/subsonicScrobble.ts +++ b/src/api/subsonicScrobble.ts @@ -1,5 +1,6 @@ import { api, apiForServer } from './subsonicClient'; import type { SubsonicNowPlaying } from './subsonicTypes'; +import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse'; async function scrobbleOnServer( serverId: string, @@ -16,6 +17,10 @@ export async function scrobbleSong(id: string, time: number, serverId: string): if (!serverId) return; try { await scrobbleOnServer(serverId, id, true, time); + // Patch-on-use (§6.5 / F3): reflect the play in the local index so the + // "recently played" surfaces aren't stale. `play_count` is left to the next + // sync (the patch sets absolute values; a correct increment needs the base). + patchLibraryTrackOnUse(serverId, id, { playedAt: time }); } catch { // best effort } diff --git a/src/api/subsonicSearch.ts b/src/api/subsonicSearch.ts index 39ffe339..641e9cf7 100644 --- a/src/api/subsonicSearch.ts +++ b/src/api/subsonicSearch.ts @@ -15,7 +15,15 @@ export function filterSearchArtistsWithNoAlbums(artists: SubsonicArtist[]): Subs return artists.filter((a) => a.albumCount !== 0); } -export async function search(query: string, options?: { albumCount?: number; artistCount?: number; songCount?: number }): Promise { +export async function search( + query: string, + options?: { + albumCount?: number; + artistCount?: number; + songCount?: number; + signal?: AbortSignal; + }, +): Promise { if (!query.trim()) return { artists: [], albums: [], songs: [] }; const data = await api<{ searchResult3: { @@ -23,13 +31,18 @@ export async function search(query: string, options?: { albumCount?: number; art album?: SubsonicAlbum[]; song?: SubsonicSong[]; }; - }>('search3.view', { - query, - artistCount: options?.artistCount ?? 5, - albumCount: options?.albumCount ?? 5, - songCount: options?.songCount ?? 10, - ...libraryFilterParams(), - }); + }>( + 'search3.view', + { + query, + artistCount: options?.artistCount ?? 5, + albumCount: options?.albumCount ?? 5, + songCount: options?.songCount ?? 10, + ...libraryFilterParams(), + }, + 15000, + options?.signal, + ); const r = data.searchResult3 ?? {}; return { artists: filterSearchArtistsWithNoAlbums(r.artist ?? []), diff --git a/src/api/subsonicStarRating.ts b/src/api/subsonicStarRating.ts index 2ce82537..eb5aa39b 100644 --- a/src/api/subsonicStarRating.ts +++ b/src/api/subsonicStarRating.ts @@ -1,5 +1,7 @@ import { api, libraryFilterParams } from './subsonicClient'; import { invalidateEntityUserRatingCaches } from './subsonicRatings'; +import { useAuthStore } from '../store/authStore'; +import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse'; import type { EntityRatingSupportLevel, StarredResults, @@ -26,6 +28,9 @@ export async function star(id: string, type: 'song' | 'album' | 'artist' = 'albu if (type === 'album') params.albumId = id; if (type === 'artist') params.artistId = id; await api('star.view', params); + if (type === 'song') { + patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { starredAt: Date.now() }); + } } export async function unstar(id: string, type: 'song' | 'album' | 'artist' = 'album'): Promise { @@ -34,10 +39,15 @@ export async function unstar(id: string, type: 'song' | 'album' | 'artist' = 'al if (type === 'album') params.albumId = id; if (type === 'artist') params.artistId = id; await api('unstar.view', params); + if (type === 'song') { + patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { starredAt: null }); + } } export async function setRating(id: string, rating: number): Promise { await api('setRating.view', { id, rating }); + // No-op in Rust when `id` is an album/artist (no track row matches). + patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { userRating: rating }); // Cached song lists keyed by rating (e.g. Tracks → Highly Rated rail) become // stale immediately. `invalidateEntityUserRatingCaches` is static-imported: // mix paths already pull `subsonicRatings` (e.g. mixRatingFilter), so a diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 57987d68..0f4da500 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -10,10 +10,13 @@ import ExportPickerModal from '../components/ExportPickerModal'; import ZipDownloadOverlay from '../components/ZipDownloadOverlay'; import FpsOverlay from '../components/FpsOverlay'; import { useAuthStore } from '../store/authStore'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useGlobalShortcutsStore } from '../store/globalShortcutsStore'; import { initHotCachePrefetch } from '../hotCachePrefetch'; import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge'; import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration'; +import { bootstrapAllIndexedServers } from '../utils/library/librarySession'; +import { hydrateQueueFromIndex } from '../utils/library/queueRestore'; import { IS_WINDOWS } from '../utils/platform'; import TauriEventBridge from './TauriEventBridge'; import AppShell from './AppShell'; @@ -34,6 +37,21 @@ export default function MainApp() { // Advanced Mode toggle. Idempotent — flagged in localStorage. useEffect(() => { runAdvancedModeMigration(); }, []); + // Re-bind the library sync session whenever the active server changes + // (covers app startup + server switch). The session is Rust + // process-memory only while the per-server index toggle persists, so + // without this the background scheduler + Sync now report + // "no bound session" after a restart. + const activeServerId = useAuthStore(s => s.activeServerId); + const serverIdsKey = useAuthStore(s => s.servers.map(srv => srv.id).join(',')); + const masterEnabled = useLibraryIndexStore(s => s.masterEnabled); + useEffect(() => { + void (async () => { + await bootstrapAllIndexedServers(); + void hydrateQueueFromIndex(); + })(); + }, [activeServerId, serverIdsKey, masterEnabled]); + // Push playback state to mini window + handle control events. useEffect(() => { return initMiniPlayerBridgeOnMain(); diff --git a/src/app/TauriEventBridge.tsx b/src/app/TauriEventBridge.tsx index ae6adb5a..65307c98 100644 --- a/src/app/TauriEventBridge.tsx +++ b/src/app/TauriEventBridge.tsx @@ -7,6 +7,7 @@ import { useTrayIconSync } from '../hooks/tauriBridge/useTrayIconSync'; import { useInAppKeybindings } from '../hooks/tauriBridge/useInAppKeybindings'; import { useMediaAndWindowBridge } from '../hooks/tauriBridge/useMediaAndWindowBridge'; import { usePlayerSnapshotPublisher } from '../hooks/tauriBridge/usePlayerSnapshotPublisher'; +import { useLibraryDevSyncLog } from '../hooks/tauriBridge/useLibraryDevSyncLog'; /** * Single mount point for everything that bridges Rust ↔ React in the main @@ -33,6 +34,7 @@ export function TauriEventBridge() { useInAppKeybindings(navigate); useMediaAndWindowBridge(navigate); usePlayerSnapshotPublisher(); + useLibraryDevSyncLog(); return null; } diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index d7b4299b..87ed97d8 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -1,4 +1,4 @@ -import { star, unstar } from '../api/subsonicStarRating'; +import { queueSongStar } from '../store/pendingStarSync'; import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import { playbackCoverArtForId } from '../utils/playback/playbackServer'; import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react'; @@ -34,7 +34,6 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const previous = usePlayerStore(s => s.previous); const stop = usePlayerStore(s => s.stop); const toggleRepeat = usePlayerStore(s => s.toggleRepeat); - const setStarredOverride = usePlayerStore(s => s.setStarredOverride); // Derive isStarred inside the selector so we only re-render when the boolean // actually flips — not when any unrelated track's star status changes. const isStarred = usePlayerStore(s => { @@ -43,17 +42,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred; }); - const toggleStar = useCallback(async () => { + const toggleStar = useCallback(() => { if (!currentTrack) return; - const nextVal = !isStarred; - setStarredOverride(currentTrack.id, nextVal); - try { - if (nextVal) await star(currentTrack.id, 'song'); - else await unstar(currentTrack.id, 'song'); - } catch { - setStarredOverride(currentTrack.id, !nextVal); - } - }, [currentTrack, isStarred, setStarredOverride]); + queueSongStar(currentTrack.id, !isStarred); + }, [currentTrack, isStarred]); const duration = currentTrack?.duration ?? 0; diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index b7f8710e..1a5c7f04 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -1,25 +1,33 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; -import { search } from '../api/subsonicSearch'; +import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '../api/library'; import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; +import { + LIVE_SEARCH_DEBOUNCE_NETWORK_MS, + LIVE_SEARCH_DEBOUNCE_RACE_MS, + EMPTY_SEARCH_RESULTS, + liveSearchQueryTooShort, + runLocalLiveSearch, + runNetworkLiveSearch, +} from '../utils/library/liveSearchLocal'; +import { raceSearchSources } from '../utils/library/searchRace'; +import { libraryIsReady } from '../utils/library/libraryReady'; +import { + logLibrarySearch, +} from '../utils/library/libraryDevLog'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Search, Disc3, Users, Music, TextSearch } from 'lucide-react'; +import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useTranslation } from 'react-i18next'; import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage'; import { showToast } from '../utils/ui/toast'; import { useShareSearch } from '../hooks/useShareSearch'; import ShareSearchResults from './search/ShareSearchResults'; -function debounce(fn: (q: string) => void, ms: number): (q: string) => void { - let timer: ReturnType; - return (q: string) => { - clearTimeout(timer); - timer = setTimeout(() => fn(q), ms); - }; -} +type LiveSearchSource = 'local' | 'network'; function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) { const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]); @@ -56,6 +64,9 @@ export default function LiveSearch() { const [activeIndex, setActiveIndex] = useState(-1); const [isFocused, setIsFocused] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); + const [searchSource, setSearchSource] = useState(null); + const localReadyRef = useRef(false); + const liveSearchGenRef = useRef(0); const navigate = useNavigate(); const enqueue = usePlayerStore(state => state.enqueue); const openContextMenu = usePlayerStore(state => state.openContextMenu); @@ -67,41 +78,174 @@ export default function LiveSearch() { const inputRef = useRef(null); const collapsedRef = useRef(false); const compactHeaderControlsRef = useRef(false); + const serverId = useAuthStore(s => s.activeServerId); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); + + const refreshLocalReady = useCallback(async () => { + if (!serverId || !indexEnabled) { + localReadyRef.current = false; + return; + } + localReadyRef.current = await libraryIsReady(serverId); + }, [serverId, indexEnabled]); + + useEffect(() => { + void refreshLocalReady(); + }, [refreshLocalReady, musicLibraryFilterVersion]); + + useEffect(() => { + if (!indexEnabled || !serverId) return; + let unlistenProgress: (() => void) | undefined; + let unlistenIdle: (() => void) | undefined; + void subscribeLibrarySyncIdle(payload => { + if (payload.serverId === serverId) void refreshLocalReady(); + }).then(fn => { + unlistenIdle = fn; + }); + void subscribeLibrarySyncProgress(p => { + if (p.serverId === serverId && p.kind === 'phase_changed') void refreshLocalReady(); + }).then(fn => { + unlistenProgress = fn; + }); + return () => { + unlistenIdle?.(); + unlistenProgress?.(); + }; + }, [indexEnabled, serverId, refreshLocalReady]); const closeSearch = useCallback(() => { setOpen(false); setQuery(''); + setSearchSource(null); }, []); const share = useShareSearch(query, closeSearch); - const doSearch = useCallback( - debounce(async (q: string) => { - if (!q.trim()) { setResults(null); setOpen(false); return; } - setLoading(true); - try { - const r = await search(q); - setResults(r); - setOpen(true); - } finally { - setLoading(false); - } - }, 300), - [musicLibraryFilterVersion] - ); - useEffect(() => { if (share.shareMatch) { setResults(null); setLoading(false); + setSearchSource(null); setOpen(true); setActiveIndex(-1); return; } - doSearch(query); + + const q = query.trim(); + if (!q) { + setResults(null); + setOpen(false); + setSearchSource(null); + setLoading(false); + return; + } + + setSearchSource(null); setActiveIndex(-1); - }, [query, doSearch, share.shareMatch]); + + const abort = new AbortController(); + const debounceMs = indexEnabled ? LIVE_SEARCH_DEBOUNCE_RACE_MS : LIVE_SEARCH_DEBOUNCE_NETWORK_MS; + + const timer = window.setTimeout(() => { + void (async () => { + const gen = liveSearchGenRef.current; + const isStale = () => + gen !== liveSearchGenRef.current || abort.signal.aborted; + + if (isStale()) return; + + setLoading(true); + const searchT0 = performance.now(); + try { + if (liveSearchQueryTooShort(q)) { + if (!isStale()) { + setResults(EMPTY_SEARCH_RESULTS); + setSearchSource('local'); + setOpen(true); + } + return; + } + + const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId }; + + if (indexEnabled && serverId) { + const winner = await raceSearchSources( + [ + { + source: 'local', + run: () => runLocalLiveSearch(serverId, q, raceCtx), + }, + { + source: 'network', + run: () => runNetworkLiveSearch(q, abort.signal), + }, + ], + isStale, + ); + if (isStale()) return; + if (winner) { + setResults(winner.result); + setSearchSource(winner.source); + setOpen(true); + logLibrarySearch({ + at: new Date().toISOString(), + query: q, + path: 'search_race', + durationMs: Math.round(performance.now() - searchT0), + debounceMs, + indexEnabled, + localReadyCached: localReadyRef.current, + raceWinner: winner.source, + raceWinnerMs: winner.durationMs, + counts: { + artists: winner.result.artists.length, + albums: winner.result.albums.length, + songs: winner.result.songs.length, + }, + }); + return; + } + showToast(t('search.liveSearchFailed'), 3200, 'error'); + } else if (serverId) { + const network = await runNetworkLiveSearch(q, abort.signal); + if (isStale()) return; + if (network) { + setResults(network); + setSearchSource('network'); + setOpen(true); + logLibrarySearch({ + at: new Date().toISOString(), + query: q, + path: 'search3', + durationMs: Math.round(performance.now() - searchT0), + debounceMs, + indexEnabled, + counts: { + artists: network.artists.length, + albums: network.albums.length, + songs: network.songs.length, + }, + }); + } + } + } catch (err) { + if (isStale()) return; + const name = err instanceof Error ? err.name : ''; + if (name === 'CanceledError' || name === 'AbortError') return; + showToast(t('search.liveSearchFailed'), 3200, 'error'); + } finally { + if (!isStale()) setLoading(false); + } + })(); + }, debounceMs); + + return () => { + window.clearTimeout(timer); + abort.abort(); + liveSearchGenRef.current += 1; + }; + }, [query, share.shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t]); const isSearchActive = isFocused || open || query.trim().length > 0; @@ -332,7 +476,16 @@ export default function LiveSearch() { autoComplete="off" /> {query && ( - )} @@ -355,6 +508,31 @@ export default function LiveSearch() { {open && (
+ {searchSource && !share.shareMatch && ( +
+ {searchSource === 'local' ? ( + + ) : ( + + )} + + {t( + searchSource === 'local' + ? 'search.localIndexBadge' + : 'search.networkSearchBadge', + )} + +
+ )} + {!hasResults && !loading && (
{t('search.noResults', { query })}
)} @@ -465,7 +643,11 @@ export default function LiveSearch() { openContextMenu(e.clientX, e.clientY, songToTrack(s), 'song'); }} role="option" aria-selected={activeIndex === i}> -
+ {(s.coverArt ?? s.albumId) ? ( + + ) : ( +
+ )}
{s.title}
{s.artist} · {s.album}
diff --git a/src/components/MobilePlayerView.tsx b/src/components/MobilePlayerView.tsx index db176bc4..c0aa63ac 100644 --- a/src/components/MobilePlayerView.tsx +++ b/src/components/MobilePlayerView.tsx @@ -1,4 +1,4 @@ -import { star, unstar } from '../api/subsonicStarRating'; +import { queueSongStar } from '../store/pendingStarSync'; import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import type { Track } from '../store/playerStoreTypes'; import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress'; @@ -182,7 +182,6 @@ export default function MobilePlayerView() { const toggleRepeat = usePlayerStore(s => s.toggleRepeat); const shuffleQueue = usePlayerStore(s => s.shuffleQueue); const starredOverrides = usePlayerStore(s => s.starredOverrides); - const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const duration = currentTrack?.duration ?? 0; @@ -197,17 +196,10 @@ export default function MobilePlayerView() { ? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred) : false; - const toggleStar = useCallback(async () => { + const toggleStar = useCallback(() => { if (!currentTrack) return; - const nextVal = !isStarred; - setStarredOverride(currentTrack.id, nextVal); - try { - if (nextVal) await star(currentTrack.id, 'song'); - else await unstar(currentTrack.id, 'song'); - } catch { - setStarredOverride(currentTrack.id, !nextVal); - } - }, [currentTrack, isStarred, setStarredOverride]); + queueSongStar(currentTrack.id, !isStarred); + }, [currentTrack, isStarred]); // Scrubber touch/mouse drag const scrubberRef = useRef(null); diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 3ec162c2..2887bd46 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -1,4 +1,4 @@ -import { star, unstar } from '../api/subsonicStarRating'; +import { queueSongStar } from '../store/pendingStarSync'; import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import type { SubsonicAlbum } from '../api/subsonicTypes'; @@ -62,8 +62,8 @@ export default function PlayerBar() { stop, toggleRepeat, repeatMode, toggleFullscreen, lastfmLoved, toggleLastfmLove, isQueueVisible, toggleQueue, - starredOverrides, setStarredOverride, - userRatingOverrides, setUserRatingOverride, + starredOverrides, + userRatingOverrides, openContextMenu, } = usePlayerStore(useShallow(s => ({ currentTrack: s.currentTrack, @@ -83,9 +83,7 @@ export default function PlayerBar() { isQueueVisible: s.isQueueVisible, toggleQueue: s.toggleQueue, starredOverrides: s.starredOverrides, - setStarredOverride: s.setStarredOverride, userRatingOverrides: s.userRatingOverrides, - setUserRatingOverride: s.setUserRatingOverride, openContextMenu: s.openContextMenu, }))); const { lastfmSessionKey } = useAuthStore(); @@ -133,17 +131,10 @@ export default function PlayerBar() { ? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred) : false; - const toggleStar = useCallback(async () => { + const toggleStar = useCallback(() => { if (!currentTrack) return; - const next = !isStarred; - setStarredOverride(currentTrack.id, next); - try { - if (next) await star(currentTrack.id, 'song'); - else await unstar(currentTrack.id, 'song'); - } catch { - setStarredOverride(currentTrack.id, !next); - } - }, [currentTrack, isStarred, setStarredOverride]); + queueSongStar(currentTrack.id, !isStarred); + }, [currentTrack, isStarred]); const duration = currentTrack?.duration ?? 0; @@ -232,7 +223,6 @@ export default function PlayerBar() { lastfmLoved={lastfmLoved} toggleLastfmLove={toggleLastfmLove} userRatingOverrides={userRatingOverrides} - setUserRatingOverride={setUserRatingOverride} toggleFullscreen={toggleFullscreen} navigate={navigatePlaybackLibrary} openContextMenu={openContextMenu} diff --git a/src/components/VirtualSongList.tsx b/src/components/VirtualSongList.tsx index 2f835679..971d4b9b 100644 --- a/src/components/VirtualSongList.tsx +++ b/src/components/VirtualSongList.tsx @@ -7,6 +7,8 @@ import { Search as SearchIcon, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useVirtualizer } from '@tanstack/react-virtual'; import { ndListSongs } from '../api/navidromeBrowse'; +import { runLocalSongBrowse } from '../utils/library/advancedSearchLocal'; +import { useAuthStore } from '../store/authStore'; import SongRow, { SongListHeader } from './SongRow'; const PAGE_SIZE = 50; @@ -15,14 +17,21 @@ const ROW_HEIGHT = 52; const PREFETCH_PX = 600; /** - * Empty query → Navidrome /api/song sorted by title (no Subsonic equivalent). + * Browse-all (empty query): local library index when ready (F1, same title-ASC + * order), else Navidrome /api/song sorted by title, else Subsonic search3. * Non-empty → Subsonic search3 (search isn't a browse). - * Either way, returns a SubsonicSong[]; on Navidrome failure we fall back to search3. + * Either way, returns a SubsonicSong[]. */ async function fetchSongPage(query: string, offset: number): Promise { if (query !== '') { return searchSongsPaged(query, PAGE_SIZE, offset); } + const local = await runLocalSongBrowse( + useAuthStore.getState().activeServerId, + offset, + PAGE_SIZE, + ); + if (local) return local; try { return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC'); } catch { diff --git a/src/components/contextMenu/QueueItemContextItems.tsx b/src/components/contextMenu/QueueItemContextItems.tsx index 4706fed5..4d91ccdd 100644 --- a/src/components/contextMenu/QueueItemContextItems.tsx +++ b/src/components/contextMenu/QueueItemContextItems.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; import { Play, Radio, Heart, ChevronRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, Share2 } from 'lucide-react'; -import { star, unstar } from '../../api/subsonicStarRating'; +import { queueSongStar } from '../../store/pendingStarSync'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm'; import type { Track } from '../../store/playerStoreTypes'; import { useAuthStore } from '../../store/authStore'; @@ -13,7 +13,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) { const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, - starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + starredOverrides, lastfmLovedCache, setLastfmLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, playlistSongIds, setPlaylistSongIds, @@ -71,9 +71,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
)}
handleAction(() => { - const starred = isStarred(song.id, song.starred); - setStarredOverride(song.id, !starred); - return starred ? unstar(song.id, 'song') : star(song.id, 'song'); + queueSongStar(song.id, !isStarred(song.id, song.starred)); })}> {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} diff --git a/src/components/contextMenu/SongContextItems.tsx b/src/components/contextMenu/SongContextItems.tsx index cf5feb63..ec28d32b 100644 --- a/src/components/contextMenu/SongContextItems.tsx +++ b/src/components/contextMenu/SongContextItems.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { getAlbum } from '../../api/subsonicLibrary'; -import { star, unstar } from '../../api/subsonicStarRating'; +import { queueSongStar } from '../../store/pendingStarSync'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm'; import type { Track } from '../../store/playerStoreTypes'; import { useAuthStore } from '../../store/authStore'; @@ -19,7 +19,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) { const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, - starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + starredOverrides, lastfmLovedCache, setLastfmLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, playlistSongIds, setPlaylistSongIds, @@ -119,9 +119,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
)}
handleAction(() => { - const starred = isStarred(song.id, song.starred); - setStarredOverride(song.id, !starred); - return starred ? unstar(song.id, 'song') : star(song.id, 'song'); + queueSongStar(song.id, !isStarred(song.id, song.starred)); })}> {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} @@ -303,8 +301,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
handleAction(() => { - setStarredOverride(song.id, false); - return unstar(song.id, 'song'); + queueSongStar(song.id, false); })}> {t('contextMenu.unfavorite')}
diff --git a/src/components/playerBar/PlayerTrackInfo.tsx b/src/components/playerBar/PlayerTrackInfo.tsx index 7b94e4c0..0ded9168 100644 --- a/src/components/playerBar/PlayerTrackInfo.tsx +++ b/src/components/playerBar/PlayerTrackInfo.tsx @@ -1,6 +1,6 @@ import { Cast, Heart, Maximize2, Music } from 'lucide-react'; import type { TFunction } from 'i18next'; -import { setRating } from '../../api/subsonicStarRating'; +import { queueSongRating } from '../../store/pendingStarSync'; import type { InternetRadioStation, SubsonicAlbum, SubsonicOpenArtistRef } from '../../api/subsonicTypes'; import type { PlayerState, Track } from '../../store/playerStoreTypes'; import type { RadioMetadata } from '../../hooks/useRadioMetadata'; @@ -39,7 +39,6 @@ interface Props { lastfmLoved: boolean; toggleLastfmLove: () => void; userRatingOverrides: Record; - setUserRatingOverride: (id: string, r: number) => void; toggleFullscreen: () => void; navigate: (to: string) => void | Promise; openContextMenu: PlayerState['openContextMenu']; @@ -51,7 +50,7 @@ export function PlayerTrackInfo({ coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist, displayArtistRefs, showPreviewMeta, previewingTrack, isStarred, toggleStar, lastfmSessionKey, lastfmLoved, toggleLastfmLove, - userRatingOverrides, setUserRatingOverride, toggleFullscreen, + userRatingOverrides, toggleFullscreen, navigate, openContextMenu, t, }: Props) { const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering); @@ -159,7 +158,7 @@ export function PlayerTrackInfo({ {currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && ( { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }} + onChange={r => queueSongRating(currentTrack.id, r)} className="player-track-rating" ariaLabel={t('albumDetail.ratingLabel')} /> diff --git a/src/components/settings/LibraryIndexSection.tsx b/src/components/settings/LibraryIndexSection.tsx new file mode 100644 index 00000000..b3d578bb --- /dev/null +++ b/src/components/settings/LibraryIndexSection.tsx @@ -0,0 +1,420 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DatabaseZap } from 'lucide-react'; +import { useAuthStore } from '../../store/authStore'; +import { useLibraryIndexStore } from '../../store/libraryIndexStore'; +import { showToast } from '../../utils/ui/toast'; +import SettingsSubSection from '../SettingsSubSection'; +import { + libraryGetStatus, + librarySyncCancel, + librarySyncClearSession, + subscribeLibrarySyncIdle, + subscribeLibrarySyncProgress, + type SyncStateDto, +} from '../../api/library'; +import { + bootstrapAllIndexedServers, + bootstrapIndexedServer, + type BindServerResult, +} from '../../utils/library/librarySession'; +import { enqueueLibrarySync } from '../../utils/library/librarySyncQueue'; +import { syncIngestDisplayCount } from '../../utils/library/libraryReady'; +import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; +import LibraryIndexServerRow, { type LibraryServerConnection } from './LibraryIndexServerRow'; + +const STATUS_POLL_MS = 3000; +const SYNC_POLL_MS = 2500; +const OFFLINE_RETRY_MS = 60_000; + +export default function LibraryIndexSection() { + const { t } = useTranslation(); + const servers = useAuthStore(s => s.servers); + const activeServerId = useAuthStore(s => s.activeServerId); + + const masterEnabled = useLibraryIndexStore(s => s.masterEnabled); + const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer); + const setMasterEnabled = useLibraryIndexStore(s => s.setMasterEnabled); + const setServerSyncExcluded = useLibraryIndexStore(s => s.setServerSyncExcluded); + const autoReconcile = useLibraryIndexStore(s => s.autoReconcileEnabled); + const setAutoReconcile = useLibraryIndexStore(s => s.setAutoReconcileEnabled); + + const indexedIds = useMemo(() => { + if (!masterEnabled) return []; + return servers.map(s => s.id).filter(id => syncExcludedByServer[id] !== true); + }, [masterEnabled, syncExcludedByServer, servers]); + + const indexedServers = useMemo( + () => servers.filter(s => indexedIds.includes(s.id)), + [servers, indexedIds], + ); + + const excludedServers = useMemo( + () => servers.filter(s => syncExcludedByServer[s.id] === true), + [servers, syncExcludedByServer], + ); + + const [statusByServer, setStatusByServer] = useState>({}); + const [connectionByServer, setConnectionByServer] = useState>({}); + const [progressByServer, setProgressByServer] = useState>({}); + const [busyServerId, setBusyServerId] = useState(null); + const [bootstrapping, setBootstrapping] = useState(false); + + const pollTimer = useRef | null>(null); + const ingestCountRef = useRef>({}); + const syncPhaseRef = useRef>({}); + + const applyConnectionResults = useCallback((results: Record) => { + setConnectionByServer(prev => { + const next = { ...prev }; + for (const [id, result] of Object.entries(results)) { + next[id] = result === 'offline' ? 'offline' : result === 'bound' ? 'online' : 'unknown'; + } + return next; + }); + }, []); + + const refreshAllStatuses = useCallback(async () => { + if (!masterEnabled || indexedServers.length === 0) return; + const entries = await Promise.all( + indexedServers.map(async srv => { + try { + const fresh = await libraryGetStatus(srv.id); + syncPhaseRef.current[srv.id] = fresh.syncPhase; + if (fresh.syncPhase === 'initial_sync') { + const next = Math.max(ingestCountRef.current[srv.id] ?? 0, syncIngestDisplayCount(fresh)); + ingestCountRef.current[srv.id] = next; + setProgressByServer(p => ({ + ...p, + [srv.id]: t('settings.libraryIndexProgressIngest', { count: next }), + })); + } else if (fresh.syncPhase === 'ready' || fresh.syncPhase === 'idle') { + ingestCountRef.current[srv.id] = 0; + } + return [srv.id, fresh] as const; + } catch { + return [srv.id, null] as const; + } + }), + ); + setStatusByServer(Object.fromEntries(entries)); + }, [masterEnabled, indexedServers, t]); + + const runBootstrap = useCallback(async () => { + if (!masterEnabled) return; + setBootstrapping(true); + try { + const results = await bootstrapAllIndexedServers(); + applyConnectionResults(results); + await refreshAllStatuses(); + } finally { + setBootstrapping(false); + } + }, [masterEnabled, applyConnectionResults, refreshAllStatuses]); + + const retryOfflineServers = useCallback(async () => { + if (!masterEnabled) return; + const offline = indexedServers.filter(s => connectionByServer[s.id] === 'offline'); + if (offline.length === 0) return; + const results: Record = {}; + for (const srv of offline) { + results[srv.id] = await bootstrapIndexedServer(srv); + } + applyConnectionResults(results); + void refreshAllStatuses(); + }, [masterEnabled, indexedServers, connectionByServer, applyConnectionResults, refreshAllStatuses]); + + useEffect(() => { + if (!masterEnabled) { + setStatusByServer({}); + setConnectionByServer({}); + setProgressByServer({}); + setBusyServerId(null); + return; + } + void runBootstrap(); + }, [masterEnabled, indexedIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!masterEnabled) return; + const poll = () => { + void refreshAllStatuses(); + const anyInitial = indexedServers.some( + s => syncPhaseRef.current[s.id] === 'initial_sync', + ); + pollTimer.current = setTimeout(poll, anyInitial ? SYNC_POLL_MS : STATUS_POLL_MS); + }; + poll(); + return () => { + if (pollTimer.current) clearTimeout(pollTimer.current); + pollTimer.current = null; + }; + }, [masterEnabled, indexedServers, refreshAllStatuses]); + + useEffect(() => { + if (!masterEnabled) return; + const retryTimer = setInterval(() => { + void retryOfflineServers(); + }, OFFLINE_RETRY_MS); + return () => clearInterval(retryTimer); + }, [masterEnabled, retryOfflineServers]); + + useEffect(() => { + if (!masterEnabled) return; + const unsubs: Array void>> = [ + subscribeLibrarySyncProgress(p => { + if (!indexedIds.includes(p.serverId)) return; + setBusyServerId(p.serverId); + if (p.kind === 'ingest_page') { + const next = Math.max(ingestCountRef.current[p.serverId] ?? 0, p.ingestedTotal ?? 0); + ingestCountRef.current[p.serverId] = next; + setProgressByServer(prev => ({ + ...prev, + [p.serverId]: t('settings.libraryIndexProgressIngest', { count: next }), + })); + } else if (p.kind === 'tombstoned') { + setProgressByServer(prev => ({ + ...prev, + [p.serverId]: t('settings.libraryIndexProgressVerify', { + checked: p.tombstonesChecked ?? 0, + deleted: p.tombstonesDeleted ?? 0, + }), + })); + } else if (p.kind === 'phase_changed' && p.phase) { + setProgressByServer(prev => ({ ...prev, [p.serverId]: p.phase ?? null })); + } + }), + subscribeLibrarySyncIdle(p => { + if (!indexedIds.includes(p.serverId)) return; + setBusyServerId(cur => (cur === p.serverId ? null : cur)); + ingestCountRef.current[p.serverId] = 0; + setProgressByServer(prev => ({ ...prev, [p.serverId]: null })); + void refreshAllStatuses(); + if (!p.ok && p.error) { + showToast(t('settings.libraryIndexSyncError', { error: p.error }), 5000, 'error'); + } + }), + ]; + return () => { + unsubs.forEach(u => void u.then(fn => fn())); + }; + }, [masterEnabled, indexedIds, refreshAllStatuses, t]); + + const handleMasterToggle = async (enabled: boolean) => { + if (enabled) { + setMasterEnabled(true); + await runBootstrap(); + return; + } + setBootstrapping(true); + try { + for (const srv of servers) { + try { + await librarySyncClearSession(srv.id); + } catch { + /* best-effort */ + } + } + setMasterEnabled(false); + setStatusByServer({}); + setConnectionByServer({}); + setProgressByServer({}); + setBusyServerId(null); + } finally { + setBootstrapping(false); + } + }; + + const runServerAction = async ( + serverId: string, + action: 'full' | 'delta' | 'verify', + ) => { + setBusyServerId(serverId); + try { + const kind = + action === 'verify' + ? 'verify' + : action === 'full' + ? 'full' + : statusByServer[serverId]?.lastFullSyncAt + ? 'delta' + : 'full'; + ingestCountRef.current[serverId] = 0; + await enqueueLibrarySync({ serverId, kind }); + } catch (e) { + setBusyServerId(null); + showToast(t('settings.libraryIndexSyncError', { error: String(e) }), 5000, 'error'); + } + }; + + const handleIncludeServer = async (serverId: string) => { + setServerSyncExcluded(serverId, false); + const srv = servers.find(s => s.id === serverId); + if (srv) { + setBootstrapping(true); + try { + const result = await bootstrapIndexedServer(srv); + applyConnectionResults({ [serverId]: result }); + await refreshAllStatuses(); + } finally { + setBootstrapping(false); + } + } + }; + + const handleExcludeServer = async (serverId: string) => { + setBootstrapping(true); + try { + await librarySyncClearSession(serverId); + setServerSyncExcluded(serverId, true); + setStatusByServer(prev => { + const next = { ...prev }; + delete next[serverId]; + return next; + }); + setConnectionByServer(prev => { + const next = { ...prev }; + delete next[serverId]; + return next; + }); + } catch (e) { + showToast(t('settings.libraryIndexBindError', { error: String(e) }), 5000, 'error'); + } finally { + setBootstrapping(false); + } + }; + + const handleCancel = async () => { + try { + await librarySyncCancel(); + } catch { + /* best-effort */ + } + }; + + const globalBusy = bootstrapping || busyServerId != null; + + return ( + } + > +
+

+ {t('settings.libraryIndexDesc')} +

+

+ {t('settings.libraryIndexDeltaHint')} +

+ +
+
+
{t('settings.libraryIndexEnable')}
+
+ {servers.length > 0 + ? t('settings.libraryIndexEnableAllDesc') + : t('settings.libraryIndexNoServer')} +
+
+ +
+ + {masterEnabled && ( + <> +
+
+ {t('settings.libraryIndexServerListTitle')} +
+ {indexedServers.length === 0 ? ( +

+ {t('settings.libraryIndexAllExcluded')} +

+ ) : ( +
+ {indexedServers.map(srv => ( + void runServerAction(srv.id, 'full')} + onDeltaSync={() => void runServerAction(srv.id, 'delta')} + onVerify={() => void runServerAction(srv.id, 'verify')} + onExclude={() => void handleExcludeServer(srv.id)} + /> + ))} +
+ )} + + {excludedServers.length > 0 && ( + <> +
+ {t('settings.libraryIndexExcludedTitle')} +
+
+ {excludedServers.map(srv => ( +
+ {serverListDisplayLabel(srv, servers)} + +
+ ))} +
+ + )} + + {busyServerId && ( +
+ +
+ )} + +
+
+
+
{t('settings.libraryIndexAutoReconcile')}
+
+ {t('settings.libraryIndexAutoReconcileDesc')} +
+
+ +
+ + )} +
+ + ); +} diff --git a/src/components/settings/LibraryIndexServerRow.tsx b/src/components/settings/LibraryIndexServerRow.tsx new file mode 100644 index 00000000..4156b9a5 --- /dev/null +++ b/src/components/settings/LibraryIndexServerRow.tsx @@ -0,0 +1,145 @@ +import { RefreshCw, ShieldCheck, WifiOff, Zap, Ban } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import type { SyncStateDto } from '../../api/library'; +import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; +import { + libraryStatusDisplayTrackCount, + libraryStatusIsReady, +} from '../../utils/library/libraryReady'; + +export type LibraryServerConnection = 'online' | 'offline' | 'unknown'; + +interface LibraryIndexServerRowProps { + server: ServerProfile; + allServers: ServerProfile[]; + isActive: boolean; + status: SyncStateDto | null; + connection: LibraryServerConnection; + progressLabel: string | null; + busy: boolean; + actionsDisabled: boolean; + onFullSync: () => void; + onDeltaSync: () => void; + onVerify: () => void; + onExclude: () => void; +} + +export default function LibraryIndexServerRow({ + server, + allServers, + isActive, + status, + connection, + progressLabel, + busy, + actionsDisabled, + onFullSync, + onDeltaSync, + onVerify, + onExclude, +}: LibraryIndexServerRowProps) { + const { t } = useTranslation(); + const name = serverListDisplayLabel(server, allServers); + + const phaseLabel = (() => { + if (connection === 'offline') { + return t('settings.libraryIndexServerOffline'); + } + if (progressLabel) return progressLabel; + if (!status) return t('settings.libraryIndexStatusIdle'); + if (libraryStatusIsReady(status)) { + return t('settings.libraryIndexStatusReady', { + count: libraryStatusDisplayTrackCount(status), + }); + } + switch (status.syncPhase) { + case 'initial_sync': + return t('settings.libraryIndexStatusInitial'); + case 'error': + return t('settings.libraryIndexStatusError'); + case 'probing': + return t('settings.libraryIndexStatusProbing'); + default: + return t('settings.libraryIndexStatusIdle'); + } + })(); + + return ( +
+
+
+
+ {name} + {isActive && ( + + {t('settings.serverActive')} + + )} + {connection === 'offline' && ( + + + {t('settings.libraryIndexServerDeferred')} + + )} + {busy && ( + {t('settings.libraryIndexServerSyncing')} + )} +
+
+ {phaseLabel} +
+
+
+ +
+ + + + +
+
+ ); +} diff --git a/src/components/settings/LibraryTab.tsx b/src/components/settings/LibraryTab.tsx index d5cb2100..5e9a7bb2 100644 --- a/src/components/settings/LibraryTab.tsx +++ b/src/components/settings/LibraryTab.tsx @@ -5,6 +5,7 @@ import { useAuthStore } from '../../store/authStore'; import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults'; import SettingsSubSection from '../SettingsSubSection'; import StarRating from '../StarRating'; +import LibraryIndexSection from './LibraryIndexSection'; const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature']; @@ -15,6 +16,9 @@ export function LibraryTab() { return ( <> + {/* Local library index (spec §7.3) */} + + {/* Random Mix Blacklist */} { - if (confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) { - auth.removeServer(server.id); + const deleteServer = async (server: ServerProfile) => { + if (!confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) { + return; + } + // §5.6: when a local library index exists for this server, let the + // user keep the cached rows (offline use) or delete them. OK = + // delete the cache, Cancel = keep it. + const hadIndex = useLibraryIndexStore.getState().isIndexEnabled(server.id); + const purgeLibrary = hadIndex && confirm(t('settings.confirmDeleteServerLibrary')); + + auth.removeServer(server.id); + useLibraryIndexStore.getState().setIndexEnabled(server.id, false); + try { + await librarySyncClearSession(server.id); + if (purgeLibrary) { + await libraryDeleteServerData(server.id); + } + } catch { + /* best-effort — server already removed from the store */ } }; @@ -161,6 +180,10 @@ export function ServersTab({ auth.setSubsonicServerIdentity(id, identity); scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity); setConnStatus(s => ({ ...s, [id]: 'ok' })); + if (useLibraryIndexStore.getState().masterEnabled) { + const added = useAuthStore.getState().servers.find(s => s.id === id); + if (added) void bootstrapIndexedServer(added); + } } else { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } @@ -317,7 +340,7 @@ export function ServersTab({