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>
This commit is contained in:
cucadmuh
2026-05-22 01:33:09 +03:00
committed by GitHub
parent 9019041592
commit 5bf2441ccf
162 changed files with 24916 additions and 547 deletions
+520
View File
@@ -0,0 +1,520 @@
/**
* Typed wrappers around the `library_*` Tauri commands (spec §7.1) plus
* subscribers for `library:sync-progress` / `library:sync-idle` events
* (§7.2). One thin file per cucadmuh's PR-5 kickoff Q1 — Settings UI
* (LibraryTab) imports from here; nothing else in the app talks to the
* backend library surface directly.
*/
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
// ── DTO mirrors (camelCase, matching the Rust `#[serde(rename_all = "camelCase")]`) ─
export interface TrackRefDto {
serverId: string;
trackId: string;
contentHash?: string | null;
}
/** E3 readiness summary — present only on single-track `libraryGetTrack` reads. */
export interface TrackEnrichmentDto {
waveformReady: boolean;
loudnessReady: boolean;
lyricsCached: boolean;
}
export interface LibraryTrackDto {
serverId: string;
id: string;
contentHash?: string | null;
title: string;
titleSort?: string | null;
artist?: string | null;
artistId?: string | null;
album: string;
albumId?: string | null;
albumArtist?: string | null;
durationSec: number;
trackNumber?: number | null;
discNumber?: number | null;
year?: number | null;
genre?: string | null;
suffix?: string | null;
bitRate?: number | null;
sizeBytes?: number | null;
coverArtId?: string | null;
starredAt?: number | null;
userRating?: number | null;
playCount?: number | null;
playedAt?: number | null;
serverPath?: string | null;
libraryId?: string | null;
isrc?: string | null;
mbidRecording?: string | null;
bpm?: number | null;
replayGainTrackDb?: number | null;
replayGainAlbumDb?: number | null;
serverUpdatedAt?: number | null;
serverCreatedAt?: number | null;
syncedAt: number;
/** E3: populated only by `libraryGetTrack` (omitted on list/batch reads). */
enrichment?: TrackEnrichmentDto | null;
rawJson: unknown;
}
export interface SyncStateDto {
serverId: string;
libraryScope: string;
syncPhase: string;
capabilityFlags: number;
libraryTier: string;
lastFullSyncAt?: number | null;
lastDeltaSyncAt?: number | null;
nextPollAt?: number | null;
serverLastScanIso?: string | null;
indexesLastModifiedMs?: number | null;
artistsLastModifiedMs?: number | null;
localTrackCount?: number | null;
serverTrackCount?: number | null;
lastError?: string | null;
localTracksMaxUpdatedMs?: number | null;
/** True when at least one non-deleted track exists locally (cheap EXISTS). */
hasLocalTracks?: boolean;
ingestStrategy?: string | null;
ingestPhase?: string | null;
/** Tracks ingested per persisted initial-sync cursor (IS-3 progress). */
cursorIngestedCount?: number | null;
n1BulkUnreliable?: boolean | null;
}
export interface LibraryTracksEnvelope {
tracks: LibraryTrackDto[];
total: number;
}
export interface TrackArtifactDto {
serverId: string;
trackId: string;
artifactKind: string;
format: string;
sourceKind: string;
sourceId: string;
language?: string | null;
contentText?: string | null;
contentBytes: number;
notFound: boolean;
contentHash?: string | null;
fetchedAt: number;
expiresAt?: number | null;
}
export interface ArtifactInputDto {
artifactKind: string;
format: string;
sourceKind: string;
sourceId: string;
language?: string | null;
contentText?: string | null;
contentBlob?: number[] | null;
contentBytes?: number;
notFound?: boolean;
contentHash?: string | null;
expiresAt?: number | null;
}
export interface TrackFactDto {
serverId: string;
trackId: string;
factKind: string;
valueReal?: number | null;
valueInt?: number | null;
valueText?: string | null;
unit?: string | null;
sourceKind: string;
sourceId: string;
confidence: number;
contentHash?: string | null;
fetchedAt: number;
expiresAt?: number | null;
}
export interface FactInputDto {
factKind: string;
valueReal?: number | null;
valueInt?: number | null;
valueText?: string | null;
unit?: string | null;
sourceKind: string;
sourceId: string;
confidence?: number;
contentHash?: string | null;
expiresAt?: number | null;
}
export interface OfflinePathDto {
serverId: string;
trackId: string;
localPath?: string | null;
missing: boolean;
}
export interface PurgeReportDto {
tracksDeleted: number;
albumsDeleted: number;
artistsDeleted: number;
offlineRowsDeleted: number;
bytesFreed: number;
}
export interface SyncJobDto {
jobId: string;
serverId: string;
kind: string; // 'initial_sync' | 'delta_sync'
}
// ── Advanced Search (PR-5d, §5.13 / §5.5B) ────────────────────────────
export type LibraryEntityType = 'artist' | 'album' | 'track';
/** v1 operator set the Rust `FilterFieldRegistry` accepts (§5.13.2). */
export type FilterOperator = 'eq' | 'gte' | 'lte' | 'between' | 'fts' | 'is_true' | 'in';
export type SortDir = 'asc' | 'desc';
export interface LibraryFilterClause {
field: string; // registry id, e.g. 'genre' | 'year' | 'bpm'
op: FilterOperator;
value?: string | number | boolean | null;
valueTo?: number | null; // between: inclusive upper bound
}
export interface LibrarySortClause {
field: string;
dir: SortDir;
}
export interface LibraryAdvancedSearchRequest {
serverId: string;
libraryScope?: string | null;
query?: string | null; // shorthand → fts clause on text fields
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
starredOnly?: boolean | null;
sort?: LibrarySortClause[];
limit: number;
offset?: number;
/** Skip expensive COUNT queries (Live Search). */
skipTotals?: boolean;
}
export interface LibraryAlbumDto {
serverId: string;
id: string;
name: string;
artist?: string | null;
artistId?: string | null;
songCount?: number | null;
durationSec?: number | null;
year?: number | null;
genre?: string | null;
coverArtId?: string | null;
starredAt?: number | null;
syncedAt: number;
rawJson: unknown;
}
export interface LibraryArtistDto {
serverId: string;
id: string;
name: string;
albumCount?: number | null;
syncedAt: number;
rawJson: unknown;
}
export interface LibrarySearchTotals {
artists: number;
albums: number;
tracks: number;
}
export interface LibraryAdvancedSearchResponse {
artists: LibraryArtistDto[];
albums: LibraryAlbumDto[];
tracks: LibraryTrackDto[];
totals: LibrarySearchTotals;
/** Registry field ids actually applied — UI chips / debug. */
appliedFilters: string[];
source: 'local' | 'network' | 'mixed';
}
export interface LibraryCrossServerSearchResponse {
hits: LibraryTrackDto[];
/** Fuzzy `title LIKE` matches the exact FTS pass missed (§5.9 / H3). */
fuzzy: LibraryTrackDto[];
serversSearched: string[];
}
// ── Read commands (PR-5a) ─────────────────────────────────────────────
export function libraryGetStatus(
serverId: string,
libraryScope?: string,
): Promise<SyncStateDto> {
return invoke<SyncStateDto>('library_get_status', { serverId, libraryScope });
}
export function librarySearch(
serverId: string,
query: string,
options?: { limit?: number; offset?: number; libraryScope?: string },
): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('library_search', {
serverId,
query,
limit: options?.limit,
offset: options?.offset,
libraryScope: options?.libraryScope,
});
}
/**
* Advanced Search against the local index (§5.13). The frontend fallback
* (PR-7 F2) decides local vs network and maps the same `LibraryFilterClause`
* shape onto the network path; this wrapper only talks to the local builder.
*/
export function libraryAdvancedSearch(
request: LibraryAdvancedSearchRequest,
): Promise<LibraryAdvancedSearchResponse> {
return invoke<LibraryAdvancedSearchResponse>('library_advanced_search', { request });
}
export interface LibraryLiveSearchResponse {
artists: LibraryArtistDto[];
albums: LibraryAlbumDto[];
tracks: LibraryTrackDto[];
source: 'local' | 'network' | 'mixed';
}
/** Live Search dropdown — one lean FTS query (§5.9), not Advanced Search. */
export interface LibraryLiveSearchRequest {
serverId: string;
query: string;
/** Subsonic `musicFolderId` / Navidrome library id — omit for all libraries. */
libraryScope?: string | null;
artistLimit?: number;
albumLimit?: number;
songLimit?: number;
/** UI generation — stale Rust FTS passes are dropped server-side. */
requestEpoch?: number;
}
export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<LibraryLiveSearchResponse> {
return invoke<LibraryLiveSearchResponse>('library_live_search', { request });
}
/** Cross-server FTS union over the given servers, or all `ready` ones (§5.5B). */
export function librarySearchCrossServer(args: {
query: string;
limit?: number;
servers?: string[];
}): Promise<LibraryCrossServerSearchResponse> {
return invoke<LibraryCrossServerSearchResponse>('library_search_cross_server', args);
}
export function libraryGetTrack(
serverId: string,
trackId: string,
): Promise<LibraryTrackDto | null> {
return invoke<LibraryTrackDto | null>('library_get_track', { serverId, trackId });
}
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
return invoke<LibraryTrackDto[]>('library_get_tracks_batch', { refs });
}
export function libraryGetTracksByAlbum(
serverId: string,
albumId: string,
): Promise<LibraryTrackDto[]> {
return invoke<LibraryTrackDto[]>('library_get_tracks_by_album', { serverId, albumId });
}
export function libraryGetArtifact(
serverId: string,
trackId: string,
artifactKind: string,
options?: { sourceKind?: string; sourceId?: string; format?: string },
): Promise<TrackArtifactDto | null> {
return invoke<TrackArtifactDto | null>('library_get_artifact', {
serverId,
trackId,
artifactKind,
sourceKind: options?.sourceKind,
sourceId: options?.sourceId,
format: options?.format,
});
}
export function libraryGetFacts(
serverId: string,
trackId: string,
factKinds?: string[],
): Promise<TrackFactDto[]> {
return invoke<TrackFactDto[]>('library_get_facts', { serverId, trackId, factKinds });
}
export function libraryGetOfflinePath(
serverId: string,
trackId: string,
): Promise<OfflinePathDto> {
return invoke<OfflinePathDto>('library_get_offline_path', { serverId, trackId });
}
// ── Session + lifecycle (PR-5b) ───────────────────────────────────────
export function librarySyncBindSession(args: {
serverId: string;
baseUrl: string;
username: string;
password: string;
libraryScope?: string;
}): Promise<void> {
return invoke<void>('library_sync_bind_session', args);
}
export function librarySyncClearSession(serverId: string): Promise<void> {
return invoke<void>('library_sync_clear_session', { serverId });
}
export type PlaybackHint = 'idle' | 'playing' | 'prefetch_active';
export function libraryGetPlaybackHint(): Promise<PlaybackHint> {
return invoke<PlaybackHint>('library_get_playback_hint');
}
export function librarySetPlaybackHint(hint: PlaybackHint): Promise<void> {
return invoke<void>('library_set_playback_hint', { hint });
}
export type SyncMode = 'full' | 'delta';
export function librarySyncStart(args: {
serverId: string;
mode: SyncMode;
libraryScope?: string;
}): Promise<SyncJobDto> {
return invoke<SyncJobDto>('library_sync_start', args);
}
/** Forced full-budget tombstone delta — Settings → «Verify integrity». */
export function librarySyncVerifyIntegrity(args: {
serverId: string;
libraryScope?: string;
}): Promise<SyncJobDto> {
return invoke<SyncJobDto>('library_sync_verify_integrity', args);
}
export function librarySyncCancel(jobId?: string): Promise<void> {
return invoke<void>('library_sync_cancel', { jobId });
}
export function libraryPatchTrack(args: {
serverId: string;
trackId: string;
patch: {
starredAt?: number | null;
userRating?: number | null;
playCount?: number | null;
playedAt?: number | null;
/** E2: playback-derived `md5_16kb` content fingerprint. Normally written
* by the Rust analysis bridge; exposed here for contract completeness. */
contentHash?: string | null;
};
}): Promise<void> {
return invoke<void>('library_patch_track', args);
}
export function libraryPutArtifact(args: {
serverId: string;
trackId: string;
artifact: ArtifactInputDto;
}): Promise<void> {
return invoke<void>('library_put_artifact', args);
}
export function libraryPutFact(args: {
serverId: string;
trackId: string;
fact: FactInputDto;
}): Promise<void> {
return invoke<void>('library_put_fact', args);
}
export function libraryPurgeServer(args: {
serverId: string;
includeAnalysis?: boolean;
includeOffline?: boolean;
}): Promise<PurgeReportDto> {
return invoke<PurgeReportDto>('library_purge_server', args);
}
export function libraryDeleteServerData(serverId: string): Promise<void> {
return invoke<void>('library_delete_server_data', { serverId });
}
// ── Event subscriptions ───────────────────────────────────────────────
export interface LibrarySyncProgressPayload {
serverId: string;
libraryScope: string;
/** 'phase_changed' | 'ingest_page' | 'remapped' | 'tombstoned' | 'completed' | 'error' */
kind: string;
phase?: string | null;
ingestedTotal?: number | null;
batchCount?: number | null;
remappedCount?: number | null;
tombstonesChecked?: number | null;
tombstonesDeleted?: number | null;
completedKind?: string | null;
message?: string | null;
/** S1 per-batch timings from the Rust ingest runner (when available). */
ingestMetrics?: IngestBatchMetrics | null;
}
export interface IngestBatchMetrics {
offset: number;
strategy: string;
fetchMs: number;
writeMs: number;
lockWaitMs: number;
sqlExecMs: number;
persistMs: number;
rowCount: number;
bulkIngestActive: boolean;
}
export interface LibrarySyncIdlePayload {
serverId: string;
libraryScope: string;
kind: string; // 'initial_sync' | 'delta_sync'
ok: boolean;
error?: string | null;
}
export function subscribeLibrarySyncProgress(
handler: (payload: LibrarySyncProgressPayload) => void,
): Promise<UnlistenFn> {
return listen<LibrarySyncProgressPayload>('library:sync-progress', ({ payload }) =>
handler(payload),
);
}
export function subscribeLibrarySyncIdle(
handler: (payload: LibrarySyncIdlePayload) => void,
): Promise<UnlistenFn> {
return listen<LibrarySyncIdlePayload>('library:sync-idle', ({ payload }) =>
handler(payload),
);
}
+20 -1
View File
@@ -20,7 +20,7 @@ import {
} from './subsonicStreamUrl';
import { beforeEach, describe, expect, it } from 'vitest';
import { parseSubsonicEntityStarRating } from './subsonicRatings';
import { getClient, libraryFilterParams } from './subsonicClient';
import { getClient, libraryFilterParams, libraryScopeForServer } from './subsonicClient';
import { useAuthStore } from '@/store/authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
@@ -78,6 +78,25 @@ describe('libraryFilterParams', () => {
});
});
describe('libraryScopeForServer', () => {
it('returns undefined for all or unset filters', () => {
const serverId = setUpServer();
expect(libraryScopeForServer(serverId)).toBeUndefined();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: 'all' },
});
expect(libraryScopeForServer(serverId)).toBeUndefined();
});
it('returns the folder id when scoped', () => {
const serverId = setUpServer();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
});
expect(libraryScopeForServer(serverId)).toBe('mf-7');
});
});
describe('getClient', () => {
it('throws when no server is configured', () => {
expect(() => getClient()).toThrow(/no server configured/i);
+17 -4
View File
@@ -68,12 +68,18 @@ export async function apiForServer<T>(
return apiWithCredentials(server.url, server.username, server.password, endpoint, extra, timeout);
}
export async function api<T>(endpoint: string, extra: Record<string, unknown> = {}, timeout = 15000): Promise<T> {
export async function api<T>(
endpoint: string,
extra: Record<string, unknown> = {},
timeout = 15000,
signal?: AbortSignal,
): Promise<T> {
const { baseUrl, params } = getClient();
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
params: { ...params, ...extra },
paramsSerializer: { indexes: null },
timeout,
signal,
});
const data = resp.data?.['subsonic-response'];
if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)');
@@ -87,9 +93,16 @@ export function libraryFilterParams(): Record<string, string | number> {
return activeServerId ? libraryFilterParamsForServer(activeServerId) : {};
}
/** Navidrome/Subsonic music folder id for the local library index, or undefined for all libraries. */
export function libraryScopeForServer(serverId: string): string | undefined {
const f = useAuthStore.getState().musicLibraryFilterByServer[serverId];
if (f === undefined || f === 'all') return undefined;
return f;
}
/** Library folder filter for an explicit saved server (e.g. Now Playing while browsing another). */
export function libraryFilterParamsForServer(serverId: string): Record<string, string | number> {
const f = useAuthStore.getState().musicLibraryFilterByServer[serverId];
if (f === undefined || f === 'all') return {};
return { musicFolderId: f };
const scope = libraryScopeForServer(serverId);
if (!scope) return {};
return { musicFolderId: scope };
}
+5
View File
@@ -1,5 +1,6 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicNowPlaying } from './subsonicTypes';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
async function scrobbleOnServer(
serverId: string,
@@ -16,6 +17,10 @@ export async function scrobbleSong(id: string, time: number, serverId: string):
if (!serverId) return;
try {
await scrobbleOnServer(serverId, id, true, time);
// Patch-on-use (§6.5 / F3): reflect the play in the local index so the
// "recently played" surfaces aren't stale. `play_count` is left to the next
// sync (the patch sets absolute values; a correct increment needs the base).
patchLibraryTrackOnUse(serverId, id, { playedAt: time });
} catch {
// best effort
}
+21 -8
View File
@@ -15,7 +15,15 @@ export function filterSearchArtistsWithNoAlbums(artists: SubsonicArtist[]): Subs
return artists.filter((a) => a.albumCount !== 0);
}
export async function search(query: string, options?: { albumCount?: number; artistCount?: number; songCount?: number }): Promise<SearchResults> {
export async function search(
query: string,
options?: {
albumCount?: number;
artistCount?: number;
songCount?: number;
signal?: AbortSignal;
},
): Promise<SearchResults> {
if (!query.trim()) return { artists: [], albums: [], songs: [] };
const data = await api<{
searchResult3: {
@@ -23,13 +31,18 @@ export async function search(query: string, options?: { albumCount?: number; art
album?: SubsonicAlbum[];
song?: SubsonicSong[];
};
}>('search3.view', {
query,
artistCount: options?.artistCount ?? 5,
albumCount: options?.albumCount ?? 5,
songCount: options?.songCount ?? 10,
...libraryFilterParams(),
});
}>(
'search3.view',
{
query,
artistCount: options?.artistCount ?? 5,
albumCount: options?.albumCount ?? 5,
songCount: options?.songCount ?? 10,
...libraryFilterParams(),
},
15000,
options?.signal,
);
const r = data.searchResult3 ?? {};
return {
artists: filterSearchArtistsWithNoAlbums(r.artist ?? []),
+10
View File
@@ -1,5 +1,7 @@
import { api, libraryFilterParams } from './subsonicClient';
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
import { useAuthStore } from '../store/authStore';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
import type {
EntityRatingSupportLevel,
StarredResults,
@@ -26,6 +28,9 @@ export async function star(id: string, type: 'song' | 'album' | 'artist' = 'albu
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('star.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { starredAt: Date.now() });
}
}
export async function unstar(id: string, type: 'song' | 'album' | 'artist' = 'album'): Promise<void> {
@@ -34,10 +39,15 @@ export async function unstar(id: string, type: 'song' | 'album' | 'artist' = 'al
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('unstar.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { starredAt: null });
}
}
export async function setRating(id: string, rating: number): Promise<void> {
await api('setRating.view', { id, rating });
// No-op in Rust when `id` is an album/artist (no track row matches).
patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { userRating: rating });
// Cached song lists keyed by rating (e.g. Tracks → Highly Rated rail) become
// stale immediately. `invalidateEntityUserRatingCaches` is static-imported:
// mix paths already pull `subsonicRatings` (e.g. mixRatingFilter), so a
+18
View File
@@ -10,10 +10,13 @@ import ExportPickerModal from '../components/ExportPickerModal';
import ZipDownloadOverlay from '../components/ZipDownloadOverlay';
import FpsOverlay from '../components/FpsOverlay';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
import { hydrateQueueFromIndex } from '../utils/library/queueRestore';
import { IS_WINDOWS } from '../utils/platform';
import TauriEventBridge from './TauriEventBridge';
import AppShell from './AppShell';
@@ -34,6 +37,21 @@ export default function MainApp() {
// Advanced Mode toggle. Idempotent — flagged in localStorage.
useEffect(() => { runAdvancedModeMigration(); }, []);
// Re-bind the library sync session whenever the active server changes
// (covers app startup + server switch). The session is Rust
// process-memory only while the per-server index toggle persists, so
// without this the background scheduler + Sync now report
// "no bound session" after a restart.
const activeServerId = useAuthStore(s => s.activeServerId);
const serverIdsKey = useAuthStore(s => s.servers.map(srv => srv.id).join(','));
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
useEffect(() => {
void (async () => {
await bootstrapAllIndexedServers();
void hydrateQueueFromIndex();
})();
}, [activeServerId, serverIdsKey, masterEnabled]);
// Push playback state to mini window + handle control events.
useEffect(() => {
return initMiniPlayerBridgeOnMain();
+2
View File
@@ -7,6 +7,7 @@ import { useTrayIconSync } from '../hooks/tauriBridge/useTrayIconSync';
import { useInAppKeybindings } from '../hooks/tauriBridge/useInAppKeybindings';
import { useMediaAndWindowBridge } from '../hooks/tauriBridge/useMediaAndWindowBridge';
import { usePlayerSnapshotPublisher } from '../hooks/tauriBridge/usePlayerSnapshotPublisher';
import { useLibraryDevSyncLog } from '../hooks/tauriBridge/useLibraryDevSyncLog';
/**
* Single mount point for everything that bridges Rust ↔ React in the main
@@ -33,6 +34,7 @@ export function TauriEventBridge() {
useInAppKeybindings(navigate);
useMediaAndWindowBridge(navigate);
usePlayerSnapshotPublisher();
useLibraryDevSyncLog();
return null;
}
+4 -12
View File
@@ -1,4 +1,4 @@
import { star, unstar } from '../api/subsonicStarRating';
import { queueSongStar } from '../store/pendingStarSync';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { playbackCoverArtForId } from '../utils/playback/playbackServer';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
@@ -34,7 +34,6 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
// Derive isStarred inside the selector so we only re-render when the boolean
// actually flips — not when any unrelated track's star status changes.
const isStarred = usePlayerStore(s => {
@@ -43,17 +42,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
});
const toggleStar = useCallback(async () => {
const toggleStar = useCallback(() => {
if (!currentTrack) return;
const nextVal = !isStarred;
setStarredOverride(currentTrack.id, nextVal);
try {
if (nextVal) await star(currentTrack.id, 'song');
else await unstar(currentTrack.id, 'song');
} catch {
setStarredOverride(currentTrack.id, !nextVal);
}
}, [currentTrack, isStarred, setStarredOverride]);
queueSongStar(currentTrack.id, !isStarred);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
+210 -28
View File
@@ -1,25 +1,33 @@
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { search } from '../api/subsonicSearch';
import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '../api/library';
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import {
LIVE_SEARCH_DEBOUNCE_NETWORK_MS,
LIVE_SEARCH_DEBOUNCE_RACE_MS,
EMPTY_SEARCH_RESULTS,
liveSearchQueryTooShort,
runLocalLiveSearch,
runNetworkLiveSearch,
} from '../utils/library/liveSearchLocal';
import { raceSearchSources } from '../utils/library/searchRace';
import { libraryIsReady } from '../utils/library/libraryReady';
import {
logLibrarySearch,
} from '../utils/library/libraryDevLog';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music, TextSearch } from 'lucide-react';
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useTranslation } from 'react-i18next';
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
import { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
let timer: ReturnType<typeof setTimeout>;
return (q: string) => {
clearTimeout(timer);
timer = setTimeout(() => fn(q), ms);
};
}
type LiveSearchSource = 'local' | 'network';
function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) {
const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]);
@@ -56,6 +64,9 @@ export default function LiveSearch() {
const [activeIndex, setActiveIndex] = useState(-1);
const [isFocused, setIsFocused] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchSource, setSearchSource] = useState<LiveSearchSource | null>(null);
const localReadyRef = useRef(false);
const liveSearchGenRef = useRef(0);
const navigate = useNavigate();
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
@@ -67,41 +78,174 @@ export default function LiveSearch() {
const inputRef = useRef<HTMLInputElement>(null);
const collapsedRef = useRef(false);
const compactHeaderControlsRef = useRef(false);
const serverId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const refreshLocalReady = useCallback(async () => {
if (!serverId || !indexEnabled) {
localReadyRef.current = false;
return;
}
localReadyRef.current = await libraryIsReady(serverId);
}, [serverId, indexEnabled]);
useEffect(() => {
void refreshLocalReady();
}, [refreshLocalReady, musicLibraryFilterVersion]);
useEffect(() => {
if (!indexEnabled || !serverId) return;
let unlistenProgress: (() => void) | undefined;
let unlistenIdle: (() => void) | undefined;
void subscribeLibrarySyncIdle(payload => {
if (payload.serverId === serverId) void refreshLocalReady();
}).then(fn => {
unlistenIdle = fn;
});
void subscribeLibrarySyncProgress(p => {
if (p.serverId === serverId && p.kind === 'phase_changed') void refreshLocalReady();
}).then(fn => {
unlistenProgress = fn;
});
return () => {
unlistenIdle?.();
unlistenProgress?.();
};
}, [indexEnabled, serverId, refreshLocalReady]);
const closeSearch = useCallback(() => {
setOpen(false);
setQuery('');
setSearchSource(null);
}, []);
const share = useShareSearch(query, closeSearch);
const doSearch = useCallback(
debounce(async (q: string) => {
if (!q.trim()) { setResults(null); setOpen(false); return; }
setLoading(true);
try {
const r = await search(q);
setResults(r);
setOpen(true);
} finally {
setLoading(false);
}
}, 300),
[musicLibraryFilterVersion]
);
useEffect(() => {
if (share.shareMatch) {
setResults(null);
setLoading(false);
setSearchSource(null);
setOpen(true);
setActiveIndex(-1);
return;
}
doSearch(query);
const q = query.trim();
if (!q) {
setResults(null);
setOpen(false);
setSearchSource(null);
setLoading(false);
return;
}
setSearchSource(null);
setActiveIndex(-1);
}, [query, doSearch, share.shareMatch]);
const abort = new AbortController();
const debounceMs = indexEnabled ? LIVE_SEARCH_DEBOUNCE_RACE_MS : LIVE_SEARCH_DEBOUNCE_NETWORK_MS;
const timer = window.setTimeout(() => {
void (async () => {
const gen = liveSearchGenRef.current;
const isStale = () =>
gen !== liveSearchGenRef.current || abort.signal.aborted;
if (isStale()) return;
setLoading(true);
const searchT0 = performance.now();
try {
if (liveSearchQueryTooShort(q)) {
if (!isStale()) {
setResults(EMPTY_SEARCH_RESULTS);
setSearchSource('local');
setOpen(true);
}
return;
}
const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId };
if (indexEnabled && serverId) {
const winner = await raceSearchSources(
[
{
source: 'local',
run: () => runLocalLiveSearch(serverId, q, raceCtx),
},
{
source: 'network',
run: () => runNetworkLiveSearch(q, abort.signal),
},
],
isStale,
);
if (isStale()) return;
if (winner) {
setResults(winner.result);
setSearchSource(winner.source);
setOpen(true);
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'search_race',
durationMs: Math.round(performance.now() - searchT0),
debounceMs,
indexEnabled,
localReadyCached: localReadyRef.current,
raceWinner: winner.source,
raceWinnerMs: winner.durationMs,
counts: {
artists: winner.result.artists.length,
albums: winner.result.albums.length,
songs: winner.result.songs.length,
},
});
return;
}
showToast(t('search.liveSearchFailed'), 3200, 'error');
} else if (serverId) {
const network = await runNetworkLiveSearch(q, abort.signal);
if (isStale()) return;
if (network) {
setResults(network);
setSearchSource('network');
setOpen(true);
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'search3',
durationMs: Math.round(performance.now() - searchT0),
debounceMs,
indexEnabled,
counts: {
artists: network.artists.length,
albums: network.albums.length,
songs: network.songs.length,
},
});
}
}
} catch (err) {
if (isStale()) return;
const name = err instanceof Error ? err.name : '';
if (name === 'CanceledError' || name === 'AbortError') return;
showToast(t('search.liveSearchFailed'), 3200, 'error');
} finally {
if (!isStale()) setLoading(false);
}
})();
}, debounceMs);
return () => {
window.clearTimeout(timer);
abort.abort();
liveSearchGenRef.current += 1;
};
}, [query, share.shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t]);
const isSearchActive = isFocused || open || query.trim().length > 0;
@@ -332,7 +476,16 @@ export default function LiveSearch() {
autoComplete="off"
/>
{query && (
<button className="live-search-clear" onClick={() => { setQuery(''); setResults(null); setOpen(false); }} aria-label={t('search.clearLabel')}>
<button
className="live-search-clear"
onClick={() => {
setQuery('');
setResults(null);
setOpen(false);
setSearchSource(null);
}}
aria-label={t('search.clearLabel')}
>
×
</button>
)}
@@ -355,6 +508,31 @@ export default function LiveSearch() {
{open && (
<div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}>
{searchSource && !share.shareMatch && (
<div
className={`live-search-source live-search-source--${searchSource}`}
data-tooltip={t(
searchSource === 'local'
? 'search.localIndexBadgeTooltip'
: 'search.networkSearchBadgeTooltip',
)}
data-tooltip-pos="bottom"
>
{searchSource === 'local' ? (
<Database size={12} aria-hidden />
) : (
<Globe size={12} aria-hidden />
)}
<span>
{t(
searchSource === 'local'
? 'search.localIndexBadge'
: 'search.networkSearchBadge',
)}
</span>
</div>
)}
{!hasResults && !loading && (
<div className="search-empty">{t('search.noResults', { query })}</div>
)}
@@ -465,7 +643,11 @@ export default function LiveSearch() {
openContextMenu(e.clientX, e.clientY, songToTrack(s), 'song');
}}
role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Music size={14} /></div>
{(s.coverArt ?? s.albumId) ? (
<LiveSearchAlbumThumb coverArt={s.coverArt ?? s.albumId!} />
) : (
<div className="search-result-icon"><Music size={14} /></div>
)}
<div>
<div className="search-result-name">{s.title}</div>
<div className="search-result-sub">{s.artist} · {s.album}</div>
+4 -12
View File
@@ -1,4 +1,4 @@
import { star, unstar } from '../api/subsonicStarRating';
import { queueSongStar } from '../store/pendingStarSync';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import type { Track } from '../store/playerStoreTypes';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
@@ -182,7 +182,6 @@ export default function MobilePlayerView() {
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const duration = currentTrack?.duration ?? 0;
@@ -197,17 +196,10 @@ export default function MobilePlayerView() {
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
const toggleStar = useCallback(async () => {
const toggleStar = useCallback(() => {
if (!currentTrack) return;
const nextVal = !isStarred;
setStarredOverride(currentTrack.id, nextVal);
try {
if (nextVal) await star(currentTrack.id, 'song');
else await unstar(currentTrack.id, 'song');
} catch {
setStarredOverride(currentTrack.id, !nextVal);
}
}, [currentTrack, isStarred, setStarredOverride]);
queueSongStar(currentTrack.id, !isStarred);
}, [currentTrack, isStarred]);
// Scrubber touch/mouse drag
const scrubberRef = useRef<HTMLDivElement>(null);
+6 -16
View File
@@ -1,4 +1,4 @@
import { star, unstar } from '../api/subsonicStarRating';
import { queueSongStar } from '../store/pendingStarSync';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import type { SubsonicAlbum } from '../api/subsonicTypes';
@@ -62,8 +62,8 @@ export default function PlayerBar() {
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue,
starredOverrides, setStarredOverride,
userRatingOverrides, setUserRatingOverride,
starredOverrides,
userRatingOverrides,
openContextMenu,
} = usePlayerStore(useShallow(s => ({
currentTrack: s.currentTrack,
@@ -83,9 +83,7 @@ export default function PlayerBar() {
isQueueVisible: s.isQueueVisible,
toggleQueue: s.toggleQueue,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
userRatingOverrides: s.userRatingOverrides,
setUserRatingOverride: s.setUserRatingOverride,
openContextMenu: s.openContextMenu,
})));
const { lastfmSessionKey } = useAuthStore();
@@ -133,17 +131,10 @@ export default function PlayerBar() {
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
const toggleStar = useCallback(async () => {
const toggleStar = useCallback(() => {
if (!currentTrack) return;
const next = !isStarred;
setStarredOverride(currentTrack.id, next);
try {
if (next) await star(currentTrack.id, 'song');
else await unstar(currentTrack.id, 'song');
} catch {
setStarredOverride(currentTrack.id, !next);
}
}, [currentTrack, isStarred, setStarredOverride]);
queueSongStar(currentTrack.id, !isStarred);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
@@ -232,7 +223,6 @@ export default function PlayerBar() {
lastfmLoved={lastfmLoved}
toggleLastfmLove={toggleLastfmLove}
userRatingOverrides={userRatingOverrides}
setUserRatingOverride={setUserRatingOverride}
toggleFullscreen={toggleFullscreen}
navigate={navigatePlaybackLibrary}
openContextMenu={openContextMenu}
+11 -2
View File
@@ -7,6 +7,8 @@ import { Search as SearchIcon, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ndListSongs } from '../api/navidromeBrowse';
import { runLocalSongBrowse } from '../utils/library/advancedSearchLocal';
import { useAuthStore } from '../store/authStore';
import SongRow, { SongListHeader } from './SongRow';
const PAGE_SIZE = 50;
@@ -15,14 +17,21 @@ const ROW_HEIGHT = 52;
const PREFETCH_PX = 600;
/**
* Empty query Navidrome /api/song sorted by title (no Subsonic equivalent).
* Browse-all (empty query): local library index when ready (F1, same title-ASC
* order), else Navidrome /api/song sorted by title, else Subsonic search3.
* Non-empty Subsonic search3 (search isn't a browse).
* Either way, returns a SubsonicSong[]; on Navidrome failure we fall back to search3.
* Either way, returns a SubsonicSong[].
*/
async function fetchSongPage(query: string, offset: number): Promise<SubsonicSong[]> {
if (query !== '') {
return searchSongsPaged(query, PAGE_SIZE, offset);
}
const local = await runLocalSongBrowse(
useAuthStore.getState().activeServerId,
offset,
PAGE_SIZE,
);
if (local) return local;
try {
return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC');
} catch {
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Play, Radio, Heart, ChevronRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, Share2 } from 'lucide-react';
import { star, unstar } from '../../api/subsonicStarRating';
import { queueSongStar } from '../../store/pendingStarSync';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
import type { Track } from '../../store/playerStoreTypes';
import { useAuthStore } from '../../store/authStore';
@@ -13,7 +13,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
const {
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong,
starredOverrides, lastfmLovedCache, setLastfmLovedForSong,
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
@@ -71,9 +71,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !starred);
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
queueSongStar(song.id, !isStarred(song.id, song.starred));
})}>
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { getAlbum } from '../../api/subsonicLibrary';
import { star, unstar } from '../../api/subsonicStarRating';
import { queueSongStar } from '../../store/pendingStarSync';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
import type { Track } from '../../store/playerStoreTypes';
import { useAuthStore } from '../../store/authStore';
@@ -19,7 +19,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
const {
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong,
starredOverrides, lastfmLovedCache, setLastfmLovedForSong,
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
@@ -119,9 +119,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !starred);
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
queueSongStar(song.id, !isStarred(song.id, song.starred));
})}>
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
@@ -303,8 +301,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
setStarredOverride(song.id, false);
return unstar(song.id, 'song');
queueSongStar(song.id, false);
})}>
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
</div>
+3 -4
View File
@@ -1,6 +1,6 @@
import { Cast, Heart, Maximize2, Music } from 'lucide-react';
import type { TFunction } from 'i18next';
import { setRating } from '../../api/subsonicStarRating';
import { queueSongRating } from '../../store/pendingStarSync';
import type { InternetRadioStation, SubsonicAlbum, SubsonicOpenArtistRef } from '../../api/subsonicTypes';
import type { PlayerState, Track } from '../../store/playerStoreTypes';
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
@@ -39,7 +39,6 @@ interface Props {
lastfmLoved: boolean;
toggleLastfmLove: () => void;
userRatingOverrides: Record<string, number>;
setUserRatingOverride: (id: string, r: number) => void;
toggleFullscreen: () => void;
navigate: (to: string) => void | Promise<void>;
openContextMenu: PlayerState['openContextMenu'];
@@ -51,7 +50,7 @@ export function PlayerTrackInfo({
coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist, displayArtistRefs,
showPreviewMeta, previewingTrack, isStarred, toggleStar,
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
userRatingOverrides, setUserRatingOverride, toggleFullscreen,
userRatingOverrides, toggleFullscreen,
navigate, openContextMenu, t,
}: Props) {
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
@@ -159,7 +158,7 @@ export function PlayerTrackInfo({
{currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && (
<StarRating
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
onChange={r => queueSongRating(currentTrack.id, r)}
className="player-track-rating"
ariaLabel={t('albumDetail.ratingLabel')}
/>
@@ -0,0 +1,420 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DatabaseZap } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { showToast } from '../../utils/ui/toast';
import SettingsSubSection from '../SettingsSubSection';
import {
libraryGetStatus,
librarySyncCancel,
librarySyncClearSession,
subscribeLibrarySyncIdle,
subscribeLibrarySyncProgress,
type SyncStateDto,
} from '../../api/library';
import {
bootstrapAllIndexedServers,
bootstrapIndexedServer,
type BindServerResult,
} from '../../utils/library/librarySession';
import { enqueueLibrarySync } from '../../utils/library/librarySyncQueue';
import { syncIngestDisplayCount } from '../../utils/library/libraryReady';
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import LibraryIndexServerRow, { type LibraryServerConnection } from './LibraryIndexServerRow';
const STATUS_POLL_MS = 3000;
const SYNC_POLL_MS = 2500;
const OFFLINE_RETRY_MS = 60_000;
export default function LibraryIndexSection() {
const { t } = useTranslation();
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer);
const setMasterEnabled = useLibraryIndexStore(s => s.setMasterEnabled);
const setServerSyncExcluded = useLibraryIndexStore(s => s.setServerSyncExcluded);
const autoReconcile = useLibraryIndexStore(s => s.autoReconcileEnabled);
const setAutoReconcile = useLibraryIndexStore(s => s.setAutoReconcileEnabled);
const indexedIds = useMemo(() => {
if (!masterEnabled) return [];
return servers.map(s => s.id).filter(id => syncExcludedByServer[id] !== true);
}, [masterEnabled, syncExcludedByServer, servers]);
const indexedServers = useMemo(
() => servers.filter(s => indexedIds.includes(s.id)),
[servers, indexedIds],
);
const excludedServers = useMemo(
() => servers.filter(s => syncExcludedByServer[s.id] === true),
[servers, syncExcludedByServer],
);
const [statusByServer, setStatusByServer] = useState<Record<string, SyncStateDto | null>>({});
const [connectionByServer, setConnectionByServer] = useState<Record<string, LibraryServerConnection>>({});
const [progressByServer, setProgressByServer] = useState<Record<string, string | null>>({});
const [busyServerId, setBusyServerId] = useState<string | null>(null);
const [bootstrapping, setBootstrapping] = useState(false);
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const ingestCountRef = useRef<Record<string, number>>({});
const syncPhaseRef = useRef<Record<string, string | null>>({});
const applyConnectionResults = useCallback((results: Record<string, BindServerResult>) => {
setConnectionByServer(prev => {
const next = { ...prev };
for (const [id, result] of Object.entries(results)) {
next[id] = result === 'offline' ? 'offline' : result === 'bound' ? 'online' : 'unknown';
}
return next;
});
}, []);
const refreshAllStatuses = useCallback(async () => {
if (!masterEnabled || indexedServers.length === 0) return;
const entries = await Promise.all(
indexedServers.map(async srv => {
try {
const fresh = await libraryGetStatus(srv.id);
syncPhaseRef.current[srv.id] = fresh.syncPhase;
if (fresh.syncPhase === 'initial_sync') {
const next = Math.max(ingestCountRef.current[srv.id] ?? 0, syncIngestDisplayCount(fresh));
ingestCountRef.current[srv.id] = next;
setProgressByServer(p => ({
...p,
[srv.id]: t('settings.libraryIndexProgressIngest', { count: next }),
}));
} else if (fresh.syncPhase === 'ready' || fresh.syncPhase === 'idle') {
ingestCountRef.current[srv.id] = 0;
}
return [srv.id, fresh] as const;
} catch {
return [srv.id, null] as const;
}
}),
);
setStatusByServer(Object.fromEntries(entries));
}, [masterEnabled, indexedServers, t]);
const runBootstrap = useCallback(async () => {
if (!masterEnabled) return;
setBootstrapping(true);
try {
const results = await bootstrapAllIndexedServers();
applyConnectionResults(results);
await refreshAllStatuses();
} finally {
setBootstrapping(false);
}
}, [masterEnabled, applyConnectionResults, refreshAllStatuses]);
const retryOfflineServers = useCallback(async () => {
if (!masterEnabled) return;
const offline = indexedServers.filter(s => connectionByServer[s.id] === 'offline');
if (offline.length === 0) return;
const results: Record<string, BindServerResult> = {};
for (const srv of offline) {
results[srv.id] = await bootstrapIndexedServer(srv);
}
applyConnectionResults(results);
void refreshAllStatuses();
}, [masterEnabled, indexedServers, connectionByServer, applyConnectionResults, refreshAllStatuses]);
useEffect(() => {
if (!masterEnabled) {
setStatusByServer({});
setConnectionByServer({});
setProgressByServer({});
setBusyServerId(null);
return;
}
void runBootstrap();
}, [masterEnabled, indexedIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!masterEnabled) return;
const poll = () => {
void refreshAllStatuses();
const anyInitial = indexedServers.some(
s => syncPhaseRef.current[s.id] === 'initial_sync',
);
pollTimer.current = setTimeout(poll, anyInitial ? SYNC_POLL_MS : STATUS_POLL_MS);
};
poll();
return () => {
if (pollTimer.current) clearTimeout(pollTimer.current);
pollTimer.current = null;
};
}, [masterEnabled, indexedServers, refreshAllStatuses]);
useEffect(() => {
if (!masterEnabled) return;
const retryTimer = setInterval(() => {
void retryOfflineServers();
}, OFFLINE_RETRY_MS);
return () => clearInterval(retryTimer);
}, [masterEnabled, retryOfflineServers]);
useEffect(() => {
if (!masterEnabled) return;
const unsubs: Array<Promise<() => void>> = [
subscribeLibrarySyncProgress(p => {
if (!indexedIds.includes(p.serverId)) return;
setBusyServerId(p.serverId);
if (p.kind === 'ingest_page') {
const next = Math.max(ingestCountRef.current[p.serverId] ?? 0, p.ingestedTotal ?? 0);
ingestCountRef.current[p.serverId] = next;
setProgressByServer(prev => ({
...prev,
[p.serverId]: t('settings.libraryIndexProgressIngest', { count: next }),
}));
} else if (p.kind === 'tombstoned') {
setProgressByServer(prev => ({
...prev,
[p.serverId]: t('settings.libraryIndexProgressVerify', {
checked: p.tombstonesChecked ?? 0,
deleted: p.tombstonesDeleted ?? 0,
}),
}));
} else if (p.kind === 'phase_changed' && p.phase) {
setProgressByServer(prev => ({ ...prev, [p.serverId]: p.phase ?? null }));
}
}),
subscribeLibrarySyncIdle(p => {
if (!indexedIds.includes(p.serverId)) return;
setBusyServerId(cur => (cur === p.serverId ? null : cur));
ingestCountRef.current[p.serverId] = 0;
setProgressByServer(prev => ({ ...prev, [p.serverId]: null }));
void refreshAllStatuses();
if (!p.ok && p.error) {
showToast(t('settings.libraryIndexSyncError', { error: p.error }), 5000, 'error');
}
}),
];
return () => {
unsubs.forEach(u => void u.then(fn => fn()));
};
}, [masterEnabled, indexedIds, refreshAllStatuses, t]);
const handleMasterToggle = async (enabled: boolean) => {
if (enabled) {
setMasterEnabled(true);
await runBootstrap();
return;
}
setBootstrapping(true);
try {
for (const srv of servers) {
try {
await librarySyncClearSession(srv.id);
} catch {
/* best-effort */
}
}
setMasterEnabled(false);
setStatusByServer({});
setConnectionByServer({});
setProgressByServer({});
setBusyServerId(null);
} finally {
setBootstrapping(false);
}
};
const runServerAction = async (
serverId: string,
action: 'full' | 'delta' | 'verify',
) => {
setBusyServerId(serverId);
try {
const kind =
action === 'verify'
? 'verify'
: action === 'full'
? 'full'
: statusByServer[serverId]?.lastFullSyncAt
? 'delta'
: 'full';
ingestCountRef.current[serverId] = 0;
await enqueueLibrarySync({ serverId, kind });
} catch (e) {
setBusyServerId(null);
showToast(t('settings.libraryIndexSyncError', { error: String(e) }), 5000, 'error');
}
};
const handleIncludeServer = async (serverId: string) => {
setServerSyncExcluded(serverId, false);
const srv = servers.find(s => s.id === serverId);
if (srv) {
setBootstrapping(true);
try {
const result = await bootstrapIndexedServer(srv);
applyConnectionResults({ [serverId]: result });
await refreshAllStatuses();
} finally {
setBootstrapping(false);
}
}
};
const handleExcludeServer = async (serverId: string) => {
setBootstrapping(true);
try {
await librarySyncClearSession(serverId);
setServerSyncExcluded(serverId, true);
setStatusByServer(prev => {
const next = { ...prev };
delete next[serverId];
return next;
});
setConnectionByServer(prev => {
const next = { ...prev };
delete next[serverId];
return next;
});
} catch (e) {
showToast(t('settings.libraryIndexBindError', { error: String(e) }), 5000, 'error');
} finally {
setBootstrapping(false);
}
};
const handleCancel = async () => {
try {
await librarySyncCancel();
} catch {
/* best-effort */
}
};
const globalBusy = bootstrapping || busyServerId != null;
return (
<SettingsSubSection
title={t('settings.libraryIndexTitle')}
icon={<DatabaseZap size={16} />}
>
<div className="settings-card">
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.libraryIndexDesc')}
</p>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.libraryIndexDeltaHint')}
</p>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.libraryIndexEnable')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{servers.length > 0
? t('settings.libraryIndexEnableAllDesc')
: t('settings.libraryIndexNoServer')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.libraryIndexEnable')}>
<input
type="checkbox"
checked={masterEnabled}
disabled={servers.length === 0 || bootstrapping}
onChange={e => void handleMasterToggle(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
{masterEnabled && (
<>
<div className="settings-section-divider" />
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.65rem' }}>
{t('settings.libraryIndexServerListTitle')}
</div>
{indexedServers.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>
{t('settings.libraryIndexAllExcluded')}
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
{indexedServers.map(srv => (
<LibraryIndexServerRow
key={srv.id}
server={srv}
allServers={servers}
isActive={srv.id === activeServerId}
status={statusByServer[srv.id] ?? null}
connection={connectionByServer[srv.id] ?? 'unknown'}
progressLabel={progressByServer[srv.id] ?? null}
busy={busyServerId === srv.id}
actionsDisabled={globalBusy && busyServerId !== srv.id}
onFullSync={() => void runServerAction(srv.id, 'full')}
onDeltaSync={() => void runServerAction(srv.id, 'delta')}
onVerify={() => void runServerAction(srv.id, 'verify')}
onExclude={() => void handleExcludeServer(srv.id)}
/>
))}
</div>
)}
{excludedServers.length > 0 && (
<>
<div style={{ fontSize: 13, fontWeight: 500, margin: '1rem 0 0.5rem' }}>
{t('settings.libraryIndexExcludedTitle')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.45rem' }}>
{excludedServers.map(srv => (
<div
key={srv.id}
className="settings-card"
style={{ padding: '0.65rem 1rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}
>
<span style={{ fontSize: 13 }}>{serverListDisplayLabel(srv, servers)}</span>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={bootstrapping}
onClick={() => void handleIncludeServer(srv.id)}
>
{t('settings.libraryIndexIncludeServer')}
</button>
</div>
))}
</div>
</>
)}
{busyServerId && (
<div style={{ marginTop: '0.75rem' }}>
<button type="button" className="btn btn-ghost" onClick={() => void handleCancel()}>
{t('settings.libraryIndexCancel')}
</button>
</div>
)}
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.libraryIndexAutoReconcile')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.libraryIndexAutoReconcileDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.libraryIndexAutoReconcile')}>
<input
type="checkbox"
checked={autoReconcile}
onChange={e => setAutoReconcile(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
</>
)}
</div>
</SettingsSubSection>
);
}
@@ -0,0 +1,145 @@
import { RefreshCw, ShieldCheck, WifiOff, Zap, Ban } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { ServerProfile } from '../../store/authStoreTypes';
import type { SyncStateDto } from '../../api/library';
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import {
libraryStatusDisplayTrackCount,
libraryStatusIsReady,
} from '../../utils/library/libraryReady';
export type LibraryServerConnection = 'online' | 'offline' | 'unknown';
interface LibraryIndexServerRowProps {
server: ServerProfile;
allServers: ServerProfile[];
isActive: boolean;
status: SyncStateDto | null;
connection: LibraryServerConnection;
progressLabel: string | null;
busy: boolean;
actionsDisabled: boolean;
onFullSync: () => void;
onDeltaSync: () => void;
onVerify: () => void;
onExclude: () => void;
}
export default function LibraryIndexServerRow({
server,
allServers,
isActive,
status,
connection,
progressLabel,
busy,
actionsDisabled,
onFullSync,
onDeltaSync,
onVerify,
onExclude,
}: LibraryIndexServerRowProps) {
const { t } = useTranslation();
const name = serverListDisplayLabel(server, allServers);
const phaseLabel = (() => {
if (connection === 'offline') {
return t('settings.libraryIndexServerOffline');
}
if (progressLabel) return progressLabel;
if (!status) return t('settings.libraryIndexStatusIdle');
if (libraryStatusIsReady(status)) {
return t('settings.libraryIndexStatusReady', {
count: libraryStatusDisplayTrackCount(status),
});
}
switch (status.syncPhase) {
case 'initial_sync':
return t('settings.libraryIndexStatusInitial');
case 'error':
return t('settings.libraryIndexStatusError');
case 'probing':
return t('settings.libraryIndexStatusProbing');
default:
return t('settings.libraryIndexStatusIdle');
}
})();
return (
<div
className="settings-card"
style={{
padding: '0.85rem 1rem',
border: isActive ? '1px solid color-mix(in srgb, var(--accent) 45%, transparent)' : undefined,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '0.75rem', flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600 }}>{name}</span>
{isActive && (
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: 'var(--radius-sm)', fontWeight: 600 }}>
{t('settings.serverActive')}
</span>
)}
{connection === 'offline' && (
<span style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-muted)' }}>
<WifiOff size={12} />
{t('settings.libraryIndexServerDeferred')}
</span>
)}
{busy && (
<span style={{ fontSize: 11, color: 'var(--accent)' }}>{t('settings.libraryIndexServerSyncing')}</span>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4, lineHeight: 1.45 }}>
{phaseLabel}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.65rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onFullSync}
>
<RefreshCw size={13} />
{t('settings.libraryIndexFullResync')}
</button>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onDeltaSync}
>
<Zap size={13} />
{t('settings.libraryIndexDeltaSync')}
</button>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onVerify}
>
<ShieldCheck size={13} />
{t('settings.libraryIndexVerify')}
</button>
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', color: 'var(--text-muted)' }}
disabled={actionsDisabled}
onClick={onExclude}
>
<Ban size={13} />
{t('settings.libraryIndexExcludeServer')}
</button>
</div>
</div>
);
}
+4
View File
@@ -5,6 +5,7 @@ import { useAuthStore } from '../../store/authStore';
import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults';
import SettingsSubSection from '../SettingsSubSection';
import StarRating from '../StarRating';
import LibraryIndexSection from './LibraryIndexSection';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
@@ -15,6 +16,9 @@ export function LibraryTab() {
return (
<>
{/* Local library index (spec §7.3) */}
<LibraryIndexSection />
{/* Random Mix Blacklist */}
<SettingsSubSection
title={t('settings.randomMixTitle')}
+27 -4
View File
@@ -4,6 +4,9 @@ import { useNavigate } from 'react-router-dom';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { AlertTriangle, CheckCircle2, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { libraryDeleteServerData, librarySyncClearSession } from '../../api/library';
import { bootstrapIndexedServer } from '../../utils/library/librarySession';
import type { ServerProfile } from '../../store/authStoreTypes';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
import { useDragDrop } from '../../contexts/DragDropContext';
@@ -133,9 +136,25 @@ export function ServersTab({
}
};
const deleteServer = (server: ServerProfile) => {
if (confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) {
auth.removeServer(server.id);
const deleteServer = async (server: ServerProfile) => {
if (!confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) {
return;
}
// §5.6: when a local library index exists for this server, let the
// user keep the cached rows (offline use) or delete them. OK =
// delete the cache, Cancel = keep it.
const hadIndex = useLibraryIndexStore.getState().isIndexEnabled(server.id);
const purgeLibrary = hadIndex && confirm(t('settings.confirmDeleteServerLibrary'));
auth.removeServer(server.id);
useLibraryIndexStore.getState().setIndexEnabled(server.id, false);
try {
await librarySyncClearSession(server.id);
if (purgeLibrary) {
await libraryDeleteServerData(server.id);
}
} catch {
/* best-effort — server already removed from the store */
}
};
@@ -161,6 +180,10 @@ export function ServersTab({
auth.setSubsonicServerIdentity(id, identity);
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity);
setConnStatus(s => ({ ...s, [id]: 'ok' }));
if (useLibraryIndexStore.getState().masterEnabled) {
const added = useAuthStore.getState().servers.find(s => s.id === id);
if (added) void bootstrapIndexedServer(added);
}
} else {
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
}
@@ -317,7 +340,7 @@ export function ServersTab({
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)', padding: '4px 8px' }}
onClick={() => deleteServer(srv)}
onClick={() => void deleteServer(srv)}
data-tooltip={t('settings.deleteServer')}
id={`settings-delete-server-${srv.id}`}
>
+1
View File
@@ -49,6 +49,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'personalisation',titleKey: 'settings.playlistLayoutTitle', keywords: 'playlist page layout add songs import csv download zip cache offline suggestions controls hide show' },
{ tab: 'personalisation',titleKey: 'settings.playerBarTitle', keywords: 'player bar playback favorites stars rating lastfm love equalizer mini player controls hide show' },
{ tab: 'appearance', titleKey: 'settings.libraryGridMaxColumnsTitle', keywords: 'grid columns album artist playlist cards layout appearance performance scroll paint' },
{ tab: 'library', titleKey: 'settings.libraryIndexTitle', keywords: 'local library index sync offline search sqlite background delta' },
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
+3 -1
View File
@@ -122,6 +122,7 @@ const CONTRIBUTOR_ENTRIES = [
'In-page browse: virtual list scrollMargin + CachedImage load priority; Artists infinite-scroll batching (PR #783)',
'Lucky Mix: hand off queue to browsed server after multi-server switch (PR #785)',
'Home album rails: stable play/enqueue hover on WebKitGTK/Wayland (PR #787)',
'Local library index: multi-server settings UI, serial sync queue, music-library-scoped local search, parallel initial ingest, i18n across 9 locales (PR #846)',
],
},
{
@@ -315,8 +316,9 @@ const CONTRIBUTOR_ENTRIES = [
'Home: Lossless Albums rail + dedicated /lossless-albums page with infinite scroll and header parity (selection mode, enqueue, offline, download ZIPs), streaming load via per-fetch onProgress, sidebar entry default visible, detection via Navidrome native bit_depth-sorted song cursor with always-lossless suffix allowlist (PR #506)',
'Accessibility: OpenDyslexic font option in the Settings picker — bundled locally via @fontsource/opendyslexic, asymmetric glyph shapes for easier b/d, p/q tracking, Latin-only with translated subtitle in all 9 locales calling out the dyslexia-friendly intent and the Cyrillic/CJK fallback (PR #507)',
'Settings: tri-state Clock Format (Auto / 24h / 12h) overriding the locale default for the queue ETA and the sleep-timer preview (PR #742)',
'Servers: per-server library scan triggers (Quick / Full) and inline edit for existing profiles (PR #780)',
'Servers: inline edit for existing profiles (PR #780)',
'Interface Scale: scales the entire window — sidebar, queue, player bar, modals and the fullscreen player follow the main content (PR #781)',
'Local library index (preview): SQLite per-server track store, background initial and delta sync, live and Advanced Search against the local index, integrity verify and auto-reconcile on count drop (PR #846)',
],
},
] as const;
+4 -19
View File
@@ -1,4 +1,4 @@
import { star, unstar } from '../api/subsonicStarRating';
import { queueSongStar } from '../store/pendingStarSync';
import { getSong } from '../api/subsonicLibrary';
import { songToTrack } from '../utils/playback/songToTrack';
import { invoke } from '@tauri-apps/api/core';
@@ -261,12 +261,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
showToast(i18n.t('contextMenu.cliMixNeedsTrack', { defaultValue: 'Load a track first.' }), 5000, 'error');
return;
}
star(track.id, 'song')
.then(() => usePlayerStore.getState().setStarredOverride(track.id, true))
.catch(err => {
console.error('Favorite current track failed', err);
showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Could not add the track to favorites.' }), 5000, 'error');
});
queueSongStar(track.id, true);
},
},
'open-help': {
@@ -314,12 +309,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
star(track.id, 'song')
.then(() => usePlayerStore.getState().setStarredOverride(track.id, true))
.catch(err => {
console.error('CLI star failed', err);
showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error');
});
queueSongStar(track.id, true);
},
cli: { verb: 'star', description: 'star' },
},
@@ -332,12 +322,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
unstar(track.id, 'song')
.then(() => usePlayerStore.getState().setStarredOverride(track.id, false))
.catch(err => {
console.error('CLI star failed', err);
showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error');
});
queueSongStar(track.id, false);
},
cli: { verb: 'unstar', description: 'unstar' },
},
+3 -6
View File
@@ -1,4 +1,4 @@
import { setRating } from '../api/subsonicStarRating';
import { queueSongRating } from '../store/pendingStarSync';
import i18n from '../i18n';
import { usePlayerStore } from '../store/playerStore';
import { showToast } from '../utils/ui/toast';
@@ -90,11 +90,8 @@ export function executeCliPlayerCommand(ctx: CliContext): void | Promise<void> {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
return setRating(track.id, stars)
.then(() => {
usePlayerStore.getState().setUserRatingOverride(track.id, stars);
})
.catch(err => console.error('CLI set rating failed', err));
queueSongRating(track.id, stars);
return;
}
// no-op for unknown command
}
@@ -0,0 +1,128 @@
import { useEffect } from 'react';
import { useAuthStore } from '../../store/authStore';
import {
libraryGetStatus,
libraryGetPlaybackHint,
subscribeLibrarySyncIdle,
subscribeLibrarySyncProgress,
} from '../../api/library';
import {
activeIngestStrategy,
ingestParallelismNote,
ingestStallHint,
libraryDevEnabled,
logLibraryStatus,
logLibrarySync,
normalizeIngestMetrics,
timed,
} from '../../utils/library/libraryDevLog';
/**
* DevTools: log library sync progress + idle with ingest strategy from status.
* Filter console: `[psysonic][library]`
*/
export function useLibraryDevSyncLog(): void {
const serverId = useAuthStore(s => s.activeServerId);
useEffect(() => {
if (!libraryDevEnabled()) return;
let unlistenProgress: (() => void) | undefined;
let unlistenIdle: (() => void) | undefined;
let lastIngestStatusFetchMs = 0;
let lastIngestEventMs = 0;
void subscribeLibrarySyncProgress(payload => {
const now = Date.now();
const sinceLastIngestMs =
payload.kind === 'ingest_page' && lastIngestEventMs > 0
? now - lastIngestEventMs
: undefined;
if (payload.kind === 'ingest_page') {
lastIngestEventMs = now;
}
const metrics = normalizeIngestMetrics(payload.ingestMetrics);
const stallHint = metrics ? ingestStallHint(metrics) : undefined;
logLibrarySync({
at: new Date().toISOString(),
kind: payload.kind,
serverId: payload.serverId,
libraryScope: payload.libraryScope,
ingestPhase: payload.phase ?? null,
ingestedTotal: payload.ingestedTotal ?? null,
batchCount: payload.batchCount ?? null,
message: payload.message ?? payload.completedKind ?? null,
sinceLastIngestMs,
ingestMetrics: metrics,
stallHint,
});
const shouldFetchStatus =
payload.kind === 'phase_changed' ||
(payload.kind === 'ingest_page' &&
Date.now() - lastIngestStatusFetchMs >= 2500);
if (shouldFetchStatus) {
if (payload.kind === 'ingest_page') {
lastIngestStatusFetchMs = Date.now();
}
void Promise.all([
timed(() => libraryGetStatus(payload.serverId)),
libraryGetPlaybackHint().catch(() => 'idle' as const),
]).then(([{ result: status, ms }, playbackHint]) => {
const ingest = activeIngestStrategy(status);
logLibrarySync({
at: new Date().toISOString(),
kind: payload.kind,
serverId: payload.serverId,
libraryScope: payload.libraryScope,
ingestStrategy: ingest.tag,
ingestPhase: status.ingestPhase ?? payload.phase ?? null,
syncPhase: status.syncPhase,
ingestedTotal: payload.ingestedTotal ?? status.cursorIngestedCount ?? null,
batchCount: payload.batchCount ?? null,
message: ingestParallelismNote(ingest.tag, playbackHint),
durationMs: ms,
});
logLibraryStatus(payload.serverId, status, `sync-${payload.kind} (${ms}ms)`, playbackHint);
});
}
}).then(fn => {
unlistenProgress = fn;
});
void subscribeLibrarySyncIdle(payload => {
void (async () => {
const { result: status, ms } = await timed(() => libraryGetStatus(payload.serverId));
logLibrarySync({
at: new Date().toISOString(),
kind: payload.ok ? `idle_${payload.kind}` : 'idle_error',
serverId: payload.serverId,
libraryScope: payload.libraryScope,
ingestStrategy: status.ingestStrategy ?? null,
ingestPhase: status.ingestPhase ?? null,
syncPhase: status.syncPhase,
n1BulkUnreliable: status.n1BulkUnreliable ?? null,
message: payload.error ?? null,
durationMs: ms,
});
logLibraryStatus(payload.serverId, status, `sync-idle (${ms}ms)`);
})();
}).then(fn => {
unlistenIdle = fn;
});
return () => {
unlistenProgress?.();
unlistenIdle?.();
};
}, []);
useEffect(() => {
if (!libraryDevEnabled() || !serverId) return;
void timed(() => libraryGetStatus(serverId)).then(({ result: status, ms }) => {
logLibraryStatus(serverId, status, `active-server (${ms}ms)`);
});
}, [serverId]);
}
+4 -3
View File
@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { setRating } from '../api/subsonicStarRating';
import { queueSongRating } from '../store/pendingStarSync';
import type { SubsonicAlbum, SubsonicArtist } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import { useAuthStore } from '../store/authStore';
@@ -31,9 +32,9 @@ export function useContextMenuRating({
const activeServerId = useAuthStore(s => s.activeServerId);
const applySongRating = useCallback((songId: string, rating: number) => {
setUserRatingOverride(songId, rating);
setRating(songId, rating).catch(() => {});
}, [setUserRatingOverride]);
// F4: optimistic override + retry-with-backoff sync via the central helper.
queueSongRating(songId, rating);
}, []);
const applyAlbumRating = useCallback((album: SubsonicAlbum, rating: number) => {
setUserRatingOverride(album.id, rating);
+4 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { star, unstar } from '../api/subsonicStarRating';
import { queueSongStar } from '../store/pendingStarSync';
import type { SubsonicSong } from '../api/subsonicTypes';
import {
lastfmLoveTrack, lastfmUnloveTrack,
@@ -29,8 +29,9 @@ export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingS
useEffect(() => { setStarred(!!songMeta?.starred); }, [songMeta]);
const toggleStar = useCallback(async () => {
if (!currentTrack) return;
if (starred) { await unstar(currentTrack.id, 'song'); setStarred(false); }
else { await star(currentTrack.id, 'song'); setStarred(true); }
const next = !starred;
setStarred(next); // local view; helper owns the override + retried server sync (no rollback)
queueSongStar(currentTrack.id, next);
}, [currentTrack, starred]);
// Last.fm love (seeded from track.getInfo, toggle via love/unlove)
+5 -6
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { setRating, star, unstar } from '../api/subsonicStarRating';
import { queueSongStar, queueSongRating } from '../store/pendingStarSync';
import type { SubsonicSong } from '../api/subsonicTypes';
import { usePlayerStore } from '../store/playerStore';
@@ -18,12 +18,11 @@ export interface PlaylistStarRatingActions {
export function usePlaylistStarRating(deps: PlaylistStarRatingDeps): PlaylistStarRatingActions {
const { setRatings, starredSongs, setStarredSongs } = deps;
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const handleRate = (songId: string, rating: number) => {
setRatings(prev => ({ ...prev, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(songId, rating);
setRating(songId, rating).catch(() => {});
// F4: optimistic override + retried server sync via the central helper.
queueSongRating(songId, rating);
};
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
@@ -34,8 +33,8 @@ export function usePlaylistStarRating(deps: PlaylistStarRatingDeps): PlaylistSta
isStarred ? next.delete(song.id) : next.add(song.id);
return next;
});
setStarredOverride(song.id, !isStarred);
(isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {});
// F4: optimistic override + retried server sync via the central helper (no rollback).
queueSongStar(song.id, !isStarred);
};
return { handleRate, handleToggleStar };
+6
View File
@@ -10,6 +10,11 @@ export const search = {
resultsFor: 'Ergebnisse für „{{query}}"',
album: 'Album',
advanced: 'Erweiterte Suche',
localIndexBadge: 'Lokaler Index',
localIndexBadgeTooltip: 'Ergebnisse aus dem lokalen Bibliotheksindex auf diesem Gerät',
networkSearchBadge: 'Serversuche',
networkSearchBadgeTooltip: 'Ergebnisse der Live-Suche auf dem verbundenen Server',
liveSearchFailed: 'Suche fehlgeschlagen — bitte erneut versuchen',
advancedSearchTerm: 'Suchbegriff',
advancedSearchPlaceholder: 'Titel, Album, Künstler…',
advancedGenre: 'Genre',
@@ -17,6 +22,7 @@ export const search = {
advancedYear: 'Jahr',
advancedYearFrom: 'von',
advancedYearTo: 'bis',
advancedBpm: 'BPM',
advancedAll: 'Alle',
advancedSearch: 'Suchen',
advancedEmpty: 'Suchbegriff eingeben oder Filter wählen, um zu beginnen.',
+32
View File
@@ -29,6 +29,7 @@ export const settings = {
noServers: 'Keine Server gespeichert.',
serverActive: 'Aktiv',
confirmDeleteServer: 'Server „{{name}}" löschen?',
confirmDeleteServerLibrary: 'Auch den lokalen Bibliotheksindex dieses Servers löschen? Auf Abbrechen klicken, um die zwischengespeicherte Kopie für die Offline-Nutzung zu behalten.',
serverConnecting: 'Verbinde…',
serverConnected: 'Verbunden!',
serverFailed: 'Verbindung fehlgeschlagen.',
@@ -246,6 +247,37 @@ export const settings = {
libraryGridMaxColumnsPerfHint: 'Mehr Spalten bedeuten mehr Kacheln pro Zeile und oft mehr Layout- und Zeichenaufwand — bei sehr großen Bibliotheken oder langsamerer Hardware spürbarer.',
libraryGridMaxColumnsRangeLabel: 'Maximale Spalten ({{min}}{{max}})',
libraryGridMaxColumnsDesc: 'Gilt für Album-, Künstler-, Wiedergabelisten-, Radio-, Offline- und andere Kartenansichten. Weniger Spalten = größere Kacheln und meist weniger CPU-Last.',
libraryIndexTitle: 'Lokaler Bibliotheksindex (Vorschau)',
libraryIndexDesc: 'Behält eine lokale Kopie der Track-Datenbank jedes Servers vor, damit Stöbern und Suche schnell bleiben und offline funktionieren. Die Erstsynchronisation läuft für alle konfigurierten Server; offline Server werden automatisch erneut versucht.',
libraryIndexDeltaHint: 'Die Hintergrund-Delta-Synchronisation prüft gebundene Server alle 30 Sekunden und startet bei Bedarf — typischerweise alle 545 Minuten, abhängig von Bibliotheksgröße und Last.',
libraryIndexEnable: 'Lokalen Bibliotheksindex aktivieren',
libraryIndexEnableAllDesc: 'Alle konfigurierten Server im Hintergrund indizieren und synchronisieren.',
libraryIndexNoServer: 'Zuerst einen Server hinzufügen.',
libraryIndexServerListTitle: 'Indizierte Server',
libraryIndexAllExcluded: 'Alle Server sind von der Synchronisation ausgeschlossen. Index wieder aktivieren oder Server hinzufügen.',
libraryIndexServerOffline: 'Server offline — Synchronisation verschoben',
libraryIndexServerDeferred: 'Verschoben',
libraryIndexServerSyncing: 'Synchronisiert…',
libraryIndexFullResync: 'Vollständige Neusynchronisation',
libraryIndexDeltaSync: 'Delta-Synchronisation',
libraryIndexExcludeServer: 'Von Synchronisation ausschließen',
libraryIndexExcludedTitle: 'Von Synchronisation ausgeschlossen',
libraryIndexIncludeServer: 'Wieder einschließen',
libraryIndexStatus: 'Status',
libraryIndexStatusIdle: 'Bereit',
libraryIndexStatusProbing: 'Server wird geprüft…',
libraryIndexStatusInitial: 'Erst-Synchronisation…',
libraryIndexStatusReady: 'Fertig ({{count}} Titel)',
libraryIndexStatusError: 'Fehler — siehe Logs',
libraryIndexProgressIngest: '{{count}} Titel indiziert…',
libraryIndexProgressVerify: '{{checked}} geprüft ({{deleted}} entfernt)',
libraryIndexSyncNow: 'Jetzt synchronisieren',
libraryIndexVerify: 'Bibliotheksintegrität prüfen',
libraryIndexCancel: 'Abbrechen',
libraryIndexAutoReconcile: 'Automatischer Abgleich bei Titelrückgang',
libraryIndexAutoReconcileDesc: 'Automatisch nach entfernten Titeln suchen, wenn der Server weniger als erwartet meldet.',
libraryIndexSyncError: 'Bibliotheks-Synchronisation fehlgeschlagen: {{error}}',
libraryIndexBindError: 'Index konnte nicht aktiviert werden: {{error}}',
randomMixTitle: 'Zufallsmix-Blacklist',
luckyMixMenuTitle: 'Glücks-Mix im Menü anzeigen',
luckyMixMenuDesc: 'Aktiviert Glücks-Mix im "Mix erstellen"-Hub und als separaten Menüeintrag bei getrennter Navigation. Sichtbar nur bei aktiviertem AudioMuse auf dem aktiven Server.',
+6
View File
@@ -10,6 +10,11 @@ export const search = {
resultsFor: 'Results for "{{query}}"',
album: 'Album',
advanced: 'Advanced Search',
localIndexBadge: 'Local index',
localIndexBadgeTooltip: 'Results from the local library index on this device',
networkSearchBadge: 'Server search',
networkSearchBadgeTooltip: 'Results from live search on the connected server',
liveSearchFailed: 'Search failed — try again',
advancedSearchTerm: 'Search term',
advancedSearchPlaceholder: 'Title, album, artist…',
advancedGenre: 'Genre',
@@ -17,6 +22,7 @@ export const search = {
advancedYear: 'Year',
advancedYearFrom: 'from',
advancedYearTo: 'to',
advancedBpm: 'BPM',
advancedAll: 'All',
advancedSearch: 'Search',
advancedEmpty: 'Enter a search term or select a filter to begin.',
+32
View File
@@ -29,6 +29,7 @@ export const settings = {
noServers: 'No servers saved.',
serverActive: 'Active',
confirmDeleteServer: 'Delete server "{{name}}"?',
confirmDeleteServerLibrary: 'Also delete this server\'s local library index? Click Cancel to keep the cached copy for offline use.',
serverConnecting: 'Connecting…',
serverConnected: 'Connected!',
serverFailed: 'Connection failed.',
@@ -249,6 +250,37 @@ export const settings = {
libraryGridMaxColumnsPerfHint: 'Higher values pack more tiles per row and can increase layout and painting work—noticeable on very large libraries or slower devices.',
libraryGridMaxColumnsRangeLabel: 'Maximum columns ({{min}}{{max}})',
libraryGridMaxColumnsDesc: 'Applies to album, artist, playlist, radio, offline, and other card-based library views. Lower values use larger tiles and are usually easier on the CPU.',
libraryIndexTitle: 'Local library index (preview)',
libraryIndexDesc: 'Keep a local copy of each server\'s track database so browsing and search stay fast and work offline. Initial sync runs for every configured server; offline servers are retried automatically.',
libraryIndexDeltaHint: 'Background delta sync checks bound servers every 30 seconds and runs when due — typically every 545 minutes depending on library size and load.',
libraryIndexEnable: 'Enable local library index',
libraryIndexEnableAllDesc: 'Index and sync all configured servers in the background.',
libraryIndexNoServer: 'Add a server first.',
libraryIndexServerListTitle: 'Indexed servers',
libraryIndexAllExcluded: 'All servers are excluded from sync. Re-enable the index or add a server.',
libraryIndexServerOffline: 'Server offline — sync deferred',
libraryIndexServerDeferred: 'Deferred',
libraryIndexServerSyncing: 'Syncing…',
libraryIndexFullResync: 'Full resync',
libraryIndexDeltaSync: 'Delta sync',
libraryIndexExcludeServer: 'Exclude from sync',
libraryIndexExcludedTitle: 'Excluded from sync',
libraryIndexIncludeServer: 'Include again',
libraryIndexStatus: 'Status',
libraryIndexStatusIdle: 'Idle',
libraryIndexStatusProbing: 'Checking server…',
libraryIndexStatusInitial: 'Initial sync…',
libraryIndexStatusReady: 'Ready ({{count}} tracks)',
libraryIndexStatusError: 'Error — see logs',
libraryIndexProgressIngest: 'Indexed {{count}} tracks…',
libraryIndexProgressVerify: 'Verified {{checked}} ({{deleted}} removed)',
libraryIndexSyncNow: 'Sync now',
libraryIndexVerify: 'Verify library integrity',
libraryIndexCancel: 'Cancel',
libraryIndexAutoReconcile: 'Auto-reconcile on count drop',
libraryIndexAutoReconcileDesc: 'Automatically check for removed tracks when the server reports fewer than expected.',
libraryIndexSyncError: 'Library sync failed: {{error}}',
libraryIndexBindError: 'Could not enable index: {{error}}',
randomMixTitle: 'Random Mix Blacklist',
luckyMixMenuTitle: 'Show Lucky Mix in menu',
luckyMixMenuDesc: 'Enables Lucky Mix in Build a Mix and as a separate menu item when split navigation is on. Visible only when AudioMuse is enabled on the active server.',
+6
View File
@@ -10,6 +10,11 @@ export const search = {
resultsFor: 'Resultados para "{{query}}"',
album: 'Álbum',
advanced: 'Búsqueda Avanzada',
localIndexBadge: 'Índice local',
localIndexBadgeTooltip: 'Resultados del índice local de biblioteca en este dispositivo',
networkSearchBadge: 'Búsqueda en servidor',
networkSearchBadgeTooltip: 'Resultados de búsqueda en vivo en el servidor conectado',
liveSearchFailed: 'Error en la búsqueda — inténtalo de nuevo',
advancedSearchTerm: 'Término de búsqueda',
advancedSearchPlaceholder: 'Título, álbum, artista…',
advancedGenre: 'Género',
@@ -17,6 +22,7 @@ export const search = {
advancedYear: 'Año',
advancedYearFrom: 'desde',
advancedYearTo: 'hasta',
advancedBpm: 'BPM',
advancedAll: 'Todos',
advancedSearch: 'Buscar',
advancedEmpty: 'Ingresa un término de búsqueda o selecciona un filtro para comenzar.',
+31
View File
@@ -245,6 +245,37 @@ export const settings = {
libraryGridMaxColumnsPerfHint: 'Más columnas caben más mosaicos por fila y pueden aumentar el trabajo de diseño y pintado; se nota más en bibliotecas muy grandes o equipos lentos.',
libraryGridMaxColumnsRangeLabel: 'Máximo de columnas ({{min}}{{max}})',
libraryGridMaxColumnsDesc: 'Se aplica a vistas de álbum, artista, lista de reproducción, radio, sin conexión y otras con tarjetas. Menos columnas = mosaicos más grandes y suele aligerar la CPU.',
libraryIndexTitle: 'Índice local de biblioteca (vista previa)',
libraryIndexDesc: 'Mantiene una copia local de la base de datos de pistas de cada servidor para que la navegación y la búsqueda sean rápidas y funcionen sin conexión. La sincronización inicial se ejecuta en todos los servidores configurados; los servidores sin conexión se reintentan automáticamente.',
libraryIndexDeltaHint: 'La sincronización delta en segundo plano comprueba los servidores vinculados cada 30 segundos y se ejecuta cuando corresponde — normalmente cada 545 minutos según el tamaño de la biblioteca y la carga.',
libraryIndexEnable: 'Activar índice local de biblioteca',
libraryIndexEnableAllDesc: 'Indexar y sincronizar todos los servidores configurados en segundo plano.',
libraryIndexNoServer: 'Añade un servidor primero.',
libraryIndexServerListTitle: 'Servidores indexados',
libraryIndexAllExcluded: 'Todos los servidores están excluidos de la sincronización. Vuelve a activar el índice o añade un servidor.',
libraryIndexServerOffline: 'Servidor sin conexión — sincronización aplazada',
libraryIndexServerDeferred: 'Aplazada',
libraryIndexServerSyncing: 'Sincronizando…',
libraryIndexFullResync: 'Resincronización completa',
libraryIndexDeltaSync: 'Sincronización delta',
libraryIndexExcludeServer: 'Excluir de la sincronización',
libraryIndexExcludedTitle: 'Excluidos de la sincronización',
libraryIndexIncludeServer: 'Incluir de nuevo',
libraryIndexStatus: 'Estado',
libraryIndexStatusIdle: 'Inactivo',
libraryIndexStatusProbing: 'Comprobando servidor…',
libraryIndexStatusInitial: 'Sincronización inicial…',
libraryIndexStatusReady: 'Listo ({{count}} pistas)',
libraryIndexStatusError: 'Error — consulta los registros',
libraryIndexProgressIngest: '{{count}} pistas indexadas…',
libraryIndexProgressVerify: '{{checked}} verificadas ({{deleted}} eliminadas)',
libraryIndexSyncNow: 'Sincronizar ahora',
libraryIndexVerify: 'Verificar integridad de la biblioteca',
libraryIndexCancel: 'Cancelar',
libraryIndexAutoReconcile: 'Reconciliación automática al bajar el recuento',
libraryIndexAutoReconcileDesc: 'Comprueba automáticamente pistas eliminadas cuando el servidor informa de menos de las esperadas.',
libraryIndexSyncError: 'Error de sincronización de biblioteca: {{error}}',
libraryIndexBindError: 'No se pudo activar el índice: {{error}}',
randomMixTitle: 'Lista negra de Mezcla Aleatoria',
luckyMixMenuTitle: 'Mostrar Mezcla Suerte en el menú',
luckyMixMenuDesc: 'Activa Mezcla Suerte en "Crear Mezcla" y como elemento de menú separado cuando la navegación dividida está activa. Solo visible cuando AudioMuse está activo en el servidor actual.',
+6
View File
@@ -10,6 +10,11 @@ export const search = {
resultsFor: 'Résultats pour « {{query}} »',
album: 'Album',
advanced: 'Recherche avancée',
localIndexBadge: 'Index local',
localIndexBadgeTooltip: 'Résultats de lindex local de bibliothèque sur cet appareil',
networkSearchBadge: 'Recherche serveur',
networkSearchBadgeTooltip: 'Résultats de la recherche en direct sur le serveur connecté',
liveSearchFailed: 'Échec de la recherche — réessayez',
advancedSearchTerm: 'Terme de recherche',
advancedSearchPlaceholder: 'Titre, album, artiste…',
advancedGenre: 'Genre',
@@ -17,6 +22,7 @@ export const search = {
advancedYear: 'Année',
advancedYearFrom: 'de',
advancedYearTo: 'à',
advancedBpm: 'BPM',
advancedAll: 'Tous',
advancedSearch: 'Rechercher',
advancedEmpty: 'Entrez un terme de recherche ou sélectionnez un filtre.',
+31
View File
@@ -243,6 +243,37 @@ export const settings = {
libraryGridMaxColumnsPerfHint: 'Plus de colonnes signifie plus de tuiles par ligne et davantage de travail de mise en page et de peinture — surtout sur de grosses bibliothèques ou du matériel lent.',
libraryGridMaxColumnsRangeLabel: 'Nombre maximal de colonnes ({{min}}{{max}})',
libraryGridMaxColumnsDesc: 'Sapplique aux vues album, artiste, liste de lecture, radio, hors ligne et autres pages en cartes. Moins de colonnes = tuiles plus grandes et en général moins de charge CPU.',
libraryIndexTitle: 'Index local de bibliothèque (aperçu)',
libraryIndexDesc: 'Conserve une copie locale de la base de morceaux de chaque serveur pour que la navigation et la recherche restent rapides et fonctionnent hors ligne. La synchronisation initiale sexécute pour tous les serveurs configurés ; les serveurs hors ligne sont réessayés automatiquement.',
libraryIndexDeltaHint: 'La synchronisation delta en arrière-plan vérifie les serveurs liés toutes les 30 secondes et sexécute quand cest dû — en général toutes les 5 à 45 minutes selon la taille de la bibliothèque et la charge.',
libraryIndexEnable: 'Activer lindex local de bibliothèque',
libraryIndexEnableAllDesc: 'Indexer et synchroniser tous les serveurs configurés en arrière-plan.',
libraryIndexNoServer: 'Ajoutez dabord un serveur.',
libraryIndexServerListTitle: 'Serveurs indexés',
libraryIndexAllExcluded: 'Tous les serveurs sont exclus de la synchronisation. Réactivez lindex ou ajoutez un serveur.',
libraryIndexServerOffline: 'Serveur hors ligne — synchronisation reportée',
libraryIndexServerDeferred: 'Reportée',
libraryIndexServerSyncing: 'Synchronisation…',
libraryIndexFullResync: 'Resynchronisation complète',
libraryIndexDeltaSync: 'Synchronisation delta',
libraryIndexExcludeServer: 'Exclure de la synchronisation',
libraryIndexExcludedTitle: 'Exclus de la synchronisation',
libraryIndexIncludeServer: 'Réinclure',
libraryIndexStatus: 'État',
libraryIndexStatusIdle: 'Inactif',
libraryIndexStatusProbing: 'Vérification du serveur…',
libraryIndexStatusInitial: 'Synchronisation initiale…',
libraryIndexStatusReady: 'Prêt ({{count}} morceaux)',
libraryIndexStatusError: 'Erreur — voir les journaux',
libraryIndexProgressIngest: '{{count}} morceaux indexés…',
libraryIndexProgressVerify: '{{checked}} vérifiés ({{deleted}} supprimés)',
libraryIndexSyncNow: 'Synchroniser maintenant',
libraryIndexVerify: 'Vérifier lintégrité de la bibliothèque',
libraryIndexCancel: 'Annuler',
libraryIndexAutoReconcile: 'Réconciliation auto en cas de baisse du nombre',
libraryIndexAutoReconcileDesc: 'Recherche automatiquement les morceaux supprimés lorsque le serveur en signale moins que prévu.',
libraryIndexSyncError: 'Échec de la synchronisation de la bibliothèque : {{error}}',
libraryIndexBindError: 'Impossible dactiver lindex : {{error}}',
randomMixTitle: 'Liste noire du mix aléatoire',
luckyMixMenuTitle: 'Afficher Mix Chance dans le menu',
luckyMixMenuDesc: 'Active Mix Chance dans "Créer un mix" et comme entrée séparée quand la navigation est scindée. Visible uniquement si AudioMuse est actif sur le serveur courant.',
+6
View File
@@ -10,6 +10,11 @@ export const search = {
resultsFor: 'Resultater for "{{query}}"',
album: 'Album',
advanced: 'Avansert søk',
localIndexBadge: 'Lokal indeks',
localIndexBadgeTooltip: 'Resultater fra det lokale biblioteksindeks på denne enheten',
networkSearchBadge: 'Serversøk',
networkSearchBadgeTooltip: 'Resultater fra live søk på tilkoblet server',
liveSearchFailed: 'Søket mislyktes — prøv igjen',
advancedSearchTerm: 'Søkeord',
advancedSearchPlaceholder: 'Tittel, album, artist…',
advancedGenre: 'Sjanger',
@@ -17,6 +22,7 @@ export const search = {
advancedYear: 'År',
advancedYearFrom: 'fra',
advancedYearTo: 'til',
advancedBpm: 'BPM',
advancedAll: 'Alle',
advancedSearch: 'Søk',
advancedEmpty: 'Skriv inn et søkeord eller velg ett filter for å begynne.',
+31
View File
@@ -242,6 +242,37 @@ export const settings = {
libraryGridMaxColumnsPerfHint: 'Flere kolonner gir flere fliser per rad og kan øke layout- og tegnearbeid — merkbart på veldig store biblioteker eller tregere maskiner.',
libraryGridMaxColumnsRangeLabel: 'Maksimalt antall kolonner ({{min}}{{max}})',
libraryGridMaxColumnsDesc: 'Gjelder album-, artist-, spilleliste-, radio-, offline- og andre kortbaserte bibliotekvisninger. Færre kolonner = større fliser og vanligvis mindre CPU-belastning.',
libraryIndexTitle: 'Lokalt biblioteksindeks (forhåndsvisning)',
libraryIndexDesc: 'Holder en lokal kopi av hver servers spordatabase slik at blaing og søk forblir raskt og fungerer offline. Første synkronisering kjører for alle konfigurerte servere; offline servere prøves automatisk på nytt.',
libraryIndexDeltaHint: 'Delta-synkronisering i bakgrunnen sjekker tilknyttede servere hvert 30. sekund og kjører når det er på tide — vanligvis hvert 5.45. minutt avhengig av biblioteksstørrelse og belastning.',
libraryIndexEnable: 'Aktiver lokalt biblioteksindeks',
libraryIndexEnableAllDesc: 'Indekser og synkroniser alle konfigurerte servere i bakgrunnen.',
libraryIndexNoServer: 'Legg til en server først.',
libraryIndexServerListTitle: 'Indekserte servere',
libraryIndexAllExcluded: 'Alle servere er ekskludert fra synkronisering. Aktiver indeksen igjen eller legg til en server.',
libraryIndexServerOffline: 'Server offline — synkronisering utsatt',
libraryIndexServerDeferred: 'Utsatt',
libraryIndexServerSyncing: 'Synkroniserer…',
libraryIndexFullResync: 'Full resynkronisering',
libraryIndexDeltaSync: 'Delta-synkronisering',
libraryIndexExcludeServer: 'Ekskluder fra synkronisering',
libraryIndexExcludedTitle: 'Ekskludert fra synkronisering',
libraryIndexIncludeServer: 'Inkluder igjen',
libraryIndexStatus: 'Status',
libraryIndexStatusIdle: 'Inaktiv',
libraryIndexStatusProbing: 'Sjekker server…',
libraryIndexStatusInitial: 'Første synkronisering…',
libraryIndexStatusReady: 'Klar ({{count}} spor)',
libraryIndexStatusError: 'Feil — se logger',
libraryIndexProgressIngest: '{{count}} spor indeksert…',
libraryIndexProgressVerify: '{{checked}} verifisert ({{deleted}} fjernet)',
libraryIndexSyncNow: 'Synkroniser nå',
libraryIndexVerify: 'Verifiser bibliotekets integritet',
libraryIndexCancel: 'Avbryt',
libraryIndexAutoReconcile: 'Auto-avstemming ved fall i antall',
libraryIndexAutoReconcileDesc: 'Sjekker automatisk etter fjernede spor når serveren rapporterer færre enn forventet.',
libraryIndexSyncError: 'Biblioteksynkronisering mislyktes: {{error}}',
libraryIndexBindError: 'Kunne ikke aktivere indeks: {{error}}',
randomMixTitle: 'Svarteliste for tilfeldig miks',
luckyMixMenuTitle: 'Vis Lykkemiks i menyen',
luckyMixMenuDesc: 'Aktiverer Lykkemiks i "Lag en miks" og som eget menypunkt når delt navigasjon er aktiv. Vises bare når AudioMuse er aktiv på gjeldende server.',
+6
View File
@@ -10,6 +10,11 @@ export const search = {
resultsFor: 'Resultaten voor "{{query}}"',
album: 'Album',
advanced: 'Geavanceerd zoeken',
localIndexBadge: 'Lokale index',
localIndexBadgeTooltip: 'Resultaten uit de lokale bibliotheekindex op dit apparaat',
networkSearchBadge: 'Serverzoekopdracht',
networkSearchBadgeTooltip: 'Resultaten van live zoeken op de verbonden server',
liveSearchFailed: 'Zoeken mislukt — probeer opnieuw',
advancedSearchTerm: 'Zoekterm',
advancedSearchPlaceholder: 'Titel, album, artiest…',
advancedGenre: 'Genre',
@@ -17,6 +22,7 @@ export const search = {
advancedYear: 'Jaar',
advancedYearFrom: 'van',
advancedYearTo: 'tot',
advancedBpm: 'BPM',
advancedAll: 'Alle',
advancedSearch: 'Zoeken',
advancedEmpty: 'Voer een zoekterm in of selecteer een filter.',
+31
View File
@@ -243,6 +243,37 @@ export const settings = {
libraryGridMaxColumnsPerfHint: 'Meer kolommen betekent meer tegels per rij en vaak meer lay-out- en schilderwerk — merkbaar bij zeer grote bibliotheken of tragere hardware.',
libraryGridMaxColumnsRangeLabel: 'Maximum aantal kolommen ({{min}}{{max}})',
libraryGridMaxColumnsDesc: 'Geldt voor album-, artiest-, afspeellijst-, radio-, offline- en andere kaartweergaven. Minder kolommen = grotere tegels en meestal minder CPU-belasting.',
libraryIndexTitle: 'Lokale bibliotheeksindex (preview)',
libraryIndexDesc: 'Houdt een lokale kopie van de trackdatabase van elke server bij, zodat bladeren en zoeken snel blijven en offline werken. De eerste synchronisatie draait voor elke geconfigureerde server; offline servers worden automatisch opnieuw geprobeerd.',
libraryIndexDeltaHint: 'Achtergronddelta-synchronisatie controleert gekoppelde servers elke 30 seconden en draait wanneer nodig — meestal elke 545 minuten, afhankelijk van bibliotheekgrootte en belasting.',
libraryIndexEnable: 'Lokale bibliotheeksindex inschakelen',
libraryIndexEnableAllDesc: 'Alle geconfigureerde servers op de achtergrond indexeren en synchroniseren.',
libraryIndexNoServer: 'Voeg eerst een server toe.',
libraryIndexServerListTitle: 'Geïndexeerde servers',
libraryIndexAllExcluded: 'Alle servers zijn uitgesloten van synchronisatie. Schakel de index weer in of voeg een server toe.',
libraryIndexServerOffline: 'Server offline — synchronisatie uitgesteld',
libraryIndexServerDeferred: 'Uitgesteld',
libraryIndexServerSyncing: 'Synchroniseren…',
libraryIndexFullResync: 'Volledige resync',
libraryIndexDeltaSync: 'Delta-sync',
libraryIndexExcludeServer: 'Uitsluiten van synchronisatie',
libraryIndexExcludedTitle: 'Uitgesloten van synchronisatie',
libraryIndexIncludeServer: 'Weer opnemen',
libraryIndexStatus: 'Status',
libraryIndexStatusIdle: 'Inactief',
libraryIndexStatusProbing: 'Server controleren…',
libraryIndexStatusInitial: 'Eerste synchronisatie…',
libraryIndexStatusReady: 'Gereed ({{count}} nummers)',
libraryIndexStatusError: 'Fout — zie logs',
libraryIndexProgressIngest: '{{count}} nummers geïndexeerd…',
libraryIndexProgressVerify: '{{checked}} gecontroleerd ({{deleted}} verwijderd)',
libraryIndexSyncNow: 'Nu synchroniseren',
libraryIndexVerify: 'Bibliotheeksintegriteit controleren',
libraryIndexCancel: 'Annuleren',
libraryIndexAutoReconcile: 'Automatisch afstemmen bij dalend aantal',
libraryIndexAutoReconcileDesc: 'Controleert automatisch op verwijderde nummers wanneer de server minder meldt dan verwacht.',
libraryIndexSyncError: 'Bibliotheeksynchronisatie mislukt: {{error}}',
libraryIndexBindError: 'Index kon niet worden ingeschakeld: {{error}}',
randomMixTitle: 'Willekeurige mix-blacklist',
luckyMixMenuTitle: 'Toon Geluksmix in menu',
luckyMixMenuDesc: 'Schakelt Geluksmix in bij "Mix samenstellen" en als apart menu-item bij gesplitste navigatie. Alleen zichtbaar wanneer AudioMuse actief is op de huidige server.',
+6
View File
@@ -10,6 +10,11 @@ export const search = {
resultsFor: 'Rezultate pentru "{{query}}"',
album: 'Album',
advanced: 'Căutare avansată',
localIndexBadge: 'Index local',
localIndexBadgeTooltip: 'Rezultate din indexul local al bibliotecii pe acest dispozitiv',
networkSearchBadge: 'Căutare pe server',
networkSearchBadgeTooltip: 'Rezultate din căutarea live pe serverul conectat',
liveSearchFailed: 'Căutarea a eșuat — încercați din nou',
advancedSearchTerm: 'Termen de căutare',
advancedSearchPlaceholder: 'Titlu, album, artist…',
advancedGenre: 'Gen',
@@ -17,6 +22,7 @@ export const search = {
advancedYear: 'An',
advancedYearFrom: 'de la',
advancedYearTo: 'până la',
advancedBpm: 'BPM',
advancedAll: 'Toate',
advancedSearch: 'Căutare',
advancedEmpty: 'Introdu un termen de căutare sau alege un filtru pentru a începe',
+31
View File
@@ -249,6 +249,37 @@ export const settings = {
libraryGridMaxColumnsPerfHint: 'Mai multe coloane înseamnă mai multe plăcuțe pe rând și mai multă muncă de layout și desen — se simte pe biblioteci foarte mari sau hardware lent.',
libraryGridMaxColumnsRangeLabel: 'Număr maxim de coloane ({{min}}{{max}})',
libraryGridMaxColumnsDesc: 'Se aplică la albume, artiști, liste de redare, radio, offline și alte ecrane cu carduri. Mai puține coloane = plăcuțe mai mari și de obicei mai puțină încărcare pe CPU.',
libraryIndexTitle: 'Index local al bibliotecii (previzualizare)',
libraryIndexDesc: 'Păstrează o copie locală a bazei de piese a fiecărui server, astfel încât navigarea și căutarea rămân rapide și funcționează offline. Sincronizarea inițială rulează pentru toate serverele configurate; serverele offline sunt reîncercate automat.',
libraryIndexDeltaHint: 'Sincronizarea delta în fundal verifică serverele legate la fiecare 30 de secunde și rulează când este cazul — de obicei la 545 de minute, în funcție de dimensiunea bibliotecii și încărcare.',
libraryIndexEnable: 'Activează indexul local al bibliotecii',
libraryIndexEnableAllDesc: 'Indexează și sincronizează toate serverele configurate în fundal.',
libraryIndexNoServer: 'Adaugă mai întâi un server.',
libraryIndexServerListTitle: 'Servere indexate',
libraryIndexAllExcluded: 'Toate serverele sunt excluse din sincronizare. Reactivează indexul sau adaugă un server.',
libraryIndexServerOffline: 'Server offline — sincronizare amânată',
libraryIndexServerDeferred: 'Amânată',
libraryIndexServerSyncing: 'Se sincronizează…',
libraryIndexFullResync: 'Resincronizare completă',
libraryIndexDeltaSync: 'Sincronizare delta',
libraryIndexExcludeServer: 'Exclude din sincronizare',
libraryIndexExcludedTitle: 'Excluse din sincronizare',
libraryIndexIncludeServer: 'Include din nou',
libraryIndexStatus: 'Stare',
libraryIndexStatusIdle: 'Inactiv',
libraryIndexStatusProbing: 'Se verifică serverul…',
libraryIndexStatusInitial: 'Sincronizare inițială…',
libraryIndexStatusReady: 'Gata ({{count}} piese)',
libraryIndexStatusError: 'Eroare — vezi jurnalele',
libraryIndexProgressIngest: '{{count}} piese indexate…',
libraryIndexProgressVerify: '{{checked}} verificate ({{deleted}} eliminate)',
libraryIndexSyncNow: 'Sincronizează acum',
libraryIndexVerify: 'Verifică integritatea bibliotecii',
libraryIndexCancel: 'Anulează',
libraryIndexAutoReconcile: 'Reconciliere automată la scăderea numărului',
libraryIndexAutoReconcileDesc: 'Verifică automat piesele eliminate când serverul raportează mai puține decât se așteaptă.',
libraryIndexSyncError: 'Sincronizarea bibliotecii a eșuat: {{error}}',
libraryIndexBindError: 'Indexul nu a putut fi activat: {{error}}',
randomMixTitle: 'Lista neagră Mix Aleatoriu',
luckyMixMenuTitle: 'Arată Mixul Norocos în meniu',
luckyMixMenuDesc: 'Activează Mixul Norocos în Construiește un Mix și ca un element separat în meniu când navigarea împărțită este pornită. Vizibil doar când AudioMuse este activat pe serverul activ.',
+6
View File
@@ -10,6 +10,11 @@ export const search = {
resultsFor: 'Результаты по «{{query}}»',
album: 'Альбом',
advanced: 'Расширенный поиск',
localIndexBadge: 'Локальный индекс',
localIndexBadgeTooltip: 'Результаты из локального индекса библиотеки на этом устройстве',
networkSearchBadge: 'Поиск на сервере',
networkSearchBadgeTooltip: 'Результаты живого поиска на подключённом сервере',
liveSearchFailed: 'Поиск не удался — попробуйте снова',
advancedSearchTerm: 'Поисковый запрос',
advancedSearchPlaceholder: 'Название, альбом, исполнитель…',
advancedGenre: 'Жанр',
@@ -17,6 +22,7 @@ export const search = {
advancedYear: 'Год',
advancedYearFrom: 'от',
advancedYearTo: 'до',
advancedBpm: 'BPM',
advancedAll: 'Все',
advancedSearch: 'Найти',
advancedEmpty: 'Введите запрос или выберите фильтр.',
+31
View File
@@ -255,6 +255,37 @@ export const settings = {
libraryGridMaxColumnsPerfHint: 'Больше колонок — больше плиток в ряд и больше работы по вёрстке и отрисовке; на очень больших библиотеках или слабом железе это заметнее.',
libraryGridMaxColumnsRangeLabel: 'Максимум колонок ({{min}}{{max}})',
libraryGridMaxColumnsDesc: 'Действует для альбомов, исполнителей, плейлистов, радио, офлайн-библиотеки и других экранов с карточками. Меньше колонок — крупнее плитки и обычно меньше нагрузка на CPU.',
libraryIndexTitle: 'Локальный индекс библиотеки (preview)',
libraryIndexDesc: 'Локальная копия каталога каждого сервера для быстрого поиска и офлайн-доступа. Первичная индексация для всех серверов; офлайн-серверы синхронизируются позже автоматически.',
libraryIndexDeltaHint: 'Фоновая дельта-синхронизация проверяет привязанные серверы каждые 30 с и запускается по расписанию — обычно раз в 5–45 минут в зависимости от размера библиотеки и нагрузки.',
libraryIndexEnable: 'Включить локальный индекс',
libraryIndexEnableAllDesc: 'Индексировать и синхронизировать все настроенные серверы в фоне.',
libraryIndexNoServer: 'Сначала добавьте сервер.',
libraryIndexServerListTitle: 'Индексируемые серверы',
libraryIndexAllExcluded: 'Все серверы исключены из синхронизации.',
libraryIndexServerOffline: 'Сервер офлайн — синхронизация отложена',
libraryIndexServerDeferred: 'Отложено',
libraryIndexServerSyncing: 'Синхронизация…',
libraryIndexFullResync: 'Полная пересинхронизация',
libraryIndexDeltaSync: 'Быстрая дельта',
libraryIndexExcludeServer: 'Исключить из синхронизации',
libraryIndexExcludedTitle: 'Исключены из синхронизации',
libraryIndexIncludeServer: 'Включить снова',
libraryIndexStatus: 'Статус',
libraryIndexStatusIdle: 'Ожидание',
libraryIndexStatusProbing: 'Проверка сервера…',
libraryIndexStatusInitial: 'Первичная синхронизация…',
libraryIndexStatusReady: 'Готово ({{count}} треков)',
libraryIndexStatusError: 'Ошибка — см. логи',
libraryIndexProgressIngest: 'Проиндексировано {{count}} треков…',
libraryIndexProgressVerify: 'Проверено {{checked}} (удалено {{deleted}})',
libraryIndexSyncNow: 'Синхронизировать',
libraryIndexVerify: 'Проверить целостность',
libraryIndexCancel: 'Отмена',
libraryIndexAutoReconcile: 'Авто-сверка при падении счётчика',
libraryIndexAutoReconcileDesc: 'Автоматически искать удалённые треки, когда на сервере их меньше ожидаемого.',
libraryIndexSyncError: 'Ошибка синхронизации: {{error}}',
libraryIndexBindError: 'Не удалось включить индекс: {{error}}',
randomMixTitle: 'Чёрный список случайного микса',
luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню',
luckyMixMenuDesc:
+6
View File
@@ -10,6 +10,11 @@ export const search = {
resultsFor: '"{{query}}" 的搜索结果',
album: '专辑',
advanced: '高级搜索',
localIndexBadge: '本地索引',
localIndexBadgeTooltip: '来自本设备本地库索引的结果',
networkSearchBadge: '服务器搜索',
networkSearchBadgeTooltip: '来自已连接服务器实时搜索的结果',
liveSearchFailed: '搜索失败 — 请重试',
advancedSearchTerm: '搜索词',
advancedSearchPlaceholder: '标题、专辑、艺术家…',
advancedGenre: '流派',
@@ -17,6 +22,7 @@ export const search = {
advancedYear: '年份',
advancedYearFrom: '从',
advancedYearTo: '至',
advancedBpm: 'BPM',
advancedAll: '全部',
advancedSearch: '搜索',
advancedEmpty: '请输入搜索词或选择过滤器。',
+31
View File
@@ -242,6 +242,37 @@ export const settings = {
libraryGridMaxColumnsPerfHint: '列数越多,每行显示的卡片越多,布局与绘制开销通常也越大;在超大曲库或较慢设备上更明显。',
libraryGridMaxColumnsRangeLabel: '最大列数({{min}}{{max}}',
libraryGridMaxColumnsDesc: '适用于专辑、艺人、播放列表、电台、离线及其他卡片式资料库视图。列数越少卡片越大,通常对 CPU 更友好。',
libraryIndexTitle: '本地资料库索引(预览)',
libraryIndexDesc: '为每个服务器保留曲目数据库的本地副本,使浏览和搜索保持快速并支持离线使用。初始同步会针对所有已配置的服务器运行;离线的服务器会自动重试。',
libraryIndexDeltaHint: '后台增量同步每 30 秒检查已绑定的服务器,并在到期时运行——通常每 5–45 分钟一次,取决于资料库大小和负载。',
libraryIndexEnable: '启用本地资料库索引',
libraryIndexEnableAllDesc: '在后台为所有已配置的服务器建立索引并同步。',
libraryIndexNoServer: '请先添加服务器。',
libraryIndexServerListTitle: '已索引的服务器',
libraryIndexAllExcluded: '所有服务器均已排除同步。请重新启用索引或添加服务器。',
libraryIndexServerOffline: '服务器离线——同步已推迟',
libraryIndexServerDeferred: '已推迟',
libraryIndexServerSyncing: '同步中…',
libraryIndexFullResync: '完全重新同步',
libraryIndexDeltaSync: '增量同步',
libraryIndexExcludeServer: '排除同步',
libraryIndexExcludedTitle: '已排除同步',
libraryIndexIncludeServer: '重新纳入',
libraryIndexStatus: '状态',
libraryIndexStatusIdle: '空闲',
libraryIndexStatusProbing: '正在检查服务器…',
libraryIndexStatusInitial: '初始同步中…',
libraryIndexStatusReady: '就绪({{count}} 首曲目)',
libraryIndexStatusError: '错误——请查看日志',
libraryIndexProgressIngest: '已索引 {{count}} 首曲目…',
libraryIndexProgressVerify: '已验证 {{checked}}(移除 {{deleted}}',
libraryIndexSyncNow: '立即同步',
libraryIndexVerify: '验证资料库完整性',
libraryIndexCancel: '取消',
libraryIndexAutoReconcile: '数量下降时自动核对',
libraryIndexAutoReconcileDesc: '当服务器报告的曲目少于预期时,自动检查已删除的曲目。',
libraryIndexSyncError: '资料库同步失败:{{error}}',
libraryIndexBindError: '无法启用索引:{{error}}',
randomMixTitle: '随机混音黑名单',
luckyMixMenuTitle: '在菜单中显示“好运混音”',
luckyMixMenuDesc: '在“创建混音”中启用“好运混音”,并在分离导航时作为独立菜单项显示。仅当当前服务器启用 AudioMuse 时可见。',
+104 -4
View File
@@ -13,6 +13,10 @@ import CustomSelect from '../components/CustomSelect';
import StarFilterButton from '../components/StarFilterButton';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { runLocalAdvancedSearch, loadMoreLocalSongs, runNetworkAdvancedTextSearch } from '../utils/library/advancedSearchLocal';
import { raceSearchSources } from '../utils/library/searchRace';
import { logLibrarySearch } from '../utils/library/libraryDevLog';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
@@ -60,7 +64,13 @@ export default function AdvancedSearch() {
const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [genreNote, setGenreNote] = useState(false);
// True while the current results came from the local index (drives the
// pagination branch — local pages every result type, network only free-text).
const [localMode, setLocalMode] = useState(false);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const serverId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const searchRunRef = useRef(0);
// Pagination — only the free-text-query branch uses search3 with offset
const SONGS_INITIAL = 100;
@@ -85,13 +95,85 @@ export default function AdvancedSearch() {
};
const runSearch = async (opts: SearchOpts) => {
const runId = ++searchRunRef.current;
const isStale = () => runId !== searchRunRef.current;
setLoading(true);
setHasSearched(true);
setGenreNote(false);
setActiveSearch(opts);
setSongsServerOffset(0);
setSongsHasMore(false);
const { query: q, genre: g, yearFrom: yf, yearTo: yt, resultType: rt } = opts;
const q = opts.query.trim();
const searchT0 = performance.now();
if (q && serverId && indexEnabled) {
try {
const winner = await raceSearchSources(
[
{
source: 'local',
run: () =>
runLocalAdvancedSearch(serverId, opts, SONGS_INITIAL, false, true, true),
},
{
source: 'network',
run: () => runNetworkAdvancedTextSearch(opts, SONGS_INITIAL),
},
],
isStale,
);
if (isStale()) return;
if (winner) {
setResults({
artists: winner.result.artists,
albums: winner.result.albums,
songs: winner.result.songs,
});
setSongsServerOffset(winner.result.songs.length);
setSongsHasMore(winner.result.songs.length >= SONGS_INITIAL);
setLocalMode(winner.source === 'local');
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'search_race',
durationMs: Math.round(performance.now() - searchT0),
indexEnabled,
raceWinner: winner.source,
raceWinnerMs: winner.durationMs,
counts: {
artists: winner.result.artists.length,
albums: winner.result.albums.length,
songs: winner.result.songs.length,
},
});
setLoading(false);
return;
}
} catch {
if (isStale()) return;
}
setLocalMode(false);
} else {
const localPage = await runLocalAdvancedSearch(serverId, opts, SONGS_INITIAL);
if (isStale()) return;
if (localPage) {
setResults({
artists: localPage.artists,
albums: localPage.albums,
songs: localPage.songs,
});
setSongsServerOffset(localPage.songs.length);
setSongsHasMore(localPage.songs.length >= SONGS_INITIAL);
setLocalMode(true);
setLoading(false);
return;
}
setLocalMode(false);
}
const { genre: g, yearFrom: yf, yearTo: yt, resultType: rt } = opts;
const from = yf ? parseInt(yf) : null;
const to = yt ? parseInt(yt) : null;
@@ -155,8 +237,26 @@ export default function AdvancedSearch() {
}, [musicLibraryFilterVersion, qFromUrl]);
const loadMoreSongs = useCallback(async () => {
if (loadingMoreSongs || !songsHasMore) return;
if (!activeSearch || !activeSearch.query.trim()) return;
if (loadingMoreSongs || !songsHasMore || !activeSearch) return;
// Local mode pages every result type (genre/year too), not just free-text.
if (localMode) {
if (!serverId) return;
setLoadingMoreSongs(true);
try {
const more = await loadMoreLocalSongs(serverId, activeSearch, songsServerOffset, SONGS_PAGE_SIZE);
setResults(prev => (prev ? { ...prev, songs: [...prev.songs, ...more] } : prev));
setSongsServerOffset(o => o + more.length);
if (more.length < SONGS_PAGE_SIZE) setSongsHasMore(false);
} catch {
setSongsHasMore(false);
} finally {
setLoadingMoreSongs(false);
}
return;
}
if (!activeSearch.query.trim()) return;
setLoadingMoreSongs(true);
try {
const q = activeSearch.query.trim();
@@ -174,7 +274,7 @@ export default function AdvancedSearch() {
} finally {
setLoadingMoreSongs(false);
}
}, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset]);
}, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId]);
// IntersectionObserver on the bottom sentinel — fires loadMoreSongs as it nears the viewport.
useEffect(() => {
+7 -14
View File
@@ -1,5 +1,6 @@
import { buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl } from '../api/subsonicStreamUrl';
import { setRating, star, unstar } from '../api/subsonicStarRating';
import { queueSongStar, queueSongRating } from '../store/pendingStarSync';
import { getArtistInfo } from '../api/subsonicArtists';
import type { SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
@@ -41,7 +42,6 @@ export default function AlbumDetail() {
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -129,10 +129,10 @@ const handleShuffleAll = () => {
const handleDoubleClickSong = (song: SubsonicSong) => addTrackToOrbit(song.id);
const handleRate = async (songId: string, rating: number) => {
const handleRate = (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(songId, rating);
await setRating(songId, rating);
// F4: optimistic override + retried server sync via the central helper.
queueSongRating(songId, rating);
};
const handleAlbumEntityRating = async (rating: number) => {
@@ -206,21 +206,14 @@ const handleShuffleAll = () => {
}
};
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
const toggleSongStar = (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const wasStarred = starredSongs.has(song.id);
const next = new Set(starredSongs);
if (wasStarred) next.delete(song.id); else next.add(song.id);
setStarredSongs(next);
setStarredOverride(song.id, !wasStarred);
try {
if (wasStarred) await unstar(song.id, 'song');
else await star(song.id, 'song');
} catch (err) {
console.error('Failed to toggle song star', err);
setStarredSongs(new Set(starredSongs));
setStarredOverride(song.id, wasStarred);
}
// F4: optimistic override + retried server sync via the central helper.
queueSongStar(song.id, !wasStarred);
};
const handleCacheOffline = useCallback(async () => {
+5 -6
View File
@@ -1,4 +1,4 @@
import { setRating, unstar } from '../api/subsonicStarRating';
import { queueSongStar, queueSongRating } from '../store/pendingStarSync';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -93,19 +93,18 @@ export default function Favorites() {
const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const psyDrag = useDragDrop();
const handleRate = (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(songId, rating);
setRating(songId, rating).catch(() => {});
// F4: optimistic override + retried server sync via the central helper.
queueSongRating(songId, rating);
};
function removeSong(id: string) {
unstar(id, 'song').catch(() => {});
setStarredOverride(id, false);
// F4: optimistic un-star + retried server sync via the central helper.
queueSongStar(id, false);
setSongs(prev => prev.filter(s => s.id !== id));
}
+4 -13
View File
@@ -1,4 +1,4 @@
import { star, unstar } from '../api/subsonicStarRating';
import { queueSongStar } from '../store/pendingStarSync';
import { getGenres } from '../api/subsonicGenres';
import type { SubsonicSong, SubsonicGenre } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
@@ -32,7 +32,6 @@ export default function RandomMix() {
const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const isMobile = useIsMobile();
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
@@ -121,23 +120,15 @@ export default function RandomMix() {
}
};
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
const toggleSongStar = (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const currentlyStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
const nextStarred = new Set(starredSongs);
if (currentlyStarred) nextStarred.delete(song.id);
else nextStarred.add(song.id);
setStarredSongs(nextStarred);
setStarredOverride(song.id, !currentlyStarred);
try {
if (currentlyStarred) await unstar(song.id, 'song');
else await star(song.id, 'song');
} catch (err) {
console.error('Failed to toggle song star', err);
setStarredSongs(new Set(starredSongs));
setStarredOverride(song.id, currentlyStarred);
}
// F4: optimistic override + retried server sync via the central helper (no rollback).
queueSongStar(song.id, !currentlyStarred);
};
const loadGenreMix = async (genre: string, overrideSize?: number) => {
+9
View File
@@ -3,6 +3,7 @@ import type { Track } from './playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import { notifyLibraryPlaybackHint } from './libraryPlaybackHint';
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
@@ -78,6 +79,9 @@ export function handleAudioPlaying(_duration: number): void {
setDeferHotCachePrefetch(false);
resetProgressEmitThrottles();
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
// Tell the library scheduler to throttle bulk crawl while a stream
// is active (spec §6.2.4). No-op unless the index is enabled.
notifyLibraryPlaybackHint('playing');
}
export function handleAudioProgress(
@@ -268,6 +272,7 @@ export function handleAudioProgress(
url: nextUrl,
durationHint: nextTrack.duration,
analysisTrackId: nextTrack.id,
serverId: serverId || null,
}).catch(() => {});
}
@@ -306,6 +311,10 @@ export function handleAudioProgress(
}
export function handleAudioEnded(): void {
// Playback stopped — let the library scheduler resume normal crawl
// parallelism (spec §6.2.4). No-op unless the index is enabled.
notifyLibraryPlaybackHint('idle');
// If a gapless switch happened recently, this ended event is stale — the
// progress task fired it for the OLD source before seeing the chained one.
if (Date.now() - getLastGaplessSwitchTime() < 600) {
+90
View File
@@ -0,0 +1,90 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
type PersistedV0 = {
indexEnabledByServer?: Record<string, boolean>;
autoReconcileEnabled?: boolean;
};
/**
* Settings for the local library index (spec §7.3).
* Master toggle indexes all configured servers; per-server exclusion opt-out.
*/
interface LibraryIndexState {
masterEnabled: boolean;
/** `serverId → true` excludes that server while master is on. */
syncExcludedByServer: Record<string, boolean>;
autoReconcileEnabled: boolean;
setMasterEnabled: (enabled: boolean) => void;
/** Legacy API — enables master and clears exclusion, or excludes one server. */
setIndexEnabled: (serverId: string, enabled: boolean) => void;
setServerSyncExcluded: (serverId: string, excluded: boolean) => void;
setAutoReconcileEnabled: (enabled: boolean) => void;
isIndexEnabled: (serverId: string | null | undefined) => boolean;
indexedServerIds: (allServerIds: string[]) => string[];
}
export const useLibraryIndexStore = create<LibraryIndexState>()(
persist(
(set, get) => ({
masterEnabled: false,
syncExcludedByServer: {},
autoReconcileEnabled: true,
setMasterEnabled: enabled => set({ masterEnabled: enabled }),
setIndexEnabled: (serverId, enabled) => {
if (enabled) {
set(s => {
const { [serverId]: _omit, ...syncExcludedByServer } = s.syncExcludedByServer;
return { masterEnabled: true, syncExcludedByServer };
});
} else {
set(s => ({
syncExcludedByServer: { ...s.syncExcludedByServer, [serverId]: true },
}));
}
},
setServerSyncExcluded: (serverId, excluded) => {
if (excluded) {
set(s => ({
syncExcludedByServer: { ...s.syncExcludedByServer, [serverId]: true },
}));
} else {
set(s => {
const { [serverId]: _omit, ...syncExcludedByServer } = s.syncExcludedByServer;
return { syncExcludedByServer };
});
}
},
setAutoReconcileEnabled: enabled => set({ autoReconcileEnabled: enabled }),
isIndexEnabled: serverId => {
if (!serverId || !get().masterEnabled) return false;
return get().syncExcludedByServer[serverId] !== true;
},
indexedServerIds: allServerIds => {
if (!get().masterEnabled) return [];
return allServerIds.filter(id => get().syncExcludedByServer[id] !== true);
},
}),
{
name: 'psysonic-library-index',
version: 1,
storage: createJSONStorage(() => localStorage),
migrate: (persisted, version) => {
if (version < 1) {
const old = persisted as PersistedV0;
const masterEnabled = Object.values(old.indexEnabledByServer ?? {}).some(v => v === true);
return {
masterEnabled,
syncExcludedByServer: {},
autoReconcileEnabled: old.autoReconcileEnabled ?? true,
};
}
return persisted as {
masterEnabled: boolean;
syncExcludedByServer: Record<string, boolean>;
autoReconcileEnabled: boolean;
};
},
},
),
);
+25
View File
@@ -0,0 +1,25 @@
import { librarySetPlaybackHint, type PlaybackHint } from '../api/library';
import { useAuthStore } from './authStore';
import { useLibraryIndexStore } from './libraryIndexStore';
/**
* Bridge from the audio lifecycle to the Rust library scheduler's
* bandwidth lane (spec §6.2.4, PR-5 kickoff Q3 JS pushes the hint).
* Only fires when the local index is enabled for the active server,
* and dedupes repeated identical hints so we don't spam the IPC
* boundary on every progress tick.
*/
let lastHint: PlaybackHint | null = null;
export function notifyLibraryPlaybackHint(hint: PlaybackHint): void {
const activeId = useAuthStore.getState().activeServerId;
if (!useLibraryIndexStore.getState().isIndexEnabled(activeId)) {
lastHint = null;
return;
}
if (lastHint === hint) return;
lastHint = hint;
void librarySetPlaybackHint(hint).catch(() => {
/* best-effort — scheduler falls back to Idle parallelism */
});
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { buildStreamUrl } from '../api/subsonicStreamUrl';
import { invoke } from '@tauri-apps/api/core';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { redactSubsonicUrlForLog } from '../utils/server/redactSubsonicUrl';
import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore';
@@ -69,9 +70,11 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
try {
const requestedTarget = useAuthStore.getState().loudnessTargetLufs;
const serverId = getPlaybackServerId() || null;
const row = await invoke<LoudnessCachePayload | null>('analysis_get_loudness_for_track', {
trackId,
targetLufs: requestedTarget,
serverId,
});
if (useAuthStore.getState().loudnessTargetLufs !== requestedTarget) {
emitNormalizationDebug('refresh:stale-target', { trackId, requestedTarget });
@@ -102,7 +105,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
url: redactSubsonicUrlForLog(url),
attempt: attempts + 1,
});
void invoke('analysis_enqueue_seed_from_url', { trackId, url })
void invoke('analysis_enqueue_seed_from_url', { trackId, url, serverId })
.then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 }))
.catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) }))
.finally(() => {
+1
View File
@@ -94,6 +94,7 @@ describe('reseedLoudnessForTrackId', () => {
trackId: 't1',
url: 'https://mock/stream/t1',
force: true,
serverId: null,
});
});
+5 -2
View File
@@ -1,5 +1,6 @@
import { buildStreamUrl } from '../api/subsonicStreamUrl';
import { invoke } from '@tauri-apps/api/core';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore';
import { bumpWaveformRefreshGen } from './waveformRefreshGen';
@@ -42,13 +43,14 @@ export async function reseedLoudnessForTrackId(trackId: string): Promise<void> {
normalizationTargetLufs: auth.loudnessTargetLufs,
normalizationEngineLive: 'loudness',
});
const serverId = getPlaybackServerId() || null;
try {
await invoke('analysis_delete_waveform_for_track', { trackId });
await invoke('analysis_delete_waveform_for_track', { trackId, serverId });
} catch (e) {
console.error('[psysonic] analysis_delete_waveform_for_track failed:', e);
}
try {
await invoke('analysis_delete_loudness_for_track', { trackId });
await invoke('analysis_delete_loudness_for_track', { trackId, serverId });
} catch (e) {
console.error('[psysonic] analysis_delete_loudness_for_track failed:', e);
}
@@ -59,6 +61,7 @@ export async function reseedLoudnessForTrackId(trackId: string): Promise<void> {
trackId,
url,
force: true,
serverId,
});
} catch (e) {
console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e);
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const starMock = vi.fn();
const unstarMock = vi.fn();
const setRatingMock = vi.fn();
vi.mock('../api/subsonicStarRating', () => ({
star: (...a: unknown[]) => starMock(...a),
unstar: (...a: unknown[]) => unstarMock(...a),
setRating: (...a: unknown[]) => setRatingMock(...a),
}));
import { usePlayerStore } from './playerStore';
import type { Track } from './playerStoreTypes';
import { queueSongStar, queueSongRating, _resetPendingStarSyncForTest } from './pendingStarSync';
const track = (id: string): Track => ({
id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1,
});
describe('pendingStarSync', () => {
beforeEach(() => {
vi.useFakeTimers();
starMock.mockReset().mockResolvedValue(undefined);
unstarMock.mockReset().mockResolvedValue(undefined);
setRatingMock.mockReset().mockResolvedValue(undefined);
_resetPendingStarSyncForTest();
usePlayerStore.setState({
currentTrack: track('t1'),
queue: [track('t1')],
starredOverrides: {},
userRatingOverrides: {},
});
});
afterEach(() => {
_resetPendingStarSyncForTest();
vi.useRealTimers();
});
it('stars optimistically, then clears the override + patches the track on success', async () => {
queueSongStar('t1', true);
expect(usePlayerStore.getState().starredOverrides.t1).toBe(true); // optimistic, instant
await vi.runAllTimersAsync();
expect(starMock).toHaveBeenCalledWith('t1', 'song');
const s = usePlayerStore.getState();
expect('t1' in s.starredOverrides).toBe(false); // cleared on success
expect(s.currentTrack?.starred).toBeTruthy(); // in-memory track patched
expect(s.queue[0].starred).toBeTruthy();
});
it('does NOT roll back on a network failure and keeps retrying', async () => {
starMock.mockRejectedValue(new Error('offline'));
queueSongStar('t1', true);
await vi.advanceTimersByTimeAsync(4000); // 0ms + 1s + 2s backoff cycles
expect(starMock.mock.calls.length).toBeGreaterThanOrEqual(2); // retried
expect(usePlayerStore.getState().starredOverrides.t1).toBe(true); // override survives (no rollback)
});
it('latest toggle wins when re-queued before sync', async () => {
queueSongStar('t1', true);
queueSongStar('t1', false); // user toggled back off
await vi.runAllTimersAsync();
expect(unstarMock).toHaveBeenCalledWith('t1', 'song');
expect('t1' in usePlayerStore.getState().starredOverrides).toBe(false);
expect(usePlayerStore.getState().currentTrack?.starred).toBeFalsy();
});
it('rates optimistically (track patched), clears override on success', async () => {
queueSongRating('t1', 4);
// setUserRatingOverride patches the track immediately:
expect(usePlayerStore.getState().currentTrack?.userRating).toBe(4);
expect(usePlayerStore.getState().userRatingOverrides.t1).toBe(4);
await vi.runAllTimersAsync();
expect(setRatingMock).toHaveBeenCalledWith('t1', 4);
const s = usePlayerStore.getState();
expect('t1' in s.userRatingOverrides).toBe(false); // cleared
expect(s.currentTrack?.userRating).toBe(4); // track stays patched
});
});
+137
View File
@@ -0,0 +1,137 @@
import { setRating, star, unstar } from '../api/subsonicStarRating';
import { usePlayerStore } from './playerStore';
/**
* F4 pending-sync for **song** star + rating (spec §6.5 / R7-18).
*
* The player-store override maps (`starredOverrides` / `userRatingOverrides`)
* are *session-only outbound sync state*, not a permanent second source of
* truth:
*
* 1. Set the override optimistically (instant UI).
* 2. Retry the Subsonic API (`star` / `unstar` / `setRating`) with exponential
* backoff; flush immediately on `online` / window focus.
* 3. On success: clear the override and patch the in-memory `Track`
* (`currentTrack` + `queue`) so the UI stays correct without the override.
* The F3 index patch-on-use runs inside the API layer, unchanged.
* 4. On app restart before success: the pending change is lost acceptable,
* overrides are not persisted.
*
* **No rollback on the first network error** (this replaces the per-component
* star rollback). v1 routes **songs only**; album/artist stay on their existing
* paths.
*/
type Task =
| { kind: 'star'; id: string; starred: boolean }
| { kind: 'rating'; id: string; rating: number };
const pending = new Map<string, Task>(); // key `${kind}:${id}` — latest wins
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const attempts = new Map<string, number>();
const MAX_BACKOFF_MS = 30_000;
let listenersArmed = false;
const keyOf = (t: Task) => `${t.kind}:${t.id}`;
function armListeners(): void {
if (listenersArmed || typeof window === 'undefined') return;
listenersArmed = true;
const flushAll = () => {
for (const k of pending.keys()) schedule(k, 0);
};
window.addEventListener('online', flushAll);
window.addEventListener('focus', flushAll);
}
function schedule(k: string, delayMs: number): void {
const existing = timers.get(k);
if (existing) clearTimeout(existing);
timers.set(
k,
setTimeout(() => {
void run(k);
}, delayMs),
);
}
async function run(k: string): Promise<void> {
timers.delete(k);
const task = pending.get(k);
if (!task) return;
try {
if (task.kind === 'star') {
if (task.starred) await star(task.id, 'song');
else await unstar(task.id, 'song');
onStarSuccess(task.id, task.starred);
} else {
await setRating(task.id, task.rating);
onRatingSuccess(task.id);
}
// Only retire the entry if a newer toggle hasn't superseded it mid-flight.
if (pending.get(k) === task) {
pending.delete(k);
attempts.delete(k);
}
} catch {
if (pending.get(k) !== task) return; // superseded — the newer task self-schedules
const n = (attempts.get(k) ?? 0) + 1;
attempts.set(k, n);
schedule(k, Math.min(MAX_BACKOFF_MS, 1000 * 2 ** (n - 1)));
}
}
function onStarSuccess(id: string, starred: boolean): void {
const starredVal = starred ? new Date().toISOString() : undefined;
usePlayerStore.setState(s => {
if (!(id in s.starredOverrides)) return {};
const next = { ...s.starredOverrides };
delete next[id];
return {
starredOverrides: next,
queue: s.queue.map(t => (t.id === id ? { ...t, starred: starredVal } : t)),
currentTrack:
s.currentTrack?.id === id ? { ...s.currentTrack, starred: starredVal } : s.currentTrack,
};
});
}
function onRatingSuccess(id: string): void {
// `setUserRatingOverride` already patched track.userRating; just drop the override.
usePlayerStore.setState(s => {
if (!(id in s.userRatingOverrides)) return {};
const next = { ...s.userRatingOverrides };
delete next[id];
return { userRatingOverrides: next };
});
}
/** Optimistically (un)star a song and sync it to the server with retry. */
export function queueSongStar(id: string, starred: boolean): void {
usePlayerStore.getState().setStarredOverride(id, starred);
const t: Task = { kind: 'star', id, starred };
const k = keyOf(t);
pending.set(k, t);
attempts.delete(k);
armListeners();
schedule(k, 0);
}
/** Optimistically rate a song and sync it to the server with retry. */
export function queueSongRating(id: string, rating: number): void {
usePlayerStore.getState().setUserRatingOverride(id, rating);
const t: Task = { kind: 'rating', id, rating };
const k = keyOf(t);
pending.set(k, t);
attempts.delete(k);
armListeners();
schedule(k, 0);
}
/** Test-only: clear all pending state + timers. */
export function _resetPendingStarSyncForTest(): void {
pending.clear();
attempts.clear();
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
+1
View File
@@ -298,6 +298,7 @@ export function runPlayTrack(
manual,
hiResEnabled: authStateNow.enableHiRes,
analysisTrackId: track.id,
serverId: getPlaybackServerId() || null,
streamFormatSuffix: track.suffix ?? null,
})
.then(() => {
+6
View File
@@ -94,6 +94,12 @@ export const usePlayerStore = create<PlayerState>()(
queue: windowedQueue,
queueServerId: state.queueServerId,
queueIndex: qi - start, // remap into the windowed slice
// F5: full ordered ref list (ids are tiny) so the *whole* queue can be
// rehydrated from the local index on startup. The windowed `queue`
// above stays as the no-index fallback (queue never empty when the
// index is off — the P6 default).
queueRefs: state.queue.map(t => t.id),
queueRefsIndex: qi,
isQueueVisible: state.isQueueVisible,
// currentTime is intentionally NOT persisted here.
// handleAudioProgress fires every 100ms and each setState with a
+6
View File
@@ -58,6 +58,12 @@ export interface PlayerState {
/** Saved server for stream/hot-cache/offline resolution while this queue plays. */
queueServerId: string | null;
queueIndex: number;
/** F5 (transient): full ordered track-id list + index persisted alongside the
* windowed `queue`. On startup, when the library index is ready, the whole
* queue is rehydrated from these refs (`library_get_tracks_batch`) and they
* are then cleared. Absent / index-off the windowed `queue` is used as-is. */
queueRefs?: string[];
queueRefsIndex?: number;
isPlaying: boolean;
/** HTTP stream still buffering (network / demux probe) — show loading on cover art. */
isPlaybackBuffering: boolean;
+1
View File
@@ -59,6 +59,7 @@ export function queueUndoRestoreAudioEngine(opts: {
manual: false,
hiResEnabled: authState.enableHiRes,
analysisTrackId: track.id,
serverId: playbackSid || null,
streamFormatSuffix: track.suffix ?? null,
})
.then(() => {
+2
View File
@@ -169,6 +169,7 @@ export function runResume(set: SetState, get: GetState): void {
manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes,
analysisTrackId: trackToPlay.id,
serverId: coldServerId || null,
streamFormatSuffix: trackToPlay.suffix ?? null,
}).then(() => {
if (getPlayGeneration() === gen && currentTime > 1) {
@@ -208,6 +209,7 @@ export function runResume(set: SetState, get: GetState): void {
manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes,
analysisTrackId: currentTrack.id,
serverId: coldServerId || null,
streamFormatSuffix: currentTrack.suffix ?? null,
}).catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
+18 -52
View File
@@ -1,37 +1,30 @@
/**
* Skip 1 helper: drive each early-return branch + the happy path through
* the threshold-crossing flow that calls `setRating` and updates the
* playerStore. Hoisted mocks replace `setRating`, the auth-store helper, and
* the player-store state surface so the test can drive every input
* independently.
* Skip 1 helper: drive each early-return branch + the happy path. The
* threshold-crossing case now delegates the rating to `queueSongRating`
* (pending-sync, F4) its optimistic patch + retry behaviour is covered in
* `pendingStarSync.test.ts`, so here we only assert the delegation + guards.
*/
import type { Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { setRatingMock, recordSkipStarMock, playerSetStateMock, playerStateGet } = vi.hoisted(() => {
const { queueSongRatingMock, recordSkipStarMock, playerStateGet } = vi.hoisted(() => {
const playerState = {
queue: [] as Track[],
currentTrack: null as Track | null,
userRatingOverrides: {} as Record<string, number>,
};
return {
setRatingMock: vi.fn(async () => undefined),
queueSongRatingMock: vi.fn(),
recordSkipStarMock: vi.fn(),
playerSetStateMock: vi.fn((updater: (s: typeof playerState) => Partial<typeof playerState>) => {
Object.assign(playerState, updater(playerState));
}),
playerStateGet: () => playerState,
};
});
vi.mock('../api/subsonicStarRating', () => ({ setRating: setRatingMock }));
vi.mock('./pendingStarSync', () => ({ queueSongRating: queueSongRatingMock }));
vi.mock('./authStore', () => ({
useAuthStore: { getState: () => ({ recordSkipStarManualAdvance: recordSkipStarMock }) },
}));
vi.mock('./playerStore', () => ({
usePlayerStore: {
getState: playerStateGet,
setState: playerSetStateMock,
},
usePlayerStore: { getState: playerStateGet },
}));
import { applySkipStarOnManualNext } from './skipStarRating';
@@ -43,9 +36,8 @@ function track(id: string, overrides: Partial<Track> = {}): Track {
}
beforeEach(() => {
setRatingMock.mockClear();
queueSongRatingMock.mockClear();
recordSkipStarMock.mockReset();
playerSetStateMock.mockClear();
const s = playerStateGet();
s.queue = [];
s.currentTrack = null;
@@ -56,7 +48,7 @@ describe('applySkipStarOnManualNext', () => {
it('is a no-op when manual=false (gapless / natural advance)', () => {
applySkipStarOnManualNext(track('t1'), false);
expect(recordSkipStarMock).not.toHaveBeenCalled();
expect(setRatingMock).not.toHaveBeenCalled();
expect(queueSongRatingMock).not.toHaveBeenCalled();
});
it('is a no-op when skippedTrack is null', () => {
@@ -68,64 +60,38 @@ describe('applySkipStarOnManualNext', () => {
recordSkipStarMock.mockReturnValueOnce({ crossedThreshold: false });
applySkipStarOnManualNext(track('t1'), true);
expect(recordSkipStarMock).toHaveBeenCalledWith('t1');
expect(setRatingMock).not.toHaveBeenCalled();
expect(queueSongRatingMock).not.toHaveBeenCalled();
});
it('handles a null return from recordSkipStarManualAdvance gracefully', () => {
recordSkipStarMock.mockReturnValueOnce(null);
expect(() => applySkipStarOnManualNext(track('t1'), true)).not.toThrow();
expect(setRatingMock).not.toHaveBeenCalled();
expect(queueSongRatingMock).not.toHaveBeenCalled();
});
it("skips rating when the track is already rated via the override map", () => {
it('skips rating when the track is already rated via the override map', () => {
recordSkipStarMock.mockReturnValueOnce({ crossedThreshold: true });
playerStateGet().userRatingOverrides = { t1: 3 };
applySkipStarOnManualNext(track('t1'), true);
expect(setRatingMock).not.toHaveBeenCalled();
expect(queueSongRatingMock).not.toHaveBeenCalled();
});
it('skips rating when the queue entry is already rated', () => {
recordSkipStarMock.mockReturnValueOnce({ crossedThreshold: true });
playerStateGet().queue = [track('t1', { userRating: 4 })];
applySkipStarOnManualNext(track('t1'), true);
expect(setRatingMock).not.toHaveBeenCalled();
expect(queueSongRatingMock).not.toHaveBeenCalled();
});
it('skips rating when the passed track is already rated', () => {
recordSkipStarMock.mockReturnValueOnce({ crossedThreshold: true });
applySkipStarOnManualNext(track('t1', { userRating: 2 }), true);
expect(setRatingMock).not.toHaveBeenCalled();
expect(queueSongRatingMock).not.toHaveBeenCalled();
});
it('calls setRating(1) when threshold crosses and the track is unrated', async () => {
it('delegates to queueSongRating(id, 1) when threshold crosses and the track is unrated', () => {
recordSkipStarMock.mockReturnValueOnce({ crossedThreshold: true });
applySkipStarOnManualNext(track('t1'), true);
expect(setRatingMock).toHaveBeenCalledWith('t1', 1);
await Promise.resolve();
expect(playerSetStateMock).toHaveBeenCalledTimes(1);
const updated = playerStateGet();
expect(updated.userRatingOverrides).toEqual({ t1: 1 });
});
it('updates queue + currentTrack when the skipped track is the current one', async () => {
recordSkipStarMock.mockReturnValueOnce({ crossedThreshold: true });
const s = playerStateGet();
s.queue = [track('t1'), track('t2')];
s.currentTrack = s.queue[0];
applySkipStarOnManualNext(track('t1'), true);
await Promise.resolve();
const updated = playerStateGet();
expect(updated.queue[0].userRating).toBe(1);
expect(updated.queue[1].userRating).toBeUndefined();
expect(updated.currentTrack?.userRating).toBe(1);
});
it('swallows setRating rejections silently', async () => {
recordSkipStarMock.mockReturnValueOnce({ crossedThreshold: true });
setRatingMock.mockRejectedValueOnce(new Error('network down'));
expect(() => applySkipStarOnManualNext(track('t1'), true)).not.toThrow();
// Drain the rejected microtask
await Promise.resolve();
await Promise.resolve();
expect(queueSongRatingMock).toHaveBeenCalledWith('t1', 1);
});
});
+4 -10
View File
@@ -1,7 +1,7 @@
import { setRating } from '../api/subsonicStarRating';
import type { Track } from './playerStoreTypes';
import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore';
import { queueSongRating } from './pendingStarSync';
/**
* Skip 1 behaviour: every user-initiated `next()` on an unrated track
* counts in `authStore.skipStarManualSkipCountsByKey` (persisted). Once the
@@ -25,13 +25,7 @@ export function applySkipStarOnManualNext(skippedTrack: Track | null, manual: bo
skippedTrack.userRating ??
0;
if (cur >= 1) return;
setRating(id, 1)
.then(() => {
usePlayerStore.setState(s => ({
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: 1 } : t)),
currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: 1 } : s.currentTrack,
userRatingOverrides: { ...s.userRatingOverrides, [id]: 1 },
}));
})
.catch(() => {});
// F4: optimistic 1★ (patches queue + currentTrack + override) and retried
// server sync via the central helper; the override clears on success.
queueSongRating(id, 1);
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { coerceWaveformBins } from '../utils/waveform/waveformParse';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { usePlayerStore } from './playerStore';
import { getWaveformRefreshGen } from './waveformRefreshGen';
@@ -25,7 +26,10 @@ export async function refreshWaveformForTrack(trackId: string): Promise<void> {
if (!trackId) return;
const gen = getWaveformRefreshGen(trackId);
try {
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', {
trackId,
serverId: getPlaybackServerId() || null,
});
if (getWaveformRefreshGen(trackId) !== gen) return;
// Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour).
if (usePlayerStore.getState().currentTrack?.id !== trackId) return;
+19
View File
@@ -141,6 +141,25 @@
animation: fadeIn 150ms ease both;
}
.live-search-source {
display: flex;
align-items: center;
gap: 6px;
padding: 8px var(--space-4);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--border-subtle);
}
.live-search-source--local {
color: var(--accent);
}
.live-search-source--network {
color: var(--text-secondary);
}
.search-section {
padding: var(--space-2) 0;
}
@@ -0,0 +1,169 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { runLocalAdvancedSearch, runLocalSongBrowse } from './advancedSearchLocal';
const opts = (over: Partial<Parameters<typeof runLocalAdvancedSearch>[1]> = {}) => ({
query: '',
genre: '',
yearFrom: '',
yearTo: '',
resultType: 'all' as const,
...over,
});
const ready = () =>
onInvoke('library_get_status', () => ({
serverId: 's1',
libraryScope: '',
syncPhase: 'ready',
capabilityFlags: 0,
libraryTier: 'unknown',
syncedAt: 0,
}));
describe('runLocalAdvancedSearch', () => {
beforeEach(() => {
useLibraryIndexStore.getState().setIndexEnabled('s1', true);
});
it('returns null (→ network fallback) when the index is not ready', async () => {
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
const res = await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
expect(res).toBeNull();
});
it('returns null when the index is disabled for the server', async () => {
useLibraryIndexStore.getState().setIndexEnabled('s1', false);
const res = await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
expect(res).toBeNull();
});
it('passes libraryScope from the sidebar music library filter', async () => {
useAuthStore.setState({ musicLibraryFilterByServer: { s1: 'lib7' } });
ready();
let captured: unknown;
onInvoke('library_advanced_search', (args) => {
captured = args;
return {
artists: [],
albums: [],
tracks: [],
totals: { artists: 0, albums: 0, tracks: 0 },
source: 'local',
};
});
await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
expect(captured).toMatchObject({ request: { libraryScope: 'lib7' } });
});
it('prefers rawJson, falls back to hot columns, and reports the full total', async () => {
ready();
onInvoke('library_advanced_search', () => ({
artists: [],
albums: [],
tracks: [
{
serverId: 's1', id: 't1', title: 'Hot Title', album: 'Alb', albumId: 'al1',
durationSec: 100, syncedAt: 0,
// rawJson is the authoritative original song — must win.
rawJson: {
id: 't1', title: 'Raw Title', artist: 'Raw Artist', album: 'Alb', albumId: 'al1',
duration: 100, contributors: [{ role: 'composer', artist: { name: 'C' } }],
},
},
{
serverId: 's1', id: 't2', title: 'Only Hot', album: 'Alb2', albumId: 'al2',
artist: 'Hot Artist', durationSec: 200, year: 1999, genre: 'Rock',
starredAt: 1_700_000_000_000, syncedAt: 0,
rawJson: {}, // sparse → hot-column fallback
},
],
totals: { artists: 0, albums: 0, tracks: 42 },
appliedFilters: [],
source: 'local',
}));
const res = await runLocalAdvancedSearch('s1', opts({ resultType: 'songs' }), 100);
expect(res).not.toBeNull();
expect(res!.songs).toHaveLength(2);
// rawJson wins where present + carries OpenSubsonic extras.
expect(res!.songs[0].title).toBe('Raw Title');
expect(res!.songs[0].artist).toBe('Raw Artist');
expect(res!.songs[0].contributors).toBeDefined();
// hot-column fallback when rawJson is sparse.
expect(res!.songs[1].title).toBe('Only Hot');
expect(res!.songs[1].artist).toBe('Hot Artist');
expect(res!.songs[1].year).toBe(1999);
expect(res!.songs[1].genre).toBe('Rock');
expect(res!.songs[1].starred).toBeTruthy();
// Total is the full match count, not the page size.
expect(res!.songsTotal).toBe(42);
});
it('returns null without throwing when the local query errors', async () => {
ready();
onInvoke('library_advanced_search', () => {
throw new Error('boom');
});
const res = await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
expect(res).toBeNull();
});
});
describe('runLocalSongBrowse', () => {
beforeEach(() => {
useLibraryIndexStore.getState().setIndexEnabled('s1', true);
});
it('returns null for a missing server id (→ network browse)', async () => {
expect(await runLocalSongBrowse(null, 0, 50)).toBeNull();
});
it('returns null (→ network browse) when the index is not ready', async () => {
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
expect(await runLocalSongBrowse('s1', 0, 50)).toBeNull();
});
it('returns null when the response is not local', async () => {
ready();
onInvoke('library_advanced_search', () => ({
artists: [], albums: [], tracks: [],
totals: { artists: 0, albums: 0, tracks: 0 }, appliedFilters: [], source: 'network',
}));
expect(await runLocalSongBrowse('s1', 0, 50)).toBeNull();
});
it('maps the local browse page to Subsonic songs (rawJson wins)', async () => {
ready();
onInvoke('library_advanced_search', () => ({
artists: [],
albums: [],
tracks: [
{
serverId: 's1', id: 't1', title: 'Hot', album: 'Alb', albumId: 'al1',
durationSec: 100, syncedAt: 0,
rawJson: { id: 't1', title: 'Raw', artist: 'Raw Artist', album: 'Alb', albumId: 'al1', duration: 100 },
},
],
totals: { artists: 0, albums: 0, tracks: 1 }, appliedFilters: [], source: 'local',
}));
const songs = await runLocalSongBrowse('s1', 0, 50);
expect(songs).not.toBeNull();
expect(songs!).toHaveLength(1);
expect(songs![0].title).toBe('Raw');
expect(songs![0].artist).toBe('Raw Artist');
});
it('returns null without throwing on error', async () => {
ready();
onInvoke('library_advanced_search', () => {
throw new Error('boom');
});
expect(await runLocalSongBrowse('s1', 0, 50)).toBeNull();
});
});
+308
View File
@@ -0,0 +1,308 @@
/**
* Advanced Search against the local library index (spec §5.13 / F2).
*
* Maps the AdvancedSearch UI inputs to a `library_advanced_search` request and
* the response back to the Subsonic shapes the existing rows render. The sync
* engine stores each entity's original Subsonic JSON in `rawJson` (ADR-7), so
* that's preferred verbatim; the flat hot columns are a fallback when a row's
* `rawJson` is sparse.
*
* `runLocalAdvancedSearch` returns `null` when the index isn't ready or the
* query can't be served locally the caller then falls back to the network
* path unchanged (§5.13.6).
*/
import {
libraryAdvancedSearch,
type LibraryAdvancedSearchRequest,
type LibraryAlbumDto,
type LibraryArtistDto,
type LibraryEntityType,
type LibraryFilterClause,
type LibraryTrackDto,
} from '../../api/library';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import { search } from '../../api/subsonicSearch';
import { libraryScopeForServer } from '../../api/subsonicClient';
import { libraryIsReady } from './libraryReady';
import { logLibrarySearch, timed } from './libraryDevLog';
export type AdvancedResultType = 'all' | 'artists' | 'albums' | 'songs';
/** UI opts for Advanced Search — BPM filter hidden until enrichment ships. */
export interface LocalSearchOpts {
query: string;
genre: string;
yearFrom: string;
yearTo: string;
resultType: AdvancedResultType;
}
export interface LocalAdvancedSearchPage {
artists: SubsonicArtist[];
albums: SubsonicAlbum[];
songs: SubsonicSong[];
/** Full track match count (not page size) — drives "load more". */
songsTotal: number;
}
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
function entityTypesFor(rt: AdvancedResultType): LibraryEntityType[] {
switch (rt) {
case 'artists':
return ['artist'];
case 'albums':
return ['album'];
case 'songs':
return ['track'];
default:
return ['artist', 'album', 'track'];
}
}
function buildFilters(opts: LocalSearchOpts): LibraryFilterClause[] {
const filters: LibraryFilterClause[] = [];
if (opts.genre) filters.push({ field: 'genre', op: 'eq', value: opts.genre });
const from = opts.yearFrom ? parseInt(opts.yearFrom, 10) : null;
const to = opts.yearTo ? parseInt(opts.yearTo, 10) : null;
if (from !== null && to !== null) {
filters.push({ field: 'year', op: 'between', value: from, valueTo: to });
} else if (from !== null) {
filters.push({ field: 'year', op: 'gte', value: from });
} else if (to !== null) {
filters.push({ field: 'year', op: 'lte', value: to });
}
return filters;
}
function buildRequest(
serverId: string,
opts: LocalSearchOpts,
entityTypes: LibraryEntityType[],
limit: number,
offset: number,
skipTotals = false,
): LibraryAdvancedSearchRequest {
const q = opts.query.trim();
const libraryScope = libraryScopeForServer(serverId);
return {
serverId,
libraryScope: libraryScope ?? undefined,
query: q || undefined,
entityTypes,
filters: buildFilters(opts),
limit,
offset,
skipTotals,
};
}
export function trackToSong(t: LibraryTrackDto): SubsonicSong {
const raw = isObject(t.rawJson) ? t.rawJson : {};
const base: SubsonicSong = {
id: t.id,
title: t.title,
artist: t.artist ?? '',
album: t.album,
albumId: t.albumId ?? '',
artistId: t.artistId ?? undefined,
duration: t.durationSec,
track: t.trackNumber ?? undefined,
discNumber: t.discNumber ?? undefined,
coverArt: t.coverArtId ?? undefined,
year: t.year ?? undefined,
genre: t.genre ?? undefined,
suffix: t.suffix ?? undefined,
bitRate: t.bitRate ?? undefined,
size: t.sizeBytes ?? undefined,
starred: t.starredAt != null ? new Date(t.starredAt).toISOString() : undefined,
userRating: t.userRating ?? undefined,
playCount: t.playCount ?? undefined,
bpm: t.bpm ?? undefined,
isrc: t.isrc ?? undefined,
albumArtist: t.albumArtist ?? undefined,
};
// `rawJson` is the authoritative original song — let it override the
// hot-column fallbacks (it carries OpenSubsonic extras too).
return { ...base, ...(raw as Partial<SubsonicSong>) };
}
export function albumToAlbum(a: LibraryAlbumDto): SubsonicAlbum {
const raw = isObject(a.rawJson) ? a.rawJson : {};
const base: SubsonicAlbum = {
id: a.id,
name: a.name,
artist: a.artist ?? '',
artistId: a.artistId ?? '',
songCount: a.songCount ?? 0,
duration: a.durationSec ?? 0,
year: a.year ?? undefined,
genre: a.genre ?? undefined,
coverArt: a.coverArtId ?? a.id,
starred: a.starredAt != null ? new Date(a.starredAt).toISOString() : undefined,
};
return { ...base, ...(raw as Partial<SubsonicAlbum>) };
}
export function artistToArtist(ar: LibraryArtistDto): SubsonicArtist {
const raw = isObject(ar.rawJson) ? ar.rawJson : {};
const base: SubsonicArtist = {
id: ar.id,
name: ar.name,
albumCount: ar.albumCount ?? undefined,
coverArt: ar.id,
};
return { ...base, ...(raw as Partial<SubsonicArtist>) };
}
/**
* Network search3 path for Advanced Search free-text (mirrors AdvancedSearch.tsx filters).
*/
export async function runNetworkAdvancedTextSearch(
opts: LocalSearchOpts,
songsLimit: number,
): Promise<LocalAdvancedSearchPage | null> {
const q = opts.query.trim();
if (!q) return null;
const g = opts.genre;
const from = opts.yearFrom ? parseInt(opts.yearFrom, 10) : null;
const to = opts.yearTo ? parseInt(opts.yearTo, 10) : null;
const rt = opts.resultType;
const r = await search(q, {
artistCount: 30,
albumCount: 50,
songCount: songsLimit,
});
let artists = r.artists;
let albums = r.albums;
let songs = r.songs;
if (g) songs = songs.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
if (from !== null) songs = songs.filter(s => !s.year || s.year >= from);
if (to !== null) songs = songs.filter(s => !s.year || s.year <= to);
if (g) albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
return {
artists: rt === 'albums' || rt === 'songs' ? [] : artists,
albums: rt === 'artists' || rt === 'songs' ? [] : albums,
songs: rt === 'artists' || rt === 'albums' ? [] : songs,
songsTotal: rt === 'artists' || rt === 'albums' ? 0 : songs.length,
};
}
/**
* Full first-page Advanced Search against the local index. Returns `null`
* when the index isn't ready or the local query fails caller falls back to
* the network path.
*/
export async function runLocalAdvancedSearch(
serverId: string | null | undefined,
opts: LocalSearchOpts,
songsLimit: number,
skipReadyCheck = false,
skipTotals = true,
suppressLog = false,
): Promise<LocalAdvancedSearchPage | null> {
if (!serverId) return null;
if (!skipReadyCheck && !(await libraryIsReady(serverId))) return null;
const t0 = performance.now();
try {
const req = buildRequest(
serverId,
opts,
entityTypesFor(opts.resultType),
songsLimit,
0,
skipTotals,
);
const { result: resp, ms: invokeMs } = await timed(() => libraryAdvancedSearch(req));
if (resp.source !== 'local') return null;
const page = {
artists: resp.artists.map(artistToArtist),
albums: resp.albums.map(albumToAlbum),
songs: resp.tracks.map(trackToSong),
songsTotal: resp.totals.tracks,
};
if (!suppressLog) {
logLibrarySearch({
at: new Date().toISOString(),
query: opts.query.trim(),
path: 'library_advanced_search',
durationMs: Math.round(performance.now() - t0),
invokeMs,
counts: {
artists: page.artists.length,
albums: page.albums.length,
songs: page.songs.length,
},
});
}
return page;
} catch (err) {
if (!suppressLog) {
logLibrarySearch({
at: new Date().toISOString(),
query: opts.query.trim(),
path: 'library_advanced_search',
durationMs: Math.round(performance.now() - t0),
error: String(err),
});
}
return null;
}
}
/**
* Browse-all songs against the local index for `VirtualSongList` (F1). An empty
* query falls through to the Rust builder's default track order
* (`t.title COLLATE NOCASE ASC`) the same alphabetical browse as the network
* `ndListSongs('title','ASC')` path, so paging stays coherent even if a later
* page falls back to the network. Returns `null` when the index isn't ready or
* the page can't be served locally; the caller then uses the network path
* unchanged. Gated per page so a readiness flip mid-scroll degrades gracefully.
*/
export async function runLocalSongBrowse(
serverId: string | null | undefined,
offset: number,
pageSize: number,
): Promise<SubsonicSong[] | null> {
if (!serverId) return null;
if (!(await libraryIsReady(serverId))) return null;
try {
const resp = await libraryAdvancedSearch({
serverId,
libraryScope: libraryScopeForServer(serverId),
query: undefined,
entityTypes: ['track'],
limit: pageSize,
offset,
skipTotals: true,
});
if (resp.source !== 'local') return null;
return resp.tracks.map(trackToSong);
} catch {
return null;
}
}
/**
* Songs-only next page for the local path (mirrors the network
* `searchSongsPaged` pagination). Throws are surfaced so the caller can stop
* the infinite-scroll loop, matching the network branch's behaviour.
*/
export async function loadMoreLocalSongs(
serverId: string,
opts: LocalSearchOpts,
offset: number,
pageSize: number,
): Promise<SubsonicSong[]> {
const req = buildRequest(serverId, opts, ['track'], pageSize, offset, true);
const resp = await libraryAdvancedSearch(req);
return resp.tracks.map(trackToSong);
}
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import {
decodeCapabilityFlags,
explainLibraryReady,
ingestStallHint,
normalizeIngestMetrics,
} from './libraryDevLog';
import type { SyncStateDto } from '../../api/library';
describe('libraryDevLog', () => {
it('decodeCapabilityFlags maps known bits', () => {
expect(decodeCapabilityFlags(0x003)).toEqual([
'navidromeNativeBulk(N1)',
'subsonicSearch3Bulk(S1)',
]);
expect(decodeCapabilityFlags(0)).toEqual(['none']);
});
it('explainLibraryReady covers ready and idle paths', () => {
expect(explainLibraryReady({ syncPhase: 'ready' } as SyncStateDto)).toBe('syncPhase=ready');
expect(
explainLibraryReady({
syncPhase: 'initial_sync',
localTrackCount: 950,
serverTrackCount: 1000,
} as SyncStateDto),
).toContain('≥95%');
expect(
explainLibraryReady({
syncPhase: 'initial_sync',
cursorIngestedCount: 68000,
localTrackCount: 69500,
serverTrackCount: 170148,
} as SyncStateDto),
).toContain('69500/170148');
expect(
explainLibraryReady({
syncPhase: 'idle',
hasLocalTracks: true,
} as SyncStateDto),
).toBe('idle + hasLocalTracks');
});
it('normalizeIngestMetrics accepts camelCase and snake_case', () => {
expect(
normalizeIngestMetrics({
offset: 4000,
fetchMs: 9000,
lockWaitMs: 8500,
writeMs: 8510,
sqlExecMs: 10,
persistMs: 0,
rowCount: 500,
bulkIngestActive: true,
strategy: 's1',
}),
).toMatchObject({ fetchMs: 9000, lockWaitMs: 8500 });
expect(
normalizeIngestMetrics({
offset: 4000,
fetch_ms: 9000,
lock_wait_ms: 8500,
write_ms: 8510,
sql_exec_ms: 10,
persist_ms: 0,
row_count: 500,
bulk_ingest_active: true,
strategy: 's1',
}),
).toMatchObject({ fetchMs: 9000, lockWaitMs: 8500 });
});
it('ingestStallHint flags lock wait vs fetch vs sql', () => {
expect(
ingestStallHint({
offset: 60500,
strategy: 's1',
fetchMs: 200,
writeMs: 61319,
lockWaitMs: 61308,
sqlExecMs: 11,
persistMs: 0,
rowCount: 500,
bulkIngestActive: true,
}),
).toBe('write_lock_held_by_other_op');
});
});
+294
View File
@@ -0,0 +1,294 @@
/**
* DevTools diagnostics for local library index + Live Search (DEV only).
* Filter console: `[psysonic][library]`
* Ring buffer: `window.__PSYSONIC_LIBRARY_DEBUG__`
*/
import type { SyncStateDto } from '../../api/library';
import { syncIngestDisplayCount } from './libraryReady';
const PREFIX = '[psysonic][library]';
const MAX_RING = 40;
export type LibrarySearchPath =
| 'library_live_search'
| 'library_advanced_search'
| 'search3'
| 'search_race'
| 'skipped_not_ready'
| 'local_empty_fallback';
export interface LibrarySearchDebugEntry {
at: string;
query: string;
path: LibrarySearchPath;
durationMs: number;
debounceMs?: number;
indexEnabled?: boolean;
localReadyCached?: boolean;
ready?: boolean;
readyReason?: string;
readyCheckMs?: number;
invokeMs?: number;
counts?: { artists: number; albums: number; songs: number };
fallbackReason?: string;
error?: string;
/** Winner when local + network ran in parallel. */
raceWinner?: 'local' | 'network';
raceWinnerMs?: number;
}
export interface LibrarySyncDebugEntry {
at: string;
kind: string;
serverId: string;
libraryScope?: string;
ingestStrategy?: string | null;
ingestPhase?: string | null;
syncPhase?: string;
n1BulkUnreliable?: boolean | null;
ingestedTotal?: number | null;
batchCount?: number | null;
localTrackCount?: number | null;
serverTrackCount?: number | null;
message?: string | null;
durationMs?: number;
/** ms since the previous ingest_page event (UI stall detector). */
sinceLastIngestMs?: number;
ingestMetrics?: IngestBatchMetrics | null;
stallHint?: string;
}
export interface IngestBatchMetrics {
offset: number;
strategy: string;
fetchMs: number;
writeMs: number;
lockWaitMs: number;
sqlExecMs: number;
persistMs: number;
rowCount: number;
bulkIngestActive: boolean;
}
/** Accept camelCase (wire) or legacy snake_case ingest metrics. */
export function normalizeIngestMetrics(raw: unknown): IngestBatchMetrics | null {
if (!raw || typeof raw !== 'object') return null;
const m = raw as Record<string, unknown>;
const num = (camel: string, snake: string) =>
Number(m[camel] ?? m[snake] ?? 0);
return {
offset: num('offset', 'offset'),
strategy: String(m.strategy ?? ''),
fetchMs: num('fetchMs', 'fetch_ms'),
writeMs: num('writeMs', 'write_ms'),
lockWaitMs: num('lockWaitMs', 'lock_wait_ms'),
sqlExecMs: num('sqlExecMs', 'sql_exec_ms'),
persistMs: num('persistMs', 'persist_ms'),
rowCount: num('rowCount', 'row_count'),
bulkIngestActive: Boolean(m.bulkIngestActive ?? m.bulk_ingest_active ?? false),
};
}
type LibraryDebugRing = {
search: LibrarySearchDebugEntry[];
sync: LibrarySyncDebugEntry[];
};
declare global {
interface Window {
__PSYSONIC_LIBRARY_DEBUG__?: LibraryDebugRing;
}
}
function ring(): LibraryDebugRing {
if (typeof window === 'undefined') {
return { search: [], sync: [] };
}
if (!window.__PSYSONIC_LIBRARY_DEBUG__) {
window.__PSYSONIC_LIBRARY_DEBUG__ = { search: [], sync: [] };
}
return window.__PSYSONIC_LIBRARY_DEBUG__;
}
function pushRing<T>(key: 'search' | 'sync', entry: T): void {
if (!import.meta.env.DEV) return;
const buf = ring()[key] as T[];
buf.push(entry);
if (buf.length > MAX_RING) buf.splice(0, buf.length - MAX_RING);
}
export function libraryDevEnabled(): boolean {
return import.meta.env.DEV;
}
const LARGE_LIBRARY_THRESHOLD = 40_000;
/** Label for cursor strategy tag (`n1` / `s1` / `s2`). */
export function formatIngestStrategyLabel(tag: string | null | undefined): string {
switch (tag) {
case 'n1':
return 'N1 — Navidrome GET /api/song (bulk)';
case 's1':
return 'S1 — Subsonic search3 empty query';
case 's2':
return 'S2 — getAlbumList2 + getAlbum per album';
default:
return tag ?? '(cursor not written yet)';
}
}
/** Best-effort when cursor.strategy is still empty (before first persist). */
export function inferInitialIngestStrategy(status: SyncStateDto): string {
const flags = status.capabilityFlags ?? 0;
const n1 = (flags & 0x001) !== 0;
const s1 = (flags & 0x002) !== 0;
const server = status.serverTrackCount ?? 0;
const large = server > LARGE_LIBRARY_THRESHOLD;
const unreliable = status.n1BulkUnreliable === true;
if (!unreliable && !large && n1) return 'n1';
if (s1) return 's1';
return 's2';
}
export function activeIngestStrategy(status: SyncStateDto): {
tag: string;
label: string;
fromCursor: boolean;
} {
const tag = status.ingestStrategy ?? inferInitialIngestStrategy(status);
return {
tag,
label: formatIngestStrategyLabel(tag),
fromCursor: status.ingestStrategy != null,
};
}
export function ingestParallelismNote(
strategy: string,
playbackHint: 'idle' | 'playing' | 'prefetch_active',
): string {
const depth =
playbackHint === 'idle' ? 4 : playbackHint === 'playing' ? 1 : 0;
if (playbackHint === 'prefetch_active') {
return 'bulk crawl paused (waveform/queue prefetch active)';
}
if (playbackHint === 'playing') {
return `${strategy.toUpperCase()}: sequential HTTP (playback active, max 1)`;
}
if (strategy === 's2') {
return 'S2: parallel getAlbum up to 4 per album-list page';
}
return `${strategy.toUpperCase()}: prefetch up to ${depth} HTTP pages; IS-3 writes upsert-only (remap/canonical deferred)`;
}
export function decodeCapabilityFlags(flags: number): string[] {
const out: string[] = [];
if (flags & 0x001) out.push('navidromeNativeBulk(N1)');
if (flags & 0x002) out.push('subsonicSearch3Bulk(S1)');
if (flags & 0x004) out.push('scanStatus');
if (flags & 0x008) out.push('openSubsonic');
if (flags & 0x010) out.push('unstableTrackIds');
if (flags & 0x020) out.push('fileTreeBrowse');
if (out.length === 0) out.push('none');
return out;
}
/** Human-readable reason for `libraryStatusIsReady` (DevTools). */
export function explainLibraryReady(status: SyncStateDto): string {
if (status.syncPhase === 'ready') return 'syncPhase=ready';
if (status.syncPhase === 'initial_sync') {
const local = syncIngestDisplayCount(status);
const server = status.serverTrackCount ?? 0;
if (server > 0 && local / server >= 0.95) {
return `initial_sync coverage ${local}/${server} (≥95%)`;
}
return `initial_sync coverage ${local}/${server} (<95%)`;
}
if (status.syncPhase === 'idle') {
if (status.hasLocalTracks) return 'idle + hasLocalTracks';
if (status.lastFullSyncAt != null) return 'idle + lastFullSyncAt';
if ((status.localTracksMaxUpdatedMs ?? 0) > 0) return 'idle + localTracksMaxUpdatedMs';
if ((status.localTrackCount ?? 0) > 0) return 'idle + localTrackCount';
return 'idle, no ready signals';
}
if (status.syncPhase === 'probing') return 'syncPhase=probing';
return `syncPhase=${status.syncPhase}`;
}
export function logLibrarySearch(entry: LibrarySearchDebugEntry): void {
if (!libraryDevEnabled()) return;
pushRing('search', entry);
console.debug(PREFIX, 'search', entry);
}
export function logLibrarySync(entry: LibrarySyncDebugEntry): void {
if (!libraryDevEnabled()) return;
pushRing('sync', entry);
const m = entry.ingestMetrics;
const slow =
(m?.lockWaitMs ?? 0) >= 1000 ||
(m?.writeMs ?? 0) >= 1000 ||
(m?.fetchMs ?? 0) >= 5000 ||
(entry.sinceLastIngestMs ?? 0) >= 5000;
if (entry.kind === 'ingest_page' && m) {
const line = `[ingest] off=${m.offset} fetch=${m.fetchMs}ms write=${m.writeMs}ms lockWait=${m.lockWaitMs}ms sql=${m.sqlExecMs}ms persist=${m.persistMs}ms rows=${m.rowCount} bulk=${m.bulkIngestActive}${entry.sinceLastIngestMs != null ? ` gap=${entry.sinceLastIngestMs}ms` : ''}${entry.stallHint ? ` hint=${entry.stallHint}` : ''}`;
if (slow) {
console.warn(PREFIX, 'ingest-batch SLOW', line, entry);
} else {
console.debug(PREFIX, 'ingest-batch', line, entry);
}
return;
}
console.debug(PREFIX, 'sync', entry);
}
/** Derive a short hint when batch timings implicate a specific bottleneck. */
export function ingestStallHint(metrics: IngestBatchMetrics): string | undefined {
if (metrics.lockWaitMs >= 1000 && metrics.sqlExecMs < 200) {
return 'write_lock_held_by_other_op';
}
if (metrics.fetchMs >= 5000 && metrics.lockWaitMs < 500) {
return 'slow_subsonic_fetch';
}
if (metrics.sqlExecMs >= 1000 && metrics.lockWaitMs < 500) {
return 'slow_sqlite_upsert';
}
if (metrics.persistMs >= 500) {
return 'slow_cursor_persist';
}
return undefined;
}
export function logLibraryStatus(
serverId: string,
status: SyncStateDto,
label: string,
playbackHint: 'idle' | 'playing' | 'prefetch_active' = 'idle',
): void {
if (!libraryDevEnabled()) return;
const ingest = activeIngestStrategy(status);
console.debug(PREFIX, 'status', label, {
serverId,
syncPhase: status.syncPhase,
ready: explainLibraryReady(status),
ingestStrategy: ingest.tag,
ingestStrategyLabel: ingest.label,
ingestFromCursor: ingest.fromCursor,
ingestPhase: status.ingestPhase ?? null,
cursorIngestedCount: status.cursorIngestedCount ?? null,
playbackHint,
ingestPrefetchDepth: playbackHint === 'idle' ? 4 : playbackHint === 'playing' ? 1 : 0,
parallelismNote: ingestParallelismNote(ingest.tag, playbackHint),
n1BulkUnreliable: status.n1BulkUnreliable ?? null,
localTrackCount: status.localTrackCount ?? null,
serverTrackCount: status.serverTrackCount ?? null,
hasLocalTracks: status.hasLocalTracks ?? false,
capabilities: decodeCapabilityFlags(status.capabilityFlags ?? 0),
});
}
export async function timed<T>(fn: () => Promise<T>): Promise<{ result: T; ms: number }> {
const t0 = performance.now();
const result = await fn();
return { result, ms: Math.round(performance.now() - t0) };
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import type { SyncStateDto } from '../../api/library';
import { libraryStatusIsReady, syncIngestDisplayCount } from './libraryReady';
const status = (over: Partial<SyncStateDto>): SyncStateDto => ({
serverId: 's1',
libraryScope: '',
syncPhase: 'idle',
capabilityFlags: 0,
libraryTier: 'unknown',
...over,
});
describe('libraryStatusIsReady', () => {
it('accepts ready', () => {
expect(libraryStatusIsReady(status({ syncPhase: 'ready' }))).toBe(true);
});
it('accepts initial_sync at 95% coverage', () => {
expect(
libraryStatusIsReady(
status({ syncPhase: 'initial_sync', localTrackCount: 950, serverTrackCount: 1000 }),
),
).toBe(true);
});
it('accepts idle after a completed full sync (legacy bind clobber)', () => {
expect(
libraryStatusIsReady(
status({ syncPhase: 'idle', localTrackCount: 100, lastFullSyncAt: 1 }),
),
).toBe(true);
});
it('accepts idle with lastFullSyncAt even when count snapshot is stale', () => {
expect(
libraryStatusIsReady(
status({ syncPhase: 'idle', localTrackCount: 0, lastFullSyncAt: 1 }),
),
).toBe(true);
});
it('accepts idle when tracks exist (localTracksMaxUpdatedMs)', () => {
expect(
libraryStatusIsReady(
status({ syncPhase: 'idle', localTracksMaxUpdatedMs: 42 }),
),
).toBe(true);
});
it('accepts idle when hasLocalTracks is set', () => {
expect(
libraryStatusIsReady(
status({ syncPhase: 'idle', hasLocalTracks: true, localTrackCount: 0 }),
),
).toBe(true);
});
it('rejects idle without a prior full sync', () => {
expect(libraryStatusIsReady(status({ syncPhase: 'idle', localTrackCount: 0 }))).toBe(false);
});
});
describe('syncIngestDisplayCount', () => {
it('prefers the highest of live db count, cursor, and event total', () => {
expect(
syncIngestDisplayCount(
{ localTrackCount: 69_500, cursorIngestedCount: 68_000 },
67_000,
),
).toBe(69_500);
expect(
syncIngestDisplayCount(
{ localTrackCount: 1_000, cursorIngestedCount: 8_000 },
7_500,
),
).toBe(8_000);
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* Is the local library index usable for `serverId` right now?
*
* Spec §5.13.6 / §9.3 (`isReady()`): consumers only read from the local
* index when it's enabled and synced enough for trustworthy results.
*/
import { libraryGetStatus, type SyncStateDto } from '../../api/library';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
/** Spec §9.3 — shared by Live Search, Advanced Search, browse, … */
export function libraryStatusIsReady(status: SyncStateDto): boolean {
if (status.syncPhase === 'ready') return true;
if (status.syncPhase === 'initial_sync') {
const local = status.localTrackCount ?? 0;
const server = status.serverTrackCount ?? 0;
if (server > 0 && local / server >= 0.95) return true;
}
// Re-bind resets sync_phase to `idle` while SQLite data stays — treat a
// completed full sync (or live rows) as ready for local reads.
if (status.syncPhase === 'idle') {
if (status.hasLocalTracks) return true;
if (status.lastFullSyncAt != null) return true;
if ((status.localTracksMaxUpdatedMs ?? 0) > 0) return true;
if ((status.localTrackCount ?? 0) > 0) return true;
}
return false;
}
/** Track count for Settings status when the index is usable. */
export function libraryStatusDisplayTrackCount(
status: Pick<SyncStateDto, 'localTrackCount' | 'cursorIngestedCount'>,
): number {
return syncIngestDisplayCount(status);
}
/** Monotonic ingest counter for Settings progress during `initial_sync`. */
export function syncIngestDisplayCount(
status: Pick<SyncStateDto, 'localTrackCount' | 'cursorIngestedCount'>,
eventTotal?: number | null,
): number {
return Math.max(
status.localTrackCount ?? 0,
status.cursorIngestedCount ?? 0,
eventTotal ?? 0,
0,
);
}
export async function libraryIsReady(serverId: string | null | undefined): Promise<boolean> {
if (!serverId) return false;
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
try {
const status = await libraryGetStatus(serverId);
return libraryStatusIsReady(status);
} catch {
return false;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
import { resumeInitialSyncIfIncomplete } from './librarySession';
import { resetLibrarySyncQueueForTests } from './librarySyncQueue';
const status = (over: Record<string, unknown> = {}) => ({
serverId: 's1',
libraryScope: '',
syncPhase: 'idle',
capabilityFlags: 0,
libraryTier: 'unknown',
syncedAt: 0,
...over,
});
function mockQueuedStart() {
const start = vi.fn(async (args: unknown) => {
const { serverId } = args as { serverId: string };
queueMicrotask(() =>
emitTauriEvent('library:sync-idle', {
serverId,
libraryScope: '',
kind: 'initial_sync',
ok: true,
}),
);
return { jobId: 'j1', serverId, kind: 'initial_sync' };
});
onInvoke('library_sync_start', start);
return start;
}
describe('resumeInitialSyncIfIncomplete', () => {
beforeEach(() => {
resetLibrarySyncQueueForTests();
});
it('resumes when initial sync was interrupted mid-run', async () => {
onInvoke('library_get_status', () => status({ syncPhase: 'initial_sync' }));
const start = mockQueuedStart();
await resumeInitialSyncIfIncomplete('s1');
expect(start).toHaveBeenCalledTimes(1);
expect(start).toHaveBeenCalledWith(
expect.objectContaining({ serverId: 's1', mode: 'full' }),
);
});
it('does not restart when idle with a completed index (legacy missing lastFullSyncAt)', async () => {
onInvoke('library_get_status', () =>
status({ syncPhase: 'idle', localTrackCount: 12_000 }),
);
const start = vi.fn();
onInvoke('library_sync_start', start);
await resumeInitialSyncIfIncomplete('s1');
expect(start).not.toHaveBeenCalled();
});
it('does nothing when a full sync has already completed', async () => {
onInvoke('library_get_status', () => status({ syncPhase: 'ready', lastFullSyncAt: 1_716_000_000_000 }));
const start = vi.fn();
onInvoke('library_sync_start', start);
await resumeInitialSyncIfIncomplete('s1');
expect(start).not.toHaveBeenCalled();
});
it('de-dupes concurrent calls so a second start cannot cancel the first', async () => {
onInvoke('library_get_status', () => status({ syncPhase: 'initial_sync' }));
const start = mockQueuedStart();
await Promise.all([
resumeInitialSyncIfIncomplete('s1'),
resumeInitialSyncIfIncomplete('s1'),
]);
expect(start).toHaveBeenCalledTimes(1);
});
it('stays silent when the status lookup fails', async () => {
onInvoke('library_get_status', () => { throw new Error('boom'); });
const start = vi.fn();
onInvoke('library_sync_start', start);
await expect(resumeInitialSyncIfIncomplete('s1')).resolves.toBeUndefined();
expect(start).not.toHaveBeenCalled();
});
});
+127
View File
@@ -0,0 +1,127 @@
import {
libraryGetStatus,
librarySyncBindSession,
} from '../../api/library';
import { enqueueLibrarySync, queueInitialSyncIfNeeded } from './librarySyncQueue';
import { pingWithCredentials } from '../../api/subsonic';
import type { ServerProfile } from '../../store/authStoreTypes';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { serverProfileBaseUrl } from '../server/serverBaseUrl';
import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog';
export type BindServerResult = 'bound' | 'offline' | 'error';
/**
* Bind one server when it participates in the local index (master on, not excluded).
*/
export async function bindIndexedServer(server: ServerProfile): Promise<BindServerResult> {
if (!useLibraryIndexStore.getState().isIndexEnabled(server.id)) return 'error';
const baseUrl = serverProfileBaseUrl(server);
if (!baseUrl) return 'error';
try {
const ping = await pingWithCredentials(server.url, server.username, server.password);
if (!ping.ok) return 'offline';
} catch {
return 'offline';
}
try {
const t0 = performance.now();
await librarySyncBindSession({
serverId: server.id,
baseUrl,
username: server.username,
password: server.password,
});
if (libraryDevEnabled()) {
const { result: status, ms } = await timed(() => libraryGetStatus(server.id));
logLibrarySync({
at: new Date().toISOString(),
kind: 'bind_session',
serverId: server.id,
ingestStrategy: status.ingestStrategy ?? null,
ingestPhase: status.ingestPhase ?? null,
syncPhase: status.syncPhase,
n1BulkUnreliable: status.n1BulkUnreliable ?? null,
durationMs: Math.round(performance.now() - t0),
message: `status fetch ${ms}ms`,
});
logLibraryStatus(server.id, status, 'bind_session');
}
return 'bound';
} catch {
return 'error';
}
}
/** Bind + kick off initial sync for one indexed server. */
export async function bootstrapIndexedServer(server: ServerProfile): Promise<BindServerResult> {
const bound = await bindIndexedServer(server);
if (bound !== 'bound') return bound;
await queueInitialSyncIfNeeded(server.id);
return 'bound';
}
/** Bind all indexed servers, then queue initial syncs one server at a time. */
export async function bootstrapAllIndexedServers(): Promise<Record<string, BindServerResult>> {
const lib = useLibraryIndexStore.getState();
if (!lib.masterEnabled) return {};
const indexed = useAuthStore.getState().servers.filter(s => lib.isIndexEnabled(s.id));
const results: Record<string, BindServerResult> = {};
for (const server of indexed) {
results[server.id] = await bindIndexedServer(server);
}
for (const server of indexed) {
if (results[server.id] === 'bound') {
await queueInitialSyncIfNeeded(server.id);
}
}
return results;
}
/**
* Re-bind the active server when indexed (legacy entry point for startup hooks).
*/
export async function ensureActiveServerSessionBound(): Promise<boolean> {
const auth = useAuthStore.getState();
const server = auth.servers.find(s => s.id === auth.activeServerId);
if (!server) return false;
if (!useLibraryIndexStore.getState().isIndexEnabled(server.id)) return false;
return (await bindIndexedServer(server)) === 'bound';
}
const resumeInFlight = new Set<string>();
export async function resumeInitialSyncIfIncomplete(serverId: string): Promise<void> {
if (resumeInFlight.has(serverId)) return;
resumeInFlight.add(serverId);
try {
const { result: status, ms: statusMs } = await timed(() => libraryGetStatus(serverId));
if (status.syncPhase === 'ready' || status.lastFullSyncAt) return;
if (status.syncPhase !== 'initial_sync') return;
const resumeT0 = performance.now();
await enqueueLibrarySync({ serverId, kind: 'full' });
if (libraryDevEnabled()) {
logLibrarySync({
at: new Date().toISOString(),
kind: 'resume_initial_sync',
serverId,
ingestStrategy: status.ingestStrategy ?? null,
ingestPhase: status.ingestPhase ?? null,
syncPhase: status.syncPhase,
n1BulkUnreliable: status.n1BulkUnreliable ?? null,
localTrackCount: status.localTrackCount ?? null,
serverTrackCount: status.serverTrackCount ?? null,
durationMs: Math.round(performance.now() - resumeT0),
message: `status ${statusMs}ms`,
});
logLibraryStatus(serverId, status, 'resume_initial_sync');
}
} catch {
/* best-effort */
} finally {
resumeInFlight.delete(serverId);
}
}
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
import {
enqueueLibrarySync,
resetLibrarySyncQueueForTests,
} from './librarySyncQueue';
function mockSyncStart() {
const start = vi.fn(async (args: unknown) => {
const { serverId } = args as { serverId: string; mode: string };
queueMicrotask(() =>
emitTauriEvent('library:sync-idle', {
serverId,
libraryScope: '',
kind: 'initial_sync',
ok: true,
}),
);
return { jobId: `j-${serverId}`, serverId, kind: 'initial_sync' };
});
onInvoke('library_sync_start', start);
return start;
}
describe('librarySyncQueue', () => {
beforeEach(() => {
resetLibrarySyncQueueForTests();
});
it('runs queued syncs one server at a time', async () => {
const order: string[] = [];
onInvoke('library_sync_start', async (args: unknown) => {
const { serverId } = args as { serverId: string };
order.push(`start:${serverId}`);
await new Promise(r => setTimeout(r, 5));
queueMicrotask(() => {
order.push(`idle:${serverId}`);
emitTauriEvent('library:sync-idle', {
serverId,
libraryScope: '',
kind: 'initial_sync',
ok: true,
});
});
return { jobId: `j-${serverId}`, serverId, kind: 'initial_sync' };
});
await Promise.all([
enqueueLibrarySync({ serverId: 'a', kind: 'full' }),
enqueueLibrarySync({ serverId: 'b', kind: 'full' }),
]);
expect(order).toEqual(['start:a', 'idle:a', 'start:b', 'idle:b']);
});
it('rejects the queue item when sync-idle reports failure', async () => {
mockSyncStart();
onInvoke('library_sync_start', async (args: unknown) => {
const { serverId } = args as { serverId: string };
queueMicrotask(() =>
emitTauriEvent('library:sync-idle', {
serverId,
libraryScope: '',
kind: 'initial_sync',
ok: false,
error: 'boom',
}),
);
return { jobId: 'j1', serverId, kind: 'initial_sync' };
});
await expect(enqueueLibrarySync({ serverId: 's1', kind: 'full' })).rejects.toThrow(
'boom',
);
});
it('routes verify through library_sync_verify_integrity', async () => {
const verify = vi.fn(async (args: unknown) => {
const { serverId } = args as { serverId: string };
queueMicrotask(() =>
emitTauriEvent('library:sync-idle', {
serverId,
libraryScope: '',
kind: 'delta_sync',
ok: true,
}),
);
return { jobId: 'v1', serverId, kind: 'delta_sync' };
});
onInvoke('library_sync_verify_integrity', verify);
await enqueueLibrarySync({ serverId: 's1', kind: 'verify' });
expect(verify).toHaveBeenCalledTimes(1);
});
});
+131
View File
@@ -0,0 +1,131 @@
import {
libraryGetStatus,
librarySyncStart,
librarySyncVerifyIntegrity,
subscribeLibrarySyncIdle,
type LibrarySyncIdlePayload,
} from '../../api/library';
import type { UnlistenFn } from '@tauri-apps/api/event';
import { libraryDevEnabled, logLibrarySync } from './libraryDevLog';
export type LibrarySyncQueueKind = 'full' | 'delta' | 'verify';
interface QueueItem {
serverId: string;
kind: LibrarySyncQueueKind;
resolve: () => void;
reject: (err: unknown) => void;
}
const queue: QueueItem[] = [];
let draining = false;
let idleListener: Promise<UnlistenFn> | null = null;
let waitingForIdle: {
serverId: string;
resolve: () => void;
reject: (err: unknown) => void;
} | null = null;
function logQueue(message: string, serverId?: string, kind?: LibrarySyncQueueKind): void {
if (!libraryDevEnabled()) return;
logLibrarySync({
at: new Date().toISOString(),
kind: 'sync_queue',
serverId: serverId ?? '',
message: `[queue ${queue.length}${draining ? ', draining' : ''}] ${message}${kind ? ` (${kind})` : ''}`,
});
}
function ensureIdleListener(): Promise<UnlistenFn> {
if (!idleListener) {
idleListener = subscribeLibrarySyncIdle(onSyncIdle);
}
return idleListener;
}
function onSyncIdle(payload: LibrarySyncIdlePayload): void {
if (!waitingForIdle || waitingForIdle.serverId !== payload.serverId) return;
const waiter = waitingForIdle;
waitingForIdle = null;
if (payload.ok) {
logQueue(`idle ok for ${payload.serverId}`, payload.serverId);
waiter.resolve();
return;
}
logQueue(`idle error for ${payload.serverId}: ${payload.error ?? 'unknown'}`, payload.serverId);
waiter.reject(new Error(payload.error ?? 'library sync failed'));
}
function waitForServerIdle(serverId: string): Promise<void> {
return new Promise((resolve, reject) => {
waitingForIdle = { serverId, resolve, reject };
});
}
async function invokeSync(serverId: string, kind: LibrarySyncQueueKind): Promise<void> {
if (kind === 'verify') {
await librarySyncVerifyIntegrity({ serverId });
return;
}
await librarySyncStart({ serverId, mode: kind === 'full' ? 'full' : 'delta' });
}
async function drainQueue(): Promise<void> {
if (draining) return;
draining = true;
await ensureIdleListener();
while (queue.length > 0) {
const item = queue[0]!;
logQueue(`start ${item.serverId}`, item.serverId, item.kind);
try {
const idlePromise = waitForServerIdle(item.serverId);
await invokeSync(item.serverId, item.kind);
await idlePromise;
queue.shift();
item.resolve();
} catch (err) {
queue.shift();
item.reject(err);
}
}
draining = false;
if (queue.length > 0) void drainQueue();
}
/**
* Run library sync jobs one at a time. Waits for `library:sync-idle` before
* starting the next server so bulk ingest passes do not cancel each other.
*/
export function enqueueLibrarySync(args: {
serverId: string;
kind: LibrarySyncQueueKind;
}): Promise<void> {
logQueue(`enqueue ${args.serverId}`, args.serverId, args.kind);
return new Promise((resolve, reject) => {
queue.push({ ...args, resolve, reject });
void drainQueue();
});
}
/** Skip enqueue when the local index is already complete. */
export async function queueInitialSyncIfNeeded(serverId: string): Promise<void> {
try {
const status = await libraryGetStatus(serverId);
if (status.syncPhase === 'ready' || status.lastFullSyncAt) return;
await enqueueLibrarySync({ serverId, kind: 'full' });
} catch {
/* best-effort */
}
}
/** Test-only reset — clears pending work and idle waiters. */
export function resetLibrarySyncQueueForTests(): void {
queue.splice(0, queue.length);
draining = false;
if (waitingForIdle) {
waitingForIdle.reject(new Error('queue reset'));
waitingForIdle = null;
}
void idleListener?.then(unlisten => unlisten());
idleListener = null;
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import {
liveSearchQueryTooShort,
runLocalLiveSearch,
} from './liveSearchLocal';
const neverStale = { epoch: 1, isStale: () => false };
const alwaysStale = { epoch: 1, isStale: () => true };
describe('runLocalLiveSearch', () => {
it('returns null without invoking for a single-character query', async () => {
let invoked = false;
onInvoke('library_live_search', () => {
invoked = true;
return { artists: [], albums: [], tracks: [], source: 'local' };
});
await expect(runLocalLiveSearch('s1', 'а', neverStale)).resolves.toBeNull();
expect(invoked).toBe(false);
});
it('returns null when stale before invoke completes', async () => {
onInvoke('library_live_search', () => ({
artists: [],
albums: [],
tracks: [{ serverId: 's1', id: 't1', title: 'T', album: 'A', durationSec: 1, syncedAt: 0 }],
source: 'local',
}));
await expect(runLocalLiveSearch('s1', 'foo', alwaysStale)).resolves.toBeNull();
});
it('returns null when live search invoke fails', async () => {
onInvoke('library_live_search', () => {
throw new Error('boom');
});
await expect(runLocalLiveSearch('s1', 'foo', neverStale)).resolves.toBeNull();
});
it('maps live search rows to search3-shaped limits', async () => {
onInvoke('library_live_search', () => ({
artists: Array.from({ length: 8 }, (_, i) => ({
serverId: 's1',
id: `a${i}`,
name: `Artist ${i}`,
albumCount: 2,
syncedAt: 1,
rawJson: {},
})),
albums: Array.from({ length: 7 }, (_, i) => ({
serverId: 's1',
id: `al${i}`,
name: `Album ${i}`,
artist: 'A',
artistId: 'a0',
songCount: 1,
durationSec: 100,
syncedAt: 1,
rawJson: {},
})),
tracks: Array.from({ length: 12 }, (_, i) => ({
serverId: 's1',
id: `t${i}`,
title: `Track ${i}`,
artist: 'A',
album: 'Al',
durationSec: 200,
syncedAt: 1,
rawJson: { id: `t${i}`, title: `Track ${i}`, artist: 'A', album: 'Al', albumId: 'al0', duration: 200 },
})),
source: 'local',
}));
const res = await runLocalLiveSearch('s1', 'foo', neverStale);
expect(res).not.toBeNull();
expect(res!.artists).toHaveLength(5);
expect(res!.albums).toHaveLength(5);
expect(res!.songs).toHaveLength(10);
});
it('passes libraryScope from the sidebar music library filter', async () => {
useAuthStore.setState({ musicLibraryFilterByServer: { s1: 'lib7' } });
let captured: unknown;
onInvoke('library_live_search', (args) => {
captured = args;
return { artists: [], albums: [], tracks: [], source: 'local' };
});
await runLocalLiveSearch('s1', 'foo', neverStale);
expect(captured).toMatchObject({ request: { serverId: 's1', libraryScope: 'lib7' } });
});
});
describe('liveSearchQueryTooShort', () => {
it('treats one grapheme as too short', () => {
expect(liveSearchQueryTooShort('а')).toBe(true);
expect(liveSearchQueryTooShort('ab')).toBe(false);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Live Search dropdown against the local library index (spec §5.9 / P24).
* Uses column-scoped `library_live_search` FTS not Advanced Search.
* Falls back to search3 when the index isn't ready (caller orchestrates).
*/
import type { SearchResults } from '../../api/subsonicTypes';
import { search } from '../../api/subsonicSearch';
import { libraryScopeForServer } from '../../api/subsonicClient';
import { libraryLiveSearch } from '../../api/library';
import { filterSearchArtistsWithNoAlbums } from '../../api/subsonicSearch';
import {
albumToAlbum,
artistToArtist,
trackToSong,
} from './advancedSearchLocal';
import { logLibrarySearch, timed } from './libraryDevLog';
export const LIVE_SEARCH_DEBOUNCE_LOCAL_MS = 200;
export const LIVE_SEARCH_DEBOUNCE_NETWORK_MS = 300;
/** Debounce when local + network run in parallel. */
export const LIVE_SEARCH_DEBOUNCE_RACE_MS = 200;
/** Local FTS skipped below this length — see `LOCAL_FTS_MIN_QUERY_CHARS` in Rust. */
export const LOCAL_FTS_MIN_QUERY_CHARS = 2;
const ARTIST_LIMIT = 5;
const ALBUM_LIMIT = 5;
const SONG_LIMIT = 10;
export function queryGraphemeCount(q: string): number {
return [...q].length;
}
export function liveSearchQueryTooShort(query: string): boolean {
const q = query.trim();
return !q || queryGraphemeCount(q) < LOCAL_FTS_MIN_QUERY_CHARS;
}
export type LiveSearchStaleCheck = () => boolean;
export interface LiveSearchRunContext {
epoch: number;
isStale: LiveSearchStaleCheck;
/** Skip per-path dev log when the caller logs the race winner. */
suppressLog?: boolean;
}
export async function runLocalLiveSearch(
serverId: string | null | undefined,
query: string,
ctx: LiveSearchRunContext,
): Promise<SearchResults | null> {
if (!serverId || ctx.isStale()) return null;
const q = query.trim();
if (liveSearchQueryTooShort(q)) return null;
const t0 = performance.now();
try {
const { result: resp, ms: invokeMs } = await timed(() =>
libraryLiveSearch({
serverId,
query: q,
libraryScope: libraryScopeForServer(serverId),
artistLimit: ARTIST_LIMIT,
albumLimit: ALBUM_LIMIT,
songLimit: SONG_LIMIT,
requestEpoch: ctx.epoch,
}),
);
if (ctx.isStale()) return null;
if (resp.source !== 'local') return null;
const mapped: SearchResults = {
artists: filterSearchArtistsWithNoAlbums(resp.artists.map(artistToArtist)).slice(
0,
ARTIST_LIMIT,
),
albums: resp.albums.map(albumToAlbum).slice(0, ALBUM_LIMIT),
songs: resp.tracks.map(trackToSong).slice(0, SONG_LIMIT),
};
if (!ctx.suppressLog) {
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'library_live_search',
durationMs: Math.round(performance.now() - t0),
invokeMs,
counts: {
artists: mapped.artists.length,
albums: mapped.albums.length,
songs: mapped.songs.length,
},
});
}
return mapped;
} catch (err) {
if (ctx.isStale()) return null;
if (!ctx.suppressLog) {
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'library_live_search',
durationMs: Math.round(performance.now() - t0),
error: String(err),
fallbackReason: 'invoke_failed',
});
}
return null;
}
}
export const EMPTY_SEARCH_RESULTS: SearchResults = {
artists: [],
albums: [],
songs: [],
};
export async function runNetworkLiveSearch(
query: string,
signal?: AbortSignal,
): Promise<SearchResults | null> {
const q = query.trim();
if (liveSearchQueryTooShort(q)) return null;
try {
return await search(q, { signal });
} catch (err) {
const name = err instanceof Error ? err.name : '';
if (name === 'CanceledError' || name === 'AbortError') return null;
throw err;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { patchLibraryTrackOnUse } from './patchOnUse';
describe('patchLibraryTrackOnUse', () => {
beforeEach(() => {
useLibraryIndexStore.getState().setIndexEnabled('s1', true);
onInvoke('library_patch_track', () => undefined);
});
it('patches the library track when the index is enabled', async () => {
patchLibraryTrackOnUse('s1', 't1', { starredAt: 1700 });
await Promise.resolve();
expect(invokeMock).toHaveBeenCalledWith('library_patch_track', {
serverId: 's1',
trackId: 't1',
patch: { starredAt: 1700 },
});
});
it('forwards an explicit null (unstar) so the column can be cleared', async () => {
patchLibraryTrackOnUse('s1', 't1', { starredAt: null });
await Promise.resolve();
expect(invokeMock).toHaveBeenCalledWith('library_patch_track', {
serverId: 's1',
trackId: 't1',
patch: { starredAt: null },
});
});
it('is a no-op when the index is disabled for the server', async () => {
useLibraryIndexStore.getState().setIndexEnabled('s1', false);
patchLibraryTrackOnUse('s1', 't1', { userRating: 4 });
await Promise.resolve();
expect(invokeMock).not.toHaveBeenCalled();
});
it('is a no-op without a server id', async () => {
patchLibraryTrackOnUse(null, 't1', { userRating: 4 });
await Promise.resolve();
expect(invokeMock).not.toHaveBeenCalled();
});
it('never throws when the patch invoke rejects', async () => {
onInvoke('library_patch_track', () => {
throw new Error('boom');
});
expect(() => patchLibraryTrackOnUse('s1', 't1', { playedAt: 9 })).not.toThrow();
await Promise.resolve();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { libraryPatchTrack } from '../../api/library';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
type TrackPatch = {
/** ms epoch when starred, or `null` to clear (unstar). */
starredAt?: number | null;
userRating?: number | null;
playCount?: number | null;
/** ms epoch of the last play. */
playedAt?: number | null;
};
/**
* Patch-on-use (spec §6.5 / F3): after a successful star / rating / scrobble,
* mirror the change into the local library index so its reads (browse F1,
* advanced search F2) reflect the action immediately no stale list after a
* rate, no full resync. Skipped when the index is off for the server; the Rust
* command additionally no-ops when no row exists / the id is not a track.
* Fire-and-forget: never throws, never blocks the originating network action.
*/
export function patchLibraryTrackOnUse(
serverId: string | null | undefined,
trackId: string,
patch: TrackPatch,
): void {
if (!serverId || !trackId) return;
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return;
void libraryPatchTrack({ serverId, trackId, patch }).catch(() => {});
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { usePlayerStore } from '@/store/playerStore';
import type { TrackRefDto } from '@/api/library';
import type { Track } from '@/store/playerStoreTypes';
import { hydrateQueueFromIndex } from './queueRestore';
const ready = () =>
onInvoke('library_get_status', () => ({
serverId: 's1',
libraryScope: '',
syncPhase: 'ready',
capabilityFlags: 0,
libraryTier: 'unknown',
syncedAt: 0,
}));
/** Echo each requested ref back as a minimal LibraryTrackDto (order preserved). */
const echoBatch = () =>
onInvoke('library_get_tracks_batch', (args) =>
(args as { refs: TrackRefDto[] }).refs.map(r => ({
serverId: r.serverId,
id: r.trackId,
title: `T-${r.trackId}`,
album: 'A',
durationSec: 1,
syncedAt: 0,
rawJson: {},
})),
);
const track = (id: string): Track => ({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1 });
function seedStore(over: Partial<ReturnType<typeof usePlayerStore.getState>> = {}) {
usePlayerStore.setState({
queue: [track('w1')],
queueServerId: 's1',
queueIndex: 0,
currentTrack: null,
queueRefs: undefined,
queueRefsIndex: undefined,
...over,
});
}
describe('hydrateQueueFromIndex', () => {
beforeEach(() => {
useLibraryIndexStore.getState().setIndexEnabled('s1', true);
seedStore();
});
it('does nothing without persisted refs', async () => {
seedStore({ queueRefs: undefined });
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']);
});
it('keeps the windowed fallback when the index is not ready', async () => {
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
seedStore({ queueRefs: ['t1', 't2', 't3'], queueRefsIndex: 1 });
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']);
expect(usePlayerStore.getState().queueRefs).toEqual(['t1', 't2', 't3']); // not cleared
});
it('restores the full queue and re-locates the current track when ready', async () => {
ready();
echoBatch();
seedStore({
queueRefs: ['t1', 't2', 't3'],
queueRefsIndex: 1,
currentTrack: track('t2'),
});
await hydrateQueueFromIndex();
const s = usePlayerStore.getState();
expect(s.queue.map(t => t.id)).toEqual(['t1', 't2', 't3']);
expect(s.queueIndex).toBe(1); // re-located to current track t2
expect(s.queueRefs).toBeUndefined(); // cleared after success
});
it('batches refs in chunks of 100', async () => {
ready();
echoBatch();
const refs = Array.from({ length: 150 }, (_, i) => `t${i}`);
seedStore({ queueRefs: refs, queueRefsIndex: 0 });
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue).toHaveLength(150);
});
it('keeps the fallback when the current track is not in the hydrated list', async () => {
ready();
echoBatch();
seedStore({
queueRefs: ['t1', 't2'],
currentTrack: track('gone'),
});
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']); // unchanged
});
});
+61
View File
@@ -0,0 +1,61 @@
import { libraryGetTracksBatch, type LibraryTrackDto, type TrackRefDto } from '../../api/library';
import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
import type { Track } from '../../store/playerStoreTypes';
import { songToTrack } from '../playback/songToTrack';
import { trackToSong } from './advancedSearchLocal';
import { libraryIsReady } from './libraryReady';
/** `library_get_tracks_batch` cap (spec §8.6 — max 100 refs/call). */
const BATCH = 100;
/**
* F5 full-queue restore. The player store rehydrates a *windowed* `queue`
* plus a full `queueRefs` id list. When the library index is ready for the
* queue's server, hydrate the entire queue from the index
* (`library_get_tracks_batch`, 100 refs/call) and swap it in, re-locating the
* current track so the playback position stays correct even if some refs were
* dropped (unknown to the index).
*
* Best-effort: missing refs / index not ready / any failure leave the windowed
* `queue` untouched no regression when the index is off (the P6 default).
* Clears `queueRefs` once a full hydrate succeeds so it runs at most once.
*/
export async function hydrateQueueFromIndex(): Promise<void> {
const player = usePlayerStore.getState();
const refs = player.queueRefs;
if (!refs || refs.length === 0) return;
const serverId = player.queueServerId ?? useAuthStore.getState().activeServerId;
if (!serverId) {
usePlayerStore.setState({ queueRefs: undefined, queueRefsIndex: undefined });
return;
}
// Keep the windowed fallback (and the refs, for a later ready startup) when
// the index can't serve the queue yet.
if (!(await libraryIsReady(serverId))) return;
try {
const dtos: LibraryTrackDto[] = [];
for (let i = 0; i < refs.length; i += BATCH) {
const chunk: TrackRefDto[] = refs.slice(i, i + BATCH).map(trackId => ({ serverId, trackId }));
dtos.push(...(await libraryGetTracksBatch(chunk)));
}
if (dtos.length === 0) return; // index has none of them → keep fallback
const hydrated: Track[] = dtos.map(d => songToTrack(trackToSong(d)));
// Re-locate the current track so queueIndex stays aligned with playback.
const cur = usePlayerStore.getState().currentTrack;
const idx = cur ? hydrated.findIndex(t => t.id === cur.id) : -1;
if (cur && idx < 0) return; // can't align playback → keep windowed fallback
usePlayerStore.setState({
queue: hydrated,
queueIndex: idx >= 0 ? idx : 0,
queueRefs: undefined,
queueRefsIndex: undefined,
});
} catch {
// best-effort; the windowed fallback stays in place
}
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest';
import { raceSearchSources } from './searchRace';
type RacePayload = { id: string };
describe('raceSearchSources', () => {
it('returns the first non-null result', async () => {
const winner = await raceSearchSources<RacePayload>(
[
{
source: 'local',
run: () =>
new Promise<RacePayload | null>(resolve => {
setTimeout(() => resolve({ id: 'local' }), 30);
}),
},
{
source: 'network',
run: () =>
new Promise<RacePayload | null>(resolve => {
setTimeout(() => resolve({ id: 'network' }), 5);
}),
},
],
() => false,
);
expect(winner?.source).toBe('network');
expect(winner?.result).toEqual({ id: 'network' });
});
it('waits for network when local returns null', async () => {
const winner = await raceSearchSources<RacePayload>(
[
{ source: 'local', run: async () => null },
{
source: 'network',
run: async () => ({ id: 'network' }),
},
],
() => false,
);
expect(winner?.source).toBe('network');
});
it('returns null when every runner returns null', async () => {
await expect(
raceSearchSources<RacePayload>(
[
{ source: 'local', run: async () => null },
{ source: 'network', run: async () => null },
],
() => false,
),
).resolves.toBeNull();
});
it('rejects when all runners fail', async () => {
await expect(
raceSearchSources<RacePayload>(
[
{ source: 'local', run: async () => { throw new Error('local boom'); } },
{ source: 'network', run: async () => { throw new Error('network boom'); } },
],
() => false,
),
).rejects.toThrow('local boom');
});
it('succeeds when one runner fails and another returns data', async () => {
const winner = await raceSearchSources<{ ok: boolean }>(
[
{ source: 'local', run: async () => { throw new Error('local boom'); } },
{ source: 'network', run: async () => ({ ok: true }) },
],
() => false,
);
expect(winner?.source).toBe('network');
});
it('does not resolve after isStale becomes true', async () => {
let stale = false;
const winnerPromise = raceSearchSources<RacePayload>(
[
{
source: 'local',
run: () =>
new Promise<RacePayload | null>(resolve => {
setTimeout(() => {
stale = true;
resolve({ id: 'late' });
}, 10);
}),
},
{ source: 'network', run: async () => null },
],
() => stale,
);
await expect(winnerPromise).resolves.toBeNull();
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Parallel local vs network search first successful backend wins.
*/
export type SearchRaceSource = 'local' | 'network';
export interface SearchRaceWinner<T> {
source: SearchRaceSource;
result: T;
durationMs: number;
}
export interface SearchRaceRunner<T> {
source: SearchRaceSource;
run: () => Promise<T | null>;
}
/**
* Run search backends in parallel. The first non-null result wins; one runner
* failing does not reject until every runner has failed or returned null.
*/
export async function raceSearchSources<T>(
runners: SearchRaceRunner<T>[],
isStale: () => boolean,
): Promise<SearchRaceWinner<T> | null> {
if (runners.length === 0 || isStale()) return null;
return new Promise((resolve, reject) => {
let pending = runners.length;
let settled = false;
const errors: unknown[] = [];
const onRunnerDone = () => {
pending -= 1;
if (!settled && pending === 0) {
if (errors.length > 0) reject(errors[0]);
else resolve(null);
}
};
for (const { source, run } of runners) {
const t0 = performance.now();
void run()
.then(result => {
if (settled) return;
if (isStale()) {
onRunnerDone();
return;
}
if (result != null) {
settled = true;
resolve({
source,
result,
durationMs: Math.round(performance.now() - t0),
});
return;
}
onRunnerDone();
})
.catch(err => {
if (settled) return;
if (isStale()) {
onRunnerDone();
return;
}
errors.push(err);
onRunnerDone();
});
}
});
}
+8
View File
@@ -0,0 +1,8 @@
import type { ServerProfile } from '../../store/authStoreTypes';
/** Normalized Subsonic root URL for a server profile (same shape as `getBaseUrl`). */
export function serverProfileBaseUrl(server: Pick<ServerProfile, 'url'>): string {
if (!server.url) return '';
const base = server.url.startsWith('http') ? server.url : `http://${server.url}`;
return base.replace(/\/$/, '');
}