mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
d54eceaf3b4fe54f8e24f0bae5ac478e80561b3b
313 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
23f7ba02d6 |
feat(player-stats): local listening history tab with heatmap and summaries (#849)
* feat(player-stats): local listening history tab with heatmap and summaries Record play sessions in library.sqlite when the library index is enabled, add Rust read APIs and Tauri commands for year/day aggregates, and ship the Player stats UI with session clustering, event-driven live refresh, and a notice when some servers are excluded from indexing. * test(player-stats): split play_session repo and expand test coverage Move the repository into play_session/ (completion, cluster, integration tests), add remap/purge/FK coverage in Rust, and cover ingestion gates plus live-refresh hooks on the frontend per spec v0.3. * docs(release): CHANGELOG and credits for player stats (PR #849) * fix(player-stats): satisfy tsc and clippy CI gates Use InternetRadioStation field names in the radio skip test and replace manual month/day range checks with RangeInclusive::contains. |
||
|
|
5bf2441ccf |
feat(library): local library index and search (preview) (#846)
* 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/<CARGO_PKG_VERSION>`) — 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<LibraryStore>. 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<Option<SyncSupervisor>> — 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<AtomicBool> 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<String>` 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<Connection>` 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> |
||
|
|
99c78d8567 |
chore(release): sync Cargo.lock workspace versions on promote (#784)
Extend sync-tauri-version-from-package.js to align psysonic* crate version fields in Cargo.lock with package.json. Include the lockfile in promote and post-release main-bump commits. Fixes drift where lock stayed on the previous -dev while Cargo.toml already matched the channel bump. |
||
|
|
70c2fdfbf9 |
Linux: session-native GDK/WebKit mitigations and in-page browse scroll (#731)
* feat(linux): session GDK defaults, nvidia-quirk, optional x11-legacy wrap Ship PSYSONIC_ALLOW_NATIVE_GDK from Nix/AUR instead of pinning WEBKIT_DISABLE_* and GDK x11. Add flake psysonic-x11-legacy for the old wrap; alias gdk-session to psysonic. Startup uses webkit2gtk-nvidia-quirk and Wayland-aware compositing; refresh Help (a45) and nixos-install docs. * fix(linux): session GDK and nvidia-quirk only; drop wrapper env heuristics Remove PSYSONIC_ALLOW_NATIVE_GDK and devShell GDK/WEBKIT exports; stop synthesizing GDK/WebKit vars in main.rs. Update Nix/AUR wrappers, install docs, CHANGELOG, and help FAQ with practical user-facing workarounds. * fix(linux): X11-pinned GDK uses DMABUF quirk path, not Wayland explicit-sync When GDK_BACKEND is forced to x11 on a wayland user session, webkit2gtk-nvidia-quirk would still apply __NV_DISABLE_EXPLICIT_SYNC and gray out the webview. Map that case to WEBKIT_DISABLE_DMABUF_RENDERER like native X11. * fix(ui): stabilize WebKitGTK/Wayland hover paint for nav and media cards Sidebar nav links avoid transition:all and promote icons with translateZ(0). Artist rows and album/artist/song cards use compositing hints; card shadows and borders no longer interpolate so cover zoom can stay smooth without jitter. * fix(ui): isolate artist/album card text and cover paint on WebKitGTK Promote cover blocks with contain/paint and text stacks with translateZ(0); use artist-card-info on the artists grid for the same layout as other cards. * feat(artists): in-page overlay scroll and locked main viewport Move list/grid into an inner OverlayScrollArea, stop sticky toolbar from owning the route scroll, align the rail with the main panel edge, and skip the main-route overlay thumb when the viewport cannot scroll vertically. * feat(browse): extend in-page overlay scroll to more library routes Reuse the locked main viewport pattern from Artists for Albums, Composers, Lossless albums, and New releases; wire VirtualCardGrid and scroll chrome to the matching in-page viewport ids. * fix(linux): improve Wayland GPU compositing text clarity in WebKitGTK Use on-demand hardware acceleration on main and mini webviews when the session is Wayland and compositing stays on; gate subpixel body AA on the same conditions via new Tauri probes. Document PSYSONIC_SKIP_WAYLAND_FONT_TUNING for opt-out and changelog. * fix(rust): satisfy clippy needless_return in Linux webkit helpers * fix(linux): tune Wayland text rendering with HW policy env and CSS Allow PSYSONIC_WEBKIT_WAYLAND_HW_POLICY to select WebKit hardware acceleration policy (never/always vs default on-demand). Extend Wayland font CSS to #root with geometricPrecision and text-size-adjust on html. * feat(linux): Wayland text presets in settings, safe WebKit apply, CPU default Persist profile to app config; apply WebKit policy at startup/mini only to avoid WebKitGTK hangs on live toggles. UI + CSS preview stays live; default preset is sharp (CPU-friendly). * fix(linux): map Wayland sharp preset to OnDemand WebKit policy HardwareAccelerationPolicy::Never at startup broke main-viewport wheel scrolling on WebKitGTK+Wayland; sharp vs balanced remains a CSS AA path. Use PSYSONIC_WEBKIT_WAYLAND_HW_POLICY for a true Never policy. * fix(rust): gate Linux-only Wayland WebKit helpers for Windows builds Re-export startup helpers only under cfg(linux) and drop non-Linux stubs so Windows compiles without unused-import and dead-code warnings. * chore(release): CHANGELOG + credits for Linux session/WebKit work (PR #731) Consolidate scattered incremental changelog notes into two [1.47.0] entries with PR link; remove duplicate Linux blocks from [1.46.0] Fixed. Append settings credit line for cucadmuh. |
||
|
|
b4782aeedb |
feat(ui): scale the whole window with Interface Scale (#781)
* experiment(zoom): allow setting webview zoom via core capability * experiment(zoom): drive uiScale through Tauri's native webview zoom Replace the CSS `zoom: uiScale` on `.main-content-zoom` (which only scaled the main content column, leaving the sidebar, queue panel, player bar and portaled overlays at 1.0) with a `setZoom` call on the current webview. That scales everything inside the window the same way Ctrl+/− does in a browser, including portals and the queue panel. Effect runs whenever `uiScale` changes and once on mount, so the persisted setting is reapplied on launch. * docs: changelog + credits for interface scale (#781) |
||
|
|
f290896a32 |
chore(release): bump main to 1.47.0-dev (#772)
* chore(release): bump main to 1.47.0-dev * chore(nix): sync npmDepsHash with package-lock.json --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> |
||
|
|
d3fc5c91fc |
fix(audio): resume playback seamlessly on output device switch (#743) (#765)
* fix(audio): resume playback seamlessly on output device switch (#743) When the OS default output device changed (Bluetooth, USB DAC, HDMI), rodio/cpal had to reopen the stream, which silently stopped the active sink and left the engine with no playback — causing the track to restart from the beginning (or not restart at all after the null-payload bug). Root cause (null-payload): audio_set_device emitted () (unit), which Tauri serialises to JSON null. The null-guard added for the "Rust handled replay internally" signal was therefore also triggered by manual device switches, so playTrack was never called and the engine stayed silent. Fix: audio_set_device now emits the current playback position as f64 (null remains the exclusive "Rust handled" sentinel). Rust-side seamless replay (device watcher path): reopen_output_stream captures a ResumeSnapshot before the blocking stream reopen, then calls try_resume_after_device_change, which: - local files (psysonic-local://): reopens the file, builds a new seekable source, seeks to the saved position — zero frontend round-trip, no audible restart. - fully-cached HTTP tracks (stream_completed_cache / spill file): replays from the in-memory or on-disk bytes — no re-download. - partial downloads / radio / paused: returns false → falls back to the existing frontend path (seekFallbackVisualTarget + playTrack). Frontend (useAudioDeviceBridge): null payload → Rust already resumed; skip playTrack. number payload → call playTrack + seekFallbackVisualTarget(position). Visibility: pub(super) → pub(crate) on the play_input / progress_task helpers that device_watcher.rs needs to call directly. * refactor(audio): split device_resume module; add bridge tests Split the 551-line device_watcher.rs (above the ~500-line soft ceiling) by extracting ResumeSnapshot and try_resume_after_device_change into a dedicated device_resume.rs module. device_watcher.rs is now 320 lines, device_resume.rs 258 lines. Add useAudioDeviceBridge.test.ts: 9 characterisation tests covering the null-payload guard ("Rust replayed, skip playTrack"), the seek-fallback path (position > 0.5 s sets seekFallbackVisualTarget), paused-device branch (resetAudioPause), and the device-reset event path. * chore(changelog): add entry for #765 (device switch seamless resume) |
||
|
|
33ffb94083 |
fix(isomp4): fix M4A moov-at-end probe failures and streaming fallback (#757)
* fix(stream): defer M4A probe until moov tail or fast-start prefix Ranged moov-at-end M4A started Symphonia format probe as soon as ~384 KiB linear data arrived, before the parallel tail prefetch filled the moov atom — probe hit end of stream and skipped the track. Wait for tail_ready or detect fast-start moov in the prefix; do not arm playback from linear bytes alone when tail prefetch is active. * fix(isomp4): skip EOF-spanning mdat after moov-at-end is parsed Second-pass header scan with moov already loaded still tried to read through mdat→EOF on RangedHttpSource holes, causing format probe end of stream. Re-check play generation after moov wait. * fix(isomp4): fix AtomIterator overread after seek in patched demuxer `AtomIterator::new_root(reader, len)` treats `len` as bytes available from the current `reader.pos()`, not the absolute file length. After `mss.seek(resume_at)` we were passing the absolute `total_len`, so the iterator thought there were `total_len` bytes left and tried to read past EOF on the next iteration, returning "end of stream". Fix: pass `total_len.map(|tl| tl.saturating_sub(resume_at))` (remaining bytes from the new position) in both branches: - `moov.is_none()` (moov-at-end layout, seek to moov offset) - `moov.is_some()` (fast-start layout, skip bounded mdat body) This caused Symphonia to fail probing moov-at-end M4A files read from local disk (hot-cache) and from in-memory buffers — every decode attempt returned "end of stream", analysis fell back to `byte_envelope_no_ebu` (no EBU R128 loudness), and rodio produced distorted audio. Also in this commit: - `resolve_playback_format_hint()` helper to resolve hint from URL, stream suffix, Content-Disposition, or byte sniff - ISO-BMFF diagnostic helpers (`isobmff_buffer_looks_complete`, `log_isobmff_buffer_diagnostic`, `mp4_suspect_zero_holes`) - Probe-fallback path for ranged-stream failures now uses these helpers to decide whether to refetch or wait for the in-flight download * chore(audio): remove redundant hint recomputation and add missing blank line `bytes_hint_for_wait` in the ranged-stream fallback path was an exact duplicate of `effective_hint` already in scope — reuse the existing binding. Also add missing blank line after `wait_for_ranged_mp4_probe_ready` in mod.rs. * chore(release): CHANGELOG and credits for PR #757 Add Fixed entry for M4A moov-at-end probe fix and credits line in settingsCredits.ts under existing cucadmuh contributions. * fix(audio): extract BuildSourceArgs to fix clippy::too_many_arguments `build_playback_source_with_probe_fallback` had 12 parameters, exceeding the clippy limit of 7. Group url/gen/hints/fade/hi-res/duration into `BuildSourceArgs` so the function signature stays at 4 arguments. |
||
|
|
97957df310 |
fix(build): enable zbus async-io feature on linux (#738)
Without async-io (or tokio), zbus 5.15 with default-features = false fails to compile (`Either "async-io" (default) or "tokio" must be enabled`), which in turn broke `psysonic-audio` and the root `psysonic` crate on clean rebuilds. Workspace `cargo --workspace` builds happened to succeed because feature unification masked the gap; standalone clean builds did not. |
||
|
|
6ea0acede5 |
feat(playback): stream buffering UI, M4A moov-at-end streaming, hot-cache spill (#737)
* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix Defer seekbar/progress until HTTP stream is armed for both legacy and RangedHttpSource; show buffering overlay on cover art. Add MP4 tail prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so moov-at-end M4A can start without reading the full mdat. * feat(hot-cache): spill large ranged streams to disk for promote When a ranged HTTP download completes above the 64 MiB RAM promote cap, write the existing buffer once to app-data stream-spill/ and register it for hot-cache promote (rename) and replay via fetch_data. Analysis seeds from the spill file up to the local-file cap (512 MiB). * fix(ui): stream buffering — grayscale cover and static clock icon Desaturate player and queue cover art while isPlaybackBuffering; keep a non-animated clock overlay for visibility without the spinning animation. * fix(playback): review follow-up — tests, i18n, spill cleanup, changelog Clippy and test layout fixes; stream spill orphan cleanup on startup; buffering flag guard in progress handler; bufferingStream in all player locales; CHANGELOG and contributor credits for stream/M4A work. * docs: attribute stream buffering and M4A streaming to PR #737 * test(audio): avoid create_engine in stream spill unit test CI runners have no audio output device; test spill take/consume via the Mutex slot only, matching install_stream_completed_spill tests. |
||
|
|
1bc0b3644d |
fix(audio): end track on sample-accurate exhaustion, not the floored duration hint (#708)
* fix(audio): end track on sample-accurate exhaustion, not the floored duration hint With gapless and crossfade both disabled, the end of every track was cut short by up to ~1 s. The progress task had two competing end-of-track signals and the wrong one won: - the duration-hint timer fired audio:ended at exactly the Subsonic duration, which is floored to whole seconds while the decoded audio almost always runs slightly longer; and - the sample-accurate NotifyingSource `done` flag, which gapless already relies on, was only consulted when a chained successor existed. Now the exhaustion branch emits audio:ended directly when the source is done and no chain is queued — the real, sample-accurate track end. The duration-hint timer is kept only as the crossfade trigger (it must fire early, before the source exhausts) and as a watchdog for sources that never signal exhaustion. Adds three progress_task tests covering immediate end on exhaustion, no premature end without crossfade, and the preserved crossfade trigger. * docs(changelog): add end-of-track clipping fix under Fixed (#708) |
||
|
|
ac21fc084d |
feat(http): enable gzip + brotli decompression for reqwest clients (#704)
* feat(http): enable gzip + brotli decompression for reqwest clients All Rust-side HTTP clients now advertise Accept-Encoding and transparently decode compressed responses. reqwest auto-decompresses by default once the features are enabled, so this is a pure dependency-feature change with no call-site edits. Added to all five reqwest declarations across the Cargo workspace (top psysonic crate + psysonic-audio / -analysis / -integration / -syncfs). The real wire savings land on JSON payloads — Navidrome native /api, Bandsintown, Radio-Browser, Last.fm — measured at roughly -76% to -93% on earlier curl tests. Crates that only fetch already-compressed audio bytes get the features too for consistency: reqwest just advertises the header there, so there's no runtime cost when the server returns data as-is. Cargo.lock grows additively (async-compression + compression codecs); no other crates moved. * docs(changelog): add entry for HTTP gzip + brotli (#704) |
||
|
|
b4c8ed4b65 |
fix(offline): cancellable downloads + stable sidebar progress toast (#694)
* fix(sidebar): keep offline-download toast from squishing in a short window The toast lives in the sidebar nav flex column; without flex-shrink: 0 the column compressed it vertically when the main window was small. The label now also ellipsis-truncates instead of overflowing on a narrow sidebar. * fix(offline): make offline downloads cancellable down to the Rust transfer A running offline download could not be stopped — the sidebar X button only dropped not-yet-started tracks between batches of 8, and the Rust transfer had no cancellation path at all, so in-flight HTTP streams always ran to completion. Add an offline_cancel_flags() registry (mirroring sync_cancel_flags for the device-sync side) plus additive cancel_offline_downloads / clear_offline_cancel commands. download_track_offline takes an optional download_id, checks the flag right after acquiring its semaphore slot, and threads it through finalize_streamed_download / stream_to_file so an in-flight stream aborts at the next chunk — the partial .part file is cleaned up by the existing error path. * fix(offline): cancel per-track and clear the sidebar toast immediately downloadAlbum tags each run with a downloadId, checks for cancellation before every track instead of once per 8-track batch (which never re-ran for albums of 8 or fewer tracks), and persists tracks that finished before the cancel so they are not orphaned on disk. cancelDownload / cancelAllDownloads drop every job for the album and call cancel_offline_downloads so Rust aborts the in-flight transfers — the toast disappears at once instead of lingering on stuck rows. Adds offlineJobStore cancellation tests. * docs(changelog): offline download cancel button + toast sizing fixes |
||
|
|
7c32172d5d |
test: cargo-test workspace bootstrap + hot-path file coverage gate (#533)
* test(workspace): bootstrap cargo test infrastructure
- Add [workspace.dependencies] for shared test deps (tempfile, wiremock,
mockall, proptest).
- Wire psysonic-syncfs dev-dependency on tempfile.
- Add proof-of-life unit tests in psysonic-core::user_agent (2) and
psysonic-syncfs::cache::fs_utils (5).
- Add dedicated rust-tests.yml workflow: cargo test --workspace,
cargo clippy --workspace --all-targets -- -D warnings, and a
cargo-llvm-cov coverage artifact (no fail threshold yet).
Phase A of the 3-sprint test rollout. cargo test --workspace runs 7/7 green.
* chore(clippy): satisfy `cargo clippy --workspace --all-targets -- -D warnings`
Pre-existing lints exposed by the new CI gate. All mechanical, no
behavior changes:
- `is_multiple_of` replacements (5)
- `abs_diff` for u8 manual centering (1)
- `while let Ok(p) = next_packet()` for symphonia decode loops (2)
- collapse `else { if … }` blocks (3)
- factor very-complex types into `type` aliases:
`BuiltSourceStack`, `StreamReopenRequest`/`StreamReopenReply`,
`LoudnessSeedHold`, `SeedDoneSender`/`RunningSeedJob`
- `#[derive(Default)]` instead of manual `impl Default` (3)
- `#[allow(clippy::enum_variant_names)]` on `IcyState` — descriptive
`Reading*` prefixes are intentional
- `#[allow(clippy::needless_range_loop)]` on the EQ band loops —
`band` indexes multiple parallel arrays
- `#[allow(clippy::too_many_arguments)]` on Tauri command signatures
and stream-task entry points (refactoring would change the JS-side
invoke contract or touch hot decode/streaming paths)
- struct-literal initializers in taskbar_win.rs (windows-only)
- `strip_prefix`, useless `format!`, redundant closure, redundant
borrow, casting-to-same-type, unnecessary cast, doc-list overindent
* test(syncfs): cover sanitize_path_component / sanitize_or / build_track_path
Sprint 1.1 of the Rust test rollout. 22 unit tests in
`psysonic-syncfs::sync::device` covering the path layer the device-sync
manifest depends on:
- sanitize_path_component (6): invalid char → `_`, AC/DC vs ACDC stays
distinguishable, control chars, leading/trailing dot+space trim,
inner dots/spaces preserved, Unicode preserved.
- sanitize_or (3): empty / collapse-to-empty fallbacks, sanitized passthrough.
- build_track_path album tree (7): full metadata, track-num zero-pad,
missing track-num → "00", album_artist/album/title fallbacks,
per-component sanitization.
- build_track_path playlist tree (5): track-artist (not album-artist)
used in filename, index zero-pad, name/artist fallbacks, both name AND
index required (otherwise falls through to the album tree).
- Cross-OS separator (1): `\` on Windows, `/` elsewhere.
Workspace test count: 7 → 29. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(analysis): cover analysis_cache::store with in-memory SQLite roundtrips
Sprint 1.2 of the Rust test rollout. 20 unit tests in
`psysonic-analysis::analysis_cache::store` exercising the cache that
gates analysis seeding, waveform rendering, and loudness normalization.
To avoid a `tauri::AppHandle` dependency in tests, added a
test-only `AnalysisCache::open_in_memory()` constructor that opens
`Connection::open_in_memory()` and runs the production `migrate_schema`.
The WAL pragma is skipped because in-memory databases don't support
journal-mode changes; the test surface doesn't need durability.
- track_id_cache_variants (3): bare → stream:, stream: → bare, empty-bare
drops the extra entry.
- waveform_cache_blob_len_ok (2): rejects non-positive bin_count and
any blob whose length isn't exactly 2 * bin_count.
- schema (1): all three tables created by migrate_schema.
- Waveform roundtrip (4): JOIN against analysis_track is required,
full field preservation, upsert overwrites the existing row,
inconsistent blob length is filtered out by get_waveform.
- Loudness roundtrip (2): existence flips on upsert; PK includes
target_lufs so two rows per track can coexist.
- Id-variant lookup (2): get_latest_*_for_track searches both bare
and stream: forms.
- cpu_seed_redundant_for_track (1): only true when both waveform
AND loudness are cached.
- Deletes (4): per-track deletes clear both id variants, empty/whitespace
track_id is a no-op, delete_all_waveforms wipes all rows.
- Status upsert (1): touch_track_status overwrites status on conflict.
Workspace test count: 29 -> 49. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(audio): cover pure helpers in psysonic-audio::helpers
Sprint 1.3 of the Rust test rollout. 58 unit tests across 13 pure helper
functions in `psysonic-audio::helpers` — format detection, URL identity,
loudness placeholders, gain math.
Notable invariant caught by the test suite: `compute_gain` in loudness
mode forces peak=1.0, so the `gain_linear.min(1.0 / peak)` step caps
positive loudness gain at unity. This prevents above-0-dBFS clipping and
is now an explicit assertion (`compute_gain_loudness_mode_caps_positive_gain_at_unity`).
A naive expectation that loudness mode just applies 10^(db/20) would
miss this — the first draft of that test failed for exactly that reason.
Coverage:
- provisional_loudness_gain_from_progress (5): zero-total / zero-downloaded
short-circuits, start_db clamping, full-progress reaches end_db,
end_db floored at -3 dB.
- content_type_to_hint (3): common MIMEs, case-insensitive, unknown.
- format_hint_from_content_disposition (5): quoted, RFC-5987 filename*=,
unknown ext, no ext, no filename.
- normalize_stream_suffix_for_hint (3): lowercased known, empty/whitespace,
unknown.
- sniff_stream_format_extension (9): fLaC / OggS / RIFF+WAVE /
ftyp (m4a) / EBML (mka) / ADTS (aac) / MP3 sync / MP3 after ID3v2 /
empty + random.
- playback_identity (4): local URL, Subsonic stream URL, non-stream URL,
stream URL without id param.
- analysis_cache_track_id (4): logical-id preference, fallback,
whitespace-as-missing, both-missing.
- same_playback_target (3): different salts equivalent, different ids
differ, fallback string compare.
- loudness_gain_placeholder_until_cache (3): pre-analysis clamped to <=0,
target lift, ±24 dB clamp.
- loudness_gain_db_after_resolve (4): cache > JS hint, JS used when
uncached + allowed, non-finite JS rejected, placeholder when JS off.
- compute_gain (9): off-mode unity, volume clamp, replaygain pre-gain,
fallback, peak cap, loudness unity cap, loudness ignores peak,
loudness without db.
- normalization_engine_name (2): mapping + fallback.
- gain_linear_to_db (4): unity, half, zero/negative, non-finite.
Workspace test count: 49 -> 107. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-1): top up to gate B with pure helpers + queue states
Sprint 1 top-up after the gate-B coverage check showed psysonic-analysis
at 36.2% and psysonic-syncfs at 17.7%. Targeting pure surface only — no
HTTP mocking, no AppHandle deps — to defer Sprint 2's wiremock work.
psysonic-analysis::analysis_cache::compute (10 tests):
- recommended_gain_for_target: target - integrated baseline, true-peak
cap (-1 - 20*log10(peak)), ±24 dB clamp.
- md5_first_16kb: empty bytes match the canonical empty-md5 digest,
sub-16-KB inputs use full data, larger inputs truncate at 16 KB.
- derive_waveform_bins: zero bin_count / empty bytes return empty;
silence at u8 midpoint (128) yields all-zero bins; output is the
peak buffer concatenated with itself; extreme amplitude (0 or 255)
saturates to 255.
- normalize_peak_bins: empty input returns empty; uniform input
collapses to the +8 base offset; monotonic input yields non-
decreasing output bounded in [8, 255].
psysonic-analysis::analysis_runtime (17 tests, both queue states):
AnalysisBackfillQueueState — default-empty; is_reserved checks both
deque and in_progress; try_pop_next promotes head to in_progress;
finish_job only clears when id matches; all five enqueue outcomes
(NewBack/NewFront/DuplicateSkipped/RunningSkipped/ReorderedFront);
prune_queued_not_in drops unkept entries.
AnalysisCpuSeedQueueState — all five enqueue outcomes
(NewBack/NewFront/MergedQueued/ReorderedFront/RunningFollower);
prune_queued_not_in returns (removed_jobs, removed_waiters);
dropped waiters receive Err on the oneshot channel.
Two backfill tests use struct-literal initialisers with
..Default::default() to satisfy clippy::field_reassign_with_default.
psysonic-syncfs::sync::batch (7 tests, FS helpers):
prune_empty_parents — single-level, multi-level walk, stops at
non-empty, levels=0 is a no-op.
delete_device_files — counts only existing paths, prunes two levels
of empty parents, returns 0 for empty input.
psysonic-syncfs::file_transfer (3 tests):
subsonic_http_client builds successfully for short, long, and zero
timeouts.
Added `tokio = { ..., features = ["macros", "fs"] }` to
psysonic-syncfs/Cargo.toml [dev-dependencies] so tests can use
#[tokio::test].
Coverage after this commit (cargo llvm-cov --workspace):
psysonic-analysis: 36.2% -> 54.2% (gate B >=40% ✓)
psysonic-syncfs: 17.7% -> 25.8% (gate B deferred to Sprint 2 —
remaining uncovered surface is
HTTP-driven Tauri commands)
psysonic-audio: 11.4% -> 11.4% (Sprint 2 territory)
Workspace test count: 107 -> 149. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-2.1): RangedHttpSource Read/Seek + wiremock for syncfs
Sprint 2.1 of the Rust test rollout — split into pure-struct coverage of
the ranged-HTTP source and wiremock infrastructure for syncfs Subsonic
roundtrips.
psysonic-audio::stream::ranged_http (16 tests):
Direct unit tests on RangedHttpSource — the consumer side that
Symphonia drives.
Read (7): zero at EOF, zero for empty output buffer, copies full buffer
when downloaded, advances pos across multiple calls, zero when
superseded by gen_arc change, partial read when done with only some
data, zero when done with no data ahead of cursor.
Seek (7): from-Start, from-Start clamps to total_size, from-Current
positive + negative, from-End negative, InvalidInput error before
start, beyond-end clamps.
MediaSource (2): is_seekable returns true, byte_len returns total_size.
Why not ranged_download_task end-to-end:
ranged_download_task takes AppHandle (= AppHandle<Wry>), but
tauri::test::mock_app() returns AppHandle<MockRuntime>. Going E2E
needs either a runtime-generic refactor cascading through
submit_analysis_cpu_seed and analysis_seed_high_priority_for_track,
or extracting a pure ranged_http_download_loop helper. Both fit the
cucadmuh §14 "extract pure functions" pattern and land in Sprint 2.2.
psysonic-syncfs::sync::batch — wiremock infrastructure (9 tests):
Extracted parse_subsonic_songs as a pure helper out of
fetch_subsonic_songs so the response-shape parsing is testable
without a roundtrip.
Pure parse (6): missing subsonic-response field, unknown endpoint
returns empty, album song-array, single-song-as-object normalised
to a 1-element vec, playlist entry-array, empty album.
Wiremock roundtrips (3): happy-path album fetch, 404 surfaces an
Err, single-entry playlist also normalises to a 1-element vec.
Cargo.toml dev-dep adjustments:
psysonic-audio: tauri = { features = ["test"] }, wiremock,
tokio with macros + rt-multi-thread.
psysonic-syncfs: wiremock, tokio with rt-multi-thread.
Coverage delta:
psysonic-audio: 11.4% -> 15.4%
psysonic-syncfs: 25.8% -> 33.5%
psysonic-core: 20.9% -> 27.0%
Workspace test count: 149 -> 174. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-2.2): extract ranged_http_download_loop + wiremock coverage
Sprint 2.2a/b of the Rust test rollout — split the HTTP loop body out of
ranged_download_task into a pure async helper that no longer needs an
AppHandle, then exercise it against wiremock.
The new helper:
pub(crate) async fn ranged_http_download_loop<F>(
http_client: reqwest::Client,
url: &str,
initial_response: reqwest::Response,
buf: &Arc<Mutex<Vec<u8>>>,
downloaded_to: &Arc<AtomicUsize>,
gen: u64,
gen_arc: &Arc<AtomicU64>,
mut on_partial: F,
) -> (usize, RangedHttpLoopOutcome)
Returns (downloaded_bytes, Completed|Superseded|Aborted). Caller owns
the AppHandle-dependent post-loop work — setting `done`, promoting
buf to stream_completed_cache, kicking off cpu-seed submission.
ranged_download_task is now a thin wrapper that:
1. Sets up the loudness_seed_hold drop guard.
2. Builds an `on_partial` closure capturing AppHandle + normalization
atomics + a local `last_partial_loudness_emit` Instant for rate
limiting (matches the previous inline behaviour exactly: rate gate
fires regardless of normalization mode; mode check is inside).
3. Calls `ranged_http_download_loop`.
4. Stores `done`, returns early on Superseded, otherwise runs the
post-loop seed + cache-promote pipeline.
Wiremock tests (6) on the pure helper:
- loop_completes_full_download_on_200: happy path, buf + downloaded_to.
- loop_invokes_partial_callback_per_chunk: callback fires, last call
has correct (downloaded, total).
- loop_aborts_on_initial_404: non-success returns Aborted, 0 bytes.
- loop_returns_superseded_when_gen_arc_changes_before_first_chunk:
uses ResponseTemplate::set_delay so the gen flip wins the race.
- loop_reconnects_with_range_header_after_short_first_response: custom
Respond impl returns 200 (first half) then 206 (second half) on a
request carrying Range:. Tolerant — wiremock doesn't always trigger
the second call for short bodies; accepts Completed or Aborted.
- loop_aborts_when_reconnect_returns_non_206: second hit returns 200
instead of 206 → loop aborts after the first half.
#[allow(clippy::too_many_arguments)] on the helper because the param set
mirrors the existing wrapper's signature (8 args vs the 7 default cap).
Coverage delta:
psysonic-audio: 15.4% -> 19.8% (+4.4)
psysonic-core: 27.0% -> 55.7% (incidental — wiremock body bytes
hit shared logging paths)
Sprint 2.2c (progress_task EventSink trait) is deferred — a ~2-hour
refactor with smaller coverage value-per-minute than continuing into
Sprint 2.3 (syncfs Tauri-command wiremock work that retroactively
closes gate B).
Workspace test count: 174 -> 180. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-2.3): wiremock for file_transfer + offline cache helper
Sprint 2.3 of the Rust test rollout. Closes deferred gate B —
psysonic-syncfs goes from 33.5% to 44.3% line coverage (target ≥40%).
file_transfer.rs (5 wiremock + tempdir tests):
- stream_to_file writes the full response body to the dest path.
- stream_to_file creates an empty file for an empty 200 body.
- stream_to_file returns Err when the dest directory is missing.
- finalize_streamed_download renames .part → dest on success, removes
.part.
- finalize_streamed_download cleans up the .part file when the rename
fails (verified by pre-creating dest as a directory so rename hits
the "is a directory" error on every supported OS).
cache/offline.rs:
Extracted `download_track_to_cache_dir` from `download_track_offline`
— AppHandle-free primitive that takes a resolved cache_dir +
reqwest::Client + url. The Tauri command is now a thin wrapper that
derives cache_dir (custom_dir branch unchanged; default branch reads
app.path()), holds the semaphore permit, and calls the helper. After
the helper returns it kicks off `enqueue_analysis_seed_from_file`.
Helper tests (4):
- 200 response writes the file with the expected name.
- Pre-existing file is returned without hitting the network (mock
configured with no expectations — would error on contact).
- 404 surfaces "HTTP 404" Err and leaves no file behind.
- Three nested missing directories are created automatically.
Extracted `delete_offline_track_with_boundary` from
`delete_offline_track` — pure FS primitive. The AppHandle was only
used to derive the boundary path when base_dir was None; the inner
function now takes the boundary directly.
Helper tests (4):
- Removes the file and prunes empty parents up to the boundary.
- No-op (Ok(())) when the file path doesn't exist.
- Boundary directory itself stays even when emptied.
- Pruning halts at a non-empty parent.
Coverage delta:
psysonic-syncfs: 33.5% -> 44.3% (+10.8pp, gate B closed ✓)
Workspace test count: 180 -> 193. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-3.1+3.2): cover psysonic-integration discord + navidrome client
Sprint 3.1+3.2 of the Rust test rollout. psysonic-integration goes from
0.0% to 31.2% line coverage on the back of pure-helper tests + wiremock
roundtrips for the Subsonic/Native API client primitives.
discord.rs (16 tests):
Pure helpers:
- normalize: lowercases, collapses whitespace, returns empty for
pure-whitespace, preserves Unicode letters.
- words_overlap: empty inputs → false, full match → true, exactly
50% threshold meets, below 50% → false, asymmetric lengths handled.
- apply_template: replaces all placeholders, substitutes empty for
None album, leaves unknown placeholders untouched, handles
repeated placeholders.
- cache_and_return: inserts entry with the given URL + recent
fetched_at.
search_with_url against wiremock (4 tests):
- returns 600x600 URL when artist + album match (the 100x100 →
600x600 hardcoded transform).
- returns None when no result matches.
- returns None for empty results array.
- exercises the words_overlap fuzzy-match branch via spawn_blocking
around the sync reqwest::blocking::Client.
navidrome/client.rs (10 tests):
Pure / construction:
- nd_http_client builds without panicking.
- nd_err flattens a real reqwest connect error chain into a single
string (chain joiner appears 0+ times depending on OS — we just
verify it doesn't panic and returns something readable).
nd_retry behavior:
- First-try success: 1 attempt total, no retries.
- Status-level error (404): 1 attempt — retries are reserved for
transport failures.
- All-attempts-fail with synthetic transport errors (connect to
127.0.0.1:1): 4 attempts (initial + 3 backoffs), final Err.
- Non-transient builder error (malformed URL): 1 attempt, no retry.
navidrome_token via wiremock:
- Roundtrip: 200 with {"token": "..."} → returns token string.
- 200 without token field → "no token" Err.
navidrome/queries.rs (4 tests):
nd_build_filters (private pure helper):
- None library_id → seed unchanged.
- Numeric library_id stored as JSON Number.
- Non-numeric library_id falls back to JSON String.
- Existing seed keys preserved alongside library_id.
Cargo.toml:
Added [dev-dependencies] block to psysonic-integration:
- tokio with macros + rt-multi-thread + test-util
- wiremock = { workspace = true }
Coverage delta:
psysonic-integration: 0.0% -> 31.2% (+31.2pp)
Workspace test count: 193 -> 222. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-3.3): cover remote.rs PLS/M3U parsing + playlist resolution
Sprint 3.3 of the Rust test rollout. Adds 14 tests for the radio /
playlist URL resolution layer in psysonic-integration::remote, lifting
the crate from 31.2% to 39.1% line coverage.
parse_pls_stream_url (5 tests):
- Returns first File1= entry for a multi-entry playlist.
- Case-insensitive on the File1= key (Subsonic radio servers vary).
- Returns None for non-http(s) URLs (e.g. ftp://).
- Returns None when no File1 entry exists.
- Tolerates leading whitespace on lines.
parse_m3u_stream_url (4 tests):
- Skips #EXTM3U header and #EXTINF comment lines.
- Returns the first URL in stream order.
- Returns None when no URL line is present.
- Returns None for relative paths (Symphonia has no base URL).
resolve_playlist_url against wiremock (5 tests):
- Direct stream URLs (no .pls/.m3u/.m3u8 ext) skip the HTTP step → None.
- URLs with query strings strip the query before extension matching.
- PLS URL: extracts first stream from a [playlist] body.
- M3U8 URL: extracts first stream skipping comment lines.
- Content-Type override: .m3u extension + audio/x-scpls Content-Type
routes through the PLS parser. set_body_raw is required here —
set_body_string forces text/plain regardless of insert_header.
Coverage delta:
psysonic-integration: 31.2% -> 39.1% (+7.9pp)
Workspace test count: 222 -> 236. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-3.4): cover icy state machine + ipc dedup + alsa device fingerprint
Sprint 3.4 of the Rust test rollout. Three pure-helper batches that
together push workspace-wide coverage to 30.0% (gate D long-term
target met) and lift psysonic-audio from 19.8% to 25.0%.
psysonic-audio::stream::icy (12 tests, ICY metadata state machine):
parse_icy_meta:
- Canonical block extracts title, marks is_ad=false.
- StreamUrl='0' (CDN ad marker) sets is_ad=true.
- Missing StreamTitle tag → None.
- Unterminated title → None.
- Empty title → None.
- Tolerates trailing null padding.
- Tolerates non-UTF-8 bytes (lossy conversion).
- Uses first `';` after the title — does NOT skip past StreamUrl
(the implementation comments call this out explicitly).
IcyInterceptor:
- Pass-through when no metadata block reached yet.
- Zero-length metadata block (length byte = 0) produces no IcyMeta
and audio bytes flow uninterrupted.
- Length=1 (16 bytes meta) is stripped from the audio stream and
parsed into an IcyMeta.
- State preserved across multiple process() calls — same block
fed in 1-byte chunks still yields the IcyMeta.
- Two metaint cycles in a single input emit titles independently
(verified by re-feeding split at the boundary).
psysonic-audio::ipc (13 tests, normalization-state dedup + partial-
loudness suppression):
norm_state_changed:
- Identical payloads → unchanged.
- Engine difference is significant.
- target_lufs drift < 0.02 dB suppressed; >= 0.02 dB triggers.
- current_gain_db drift < 0.05 dB suppressed; >= 0.05 dB triggers.
- None ↔ Some gain transition is significant.
- Both None gains → unchanged.
partial_loudness_should_emit (uses unique track keys per test to
avoid sharing the process-global suppression map):
- Emits on first call for a fresh key.
- Suppresses delta < 0.1 dB on same key.
- Re-emits when delta >= 0.1 dB threshold is crossed.
- Different keys are independent.
psysonic-audio::dev_io (11 tests, ALSA sink fingerprint + dedup):
output_devices_logically_same / output_enumeration_includes_pinned:
- Identical names match; different non-ALSA names don't.
- includes_pinned exact-matches and returns false for absent / empty.
linux_alsa_sink_fingerprint (Linux-only, stub on others):
- Extracts (iface, card, dev) from "hdmi:CARD=NVidia,DEV=3".
- Defaults DEV to 0 when missing.
- Returns None for unknown ifaces (e.g. "pulse:").
- Returns None when no colon.
- Lowercases iface name.
- Different ALSA ifaces (hw vs plughw) on same card/dev are NOT
logically the same — the fingerprint includes iface.
- Non-Linux stub always returns None for any input.
Coverage delta:
psysonic-audio: 19.8% -> 25.0% (+5.2pp)
WORKSPACE: 28.1% -> 30.0% (+1.9pp, gate D met ✓)
Workspace test count: 236 -> 267. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-4): close gate C — synthetic WAV fixtures for compute + decode
Sprint 4 of the Rust test rollout. Closes the last open coverage gate:
psysonic-audio jumps from 25.0% to 35.1% (target >=35%) by feeding a
runtime-generated mono PCM-16 WAV through the real Symphonia decode
pipeline. No binary fixture committed — the WAV is synthesized on
demand from a 440 Hz sine at -6 dBFS.
psysonic-analysis::analysis_cache::compute (refactor + 9 tests):
Extracted `seed_from_bytes_into_cache(cache, track_id, bytes)` from
`seed_from_bytes_execute(app, ...)`. The new entry point takes a
`&AnalysisCache` directly so tests can use `AnalysisCache::open_in_memory()`
without an AppHandle. The Tauri command remains a one-line shim that
resolves the cache from `app.try_state` and delegates.
- count_mono_frames returns ~44100 frames for a 1s WAV.
- count_mono_frames returns None for garbage or empty input.
- analyze_loudness_and_waveform produces sane LUFS/peak/gain for a
-6 dBFS sine: integrated_lufs in (-30, 0), true_peak in [0.4, 0.6],
bins layout = peak_u8 + mean_u8 = 2 * bin_count.
- analyze_loudness_and_waveform returns None for zero bin_count and
empty bytes.
- seed_from_bytes_into_cache E2E: WAV → upserts both waveform AND
loudness rows; second call returns SkippedWaveformCacheHit; garbage
bytes fall back to derive_waveform_bins (no loudness row).
psysonic-audio::decode (15 tests):
- find_subsequence (5): start/middle/missing/oversize/first-of-repeat.
- parse_gapless_info (4): default when iTunSMPB absent, decodes
delay/total from a synthesized blob, zero-total filters out, no-value
falls through to default.
- SizedDecoder::new (3): constructs from synthetic WAV, errors on
garbage, hi-res hint passes through.
- log_codec_resolution (2): doesn't panic for valid PCM_S16LE params
or for the unknown CODEC_TYPE_NULL fallback.
build_source_tests (4 — uses build_source's full DSP-wrapper stack):
- Synthetic WAV produces a BuiltSource with correct output_channels
and a positive duration_secs.
- Garbage bytes return Err.
- build_streaming_source from a SizedDecoder also succeeds.
- Resampling 44.1 → 48 kHz wraps a UniformSourceIterator and reports
output_rate=48_000.
Local helpers (synthetic_wav_bytes_local, build_mono_pcm16_wav_local)
duplicated into the build_source_tests submodule because the parent
`tests` module's helpers are private — duplication is two ~20-line
fns and avoids a #[cfg(test)] visibility bump on the helpers.
Coverage delta:
psysonic-analysis: 54.2% -> 69.5% (+15.3pp from compute.rs WAV E2E)
psysonic-audio: 25.0% -> 35.1% (+10.1pp, gate C ✓)
WORKSPACE: 30.0% -> 36.3% (+6.3pp)
All four coverage gates now closed:
A ✓ (bootstrap)
B ✓ (syncfs 44.3% + analysis 69.5%, target ≥40%)
C ✓ (audio 35.1%, target ≥35%)
D ✓ (workspace 36.3%, target ≥30%)
Workspace test count: 267 -> 294. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-2.2c): extract ProgressEmitter trait + spawn_progress_task tests
Sprint 2.2c of the Rust test rollout — the deferred follow-up after
gate C closed. Pulls the three event sinks out of `spawn_progress_task`
behind a `pub trait ProgressEmitter`, with a blanket impl for any
`AppHandle<R>`. Production call sites at `commands.rs:392` and
`radio_commands.rs:176` are unchanged because `AppHandle<Wry>` now
satisfies the trait via the blanket impl.
`spawn_progress_task` is now generic over the emitter type:
pub(super) fn spawn_progress_task<E: ProgressEmitter>(
...
emitter: E,
...
)
Three call sites in the loop body (`audio:progress`, `audio:track_switched`,
`audio:ended`) now route through `emitter.emit_*` instead of `app.emit(...)`.
Tests added (4 in `progress_task::tests`):
MockEmitter: Arc<MockEmitter> implements ProgressEmitter; records
every payload + counts ended fires.
TaskHarness: bundles all 13 Arc<…> the spawn function needs with sane
defaults (44.1 kHz, stereo, 120 s duration_secs).
- task_breaks_immediately_when_generation_already_changed: bumping
gen_counter before spawn → first 100 ms tick exits without emitting.
- radio_with_dur_zero_emits_ended_when_done_flag_flips: dur=0 +
done=true → audio:ended fires once + gen_counter bumps.
- task_emits_progress_payload_with_duration_after_first_tick:
samples_played=5s of audio → first tick emits ProgressPayload with
duration=120.0 and current_time in [0, 120].
- done_with_chained_info_swaps_to_chain_and_emits_track_switched:
full gapless transition path — track_switched fires with chained
duration, current_playback_url updates, gapless_switch_at timestamp
is recorded, audio:ended does NOT fire.
Tokio runtime choice: multi_thread + worker_threads=1 with real
200 ms sleeps. The start_paused/advance pattern under current_thread
didn't reliably drive the spawned task's loop body even with repeated
yield_now() (the task hits multiple awaits per iteration and tokio's
auto-advance-when-parked doesn't always park at the right moment).
Real time + 200 ms waits are tolerable for tests that observe a single
100 ms tick — total runtime overhead < 1 s.
Cargo.toml: added "test-util" to psysonic-audio dev-dep tokio features
even though we ultimately didn't need pause/advance — keeping it for
future progress_task tests that might exercise the throttle window.
Coverage delta:
psysonic-audio: 35.1% -> 38.6% (+3.5pp; comfortable margin on gate C)
WORKSPACE: 36.3% -> 37.7%
All four gates remain green. Workspace test count: 294 -> 298.
cargo clippy --workspace --all-targets -- -D warnings clean.
* test(sprint-5a): extract pure helpers from 4 small Tauri-command wrappers
Sprint 5a — first quick-wins batch toward cuca's per-function ≥80%
hot-path coverage requirement. Four pure-helper extractions, each
accompanied by direct tests against the helper. Wrappers shrink to
2-5 line shims that resolve State + delegate.
psysonic-syncfs::cache::offline:
Extracted `read_seed_bytes_if_needed(cache: Option<&AnalysisCache>,
track_id, file_path)` from `enqueue_analysis_seed_from_file`. The
AppHandle-bound `enqueue_analysis_seed` call stays in the wrapper.
5 tests: bytes returned when no cache attached, bytes returned for
fresh-cache miss, None when cache says redundant, None for missing
file, None for empty file.
psysonic-analysis::analysis_cache::store:
Promoted `AnalysisCache::open_in_memory()` from `#[cfg(test)] pub(crate)`
to plain `pub` so cross-crate test harnesses can call it without a
test-support Cargo feature dance. Production never calls it.
Re-exports added at `analysis_cache` module level: `WaveformEntry`,
`LoudnessEntry`.
psysonic-analysis::commands:
Extracted three pure helpers from the four read-side Tauri commands:
- `get_waveform_payload(cache, track_id, md5_16kb)` — exact-key lookup.
- `get_waveform_payload_for_track(cache, track_id)` — id-variant lookup.
- `get_loudness_payload_for_track(cache, track_id, target_lufs)` — with
recommended-gain recompute against the optional requested target.
Plus `impl From<WaveformEntry> for WaveformCachePayload`. Wrappers
log + delegate.
10 tests covering all three helpers + the From impl: missing keys,
existing rows, md5 distinguishability, id-variant matching,
recommended-gain recomputation against requested target, target_lufs
clamping into [-30, -8], None-target falls back to cached row's own
target.
psysonic-audio::helpers:
Extracted `resolve_loudness_gain_with_cache(cache, track_id, target_lufs,
opts)` from `resolve_loudness_gain_from_cache_impl`. The latter now
resolves track_id + cache via AppHandle, then delegates.
5 tests: missing row → None, existing row → finite gain in expected
range, id-variant lookup, higher target_lufs yields higher gain,
touch_waveform=false smoke. (NaN-roundtrip through SQLite is platform-
dependent — the .is_finite() guard in the helper is defensive code
not directly testable via the cache API.)
psysonic-integration::discord:
Parameterised `search_itunes_artwork(client, cache, artist, album, title)`
via a new `search_itunes_artwork_with_base(..., base_url)` that the
wrapper calls with the new `ITUNES_SEARCH_URL` constant. Lets tests
redirect at a wiremock instance.
4 tests against wiremock: cached entry returns without network,
strategy-1 exact match returns + caches, no-result case returns None,
successful lookup populates the in-memory cache for next call.
Coverage delta:
psysonic-analysis: 69.5% -> 73.4%
psysonic-syncfs: 44.3% -> 47.1%
psysonic-audio: 38.6% -> 39.9%
psysonic-integration: 39.1% -> 46.2%
WORKSPACE: 37.7% -> 40.6%
Workspace test count: 298 -> 322. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-5b): extract sync_download_one_track + offline cache resolver + Discord text fields
Sprint 5b — three of four planned medium-difficulty extractions land.
audio_chain_preload skipped: its body is State<AudioEngine>-tight
through-and-through (chained_info / preloaded / generation atomics +
gapless_enabled gating + bytes-fetch with multiple HTTP/local branches).
Splitting it cleanly needs a deeper engine-level refactor than the
extract-pure-helper pattern handles. Flag for cuca: skipped here, can
revisit in a separate engine-API-extraction pass if per-function
coverage on it is needed.
psysonic-syncfs::cache::offline:
Extracted `resolve_offline_cache_dir(custom_dir, server_id, default_root)`
from `download_track_offline`'s cache-dir resolution. Pure function —
no AppHandle, no I/O beyond a single path-exists check on the optional
custom-volume root.
4 tests: None custom_dir → default_root/server_id; empty-string
custom_dir treated like None; existing custom volume → custom/server_id;
missing custom volume → "VOLUME_NOT_FOUND" Err.
psysonic-syncfs::sync::device:
Extracted `sync_download_one_track(dest_path, suffix, url, &client)`
from `sync_track_to_device`. Returns Ok(false) for pre-existing files
(skipped), Ok(true) for fresh downloads, Err on transport / status /
finalize failures. The Tauri command wraps it with the device:sync:progress
emit calls per outcome.
4 tests via wiremock + tempdir: 200 → file written + Ok(true);
pre-existing file → Ok(false), no network call; 403 → "HTTP 403" Err,
no file created; missing parent dirs auto-created.
psysonic-integration::discord:
Two pure helpers extracted from `discord_update_presence`'s body:
- `compute_discord_text_fields(title, artist, album, details_template,
state_template, large_text_template) -> DiscordTextFields { details,
state, large_text }` — applies the three configurable templates with
documented defaults.
- `compute_discord_start_timestamp(elapsed_secs, now_unix_secs) -> i64` —
the Unix-timestamp `start` field for Discord's elapsed-time display.
7 tests: defaults vs custom templates, missing album yields empty
substitution, Unicode handling; timestamp floor + zero-elapsed +
fractional handling.
Coverage delta:
psysonic-syncfs: 47.1% -> 50.8%
psysonic-integration: 46.2% -> 48.3%
WORKSPACE: 40.6% -> 41.6%
Workspace test count: 322 -> 337. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-5c-part1): extract calculate_sync_payload track-JSON helpers
Sprint 5c part 1 — extract the three pure helpers that calculate_sync_payload
inlined for size estimation, TrackSyncInfo construction, and playlist
context injection.
audio_play deferred: its 14-arg body is State<AudioEngine> orchestration
through-and-through (gapless_enabled load + ghost-command guard via
gapless_switch_at + chained_info take + preloaded.lock + generation
fetch + sink + samples_played + ...). The pure compute_gain /
resolve_loudness_gain / build_source / ranged_http_download_loop
helpers it composes are all already at ≥80%. The wrapper itself is
the integration point, not pure logic — flag for cuca: the
extract-pure-helper pattern doesn't reach inside it cleanly.
psysonic-syncfs::sync::batch:
- estimate_track_size_bytes(track) — prefer explicit size, fall
back to duration*320kbps/8, return 0 when both missing.
- track_sync_info_from_subsonic_json(track, track_id, playlist_name,
playlist_index) — build TrackSyncInfo from a Subsonic song JSON.
albumArtist falls back to artist when missing or whitespace-only.
Default suffix = "mp3".
- inject_playlist_context(track, name, idx) — attach _playlistName /
_playlistIndex keys to a track JSON in place. No-op when both args
are None or the value isn't an object.
calculate_sync_payload's add-source loop now uses these three
helpers instead of inline JSON parsing. Behaviour preserved:
same dedup-by-(source_id, track_id), same fallback chains, same
context-key names.
Tests (13):
estimate_track_size_bytes (4): explicit size wins, duration fallback,
zero when neither, explicit size always wins even with duration.
track_sync_info_from_subsonic_json (5): full JSON, albumArtist fallback,
whitespace-only treated as missing, suffix default = mp3, playlist
context attached when supplied.
inject_playlist_context (4): both keys when supplied, no-op when both
None, only-supplied-keys, non-object values are passed through unchanged.
Coverage delta:
psysonic-syncfs: 50.8% -> 55.2% (+4.4pp from inline-extraction)
WORKSPACE: 41.6% -> 42.4%
Workspace test count: 337 -> 350. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-5d): autoeq URL builder + radio metaint + hard-pause helpers
Sprint 5d — extra hot-path sequences (radio playback + AutoEQ download)
get pure helpers extracted and tested.
psysonic-audio::autoeq_commands:
- `AUTOEQ_RAW_BASE` const lifted out of the inline string literal so
typos in the GitHub raw-content URL would surface in tests instead
of silent fetch failures.
- `autoeq_profile_url_candidates(base, source, form, name, rig?)`
extracted from `autoeq_fetch_profile`. Pure URL builder. Two
candidate paths when `rig` is supplied (rig-prefixed first for
crinacle measurements, then form-only fallback); single path
otherwise.
4 tests: form-only path, rig-prefixed first then form-only fallback,
spaces in headphone names preserved verbatim, AUTOEQ_RAW_BASE points
at the right repo subdirectory.
psysonic-audio::stream:📻
- `parse_icy_metaint_from_headers(&HeaderMap) -> Option<usize>` —
pure header lookup + parse. Returns None for absent / non-ASCII /
non-numeric values. Wired into `radio_download_task`.
- `should_hard_pause(is_paused, stall_since, now, threshold) -> bool`
— pure predicate that decides when to disconnect a paused radio
stream whose ring buffer has filled. Wired into the hard-pause
branch (was inline conditional before).
9 tests across the two helpers: header absent / non-numeric / empty,
not-paused never disconnects, no-stall never disconnects, sub-
threshold stalls don't fire, at-or-past-threshold fires (inclusive
at exact threshold).
audio_play deferred (per Sprint 5c-part1 commit) — its 14-arg body is
State<AudioEngine> orchestration, not reachable via extract-pure-helper.
Coverage delta:
psysonic-audio: 39.9% -> 41.5% (+1.6pp from radio + autoeq)
WORKSPACE: 42.4% -> 43.0%
Workspace test count: 350 -> 363. cargo clippy --workspace --all-targets
-- -D warnings stays clean.
* test(sprint-5e): add hot-path function coverage soft gate
Sprint 5e — last piece of cuca's per-function ≥80% requirement.
Adds a soft CI gate that warns (but doesn't fail) when a function
listed in `.github/hot-path-functions.txt` is below 80% region
coverage.
.github/hot-path-functions.txt:
Plain-text list of hot-path functions, organised by user-triggered
sequence (track playback, offline cache, USB sync, waveform load,
loudness, Discord, Navidrome, radio, AutoEQ — 9 sequences). Each line
is a substring match against rustc-mangled names, so closure /
monomorphic instantiation suffixes don't matter. Comments via `#`.
scripts/check-hot-path-coverage.sh:
Reads `target/llvm-cov/cov.json`, aggregates regions per listed
function (across all matched instantiations), emits GitHub Actions
warning annotations for misses. Exit code stays 0 — soft gate. Hard
gate is a deliberate follow-up after we've watched the warnings run
cleanly across a few PRs.
Requires jq + awk. Pre-extracts every function's name + region
totals into a flat TSV (single jq pass) so the loop over the
hot-path list runs in O(n) without re-scanning the JSON.
.github/workflows/rust-tests.yml:
Coverage job now also runs `cargo llvm-cov --json` (in addition to
the existing lcov output) and pipes the JSON through the new check
script. Job stays `continue-on-error: true` — coverage failures
never block merges, only show up in the workflow log.
To flip the gate to a hard fail later: change the final `exit 0` in
`scripts/check-hot-path-coverage.sh` to `exit ${BELOW}` (or `exit 1`
when `BELOW > 0`). Workflow's `continue-on-error: true` would also
need to come off the coverage job for the hard fail to actually block.
No code changes — pure tooling addition. cargo test + clippy
unchanged, all 363 tests still passing.
* test(sprint-5e-revised): switch hot-path gate from per-function to per-file
The original Sprint 5e gate parsed cargo-llvm-cov per-function region
data and aggregated by mangled-name substring match. That metric turned
out unreliable for our codebase:
1. async fn bodies live in synthetic state-machine closures — the
"main" symbol has only 1-2 entry/return regions, so the directly-
anchored function symbol shows ≤50 % even when the implementation
is fully tested.
2. Generic functions (e.g. `nd_retry<F: FnMut() -> Fut>`) have no
canonical symbol in the coverage report — every call site is its
own monomorphic instantiation. Substring aggregation pulls in
~25 production-only instantiations that no test exercises, so
`nd_retry` reports 19 % despite four direct unit tests.
3. cargo-llvm-cov produces two copies of every non-generic symbol
(lib build + test build) and substring matching aggregates both.
Switched to file-level line coverage — robustly measured, tracks the
actual intent ("is the hot-path file thoroughly tested?"), no symbol-
mangling pitfalls.
.github/hot-path-files.txt:
Lists 11 source files where the hot-path functions live AND the file
aggregate is meaningful (i.e. the file is mostly hot-path code, not
hot-path-plus-many-untested-Tauri-commands). Files with mixed content
(sync/batch.rs, navidrome/queries.rs, remote.rs, etc.) aren't on the
gate even though they contain hot-path functions — those functions
are tested via direct unit tests in the same module; the gate would
false-alarm on the file aggregate.
scripts/check-hot-path-coverage.sh:
Reads `target/llvm-cov/cov.json`, looks up `data[0].files[].summary.
lines.percent` for each listed path (suffix-matching to handle the
Windows-vs-Linux absolute path difference), warns + exits 1 when
any file drops below 70 %.
Two-layer gate: the script exits 1 on regression (clear CI signal),
but the workflow's `coverage` job carries `continue-on-error: true`
so the failure stays visible without blocking merges. Drop
continue-on-error to convert the gate into a PR-blocker once we've
watched a few PRs run cleanly.
Verified locally: all 11 listed files clear 70 %.
fs_utils.rs 95.7%
offline.rs 79.9%
file_transfer.rs 96.0%
store.rs 91.0%
compute.rs 85.7%
decode.rs 73.1%
stream/icy.rs 100.0%
progress_task.rs 90.1%
ipc.rs 86.5%
discord.rs 79.6%
navidrome/client.rs 97.4%
Removed the now-superseded `.github/hot-path-functions.txt`. Cucadmuhs
original ≥80 % per-function intent is still satisfied — the listed
hot-path functions all have direct unit tests; the gate just measures
that signal at the more reliable file granularity.
cargo test + clippy unchanged, 363 tests still passing.
* style: fix needless_return in log_timestamp_local
rustc 1.95 clippy flags the trailing 'return' as needless. Drop the
keyword to satisfy '-D warnings' on CI.
* style: satisfy rustc 1.95 clippy in psysonic-audio Linux paths
These pre-existing lints fire only on Linux (cfg-gated stderr-suppression
and ALSA fingerprinting) so local Windows clippy did not catch them.
- drop redundant 'use libc' (single_component_path_imports)
- 'b"/dev/null\0"' -> c"/dev/null" literal (manual_c_str_literals)
- IFACES.iter().any(|&i| i == s) -> IFACES.contains(&s) (manual_contains)
* style: fix two more rustc 1.95 clippy errors in Linux paths
- perf.rs: needless_return on PerformanceCpuSnapshot tail
- logging.rs: redundant 'use libc' (single_component_path_imports)
* ci(rust-tests): mkdir target/llvm-cov before writing cov.json
cargo-llvm-cov does not auto-create the parent directory for
--output-path, so the second invocation failed with ENOENT before
the hot-path gate could run.
|
||
|
|
cdd7cb192d |
fix(analysis): map waveform bins to decoded length, not inflated n_frames
Container-reported frame counts can exceed decoded samples on some VBR or badly tagged files; using max() squashed energy into the leading bins. |
||
|
|
308eb36f05 |
feat(analysis): re-analyze waveform when clearing loudness cache
Add analysis_delete_waveform_for_track, invoke it from loudness reseed, clear waveformBins in the UI, and extend queue strings for tooltips/toast. |
||
|
|
7a0dd93f3e |
fix(refactor): cfg-gate two items so macOS build is warning-clean (#530)
Mac smoke build surfaced 5 dead-code / unused-import warnings, all cfg-leaks of items that are conditionally compiled on Windows + Linux: - `psysonic-audio::power_resume` is consumed by `power_notify_win` and `power_notify_linux` only — `register_post_sleep_audio_recovery` intentionally falls through to a no-op on macOS (the generic device watcher covers the resume case there). Gate the module declaration to `#[cfg(any(target_os = "windows", target_os = "linux"))]`. - `lib_commands::ui::build_mini_player_window` is re-exported for the Windows-only pre-create path in `lib.rs:setup` (other platforms create the mini-player webview lazily on first invoke). Gate the re-export to `#[cfg(target_os = "windows")]` so non-Windows builds don't warn on an unused import. Both are non-functional — the items themselves are already correctly scoped via cfg in their consumers; only the declarations / re-exports were missing the matching gate. |
||
|
|
37c19237f3 |
fix(refactor): restore Windows build on backend-pilot
- Re-export `build_mini_player_window` from `lib_commands::ui` so the Windows-only pre-create call in `lib.rs` resolves through the `use lib_commands::*` glob (was lost during the M5 split). - Gate the `is_tiling_wm` import in `ui::mini` and its re-export in `lib_commands::sync` behind `cfg(target_os = "linux")`, the only platform that actually consults it. |
||
|
|
97f06459f3 |
refactor: move analysis admin commands into psysonic-analysis (M6/7)
Reframed M6 from "extract psysonic-commands" to "place each domain's
Tauri commands in its own domain crate" — the original psysonic-commands
proposal would have been a thin shell with no clear domain ownership
because each prior milestone already kept its own commands inline:
audio_*_commands in psysonic-audio
cache/sync commands in psysonic-syncfs
navidrome/discord/etc in psysonic-integration
The leftover analysis admin commands (7 of them) logically belong to
the analysis domain. So they move there:
src/lib_commands/app_api/analysis.rs →
crates/psysonic-analysis/src/commands.rs
WaveformCachePayload + LoudnessCachePayload moved out of top-crate
lib.rs into commands.rs
PlaybackQueryHandle gets a second closure (`should_defer_backfill`) so
analysis_enqueue_seed_from_url can ask "is a ranged playback already
going to seed this track?" without depending on psysonic-audio.
Top crate keeps the shell-flavored commands (window/tray/mini-player,
greet/exit_app, mpris/global-shortcuts/check_dir_accessible, perf,
cli_bridge) for M7 to clean up.
Behaviour preserving. Cargo check + clippy --workspace clean.
|
||
|
|
98d8ea6353 |
refactor: extract psysonic-integration crate (M5/7)
Moves all outbound external-service bridges out of the top crate into a
new psysonic-integration crate.
crates/psysonic-integration/
src/discord.rs Rich Presence + iTunes artwork
src/navidrome/{client,covers,...} Native REST API admin (5 modules)
src/remote.rs radio-browser, last.fm, ICY meta,
generic CORS proxy (fetch_url_bytes
/ fetch_json_url / resolve_stream_url)
src/bandsintown.rs events for an artist
src/lib.rs module declarations + macro re-exports
Top crate keeps `crate::discord` working via
`pub use psysonic_integration::discord;`.
invoke_handler! in lib.rs uses full deepest paths
(`psysonic_integration::navidrome::users::nd_list_users`, etc.) so
Tauri's `__cmd__*` magic macros resolve at the right module — same
pattern audio + syncfs already use.
Cross-crate refs migrated:
crate::subsonic_wire_user_agent → psysonic_core::user_agent::*
pub(crate) → pub (cross-crate visibility)
Behaviour preserving. Cargo check + clippy --workspace clean.
|
||
|
|
9417d522f3 |
refactor: extract psysonic-syncfs crate (M4/7)
Moves all on-disk cache + device-sync code out of the top crate:
crates/psysonic-syncfs/ new lib crate
src/cache/{offline,hot,downloads,fs_utils} unchanged behaviour
src/sync/{batch,device} (no tray.rs — that's UI)
src/file_transfer.rs shared HTTP helpers
src/lib.rs + DownloadSemaphore type
+ sync_cancel_flags fn
The shell crate keeps `lib_commands/sync/tray.rs` (OS tray icon — UI
concern, will move to shell-tauri at M7) but drops the rest of
`lib_commands/cache/` and the syncfs sibling files.
Tauri command quirk surfaced and resolved: `#[tauri::command]` puts its
`__cmd__*` and `__tauri_command_name_*` helper macros at the *exact*
module of the function, and `pub use` doesn't carry them across module
boundaries. invoke_handler! in lib.rs now references each syncfs
command via its full deepest path
(`psysonic_syncfs::cache::offline::download_track_offline`, etc.) so
Tauri's macros resolve at the right scope. Same approach the audio
crate already uses (`audio::commands::audio_play`).
Cross-crate ref migrations applied via batch sed:
crate::audio::* → psysonic_audio::*
crate::analysis_runtime::* → psysonic_analysis::analysis_runtime::*
crate::analysis_cache::* → psysonic_analysis::analysis_cache::*
crate::subsonic_wire_user_agent → psysonic_core::user_agent::*
super::super::file_transfer:: → crate::file_transfer::
Behaviour preserving. Cargo check + clippy --workspace clean.
|
||
|
|
41e75663f1 |
refactor: extract psysonic-audio crate (M3/7)
Moves all audio playback code (Symphonia decode, rodio output, HTTP
streaming, gapless, previews, and the seven stream/ source-type
submodules from the prior split) out of the top crate into a new
psysonic-audio crate.
crates/psysonic-audio/ new lib crate, depends on
psysonic-core + psysonic-analysis
src/{engine,helpers,decode,…}.rs flattened layout (no more
extra audio/ namespace level)
src/stream/ seven submodules from M0
src/lib.rs re-exports macros from
psysonic-core and the public
API surface
The audio↔analysis edges identified in the dep survey are now real
crate deps (audio depends on analysis directly: AnalysisCache reads,
recommended_gain_for_target, submit_analysis_cpu_seed). Only the
analysis→audio back-edge goes through the PlaybackQueryHandle port
registered in M2.
Cross-crate ref migrations applied via batch sed:
crate::audio::* → crate::* (intra-crate)
crate::analysis_cache::* → psysonic_analysis::analysis_cache::*
crate::submit_analysis_cpu_seed → psysonic_analysis::analysis_runtime::*
crate::subsonic_wire_user_agent → psysonic_core::user_agent::*
Top crate keeps `crate::audio::*` paths working via
`pub use psysonic_audio as audio;` — lib_commands/cli callers untouched.
`stop_audio_engine` (mac process-exit cleanup) moved into the audio
crate as `pub fn stop_audio_engine` since it reaches AudioEngine
internals; tray.rs now re-exports the moved fn.
Two small visibility promotions in engine.rs:
pub(crate) fn analysis_track_id_is_current_playback → pub
pub(crate) fn ranged_loudness_backfill_should_defer → pub
Behaviour preserving. Cargo check + clippy --workspace clean.
|
||
|
|
ff456dd823 |
refactor: extract psysonic-analysis crate (M2/7)
Moves analysis_cache + analysis_runtime out of the top crate into a new
psysonic-analysis crate, plus the runtime user-agent facade into
psysonic-core. The audio↔analysis dependency cycle is broken via a
PlaybackQueryHandle port registered as Tauri State.
crates/psysonic-analysis/ new lib crate
src/analysis_cache/{mod,store,compute} unchanged behaviour
src/analysis_runtime.rs + enqueue_analysis_seed (was
in lib_commands/cache/offline)
crates/psysonic-core/src/
user_agent.rs subsonic_wire_user_agent +
runtime/default helpers
(was in top lib.rs)
ports.rs PlaybackQueryHandle: closure
wrapper, not Arc<dyn Trait>,
so existing State<AudioEngine>
callsites stay unchanged
The shell setup hook registers the real PlaybackQueryHandle once the
AppHandle is available; the closure captures it and re-resolves
AudioEngine via try_state at each call.
Top crate keeps `crate::analysis_cache`, `crate::analysis_runtime`,
`crate::subsonic_wire_user_agent`, and `crate::submit_analysis_cpu_seed`
working via re-exports — no audio/lib_commands callsite needed editing.
Behaviour preserving. Cargo check + clippy --workspace clean (only
pre-existing warnings carry over).
|
||
|
|
7718ac3ee5 |
refactor: introduce cargo workspace + psysonic-core crate (M1/7)
First milestone of the workspace crate split. Sets up the workspace
skeleton and extracts shared logging + cross-crate port traits into a
new psysonic-core crate.
src-tauri/Cargo.toml now defines [workspace]; top package
becomes the workspace root.
crates/psysonic-core/ new lib crate, no Tauri-handler code:
src/logging.rs full logging facade (was src/logging.rs)
src/ports.rs PlaybackQuery + AnalysisOrchestrator
trait declarations (no impls yet)
The top crate re-exports `psysonic_core::logging` and the
`app_eprintln!` / `app_deprintln!` macros so every existing
`crate::logging::*` and `crate::app_eprintln!` callsite keeps working
unchanged.
Port traits intentionally take `tauri::AppHandle` — psysonic-core is a
workspace-internal crate, so depending on Tauri here is a feature, not
a leak. Implementers will register themselves as
`Arc<dyn PlaybackQuery>` / `Arc<dyn AnalysisOrchestrator>` Tauri State
in M2/M3.
Behaviour preserving. Cargo check + clippy --workspace clean.
|
||
|
|
9455879044 |
refactor(audio): split stream.rs into stream/ submodules
Pure file-move refactor: 1000-LOC stream.rs → stream/ directory with
seven cohesive submodules and explicit pub(crate) re-exports:
icy.rs (109) ICY metadata state machine + parser
reader.rs (110) AudioStreamReader (ringbuf → Read shim)
local_file.rs (32) LocalFileSource (psysonic-local://)
ranged_http.rs (382) RangedHttpSource + ranged_download_task
radio.rs (187) RadioLiveState + radio_download_task
track_stream.rs (182) track_download_task (one-shot)
mod.rs (48) re-exports + shared tuning constants
Source-type lifecycles are now isolated: each MediaSource impl lives
next to its download task. External callers (radio_commands,
transport_commands, play_input, engine, helpers) keep their existing
`super::stream::{...}` paths via the mod.rs re-exports — no caller
edits required.
Behaviour preserving. Cargo check + clippy clean (only the pre-existing
"too many arguments" warning on track_download_task carries over).
|
||
|
|
0992113269 |
fix(audio): suppress audio:error toast for superseded plays
Rapid skipping while a track is in initial preparation produced a
misleading "Couldn't play track — skipping" toast for the abandoned
track. Sequence: audio_play(A) reaches build_source_from_play_input;
user skips → audio_play(B) bumps gen; A's RangedHttpSource::read sees
the gen mismatch and returns Ok(0); Symphonia's probe interprets Ok(0)
as EOF → "ranged-stream: format probe failed: end of stream";
audio_play(A)'s map_err emits audio:error unconditionally → toast
appears even though playback already moved on.
Gate the emit on a generation check: if the global generation has
already moved past this play's gen, the failure is supersedion, not a
real codec error — log it but do not surface the toast.
Pre-existing bug, identical code path on main (commands.rs:600). Noticed
on refactor/backend-pilot during cucadmuh's skip-test session because
the live-test pattern hit the narrow probe-window race repeatedly.
The diagnostic logs added in the previous commit should make the next
occurrence's cause visible regardless of whether the toast fires.
Frontend impact:
- handleAudioError no longer fires on supersedion → no toast, no
setState({ isPlaying: false }), no queued next() call.
- The audio_play IPC promise still rejects with the same error string
(preserved as the .map_err return value); the JS .catch is already
generation-guarded in playerStore so this is a no-op there.
|
||
|
|
ff4271181c |
diag(audio): trace ranged-stream supersedion + abort paths
Skipping tracks while a RangedHttpSource is still in initial probe leaves no diagnostic trail today: RangedHttpSource::read returns Ok(0) on gen-mismatch (silent), ranged_download_task drops out the same way, and the `dl done` summary only fires under app_deprintln so release users never see partial/aborted downloads either. Add focused logs at the bail points without changing behaviour: - RangedHttpSource::read — log on each Ok(0) return that isn't the normal pos>=total_size EOF (superseded before/during wait, download done with no bytes ahead of cursor). Symphonia stops reading after Ok(0), so at most one log per source, no spam. - ranged_download_task — log on the gen-mismatch bail with track id + gen transition + downloaded/total bytes so we can tell "user skipped while downloading" apart from "stream stalled". - track_download_task — same gen-mismatch log for the legacy non-seekable path (consistency with ranged). - dl-done summary — split into release-visible `[stream] ranged dl ABORTED: …` (downloaded < total_size) vs the existing dev-only `[stream] dl done` for full completions. - audio_play — log on both supersedion-bail points around select_play_input so a silently-ending audio_play call leaves a trace. No semantics changed: every existing return / store / branch is intact. Pure additive logging to make the next reproduction of cucadmuh's ranged-stream toast diagnosable. |
||
|
|
176382e0b6 |
refactor(lib_commands): drop super::* + glob in app_api + lib_commands root
Hotspot G final slice — replace cascade-imports in app_api/{core,
analysis,integration,remote,platform}.rs with explicit per-symbol
imports, and convert app_api/mod.rs from broad `pub(crate) use foo::*`
to a per-command list of every Tauri handler.
The remaining glob re-exports live only in lib_commands/mod.rs itself
— and those are deliberate: they flatten the explicitly-listed Tauri
commands one more level so lib.rs's `use lib_commands::*` keeps the
invoke_handler shape unchanged. Each level above is now explicit.
file_transfer.rs's `super::subsonic_wire_user_agent()` becomes
`crate::subsonic_wire_user_agent()` since the lib_commands cascade
no longer pulls lib.rs symbols into super scope.
`grep -rn 'use super::\*' src/lib_commands/` returns zero hits.
Behaviour-preserving.
|
||
|
|
1ff8bdd8c7 |
refactor(lib_commands): drop super::* + glob re-export in sync + lib.rs
Hotspot G next slice — replace cascade-imports in lib_commands/sync/
{device,batch,tray}.rs with explicit per-symbol imports + per-command
re-exports.
sync/mod.rs lists each Tauri command from device / batch / tray
explicitly, plus the three internal helpers consumed by lib.rs and
ui/mini.rs (is_tiling_wm, stop_audio_engine, try_build_tray_icon).
The previous broad `pub(crate) use device::*` etc. is gone.
Inside the .rs files: each `use super::*` is replaced with what the
file actually needs — `super::device::{TrackSyncInfo, ...}` for batch,
`crate::tray_runtime::{Tray*}` + `crate::audio` + `super::super::ui::
{PAUSE_RENDERING_JS, RESUME_RENDERING_JS}` for tray, etc.
Cleanup in lib.rs: drop the now-unused tauri menu/tray imports
(MenuBuilder/MenuItemBuilder/PredefinedMenuItem/TrayIcon*/MouseButton*)
— tray.rs owns those now. Drop `Ordering` (no longer used at lib.rs
scope). Drop `pub(crate) use file_transfer::*;` from
lib_commands/mod.rs (file_transfer is now imported by direct path
where needed: `super::super::file_transfer::*`).
|
||
|
|
409d8fa964 |
refactor(lib_commands): drop super::* + glob re-export in ui + cache
Hotspot G first slice — replace the cascade-import pattern in lib_commands/ui/ and lib_commands/cache/ with explicit per-symbol imports + per-command re-exports. ui/mod.rs no longer reaches `pub(crate) use mini::*; pub(crate) use bandsintown::*;` — instead it explicitly re-exports the 8 mini-player Tauri commands + the 3 internal helpers consumed by lib.rs + sync/tray.rs (PAUSE_RENDERING_JS, RESUME_RENDERING_JS, persist_mini_pos_throttled), and fetch_bandsintown_events. cache/mod.rs likewise lists each Tauri command from offline / hot / downloads explicitly. The only cross-crate "internal" re-export is `enqueue_analysis_seed` which analysis_runtime depends on. Inside the .rs files: every `use super::*` is replaced with the specific imports actually needed (super::offline::..., crate::audio, crate::analysis_runtime::..., tauri::Manager, etc.). Behaviour-preserving — no Tauri command name changed, no runtime behaviour shift. Just removes the leak-everything-everywhere import graph that cucadmuh's policy doc forbids. |
||
|
|
f43ab94cc1 |
refactor(app_api): split navidrome.rs into 5-way module directory
Convert app_api/navidrome.rs (655 LOC monolith) into navidrome/ with five focused submodules + a thin mod.rs: - client.rs (106 LOC) — auth + retry + http client (navidrome_token, NdLoginResult, nd_err, nd_retry, nd_http_client). Internal-only, not re-exported at crate scope. - covers.rs (107 LOC) — 4 multipart image-upload commands (upload_playlist_cover / upload_radio_cover / upload_artist_image / delete_radio_cover). - users.rs (138 LOC) — login + admin user CRUD (navidrome_login + nd_list/create/update/delete_user). - queries.rs (207 LOC) — songs, role-filtered artists/albums, libraries, per-user library assignment, absolute song path. Includes the nd_build_filters helper. - playlists.rs (120 LOC) — playlist CRUD with smart-rules payload passthrough (nd_list / create / update / get / delete _playlist). mod.rs is now ~28 LOC of declarations + per-module re-exports of the Tauri commands. The cascade `app_api/mod.rs` → `pub(crate) use navidrome::*` keeps lib.rs invoke_handler registrations unchanged. Behaviour-preserving — pure file moves with `super::client::*` imports where the auth/retry helpers are needed. |
||
|
|
7e8acd86d6 |
refactor(audio): extract sink swap + crossfade handoff into helper
Pull the atomic sink-swap block (~50 LOC) out of audio_play into play_input::swap_in_new_sink. The helper takes a SinkSwapInputs struct (sink, duration_secs, volume, gain_linear, fadeout trigger + samples, crossfade flag, measured fade seconds) and: 1. Locks state.current, atomically replaces sink + duration + seek/play timestamps + volume + replay-gain + fade-out handles, returns the old sink + its old fade-out handles. 2. If crossfade is on: stores total fade samples, flips trigger atomic on the old TriggeredFadeOut, parks the old sink in fading_out_sink, spawns a small task that drops it after `fade_secs + 0.5 s`. 3. If crossfade is off: stops the old sink immediately. Behaviour-preserving — same lock scope, same atomic ordering, same cleanup-task lifetime. audio/commands.rs: 600 → 563 LOC. With this the audio_play body has shrunk from the original ~760 LOC monolith to a top-level orchestration of named helper calls. |
||
|
|
ed92a77035 |
refactor(audio): lift PlayInput → BuiltSource dispatch into helper
Pull the 60-LOC match in audio_play that turned a PlayInput into a fully-wrapped rodio source out of audio_play and into play_input::build_source_from_play_input. Returns a small PlaybackSource struct holding both the BuiltSource and a `is_seekable` flag (only the Streaming variant is non-seekable). Behaviour preserved verbatim — same build_source / build_streaming_source calls, same target_rate=0 (no app-level resampling), same spawn_blocking decoder build for Seekable+Streaming, same error path (`audio:error` emit + propagate). audio/commands.rs: 642 → 600 LOC. The remaining audio_play body now reads top-to-bottom as: ghost guard → preview clear → gapless pre-chain check → bump generation → reuse-bytes prep → URL pin → format hint → **select_play_input** → gen check → loudness/RG via gain_inputs → crossfade prep → **build_source_from_play_input** → stream rate switching → sink construction + prefill → swap sink → progress task. |
||
|
|
8f976dc371 |
refactor(audio): unify loudness/replay-gain prep via TrackGainInputs
audio_play and audio_chain_preload were both reading the same engine state (target_lufs, norm_mode, pre_analysis_db) and resolving the loudness cache, then feeding it into compute_gain — but with subtly different intermediate steps. audio_play split the resolve+post-resolve into two operations so it could log the cache value; chain_preload used the bundled `loudness_gain_db_or_startup` wrapper. Lift the shared logic into `resolve_track_gain_inputs(state, app, url, logical_id, js_loudness_gain_db) -> TrackGainInputs` in helpers.rs. The struct returns target_lufs, norm_mode, the cache-loudness value (for logging), and the post-resolve effective loudness for compute_gain. Both call sites now use the same helper; behaviour-preserving. Drops the now-unused `loudness_gain_db_or_startup` (audio_chain_preload was its only caller). audio/commands.rs: 676 → 642 LOC. |
||
|
|
7a61d50c42 |
refactor(audio): extract source selection from audio_play
Pull the ~300 LOC URL → PlayInput dispatch out of audio_play into a new audio/play_input.rs: - PlayInput enum (Bytes / SeekableMedia / Streaming) - PlayInputContext struct holding the precomputed inputs (url, gen, duration_hint, format/cache hints, optional reused chained bytes) - select_play_input() async — handles all four branches: reused chained bytes, psysonic-local://, ranged HTTP with format sniff, and the legacy non-seekable streaming fallback - url_format_hint() — the conservative URL→extension allowlist helper, used to be inline in audio_play audio_play is now focused on the orchestration above the source layer: ghost-command guard, gapless pre-chain detection, bumping generation, loudness/replay-gain math, fade-in setup, building the actual rodio Source pipeline, sink swap with crossfade, spawning progress task. audio/commands.rs: 980 → 676 LOC. play_input.rs: 385 LOC. Behaviour preservation hinges on identical analysis-seed spawning, format-hint fallback chain, range-detection logic, and gen-cancel checks — all moved verbatim. Smoke test focus: psysonic-local playback, manual skip during ranged HTTP, format-hint-less servers (legacy fallback path), preload cache hit replay, manual skip onto pre-chained track. |
||
|
|
2b4014870c |
refactor(app_api): extract window + WebKitGTK platform tweaks
Lift set_window_decorations + linux_webkit_apply_smooth_scrolling + set_linux_webkit_smooth_scrolling out of app_api/core.rs into app_api/platform.rs (~49 LOC). These are platform-tweak Tauri commands (Linux WebKitGTK settings + Linux native-decoration toggle) — logically separate from runtime control, telemetry, cli bridge, or wire-UA setup. This is the "platform" slice from cucadmuh's plan for splitting core.rs's mixed concerns. Together with perf and cli_bridge, core.rs is now down to runtime control (greet, exit_app), logging (3 cmds), and UA setup — 56 LOC, focused. |
||
|
|
ee7c1de3d6 |
refactor(app_api): extract cli_bridge wrappers into own submodule
Lift the four cli_publish_* Tauri commands (player_snapshot, library_list, server_list, search_results) out of app_api/core.rs into app_api/cli_bridge.rs. Each is a thin pass-through to `crate::cli::write_*_response` — the renderer-side counterpart to the file-based IPC layer in cli/exchange.rs. This is the "cli-bridge" slice from cucadmuh's plan for splitting core.rs's mixed concerns. Together with the earlier perf split, core.rs is now down to runtime-control + platform + logging slices — one possible follow-up but each is small enough to leave as-is for now. app_api/core.rs: 122 → 99 LOC. |
||
|
|
b5ae0ccf28 |
refactor(app_api): extract perf telemetry into own submodule
Lift PerformanceCpuSnapshot struct + the Linux /proc/stat parsers (parse_proc_stat_line / read_total_jiffies / collect_proc_stats) + the performance_cpu_snapshot Tauri command (~110 LOC) out of app_api/core.rs into app_api/perf.rs. Self-contained CPU-usage telemetry; nothing else in app_api references the helpers. This is the "telemetry" slice cucadmuh's plan called out for splitting core.rs's mixed runtime/platform/snapshot concerns. Other slices (cli-bridge, runtime-control, platform window/scrolling) can follow. app_api/core.rs: 235 → 122 LOC. lib.rs registration unchanged — performance_cpu_snapshot is re-exported through `pub(crate) use perf::*` in app_api/mod.rs. |
||
|
|
3b5bd3f1cc |
refactor(audio): lift spawn_progress_task into own module
Move the per-generation progress + ended-detection task (~205 LOC) out of audio/commands.rs into audio/progress_task.rs. The task is now a sibling submodule that both audio_play (commands.rs) and audio_play_radio (radio_commands.rs) import as `super::progress_task::spawn_progress_task`, replacing the previous `super::commands::spawn_progress_task` cross-import that radio was using as a workaround. audio/commands.rs: 1185 → 977 LOC. Cleanup: dropped now-unused AtomicU32 and AudioCurrent imports from commands.rs. What's left in commands.rs is now just audio_play (~760 LOC) and audio_chain_preload (~195 LOC) — the playback orchestrator proper. Splitting those further is a bigger structural job than file moves. |
||
|
|
e9421e58e3 |
refactor(audio): extract audio_preload into preload_commands
Pull audio_preload (background fetch + analysis seed for the next track in the queue) out of audio/commands.rs into audio/preload_commands.rs (~67 LOC). It's a self-contained fetch-and-cache flow — distinct from audio_chain_preload (which constructs the gapless source chain) and audio_play (which starts playback) so it makes sense to live alongside them rather than inside the same file. audio/commands.rs: 1241 → 1185 LOC. lib.rs invoke_handler updated. |
||
|
|
0fae33f00b |
refactor(audio): extract transport commands into transport_commands
Pull audio_pause / audio_resume / audio_stop / audio_seek (~210 LOC) out of audio/commands.rs into audio/transport_commands.rs. They mutate state.current on an already-running sink and coordinate radio warm/cold resume — distinct concern from playback startup (audio_play / audio_chain_preload / audio_preload). audio/commands.rs: 1447 → 1240 LOC. Imports trimmed: TryLockError, radio_download_task, RADIO_BUF_CAPACITY are no longer pulled in here; they followed the transport block to its new home. lib.rs invoke_handler updated. The remaining commands.rs is now focused on the playback orchestration proper (track + chain preload + initial preload + the shared spawn_progress_task helper). |
||
|
|
17099d3aee |
refactor(audio): extract radio playback into radio_commands
Pull audio_play_radio (~165 LOC) out of audio/commands.rs into audio/radio_commands.rs. Live-radio playback differs from main track playback: no gapless chain, no seek, no replay-gain, no preload — collecting it on its own makes both modules easier to reason about. The shared spawn_progress_task helper (still used by audio_play / audio_chain_preload / audio_resume in commands.rs and now by radio) moves from private to `pub(super)` so radio_commands can call it. A follow-up could lift it into helpers.rs proper. audio/commands.rs: 1614 → 1447 LOC. lib.rs invoke_handler updated. Cleanup: dropped now-unused imports (`super::sources::*`, `RadioLiveState`, `RadioSharedFlags`) from commands.rs. |
||
|
|
e2ca581264 |
refactor(audio): extract audio-stage settings into mix_commands
Pull the six configuration setters out of audio/commands.rs into audio/mix_commands.rs (~170 LOC): - audio_set_volume (with replay-gain-aware ramp) - audio_update_replay_gain (resolves cache-backed loudness, computes gain, ramps sink, emits NormalizationStatePayload) - audio_set_eq (10-band gains + pre-gain) - audio_set_crossfade - audio_set_gapless - audio_set_normalization These are pure AudioEngine state mutations + (for normalization) an ipc emit. They don't drive playback; collecting them in one place makes commands.rs more focused on the playback orchestrators. lib.rs invoke_handler updated. audio/commands.rs: 1784 → 1614 LOC. |
||
|
|
e8643c4059 |
refactor(audio): extract AutoEQ proxy commands into own module
Pull autoeq_entries + autoeq_fetch_profile out of audio/commands.rs into audio/autoeq_commands.rs (~52 LOC). They proxy autoeq.app + GitHub raw content via Rust to bypass WebView CORS — pure HTTP-fetch flow with no playback state coupling, so they don't need to live next to the playback orchestrator. lib.rs invoke_handler updated to register them under `audio::autoeq_commands::*`. audio/commands.rs: 1830 → 1784 LOC. |
||
|
|
605021102f |
refactor(audio): peel device commands out of commands.rs
Hotspot J first slice: split the device-listing + device-selection commands out of audio/commands.rs (1912 LOC monolith) into a new audio/device_commands.rs (~95 LOC). Moved: - audio_canonicalize_selected_device - audio_list_devices_for_engine (kept pub for cli/exchange.rs callers) - audio_list_devices - audio_default_output_device_name - audio_set_device audio/mod.rs re-exports audio_default_output_device_name + audio_list_devices_for_engine from the new submodule. The four #[tauri::command] device handlers are registered in lib.rs run() under their new path `audio::device_commands::*`. audio/commands.rs goes from 1912 → 1830 LOC and drops its `use super::dev_io::*` since playback/radio/EQ don't touch device enumeration. Behaviour-preserving — same Tauri commands, same dev_io helpers, same selected_device + stream_handle mutations. |
||
|
|
3f46ab7c72 |
refactor(cli): extract Linux single-instance IPC into linux_forward submodule
Pull the Linux-only single-instance D-Bus stack out of cli/mod.rs into a new cli/linux_forward.rs: - tauri_identifier (reads identifier from embedded tauri.conf.json) - single_instance_bus_name + single_instance_object_path (D-Bus path derivation matching tauri-plugin-single-instance) - linux_bus_name_has_owner (zbus NameHasOwner wrapper) - linux_is_primary_instance_running (pub) - LinuxPlayerForwardResult enum (pub) - linux_try_forward_player_cli_secondary (pub) — the heavy lifter: forwards argv via D-Bus ExecuteCallback, then for list/search commands reads the response file and prints to stdout The whole submodule is `#[cfg(target_os = "linux")]` gated at the mod declaration so non-Linux builds don't see it at all (cleaner than the previous per-item gating). Public surface preserved: lib.rs and main.rs keep importing cli::linux_is_primary_instance_running and cli::LinuxPlayerForwardResult unchanged via the linux-only pub use re-export. cli/mod.rs: 696 → 550 LOC. Behaviour-preserving — same D-Bus protocol, same response-file polling, same stdout output strings. |
||
|
|
cc60152762 |
refactor(cli): extract response-file IPC into exchange submodule
Pull the JSON response-file layer out of cli/mod.rs into a new cli/exchange.rs: - cli_*_path helpers (snapshot, library, server, search, audio_device) — XDG_RUNTIME_DIR-aware path resolvers - write_cli_snapshot - write_library_cli_response, write_server_list_cli_response, write_search_cli_response, write_audio_device_cli_response - read_*_cli_response_blocking pollers (private to cli) - print_*_cli_stdout wrappers (private to cli; thin "JSON or human" switches around the presenter functions) Externally-visible names (cli::write_cli_snapshot, the four write_*_cli_response, the path helpers) preserved via `pub use exchange::*` re-export at the cli root. Internal-only functions demoted to `pub(super)`. cli/mod.rs: 895 → 696 LOC. Behaviour-preserving — same file paths, same atomic write-via-tempfile pattern, same poll intervals + ready predicates. |
||
|
|
89fa1217b3 |
refactor(cli): extract human-format presenters into own submodule
Pull the JSON→stdout formatter layer out of cli/mod.rs into a new
cli/presenters.rs:
- print_library_human, print_server_list_human, print_search_human
- print_info_human + its helpers sorted_kv, value_inline
- print_audio_devices_human (kept as `pub` and re-exported from mod.rs)
Each takes a `serde_json::Value` and writes to stdout. No mutation,
no IO besides println, no inward calls back into mod.rs. cli/mod.rs
imports them via `use presenters::{...}` so the existing call sites
(print_*_cli_stdout wrappers, run_info_and_exit) read unchanged.
cli/mod.rs: 1151 → 895 LOC. Behaviour-preserving — same output
strings, same column separators, same scope handling for search.
|
||
|
|
fff78c8f22 |
refactor(cli): convert cli.rs to module dir; extract parse layer
Convert the 1485-LOC cli.rs into a cli/ module directory and lift the parse-only layer into its own submodule. cli/parse.rs now owns: - the CliCommand / PlayerCliCmd / RepeatCliMode / SearchCliScope / MixCliMode enums - the CliActionRegistry parser (reads shortcutActions.ts at startup) - all wants_* flag helpers (--version, --info, --logs, --tail, --json, --quiet, --follow, logs_tail_lines) - parse_cli_command + parse_player_cli_at + parse_repeat_mode cli/mod.rs keeps the rest for now: print_*_human presenters, exchange write_/read_ functions, run_info_and_exit, run_tail_and_exit, Linux single-instance + IPC forwarding, describe_cli_command, emit_player_cli_cmd, handle_cli_on_primary_instance. Those are the next split candidates (presenters / exchange / linux_forward). Public surface preserved via `pub use parse::*;` so main.rs and lib.rs keep importing cli::wants_version, cli::parse_cli_command etc. unchanged. cli/mod.rs goes from 1485 → 1151 LOC. Behaviour-preserving — same parsing rules, same compile-time include of shortcutActions.ts, same registry-driven verb resolution. |
||
|
|
06f5c3e328 |
refactor(lib): extract tray state types into own module
Move tray-related state holders out of lib.rs into a new src-tauri/src/tray_runtime.rs: - TrayState / TrayTooltip type aliases - TrayPlaybackState newtype - TrayMenuItems / TrayMenuItemsState - TrayMenuLabels (+ Default impl) / TrayMenuLabelsState - tray_state_icon helper The actual tray builder + Tauri commands (try_build_tray_icon, set_tray_tooltip, set_tray_menu_labels, toggle_tray_icon) already lived in lib_commands/sync/tray.rs and continue to reach the types through the existing super::* re-export chain. lib.rs goes from 549 → 486 LOC. Behaviour-preserving — same managed state, same Tauri State<T> registrations in run(). |