mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
d9dfdf66e947cbaa0b878533debacc42fb390eb4
146 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> |
||
|
|
1a7a2a0bfc |
chore(servers): remove quick/full scan buttons from server cards (#843)
Drops the Quick/Full scan actions and all supporting logic — they are no longer needed. Removes the ServerScanActions component (incl. the unused compact variant), the subsonicScan API, the scanStore, and the app-root useScanPolling hook (no more background scan polling). Cleans up the .server-scan-* CSS and the settings.scan i18n block across all 9 locales. 440 deletions, no new code; tsc + bundle clean. |
||
|
|
bca45d5a80 |
feat(servers): scan actions + edit existing server profiles (#780)
* feat(server-scan): plumbing for triggering library scans
Adds `startScan` / `getScanStatus` against the Subsonic API
(`fullScan=true` is Navidrome's extension), a small per-server scan
store, and a global polling hook (2 s cadence) that emits a toast when
each scan finishes. Scans can run on any configured server, including
inactive ones, by reusing `apiForServer`.
UI surfaces follow in the next commit.
* feat(server-scan): expose Quick / Full Scan in switcher + settings cards
Adds a `ServerScanActions` component with two variants (compact for the
server-switcher dropdown, card for the Settings server cards) backed by
the scan store from the previous commit. Full Scan requires a second
click within 3 s to confirm, matching the playlist-delete pattern.
Status slot shows a spinner with running track count while scanning, a
green check when finished, and a red icon on error.
The switcher row is converted from a single button to a flex container
so per-server scan controls don't hijack the server-switch click.
i18n added across all 9 locales.
* fix(server-scan): reorder switcher row to check / name / scan actions
Moves the check / spinner slot from the right edge to the left so the
spinner pop-in on server switch doesn't sit next to the scan icons.
Removes the layout shift that briefly hovered the Quick scan button
when the row re-rendered.
* feat(servers): edit existing server profiles in Settings → Servers
* Pencil-button on each server card opens an inline edit form that
replaces the card (prefilled name / URL / username / password).
* `AddServerForm` reused with an `editingServer` prop — title flips to
"Edit Server", submit label to "Save", magic-string field hidden (the
edit scope is manual fields; magic-string remains an add-time invite
shortcut).
* Edit saves unconditionally — ping runs post-save as a status indicator
(analog to the existing Test button) instead of gating the save. Lets
users update a profile when the server is currently unreachable.
* Translations across all 9 locales (`editServer`, `editServerTitle`).
* fix(servers): submit Add/Edit Server form on Enter
Wrapped the form body in a real <form>, made the submit button
type="submit", marked Cancel as type="button" so Enter no longer
cancels. Add-Mode now also responds to Enter — same flow, consistent
across both modes.
* fix(servers): collapse card action buttons to icon-only on narrow screens
* Quick-Scan / Full-Scan / Test buttons in each server card hide their
text label below 1100px viewport via the .server-card-btn-label class
and a single media query in connection-indicator.css.
* Labels remain accessible via data-tooltip and aria-label so screen
readers + hover both keep working in the collapsed state.
* No content reflow above the breakpoint — pure additive CSS.
* fix(servers): include Use button in icon-only narrow-screen collapse
The Use ("Verwenden") button on inactive server cards lacked the
.server-card-btn-label wrapper, so its text stayed visible at narrow
viewports and pushed Edit/Delete off-screen. Added a Power icon and
wrapped the label so it collapses alongside the other action buttons.
* docs(changelog,credits): #780 server scan + edit
|
||
|
|
db98d30a78 |
fix(playback): cross-server browse, Lucky Mix, and Now Playing (#768)
* fix(playback): keep browsed server when queue plays elsewhere Lucky Mix on a non-playback server now clears the old queue and pins the active server before building. Now Playing metadata uses apiForServer against the queue server instead of forcing ensurePlaybackServerActive on every activeServerId change. * docs: note PR #768 in CHANGELOG and settings credits * fix(now-playing): gate AudioMuse similar artists on playback server Match getArtistInfoForServer credentials: when browsing another server while the queue plays elsewhere, similar-artist count follows the queue server's AudioMuse flag, not the browsed server. |
||
|
|
6e0f076f43 |
fix(album-card): per-artist click on multi-artist albums (#767)
* refactor(album): rename SubsonicAlbum.albumArtists to artists per OpenSubsonic spec
The OpenSubsonic AlbumID3 object exposes structured album-artist credits as
`artists` (not `albumArtists`); psysonic's internal type used the wrong name,
so `album.albumArtists` was always undefined on the Subsonic responses
Navidrome returns. `deriveAlbumHeaderArtistRefs` therefore never hit its
album-level branch — only the song-level fallback was carrying the multi-
artist split on the album-detail header.
- Rename the field on `SubsonicAlbum` and add the spec-defined `displayArtist`
string alongside it.
- Extract `deriveAlbumArtistRefs(album)` for the common case where only the
album object is available (cards, rails); `deriveAlbumHeaderArtistRefs`
now uses it after the song-level fallback. The song-level `albumArtists`
field is unchanged (the spec does name it that way on child songs).
- Tests cover both helpers and the legacy `artist` + `artistId` fallback.
* fix(album-card): per-artist click on multi-artist albums
The artist subtitle under an album card rendered the full `album.artist`
string ("Melvins • Napalm Death") as a single link that always navigated to
`album.artistId`. On the artist-detail page that id is the page's own
artist — so the click resolved to the URL the user was already on and the
router silently no-op'd, with no visible effect.
Render through the existing `OpenArtistRefInline` component instead, fed
from the structured `artists` array via `deriveAlbumArtistRefs`. Each
artist becomes its own ·-separated link; the single-artist fallback is the
same legacy `artist` + `artistId` pair as before, so the behaviour on
servers that don't expose the structured field is unchanged.
* docs(changelog): note PR #767 under Fixed in v1.46.0
|
||
|
|
6595c146a3 |
feat(album): show OpenSubsonic disc subtitles after the CD heading (#753)
* feat(album): show OpenSubsonic disc subtitles after the CD heading Multi-disc albums in OpenSubsonic / Navidrome carry a per-disc subtitle (`discTitles`) — e.g. "Sessions" on CD 3 of a deluxe edition. AlbumTrackList only rendered "CD N" and dropped the subtitle, so users couldn't tell two discs apart unless they read the track names. * `SubsonicAlbum.discTitles` typed; `getAlbum` forwards it as-is. * `AlbumTrackList` and `AlbumTrackListMobile` take a discTitleByNum map and render the subtitle in the disc separator after "CD N". * Heading bumped slightly (13 → 15 px, icon 16 → 18 px) so the disc separator stays legible next to the new subtitle. * docs(changelog): note disc subtitles after CD heading (#753) |
||
|
|
efd85ffde3 |
feat(tracklist): play count, last played, BPM columns + Song Info rows (#730)
* feat(tracklist): play count, last played, and BPM columns (#516) Adds three opt-in columns to Album / Playlist / Favorites tracklists, plus the same fields in the Song Info modal. Picks up Navidrome's existing `playCount` / `played` / `bpm` from the Subsonic response — no new API calls. - `SubsonicSong` gains `playCount`, `played`, `bpm` (already populated by Navidrome's Subsonic API, just unmapped in the TS model). - `albumTrackListHelpers.COLUMNS`, `PL_COLUMNS` (PlaylistDetail), and `FAV_COLUMNS` (Favorites) get the three new entries. Genre also added to the playlist column set for parity with the other two lists. - Render uses `.track-duration` (12px tabular, centered, muted) for the numeric stats and `.track-genre` (11px text, muted) for the relative last-played timestamp via the existing `formatLastSeen` helper. - Sort logic extended in `playlistDisplayedSongs`, `useAlbumDetailSort`, and `useFavoritesSongFiltering` for the three new keys. - `SongInfoModal` gets matching rows (BPM / Play count / Last played). - `useTracklistColumns.gridTemplate` / `gridMinWidth` fall back to the ColDef's `defaultWidth` when a visible column has no saved width (newly added column on an old prefs blob would otherwise emit `undefinedpx` and collapse the row layout until reset-to-defaults). - BPM cells skip the rendering when Navidrome returns 0 (default for untagged files) — show `—` instead of `0`. - i18n: `albumDetail.trackPlayCount / trackLastPlayed / trackBpm` and `songInfo.playCount / lastPlayed / bpm` in all 9 locales. Suggested by jbigginswyl (#516). * docs(changelog): tracklist Plays / Last played / BPM columns (#730) |
||
|
|
187170057d |
fix(build): static import ratings cache invalidate; raise chunk warning limit (#723)
Replace ineffective dynamic import of subsonicRatings from setRating (mix paths already statically load it). Set Vite chunkSizeWarningLimit to 1000 kB for desktop bundles. |
||
|
|
45e0e1206f |
fix(playback): pin queue playback to source server when browsing another library (#717)
* fix(playback): pin queue streams, cover art, and library links to queue server When the active server changes while a queue from another server is playing, keep streams and UI on queueServerId; switch back for artist/album links and queue or player-bar context menus. * fix(playback): switch to queue server when opening Now Playing Ensure active server matches queueServerId before Subsonic fetches on the Now Playing page, mobile player route, and queue info panel; scope caches by server id. * docs(credits): mention Now Playing in PR #717 contribution line * fix(playback): route scrobble and queue sync to queue server Address PR review: apiForServer for scrobble/now-playing/savePlayQueue, clear queueServerId on server removal, mini-player queueServerId sync, block cross-server enqueue with toast, and regression tests. |
||
|
|
e7431b94b8 |
feat(search): queue pasted share links from Live Search and mobile search
Implement share-link detection in search (track, queue, album, artist, composer): enqueue tracks/queues without interrupting playback; preview album/artist/composer without switching the active server; queue preview modal with scrollable track list. Based on community PR #551. Co-authored-by: Daniel Wagner <daniel.iuser@icloud.com> |
||
|
|
02fd828049 |
fix(mix): rating filter across mixes and Lucky Mix queue fill (#714)
* fix(mix): apply rating filter across mixes and fix Lucky Mix queue fill Invalidate entity rating cache on setRating and stop negative-caching unrated artists/albums so filters see fresh stars. Honor UI rating overrides, wire Instant Mix and CLI paths, fix Random Mix filter order, and align Lucky Mix progress with the real player queue length. * docs(changelog): document mix rating filter and Lucky Mix queue fix |
||
|
|
5d53a63553 |
fix(search): hide search3 artists with zero albums (#697)
* fix(search): hide search3 artists with zero albums * docs: changelog and credits for search zero-album filter (PR #697) |
||
|
|
3cc172723d |
fix(ui): split album and track artists (OpenSubsonic) (#696)
* fix(ui): split OpenSubsonic album and track artists in header and player Album detail header uses albumArtists from album or child songs; player bar, mobile player, and mini player use structured track artists with per-id links. Adds deriveAlbumHeaderArtistRefs helper and OpenArtistRefInline. Fixes #552 * docs: changelog and credits for OpenSubsonic artist links (PR #696) |
||
|
|
7a7a9f5e6b |
refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
111 of 122 top-level src/utils/ files move into 16 topic folders (audio, cache, cover, share, server, playback, playlist, deviceSync, waveform, mix, format, export, changelog, ui, perf, componentHelpers). True singletons with no cluster stay at the utils/ root. Pure file-move: a path-aware codemod rewrote 539 relative-import specifiers across 275 files; no logic touched. The hot-path coverage gate list (.github/frontend-hot-path-files.txt) is updated to the new paths for the 11 gated utils files — a mechanical consequence of the move, not a CI change. tsc is green. |
||
|
|
e04980626d |
refactor(api): F.52 — move credential helpers to subsonicClient (#617)
Move `apiWithCredentials` + `restBaseFromUrl` from `api/subsonic.ts` into `api/subsonicClient.ts` where the other token-auth primitives already live. They are the credential-bearing variants of `api`, sitting in the same client module is the natural home. `api/subsonic.ts` now holds only the connection-probing domain: `ping`, `pingWithCredentials`, `probeInstantMixWithCredentials`, `scheduleInstantMixProbeForServer`. Clear, cohesive, stable import path for the call sites that still use it. Pure code-move. subsonic.ts: 144 → 119 LOC. Total Phase F journey: 1333 → 119 LOC (~91% reduction). |
||
|
|
f7b2799d39 |
refactor(api): F.51 — extract playlists + play queue + radio + statistics (#616)
Four more domain-eng modules out of `api/subsonic.ts`:
- `subsonicPlaylists.ts` — `getPlaylists`/`getPlaylist`/`createPlaylist`
/`updatePlaylist`/`updatePlaylistMeta`/`uploadPlaylistCoverArt`/
`uploadArtistImage`/`deletePlaylist`. The two upload helpers use
the Tauri-side CORS bypass (`upload_playlist_cover` /
`upload_artist_image`).
- `subsonicPlayQueue.ts` — `getPlayQueue`/`savePlayQueue`.
- `subsonicRadio.ts` — Internet Radio CRUD (4 fns), Tauri-side cover
art ops (3 fns), RadioBrowser search/top + `fetchUrlBytes`.
- `subsonicStatistics.ts` — `fetchStatisticsLibraryAggregates`/
`fetchStatisticsOverview`/`fetchStatisticsFormatSample` with their
three per-server-folder caches and the `statisticsPageCacheKey`
helper. `STATS_CACHE_TTL` renamed from the misleadingly-shared
`RATING_CACHE_TTL` constant.
Also: drop ~20 now-unused type/value imports from `subsonic.ts`, trim
three orphan jsdoc comments left behind by earlier slices, fix four
`vi.mock` targets (`queueSync`, `playerStore.persistence`) plus the
dynamic `await import('../api/subsonic')` calls in `ContextMenu.tsx`
to point at the new module paths.
Pure code-move. subsonic.ts: 561 → 144 LOC (−417). What's left:
ping/pingWithCredentials/probeInstantMix/scheduleInstantMixProbe +
internal `apiWithCredentials`/`restBaseFromUrl`.
|
||
|
|
9606a99efb |
refactor(api): F.50 — extract 7 small domain modules (#615)
Seven domain-eng splits peel ~200 LOC of read endpoints out of `api/subsonic.ts`: - `subsonicStreamUrl.ts` — `buildStreamUrl`, `coverArtCacheKey`, `buildCoverArtUrl`, `buildDownloadUrl` (token-signed URL builders for the four /rest endpoints we hand to the browser). - `subsonicStarRating.ts` — `getStarred`, `star`, `unstar`, `setRating`, `probeEntityRatingSupport`. `setRating` still triggers the lazy `navidromeBrowse` cache invalidation; the same-folder lazy import path is preserved. - `subsonicSearch.ts` — `search`, `searchSongsPaged`. - `subsonicScrobble.ts` — `scrobbleSong`, `reportNowPlaying`, `getNowPlaying`. - `subsonicAlbumInfo.ts` — `getAlbumInfo2`. - `subsonicLyrics.ts` — `getLyricsBySongId`. - `subsonicGenres.ts` — `getGenres`, `getAlbumsByGenre`. 63 external call sites migrated to direct imports. Four `vi.mock` targets in the store-level tests pointed at `../api/subsonic` and were updated to the new module paths. Pure code-move. subsonic.ts: 762 → 561 LOC (−201). |
||
|
|
006635de4b |
refactor(api): F.49 — extract library + artists + ratings (#614)
Three domain-eng modules peel ~316 LOC of fetch/mapping out of `api/subsonic.ts`: - `subsonicLibrary.ts` — browse + random + per-song fetch (`getMusicDirectory`, `getMusicIndexes`, `getMusicFolders`, `getRandomAlbums`, `getAlbumList`, `getRandomSongs`, `getRandomSongsFiltered`, `getSong`, `getAlbum`, `filterSongsToActiveLibrary`, `similarSongsRequestCount`, plus the private `albumIdsInActiveLibraryScope` cache). - `subsonicArtists.ts` — artist endpoints (`getArtists`, `getArtist`, `getArtistInfo`, `getTopSongs`, `getSimilarSongs2`, `getSimilarSongs`). Uses Library's `filterSongsToActiveLibrary` and `similarSongsRequestCount` for the per-library scoping fallback. - `subsonicRatings.ts` — `parseSubsonicEntityStarRating` parser plus `prefetchArtistUserRatings` and `prefetchAlbumUserRatings` workers with the shared 7-min cache. Calls back into Library/Artists for the per-id fetch. 51 external call sites migrated to direct imports. No re-export shims in `subsonic.ts`. Statistics endpoints still in `subsonic.ts` keep their `RATING_CACHE_TTL` constant locally (same 7-min window). subsonic.ts: 1078 → 762 LOC (−316). |
||
|
|
72030f17fd |
refactor(api): F.48 — extract subsonic types + client primitives (#613)
First Phase F slice. Splits the 1333-LOC `api/subsonic.ts` along its two most obvious axes: - `subsonicTypes.ts` — all ~24 exported interfaces + type aliases (album/song/artist/playlist/directory/genre/now-playing/radio, random-songs filters, three statistics shapes, search + starred results, AlbumInfo, structured-lyrics types, etc.) plus the `RADIO_PAGE_SIZE` constant. - `subsonicClient.ts` — token-auth + `getClient` + `api<T>()` + `libraryFilterParams` + `secureRandomSalt` / `getAuthParams` / `SUBSONIC_CLIENT`. The credential-bearing API helpers (`pingWithCredentials`, `apiWithCredentials`, `restBaseFromUrl`, `probeInstantMixWithCredentials`) stay in `subsonic.ts` for now — they could move into the client module in a follow-up. 66 external call sites migrated to direct imports from the new modules (no re-export shims in `subsonic.ts`). Pure code-move; contract test stays green. subsonic.ts: 1333 → 1078 LOC (−255). |
||
|
|
568f3aeb7d |
test(api): subsonic.ts async endpoint contracts (F3 follow-up) (#549)
39 new tests targeting the response normalization paths the F3 PR (#544) deferred. Mocks axios at the module boundary; pins: - api() helper envelope: unwrap subsonic-response on status=ok, throw "Invalid response" without envelope, throw the server message on status=failed, throw a generic on failed-without-message, propagate network failures. - song-array vs single-object normalization paths: - getMusicDirectory normalizes child (object -> [object]), empty -> []. - getMusicIndexes flattens index -> artist arrays (object or array). - getMusicFolders coerces numeric ids to strings + defaults name. - getRandomSongs pass-through behaviour pinned (no normalization -- Navidrome always returns the array form). - collection-shape contracts: getAlbum splits { album, songs } + empty fallback when album.song is absent, getStarred returns empty arrays on missing starred2 / missing fields / pass-through arrays. - single endpoint behaviours: getSong null on failure, getTopSongs [] + slice to 5, getArtists flatten + empty, search whitespace-query short-circuit (no HTTP), getAlbumInfo2 null on error, ping true/false based on status. - pingWithCredentials (explicit-URL path): full response (type + serverVersion + openSubsonic), http:// prepend when scheme missing, trailing-slash strip before /rest/ping.view, ok=false on any failure / status=failed, openSubsonic defaults to false when omitted. subsonic.ts coverage 12.66% -> 31.87% lines (+19pp). The remaining surface lives in niche endpoints (playlist mutations, statistics overview/aggregates, internet radio CRUD, cover-art uploads, ratings prefetch). Not gate-eligible yet; a follow-up could push to ~60% but diminishing returns relative to other backlog items. Frontend suite: 412 -> 451 tests (+39). |
||
|
|
d2898ebaf6 |
test(api): URL builders + playback URL resolver + share link composition (Phase F3) (#544)
subsonic.contract.test.ts (21): parseSubsonicEntityStarRating (userRating first then rating fallback, numeric-string coercion, undefined for null / NaN / non-numeric), libraryFilterParams (empty without active server, empty on "all" filter, returns musicFolderId on specific filter), getClient (throws without a server, returns baseUrl + auth params, rotates token + salt across calls), coverArtCacheKey (serverId:cover:id:size shape, "_" fallback without active server, no ephemeral salt embedded -- stays cacheable), buildStreamUrl (URL shape + Subsonic auth params: id u t s v=1.16.1 c=psysonic/* f=json, rotates t/s across calls so Rust matches by id, special character ids encoded once not twice), buildCoverArtUrl (default size=256), buildDownloadUrl (download.view path), trailing-slash + scheme handling on base URL. resolvePlaybackUrl.test.ts (15): precedence offline > hot-cache > stream (first priority wins even when later sources also have the track), forwards trackId + serverId to both stores. getPlaybackSourceKind for offline / hot / stream / engine-preload-hint cases. streamUrlTrackId parser (id from stream.view query, null for non-stream URLs / no query / missing id, decodes URL-encoded ids, manual-query fallback for relative paths). copyEntityShareLink.test.ts (5): writes a psysonic2-prefixed payload that round-trips, returns false without an active server, returns false on empty / whitespace id, trims surrounding whitespace before encoding, propagates clipboard-failure return. Gate broadens with src/utils/resolvePlaybackUrl.ts (95.8 %) + src/utils/copyEntityShareLink.ts (100 %). subsonic.ts at 12.7 % stays out -- the URL-builder + parser surface this PR covers is the structural part; the async API endpoints need axios mocking, deferred to a follow-up. authStore.ts (79 %) and playerStore.ts (40 %) deferred-list comments updated to reflect F2 + F1 actuals. |
||
|
|
bd56177e2c |
feat(home): Lossless Albums rail + dedicated page + sidebar nav (#506)
* wip(home): Lossless rail + dedicated /lossless-albums page Rail under Home > mostPlayed and a dedicated infinite-scroll page that list albums whose tracks are tagged in lossless containers. Walks Navidrome's native /api/song?_sort=bit_depth&_order=DESC, dedupes by albumId on the way down, stops when the song stream crosses into lossy (bitDepth==0) or the server runs out of rows. _filters has no operators on quality columns, so a sort + walk is the only path; equality on sample_rate / bit_depth probes returned empty (verified). The page paginates through the song-cursor with an in-flight ref so overlapping IntersectionObserver fires don't double-add albums, plus a cancelled flag so React StrictMode's double-mount doesn't apply two parallel result sets in dev. Suffix allowlist excludes ambiguous wrappers — m4a/m4b can be ALAC *or* AAC and Navidrome's response carries an empty codec field, so we can't tell them apart; same story for wma. Allowlist is flac, wav, aiff/aif, dsf/dff, ape, wv, shn, tta — containers that are only lossless. ALAC-in-m4a setups will miss out, acceptable trade-off without a reliable codec field. Status: WIP. Settings home-customizer label and en.ts strings landed, no other locales yet, no quality badge on AlbumCards across the rest of the app, no CHANGELOG. Branch was 'feat/home-hires-rail' during the earlier hi-res-only iteration before the lossless broadening. * wip(home): Lossless page header parity + streaming + sidebar nav Page header now mirrors All Albums: selection mode with three action buttons (Enqueue Selected, Add Offline, Download ZIPs) wired to the same handlers Albums.tsx uses, selection-counter title swap, and the perfFlags.disableMainstageStickyHeader respect path. Filters were intentionally skipped — the rail is sorted by bit_depth, mixing client- side filters with server-driven pagination would produce gaps. Loading feels noticeably faster: ndListLosslessAlbumsPage takes an optional onProgress callback that fires once per internal fetch with the entries discovered in that fetch, so the page can stream new albums into state instead of waiting on the whole loadMore. Page-side budget dropped from 5×200 to 2×100 songs per loadMore (~1 MB worst case vs 5 MB before), since the rail's catch-em-all pass is wrong for infinite-scroll UX. Subtitle under the page title primes the user that this is slower than other album pages because Psysonic walks the song catalog by quality (Navidrome ignores _fields, so per-song responses ship with lyrics + tags + participants whether we want them or not). Sidebar nav entry registered under 'losslessAlbums' with a Gem icon, defaults to visible:false (matches composers / folderBrowser / deviceSync — niche browsing modes). Existing users get the entry appended at the end of their persisted sidebar list automatically via the onRehydrateStorage merge that sidebarStore already runs for new DEFAULT_SIDEBAR_ITEMS. i18n: full coverage across all 8 locales for sidebar.losslessAlbums, home.losslessAlbums, losslessAlbums.empty, losslessAlbums.unsupported and the new losslessAlbums.slowFetchHint subtitle. ru/zh are machine-translation quality, flagged for a polish pass. * feat(home): default Lossless sidebar entry to visible Flips the DEFAULT_SIDEBAR_ITEMS entry for `losslessAlbums` from false to true. Existing installs keep whatever the user has in persisted storage; fresh installs see the entry in the sidebar from the start. Earlier wip commit defaulted it off (matched composers / folderBrowser / deviceSync as a niche browse mode), but the rail + page do show something useful for any library with at least one FLAC/WAV/etc. album, so off-by-default just hid the feature. * docs: changelog entry for PR #506 Logs the Lossless Albums rail + page + sidebar entry in v1.46.0 "## Added". * docs(settings): contributor entry for PR #506 Adds the Lossless Albums rail/page bullet to Psychotoxical's contributions list. |
||
|
|
a41e3a624a |
feat(song-info): show absolute file path on Navidrome via native API (#504)
* feat(song-info): show absolute file path on Navidrome via native API
Subsonic's `getSong.view` returns at most a relative path (or none on
Navidrome), so the Path row in the Song Info modal stayed empty for
most users. Feishin and the Navidrome web client surface the full
server-side path by hitting Navidrome's native `/api/song/{id}` instead.
Added an `nd_get_song_path` Tauri command that logs in to the native
API with the active server's credentials, fetches the song, and returns
the `path` string. Wired it into `SongInfoModal`: the Subsonic
`getSong` call still drives the rest of the dialog, and the native call
runs in parallel only when the active server's identity is
"navidrome". When it returns a path, that absolute path replaces the
relative Subsonic value; native-API failures are silent and the modal
falls back to whatever Subsonic provided.
No token cache yet — the modal is opened occasionally enough that one
fresh login per call is fine.
Closes discussion #479.
* docs: changelog entry for PR #504
Logs the Navidrome native-API absolute file path support for the
Song Info dialog in v1.46.0 "## Added".
* docs: settings contributor entry for PR #504, drop competitor mention
Adds the song-info absolute path bullet to Psychotoxical's contributors
list in Settings, and rewords the existing CHANGELOG entry so neither
text references competing clients.
|
||
|
|
59744601d4 |
feat(composer): Browse by Composer page (issue #465) (#487)
* feat(composer): Browse by Composer page (issue #465) New library section listing every artist credited as composer on at least one track, with a detail page showing all works they're credited on in that role. Targeted at classical-music libraries where the "recording artist" tag carries the orchestra and the "composer" tag carries Bach / Mozart / Chopin. Hits Navidrome's native /api/artist?_filters={"role":"composer"} for the listing and /api/album?_filters={"role_composer_id":"…"} for the works grid — Subsonic getArtist only follows AlbumArtist relations and returns 0 albums for composer-only credits, so the native API is the only path that works. Requires Navidrome 0.55+ (uses library_artist.stats role aggregation); on older / pure-Subsonic servers the page shows a one-line capability banner. - Two new Tauri commands: nd_list_artists_by_role + nd_list_albums_by_artist_role, generic over participant role so conductor / lyricist / arranger pages are trivial to add later. - Composers grid: text-only compact tiles (name + participation count pulled from stats[role].albumCount). No avatars — composer libraries carry no useful imagery and the listing endpoint exposes no image URLs anyway. - ComposerDetail: hero with Last.fm bio (via getArtistInfo2) plus the full work grid, with a graceful fallback when the artist has no external info synced. - Sidebar entry default off (Feather icon) — opt-in for the niche classical use case. - nd_retry backoffs widened from [500] to [300, 800, 1800] — helps every nd_* call survive intermittent TLS-handshake-EOF errors that some reverse-proxy setups produce when keep-alive pools churn. - Distinguishes "server can't do this" (HTTP 400/404/422/501) from transient errors so the capability banner only fires when the server actually rejects the request shape; everything else gets a retry button. - i18n in all 8 supported locales. * fix(composer): address review feedback on detail page + role queries - Re-fetch ComposerDetail when music-library scope changes; previously the album grid stayed stale until navigation while the list refreshed. - Thread library_id through nd_list_artists_by_role and nd_list_albums_by_artist_role so role queries respect the active Navidrome library, matching the Subsonic musicFolderId already piped through libraryFilterParams(). - Fix CachedImage cache-key mismatch on ComposerDetail: a Last.fm header image was stored under the Subsonic cover-art key, aliasing cache entries and risking cross-source pollution. - Consolidate the two contradictory composer-imagery comments in Composers.tsx into a single accurate one (the older one referenced an Images toggle that was never implemented). - Align openLink toast duration with ArtistDetail (1500ms -> 2500ms). * fix(composer): keep bio across scope changes, add share, degrade gracefully Three remaining items from the latest review pass on the composer flow. 1. Bio survives a music-library scope change. The previous fix added musicLibraryFilterVersion to the load effect, but that effect also did setInfo(null) while the getArtistInfo effect still depended on [id] alone — so a scope bump on the open page wiped the bio without re-fetching it. Move the info reset into the bio effect (keyed on id) and out of the load effect: the album grid still refreshes on scope change; the Last.fm header image and biography survive untouched, since both are library-independent. 2. Composers join the share pipeline as a first-class entity kind. Extend EntityShareKind with 'composer' (and isEntityKind), branch applySharePastePayload to validate via getArtist (same id pool) and navigate to /composer/:id, and wire a Share button into ComposerDetail. A pasted composer link now opens the composer view instead of the artist view, matching what was copied. i18n added in all 8 locales (sharePaste.composerUnavailable, openedComposer; composerDetail.shareComposer, unknownComposer). 3. Partial server failure no longer hides the works. If getArtist rejects but ndListAlbumsByArtistRole succeeds, the page used to show full "not found" despite having data to display. Switch the not-found gate to require both empty (`!artist && !albums`) and render a degraded header (placeholder name, no Wikipedia / favourite / share / Last.fm image) when only metadata is missing. * fix(composer): right-click share copies a composer link, not an artist link The context menu opened from a composer card / row uses type='artist' because every composer-action (radio, favourite, rating, add-to-playlist) is identical to the artist counterpart — they share an id space and a backend representation. Sharing was the one exception: the "Share Link" entry produced a 'psysonic2-' string with k='artist', so a paste opened /artist/:id even though the user came from /composers. Add an optional shareKindOverride to openContextMenu (default: undefined, preserves existing behaviour) and have the artist-typed branch consult it when calling copyShareLink. Composers.tsx now passes 'composer' on both right-click sites; nothing else changes downstream because the override only affects the share kind. * polish(composer): show Last.fm avatar even without server metadata Two minor follow-ups from the latest review. - ComposerDetail: drop the `&& artist` guard on the header-avatar render path. info?.largeImageUrl can resolve through getArtistInfo(id) without ever needing the SubsonicArtist record, so the previous gate hid a perfectly good Last.fm portrait whenever getArtist failed but the bio fetch succeeded. Replace artist.name with displayName so the alt / aria-label degrade to the localised "Composer" placeholder instead of empty strings. - copyEntityShareLink: doc comment now mentions composer alongside track / album / artist. * fix(composer): derive Last.fm cache key from route id, not from artist record Follow-up to the previous polish: the avatar render path no longer requires `artist` to be populated, but the cache-key gate still did. So when getArtist failed but getArtistInfo returned a Last.fm portrait, the key fell through to coverKey — which is empty without an artist record, re-creating the very aliasing bug the earlier Subsonic-vs-Last.fm fix was meant to close. Switch the Last.fm branch to the route id (same id namespace as the SubsonicArtist record), so the key stays stable whenever Last.fm art is shown, independent of getArtist succeeding. * docs: CHANGELOG + Contributors entry for composer browsing (PR #487) |
||
|
|
dc35f53674 |
feat(artist): group albums by release type on artist page (#471)
* feat(artist): group albums by release type on artist page Uses the releaseType field to group albums/releases into sections like Albums, Compilation, Live, etc. If there's no release type it falls back to normal view * feat(artist): i18n release-type group labels * fix(artist): deterministic release-type group order * refactor(artist): replace inline styles with CSS classes * i18n(artist): translate release-type labels in remaining 7 locales Sayykii's `releaseTypes` namespace was added to en.ts only. Fills in de, fr, nl, zh, nb, ru, es with the same 8 keys (album, ep, single, compilation, live, soundtrack, remix, other) so users on non-English UIs see translated section headers on the artist page instead of the raw title-cased fallback. * docs: changelog + contributors for PR #471 Adds the v1.46.0 "Added" entry and bumps Sayykii's contributors line for the artist-page release-type grouping. --------- Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> |
||
|
|
e1f2cb4c37 |
feat(discord): add server cover art source (#462)
* feat(discord): add server cover art source
The old Apple Music toggle is replaced with a radio selector which let's you choose
between Apple Music, Server and no image.
It's important to note that the server needs to be publicly accessible.
Translations have been added for all locales
* feat(discord): toggle UI for cover source and tightened defaults
- Replace cover-source radio buttons with three indented sub-toggles
(none / server / apple) under Discord Rich Presence; mutex via
setDiscordCoverSource — turning one on flips the others off.
- Default discordCoverSource is now 'server' for fresh installs
(opt-in friendly: own server, no third-party data leak). Existing
users keep their state via the legacy bool migration.
- Tighten template defaults: details {artist}, state {title}, largeText
unchanged. Existing users keep their persisted values.
* docs(contributors): credit Sayykii + changelog entry for #462
---------
Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
|
||
|
|
1799e90e04 |
feat(tracks): Highly Rated rail + per-card star display (#443)
* feat(tracks): Highly Rated rail + per-card star display Adds a new SongRail above the Random Pick on the Tracks page that surfaces the user's highly-rated tracks (sorted by rating DESC). Auto-hides on non-Navidrome servers and when the library has no rated tracks yet. Reuses the existing SongRail layout, with the standard reroll button forcing a cache bypass. Per-card stars: any SongCard whose `userRating > 0` now shows a small five-star row (filled to the rating value) below the artist line — visible everywhere SongCard is used, not only in the new rail. Read-only display; rating is still done via the row's context menu or the Now Playing star widget. Cache layer in `ndListSongs`: opt-in `cacheMs` parameter (skipped by VirtualSongList; used only by the Highly Rated rail with a 60 s TTL). Cleared on `setRating` mutation so a freshly-rated track shows up on the next page revisit, and on server switch alongside the existing token cache. The reroll button explicitly invalidates before refetching, so a manual refresh always hits the network. * docs(changelog): add #443 Tracks Highly Rated rail entry * chore(credits): add #443 to Psychotoxical contributions |
||
|
|
cf09fd4bd3 |
fix(player): Lucky Mix + mix rating filter (Navidrome / OpenSubsonic) (#332)
* fix(player): Lucky Mix respects mix rating filter and harden rating reads Wire Lucky Mix through the same Settings → Ratings filter as Random Mix (enrich + passesMixMinRatings). Prefetch artist/album ratings via getArtist/getAlbum using both userRating and Navidrome-style rating, and bump the in-memory prefetch cache key so stale misses are not reused. Resolve artist entity id from OpenSubsonic artists[] or contributors[] when top-level artistId is missing; read rating on child song and artist refs so thresholds apply consistently. * chore: clarify JSDoc for mix rating enrich vs Lucky Mix filter |
||
|
|
4a0bdfed92 |
chore(subsonic): log pingWithCredentials failures to console
Server-switch silently returns { ok: false } when the upstream ping
throws, so the user never sees why the switch refused. Surfacing the
underlying error (timeout, network, CORS, 401) gives us something to
work with the next time the issue reproduces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
0404a23cc9 |
Merge branch 'main' into exp/orbit (pre-PR sync)
Conflicts resolved in: - src/pages/SearchResults.tsx - src/pages/AdvancedSearch.tsx Both pages were rewritten on main (PR #303) to use the shared <SongRow> component with click-to-enqueueAndPlay semantics. Orbit's playSong helper that branched on orbit-active is no longer needed at the page level — instead, orbit awareness moved INTO SongRow and SongCard themselves: in an active orbit session both buttons collapse into addTrackToOrbit (suggest for guests, host-enqueue for the host) so we don't ship a queue replacement to every guest. Also kept main's IntersectionObserver-based pagination on both pages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e3aabd98b7 |
feat(tracks): add Tracks library hub page (closes #299) (#300)
New /tracks route with three sections: - Hero "Track of the moment" — random pick with play / enqueue / reroll - Random Pick rail — 18 song cards, rerollable; hero song deduped - Browse all tracks — virtualized list (@tanstack/react-virtual), paginated 50 at a time Browse uses Navidrome's native /api/song?_sort=title&_order=ASC for proper A-Z order (no Subsonic equivalent), with automatic fallback to search3 on non-Navidrome servers. Search input drives search3 with 300ms debounce. Bearer token cached module-level, re-auth on 401. Play button on rows + cards calls a new enqueueAndPlay() helper that appends to the existing queue (skip if duplicate) and jumps to the song — different from playSongNow which replaces the queue. Enqueue button stays opaque (no hover-only). i18n keys for sidebar.tracks + tracks.* namespace in all 8 locales. New AudioLines sidebar icon. Sidebar entry inserted between "All Albums" and "Build a Mix". Performance: cover thumbnails dropped (uniform layout instead), RAF-throttled scroll prefetch, hover transforms removed from cards (WebKitGTK compositing-friendly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e4cc54a1b5 |
revert(orbit): drop maxPending cap feature, keep suggestion mute
The pending counter desynced from the actual approval list (state.queue holds approved items as history, so the count never decreased after a host approve). The host-pushed pendingApprovalCount workaround didn't hold up under live testing either, so we're rolling the whole cap feature back rather than ship something flaky. What's gone: - OrbitSettings.maxPending + state.pendingApprovalCount - cap branch in applyOutboxSnapshotsToState (now back to mute-only) - maxPending number input in settings popover - pending counter chip in OrbitQueueHead - 'cap-reached' branch in evaluateOrbitSuggestGate / OrbitSuggestGateReason - cap-related toasts in ContextMenu / useOrbitSongRowBehavior - cap-related i18n keys (suggestBlockedCap, settingMaxPending*, pendingCounter*) - cap CSS (.orbit-queue-head__pending, .orbit-settings-pop__number) What stays: per-guest suggestion mute (works correctly) and everything that fed into both features (OrbitState.suggestionBlocked, setOrbitSuggestionBlocked, evaluateOrbitSuggestGate, the participants popover Mic/MicOff toggle, the suggestBlockedMuted toast). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7c379c2111 |
fix(orbit): pending counter ignored merged/declined items
The "X / Y pending" counter in the queue head and the guest-side gate-check both used `state.queue.filter(non-host).length`, which is the *history* count — items the host has already approved or declined still sit in `state.queue` for attribution lookup, so the counter never decreased. Reported as "3 / 4 pending" with no actual rows in the approval list. The merged / declined sets only exist in the host's local store, so the guest can't filter them out itself. Solution: the host writes an authoritative `pendingApprovalCount` into the state blob each tick; guests (and the host's own UI) read it directly, with a fallback to the old over-counting behaviour for any older client that doesn't write the field. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cfb7e7f6c1 |
feat(orbit): per-guest suggestion mute + global pending-cap setting
Two anti-spam knobs the host can dial during a live session: 1. Per-guest suggestion mute — Mic / MicOff toggle next to the kick/ban buttons in the participants popover. Symmetric (re-enable later). State lives in OrbitState.suggestionBlocked: string[]; the guest reads it and disables its own Suggest controls so the user sees a clear "muted" state instead of silent failures. Host-side sweep also drops their outbox entries as a safety net. 2. Max pending approvals cap — number input in the session-settings popover, default 0 (= unlimited so existing sessions are unaffected). When set, the host sweep stops folding new outbox entries into the approval list once the cap is reached. The OrbitQueueHead surfaces "X / Y pending" so guests can see when they're getting close. State changes are additive on the wire — both fields are optional, with parseOrbitState defaulting them, so older clients keep working. evaluateOrbitSuggestGate() centralises the guest-side allow/block check shared between useOrbitSongRowBehavior and the ContextMenu Add-to-Session items, plus suggestOrbitTrack as a defensive last line. i18n: en + de + fr + nl + zh + nb + ru + es. Also fixes an earlier dedupe-key collision: the (user, trackId) cache keys were missing their separator (NULL byte slipped in during the previous patch), so two tracks could share a key and one of them silently overwrite the other. Restored the space separator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e3e2da07c0 |
Merge remote-tracking branch 'origin/main' into exp/orbit
# Conflicts: # src/api/subsonic.ts |
||
|
|
728b8f6315 |
chore(orbit): default auto-approve to off for new sessions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
401fed8368 |
feat(playlists): improve smart playlist editing and localization
Open smart playlist editing from playlist cards, load rules via Navidrome single-playlist API with safer fallbacks, and keep edit visibility aligned with ownership rules. Also add/clean smart playlist locale keys across all supported languages and preserve smarter autogenerated naming behavior. |
||
|
|
27a59f6b23 |
feat(playlists): integrate Navidrome smart playlist flow into playlists page
Move smart playlist creation and management into Playlists, including Navidrome-only gating, smart-name/icon presentation, and smarter refresh handling while server-side smart rules are being applied. |
||
|
|
089774dc3e |
chore(orbit): configurable shuffle interval
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
67385c7cef |
chore(orbit): hide and auto-reap technical session playlists
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
11dddf6290 |
exp(orbit): iterative refinements
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2b97a7b01e |
exp(orbit): host merge, guest queue view, settings popover, i18n
Guest suggestions now auto-merge into the host's play queue at random positions inside the upcoming range, so they actually surface alongside host-picked tracks instead of piling up at the end. Per-item dedupe via addedBy:addedAt:trackId keys survives reshuffles. Guests get a read-only mirror of the session in place of their queue panel — live-card for the host's current track, suggestion list with submitter attribution, and a footer reminding them the host owns playback. Track metadata is resolved lazily and cached locally. New host-only settings popover (gear in the bar) with auto-approve and auto-shuffle toggles plus a "Shuffle now" action. Settings live in the OrbitState blob and push immediately to Navidrome; missing fields on older blobs default to "both on". 15-min shuffle now touches the real playerStore queue via a new shuffleUpcomingQueue action — previous behaviour only reshuffled the guest-facing suggestion history. A manual shuffle is available from the settings popover so the interval can be verified without waiting. Start-modal reworked into a single step: share-link is visible and copy-able from the start, auto-copied on Start if the host hasn't already hit Copy. Link carries a live name-derived slug (parser strips it, SID is authoritative). LAN/remote-server hint above the facts. Random session-name suggester pulls from three pattern pools for ~10k unique combinations. All user-visible Orbit strings moved to a dedicated orbit i18n namespace (en + de); other locales fall back to en via i18next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e398d68184 |
exp(orbit): scaffold store and types
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b4f31e0954 |
feat(now-playing): redesign page as info dashboard (#266)
Replace the flat Now Playing page with a two-column dashboard: hero (cover, metadata, badges, Last.fm stats + love button, release-age tagline), and a grid of themed cards (Album tracklist with sliding window, most-played-by-artist, credits, about-the-artist with Last.fm bio fallback, compact discography contact-sheet with expand, upcoming tours via Bandsintown). Adds lastfmGetTrackInfo + lastfmGetArtistStats for global listener counts + userplaycount, plus Last.fm love/unlove wired to the new hero button. Module-level TTL caches per entity keep same-artist track switches instant. Artist-image guard filters the well-known Last.fm no-image placeholder so the card collapses cleanly when no real image exists. Co-authored-by: Psychotoxical <dev@psysonic.app> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
06140a490b |
feat(queue): add Now-Playing Info tab with artist bio, song credits and Bandsintown tour dates (#244)
* feat(queue): add Now-Playing Info tab with artist bio, song credits and Bandsintown tour dates A third tab in the right-side queue panel surfaces context for the currently playing track: - Artist card: image + biography from Subsonic getArtistInfo (Last.fm "Read more on Last.fm" anchor stripped), with read-more toggle that only appears when the bio actually overflows the 4-line clamp. - Song info: contributor credits from OpenSubsonic contributors[] rendered stacked (name prominent, role muted). Section is hidden entirely on servers without contributor support, and rows that just re-state the main artist under role "artist" are filtered out. - On tour: optional Bandsintown integration (opt-in, off by default). HTTP fetch + JSON parsing happen entirely on the Rust side; the frontend wrapper deduplicates concurrent calls and caches results in RAM for the session. Limited to 5 events with a "Show N more" toggle. When the toggle is off, the section becomes an in-place opt-in card with a privacy info-tooltip explaining what data is sent — same tooltip is also exposed on the matching toggle in Settings. Caching: artist info and song detail are memoised by stable IDs across component remounts, so jumping between tracks of the same album/artist does not refire the network calls. Implementation notes: - Bandsintown app_id "js_app_id" — arbitrary strings (e.g. "psysonic") now return HTTP 403 from rest.bandsintown.com; js_app_id is the ID Bandsintown's own embeddable widget uses and is broadly accepted. - Tour items use negative left/right margins so the date badge stays visually aligned with the section title while the hover background extends slightly past the section edges. - New i18n namespace nowPlayingInfo across all 8 locales (en, de, fr, nl, zh, nb, ru, es) including pluralised "Show N more" forms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(now-playing-info): switch nested flex-columns to block layout to stop content-dependent drift Repeated reports of the Tour and Song-info sections rendering at inconsistent x-positions (sometimes left of the section title, sometimes centred, sometimes correctly aligned) — varying by artist, sidebar width, and even whether "Show more" was clicked. Root cause: nested flex-direction: column containers (.np-info → .np-info-section → .np-info-tour / .np-info-credits) where the cross-axis (horizontal) sizing on WebKitGTK occasionally inherits the intrinsic min-content of the longest child. With one "Naherholungsgebiet/Freizeitgelände Lago Alfredo"-style venue name that overflows the sidebar, the parent ul gets pushed past 100% width and the entire layout drifts. min-width: 0 on every level didn't help because cross-axis stretch in flex-column ignores it for content-driven overflow. Fix: collapse all the section/list containers to display: block. Block layout is content-agnostic — children always render at 100% parent width, left-aligned, deterministically. Spacing between siblings now uses the lobotomy selector (`> * + * { margin-top }`). Only the tour item itself stays flex (badge ↔ meta horizontal layout), and that one still has overflow: hidden + flex: 1 1 0 + min-width: 0 on the meta column to truncate venue/place text with ellipsis. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Psychotoxical <dev@psysonic.app> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bac0afe6ae |
fix(subsonic): sync Rust HTTP UA with main webview UA (#235)
Use the main window WebView user agent as the runtime source for Rust-side HTTP clients and refresh the audio engine client when the UA changes. This keeps backend and WebView requests aligned while preserving a simple startup sync flow. |
||
|
|
2d3be10c8f |
feat(users): show last-access timestamp per user
Maps Navidrome's lastAccessAt onto NdUser and renders it in the User Management row as a localized relative time (Intl.RelativeTimeFormat with the current i18n language). Tooltip carries the absolute timestamp. Navidrome returns 0001-01-01 for never-accessed users — detected and shown as the localized "Never" label. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
459c9f688d |
feat(users): per-user library assignment + themed confirm modal (#222)
Adds Navidrome library assignment to the User Management settings
panel: GET /api/library + PUT /api/user/{id}/library wired through
new Rust commands. UserForm gets a checkbox picker (hidden for
admins, who auto-receive all libraries server-side) with inline
validation. User rows compacted to a single clickable line with
hover highlight; native confirm() replaced by a portal-based
ConfirmModal (theme-aware, ESC/Enter, vertically centered).
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
dc819f3a2c |
feat(settings): admin-gated User Management tab via Navidrome REST API
Adds a dedicated Settings tab for managing Navidrome users (list, create, edit, delete). Only visible to admins — gated by `/auth/login` probe on mount. Uses Navidrome's native /api/user endpoints instead of Subsonic (getUsers.view etc. return only the caller on Navidrome). All HTTP calls go through new Rust commands to bypass WebView CORS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |