Commit Graph

234 Commits

Author SHA1 Message Date
cucadmuh bc85065316 fix(analysis): persist failed tracks and reconcile progress counts (#867)
* fix(analysis): persist failed-track suppression and reduce aggressive polling

Persist unsupported/broken analysis tracks as failed entries and expose them in Settings with track metadata, export, and targeted rescan actions. Also make aggressive-mode completion checks cheap by gating re-entry on live track-count changes with a startup seed and 5-minute recheck cadence.

* fix(analysis): mark unsupported decode tracks as failed in cpu-seed

When full-seed falls back to waveform-only (no EBU loudness) or enrichment decode fails, persist analysis_track status as failed so aggressive backfill does not requeue the same unsupported tracks indefinitely.

* fix(analysis): reconcile legacy ready tracks without loudness

Auto-mark legacy ready tracks that only miss loudness as failed during needs-work checks so analysis progress converges instead of staying permanently pending.

* docs(changelog): add failed-analysis recovery notes for PR 867

Document persistent failed-track handling, analytics strategy controls, and low-cost aggressive-mode recheck behavior in the 1.47.0 changelog.

* fix(i18n): align settings locale coverage across all languages

Sync missing Settings keys for all shipped locales and replace recent English fallbacks with localized strings so analytics and backup UI text stays consistent outside en/ru.
2026-05-25 01:37:15 +03:00
cucadmuh 11974e1438 feat(analysis): ship index-key rebuild, strategy controls, and playback/queue pipeline updates (#864)
* feat(analysis): align index settings and per-server strategies

Rebuild the local index UX to live under Servers with per-server analytics
strategies, and scope analysis queue hints/pruning by playback server so
priorities stay isolated across profiles.

* feat(analysis): add progress tracking and server analysis deletion functionality

Introduce new interfaces for tracking library analysis progress and reporting on server analysis deletions. Implement functions to retrieve analysis progress for a server and to delete all analysis data for a specified server, enhancing the analytics strategy section with real-time progress updates and management capabilities. Update relevant components and localization files to support these features.

* feat(server): implement server index key migration and enhance server ID resolution

Add functionality to migrate server index keys from legacy IDs to new URL-based keys, improving server ID resolution across the application. Introduce new types and commands for handling server key migrations in both analysis and library contexts. Update relevant functions to utilize the new server ID resolution logic, ensuring consistency and accuracy in server-related operations.

* refactor(library): simplify server ID handling in sync progress and idle subscriptions

Refactor the library sync progress and idle subscription functions to directly use the payload's server ID without additional mapping. Update related components to resolve server IDs using a new utility function, ensuring consistent server ID resolution across the application. This change enhances code clarity and maintains functionality.

* refactor(analytics): rename advanced strategy to aggressive and update descriptions

Refactor the AnalyticsStrategySection component to rename the 'advanced' strategy to 'aggressive' for clarity. Update related localization strings to reflect this change, enhancing the user experience by providing clearer descriptions of the analytics strategies. Additionally, remove unused strategy description functions to streamline the code.

* fix(audio): update server ID handling in audio progress functions

Refactor the audio progress handling to utilize the new `getPlaybackIndexKey` function for server ID resolution. This change ensures that the correct analysis server ID is used when processing audio progress, enhancing the accuracy of playback operations. Additionally, a minor update was made to the analysis cache to include a checkpoint after seeding from bytes. Update the library path in live search to reflect the new database structure.

* refactor(analysis): update server ID handling and drop legacy keys

Refactor server ID handling across analysis components to utilize scheme-less keys (host + optional path) instead of legacy scheme-based keys. Introduce SQL migrations to drop legacy analysis rows and library entries keyed by scheme URLs. Update relevant functions and tests to ensure consistent server ID resolution and remove references to the legacy '' scope, enhancing clarity and maintainability.

* refactor(migration): switch to strategy C dual-db flow

Replace destructive server-key migration paths with a blocking inspect/run pipeline that imports into v2 sqlite files, verifies data, then switches active databases with backup safety. Add frontend migration orchestration and post-switch key rewrites while preserving existing user settings behavior.

* fix(migration): harden runtime db switch and startup gate

Switch database promotion through live runtime store/cache connection swaps so migration cannot leave writers on old sqlite inodes, and tighten startup gating to block initialization until migration completes. Also fix empty-bucket warning detection and set the done flag only after a post-run inspect confirms no pending legacy rows.

* feat(migration): enhance migration reporting with skipped server rows tracking

Add new fields to migration interfaces and reports to track skipped rows for removed servers. Update relevant components to display warnings and log messages when such rows are encountered during migration processes, improving visibility and user awareness of migration status.

* fix(migration): avoid startup blocking modal on no-op runs

Keep migration gate completed by default after successful runs and perform done-flag inspections without forcing a blocking phase, so normal app startup no longer flashes migration preparation when no migration is needed.

* fix(migration): enforce startup precheck and purge unknown rows

Prevent stale done-flag bypass by starting migration state in idle and gating completion on orchestrator precheck, and delete unknown removed-server rows from v2 databases before switch so skipped rows are not carried into the new active DB.

* fix(migration): block UI during done-flag precheck

Set inspecting phase before the first migration inspection and treat idle as blocking in the migration gate, so startup precheck cannot render the app before migration status is confirmed.

* fix(migration): hide precheck modal when no migration is needed

Keep startup precheck in a non-blocking idle phase and show the migration modal only after inspect confirms real migration work, removing the recurring half-second migration flash for already-migrated users.

* fix(migration): cleanup legacy db files after path migration

Always remove legacy analysis and library sqlite files (including wal/shm sidecars) when the new database paths are active, so old-path artifacts from previous builds do not linger after migration.

* docs(changelog): add PR #864 release notes and contributor credit

Document the full index-key rebuild scope for 1.47.0 and add the
corresponding settings credit entry for PR #864.

* test(analysis): raise hot-path coverage for analysis cache

Add focused unit tests for analysis cache compute/store hot paths and edge branches so coverage regressions are caught before CI. Make AppHandle entrypoints runtime-generic and enable tauri test utilities in dev dependencies to cover no-cache and registered-cache execute paths.

* fix(migration): make rebind pass resilient to foreign key ordering

Run library and analysis server_id rebind operations inside a foreign-key-disabled transaction and validate with PRAGMA foreign_key_check after commit, so migrations from older databases do not fail on transient FK ordering during bulk updates.

* feat(backup): add dual-database backup flow and blocking UX

Extend backup/export and restore flows to handle library databases with unified archive detection and asynchronous backend execution. Improve backup UI with a global blocking modal and clearer localized copy so long operations do not look like app hangs.

* docs(changelog): add PR #864 backup notes and contributor credit

Update 1.47.0 release notes with backup/restore UX and archive-flow entries for PR #864, and add the matching settings credits contribution line for cucadmuh.

* docs(changelog): sort 1.47.0 entries from old to new

Reorder Added, Changed, and Fixed subsections in the 1.47.0 changelog so entries follow chronological PR order inside each block.

* fix(playback): align offline/hot cache lookup with indexKey scope

Use a canonical playback cache key based on indexKey with legacy UUID fallback so migrated offline and hot-cache entries are still resolved on normal play, resume, queue-undo, and prefetch paths. Refresh PR #864 changelog/credits text to reflect the full migration and backup scope.
2026-05-24 21:11:04 +03:00
cucadmuh 003b280a77 feat(enrichment): oximedia BPM/mood facts, mood search, and queue display (#863)
* feat(enrichment): oximedia BPM/mood facts, mood search, and queue UI

Run client-side oximedia analysis after CPU seed and persist BPM, mood JSON,
and searchable mood_tag facts. Add product mood groups (joy/sadness/dance/work/
romance) with Advanced Search filter on the local index, queue BPM/mood display,
migration 008 mood_tag index, and refreshed licenses for oximedia crates.

* fix(enrichment): keep mood_groups module comment in English

* feat(search): virtual mood groups, anger filter, and Advanced Search UX

Expand mood search via overlapping virtual groups (tag expansion only),
add anger/Злость group, skip album/artist shortcuts for track-only filters,
and simplify mood search UI (songs-only, hide type tabs). Fix CustomSelect
spurious scrollbar on short option lists.

* feat(analysis): unified track analysis plan and enqueue path

Add TrackAnalysisPlan (waveform, LUFS, enrichment) with a single
enqueue_track_analysis entry for all byte-backed triggers. Run enrichment
when cache is full but library facts are missing; route playback, cache,
and backfill through the planner. Fix browseTextSearch LocalSearchOpts tsc
gap and remove obsolete read_seed_bytes_if_needed helper.

* fix(analysis): wire playback dispatch, preload enrichment, and UI refresh

Route stream, gapless, preload, and local-file playback through analysis_dispatch
so BPM/mood enrichment runs when waveform/LUFS are already cached. Fix audio_preload
cache-hit and hot-cache paths, emit preload-cancelled for retry, and add
analysis:enrichment-updated plus content_cache_coverage key resolution.

* fix(audio): preload local files from disk and stop analysis retry loop

Seed hot/offline next tracks via LocalFilePlayback (512 MiB) instead of copying
into the RAM preload slot. Keep bytePreloadingId set after preload-ready so
progress ticks do not re-invoke audio_preload every second.

* fix(enrichment): clippy, album bpm filter routing, and queue mood display

Clippy-clean analysis_dispatch and engine imports; restrict track-derived album
routing to mood_group/mood_tag only so bpm is skipped on album queries. Log
enrichment plan errors with retry-all plan; filter queue mood labels to oximedia ids.

* fix(enrichment): simplify mood_tag backfill branch in plan_track_enrichment

Remove empty if-block; keep same behaviour when backfill fails and moods row exists.

* docs: CHANGELOG and credits for track enrichment PR #863

* docs(credits): track enrichment PR #863 contributor line

* chore(enrichment): clippy-clean plan branch and trim dead exports

Collapse mood_tag backfill if for clippy; remove unused moodGroupById and
OXIMEDIA_MOOD_LABELS re-exports; stop poll when server BPM is already known.

* fix(enrichment): close R2/S1–S3 limits and Song Info BPM fallback

Return TrackEnrichmentOutcome::Failed on oximedia errors so retries are not
masked as complete; extract mood Advanced Search SQL, unify top-3 mood tag
selection in mood_groups with TS invariant tests, and show measured BPM in
Song Info when tag BPM is missing or zero.

* fix(enrichment): restore offline coverage and show mood in Song Info

Add unit tests for offline download cancel/clear registry after the analysis
seed refactor dropped read_seed_bytes coverage; show localized mood labels in
Song Info when library enrichment facts exist.

* fix(enrichment): soft mood scoring and unblock offline cancel tests

Replace oximedia quadrant happy/excited mapping with valence/arousal
soft scores across all mood tags for display, storage, and backfill;
fix offline cancel unit tests that deadlocked by calling clear while
holding the global offline_cancel_flags mutex.

* fix(enrichment): dedupe joy cluster and cap mood display at two labels

Never show happy and excited together; pick one tag per V/A cluster,
tighten oximedia recalibration, and limit queue/Song Info to two moods
that pass a relative score floor.

* fix(enrichment): disable oximedia mood labels in UI and search tags

Oximedia 0.1.7 mood is a spectral energy heuristic, not independent mood
weights; valence correlates with loud/bright audio and false-labels metal
and lyrical tracks as happy. Hide queue/Song Info mood and stop writing
mood_tag facts until a reliable detector lands; keep V/A/moods JSON stored.

* fix(enrichment): disable oximedia mood analysis and add BPM advanced search

Stop planning, running, and storing oximedia mood facts; purge accumulated
mood rows via migration 009. Hide mood filters in Advanced Search, expose
BPM range filter with dual-storage resolution, and show a BPM column in song
results when that filter is active.

* feat(search): analysis BPM priority, source tooltip, and filter UX

Prefer analysis track_fact over file tags for BPM resolution; show source
in list tooltips. Validate BPM range on blur, add clear button, fix double tooltip.

* fix(enrichment): prefer analysis BPM in Song Info and queue tech row

Show measured track_fact BPM before file tags until analysis completes;
pick the highest-confidence analysis fact when several exist.
2026-05-23 18:54:04 +03:00
Frank Stellmacher 10c3d9a3ce chore(i18n): translate lyrics source/YouLyPlus strings for all locales (#856)
Add the new lyrics keys (youLyPlus toggle, source fallback/primary hints,
queue 'no sources' hint) to fr/es/nb/nl/ro/ru/zh and drop the now-dead
lyricsMode* keys, completing the i18n for #855 (was on en fallback).
2026-05-22 21:15:14 +02:00
Frank Stellmacher 02b2df1589 feat(lyrics): make lyrics fully disablable (independent YouLyPlus toggle) (#855)
* feat(lyrics): independent YouLyPlus toggle + all-sources-off state

Replace the binary lyricsMode ('standard' | 'lyricsplus') with an
independent youLyPlusEnabled flag so YouLyPlus and the standard sources
are no longer mutually exclusive — turning one off no longer forces the
other on. YouLyPlus (when on) is tried first with the enabled sources as
fallback; off uses only the enabled sources. When YouLyPlus is off and no
source is enabled, useLyrics fetches nothing (issue #810).

Fresh installs ship with every source off; the rehydrate migration only
restores the old on-by-default set for genuine upgrades, not new installs.

* feat(lyrics): YouLyPlus toggle UI + queue 'no sources' hint

Settings: single YouLyPlus toggle replacing the two mutually exclusive
mode switches; the source list is always visible with a context hint
(fallback vs primary). Queue lyric tab shows a hint when no source is
active. en + de strings; other locales fall back to en.

* docs(changelog): lyrics fully disablable (#855)
2026-05-22 21:06:04 +02:00
cucadmuh e8e41752a7 feat(playback): global speed with three strategies (#852)
* feat(playback): global speed with three strategies

Add Settings → Audio and player-bar controls for global playback speed
(speed with auto pitch correction as default, varispeed, manual pitch shift).
Time-stretch runs on a background worker; Orbit sessions force 1.0× passthrough.

* fix(playback): align seekbar, seek, and progress on content timeline

Unify UI timebase across varispeed and preserve strategies: full-track
duration, speed-scaled progress for DSP paths, and content-timeline seeks
without varispeed scaling. Reset the sample counter after seek so clicks
land correctly; restart playback on strategy/enable changes instead of
fragile hot-switching.

* fix(ui): anchor playback speed popover like volume controls

Replace the centered EQ-style modal with a player-bar popover (outside
click, Escape, reposition on scroll). Show compact controls in the bar
and overflow menu; keep strategy hints and labels in Settings only.

* docs(release): CHANGELOG and credits for playback speed (PR #852)

* docs(changelog): add playback speed entry for PR #852

* fix(clippy): simplify raw_counter_samples branch for CI

Collapse duplicate if branches flagged by clippy::if-same-then-else.

* fix(ui): wheel on pitch slider adjusts pitch in speed popover

In compact player-bar controls, scroll over the pitch row changes pitch;
elsewhere in the panel changes speed. Stop propagation so overflow menu
wheel does not tweak volume.

* fix(playback): address PR #852 review and drop ineffective dynamic imports

Translate playback-rate strings for de/fr/es/zh/nb/nl/ro; restamp sample
counter on live preserve-path speed changes; use neutral rate atomics for
radio progress; static-import playerStore in playListenSession (move preview
volume sync to previewPlayerVolumeSync side-effect module).

* fix(i18n): translate playback-rate strategy labels in all locales

Replace leftover English Varispeed/Pitch strings in ru and other non-en
settings blocks so popover strategy buttons and hints read natively.

* fix(i18n): refine German varispeed label to "Tonhöhe folgt dem Tempo"

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-22 18:59:28 +02:00
cucadmuh d54eceaf3b fix(statistics): keep player stats tab visible without local index (#851)
* fix(statistics): keep player stats tab visible without local index

Show the tab always and explain that player statistics require the local
library index, with a link to library settings, instead of hiding the tab.

* docs(release): CHANGELOG and credits for player stats tab UX (PR #851)

* docs(credits): drop minor library index and player stats tab fixes

Per team policy, small UX fixes (PR #850, #851) stay in CHANGELOG only.
2026-05-22 14:41:04 +03:00
Maxim Isaev 376edbfe18 fix(settings): show including state on library index include action
Mirror the exclude UX: flushSync before bootstrap, block repeat clicks,
show "Including…" on the row, and roll back exclusion if bind fails.
2026-05-22 14:16:19 +03:00
Maxim Isaev 60aad12cd4 fix(settings): show excluding state on library index exclude action
Flush UI before the async unbind, disable repeat clicks, cancel an active
sync when needed, and label the button "Excluding…" / localized equivalent.
2026-05-22 14:14:09 +03:00
cucadmuh 23f7ba02d6 feat(player-stats): local listening history tab with heatmap and summaries (#849)
* feat(player-stats): local listening history tab with heatmap and summaries

Record play sessions in library.sqlite when the library index is enabled,
add Rust read APIs and Tauri commands for year/day aggregates, and ship the
Player stats UI with session clustering, event-driven live refresh, and a
notice when some servers are excluded from indexing.

* test(player-stats): split play_session repo and expand test coverage

Move the repository into play_session/ (completion, cluster, integration tests),
add remap/purge/FK coverage in Rust, and cover ingestion gates plus live-refresh
hooks on the frontend per spec v0.3.

* docs(release): CHANGELOG and credits for player stats (PR #849)

* fix(player-stats): satisfy tsc and clippy CI gates

Use InternetRadioStation field names in the radio skip test and replace
manual month/day range checks with RangeInclusive::contains.
2026-05-22 14:07:38 +03:00
cucadmuh 5bf2441ccf feat(library): local library index and search (preview) (#846)
* feat(library): scaffold psysonic-library crate with v1 schema and store (#791)

Adds a new workspace crate that will host the unified track store and the
upcoming sync engine. PR-1a covers spec phases A1–A6:

- migrations/001_initial.sql: full v1 schema — sync_state, track, album,
  artist, track_fts (+ ai/ad/au triggers), track_extension, track_offline,
  track_id_history, track_fact, track_artifact, canonical_track,
  canonical_identity, track_canonical_link, canonical_enrichment_link, and
  all §5.2 partial indexes.
- store::LibraryStore: WAL + foreign_keys=ON SQLite connection rooted at
  app_data_dir/library.sqlite (distinct from the analysis cache, which
  uses app_config_dir). schema_migrations table + idempotent embedded
  migration runner; LIBRARY_DB_SCHEMA_VERSION = 1.
- repos::TrackRepository::upsert_batch: 35-column transactional upsert
  with ON CONFLICT(server_id, id) all-fields rewrite; FTS rows follow via
  the triggers.
- search::search_tracks: minimal bm25-ordered FTS5 helper scoped to a
  single server_id, filtering deleted rows.
- filter::FilterFieldRegistry: static v1 registry (text, genre, year,
  starred = V1; bpm = SchemaV1UiLater; user_rating/suffix/bit_rate =
  Planned). Entity routing is a silent skip per §5.13.3.

No Tauri commands, no frontend, no sync — those land in PR-2..PR-7.

PR-1b will follow with the migration-runner edge-case tests, the
initial_sync_cursor_json read/write API, and the breaking-migration hook
stub (P22).

* feat(library): A7 migration-runner safety net + initial-sync cursor API (PR-1b) (#792)

* feat(library): wire migration-runner safety net and initial-sync cursor API

PR-1b — Phase A7 infrastructure on top of PR-1a. Production behaviour
is unchanged at v1 launch; everything here is plumbing that PR-3 will
consume.

- store::run_migrations_with: testable entry point that takes an explicit
  migration slice, a min-compatible-version threshold, and a breaking-bump
  hook. The prod `run_migrations` fixes those to MIGRATIONS,
  LIBRARY_DB_MIN_COMPATIBLE_VERSION, and the no-op stub. The slice is now
  sorted defensively before applying.
- store::LIBRARY_DB_MIN_COMPATIBLE_VERSION: new public constant (currently
  equal to LIBRARY_DB_SCHEMA_VERSION). When a future release needs to
  invalidate v1 data, bumping this above the max applied version trips
  the hook on next open per spec §5.7 / P22.
- store::MigrationOutcome (Applied | BreakingBump): crate-internal signal
  callers can branch on. PR-1b consumers ignore it; PR-3 / Settings will
  surface the "library rebuilt after update" toast when it surfaces.
- store::handle_breaking_schema_bump: documented no-op stub. The drop +
  resync logic lands with the first real breaking bump.
- repos::SyncStateRepository: ensure(server_id, scope) idempotently
  inserts a default row; get_initial_sync_cursor / set_initial_sync_cursor
  read and write sync_state.initial_sync_cursor_json via
  serde_json::Value. The set uses ON CONFLICT … DO UPDATE scoped to the
  cursor column only, so phase / poll-stats / tier survive cursor writes
  intact.
- Tests cover: additive 002-style migration preserves prior data
  (spec §5.7 explicit integration test), runner sorts an unsorted source
  slice, breaking-bump hook fires when max applied < min_compatible,
  hook does not fire on a fresh DB, cursor round-trips a nested
  serde_json::Value, ON CONFLICT preserves sibling columns, library_scope
  separates rows per server.

End-to-end "kill mid-500k-sync → resume same cursor" stays out of scope
per the kickoff answer — it belongs to PR-3 / C2 where the
InitialSyncRunner lives.

* test(library): cover AC A3 — 500-row upsert_batch under perf budget

* feat(library): Subsonic REST client for the sync engine (Phase B, PR-2) (#793)

Phase B (B1-B9 per spec §10) — pure-Rust Subsonic client that the
library-sync engine (PR-3) will drive. No Tauri commands, no events;
the surface is added internally to psysonic-integration as a sibling
of the existing navidrome native-REST module.

- B1 — SubsonicClient + ping over /rest/{method}.view. Auth via the
  legacy salted-md5 token (spec v1.13+, advertised as 1.16.1). New
  SubsonicCredentials helper computes token = md5(password || salt)
  and ships a per-process unique salt nonce so back-to-back calls
  don't repeat.
- B2 — get_scan_status → ScanStatus { scanning, count, folder_count,
  last_scan }. Lightweight poll for the Huge-tier path (§6.2.2).
- B3 — get_album_list2(type, size, offset, musicFolderId?) +
  get_album(id). The two-call pattern the sync engine walks during
  initial ingest (§6.3).
- B4 — search3(query, songCount, songOffset, musicFolderId?). Empty
  query → all songs paged (Navidrome quirk, spec §2.4).
- B5 — get_indexes(musicFolderId?, ifModifiedSince?). Conditional
  fetch for file-tree fallback (S3 / §3.1).
- B6 — get_song(id). Error code 70 maps to the dedicated
  SubsonicError::NotFound variant so the tombstone reconciler can
  match on the variant instead of parsing strings.
- B8 — get_artists(musicFolderId?). ID3-path artist index; clients
  compare ArtistIndex.last_modified_ms against the local watermark
  to decide if a delta pass is needed (§2.2.1).
- B9 — fingerprint_sample helper picks every-Nth track id for the
  server-fingerprint verify pass. Sampling is deterministic so
  reruns probe the same tracks. The verify-and-compare glue itself
  is library-side (PR-3 territory, deps on the store).

Tests cover envelope parsing (status=ok/failed, code 70 → NotFound,
missing body key), credentials (md5 vectors, salt uniqueness across
1k rapid calls, salt differs per from_password call), each endpoint
end-to-end through wiremock with query-param matchers, OpenSubsonic
forward-compat (unknown fields ignored on Song), and the trailing-slash
base-URL normalisation.

Cargo.toml — adds query + form + multipart to psysonic-integration's
reqwest feature set. PR-2's client needs `query`; the other two were
already used by existing navidrome::covers / remote::lastfm code and
only worked via top-crate feature unification. Aligning the crate's
own deps means `cargo test -p psysonic-integration` now compiles
without depending on the workspace build.

Out of scope: capability detection (C1 / PR-3), Navidrome native
bulk path (uses existing psysonic-integration::navidrome::queries),
fixtures harness expansion (G1).

* feat(library): subsonic client follow-ups from PR-2 review (PR-2b) (#794)

Picks up the three non-blocking items from cucadmuh's PR-793 review
(handoffs/2026-05-19-pr-793-review.md) before PR-3 starts on top.

- Fresh `(token, salt)` per request. `SubsonicClient` now caches the
  plaintext username + password and derives a new `SubsonicCredentials`
  inside `send()` for every endpoint call — matches the frontend's
  `subsonicClient.ts` `getAuthParams()` lifecycle and follows Subsonic
  replay-resistance guidance. Test path keeps a `with_static_credentials`
  constructor so wiremock matchers stay deterministic. New
  `build_credentials` (`pub(crate)`) routes the two modes.
- `SUBSONIC_CLIENT_ID` now carries the crate version
  (`psysonic/<CARGO_PKG_VERSION>`) — aligns with the frontend's
  `psysonic/${version}` so Navidrome log lines correlate across the
  WebView and Rust sync paths.
- `Song.mbid_recording` gains the `musicBrainzId` serde alias (plus the
  schema-column spelling) so the OpenSubsonic field lands on the same
  hot column the §5.1 schema names. P13 strong-key matching can now key
  off it on ingest.
- `get_song_with_raw` / `get_album_with_raw` return both the typed
  projection and the raw `serde_json::Value` body sub-tree. PR-3 ingest
  will write that raw value verbatim into `track.raw_json`, so
  OpenSubsonic extensions (`contributors`, `replayGain`, future fields)
  survive without manual field mirroring. Internal `parse_envelope_body`
  extracts the validation + body-key lookup once; `parse_envelope` and
  the new `parse_envelope_with_raw` share it.

Tests cover: `from_password` produces unique salt/token across two
back-to-back calls (direct + over-the-wire via wiremock
`received_requests`), static mode returns the same triple,
`c` query param starts with `psysonic/` and equals `SUBSONIC_CLIENT_ID`,
`get_song_with_raw` preserves untyped fields (`replayGain`,
`contributors`) in the raw value, `get_album_with_raw` keeps per-track
extensions in `raw.song[i]`, error 70 still maps to `NotFound` on the
raw variant, and `Song` deserializes `musicBrainzId` and
`mbid_recording` interchangeably.

B9 fingerprint-verify glue and the wider raw-ingest call sites stay
with PR-3 / C2 as the review's §5 / §7 checklist directs.

* feat(library): capability probe + sync_state accessors (Phase C1+C7, PR-3a) (#795)

First sub-PR of Phase C (sync orchestrator). Lands the foundation that
PR-3b's InitialSyncRunner consumes — pure plumbing, no runners or
background tasks yet.

- C1 capability probe. `psysonic_library::sync::CapabilityProbe::run`
  drives the §6.1 probe chain: Subsonic ping (captures `ServerInfo`
  envelope metadata for server-type / OpenSubsonic detection), then
  best-effort probes for search3 / getScanStatus / getIndexes, plus an
  optional Navidrome native bulk probe (caller passes
  `NavidromeProbeCredentials`). `CapabilityFlags(u32)` matches the
  §6.1.1 bitfield: NavidromeNativeBulk / SubsonicSearch3Bulk /
  ScanStatusAvailable / OpenSubsonic / UnstableTrackIds / FileTreeBrowse.
- C7 sync_state accessors. `SyncStateRepository` gains get/set
  capability_flags, get/set sync_phase (idle / probing / initial_sync
  / ready / error), and column-scoped setters for server_last_scan_iso,
  indexes_last_modified_ms, artists_last_modified_ms, library_tier.
  Every setter uses `ON CONFLICT … DO UPDATE` scoped to its own column
  so concurrent watermark writes don't clobber each other.
- Supporting additions in `psysonic-integration`:
  - `subsonic::SubsonicClient::server_info()` extracts `ServerInfo`
    from the ping envelope (server_type, server_version, api_version,
    open_subsonic). Re-uses `send()` so auth lifecycle is the same.
  - `navidrome::probe::native_bulk_available(url, token)` does the
    `GET /api/song?_start=0&_end=1` Bearer-auth probe. Returns
    Ok(true) on 2xx, Ok(false) on 4xx (auth ok but endpoint missing),
    Err on 5xx. Probe-only — full nd_list_songs port is PR-3b.
- `psysonic-library/Cargo.toml` gains a `psysonic-integration`
  dependency (sync calls into Subsonic + Navidrome probes). DAG stays
  acyclic: integration does not depend on library.

Per cucadmuh's PR-3 kickoff answer (handoff `2026-05-19-pr3-kickoff.md`):
- Crate placement: option A — sync lives in `psysonic-library/src/sync/`,
  no new psysonic-sync crate.
- N1 gate: probe is `/api/song?_start=0&_end=1` only; `nd_list_artists_by_role`
  is NOT required (Q3 answer + N1 ingest port lands in PR-3b).
- UnstableTrackIds: set for Navidrome via `ServerInfo.server_type`,
  cleared for generic Subsonic.

Tests added: 23 across library/sync, library/repos/sync_state,
integration/subsonic, integration/navidrome/probe. Cover bitfield
contains/insert/remove + spec bit values, probe across mixed-capability
servers (full Navidrome, minimal Subsonic, broken endpoints), ping-failure
short-circuit, optional Navidrome creds gating N1, sync_state column-scoped
upserts (capability_flags / sync_phase / watermarks / library_tier),
cross-column independence (capability writes don't reset cursor),
ServerInfo extraction from ping envelope, Navidrome bulk probe across
2xx/4xx/5xx.

* feat(library): InitialSyncRunner + C12 backoff + C13 id remap (Phase C2/C12/C13, PR-3b) (#796)

Second sub-PR of Phase C — wires the actual ingest path on top of
PR-3a's capability + sync_state foundation. Runner is pure async Rust:
PR-3d will spawn it inside a tokio task and emit Tauri progress events
on top.

- C2 InitialSyncRunner. Drives spec §6.3 IS-1 → IS-6: probe-derived
  IngestStrategy (enum N1/S1/S2/S3, selector picks N1 → S1 → S2 chain
  per kickoff Q3), per-page upsert loop, cursor flush after every
  successful batch, IS-4 best-effort getArtists watermark, IS-5
  getScanStatus.lastScan capture, IS-6 phase=ready + cursor cleared.
  Resume is automatic: a non-empty initial_sync_cursor_json restarts
  at the persisted offset; a strategy mismatch between cursor and
  capability flags surfaces as SyncError::CursorIncompatible.
- C12 backoff. sync::backoff::Backoff implements the §6.8 schedule
  (2s → 4s → … cap 120s) with ±25% jitter via deterministic salt.
  retry_with_backoff wraps every endpoint call: transport / Navidrome
  failures retry up to MAX_ATTEMPTS_PER_BATCH (5), the cursor never
  advances on failure, success resets the counter. Cancellation
  AtomicBool is checked between attempts.
- C13 id remap. TrackRepository::upsert_batch_with_remap performs the
  §6.9 detect-and-rebind pass inside the same SQLite transaction as
  the upsert: a content_hash or server_path collision on a different
  existing id triggers UPDATE of child tables (track_offline,
  track_extension, track_fact, track_artifact, track_canonical_link),
  INSERT INTO track_id_history, DELETE old track row. Off when
  UnstableTrackIds is clear (generic Subsonic). New
  TrackIdHistoryRepository read-side helper for forward lookups
  (analysis cache reuse, Phase E).
- IngestStrategy enum + selector (sync::strategy) — N1 → S1 → S2;
  N1 requires Navidrome bearer credentials at runtime (skipped when
  None). S3 is enumerated for future file-tree fallback but returns
  StrategyUnsupported in v1 per kickoff Q3.
- InitialSyncCursor (sync::cursor) — JSON-serialisable
  { strategy, phase, library_scope, ingested_count, strategy_state }.
  StrategyState tagged enum: LinearOffset { offset } for N1/S1,
  AlbumCrawl { album_offset, current_album_id } for S2.
- mapping::subsonic_song_to_track_row + navidrome_song_to_track_row
  centralise the JSON → TrackRow projection. Subsonic path also reads
  replayGain.{trackGain,albumGain} from the raw value so PR-3b doesn't
  drop the columns that PR-2b reserves on TrackRow.
- Supporting bits in psysonic-integration:
  - subsonic types now derive Serialize so the runner can round-trip
    a typed Song back into raw JSON when feeding upsert.
  - navidrome::queries gains nd_list_songs_internal — pure async
    function (no #[tauri::command] decorator) that the N1 ingest
    loop calls directly. The existing Tauri command wraps it.

Tests added across sync::* and repos::track_id_history. Wiremock
covers S1 happy-path, mid-cursor resume from a persisted offset,
strategy mismatch → CursorIncompatible, 503 transient → retry-then-
succeed, AtomicBool cancellation → Cancelled, N1 paginated /api/song
ingest, S2 album crawl, and §6.9 remap firing under UnstableTrackIds
during an actual sync. Backoff schedule + jitter formula pinned.
TrackRepository remap path covered by content_hash collision,
server_path collision, hash+path-missing skip, identity-noop, and
remap-off compatibility with the existing upsert_batch contract.

Also fixes cucadmuh's PR-3a review minor 1: drops the dead
`mount_ok` scaffolding from sync::capability tests.

Out of scope per kickoff Q2:
- DeltaSyncRunner + tombstones → PR-3c
- Background task lifecycle, cancellation wiring, progress emit
  throttle, adaptive scheduler, request budget, bandwidth lane → PR-3d
- Tauri command surface for "sync now" / progress events → PR-5

* feat(library): search3 raw envelope fidelity for S1 ingest (PR-3b follow-up) (#797)

Picks up cucadmuh's PR-3b review minor 1: the S1 path in
InitialSyncRunner was reserialising the typed `Song` for
`track.raw_json`, dropping unknown OpenSubsonic extensions
(`replayGain`, `contributors`, …). N1 and S2 already carry the raw
sub-tree verbatim through `nd_list_songs_internal` and
`get_album_with_raw`; S1 now matches via the new
`SubsonicClient::search3_with_raw` mirror of the PR-2b pattern.

- subsonic::SubsonicClient::search3_with_raw — returns
  `(SearchResult, serde_json::Value)`; uses the existing
  `parse_envelope_with_raw` so error 70 / `Api { code, .. }` mapping
  stays consistent.
- sync::initial::run_s1 now calls `search3_with_raw` and feeds the
  per-song raw sub-tree (`raw_body.song[i]`) into
  `subsonic_song_to_track_row` instead of a typed reserialise.

Tests cover `search3_with_raw` round-trip on a payload with
`replayGain` + `contributors` (verifies the raw value preserves both)
and the empty-result case where the body is `searchResult3: {}`.
Plus an end-to-end S1 ingest test that asserts the persisted
`track.raw_json` column contains the OpenSubsonic extensions after a
full runner pass, and that `replay_gain_track_db` / `_album_db` still
land on the typed columns via the mapping helper.

Full review: psysonic-workdocs/internal/collaboration/handoffs/2026-05-19-pr-796-review.md

* feat(library): DeltaSyncRunner + TombstoneReconciler (Phase C3/C4, PR-3c) (#798)

Third sub-PR of Phase C — drives targeted delta passes on top of
PR-3a/b's foundation. Pure async; PR-3d will spawn it inside the
background scheduler.

- C3 DeltaSyncRunner. Walks spec §6.4 DS-0 … DS-9:
  - DS-0/1/2/3 cheap probe via `getArtists` (small/medium tier) or
    `getScanStatus` (huge tier when `ScanStatusAvailable`). Server
    watermark match → up_to_date short-circuit, scan-in-progress →
    deferred_scanning report; zero further requests in either case.
  - DS-4 targeted ingest. Strategy from capability_flags: N1-delta
    when NavidromeNativeBulk is set, otherwise S2-delta. S1 has no
    delta semantic so it's not used here.
    - N1-delta: GET /api/song _sort=updated_at _order=DESC, pages
      until rows fall under the local `MAX(server_updated_at)`
      watermark; out-of-band rows in the same page are dropped.
    - S2-delta: getAlbumList2 type=newest then type=recent, up to
      a small page cap; getAlbum is fetched only for album_ids the
      local store doesn't already have. Known albums are skipped
      so a play-bump under "recent" doesn't re-ingest the whole
      tracklist.
  - DS-6 id remap reuses TrackRepository::upsert_batch_with_remap.
  - DS-9 stamps next watermark (artists_last_modified_ms or
    server_last_scan_iso) + last_delta_sync_at.
  - DS-5 canonical matcher (Phase H) and DS-7 starred delta are out
    of scope for PR-3c.
- C4 TombstoneReconciler. Caller-driven streaming: each
  `reconcile_chunk(budget)` picks the next `budget` ids ordered by
  synced_at ASC, calls getSong, marks deleted=1 on code 70, and
  refreshes synced_at on every checked id so the queue rotates.
  Mode A (manual integrity) loops until checked == 0; Mode B
  (auto-threshold) tests `should_auto_reconcile(local, server, pct)`
  per delta tick and runs a small budgeted chunk. Memory bounded —
  no full local-id list ever held in RAM.

- SyncStateRepository: new getters for artists_last_modified_ms,
  server_last_scan_iso, library_tier; new
  set_last_delta_sync_at stamp helper. All column-scoped upserts
  preserve neighbouring fields.

Tests cover DS-2 short-circuit (watermark match), DS-3 defer
(scanning=true), N1-delta watermark cutoff (3 fresh + 2 stale rows →
only 3 upserted), S2-delta known-album skip (mock 404 on al_known
guards the assertion), DS-9 watermark + last_delta stamping,
should_auto_reconcile threshold cases (gap, tolerance, server=0,
local<=server), reconcile_chunk code-70 → deleted=1, budget +
ordering (oldest first, newest untouched), empty-store noop, and
cancellation.

PR-3d (background task, probe→flags wiring, progress emit, adaptive
scheduler, request budget, bandwidth throttle) lands next on the
same integration branch.

* feat(library): sync supervisor + progress channel + DS-8 wiring (Phase C5/C6, PR-3d1) (#799)

First half of PR-3d (cucadmuh-approved split per kickoff Q2).
Pure-Rust lifecycle + progress infrastructure on top of the runners
from PR-3a/b/c. Tauri events stay in the top crate (PR-5); this PR
only ships the channel the top crate will subscribe to.

- C5 SyncSupervisor. Spawns a sync workload inside a tokio task,
  owns the cancellation AtomicBool, and exposes a single-consumer
  mpsc receiver for ProgressEvent. join() returns the inner
  Result<(), SyncError>; panics surface as Storage so callers
  never need to know about tokio internals.
- C6 progress channel. New sync::progress module:
  - ProgressEvent enum — lean variants
    (PhaseChanged / IngestPage / Remapped / Tombstoned /
    Completed / Error). Server / scope context lives on the
    channel side (one supervisor = one scope).
  - Progress trait + NoopProgress default + ChannelProgress
    forwarding through tokio mpsc. Throttle is the simple
    last-emit-timestamp gate; terminal events (Completed /
    Error) bypass it.
- InitialSyncRunner + DeltaSyncRunner gain with_progress(...)
  builders. IS-1 / IS-6 emit PhaseChanged + Completed; delta
  emits PhaseChanged at strategy pick, Tombstoned at DS-8, and
  Completed at DS-9. Defaults to NoopProgress so existing call
  sites keep working.
- DS-8 wired. DeltaSyncRunner::with_tombstone_budget(n) drives
  TombstoneReconciler::reconcile_chunk(n) after DS-4 ingest;
  shares the runner's cancellation flag + sleep override. The
  DeltaSyncReport gains tombstones_checked / tombstones_deleted
  so callers can act on the counts.
- capability::probe_and_persist helper. Chains
  CapabilityProbe::run with sync_state writes: sets phase to
  "probing" before the probe, persists capability_flags, then
  drops back to "idle". PR-3d2 (the scheduler) will call this in
  front of every initial / delta run so the stored flags reflect
  the live server.

Tests cover: ChannelProgress throttle (zero-interval pass-through,
terminal bypass, non-terminal collapse, sender alive after
receiver drop), SyncSupervisor task completion + cancel +
panic-as-Storage + receiver-take-once, probe_and_persist
round-trip through SyncStateRepository (flags persisted, phase ends
at "idle"), DS-8 reconcile-after-ingest landing tombstones on
code 70 returns.

PR-3d2 follows with the adaptive scheduler (C8), request budget
(C9), poll EWMA (C10), and the bandwidth / queue priority lane
(C11).

* feat(library): adaptive scheduler + request budget + EWMA poll + bandwidth (Phase C8/C9/C10/C11, PR-3d2) (#800)

Second half of PR-3d per cucadmuh's kickoff-Q2 split. Wraps the
runners + supervisor from PR-3a/b/c/d1 into a tick-driven background
scheduler. Top crate (PR-5) plumbs the timer.

- C8 BackgroundScheduler. Tick-based — caller drives the interval,
  scheduler decides whether the tick should run. is_due(now_ms)
  checks sync_state.next_poll_at; tick(now_ms) either skips
  (not due / PrefetchActive pause), or runs a DeltaSyncRunner with
  the right budget + tombstone trigger, then stamps the next
  poll_at via the adaptive formula. No tokio task ownership —
  tests stay deterministic, PR-5 plugs spawn behaviour to taste.
- C9 RequestBudget. PassKind enum (PollTick / DeltaLight /
  DeltaMismatch / InitialSync) with caps per spec §6.2.5
  (1 / 50 / 200 / unlimited). RequestBudget::has_room(used) gates
  the runner; PR-3d2 ships the data type, runner enforcement of
  the cap is a future tightening (DeltaSyncRunner already has its
  own page cap so the soft cap mostly informs Settings).
- C10 PollStats EWMA. New sync::poll_stats with PollStats
  (artist_count, ewma_bytes, ewma_duration_ms, library_tier),
  observe()/set_artist_count()/reclassify() helpers, the §6.2.2
  tier table (<2k / 2k-15k / >15k or ewma_bytes >2MB), and
  next_interval_ms following the spec formula
  (base * load_factor * artist_factor, load_factor clamped
  [1, 10]).
- C11 PlaybackHint + ParallelismBudget. PlaybackHint enum
  (Idle / Playing / PrefetchActive) resolved to a
  ParallelismBudget { max_concurrent, min_request_gap_ms }.
  PrefetchActive pauses bulk (`max_concurrent = 0`) per
  §6.2.4; the scheduler honours it via tick short-circuit.
- Auto-tombstone wire. Before running the DeltaSyncRunner the
  scheduler tests `should_auto_reconcile(local, server, pct)`
  against the persisted counts; on threshold trip it sets
  `with_tombstone_budget(200)` (the §6.2.5 DeltaMismatch cap).
- SyncStateRepository gains poll_stats_json get/set,
  next_poll_at get/set, local_track_count get/set, and
  server_track_count get/set — all column-scoped upserts.

Tests: ~30 new across poll_stats / budget / bandwidth / scheduler.
EWMA seed + smoothing, tier-classification edges (artist + size
overrides), next-interval formula bounds (idle base, slow-network
load_factor clamp), RequestBudget caps per pass, ParallelismBudget
resolution, scheduler is_due (no schedule / future schedule),
tick short-circuit (not due, PrefetchActive pause), tick runs
delta and persists next_poll_at, auto-tombstone trigger above
5 % threshold, PollStats round-trip through SQLite.

Together with PR-3d1 this finishes Phase C — Tauri command surface
(D1-D4) lands with PR-5.

* feat(library): read-only Tauri command surface (Phase D1 part 1, PR-5a) (#801)

First sub-PR of Phase D per cucadmuh's kickoff Q1 split. Lands the
LibraryRuntime Tauri State plus the 8 read-only library commands
from spec §7.1. No SyncSupervisor spawn, no sync lifecycle commands,
no credentials store — those land in PR-5b.

- New psysonic_library::runtime::LibraryRuntime — Tauri State
  wrapping Arc<LibraryStore>. Top crate's lib.rs setup() now calls
  LibraryStore::init(app), wraps the result in the runtime, and
  app.manage's it. Mirrors the AnalysisCache wiring above it.
- New psysonic_library::dto module — camelCase wire DTOs per
  src-tauri/CLAUDE.md: SyncStateDto, LibraryTrackDto (flat
  projection over the track hot columns + raw_json sub-tree),
  LibraryTracksEnvelope, TrackArtifactDto, TrackFactDto,
  OfflinePathDto, TrackRefDto. local_tracks_max_updated_ms helper
  surfaces the implicit N1-delta watermark on the SyncStateDto.
- New psysonic_library::payload module — pure
  ProgressEvent → LibrarySyncProgressPayload mapper (the
  payload Tauri events carry once PR-5b plugs the supervisor's
  mpsc receiver into AppHandle::emit). Constants for the event
  names too. Unit-testable without Tauri runtime.
- New psysonic_library::commands module with 8 #[tauri::command]
  handlers:
  - library_get_status — joins the sync_state row + the
    track-watermark MAX query into one SyncStateDto.
  - library_search — FTS5 via the existing search_tracks helper,
    paginated; hydrates hits to full LibraryTrackDto.
  - library_get_track — single SELECT through new
    TrackRepository::find_one.
  - library_get_tracks_batch — capped at 100 refs/call per spec,
    preserves caller-supplied order, drops unknowns silently.
  - library_get_tracks_by_album — ordered by
    disc/track/id via new TrackRepository::find_by_album.
  - library_get_artifact — flexible WHERE over track_artifact
    (artifact_kind required, source/format optional), latest
    fetched_at wins.
  - library_get_facts — fact_kinds filter optional;
    returns all rows for the (server_id, track_id) pair when
    none specified, sorted by fact_kind + fetched_at DESC.
  - library_get_offline_path — returns local_path with a
    `missing: true` flag when the row is absent.
- TrackRepository gains find_one / find_batch / find_by_album
  with a shared row-to-TrackRow mapper. SQL constants pinned next
  to the existing UPSERT_SQL so a schema change touches one file.
- src-tauri/src/lib.rs: LibraryStore::init in setup(), the eight
  command handlers added to invoke_handler!.

Tests cover: DTO field-name camelCase (IPC contract guard),
LibraryTrackDto round-trip through TrackRow, raw_json fallback to
Value::Null on bad input, local_tracks_max_updated_ms ignores
deleted rows, TrackRepository::find_one / find_batch / find_by_album
ordering + unknown-ref drop, ProgressEvent mapper across all six
variants + serialization keys camelCase. Library tests at 166;
workspace stays green.

Out of scope per kickoff Q1:
- Mutating commands (library_sync_*, library_patch_*,
  library_put_*, library_purge_*, library_delete_*) → PR-5b
- SyncSupervisor spawn + background scheduler tick loop +
  progress emit → PR-5b
- library_sync_bind_session / clear_session credentials → PR-5b
- TS wrappers + Settings UI + server-remove modal → PR-5c
- library_advanced_search / library_search_cross_server SQL
  builders → PR-5d

* feat(library): sync lifecycle + mutate + purge Tauri surface (Phase D1 part 2, PR-5b) (#802)

Second sub-PR of Phase D per cucadmuh's kickoff Q1 split. Adds the
mutating side of §7.1 plus the SyncSession credentials store, the
PlaybackHint setter, the orchestrator that runs InitialSyncRunner /
DeltaSyncRunner under a Tauri AppHandle and emits library:sync-progress
and library:sync-idle events, and the top-crate scheduler tick task
that sweeps every bound session through BackgroundScheduler::tick.

- LibraryRuntime extended per kickoff Q2: sync_sessions HashMap,
  playback_hint cell, current_job (cancel handle + identity), and
  scheduler_cancel flag the tick task watches. Kickoff sketch said
  Mutex<Option<SyncSupervisor>> — supervisor's join() consumes self,
  so holding it in the mutex would block library_sync_cancel behind
  the orchestrator's join; CurrentJob carries the Arc<AtomicBool>
  cancel + metadata instead, orchestrator task owns supervisor /
  receiver / join.
- New commands (spec §7.1):
  - library_sync_bind_session — caches Subsonic creds in memory,
    tries navidrome_token once for bearer cache, runs
    probe_and_persist so capability_flags reflect the live server.
  - library_sync_clear_session — drops cached credentials.
  - library_set_playback_hint — JS pushes idle / playing /
    prefetch_active from existing audio listeners.
  - library_sync_start — dispatches InitialSyncRunner (mode='full')
    or DeltaSyncRunner (mode='delta', with auto-tombstone budget
    when local/server count gap exceeds threshold). Spawns runner
    + orchestrator task that drains the progress mpsc into
    library:sync-progress emits and emits library:sync-idle when
    the runner exits.
  - library_sync_cancel — trips the current job's cancel flag.
  - library_patch_track — sparse JSON patch (starredAt, userRating,
    playCount, playedAt) per §6.5.
  - library_put_artifact / library_put_fact — upserts with
    ON CONFLICT scoped to the PK so lyrics / BPM writes survive
    re-fetches.
  - library_purge_server — transactional DELETE across the v1
    schema tables for this server_id. include_offline (default
    false) controls track_offline + bytes_freed.
  - library_delete_server_data — alias that always purges offline
    too (logout flow).
- src-tauri/src/lib.rs setup() spawns a 30 s
  MissedTickBehavior::Skip task that snapshots bound sessions and
  drives BackgroundScheduler::tick(now_ms) for each. Honours
  runtime.scheduler_cancel + the current PlaybackHint. Background
  ticks stay silent (NoopProgress) — Tauri emit for the
  scheduler path lands when Settings (PR-5c) surfaces it.
- psysonic-integration::navidrome re-exports navidrome_token so the
  bind_session command can drive the bearer cache without making
  the client module pub.

Tests cover: LibraryRuntime session round-trip (set/get/clear
scopes per server), playback_hint default + setter, snapshot
returns clones so callers can mutate freely. Existing library tests
stay green (171 → 171; new code paths under the Tauri command
surface — devtools integration smoke is PR-5c's job).

Out of scope per kickoff Q1:
- src/library/ TS wrappers + Settings UI subsection + server-remove
  modal → PR-5c
- library_advanced_search / library_search_cross_server SQL
  builders → PR-5d
- Background-tick Tauri emit (NoopProgress today) → PR-5c
- analysis_cache cross-purge in library_purge_server → PR-6

* feat(library): typed invoke wrappers + verify_integrity command (Phase D2 + part of D1, PR-5c) (#803)

Frontend-facing slice of Phase D. Ships the typed src/api/library.ts
wrapper layer that any Settings / browse code will import from, plus
the manual-integrity backend command PR-5b's review §5 note 2 called
out as missing.

Scope cut from cucadmuh's PR-5 kickoff Q1 split: that proposal had
PR-5c = D2 + D3 + D4 (wrappers + Settings subsection + server-remove
modal). The Settings UI + server-remove + audio playback-hint
wiring + authStore extensions + i18n strings turn into a thick frontend
patch in their own right; landing them in one PR with the wrappers
would mix Tauri-surface review with Settings UX review. The split:

- PR-5c (this PR) — D2 wrappers + library_sync_verify_integrity.
- PR-5c-ui (follow-up) — D3 Library Settings subsection, D4
  server-remove modal contract, playback hint feed, authStore /
  i18n.

Per kickoff exit clause ("Do not split 5c unless review size
forces it"). Reviewable as a clean Tauri-surface vs UX boundary.

- Backend: `library_sync_verify_integrity { serverId, libraryScope? }`
  command — same dispatch shape as `library_sync_start { mode:'delta' }`
  but always forces the full `DELTA_MISMATCH_CAP` tombstone budget
  regardless of the local/server count gap. Spec §6.7 Mode A user-
  initiated full reconcile bypasses the threshold check that
  governs background ticks.
  `library_sync_start` itself is refactored to delegate to a private
  `library_sync_start_inner(force_full_tombstone)` so both entry
  points share the runner-spawn + orchestrator + emit code.

- Frontend `src/api/library.ts`: full typed wrapper layer over the
  19 `library_*` Tauri commands. DTO mirrors carry the camelCase
  wire shape (`SyncStateDto`, `LibraryTrackDto`, `TrackArtifactDto`,
  `TrackFactDto`, `OfflinePathDto`, `PurgeReportDto`, `SyncJobDto`,
  `TrackRefDto`, `ArtifactInputDto`, `FactInputDto`). Plus the
  `LibrarySyncProgressPayload` / `LibrarySyncIdlePayload` interfaces
  and `subscribeLibrarySyncProgress` / `subscribeLibrarySyncIdle`
  helpers that wrap `@tauri-apps/api/event` listen. PlaybackHint
  literal type lives here too (`'idle' | 'playing' | 'prefetch_active'`)
  so the audio listeners in PR-5c-ui can import a single source of
  truth.

- `src-tauri/src/lib.rs` adds the new verify_integrity handler to
  the `invoke_handler!` aggregate.

Tests: library tests stay at 171 — verify_integrity is exercised
through the existing `sync_start_inner` paths; the wrapper layer is
trivial passthrough that TypeScript types already check. Vitest
coverage for the typed wrappers belongs with PR-5c-ui where there
are real consumers (LibraryTab) to drive integration tests.

PR-5c-ui (next) lands:
- Library Settings subsection (§7.3 minus advanced toggles)
- ServerRemoveModal extension (keep vs delete local index per §5.6)
- authStore: libraryIndexEnabledByServer + auto-reconcile toggle
- src/store/audioListenerSetup audio:playing / ended /
  setDeferHotCachePrefetch → library_set_playback_hint
- i18n keys for the new strings

* feat(library): Settings library index UI + playback hint + purge-on-remove (Phase D3/D4, PR-5c-ui) (#804)

* feat(library): Settings library index UI + playback hint + purge-on-remove (Phase D3/D4, PR-5c-ui)

Frontend half of Phase D, on top of PR-5c's typed wrappers. Wires the
Settings → Library subsection (§7.3), the audio playback-hint feed
(§6.2.4), and the server-remove keep-vs-delete choice (§5.6).

- New libraryIndexStore (Zustand, persisted) — per-server enable flag
  + auto-reconcile toggle. Kept out of authStore so the index feature
  evolves independently and the persisted blob stays small.
- New LibraryIndexSection in Settings → Library:
  - Per-server "Enable local library index" toggle → binds /
    clears the Rust sync session with the active server's
    credentials. Off by default (P6).
  - Read-only status (Idle / Checking / Initial sync / Ready (n) /
    Error) polled from library_get_status every 3 s, overlaid with
    live library:sync-progress events.
  - Sync now / Verify integrity / Cancel buttons. Verify runs one
    §6.7 pass (budget 200) per click; the status line shows the
    checked/removed counts so large libraries can be continued with
    another click (auto-resume loop is a follow-up).
  - Auto-reconcile toggle.
  - Subscribes to library:sync-progress + library:sync-idle for the
    active server; errors surface as a toast.
- Audio playback hint: handleAudioPlaying → 'playing',
  handleAudioEnded → 'idle' via notifyLibraryPlaybackHint, which
  gates on the per-server index toggle + dedupes repeated hints so
  the IPC boundary isn't spammed on every progress tick.
- ServersTab delete flow: when a server with an enabled index is
  removed, a second confirm offers keep-vs-delete of the local
  library cache (OK = library_delete_server_data, Cancel = retain
  for offline). Always clears the sync session.
- i18n: en + de keys for the new strings; other locales fall back
  to en via i18next (later sweep).

Per PR-803 review §5: verify-integrity resume UX is one-pass-per-click
with a visible counter; sync_start idempotency (replaces in-flight)
is surfaced via the Cancel button appearing while busy.

Out of scope:
- VirtualSongList / playerStore local-mode consumers → PR-7 (F1/F3/F5)
- library_advanced_search / cross-server UI → PR-5d + PR-7 F2
- Auto-resume loop for very large verify-integrity runs → follow-up
- Search-all-servers + threshold input (advanced §7.3) → later

* fix(library): normalize server base URL before bind probe

The bind toggle threw "subsonic transport: builder error | relative
URL without a base" — `server.url` is stored bare (e.g.
`nas.example.com`) and reqwest needs a scheme. Two-sided fix:

- Frontend: LibraryIndexSection passes `authStore.getBaseUrl()`
  (adds http:// + strips trailing slash) instead of the raw
  `server.url`, matching the existing `subsonic.ts` convention.
- Backend: `library_sync_bind_session` normalizes the incoming
  `base_url` defensively so the stored session + every downstream
  caller (sync_start, scheduler tick, navidrome_token) gets a
  scheme-qualified URL regardless of what the WebView sends.

Tests: normalize_base_url covers bare host, trailing slash, existing
http/https scheme, and whitespace.

* fix(library): re-bind sync session on startup + server switch

"Library sync failed: no bound session" — the per-server index toggle
persists in localStorage but the Rust sync session (credentials +
bearer) lives in process memory and is gone after an app restart, so
the toggle showed "on" while no session existed. Per PR-5 kickoff Q5
("on server connect if index already on").

- New `ensureActiveServerSessionBound()` helper: re-binds the active
  server's session when its index toggle is enabled. Best-effort —
  silent on failure (Settings surfaces the real error on explicit
  toggle).
- MainApp re-binds on every `activeServerId` change (covers app
  startup + server switch — `setActiveServer` drives the effect).
- LibraryIndexSection re-binds on mount before the first status poll,
  so Sync now / Verify integrity work immediately even when the
  toggle was already on from a previous run.

* fix(library): trigger initial full sync on first enable (PR-804 review §5.1)

cucadmuh's PR-804 review flagged this as release-blocking: the toggle
only bound the session and «Sync now» / the background tick ran
delta-only, so a fresh enable left the index empty — delta can't
populate a never-synced library.

- On first enable, after bind, fetch status and dispatch
  `library_sync_start { mode: 'full' }` when `lastFullSyncAt` is null
  (matches spec §6.2 "initial sync always background").
- «Sync now» now picks mode adaptively: `full` until a full sync has
  completed, `delta` afterward — so the button works both for the
  initial population and incremental updates.

Other PR-804 review notes (auto-reconcile toggle → backend wiring,
prefetch_active hint, clear-old-session-on-switch) stay as documented
non-blocking follow-ups.

* feat(library): advanced search + cross-server SQL builders (Phase D-search, PR-5d) (#806)

* feat(library): advanced search + cross-server SQL builders + commands (Phase D-search, PR-5d)

- FilterFieldRegistry SQL resolution: SqlFragment, compare_fragment, validate_for_entity (§5.13.5)
- Advanced Search builder: per-entity track/album/artist queries; genre (case-insensitive), year, starred, bpm filters; bpm dual-storage resolution (§5.13.4); libraryScope; sort allowlist; full-match totals
- Cross-server FTS union (§5.5B / §5.9 A') with canonical-id dedup
- library_advanced_search + library_search_cross_server commands, registered in the shell

* feat(library): typed advanced search / cross-server invoke wrappers (PR-5d)

Mirror request/response DTOs and add libraryAdvancedSearch / librarySearchCrossServer
in src/api/library.ts. UI parity (AdvancedSearch.tsx) stays PR-7.

* fix(library): self-heal stale/unreadable initial-sync cursor instead of bricking (#807)

The initial-sync cursor records the ingest strategy it was created under.
When a re-probe later selects a different strategy (e.g. the Navidrome
native bearer is briefly unavailable, downgrading N1->S2), the cursor guard
returned a hard error — and since nothing clears the cursor, every later
full sync failed with no recovery path.

Reset the stale (or unreadable) cursor and start fresh under the selected
strategy instead of erroring. Re-ingest is idempotent (upsert); the
tombstone pass reconciles leftovers.

* fix(library): emit per-batch progress during initial sync (#808)

The initial-sync runner only emitted PhaseChanged (start) and Completed
(end), so the Settings status sat at "initial_sync" with no count for the
entire ingest — looking stuck on large libraries even while rows landed.

Emit IngestPage per batch from the N1/S1/S2 loops with the running ingested
total; the existing <=2 Hz throttle paces it. The frontend already renders
the count from these events.

* feat(library): Advanced Search reads the local index when ready (Phase F2, PR-7a) (#811)

When the active server's index is fully synced, Advanced Search serves
query / genre / year / result-type from library_advanced_search (instant +
offline) and pages songs locally. On not-ready or any failure it falls back
to the existing network path unchanged (spec 5.13.6). Results map from each
entity's stored Subsonic rawJson, with the flat hot columns as a fallback.

* feat(library): canonical matcher — link tracks by ISRC/MBID on ingest (Phase H1/H2, PR-4a) (#812)

Adds the strong-key cross-server matcher (spec §5.5A): on every track upsert,
link (server_id, track_id) to a canonical id derived from its ISRC (preferred)
or MBID recording. Deterministic id (`{kind}:{value}`) keeps it O(1) and
idempotent — no lookup-then-create race, no fuzzy loop on the bulk path.
Tracks without a strong key stay standalone (fuzzy/search-time matching is H3).

* feat(library): cross-server fuzzy fallback in search (Phase H3, PR-4b) (#813)

library_search_cross_server now returns a `fuzzy` list alongside the exact
FTS `hits` (spec §5.9): per-server `title LIKE %query%` for matches the exact
pass missed (diacritics, partial words), capped per server, excluding exact
hits and deduped by canonical id against them. Shared `like_contains` moved
to the `search` module.

* feat(library): FactRepository with TTL + provenance rules (Phase E4, PR-6a) (#814)

Typed CRUD over track_fact behind library_get_facts / library_put_fact
(spec §5.12): get lazily deletes the track's expired facts then returns the
survivors (no background GC, P34); a `user` bpm fact also writes the hot
track.bpm column so the override wins and survives a resync (R6-3.4). The
commands now delegate here instead of inlining the SQL.

* feat(library): ArtifactRepository with TTL + 512KB cap (Phase E4, PR-6b) (#815)

* fix(integration): decode OpenSubsonic isrc string-array on Song (#818)

OpenSubsonic types `isrc` as `string[]`; Navidrome 0.61.2 ships it as
`isrc: []` or `["USRC…"]`. The typed `Song.isrc: Option<String>` could
not decode either form, which broke the S1 (`search3`) and S2
(`getAlbum`) ingest paths on real Navidrome libraries — initial sync
could not complete past the first array-valued track.

Add a tolerant `de_string_or_seq` deserializer: plain string →
`Some`, non-empty array → first usable value (string element, or an
object element's `name` for the `[{ "name": … }]` shape), `[]`/null →
`None`. The full multi-value set still survives verbatim in
`track.raw_json` (ADR-7). Applied to `Song.isrc`.

Per maintainer policy R7-15 (workdocs question
2026-05-20-large-library-ingest-client-only, checklist item 1):
treat Navidrome as a black box, harden the client decode.

Tests cover `isrc: []` → None, populated array, and the legacy
single-string form.

* feat(library): large-library ingest strategy — S1 over N1 (R7-15) (#819)

Per maintainer policy R7-15 (large-library ingest, client-only): very
large Navidrome catalogs must not start initial sync on N1 — its native
`/api/song` returns HTTP 500 beyond a deep offset and can never finish.
S1 (`search3`) does not hit that wall.

- Add `IngestStrategy::select_initial_strategy(flags, server_track_count,
  n1_bulk_unreliable)`. Large libraries (count > LARGE_LIBRARY_THRESHOLD,
  default 40_000) or servers flagged `n1_bulk_unreliable` route to S1 — or
  S2 when search3 bulk is absent. Normal-size libraries keep the cheapest
  N1 → S1 → S2 chain unchanged.
- Persist the learned per-server `n1_bulk_unreliable` flag on `sync_state`
  (additive migration 002, DEFAULT 0). The mid-run N1→S1 fallback that
  sets it lands in a follow-up.
- Capture `getScanStatus.count` in the capability probe and persist it as
  the `server_track_count` watermark, so the threshold applies from the
  first sync rather than only after N1 hits the wall once. A count-less
  probe never clobbers a watermark from a prior run.
- The initial-sync runner now selects via the new policy.

Tests: selector table (all branches incl. threshold boundary and the
search3-absent fallback), repo flag roundtrip, probe count capture +
watermark-preservation, migration head-version bookkeeping.

* feat(library): freeze ingest strategy on resume (R7-15 Q3) (#820)

A persisted initial-sync cursor that has already made progress must resume
under its own strategy and ignore what a fresh capability probe would now
pick. Previously any strategy mismatch reset the cursor to a fresh one — so
a flapping Navidrome bearer (N1 flag toggling between probes) restarted
ingest from offset 0 on every launch, which is why large initial syncs
never completed across restarts.

`load_or_init_cursor` now:
- resumes the cursor's strategy when it has progress (`ingested_count > 0`
  or `phase != Ingest`), regardless of the re-selected strategy;
- adopts the freshly-selected strategy only when there is no resumable
  progress (offset 0), where re-selecting costs nothing;
- still resets a corrupt/unreadable cursor rather than hard-erroring.

One guarded exception: a cursor still on N1 after the server was learned
`n1_bulk_unreliable` is known-broken and re-selects onto the non-N1 path
instead of resuming a wall-bound N1 loop (the mid-run N1→S1 fallback that
preserves progress lands next).

Tests: resume-with-progress freezes strategy and keeps the count;
no-progress cursor adopts the re-selected strategy; known-broken N1 cursor
re-selects; unreadable cursor still resets.

* feat(library): one-way N1→S1 fallback on deep-offset 500 (R7-15 Q5) (#821)

When the N1 ingest loop hits a persistent HTTP 500 at or beyond the
deep-offset safety line (`N1_DEEP_OFFSET_SAFE`, 50_000) it now treats it
as Navidrome's server-side deep-offset wall rather than a transient error:
it learns `n1_bulk_unreliable` for the server and finishes the sync on S1.

- `run_n1` catches the wall after retry exhaustion (`n1_hit_deep_offset_wall`:
  HTTP 500 AND offset >= the safety line) and hands off to `fall_back_n1_to_s1`.
  A 500 below the line stays a propagated error — no silent downgrade.
- The fallback flags the server, then restarts S1 from offset 0. N1 (`id ASC`)
  and S1 (`search3` default order) don't share an offset space, so resuming
  from the N1 offset would skip songs; re-ingest is idempotent (PK upsert),
  duplicate work over the rows N1 already wrote is acceptable for v1. The
  cursor is rewritten in place, never zeroed.
- One-way only: S1 never flips back to N1 mid-run. Combined with the
  persisted flag and the resume freeze, a future sync selects S1 directly.
- `N1_DEEP_OFFSET_SAFE` is overridable on the runner so the fallback is
  testable without 50k rows of fixture data.

Tests: deep-offset 500 falls back to S1, ingests the full set without
duplicating N1's rows, and persists the flag; a shallow 500 propagates and
does not flag the server.

* feat(library): cache + retry Navidrome bearer, keep N1 flag on transient loss (R7-15 Q3) (#822)

A flaky `/auth/login` previously stripped N1 for a whole bind: the bearer
was fetched once, best-effort, and a single miss dropped to Subsonic-only.
Per R7-15 Q3 a transient `navidrome_token` failure must not drop the
`NavidromeNativeBulk` capability.

- `bind_session` fetches the bearer with `navidrome_token_with_retry`
  (3 attempts, short backoff); if it still fails, it keeps the bearer
  cached from a prior bind instead of overwriting it with `None`. The token
  / credentials are never logged.
- `probe_and_persist` preserves a previously-learned `NavidromeNativeBulk`
  flag when it probes without a token — the server still supports
  `/api/song`; only the bearer is missing this bind. The capability is a
  stable server property, so a token-less probe must not clear it.
- `library_sync_start_inner` masks `NavidromeNativeBulk` from *this run's*
  strategy selection when the session has no token, so the run proceeds
  Subsonic-only (S1/S2) instead of selecting N1 with no creds. The
  persisted capability stays intact for a later bind that recovers the
  token. The in-flight cursor is already protected by the resume freeze.

Tests: token retry yields the token on success and `None` after exhausting
attempts; the probe keeps a learned N1 flag across a token-less re-probe.

* feat(library): mid-run S1→S2 fallback on persistent S1 failure (R7-15 Q8) (#823)

The N1→S1 fallback (#821) had no analogue when S1 itself fails on a server.
Per R7-15 Q8, a persistent S1 failure (C12 retries already exhausted) must
fall back to the universal S2 album crawl — no new artist-walk strategy.

- `run_s1` catches a persistent fetch failure from the `search3` retry loop
  (`is_fetch_failure`: transport / HTTP / decode / Subsonic API / not-found)
  and hands off to `fall_back_s1_to_s2`. Cancellation and storage errors
  propagate untouched.
- The fallback restarts S2 from scratch. S1 (`search3` order) and S2
  (album-list order) don't share an offset space, so resuming from the S1
  offset would skip songs; re-ingest is idempotent (PK upsert). The cursor is
  rewritten in place, never zeroed — the resume freeze then keeps the run on
  S2 across restarts.

This completes the ingest fallback chain N1→S1→S2 from the §6.3 strategy
order; the start-time "no search3 → S2" selection was already covered (#819).

Tests: a persistent S1 500 falls back to S2 and the album crawl ingests the
track.

* fix(library): resume interrupted initial sync on startup (#824)

* fix(library): resume interrupted initial sync on startup

An initial sync killed mid-run (app restart) sat at `idle` until the user
clicked «Sync now» — the background scheduler is delta-only and the
auto-full-sync only fired on the index toggle, not on the startup re-bind.

`resumeInitialSyncIfIncomplete` runs after the active server's session is
re-bound (startup + server switch): if no full sync has completed yet
(`!lastFullSyncAt`) it dispatches `library_sync_start { mode: 'full' }`,
which resumes from the persisted cursor instead of restarting from zero.
Once a full sync has landed it is a no-op, so delta stays the scheduler's
job. Best-effort — errors stay silent (Settings surfaces them on explicit
action).

Tests: starts a full sync when none has completed, no-ops once a full sync
has landed, stays silent when the status lookup fails.

* fix(library): silence cancelled-sync toast, de-dupe startup resume

Two rough edges from the startup resume:

- A cancelled sync surfaced as «Library sync failed: sync cancelled». The
  orchestrator emitted the runner's `Cancelled` result as an error on the
  sync-idle event. Cancellation is expected — the user cancelled, or a newer
  `library_sync_start` superseded the job (server switch / startup resume) —
  and is documented as silent. `sync_outcome_to_result` now maps
  `SyncError::Cancelled` to a clean idle, only real errors toast.
- `resumeInitialSyncIfIncomplete` is now de-duped per server. React
  StrictMode fires the startup effect twice, so a second `library_sync_start`
  cancelled the first (`set_current_job` is cancel-and-replace) — harmless
  with the fix above, but the dedupe avoids the wasted job + probe entirely.

Tests: `sync_outcome_to_result` keeps `Cancelled` silent and forwards real
errors; concurrent resume calls start a single full sync.

* fix(library): run DB read commands off the main thread (async) (#825)

The 10 library read commands were synchronous (`pub fn`). Per the Tauri v2
docs, commands without `async` run on the main thread — so a read that
blocks freezes the UI. During an initial sync the runner holds the single
`Mutex<Connection>` for a whole batch write (500 rows × per-row remap on
Navidrome + upsert + FTS, one transaction), and the Settings library
section polls `library_get_status` on an interval. Each batch write blocked
that polled read on the main thread → the window greyed out until the batch
finished, with the freeze growing as the DB grew.

Make the DB-touching read commands `async` so they run off the main thread:
`library_get_status`, `library_search`, `library_get_track`,
`library_get_tracks_batch`, `library_get_tracks_by_album`,
`library_get_artifact`, `library_get_facts`, `library_get_offline_path`,
`library_advanced_search`, `library_search_cross_server`. Reads still
serialize behind the writer (the single connection is intentional — the
schema mirrors `analysis_cache`, spec §5.1), but the wait no longer blocks
the UI. State-only commands stay sync. Invoke names / payloads are
unchanged, so the frontend is unaffected.

Spec §15 R7-15 follow-up — surfaced in live QA on a 170k library.

* feat(library): scope analysis cache by server_id (E1, schema only) (#826)

Add a versioned migration to audio-analysis.sqlite so waveform/loudness
rows are keyed per server. This is the schema-only step (PR-6c-1): every
existing row migrates to server_id='' and behaviour is unchanged. The
server_id write/read wiring, legacy fallback and lazy re-tag follow in 6c-2.

- migrations 001 (baseline = the pre-versioning schema) + 002 (rebuild the
  three tables with server_id; PK (server_id, track_id, md5_16kb), loudness
  + target_lufs)
- versioned runner mirroring the library store; each migration commits its
  schema change and version marker in one transaction, so a failure or crash
  rolls the whole migration back and retries cleanly
- VACUUM INTO snapshot before the table rewrite as a safety net beyond the
  transaction (disk-full at COMMIT, FS corruption)
- TrackKey gains server_id; all callers pass "" for now

* feat(library): analysis cache server_id wiring (E1, 6c-2) (#827)

* feat(library): scope analysis cache writes/reads/deletes by server_id (E1 wiring)

Build on the 6c-1 schema migration: thread the playback server scope
(playbackServerId ?? activeServerId) through the analysis cache so a server
switch can no longer surface another server's waveform/loudness for the same
bare track_id.

- Write: seed_from_bytes_* and the CPU-seed / HTTP-backfill queues carry a
  server_id; every audio write path (in-memory, ranged, legacy stream, local
  file, spill, preload), the syncfs offline/hot caches, and the backfill
  command write under the playback server (empty = legacy '').
- Read: get_latest_*_for_track and the exact-key lookup try the server scope
  first, then fall back to the legacy '' rows; a legacy hit is re-tagged onto
  the server scope (INSERT OR IGNORE, never clobbers a precise row). No bulk
  backfill — existing caches re-tag lazily on play, so they are not
  re-analysed wholesale.
- The backend gain-resolution path (loudness normalization, replay-gain
  updates, device resume) is scoped via a pinned current_playback_server_id on
  the audio engine, so normalization keeps working for server-scoped rows.
- Delete: delete_*_for_track_id scope to (server + legacy ''); reseed on one
  server no longer wipes another server's analysis. delete_all_waveforms stays
  global (Settings -> Storage).

Tauri boundary: analysis_get_waveform(_for_track), analysis_get_loudness_for_track,
analysis_delete_waveform/loudness_for_track and analysis_enqueue_seed_from_url
gain an optional serverId; audio_play and audio_preload gain an optional
serverId. All additive (absent = legacy '').

* feat(library): pass playback serverId to analysis IPC (E1 wiring)

Send getPlaybackServerId() (queueServerId ?? activeServerId) with every
analysis-cache call so reads/writes/deletes scope to the right server:

- audio_play / audio_preload (playTrack, resume, queue-undo restore, gapless
  byte-preload)
- analysis_get_waveform_for_track / analysis_get_loudness_for_track (waveform +
  loudness refresh)
- analysis_delete_waveform/loudness_for_track + analysis_enqueue_seed_from_url
  (reseed + loudness backfill)

Absent serverId stays backward-compatible (legacy '' scope).

* feat(library): content_hash from playback (E2, 6d) (#828)

* feat(library): record playback content_hash into the track store (E2)

Bridge the playback-derived md5_16kb into library `track.content_hash` (R7-16 Q4)
so id-remap can rebind a track when the server reassigns ids (§6.9).

- New `ContentHashSink` port in psysonic-core (closure handle, mirrors
  PlaybackQueryHandle): keeps psysonic-analysis decoupled from psysonic-library.
- `seed_from_bytes_into_cache` returns the computed md5; `seed_from_bytes_execute`
  fires the sink after a successful seed (Upserted or cache-hit) when a real
  server is known. The shell crate registers the sink to patch the library.
- `patch_content_hash` + `library_patch_track`'s new optional `contentHash`
  field write it; both no-op when the library has no row for (server_id, id),
  i.e. the index is off for that server.
- Sync upsert no longer clobbers it: `content_hash = COALESCE(NULLIF(
  excluded.content_hash,''), track.content_hash)` — a sync (which passes NULL)
  preserves the playback hash, a non-empty incoming hash still wins.

No schema migration — the `content_hash` column already exists. Tauri boundary:
`library_patch_track` gains optional `contentHash` (additive).

* feat(library): expose contentHash on libraryPatchTrack wrapper (E2)

Add optional `contentHash` to the `libraryPatchTrack` patch type so the TS
contract matches the extended Rust command. Normally written by the Rust
analysis bridge; exposed for completeness.

* feat(library): enrichment summary on library_get_track (E3, 6e) (#829)

* feat(library): enrichment summary on library_get_track (E3)

Add an optional `enrichment { waveformReady, loudnessReady, lyricsCached }` to
the single-track `library_get_track` read (R7-16 Q5). Read-only, per-server,
never blocks on the network; list/batch projections leave it unset.

- New `AnalysisReadinessQuery` port in psysonic-core (closure handle, mirrors
  ContentHashSink) keeps psysonic-library decoupled from psysonic-analysis. The
  shell crate registers it to probe the analysis cache by exact
  (server_id, track_id, content_hash) key with legacy '' fallback — read-only,
  no re-tag. waveform/loudness readiness is gated on a known content_hash (E2).
- `lyricsCached` from a new pure-read `ArtifactRepository::lyrics_cached`
  (valid, non-expired, non-not_found lyrics row).
- `library_purge_server`'s `includeAnalysis` documented as a deliberate v1
  no-op (R7-16 Q7): analysis is never deleted on purge / server remove.

Tauri boundary: `LibraryTrackDto` gains optional `enrichment` (additive).

* feat(library): mirror enrichment on LibraryTrackDto wrapper (E3)

Add `TrackEnrichmentDto` + optional `enrichment` to the TS `LibraryTrackDto` so
the contract matches the extended `library_get_track` response.

* feat(library): VirtualSongList browses the local index when ready (F1) (#830)

The all-songs browse now serves pages from the local library index when it is
ready for the active server, falling back to the unchanged network path
otherwise.

- `runLocalSongBrowse` (reuses the F2 local-read adapters): empty-query
  browse-all via `library_advanced_search`, whose default track order
  (`t.title COLLATE NOCASE ASC`) matches the network `ndListSongs('title','ASC')`
  path, so paging stays coherent across a local↔network boundary.
- Gated per page on `libraryIsReady` + `source === 'local'`; any miss / failure
  returns null → VirtualSongList uses the existing browse path unchanged.
- Search (non-empty query) stays on the network path for now; rich search is
  already covered by Advanced Search (F2).

* feat(library): patch-on-use for star/rating/scrobble (PR-7 F3) (#831)

* feat(library): library_patch_track clears nullable fields on explicit null (F3)

Extract the patch logic into a testable `apply_track_patch`. Nullable integer
fields (`starredAt` / `userRating` / `playCount` / `playedAt`) now distinguish an
absent key (leave untouched) from an explicit `null` (clear the column), so
`unstar` ({ starredAt: null }) actually un-stars the local row. `.map` keeps the
present/absent distinction; `as_i64()` yields the value or `None` → bound as SQL
NULL. F3 is the first caller that sends null, so no existing behaviour changes.

* feat(library): patch-on-use wiring for star / rating / scrobble (F3)

After a successful star/unstar, setRating, or play scrobble, mirror the change
into the local library index via `library_patch_track` so its reads (browse F1,
advanced search F2) reflect the action immediately — no stale list after a rate,
no full resync.

- `patchLibraryTrackOnUse` helper: fire-and-forget, gated on the index being
  enabled for the server; the Rust command additionally no-ops when no row
  matches (album/artist id, or index off).
- Wired at the central API chokepoints: `star`/`unstar` (song only) →
  `starredAt`, `setRating` → `userRating`, `scrobbleSong` → `playedAt`.
- `play_count` is left to the next sync (the patch sets absolute values; a
  correct increment needs the current base).

F4 (deprecating the player-store override maps) is intentionally separate —
removing them would break instant star feedback when the index is off.

* feat(library): full-queue restore from the index on startup (PR-7 F5) (#832)

Persist the whole queue as a lightweight ref list and rehydrate it from the
local index on startup, so the entire queue survives a restart instead of only
the windowed slice (R7-17 / §8.6).

- Persist adds `queueRefs` (full ordered ids) + `queueRefsIndex` alongside the
  existing windowed `queue`. Ids are tiny; the windowed objects stay as the
  no-index fallback.
- `hydrateQueueFromIndex` (startup, after session bind): when the library index
  is ready for the queue's server, hydrate the full queue via
  `library_get_tracks_batch` (batched ≤100), map `songToTrack ∘ trackToSong`,
  re-locate the current track so `queueIndex` stays aligned, then clear the refs.
- Index not ready / missing rows / current track not found → keep the windowed
  fallback (queue never empty when the index is off, the P6 default). Old
  persisted shape without refs loads unchanged.
- `trackToSong` exported from the F2 local-read adapters (one mapper).

Kept the windowed-objects persist (did not drop the cap per R7-17 note): the
index-off default needs the embedded fallback or the queue would restore empty.

* feat(library): pending-sync for song star/rating (PR-7 F4) (#833)

* feat(library): central pending-sync helper for song star/rating (PR-7 F4)

`queueSongStar` / `queueSongRating` (spec §6.5 / R7-18): set the player-store
override optimistically, retry the Subsonic API with exponential backoff (flush
on `online` / window focus), and on success clear the override + patch the
in-memory Track so the UI stays correct without it. The F3 index patch-on-use
runs inside the API layer, unchanged.

- No rollback on the first network error (the override survives until the retry
  succeeds or the app restarts; overrides are session-only, not persisted).
- Latest-toggle-wins coalescing + an identity guard so a fast re-toggle while a
  request is in flight can't retire the newer task.
- v1: songs only.

* feat(library): route song star/rating through the pending-sync helper (PR-7 F4)

Replace the scattered optimistic-set + API-call + rollback logic with the single
`queueSongStar` / `queueSongRating` helper across cucadmuh's named v1 surfaces:
PlayerBar, FullscreenPlayer, MobilePlayerView, both context menus (song + queue
row), both shortcut paths, the song-rating hook + player-bar stars, skip→1★, and
AlbumDetail (song star + rating). The 30+ override read sites are unchanged —
they already read `override ?? track`, and the override now clears on success.

Standalone page toggles (Favorites, RandomMix, NowPlaying star) and the separate
mini-player webview keep their existing path — no regression (a non-migrated
override simply lingers as before) — and move to a follow-up.

* feat(library): route remaining song star/rating sites through pending-sync (F4 follow-up) (#834)

Migrate the three standalone song write sites left out of #833 onto the
central queueSongStar / queueSongRating helper:

- Favorites: handleRate + removeSong (un-star)
- RandomMix: toggleSongStar (drops local try/catch rollback per no-rollback policy)
- useNowPlayingStarLove: toggleStar (keeps local view state, helper owns override + retried sync)

MiniContextMenu stays on its direct path (separate webview, no shared store).
No behaviour change for album/artist rating paths.

* feat(library): route playlist song star/rating through pending-sync (F4 follow-up) (#835)

The playlist-detail star/rating hook was the last shared-store song write
site still calling the Subsonic API directly. Route handleRate +
handleToggleStar through queueSongRating / queueSongStar, matching the
Favorites and RandomMix follow-ups; keep the local ratings/starredSongs
view state, drop the inline override.

MiniContextMenu remains on its direct path (separate webview).

* feat(library): BPM range filter UI in Advanced Search (PR-7 F6) (#836)

* feat(library-sync): parallel initial ingest (S2 + N1/S1 prefetch)

Wire C11 ParallelismBudget (max 4 when idle) into InitialSyncRunner:
parallel getAlbum for S2, up to 4 in-flight pages for N1/S1, and persist
S2 cursor once per album-list page instead of per album.

* fix(library-sync): defer scheduler during initial sync and improve ingest diagnostics

Background delta/tombstone ticks every 30s were competing with IS-3 bulk ingest
for the write mutex (20–60s lock waits on large libraries). Skip scheduler while
sync_phase is initial_sync/probing or bulk ingest is active.

Serialize ingest batch metrics as camelCase for DevTools, add bulk-ingest FTS/index
suspension, combined cursor persist, write-op tracing, live local search, and
library dev logging helpers.

* fix(library-search): scoped FTS, cancel stale live search, skip 1-char queries

Use column-scoped FTS for artists/albums/songs, min two graphemes for local
FTS, capped match counts in Advanced Search, and title browse index (m004).

Live Search aborts superseded network requests, passes requestEpoch to drop
stale Rust FTS, and avoids search3 fallback for too-short queries.

* fix(library-search): prefix FTS, fast subquery joins, hide BPM in Advanced Search

Live and Advanced Search now use FTS5 prefix tokens ("metal"*) and limit
bm25 ranking inside rowid subqueries so large libraries stay in the ms range.
Advanced Search BPM filter is removed from the UI until enrichment ships.

* feat(library-search): race local index vs search3, show first result

Live Search and Advanced Search text queries run library and network
backends in parallel; the faster source wins. Adds searchRace helper
and search_race dev logging.

* feat(library-index): multi-server UI, serial sync queue, scoped local search

Add master library index toggle with per-server rows, offline retry, and a
frontend sync queue so initial ingest runs one server at a time. Scope Live
Search and Advanced Search to the sidebar music library filter via library_id
and raw_json fallbacks; coerce numeric libraryId on ingest. Promote idle sync
state to ready when a full sync stamp exists and block cross-server initial
sync starts in Rust.

* chore(library-store): compliance — clippy, i18n, CHANGELOG

Fix clippy/tsc blockers (request structs, IngestPageCtx, type aliases),
add library index strings to all 9 locales, and document the preview
feature in CHANGELOG [1.47.0] with Psychotoxical + cucadmuh attribution.

* docs(credits): library index preview contributions

Credit Psychotoxical for the local library store foundation and cucadmuh
for multi-server UI, scoped search, and i18n. Drop removed scan-trigger
wording from PR #780 entry.

* docs(release): link library index preview to PR #846

* docs(changelog): sort [1.47.0] entries by ascending PR number

* docs(changelog): mark library index as Added in [1.47.0]

* docs(changelog): restructure [1.47.0] into Added/Changed/Fixed

Match 1.46.0 layout: new features in Added (incl. library index),
enhancements in Changed, bug fixes in Fixed — PR ascending within each block.

* fix(library): address PR #846 review — delta guard + FTS order

Skip background scheduler delta when LibraryRuntime already has a
foreground sync job for the same server. Preserve bm25 rowid ordering
in live search track/artist/album fetches.

* fix(library): address remaining PR #846 review items

S2 resume persists current_album_id per album; same-server resync awaits
the previous runner. N1 delta watermark uses strict less-than; Navidrome
HTTP 500 detection is structured. Adds genre/year indexes, backoff jitter
salt, LiveSearch failure toast, and user-facing search badge copy.

* fix(library): close resync notify race and tighten FTS trigger test

Use notify_one() so an early runner completion cannot lose the drain
signal before same-server full resync awaits. FTS test now compares
normalized trigger bodies from migration vs suspend/restore roundtrip.

---------

Co-authored-by: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com>
2026-05-22 00:33:09 +02:00
Frank Stellmacher 1a7a2a0bfc chore(servers): remove quick/full scan buttons from server cards (#843)
Drops the Quick/Full scan actions and all supporting logic — they are no
longer needed. Removes the ServerScanActions component (incl. the unused
compact variant), the subsonicScan API, the scanStore, and the app-root
useScanPolling hook (no more background scan polling). Cleans up the
.server-scan-* CSS and the settings.scan i18n block across all 9 locales.

440 deletions, no new code; tsc + bundle clean.
2026-05-21 22:43:46 +02:00
Frank Stellmacher d7abf9be3b fix(radio): card polish and paused streams stop auto-resuming (#786)
* refactor(playback): extract fadeOut helper

Used by playAlbum.ts today; about to be reused for the radio delete
fade-out so it lives in its own module instead of being copy-pasted.

* fix(player): disable Repeat button while a radio stream is active

Matches the Prev / Next buttons that already gate on isRadio — repeat
has no meaning for a live stream, and the live tooltip plus accent
colour suggested it was interactive.

* fix(radio): fade out before deleting the playing station

Mirrors the 700 ms fade-out playAlbum uses on track changes — beats
the abrupt cut when the deleted station is the one currently on air.

* fix(radio): add Play/Stop tooltip to the cover-overlay button

Cast / X icon now describes itself, matching the favourite and delete
buttons that already had tooltips.

* fix(radio): use Square stop icon on the active card

The play-overlay and delete buttons both used X, making it ambiguous
which click stopped the stream and which deleted the station. Stop
gets a filled Square; delete keeps its X.

* fix(radio): cancel auto-reconnect when the user pauses a stream

Closes #779.

Root cause: a 'stalled' event during a paused stream still scheduled a
4 s reconnect timer that called play(), and the timer was not cancelled
on pauseRadio(). On macOS WKWebView the underlying TCP socket droops
roughly a minute after pause, the browser fires 'stalled', and the
stream resumes against the user's intent. The store's isPlaying flag
stays false because nothing on the reconnect path syncs it, so the
play/pause button stops matching reality.

Fix: skip the schedule when radioAudio.paused, re-check inside the
timer callback (covers a pause within the 4 s window), and clear any
pending timer in pauseRadio().

* docs: changelog for radio card polish + paused-stream fix (#786)
2026-05-18 22:10:04 +02:00
cucadmuh 70c2fdfbf9 Linux: session-native GDK/WebKit mitigations and in-page browse scroll (#731)
* feat(linux): session GDK defaults, nvidia-quirk, optional x11-legacy wrap

Ship PSYSONIC_ALLOW_NATIVE_GDK from Nix/AUR instead of pinning WEBKIT_DISABLE_*
and GDK x11. Add flake psysonic-x11-legacy for the old wrap; alias gdk-session
to psysonic. Startup uses webkit2gtk-nvidia-quirk and Wayland-aware compositing;
refresh Help (a45) and nixos-install docs.

* fix(linux): session GDK and nvidia-quirk only; drop wrapper env heuristics

Remove PSYSONIC_ALLOW_NATIVE_GDK and devShell GDK/WEBKIT exports; stop
synthesizing GDK/WebKit vars in main.rs. Update Nix/AUR wrappers, install
docs, CHANGELOG, and help FAQ with practical user-facing workarounds.

* fix(linux): X11-pinned GDK uses DMABUF quirk path, not Wayland explicit-sync

When GDK_BACKEND is forced to x11 on a wayland user session, webkit2gtk-nvidia-quirk
would still apply __NV_DISABLE_EXPLICIT_SYNC and gray out the webview. Map that case
to WEBKIT_DISABLE_DMABUF_RENDERER like native X11.

* fix(ui): stabilize WebKitGTK/Wayland hover paint for nav and media cards

Sidebar nav links avoid transition:all and promote icons with translateZ(0).
Artist rows and album/artist/song cards use compositing hints; card shadows
and borders no longer interpolate so cover zoom can stay smooth without jitter.

* fix(ui): isolate artist/album card text and cover paint on WebKitGTK

Promote cover blocks with contain/paint and text stacks with translateZ(0);
use artist-card-info on the artists grid for the same layout as other cards.

* feat(artists): in-page overlay scroll and locked main viewport

Move list/grid into an inner OverlayScrollArea, stop sticky toolbar from
owning the route scroll, align the rail with the main panel edge, and skip
the main-route overlay thumb when the viewport cannot scroll vertically.

* feat(browse): extend in-page overlay scroll to more library routes

Reuse the locked main viewport pattern from Artists for Albums, Composers,
Lossless albums, and New releases; wire VirtualCardGrid and scroll chrome
to the matching in-page viewport ids.

* fix(linux): improve Wayland GPU compositing text clarity in WebKitGTK

Use on-demand hardware acceleration on main and mini webviews when the
session is Wayland and compositing stays on; gate subpixel body AA on the
same conditions via new Tauri probes. Document PSYSONIC_SKIP_WAYLAND_FONT_TUNING
for opt-out and changelog.

* fix(rust): satisfy clippy needless_return in Linux webkit helpers

* fix(linux): tune Wayland text rendering with HW policy env and CSS

Allow PSYSONIC_WEBKIT_WAYLAND_HW_POLICY to select WebKit hardware
acceleration policy (never/always vs default on-demand). Extend Wayland
font CSS to #root with geometricPrecision and text-size-adjust on html.

* feat(linux): Wayland text presets in settings, safe WebKit apply, CPU default

Persist profile to app config; apply WebKit policy at startup/mini only to
avoid WebKitGTK hangs on live toggles. UI + CSS preview stays live; default
preset is sharp (CPU-friendly).

* fix(linux): map Wayland sharp preset to OnDemand WebKit policy

HardwareAccelerationPolicy::Never at startup broke main-viewport wheel
scrolling on WebKitGTK+Wayland; sharp vs balanced remains a CSS AA path.
Use PSYSONIC_WEBKIT_WAYLAND_HW_POLICY for a true Never policy.

* fix(rust): gate Linux-only Wayland WebKit helpers for Windows builds

Re-export startup helpers only under cfg(linux) and drop non-Linux stubs so
Windows compiles without unused-import and dead-code warnings.

* chore(release): CHANGELOG + credits for Linux session/WebKit work (PR #731)

Consolidate scattered incremental changelog notes into two [1.47.0]
entries with PR link; remove duplicate Linux blocks from [1.46.0] Fixed.
Append settings credit line for cucadmuh.
2026-05-18 21:00:46 +03:00
Frank Stellmacher bca45d5a80 feat(servers): scan actions + edit existing server profiles (#780)
* feat(server-scan): plumbing for triggering library scans

Adds `startScan` / `getScanStatus` against the Subsonic API
(`fullScan=true` is Navidrome's extension), a small per-server scan
store, and a global polling hook (2 s cadence) that emits a toast when
each scan finishes. Scans can run on any configured server, including
inactive ones, by reusing `apiForServer`.

UI surfaces follow in the next commit.

* feat(server-scan): expose Quick / Full Scan in switcher + settings cards

Adds a `ServerScanActions` component with two variants (compact for the
server-switcher dropdown, card for the Settings server cards) backed by
the scan store from the previous commit. Full Scan requires a second
click within 3 s to confirm, matching the playlist-delete pattern.
Status slot shows a spinner with running track count while scanning, a
green check when finished, and a red icon on error.

The switcher row is converted from a single button to a flex container
so per-server scan controls don't hijack the server-switch click.
i18n added across all 9 locales.

* fix(server-scan): reorder switcher row to check / name / scan actions

Moves the check / spinner slot from the right edge to the left so the
spinner pop-in on server switch doesn't sit next to the scan icons.
Removes the layout shift that briefly hovered the Quick scan button
when the row re-rendered.

* feat(servers): edit existing server profiles in Settings → Servers

* Pencil-button on each server card opens an inline edit form that
  replaces the card (prefilled name / URL / username / password).
* `AddServerForm` reused with an `editingServer` prop — title flips to
  "Edit Server", submit label to "Save", magic-string field hidden (the
  edit scope is manual fields; magic-string remains an add-time invite
  shortcut).
* Edit saves unconditionally — ping runs post-save as a status indicator
  (analog to the existing Test button) instead of gating the save. Lets
  users update a profile when the server is currently unreachable.
* Translations across all 9 locales (`editServer`, `editServerTitle`).

* fix(servers): submit Add/Edit Server form on Enter

Wrapped the form body in a real <form>, made the submit button
type="submit", marked Cancel as type="button" so Enter no longer
cancels. Add-Mode now also responds to Enter — same flow, consistent
across both modes.

* fix(servers): collapse card action buttons to icon-only on narrow screens

* Quick-Scan / Full-Scan / Test buttons in each server card hide their
  text label below 1100px viewport via the .server-card-btn-label class
  and a single media query in connection-indicator.css.
* Labels remain accessible via data-tooltip and aria-label so screen
  readers + hover both keep working in the collapsed state.
* No content reflow above the breakpoint — pure additive CSS.

* fix(servers): include Use button in icon-only narrow-screen collapse

The Use ("Verwenden") button on inactive server cards lacked the
.server-card-btn-label wrapper, so its text stayed visible at narrow
viewports and pushed Edit/Delete off-screen. Added a Power icon and
wrapped the label so it collapses alongside the other action buttons.

* docs(changelog,credits): #780 server scan + edit
2026-05-18 16:32:40 +02:00
Frank Stellmacher 562218f447 feat(settings): dim disabled toggle rows + rename Clear → Clear queue (#778)
* feat(settings): dim disabled toggle rows + rename Clear → Clear queue

* Settings toggle rows (`.settings-toggle-row`, `.sidebar-customizer-row`) dim
  their non-toggle content to 0.6 opacity when the switch is off. Driven by a
  single `:has(.toggle-switch input:not(:checked):not(:disabled))` rule so it
  applies across every Settings tab without per-row markup. Mutex-disabled
  toggles (Crossfade/Gapless) are excluded to avoid stacking with the existing
  inline 0.45 dim on their row.
* Queue toolbar "Clear" button now reads "Clear queue" in all 9 locales for
  parity with "Shuffle queue" and to distinguish from playlist clear/delete.

Adopted from @kveld9's PR #558 — cherry-picked the spirit, rewrote the dim as
a single global selector instead of three inline-styled customizer rows, and
extended the rename to the Romanian locale that landed after #558 was opened.

Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>

* docs(changelog,credits): credit @kveld9 for #778

Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>

---------

Co-authored-by: kveld9 <179108235+kveld9@users.noreply.github.com>
2026-05-18 15:21:45 +02:00
Frank Stellmacher 48c7b8b780 fix(playerbar): add Mute / Unmute tooltip on the speaker icon (#752)
The speaker button in the player bar toggled volume to 0 (or back to
the pre-mute level) but exposed no `data-tooltip`, so hovering it
showed nothing — users couldn't tell what the icon did. Add a tooltip
that switches between "Mute" and "Unmute" with the current state, and
mirror the same string on `aria-label` for screen readers. New
`player.mute` / `player.unmute` keys land across all 9 locales.
2026-05-17 14:26:19 +02:00
Frank Stellmacher 5856bdbf1a fix(library): empty-state on Mainstage, Albums, New Releases, Random Albums (#750)
* fix(library): show empty-state on Mainstage, Albums, New Releases, Random Albums

When the active library has no albums, Mainstage and the three album-list
pages rendered a fully blank page — every rail and grid was empty but
nothing told the user why. Add a shared `common.libraryEmpty` message
(all 9 locales) and render it in place of the empty grid:

* Home: when no rail has any albums after loading.
* Albums: when no albums and no filter (genre/year/starred/comp) is
  active — filtered "no matches" is a separate state and stays as-is.
* New Releases: when no albums and no genre filter is active.
* Random Albums: when no albums after loading.

Pages that already had a dedicated empty-state (Artists, Genres,
Composers, Playlists, Favorites, MostPlayed, LosslessAlbums,
LabelAlbums, InternetRadio) are left alone — their wording is
intentional and per-page.

* docs(changelog): note empty-library states on Mainstage / album lists (#750)
2026-05-17 14:10:36 +02:00
Frank Stellmacher 606a150e01 feat(settings): clock format setting (Auto / 24h / 12h) (#742)
* feat(settings): clock format setting (Auto / 24h / 12h)

Reported on the Psysonic Discord — the Queue side panel's ETA label
and the sleep-timer preview both render via `formatClockTime`, which
just calls `toLocaleTimeString` and so follows the user's system
locale. On en-US that means AM/PM, with no in-app way out.

Add a tri-state **Clock Format** setting under
**Settings → System → App Behavior**:

* `auto` (default) — keep the existing locale-driven behaviour, so
  bestehende installs are unaffected on first launch.
* `24h` — force 24-hour wall-clock output everywhere
  `formatClockTime` is used.
* `12h` — force AM/PM output.

Wired through `authStore` (`clockFormat`, `setClockFormat`), exposed
via `CustomSelect` in `SystemTab`, and threaded into the two
consumers (`QueueHeader`, `PlaybackDelayModal`) so they re-render on
change. `formatClockTime` itself stays a pure helper — it accepts the
setting as an optional second argument and maps it to `hour12`.

Locale coverage: all nine bundled locales (en, de, es, fr, nl, nb,
ru, zh, ro) get the four new settings strings. Pin tests added for
the `setClockFormat` setter and the `hour12` mapping in
`formatClockTime`.

* docs(changelog): clock format setting + contributors (PR #742)
2026-05-17 01:26:40 +02:00
Frank Stellmacher 02e23b5755 fix(home): align mainstage row title with "New Releases" (#741)
* fix(home): mainstage row matches "New Releases" sidebar + page label

The Mainstage row whose title chevron links to `/new-releases` was
labelled **Recently Added** (`home.recent`) while the sidebar entry and
the page itself are **New Releases** (`sidebar.newReleases`) — three
labels for the same destination. Reported on the Psysonic Discord.

Reuse `sidebar.newReleases` in both consumers (the row title in
`Home.tsx` and the section label in `HomeCustomizer.tsx`) so the
string lives in exactly one place. The now-orphaned `home.recent` key
is dropped from all nine locale files.

* docs(changelog): mainstage New Releases label fix (PR #741)
2026-05-17 01:06:23 +02:00
cucadmuh 6ea0acede5 feat(playback): stream buffering UI, M4A moov-at-end streaming, hot-cache spill (#737)
* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix

Defer seekbar/progress until HTTP stream is armed for both legacy and
RangedHttpSource; show buffering overlay on cover art. Add MP4 tail
prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so
moov-at-end M4A can start without reading the full mdat.

* feat(hot-cache): spill large ranged streams to disk for promote

When a ranged HTTP download completes above the 64 MiB RAM promote cap,
write the existing buffer once to app-data stream-spill/ and register it
for hot-cache promote (rename) and replay via fetch_data. Analysis seeds
from the spill file up to the local-file cap (512 MiB).

* fix(ui): stream buffering — grayscale cover and static clock icon

Desaturate player and queue cover art while isPlaybackBuffering; keep a
non-animated clock overlay for visibility without the spinning animation.

* fix(playback): review follow-up — tests, i18n, spill cleanup, changelog

Clippy and test layout fixes; stream spill orphan cleanup on startup;
buffering flag guard in progress handler; bufferingStream in all player
locales; CHANGELOG and contributor credits for stream/M4A work.

* docs: attribute stream buffering and M4A streaming to PR #737

* test(audio): avoid create_engine in stream spill unit test

CI runners have no audio output device; test spill take/consume via
the Mutex slot only, matching install_stream_completed_spill tests.
2026-05-16 22:56:47 +03:00
Frank Stellmacher 31abdc03ef feat(hero): prev / next arrows on the Mainstage featured strip (#735)
* feat(hero): prev / next arrows on the Mainstage featured strip

Adds left and right chevron buttons over the featured-album hero so a
single click flips to the previous / next album. Hitting the 8 px dot
indicators was awkward and often opened the underlying album by
mistake; the arrows give a generous 44 px touch target on each edge.

- New `goPrev` / `goNext` callbacks wrap with modulo, restart the
  auto-advance timer on click (same pattern the old dot handler used).
- Buttons live in a new `.hero-nav` flex wrapper with `inset: 0` and
  `justify-content: space-between`, so they pin to the hero's left and
  right edges regardless of theme padding. `pointer-events: none` on
  the wrapper + `auto` on the buttons keeps the rest of the hero
  click-through (navigate to album).
- Dots are now decorative spans, not buttons — `pointer-events: none`,
  no hover state, no `onClick`. Clicking near a dot used to navigate
  to the album because the dot was too small to land on.
- i18n: `previousAlbum` / `nextAlbum` keys in all 9 locales.

Reported by zunoz on Discord.

* docs(changelog): note Mainstage hero prev / next arrows (#735)
2026-05-16 13:43:09 +02:00
Frank Stellmacher efd85ffde3 feat(tracklist): play count, last played, BPM columns + Song Info rows (#730)
* feat(tracklist): play count, last played, and BPM columns (#516)

Adds three opt-in columns to Album / Playlist / Favorites tracklists,
plus the same fields in the Song Info modal. Picks up Navidrome's
existing `playCount` / `played` / `bpm` from the Subsonic response — no
new API calls.

- `SubsonicSong` gains `playCount`, `played`, `bpm` (already populated
  by Navidrome's Subsonic API, just unmapped in the TS model).
- `albumTrackListHelpers.COLUMNS`, `PL_COLUMNS` (PlaylistDetail), and
  `FAV_COLUMNS` (Favorites) get the three new entries. Genre also added
  to the playlist column set for parity with the other two lists.
- Render uses `.track-duration` (12px tabular, centered, muted) for the
  numeric stats and `.track-genre` (11px text, muted) for the relative
  last-played timestamp via the existing `formatLastSeen` helper.
- Sort logic extended in `playlistDisplayedSongs`,
  `useAlbumDetailSort`, and `useFavoritesSongFiltering` for the three
  new keys.
- `SongInfoModal` gets matching rows (BPM / Play count / Last played).
- `useTracklistColumns.gridTemplate` / `gridMinWidth` fall back to the
  ColDef's `defaultWidth` when a visible column has no saved width
  (newly added column on an old prefs blob would otherwise emit
  `undefinedpx` and collapse the row layout until reset-to-defaults).
- BPM cells skip the rendering when Navidrome returns 0 (default for
  untagged files) — show `—` instead of `0`.
- i18n: `albumDetail.trackPlayCount / trackLastPlayed / trackBpm` and
  `songInfo.playCount / lastPlayed / bpm` in all 9 locales.

Suggested by jbigginswyl (#516).

* docs(changelog): tracklist Plays / Last played / BPM columns (#730)
2026-05-15 22:01:05 +02:00
Frank Stellmacher 2d27428056 feat(settings): player bar layout — per-control visibility toggles (#627) (#721)
Adds a new sub-section under Settings → Personalisation (Advanced) that
hides individual controls in the player bar: Star rating, Favorite
(heart), Last.fm love, Equalizer, Mini player. Last.fm love still only
renders when a Last.fm session exists; the overflow row in the player
collapses when both Equalizer and Mini player are hidden.

- New `playerBarLayoutStore` (Zustand + persist, items[{id, visible}] +
  rehydrate sanitize) following the queueToolbar / playlistLayout
  pattern; defaults to all visible.
- New `PlayerBarLayoutCustomizer` reuses the same row + toggle pattern
  as the other personalisation customisers.
- Gates threaded through `PlayerTrackInfo` (3 controls), `PlayerBar`
  (EQ + Mini buttons), and `PlayerOverflowMenu` (EQ + Mini in the
  overflow row, with row-level conditional).
- `PersonalisationTab`: added as the last advanced sub-section so it
  only appears when the global Advanced Mode toggle is on.
- Settings search index gets entries for both Playlist page layout and
  Player bar (playlist row was missing).
- New i18n keys `settings.playerBar*` in all 9 locales.

Reuses kveld9's design from PR #627; not merged because the locale
split and the Advanced Mode refactor landed afterwards. Credited via
Co-Authored-By trailer + a new line in settingsCredits.ts under the
existing kveld9 entry.

Co-authored-by: Kveld. <kveld912@proton.me>
2026-05-15 17:48:32 +02:00
cucadmuh ea6ac49885 feat(offline): Offline Library shows cached albums from all servers (#719)
* feat(offline): show cached albums from all servers in Offline Library

List every offline album regardless of active server; load cover art per
source server and switch before play/enqueue. Sidebar, mobile nav, and
disconnect auto-nav use any cached content; multi-server cards show a label.

* docs(changelog): link offline library PR #719

* chore(pr-719): address review — CHANGELOG in Added, helper tests

Move release note to ## Added per team changelog policy; cover
offlineAlbumCoverArt and ensureServerForOfflineAlbum in unit tests.
2026-05-15 17:20:19 +03:00
Frank Stellmacher 651a3f276a feat(settings): global Advanced Mode toggle + playlist page layout (#556) (#720)
Adds a per-element visibility toggle for the playlist detail page (Add
Songs, Import CSV, Download ZIP, Cache Offline, Suggestions) and reworks
the way uncommon options are surfaced: instead of a per-tab collapsible
group, a global "Advanced" toggle in the Settings header reveals all
`advanced` sub-sections across every tab and marks each one with a small
badge. Sets the pattern up so any future advanced option lives in its
natural tab, gated by the same switch.

- New `advancedSettingsEnabled` boolean on `authStore`
  (UiAppearance slice, persisted with the rest of the store).
- `SettingsSubSection` gains an `advanced?: boolean` prop. Hidden when
  the toggle is off; renders an "Advanced" pill in the header when on.
- Settings header gets a Toggle-Switch next to the search lupe.
- `PersonalisationTab` flattens — Sidebar + Home stay always visible;
  Artist sections, Queue Toolbar, and the new Playlist layout get
  `advanced` and disappear by default. `PersonalisationAdvancedGroup`
  component + CSS removed.
- New `playlistLayoutStore` (Zustand + persist, items[{id,visible}] +
  rehydrate sanitize) following the queueToolbarStore pattern.
- `PlaylistHero` and `PlaylistSuggestions` gate the four toolbar buttons
  and the suggestions rail on the store directly.
- One-time migration in MainApp on mount: if the user had opened the
  old per-tab Advanced group (`psysonic_personalisation_advanced_open
  === 'true'`) OR already customised any of the three sub-sections,
  Advanced Mode auto-enables on first launch. Idempotent via a
  localStorage flag; legacy key removed afterwards.
- New i18n keys `settings.advancedMode`, `settings.advancedModeTooltip`,
  `settings.advancedBadge`, `settings.playlistLayout*` in all 9 locales.

Reuses kveld9's design from PR #556; not merged because the locale split
landed afterwards. Credited under the existing kveld9 entry in
settingsCredits.ts.

Co-authored-by: Kveld. <kveld912@proton.me>
2026-05-15 15:55:11 +02:00
cucadmuh 45e0e1206f fix(playback): pin queue playback to source server when browsing another library (#717)
* fix(playback): pin queue streams, cover art, and library links to queue server

When the active server changes while a queue from another server is playing,
keep streams and UI on queueServerId; switch back for artist/album links and
queue or player-bar context menus.

* fix(playback): switch to queue server when opening Now Playing

Ensure active server matches queueServerId before Subsonic fetches on the
Now Playing page, mobile player route, and queue info panel; scope caches
by server id.

* docs(credits): mention Now Playing in PR #717 contribution line

* fix(playback): route scrobble and queue sync to queue server

Address PR review: apiForServer for scrobble/now-playing/savePlayQueue,
clear queueServerId on server removal, mini-player queueServerId sync,
block cross-server enqueue with toast, and regression tests.
2026-05-15 16:08:41 +03:00
Maxim Isaev 33b0206ea2 feat(search): queue preview on Ctrl+V paste and modal polish
Global queue paste opens the preview modal before play; overlay scrollbar,
context-menu suppression, changelog/credits for PR #716 and DanielWTE (#551).
2026-05-15 13:52:07 +03:00
Maxim Isaev e7431b94b8 feat(search): queue pasted share links from Live Search and mobile search
Implement share-link detection in search (track, queue, album, artist,
composer): enqueue tracks/queues without interrupting playback; preview
album/artist/composer without switching the active server; queue preview
modal with scrollable track list. Based on community PR #551.

Co-authored-by: Daniel Wagner <daniel.iuser@icloud.com>
2026-05-15 13:38:35 +03:00
Maxim Isaev 0f5ece6d03 feat(settings): library card grid max columns in Appearance (4–12, default 6)
Persist libraryGridMaxColumns, wire useCardGridMetrics and card grid layout,
add i18n and settings search index; document performance hint in copy.
2026-05-15 02:19:20 +03:00
Frank Stellmacher 2409a1fec8 refactor(i18n): split locale files into per-namespace modules (Phase K) (#688)
Each src/locales/<lang>.ts (~1800 LOC) becomes a folder src/locales/<lang>/
with one module per i18n namespace (44 each) plus an index.ts barrel that
reassembles <lang>Translation in the original key order.

Mechanical, script-driven split with a JSON round-trip check: every
locale object is byte-identical to its pre-split form. i18n.ts is
unchanged — './locales/<lang>' now resolves to the folder index.

The per-namespace settings.ts files land ~440-460 LOC; a single i18n
namespace is the natural, non-arbitrary split unit for a flat string
table, so they are intentionally left whole.
2026-05-14 14:06:31 +02:00
Mihai Toderita 7a4bdbc88e Feat/romanian translation (#663)
* feat(i18n): Add Romanian translation

* feat(i18n): Update Romanian translation to lang file changes

* feat(i18n): Add new Romanian translation entries

* fix(i18n): add settings.languageRo to remaining locale bundles

Romanian was missing from the language picker labels when UI was not en/ro;
add endonym-style names per locale (de/fr/nl/nb/ru/es/zh) for consistency.

* fix(i18n): use Romanian autonym for settings.languageRo everywhere

Match existing language picker convention (e.g. languageDe is Deutsch in
every locale bundle). Replaces UI-language translations of Romanian.
2026-05-13 23:13:42 +03:00
Kveld. 64b33e6941 feat: customizable queue toolbar with drag-and-drop reordering and visibility toggles (#534)
* Add drag-and-drop reordering and visibility toggles for queue toolbar

* docs(changelog): credit PR #534 (queue toolbar customization)

Adds the v1.46.0 CHANGELOG entry and a new bullet on kveld9's
contributors block in Settings → System.

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-11 12:35:38 +02:00
Maxim Isaev 308eb36f05 feat(analysis): re-analyze waveform when clearing loudness cache
Add analysis_delete_waveform_for_track, invoke it from loudness reseed,
clear waveformBins in the UI, and extend queue strings for tooltips/toast.
2026-05-10 01:34:40 +03:00
Frank Stellmacher a702a5dd5b feat(orbit): in-app diagnostics popover with copyable event log (#524)
* feat(orbit): in-app diagnostics popover with copyable event log

Multiple users on Discord report Orbit guests stopping after the first
song with no errors anywhere — Settings → Debug → Export Logs is too
buried for non-technical reporters, and the relevant code branches
have no logging at all (silent fail). This adds a one-click "Copy log"
path right inside the Orbit session bar.

The new Activity-icon button next to Help opens a popover with:

- Live mini-display: role, host vs. guest track id + position, drift,
  age of the host's last state write — all updating once a second.
- Scrolling event log textarea fed by an in-memory ring (200 events).
- Copy + Clear buttons. Copy formats `[ISO] [scope] body` lines and
  drops them on the clipboard — paste straight into a Discord report.

Instrumentation lands at the previously-silent decision points:

- Guest pull tick: full snapshot of host vs. guest state on every read.
- Each branch of the divergence detection in `useOrbitGuest.ts` logs
  which path it took and why (initial / track-change-followed /
  track-change-diverged / play-pause-flip), making the
  "stuck after first song" symptom diagnosable from the buffer alone.
- Host pushes log track id, isPlaying, queue length, guest count.

Events are also bridged to the existing `frontend_debug_log` Tauri
command when Settings → Logging is on Debug, so power users still get
the same data in `psysonic-logs-*.log` for offline triage.

i18n: full `orbit.diag.*` namespace in all eight locales. EN + DE are
native; ES / FR / NB / NL / RU / ZH are first-pass and may want a
polish from native speakers later.

* docs(changelog): add orbit diagnostics popover entry
2026-05-09 21:49:17 +02:00
Frank Stellmacher 8b781a848d refactor(i18n): show language names as endonyms in picker (#514)
* refactor(i18n): show language names as endonyms in picker

* docs(changelog): add language-endonyms entry for #514
2026-05-08 00:19:35 +02:00
Frank Stellmacher c982362884 fix(i18n): OpenDyslexic font supports Cyrillic (#513)
* fix(i18n): OpenDyslexic font supports Cyrillic

The OpenDyslexic subtitle in the font picker stated "no RU/ZH support",
but the bundled `@fontsource/opendyslexic` 5.x ships Cyrillic glyphs and
Russian renders correctly — verified empirically. Only Chinese (CJK)
actually falls back to the system font. Updated the subtitle string in
all 8 locales accordingly; the Russian locale itself no longer claims
unsupported-self.

* docs(changelog): correct OpenDyslexic locale-coverage claim (#513)

The #507 entry in the unreleased v1.46.0 block claimed `Latin +
Latin-extended only` and listed Cyrillic among the system-font
fallbacks. Cyrillic glyphs ship in @fontsource/opendyslexic 5.x and
render correctly — only Chinese (CJK) actually falls back. Updated the
bullet in place to match the corrected subtitle hints from this PR.
2026-05-07 23:55:24 +02:00
Frank Stellmacher f520f7951a feat(settings): OpenDyslexic font option for dyslexic readers (#507)
* feat(settings): OpenDyslexic font option for dyslexic readers

Next step on the accessibility track. The first pass was on the colour
side — WCAG contrast audits across every theme and dedicated colour-
vision-deficiency variants for the protanopia / deuteranopia / tritan-
opia palettes. Typography is the other axis: some users with dyslexia
find a font with a heavier weighted baseline and asymmetric glyph
shapes (b/d, p/q never mirror, italic forms differentiated rather
than slanted-regular) easier to track than a typical sans.

Adds OpenDyslexic to the existing Fontsource font picker. SIL OFL
licensed, freely redistributable, and the de-facto open-source
standard for this use case. Non-variable axis, ships as four discrete
weight/style files (regular, bold, italic, bold-italic) — the Settings
picker grew an optional `hint` field on font entries so this one row
can carry a "dyslexia-friendly · no RU/ZH support" subtitle without
bloating the other 14 entries.

Latin + Latin-extended only. Cyrillic and CJK locales (RU, ZH) fall
back to the system font when this is selected; the subtitle calls out
that limitation upfront. i18n: hint string in all 8 locales
(settings.fontHintOpenDyslexic).

Accessibility is intentional product positioning here — it's an
underserved corner of the Subsonic-client ecosystem.

* chore(nix): sync npmDepsHash with package-lock.json

* docs: changelog entry for PR #507

Logs the OpenDyslexic font option in v1.46.0 "## Added".

* docs(settings): contributor entry for PR #507

Adds the OpenDyslexic accessibility bullet to Psychotoxical's
contributions list.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-07 22:15:38 +02:00
Frank Stellmacher bd56177e2c feat(home): Lossless Albums rail + dedicated page + sidebar nav (#506)
* wip(home): Lossless rail + dedicated /lossless-albums page

Rail under Home > mostPlayed and a dedicated infinite-scroll page that
list albums whose tracks are tagged in lossless containers. Walks
Navidrome's native /api/song?_sort=bit_depth&_order=DESC, dedupes by
albumId on the way down, stops when the song stream crosses into lossy
(bitDepth==0) or the server runs out of rows. _filters has no operators
on quality columns, so a sort + walk is the only path; equality on
sample_rate / bit_depth probes returned empty (verified).

The page paginates through the song-cursor with an in-flight ref so
overlapping IntersectionObserver fires don't double-add albums, plus a
cancelled flag so React StrictMode's double-mount doesn't apply two
parallel result sets in dev.

Suffix allowlist excludes ambiguous wrappers — m4a/m4b can be ALAC
*or* AAC and Navidrome's response carries an empty codec field, so we
can't tell them apart; same story for wma. Allowlist is flac, wav,
aiff/aif, dsf/dff, ape, wv, shn, tta — containers that are only
lossless. ALAC-in-m4a setups will miss out, acceptable trade-off
without a reliable codec field.

Status: WIP. Settings home-customizer label and en.ts strings landed,
no other locales yet, no quality badge on AlbumCards across the rest of
the app, no CHANGELOG. Branch was 'feat/home-hires-rail' during the
earlier hi-res-only iteration before the lossless broadening.

* wip(home): Lossless page header parity + streaming + sidebar nav

Page header now mirrors All Albums: selection mode with three action
buttons (Enqueue Selected, Add Offline, Download ZIPs) wired to the
same handlers Albums.tsx uses, selection-counter title swap, and the
perfFlags.disableMainstageStickyHeader respect path. Filters were
intentionally skipped — the rail is sorted by bit_depth, mixing client-
side filters with server-driven pagination would produce gaps.

Loading feels noticeably faster: ndListLosslessAlbumsPage takes an
optional onProgress callback that fires once per internal fetch with
the entries discovered in that fetch, so the page can stream new
albums into state instead of waiting on the whole loadMore. Page-side
budget dropped from 5×200 to 2×100 songs per loadMore (~1 MB worst
case vs 5 MB before), since the rail's catch-em-all pass is wrong for
infinite-scroll UX. Subtitle under the page title primes the user that
this is slower than other album pages because Psysonic walks the song
catalog by quality (Navidrome ignores _fields, so per-song responses
ship with lyrics + tags + participants whether we want them or not).

Sidebar nav entry registered under 'losslessAlbums' with a Gem icon,
defaults to visible:false (matches composers / folderBrowser /
deviceSync — niche browsing modes). Existing users get the entry
appended at the end of their persisted sidebar list automatically via
the onRehydrateStorage merge that sidebarStore already runs for new
DEFAULT_SIDEBAR_ITEMS.

i18n: full coverage across all 8 locales for sidebar.losslessAlbums,
home.losslessAlbums, losslessAlbums.empty, losslessAlbums.unsupported
and the new losslessAlbums.slowFetchHint subtitle. ru/zh are
machine-translation quality, flagged for a polish pass.

* feat(home): default Lossless sidebar entry to visible

Flips the DEFAULT_SIDEBAR_ITEMS entry for `losslessAlbums` from false
to true. Existing installs keep whatever the user has in persisted
storage; fresh installs see the entry in the sidebar from the start.

Earlier wip commit defaulted it off (matched composers / folderBrowser
/ deviceSync as a niche browse mode), but the rail + page do show
something useful for any library with at least one FLAC/WAV/etc. album,
so off-by-default just hid the feature.

* docs: changelog entry for PR #506

Logs the Lossless Albums rail + page + sidebar entry in v1.46.0
"## Added".

* docs(settings): contributor entry for PR #506

Adds the Lossless Albums rail/page bullet to Psychotoxical's
contributions list.
2026-05-07 20:44:27 +02:00
Frank Stellmacher ddb1f29af9 refactor(settings): remove redundant Animations 3-state setting (#495)
* refactor(settings): remove redundant Animations 3-state setting under Seekbar Style

The `animationMode` setting (Full / Reduced / Static) duplicated work
the perf-flag system and OS-level reduced-motion preference already
covered:

- `perfFlags.disableMarqueeScroll` already kills marquee scrolling on
  demand, replacing what `static` mode used to gate.
- The `data-perf-disable-animations` html-level switch already strips
  every `*` animation, replacing what `static` mode used to do globally.
- `@media (prefers-reduced-motion: reduce)` honours the OS setting for
  every user that asked for it via system preferences.
- The 30 fps cap that `reduced` mode applied to the seekbar wave was
  better served by per-feature perf toggles cucadmuh added later.

Removed:
- `AnimationMode` type, `animationMode` field + setter from auth store.
- Settings UI block (3 buttons + hint text) under Appearance > Seekbar
  Style.
- `animationMode === 'static'` short-circuit in WaveformSeek's rAF
  effect; `isReduced` skip-every-other-frame logic; `static`-checks in
  `drawNow` / `needsDirectDraw`.
- `animationMode !== 'static'` guard and `data-anim-mode` attribute in
  MarqueeText.
- `[data-anim-mode="static"]` and `[data-anim-mode="reduced"]` rules in
  layout.css.
- Seven i18n keys (animationMode + 6 variants) across all eight
  locales.

Migration: the persist layer strips `animationMode` (and the legacy
`reducedAnimations` boolean predecessor) so anyone who had `'reduced'`
or `'static'` selected silently lands on the former `'full'` path on
first launch after upgrade. No user-facing prompt — the missing setting
just stops existing.

cucadmuh's PR #472 (FPS overlay), #476 (preview-freeze main seekbar,
sleep-recovery hooks, card-hover removal) and #486 (interpolation
anchor reset on resume) are all preserved untouched — they live in
separate effects / files and were not driven by `animationMode`.

* docs(changelog): add Removed section for animationMode setting (PR #495)

* docs(changelog): refine animationMode removal rationale (drop prefers-reduced-motion overstatement)
2026-05-07 12:53:19 +02:00
Frank Stellmacher d1ff2fab51 feat(home): Because you listened recommendation rail (#489)
* feat(home): "Because you listened" recommendation rail

New Home rail (under Recently Added, default on, toggleable in Settings →
Personalisation → Home Page) that surfaces 3 albums from artists similar
to one of your top-played artists. Anchor rotates per Home mount so a
different top-artist seeds the recommendations each visit; within each
anchor, both the similar-artist subset and the chosen album per artist
are randomised, so the same anchor returns different picks on subsequent
visits.

Card layout matches the regular Album cards' surface (--bg-card with
accent-tinted border + 1px inset top highlight) and gets the same
Play / Enqueue hover overlay buttons. Cover and meta scale via CSS only
— no infinite animations, no filter/blur/transform, no compositing
layers. Grid wraps below 3-up at <400px card width instead of shrinking.

API budget: one getArtistInfo2 + 6 parallel getArtist calls per Home
mount, both reusing the existing mostPlayed payload to derive the anchor
pool (no extra API call to find top artists). All 8 locales seeded.

* fix(home): ru plurals + per-server anchor + narrower card grid

- ru: add _few / _many for becauseYouLikeTracks (CLDR Russian needs
  4 forms — 3 треков was wrong, now 3 трека).
- Anchor rotation memory is now per-server. The localStorage key
  becomes psysonic_because_anchor:<serverId>; switching servers no
  longer aliases server A's rotation onto server B's pool.
- because-card grid minmax(400px, 1fr) -> minmax(340px, 1fr) so two
  cards fit side by side at typical sidebar-expanded widths instead
  of collapsing to a single card per row.

* docs: CHANGELOG + Contributors entry for Because-you-listened rail (PR #489)

* style(home): blurred cover backdrop + centred layout for Because-cards

- Each Because-card renders the album cover as a blurred, low-opacity
  full-bleed background layer behind the existing cover thumb and text.
  Resolved through useCachedUrl so the cache layer feeds it (same key
  as the thumbnail) instead of a fresh salted URL on every render.
- Card content (cover thumb + text block) now centred horizontally and
  vertically within the card; the meta line lives in a small pill that
  sits centred under the artist row.
- Text contrast halo and meta-pill background are theme-aware via
  color-mix on var(--bg-card) / var(--text-primary), so the same rules
  read on dark and light themes (was hard-coded rgba black before and
  smudged the type on Latte / Nord Snowstorm).
2026-05-07 02:28:58 +02:00
Frank Stellmacher 59744601d4 feat(composer): Browse by Composer page (issue #465) (#487)
* feat(composer): Browse by Composer page (issue #465)

New library section listing every artist credited as composer on at
least one track, with a detail page showing all works they're credited
on in that role. Targeted at classical-music libraries where the
"recording artist" tag carries the orchestra and the "composer" tag
carries Bach / Mozart / Chopin.

Hits Navidrome's native /api/artist?_filters={"role":"composer"} for
the listing and /api/album?_filters={"role_composer_id":"…"} for the
works grid — Subsonic getArtist only follows AlbumArtist relations and
returns 0 albums for composer-only credits, so the native API is the
only path that works. Requires Navidrome 0.55+ (uses
library_artist.stats role aggregation); on older / pure-Subsonic
servers the page shows a one-line capability banner.

- Two new Tauri commands: nd_list_artists_by_role +
  nd_list_albums_by_artist_role, generic over participant role so
  conductor / lyricist / arranger pages are trivial to add later.
- Composers grid: text-only compact tiles (name + participation count
  pulled from stats[role].albumCount). No avatars — composer libraries
  carry no useful imagery and the listing endpoint exposes no image
  URLs anyway.
- ComposerDetail: hero with Last.fm bio (via getArtistInfo2) plus the
  full work grid, with a graceful fallback when the artist has no
  external info synced.
- Sidebar entry default off (Feather icon) — opt-in for the niche
  classical use case.
- nd_retry backoffs widened from [500] to [300, 800, 1800] — helps
  every nd_* call survive intermittent TLS-handshake-EOF errors that
  some reverse-proxy setups produce when keep-alive pools churn.
- Distinguishes "server can't do this" (HTTP 400/404/422/501) from
  transient errors so the capability banner only fires when the server
  actually rejects the request shape; everything else gets a retry
  button.
- i18n in all 8 supported locales.

* fix(composer): address review feedback on detail page + role queries

- Re-fetch ComposerDetail when music-library scope changes; previously
  the album grid stayed stale until navigation while the list refreshed.
- Thread library_id through nd_list_artists_by_role and
  nd_list_albums_by_artist_role so role queries respect the active
  Navidrome library, matching the Subsonic musicFolderId already piped
  through libraryFilterParams().
- Fix CachedImage cache-key mismatch on ComposerDetail: a Last.fm header
  image was stored under the Subsonic cover-art key, aliasing cache
  entries and risking cross-source pollution.
- Consolidate the two contradictory composer-imagery comments in
  Composers.tsx into a single accurate one (the older one referenced an
  Images toggle that was never implemented).
- Align openLink toast duration with ArtistDetail (1500ms -> 2500ms).

* fix(composer): keep bio across scope changes, add share, degrade gracefully

Three remaining items from the latest review pass on the composer flow.

1. Bio survives a music-library scope change.
   The previous fix added musicLibraryFilterVersion to the load effect,
   but that effect also did setInfo(null) while the getArtistInfo effect
   still depended on [id] alone — so a scope bump on the open page
   wiped the bio without re-fetching it. Move the info reset into the
   bio effect (keyed on id) and out of the load effect: the album grid
   still refreshes on scope change; the Last.fm header image and
   biography survive untouched, since both are library-independent.

2. Composers join the share pipeline as a first-class entity kind.
   Extend EntityShareKind with 'composer' (and isEntityKind), branch
   applySharePastePayload to validate via getArtist (same id pool) and
   navigate to /composer/:id, and wire a Share button into
   ComposerDetail. A pasted composer link now opens the composer view
   instead of the artist view, matching what was copied. i18n added in
   all 8 locales (sharePaste.composerUnavailable, openedComposer;
   composerDetail.shareComposer, unknownComposer).

3. Partial server failure no longer hides the works.
   If getArtist rejects but ndListAlbumsByArtistRole succeeds, the page
   used to show full "not found" despite having data to display. Switch
   the not-found gate to require both empty (`!artist && !albums`) and
   render a degraded header (placeholder name, no Wikipedia / favourite
   / share / Last.fm image) when only metadata is missing.

* fix(composer): right-click share copies a composer link, not an artist link

The context menu opened from a composer card / row uses type='artist'
because every composer-action (radio, favourite, rating, add-to-playlist)
is identical to the artist counterpart — they share an id space and a
backend representation. Sharing was the one exception: the "Share Link"
entry produced a 'psysonic2-' string with k='artist', so a paste opened
/artist/:id even though the user came from /composers.

Add an optional shareKindOverride to openContextMenu (default: undefined,
preserves existing behaviour) and have the artist-typed branch consult
it when calling copyShareLink. Composers.tsx now passes 'composer' on
both right-click sites; nothing else changes downstream because the
override only affects the share kind.

* polish(composer): show Last.fm avatar even without server metadata

Two minor follow-ups from the latest review.

- ComposerDetail: drop the `&& artist` guard on the header-avatar render
  path. info?.largeImageUrl can resolve through getArtistInfo(id) without
  ever needing the SubsonicArtist record, so the previous gate hid a
  perfectly good Last.fm portrait whenever getArtist failed but the
  bio fetch succeeded. Replace artist.name with displayName so the
  alt / aria-label degrade to the localised "Composer" placeholder
  instead of empty strings.
- copyEntityShareLink: doc comment now mentions composer alongside
  track / album / artist.

* fix(composer): derive Last.fm cache key from route id, not from artist record

Follow-up to the previous polish: the avatar render path no longer
requires `artist` to be populated, but the cache-key gate still did. So
when getArtist failed but getArtistInfo returned a Last.fm portrait, the
key fell through to coverKey — which is empty without an artist record,
re-creating the very aliasing bug the earlier Subsonic-vs-Last.fm fix
was meant to close.

Switch the Last.fm branch to the route id (same id namespace as the
SubsonicArtist record), so the key stays stable whenever Last.fm art is
shown, independent of getArtist succeeding.

* docs: CHANGELOG + Contributors entry for composer browsing (PR #487)
2026-05-07 00:36:09 +02:00
Frank Stellmacher e215694301 feat(help): rewrite Help page — trimmed Q/A, 10 sections, live search (#485)
* feat(help): rewrite English Q/A entries — trim, consolidate, refresh

The Help page had grown to ~50 entries over time, with several that
the UI itself answers (double-click to play, click the cover for
fullscreen, click the repeat button to cycle, …) and other groups
that were better folded into a single answer (rating + Skip-to-1★,
Internet Radio basics + supported formats, Device Sync overview +
filename template + cross-platform behaviour, …).

This pass:
- drops obviously-redundant entries (q4, q7, q8, q11, q22, q24, q25,
  and the trivial Settings → X pointers q12, q13, q15, q32, q42, q43)
- consolidates the natural groupings (q5+q31, q37+q38+q39, q53+q54+q55,
  q34+q35+q47, q26+q27+q28, q12+q41, q56+q57)
- adds entries for features that did not exist yet when the previous
  Q/A list was written: Orbit (Listen Together), Magic Strings
  sharing, LUFS Smart Loudness Normalization, Mini Player + Floating
  Player Bar, Smart Playlists, Track Preview, Search and Advanced
  Search, Statistics, Tracks library hub, Genre tag-cloud browser,
  Discord Rich Presence, Bandsintown tour dates, Multi-select +
  Shift-click range selection, Sidebar / Home / Artist Page
  customization, Sleep Timer, Open Source Licenses

Result: 45 focused entries across 10 sections (Getting Started /
Playback & Queue / Audio Tools / Library & Discovery / Lyrics /
Sharing & Social / Personalization / Power User / Offline & Sync /
Integrations & Troubleshooting), each one answering something the UI
does not already answer at a glance.

* feat(help): restructure into 10 sections with live search

Page is now organised into ten focused sections (Getting Started,
Playback & Queue, Audio Tools, Library & Discovery, Lyrics, Sharing
& Social, Personalization, Power User, Offline & Sync, Integrations
& Troubleshooting) each rendered as its own column-friendly accordion
group with a Lucide icon.

A search input lives in the page header. Typing filters every Q+A
pair across all sections by case-insensitive substring; sections that
end up empty are hidden, matched items are auto-expanded so the user
sees the answer without having to click each result, and a "no
results" empty state appears when the query matches nothing. Clearing
the input restores the manual accordion behaviour. An × button next
to the input clears the query in one click.

CSS uses dedicated `.help-search`, `.help-search-icon`,
`.help-search-input`, `.help-search-clear` rules instead of leaning
on the global `.input` class — the latter brought its own focus-ring
styles that doubled with the wrapper border. Focus state highlights
the wrapper border to `--accent` via `:focus-within`.

* i18n(help): translate the new Help page to 7 locales

Updates de, fr, nl, zh, nb, ru, es to match the new English Q/A
structure (45 entries across 10 sections, plus the live-search
labels: title, searchPlaceholder, noResults).

DE / FR / NL / NB / ES were translated directly. RU and ZH are
structurally correct but written at machine-translation quality;
both could use a pass from the original locale maintainers
(@cucadmuh for RU, @jiezhuo for ZH) — none of the wording is
load-bearing for the i18n keys, so the page renders correctly today
and refinements can land as follow-up touch-ups without coupling.

* docs: changelog + contributors for PR #485

Adds the v1.46.0 "Changed" entry and the Psychotoxical contributors
line for the Help page rewrite.
2026-05-06 19:28:47 +02:00
Sayykii dc35f53674 feat(artist): group albums by release type on artist page (#471)
* feat(artist): group albums by release type on artist page

Uses the releaseType field to group albums/releases into sections like Albums, Compilation, Live, etc.
If there's no release type it falls back to normal view

* feat(artist): i18n release-type group labels

* fix(artist): deterministic release-type group order

* refactor(artist): replace inline styles with CSS classes

* i18n(artist): translate release-type labels in remaining 7 locales

Sayykii's `releaseTypes` namespace was added to en.ts only. Fills in
de, fr, nl, zh, nb, ru, es with the same 8 keys (album, ep, single,
compilation, live, soundtrack, remix, other) so users on non-English
UIs see translated section headers on the artist page instead of the
raw title-cased fallback.

* docs: changelog + contributors for PR #471

Adds the v1.46.0 "Added" entry and bumps Sayykii's contributors line
for the artist-page release-type grouping.

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-06 14:47:05 +02:00
Frank Stellmacher 8692e50603 feat(settings): Open Source Licenses section in System tab (#477)
* feat(licenses): tooling and initial data generation

Adds the maintainer-only generator that produces src/data/licenses.json:
- src-tauri/about.toml + about.hbs: cargo-about config + handlebars template
  for the Rust-side license enumeration
- scripts/generate-licenses.mjs: orchestrator that runs cargo-about and
  license-checker-rseidelsohn (via npx, no devDep), merges the outputs into
  a single per-crate JSON with full license texts, and writes the result to
  src/data/licenses.json

The script is invoked directly with `node scripts/generate-licenses.mjs` —
no npm script wrapper on purpose, since adding one to package.json would
trigger the nix-npm-deps-hash-sync workflow on every push.

Initial generation covers 575 cargo crates + 71 npm packages (646 entries
total, all with full license text bundled, ~1.4 MB JSON).

* feat(licenses): Settings panel UI

Adds a new Open Source Licenses section under Settings → System,
sitting below Contributors. Components:

- LicensesPanel.tsx: search input, curated highlight block of ~10 key
  dependencies (Tauri, React, rodio, symphonia, etc.), TanStack-Virtual
  list of all 600+ entries
- LicenseTextModal.tsx: full-screen-ish modal showing the bundled license
  text plus name/version/license-id badges + repository link
- licensesData.ts: lazy dynamic-import loader (Vite emits the JSON as a
  separate chunk, so the heavy ~1.4 MB payload is only loaded when the
  user actually opens the panel — no runtime fetch, the data is fixed
  into the build artifact)

The panel registers itself in the Settings in-page search index under
the System tab.

* feat(licenses): i18n in 8 locales

Adds the `licenses` namespace (title, intro, highlights, search
placeholder, no-results, loading / load error, no-license-text,
view-source, total line, generated-at) across en, de, fr, nl, zh, nb,
ru, es. License names themselves (MIT, Apache-2.0, GPL-3.0, …) stay
universal and are rendered as-is.

* docs(release): document licenses regeneration step

Adds a Step A.3 to the release SOP describing the maintainer-only
`node scripts/generate-licenses.mjs` workflow, the cargo-about
prerequisite, and explicitly notes why no npm script wrapper exists
(would trigger the nix-npm-deps-hash-sync workflow).
2026-05-06 14:17:13 +02:00
Frank Stellmacher d33abf565c feat(library): "favorites only" filter on Albums, Artists, AdvancedSearch (#466)
* feat(ui): StarFilterButton component + common i18n keys

Reusable toggle button for "favorites only" filtering. Three size
variants for different toolbar contexts:
- default: icon + label (Albums-style)
- compact: icon-only with 0.5rem padding (Artists view-mode buttons)
- small:   icon + label at 12px / 4×14 padding (AdvancedSearch tabs)

Adds common.favorites + favoritesTooltipOff/On in all 8 locales.

* feat(library): "favorites only" filter on Albums, Artists, AdvancedSearch

Client-side filter using the existing useMemo pipelines on each page.
Reads starred state from item.starred + playerStore.starredOverrides
(O(1) Map lookup, picks up live star toggles without refetch).

- Albums: toolbar button (default size) next to compilation filter.
- Artists: toolbar button (compact / icon-only) before the Images toggle.
- AdvancedSearch: toolbar button (small) next to the result-type tabs;
  filters all three result categories (artists / albums / songs) and
  updates the count badges accordingly.

Filter state is ephemeral per-page (not persisted) so users don't get
surprised by hidden items after a restart. Zero extra server calls.

* docs(contributors): credit + changelog entry for #466
2026-05-05 23:02:22 +02:00
Frank Stellmacher 0fab2849e5 feat(queue): preserve Play Next order toggle (#464)
* feat(queue): add preservePlayNextOrder setting + playNext store action

- New Track.playNextAdded flag (analogous to autoAdded / radioAdded).
  Stale flags behind queueIndex are harmless — only forward streak scan.
- New playerStore action playNext(tracks): tags incoming tracks and
  delegates to enqueueAt for unified undo + server sync.
- New authStore boolean preservePlayNextOrder (default false). When on,
  playNext appends behind the existing Play-Next streak (Spotify-style)
  instead of inserting directly after the current track.

* refactor(context-menu): centralise Play Next; add Settings toggle + i18n

- Replace 3 inline splice/enqueueAt call sites in ContextMenu with the
  new playNext action. Side-benefit: the single-song path now goes
  through enqueueAt and gets undo + queue sync (previously missing).
- Settings → Audio → Playback: new toggle below Gapless.
- 8 locales: preservePlayNextOrder + preservePlayNextOrderDesc.

* docs(contributors): credit + changelog entry for #464
2026-05-05 22:33:15 +02:00
Sayykii e1f2cb4c37 feat(discord): add server cover art source (#462)
* feat(discord): add server cover art source

The old Apple Music toggle is replaced with a radio selector which let's you choose
between Apple Music, Server and no image.

It's important to note that the server needs to be publicly accessible.

Translations have been added for all locales

* feat(discord): toggle UI for cover source and tightened defaults

- Replace cover-source radio buttons with three indented sub-toggles
  (none / server / apple) under Discord Rich Presence; mutex via
  setDiscordCoverSource — turning one on flips the others off.
- Default discordCoverSource is now 'server' for fresh installs
  (opt-in friendly: own server, no third-party data leak). Existing
  users keep their state via the legacy bool migration.
- Tighten template defaults: details {artist}, state {title}, largeText
  unchanged. Existing users keep their persisted values.

* docs(contributors): credit Sayykii + changelog entry for #462

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-05 21:30:23 +02:00
cucadmuh dc7a785f94 feat(ui): bulk entity ratings, Random Albums multi-select, album New badge (#446)
Add star rating rows to multi-artist and multi-album context menus so the
selection shares one rating control (mixed ratings show empty until set;
keyboard navigation supported). Pass selectedAlbums into AlbumCard on Random
Albums so multi-select context menu works. Add i18n aria labels for bulk
rating controls. Move the New album badge to the top-right of the cover and
stack it with the offline badge to avoid overlap.
2026-05-04 01:56:26 +03:00
Frank Stellmacher 3b4d54431b feat(random-mix): playlist size selector + filter panel layout cleanup (#445)
* feat(random-mix): playlist size selector + filter panel layout cleanup

Adds a 5-button playlist-size picker (50/75/100/125/150) at the top of
the Random Mix filter panel, persisted via authStore. Clicking a size
immediately reruns the current mix (genre-scoped or All Songs) at the
new size — no second click on Remix needed.

Filter panel layout cleaned up:

- Two sub-sections "MIX SETTINGS" and "EXCLUSIONS" with a divider
  between them so the panel reads cleanly with the new size row.
- Larger panel-level headers (FILTERS / GENRE MIX) so the hierarchy
  panel-title > sub-section is visually unambiguous.
- Italic muted note under MIX SETTINGS calling out that large mix
  sizes may return fewer unique tracks if the server's random pool
  runs short — sets honest expectations instead of users wondering
  why a 150 request returned ~126.

fetchRandomMixSongsUntilFull now scales batch size, max-batch ceiling
and dup-streak budget with target size; when no Settings-level mix
filter is active, the first call asks for the full target so a 150
mix can finish in a single round-trip on most libraries. The loop
falls through to top up with deduped follow-up calls if the server
returns fewer than requested.

* docs(changelog): add #445 Random Mix playlist size selector entry

* chore(credits): add #445 to Psychotoxical contributions
2026-05-03 19:57:08 +02:00