Commit Graph

63 Commits

Author SHA1 Message Date
Psychotoxical 9034882bf6 fix(audio): bump generation on paused device reopen to stop spurious audio:ended
Third defence for #1094: on a device change while paused/stopped,
reopen_output_stream stopped the old sink without bumping the engine
generation (the bump only happened on a successful internal resume). The
still-running progress task could then flip done_flag and emit a spurious
audio:ended, which the frontend turns into a restart. Bump the generation
before sink.stop() in the non-playing case so the progress task bails out;
the active-playback path keeps bumping inside try_resume_after_device_change.
2026-06-16 14:49:49 +02:00
cucadmuh abc2c0b579 refactor(audio): split source-build pipeline into source_build module (#1074)
Move the source-building pipeline out of play_input.rs into a focused
source_build.rs: BuildSourceArgs/PlaybackSource, the ranged-stream probe
fallback (build_playback_source_with_probe_fallback) and its private helpers
(build_source_from_play_input, wait_or_fetch_bytes_for_stream_fallback, etc.).
play_input.rs now only handles source *selection* and drops from 799 to 420
lines; helpers that became module-internal lose their pub(super) visibility.
No behavior change.
2026-06-12 19:41:17 +03:00
cucadmuh 184e87a469 fix(audio): release idle output stream after 60s (#1071) (#1073)
* fix(audio): release idle output stream after 60s (#1071)

Lazy-open CPAL on first playback and close the device handle after one
minute without active audio so Windows can sleep; emit output-released
for cold resume and skip post-wake reopen when idle.

* docs: CHANGELOG and credits for idle audio stream fix (PR #1073)

* fix(audio): satisfy clippy if-same-then-else in idle watcher

* fix(audio): silence rodio DeviceSink drop unless logging is debug

Gate log_on_drop(false) on runtime should_log_debug() so normal/off
logging modes avoid stderr noise from intentional idle stream release.

* feat(audio): cold-start paused restore and silent engine prepare

After getPlayQueue on startup, apply saved seek position to the UI,
prefetch the current track to hot cache, and load the engine paused via
new audio_play startPaused so playback does not audibly start before
pause. Shared engineLoadTrackAtPosition with queue-undo restore.

* fix(audio): satisfy clippy too_many_arguments on stream arm helper

Bundle spawn_legacy_stream_start_when_armed parameters into
LegacyStreamStartWhenArmed so workspace clippy passes.

* fix(audio): release output stream immediately on stop (#1071)

Stop and natural queue end call audio_stop; close the CPAL device right
away instead of waiting for the 60s idle timer. Pause keeps the grace
period for warm resume.

* fix(audio): keep waveform mounted after stop (#1071)

Stop preserves currentTrack, so its cached analysis waveform stays valid.
Stop no longer nulls waveformBins for the still-shown track and re-hydrates
them from the analysis DB, instead of dropping to flat placeholder bars.

* test(audio): cover output_stream_is_needed branches; harden audio_play arg (#1071)

- Add unit tests for the idle-keepalive decision: empty/playing/paused main
  sink, preview and fading-out sinks, and radio playing/paused. Players are
  built device-less via rodio's Player::new + a Zero source, so empty()/state
  are exercised without an audio device.
- Make audio_play's `start_paused` an Option<bool> defaulting to false, so the
  new field is strictly additive (omitting startPaused no longer fails serde).
- Drop the unused `_engine` parameter from start_stream_idle_watcher; it
  resolves the engine from the AppHandle each poll.

* refactor(audio): extract sink-swap lifecycle into sink_swap module (#1071)

Move SinkSwapInputs/swap_in_new_sink and the legacy stream-arm helper
(LegacyStreamStartWhenArmed/spawn_legacy_stream_start_when_armed) out of
play_input.rs into a focused sink_swap.rs, so source selection and source
building stay separate from sink lifecycle. play_input.rs drops from 953 to
799 lines. No behavior change.
2026-06-12 17:13:51 +03:00
Psychotoxical 1a82376f8c feat(music-network): unified scrobble & enrichment framework (replaces hard-wired Last.fm) (#1066)
* feat(music-network): core domain types and wire contracts

Foundation for the Music Network framework: provider-agnostic domain
types, capability model, typed errors, and account shapes under
src/music-network/core, plus the ScrobbleWire / EnrichmentWire /
PresetManifest / AuthStrategy contracts. No runtime wiring yet.

* feat(music-network): generic audioscrobbler/listenbrainz/maloja transports

Generalize the Rust remote layer for the Music Network framework. Add
provider-agnostic transports parameterized by base_url:

- audioscrobbler_request: Audioscrobbler v2 with caller-supplied endpoint
  (Last.fm, Libre.fm, Rocksky, custom GNU FM, Maloja compat share it)
- listenbrainz_request: Token-header JSON (direct + Maloja LB compat)
- maloja_request: native /apis/mlj_1 JSON

lastfm_request stays as a thin transition delegate against the fixed
host; it is removed once the framework owns all call sites. Wiremock
tests cover audioscrobbler_request with a custom base_url and API-error
mapping.

* feat(music-network): audioscrobbler wire with last.fm + libre.fm presets

Add the Audioscrobbler v2 wire, the behavioural successor to the legacy
src/api/lastfm.ts, implementing the full EnrichmentWire surface (scrobble,
now playing, love/unlove, loved sync, similar artists, track/artist stats,
top lists, recent tracks, user profile, urls).

- client.ts: transport wrapper over audioscrobbler_request, classifying
  failures into MusicNetworkError without touching any store
- sign.ts: TS mirror of the api_sig base-string ordering rule (unit-tested)
- auth/tokenPoll.ts: browser token-poll connect flow as a reusable strategy
- presets/lastfm.ts, presets/librefm.ts: bundled, enrichment-capable,
  token-poll presets (both endpoints verified live)

Extends WireContext with profileBase and ConnectContext with authBase so
URL builders and connect flows need no preset lookup.

* feat(music-network): listenbrainz + maloja-native wires, paste-auth presets

Add the scrobble-destination wires and presets:

- ListenBrainz wire (scrobble + now playing via playing_now), backing both
  the direct api.listenbrainz.org preset and the Maloja /apis/listenbrainz
  compat preset (one wire, two presets, differing only by base URL)
- Maloja native wire (/apis/mlj_1/newscrobble, scrobble only — Maloja has
  no now-playing endpoint)
- Shared api_key_only paste-auth strategy (token/key/session-key paste);
  the Audioscrobbler wire now dispatches token-poll vs paste by preset
- Presets: listenbrainz, maloja_listenbrainz, maloja_native, rocksky
  (scrobble-only, session-key paste, bundled keys — verified live), and
  custom_gnufm (token-poll, user-supplied url/key/secret)
- Contracts: ConnectContext.authStrategy, PresetManifest.selfHostedApiSuffix

maloja_compat (the {url}/apis/audioscrobbler mode) is intentionally omitted:
its protocol cannot be verified and is almost certainly the legacy handshake,
not the 2.0 web API; Maloja is covered by the native and ListenBrainz modes.

* feat(music-network): registry, orchestrator, enrichment router + runtime facade

Wire the framework together behind a single facade:

- registry: wireRegistry (WireId -> wire), presetRegistry (the 7 built-in
  presets), registerBuiltinWires (one-time side-effect registration)
- CapabilityProbe: wire probe overlaid by manifest staticCapabilities as the
  final authority (lets two presets on one wire diverge, e.g. Rocksky's
  nowPlaying:false over the Audioscrobbler wire's optimistic yes)
- ScrobbleOrchestrator: best-effort fan-out; flips the per-account
  session-error flag on AUTH_SESSION_INVALID and clears it on next success
- EnrichmentRouter: resolves the single primary to its EnrichmentWire; the
  type guard rejects non-enrichment wires (Maloja/ListenBrainz)
- MusicNetworkRuntime: the only app entry point — accounts, roles, fan-out,
  enrichment, urls, probe. Reads/writes state through the MusicNetworkStore
  port (Phase 5 backs it with the auth store) and a RuntimeHost for side effects
- getMusicNetworkRuntime singleton + index.ts public barrel

Tests cover fan-out, master toggle, capability gating, session-error
flip/clear, primary eligibility, and enrichment routing.

* feat(music-network): auth-store state + lossless legacy migration + runtime bridge

Add the persisted Music Network state to the auth store and wire the runtime,
all additively — nothing existing breaks yet.

- authStoreTypes: musicNetworkAccounts / enrichmentPrimaryId /
  scrobblingMasterEnabled + actions; legacy lastfm* fields kept until Phase 6
- authMusicNetworkActions + defaults/wiring (synchronous localStorage)
- accountPersistence: migrateLegacyLastfm (lossless — preserves session key,
  username and scrobbling preference; fills bundled Last.fm key from the preset;
  sets the migrated account as enrichment primary) + sanitizeAccounts
- authStoreRehydrate: one-shot migration guarded by a sentinel so a later
  disconnect cannot resurrect the account from still-present legacy fields
- musicNetworkBridge: backs the MusicNetworkStore port with the auth store and
  the RuntimeHost with the Tauri shell; initialized in pre-React bootstrap

nowPlayingEnabled stays a global toggle (not a lastfm* field); the Phase 6
playback call-site will gate dispatchNowPlaying on it, preserving behaviour.

* feat(music-network): route playback, enrichment and love through the runtime

Migrate every Last.fm call-site onto the Music Network runtime, preserving
behaviour:

- playback (audioEventHandlers, playTrackAction): scrobble@50% and now-playing
  via dispatchScrobble/dispatchNowPlaying; loved-fetch via isTrackLoved. Now-
  playing follows scrobbling (as Last.fm did), Navidrome now-playing keeps the
  nowPlayingEnabled gate
- enrichment: useArtistSimilarArtists, useNowPlayingFetchers, Statistics, and
  the ArtistDetail similar-artists gate now use the runtime, gated on an
  enrichment primary
- love: PlayerBar, PlayerTrackInfo, all context menus, useNowPlayingStarLove and
  the startup loved-sync route through setTrackLoved / toggleNetworkLove
- player store: lastfmLoved/lastfmLovedCache -> networkLoved/networkLovedCache;
  lastfmActions -> networkLoveActions; loved cache storage renamed with a
  lossless legacy-key fallback
- getMusicNetworkRuntimeOrNull() for best-effort callers so they no-op (not
  throw) before the runtime is initialized

src/api/lastfm.ts and the Integrations UI still use the legacy path; they are
migrated and removed in the next phase.

* feat(music-network): manifest-driven Integrations UI + scrobble batch format

Replace the Last.fm Integrations card with a manifest-driven Music Network
section, and fix Audioscrobbler scrobbling to the batch/array shape.

- settings/musicNetwork/: MusicNetworkSection (master toggle, destination
  cards, enrichment-primary picker, Maloja proxy warning, add-a-service list)
  driven entirely off the preset registry; icon map from PresetManifest.icon
- IntegrationsTab delegates to MusicNetworkSection (Discord/Bandsintown/
  Navidrome now-playing unchanged)
- i18n: musicNetwork.* across all 9 locales, incl. a per-field help hint for
  Rocksky's CLI session-key flow (rocksky login)
- scrobble now uses the documented array form (artist[0]/track[0]/…); the bare
  single form is only tolerated by Last.fm, Rocksky requires the indexed form
- auth-error detection keys off the response message (not the ambiguous numeric
  code) so a Rocksky server-500 no longer flips the account to a reconnect state
- PresetManifest.PresetField gains an optional helpKey

Rocksky's server rejects some non-ASCII track metadata with a 500 — a Rocksky
backend bug; the client call is correct (verified).

* fix(music-network): clearer Integrations layout + scrobble batch fix

Address UI feedback on the Music Network section:
- per-account scrobble toggle moves inside its account block (was a loose
  row between cards — unclear which account it belonged to)
- master toggle and the primary-service picker are now boxed blocks at the top,
  not bare rows
- primary-service copy reworked: 'Primary service' + a line spelling out that
  liked tracks/similar artists/stats come from it while scrobbling still goes
  to all enabled services
- distinct zones separated by dividers (master/primary · connected · add)

Also folds in the verified scrobble fixes: documented array form
(artist[0]/track[0]/…) so Rocksky accepts scrobbles, and auth-error detection
by message rather than the ambiguous numeric code (a Rocksky server-500 no
longer flips the account to a reconnect state). Rocksky session-key field gains
a CLI help hint (rocksky login), all 9 locales.

* feat(music-network): indicator + remove legacy lastfm path

Phase 7b/7c — finish the cutover and delete the old Last.fm path.

- LastfmIndicator -> MusicNetworkIndicator (shows the enrichment primary's
  status, click -> Integrations)
- delete src/api/lastfm.ts; remove Rust lastfm_request (remote.rs + lib.rs)
- remove legacy authStore lastfm* fields, actions and types; delete
  authLastfmActions.ts; rehydrate migration reads the legacy blob via a cast
- migrate the remaining NowPlaying call-sites (NowPlaying.tsx + the now-playing
  fetchers/prewarm/star-love hooks) off lastfmSessionKey/lastfmUsername onto the
  enrichment primary (gate + cache key)
- type imports LastfmTrackInfo/LastfmArtistStats -> music-network TrackStats/
  ArtistStats; drop 8 stale api/lastfm test mocks and the obsolete Last.fm auth
  tests; update settingsTabs + src/CLAUDE.md

No lastfm imports remain outside src/music-network/; lastfm_request removed
(acceptance §12). tsc clean, 1947 frontend tests + remote rust tests green.

* test(music-network): cover scrobble shape + error classification, drop dead i18n keys

Remove the 14 unused legacy scrobble/connection i18n keys across all 9
locales (settings.lfm*/scrobble*, connection.lastfm*); the live love,
profile-link and now-playing keys stay.

Add regression tests for the parity-critical transport logic: the indexed
batch/array scrobble body, the auth-vs-network error classification
(numeric codes collide across providers), and the manifest-overrides-probe
capability merge.

* feat(music-network): provider-agnostic UI + Maloja Audioscrobbler & Koito presets

- de-hardcode the single provider name across every enrichment surface
  (love labels, now-playing badge, stats title); derive it from the
  enrichment primary and interpolate via {{provider}} i18n params
- surface a toast when a paste-auth connect probe fails — a static
  'supported' capability flag no longer masks a runtime probe error
- add the Maloja Audioscrobbler (GNU FM) preset (the third Maloja wire
  mode) and a Koito preset (ListenBrainz-compatible), both data-only
- generalise PRIVACY.md and the scrobbling help entry to the framework
- rename residual lfm* identifiers to network*; strip legacy flat
  lastfm* fields from the persisted blob; neutral transport error prefix
- tests: error classification, scrobble body, capability probe, registry

* fix(music-network): validate paste-auth keys on connect + UI polish

- AudioscrobblerWire.probe now validates an api_key_only session with a
  signed call and reports scrobble:'error' only on a genuine auth failure
  (a scrobble-only service that rejects user.getInfo is not a bad key), so
  an invalid Maloja Audioscrobbler / Rocksky key surfaces a connect toast
  instead of failing silently; WireContext carries the preset authStrategy
- drop the unreachable Statistics empty-state branch and its dead
  lfmNotConnected i18n key; use the useEnrichmentPrimaryLabel hook there
- drive the love-button glyph from the enrichment primary's manifest icon;
  neutral Music Network section icon; remove a dead LastfmIcon import
- tests: paste-auth vs token-poll probe behaviour

* chore(music-network): rename showLastfmSimilar → showNetworkSimilar, refresh stale comments

Post-parity polish: the similar-artists toggle now sources from the generic
enrichment runtime, so rename the lingering lastfm-flavoured identifier; drop
stale 'Mirrors today's LastfmX' doc comments referencing the removed legacy
types, and generalise the scrobble-point comment.

* docs(changelog): Music Network entry (PR #1066)

Co-Authored-By: cucadmuh <49571317+cucadmuh@users.noreply.github.com>

* fix(music-network): bound request timeout on the provider transports

audioscrobbler_request / listenbrainz_request / maloja_request built a
reqwest client with no timeout, so a hung provider left scrobble / probe /
loved-sync promises unresolved. Add a shared provider_http_client() with a
15s timeout, matching the sibling fetch_* commands. Addresses review C3.

* refactor(music-network): dedupe wire transport + no-enrichment helpers

The three provider clients repeated the same invoke -> classify-error ->
MusicNetworkError boilerplate, and the three probe() bodies repeated the
"mark every enrichment capability no" loop. Extract
wires/shared/invokeTransport() (each wire keeps its own arg shape + auth
rule) and markNoEnrichment() in core/capabilities.ts. Addresses review C4.

* refactor(music-network): drop write-only malojaWireMode dead state

malojaWireMode was written on connect but never read — the wire is resolved
by wireId and the Maloja base URL by the preset's selfHostedApiSuffix.
Remove the field, the MalojaWireMode type, the malojaWireModeFor helper,
the AccountPatch entry, the PresetField union member, and the export.
Addresses review C1.

* refactor(music-network): one useEnrichmentPrimary hook, drop lastfm fallback

The enrichment-primary lookup (accounts.find by enrichmentPrimaryId) was
duplicated across two hooks and inlined in the indicator and both
context-menu builders, two of them with a hardcoded 'lastfm' icon fallback.
Add one music-network/ui/useEnrichmentPrimary() returning
{account,label,icon}|null; useEnrichmentPrimaryLabel/Icon delegate to it and
the indicator + context menus consume it directly. Icon fallback is the
neutral 'custom' glyph, never a provider (provider-agnostic, §7.3).
Addresses review C2.

---------

Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
2026-06-11 22:56:30 +02:00
cucadmuh 5cd01c90ac fix(library): multi-genre local index with track_genre and backfill (#1059)
* fix(library): multi-genre local index with track_genre and backfill

Restore atomic genre browse, filters, and counts via track_genre:
OpenSubsonic genres[] first with Navidrome-default split fallback, sync
write path, read-path query switches, blocking startup backfill with
progress, and v12 repair migration for DBs that recorded legacy 002–011.
TS fallback adds genreTagsFor and migration gate i18n across locales.

* fix(library): address multi-genre review — robust TS genres and scope join

genreTagsFor routes raw genres through parseItemGenres (single-object
Subsonic quirk and bare strings). Library-scoped genre browse/counts join
track for raw_json library_id fallback. Statistics keeps empty-genre bucket.

* docs: CHANGELOG and credits for multi-genre local index (PR #1059)

* docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059)
2026-06-11 01:02:35 +03:00
cucadmuh fb5a257735 fix(library): show album artist correctly in album grids (#1056) (#1057)
* fix(library): show album artist in album grids (#1056)

Prefer album-artist tags over track artist when building album rows from
the local index, and align grid cards with OpenSubsonic displayArtist.

* fix(library): align album artist in FTS, search, and offline paths (#1056)

Apply album-artist preference in FTS album dedupe and live search, fix
offline pin hydration order, and use albumArtistDisplayName in remaining
cheap UI/export/download call sites.

* docs(changelog): album artist grid fix for compilations (PR #1057)

* fix(library): parity guard and live-search album artist helper (#1056)

Align SQL ELSE branch with pick_album_group_artist trimming, add parity
test, and use albumArtistDisplayName in LiveSearch and MobileSearchOverlay.
2026-06-10 15:54:46 +03:00
cucadmuh b2a5baa48d fix(library-db): name slow-write ops for macOS stall diagnosis (#1043)
* fix(library-db): name slow-write ops for macOS stall diagnosis (#1040)

Replace generic op=misc labels on production library-db write paths with
stable module.action names (sync_state.*, track.*, tombstone.*, cmd.*, …)
so SLOW write logs pinpoint the call site. Document the naming convention
on LibraryStore::with_conn.

Diagnostic step for #1040 — no behaviour change.

* docs: add CHANGELOG and credits for PR #1043

Library-db slow-write op naming diagnostic for issue #1040.
2026-06-09 11:03:22 +03:00
Psychotoxical c6298d8c25 fix(audio): poll only the default device when none is pinned (#996) (#1039)
* fix(audio): poll only the default device when none is pinned (#996)

The device watcher ran a full output_devices() + per-device description()
CoreAudio enumeration every 3s, even with no pinned device. On some macOS
setups this contends with the audio render thread and causes a brief dropout
once per poll — a stutter whose cadence tracks the poll interval exactly.

The full enumeration is only needed to detect a pinned device disappearing.
With no pin (system default, the common case) only the current default is
needed, so the enumeration is skipped entirely in that case; the cheap
single default_output_device() query still detects default-device changes.

Confirmed with a diagnostic build: throttling the enumeration to ~60s moved
the reporter's stutter cadence from ~3s to ~60s, isolating the enumeration
as the cause.

* docs: add CHANGELOG entry for PR #1039
2026-06-09 00:16:29 +02:00
cucadmuh 086c7e43b4 feat(psylab): rename probe, Tuning tab, log tools, and safe log sanitization (#1027)
* feat(psylab): rename probe UI, add Tuning tab and log copy/export

PsyLab (Ctrl+Shift+D): cover backfill threads move to Tuning; logs gain
selectable text, toolbar copy/export, and a selection-only context menu.

* fix(logging): redact secrets and mask remote hosts in runtime logs

Sanitize lines at append time (buffer, CLI tail, export) and in PsyLab:
Subsonic/auth query params, bearer tokens, password fields, URL userinfo;
remote hostnames partially starred, LAN/localhost left readable.

* fix(logging): UTF-8-safe log sanitization — unbreak playback on em dash

Byte-indexed URL scanning panicked on multi-byte chars (e.g. "—" in stream
logs), killing tokio workers and aborting playback. Iterate by char boundary;
add infallible wrapper on the append hot path.

* docs(changelog): PsyLab UI and safe log sanitization (PR #1027)
2026-06-08 02:29:23 +03:00
cucadmuh 32832246c0 fix(library): All Albums compilation filter matches VA album artist (#1026)
* fix(library): All Albums compilation filter matches VA album artist

Local index SQL and track-grouped browse missed compilations tagged via
Various Artists on album_artist; genre-only browse also ignored combined
filters. Extend predicates on both sides and route genre+filter to advanced search.

* docs(changelog): All Albums compilation filter fix (PR #1026)
2026-06-08 01:19:24 +03:00
cucadmuh 2d3c723a6e feat(offline): unify local playback, offline library, and favorites sync (#1008)
* feat(local-playback): LP-1 media layout and download_track_local

Add library-index-backed path builder in psysonic-core and a unified
Tauri download command that writes under media/{cache|library}/ with
layout fingerprints; legacy hot/offline commands unchanged for now.

* feat(local-playback): LP-2 localPlaybackStore and media tier Rust helpers

Add unified Zustand index with legacy offline/hot-cache import, media_layout
TS mirror, and Rust commands for tier size/purge/delete/promote.

* feat(local-playback): LP-3 wire prefetch and playback to unified index

Route downloads through download_track_local, delegate hot/offline shims
to localPlaybackStore, and update resolve/promote/prefetch plus key rewrite.

* feat(local-playback): LP-4–LP-6 offline UI, invalidation, and mediaDir

Offline Library loads pinned groups via library index; sync-idle invalidates
stale paths; Settings uses a single mediaDir with cache/library tier sizes.

* feat(local-playback): migrate legacy offline files to media/library layout

Move flat psysonic-offline downloads into nested media/library paths using
library index metadata, with retry on sync-idle when tracks are not yet indexed.

* feat(local-playback): simplify offline disk migration and restore Offline Library UI

Scan psysonic-offline on disk and relocate by library track id; restore pinSource
and cover art for migrated pins; add find_live_by_id for segment/key resolution.

* feat(local-playback): disk-first offline reconcile and fast library tier discovery

Reconcile library-tier index against on-disk files using candidate track IDs
instead of scanning the full catalog. Refresh Offline Library from disk on
open and focus so deleted folders drop out of the UI. Add Rust discover/prune
helpers and wire album/server reconcile through the unified path.

* fix(offline): resolve local playback URLs across server index-key variants

Offline Library play failed when library-tier files were indexed under a
host key while playback looked up only the active profile UUID. Use
findLocalPlaybackEntry for URL resolution, pin queueServerId to the card
server, and build play queues from tracks that still have on-disk bytes.

* fix(offline): playlist cards, playback from Offline Library, and local URL routing

Show playlists with name and quad/custom cover instead of the first track's
album artist. Build play queues with library-batch fallback and offline-only
server switch. Prefer library-tier URLs in playTrack; add playback-unavailable
toast and missing trackToSong import.

* fix(cache): ephemeral disk reconcile, empty-dir prune, and Storage UI

Sweep media/cache after eviction (orphan files, stale index, empty folders).
Settings: split media folder from cover cache; in-browser image cache lives
under Cover art cache with aligned columns; clear only IndexedDB images.

* feat(offline): show library disk usage in Offline Library header

Query media/library tier size on reconcile and display it in a right-aligned
stat block beside the page title and album count.

* fix(cache): defer unindexed hot-cache eviction; drop legacy offline size cap

Reconcile ephemeral cache without deleting files from other app instances;
evict unindexed hot-cache files oldest-first only when over hotCacheMaxMb.
Remove the hidden maxCacheMb gate and offline-full banner on album pages.

* feat(offline): play-all cache card and stable Offline Library grid rows

Add a shuffle-and-play card for all on-disk library pins plus hot-cache
tracks when buffering is enabled. Fix virtual row height for offline cards
and reserve the year line so grid rows no longer overlap.

* feat(offline): queue-cache grid card limited to media/cache

Replace the full-width play-all banner with a playlist-style grid tile.
Shuffle/enqueue only ephemeral hot-cache tracks when buffering is enabled,
not offline library pins.

* chore(licenses): regenerate bundled OSS list for 1.48.0-dev

Set GPL-3.0-or-later on workspace crates and extend cargo-about accepted
licenses so generate-licenses.mjs runs; refresh src/data/licenses.json.

* feat(favorites): auto-sync starred tracks into separate media/favorites tier

Keep manual Offline Library in media/library/ and favorites offline in
favorite-auto/index + media/favorites/ so toggling sync cannot purge
user-pinned bytes; playback resolves library before favorites.

* feat(favorites): compact offline toggle with disk icon and sync semaphore

Move control to the page header (disk + switch, tooltips); show red/yellow/green
LED when enabled instead of the full-width save-offline card.

* fix(favorites): trigger offline sync on star/unstar from anywhere

Hook star/unstar API so favorites offline reconcile runs globally (songs,
albums, artists); optimistic unstar removes local bytes; drop Favorites-page-only sync.

* fix(cache): skip hot-cache prefetch when favorites or library bytes exist

Treat favorite-auto tier like offline library for prefetch, stream promote,
and same-track replay so synced favorites are not duplicated in media/cache.

* fix(favorites): reconcile offline files on merged track union only

Dedupe artist/album/song stars into one target set per track id; drop eager
unstar deletes so overlapping favorites do not remove bytes still needed.

* feat(offline): add Favorites card to Offline Library

Mirror queue-cache card for favorite-auto tier with play, enqueue, and
navigation to Favorites on card click.

* feat(offline): show library+favorites disk total with icon breakdown

Sum media/library and media/favorites in the On disk widget and open an
icon popover on hover with per-tier sizes for screen readers and sighted users.

* feat(favorites): enable offline Favorites tab when auto-save is on

Keep Favorites in the sidebar when disconnected, land on /favorites without
manual pins, and load starred rows from the local library index.

* feat(favorites): cross-server offline browse with per-server covers

When auto-save is on, Favorites merges starred items from every indexed
server and syncs each server independently. Detail links carry ?server=
for offline album/artist pages; cover art resolves disk cache by entity
serverId instead of the active server only.

* feat(playback): mixed-server queue scope and cross-server favorites sync

Per-ref server identity for playback (URL index key in queue refs, profile
UUID for API): trackServerScope, playbackServer helpers, gapless/scrobble/covers
by playing ref. Remove cross-server enqueue block; remap queueItems on URL
remigration.

Favorites: star/unstar and favorite-auto sync target the owning serverId
(not only active); queueSongStar passes server through pending sync.

* fix(offline): suppress Subsonic calls during favorites and local playback

Add reachability guards so offline favorites browse, album detail, queue
sync, scrobble, and Now Playing metadata skip network when the server is
down or the track plays from psysonic-local. Load starred albums/tracks
from the library index only (not the full artist table), refresh favorites
from index first, and pass server scope in favorites navigation.

* fix(queue): export share and playlist save for active server only

Mixed-server queues now filter queue refs by the browsed server profile
before copying a share link or saving/updating a playlist from the toolbar.

* fix(offline): complete album pins and resume interrupted downloads

Prefer full getAlbum track lists when online so partial library index
does not truncate offline pins; refresh songs before pin from album
detail. Resume incomplete persisted pins after reconcile and reconnect,
cancel in-flight work on delete, and chunk library batch fetches past
100 refs.

* fix(offline): pin queue, queued UI, and re-pin after remove

Serialize album and playlist offline pins so parallel enqueue no longer
drops in-flight work. Show an explicit queued state on album and playlist
actions with dequeue on repeat click, sidebar tooltips for long labels,
and clear stale cancel flags so Make available offline works after remove.

* fix(offline): remove Offline Library cards without full page reload

Optimistically drop the deleted card from local grid state, show the
loading spinner only on first visit, and ignore stale disk refreshes so
pin updates no longer flash the whole library view.

* fix(offline): artist discography pin state and queue handling

Detect cached/queued/downloading from persisted album pins instead of
ephemeral bulkProgress, skip already offline or in-flight albums when
enqueueing discography, and show the correct hero button after revisit.

* fix(local-playback): address LP-1 review handoff (B1, M1–M7)

Harden media path sanitization and tier containment, align Rust/TS layout
fingerprints, serialize per-track downloads, and fix favorites re-enable,
multi-server debounce, prev-track promote key, now-playing reachability,
and ephemeral prefetch soft-skip for unindexed tracks.

* fix(offline): cancel in-flight favorites downloads on unstar

Abort Rust streams with the real favorites downloadId when sync is
rescheduled or disabled, and drop completed bytes that no longer belong
in the starred set so unstar does not leave orphan files on disk.

* fix(build): resolve TypeScript errors blocking prod nix build

Align mediaLayout with LibraryTrackDto camelCase, extend analysis-sync
reasons, fix OfflineLibrary grid cover typing, and tighten vitest mocks
so `tsc && vite build` passes under the flake beforeBuildCommand.

* fix(rust): satisfy clippy too_many_arguments for CI

Bundle offline-library analysis and local path/migration helpers into
parameter structs so `cargo clippy -D warnings` passes on the branch.

* fix(settings): show correct hot-cache track count in Buffering section

Count ephemeral localPlayback rows instead of prefix-matching index keys,
which always missed host:port server segments and showed zero tracks.

* fix(media-layout): align truncation threshold on code points (M1)

Rust sanitize_and_truncate_segment now uses char count like TS so long
non-ASCII metadata does not diverge layout fingerprints; add Cyrillic
parity tests and clarify ephemeral cold-miss doc on download_track_local.

* fix(test): use numeric cachedAt in hotCacheStore count test

Align test fixture with LocalPlaybackEntry type so tsc passes in CI.

* docs: add CHANGELOG and credits for offline experience PR #1008

* feat(offline): auto-sync manually cached playlists when track list changes

Re-download new tracks and prune removed ones for playlist pins only, triggered
from updatePlaylist, playlist detail load, smart-playlist polling, and reconnect.

* docs: note cached-playlist sync in CHANGELOG and credits for PR #1008

* fix(offline): exclude smart playlists from manual offline cache and sync

Hide cache-offline for psy-smart-* playlists, block download/sync paths, and
document the distinction in CHANGELOG.

* feat(offline): auto-sync cached albums and artist discographies

Generalize pinned playlist reconcile into pinnedOfflineSync so manually
pinned albums and artist discographies re-download added tracks and prune
removed ones on reopen, reconnect, and catalog changes.

* feat(offline): split pinned sync triggers by pin kind

Album and artist pins reconcile after library index sync and reconnect;
regular playlists reconcile hourly and on in-app playlist edits only.
Remove reconcile-on-open for album, artist, and playlist detail views.

* fix(offline): address PR #1008 review (N1, tests, pin queue)

Scope playlist reconcile to the owning server via getPlaylistForServer.
Add artist discography and mixed-server playlist tests; dedupe pending
sync jobs; skip pinTasks overwrite during active downloads.
2026-06-06 22:43:03 +03:00
cucadmuh 40db6b08d2 chore(playback): remove Preload Next Track setting (#1007)
* chore(playback): remove Preload Next Track setting

The configurable next-track RAM preload duplicated hot cache and is
obsolete now that ranged streaming starts playback sooner. Gapless and
crossfade still use the internal audio_preload backup when hot cache is off.

* docs: add CHANGELOG entry for Preload Next Track removal (PR #1007)
2026-06-06 01:01:08 +03:00
cucadmuh a66d932afe fix(preview): Symphonia format sniff and ranged stream startup (#1006)
* fix(preview): Symphonia format sniff and cluster member stream URLs

Resolve preview container hints from HTTP headers, Subsonic suffix, and
magic-byte sniff after Symphonia 0.6. Route preview streams through
clusterBrowseServerId like main playback; guard CoverArtImage when the
preview cover ref is still loading.

* fix(preview): adapt Symphonia sniff branch for main without cluster routing

Keep formatSuffix and cover-ref guards from the cluster work; use
buildStreamUrl on the active server instead of clusterBrowseServerId.

* fix(preview): use ranged HTTP so preview starts without full-file download

Open preview via RangedHttpSource when the server supports byte ranges;
fall back to buffered download otherwise. Gate in-memory probe with
ProbeSeekGate so Symphonia 0.6 does not scan the entire file before audio.

* docs: CHANGELOG and credits for preview fix PR #1006

* chore: drop PR #1006 from settings credits (minor fix)

* fix(preview): allow clippy too_many_arguments on audio_preview_play

formatSuffix pushed the Tauri command to 8 args; matches other IPC commands.
2026-06-05 22:59:20 +03:00
cucadmuh c674e4515b feat(audio): migrate Symphonia 0.5 -> 0.6 (#999)
* feat(audio): migrate Symphonia 0.5 -> 0.6

Port the audio + analysis pipeline to the Symphonia 0.6 API:
- bump symphonia to 0.6 and symphonia-adapter-libopus to 0.3; drop rodio's
  symphonia-all feature to avoid a duplicate symphonia-core 0.5
- remove the local symphonia-format-isomp4 0.5 patch and rely on upstream 0.6
- switch codec registries to register_enabled_codecs / make_audio_decoder with
  AudioCodecParameters + AudioDecoderOptions
- rework decode.rs SizedDecoder and analysis decode loops for the new
  AudioDecoder trait, GenericAudioBufferRef, Time/Timestamp newtypes, and
  next_packet() -> Result<Option<Packet>>

Streaming regression fixes:
- ProbeSeekGate hides seekability during probe for non-MP4 progressive streams
  so Symphonia 0.6's trailing-metadata scan no longer forces a full download
  before ranged FLAC/MP3/OGG playback can start
- guard the streaming probe() with a 20s timeout on a worker thread so a stalled
  ranged source (e.g. right after a server switch) can no longer hang playback
  start until a player restart; add probe start/done diagnostics

* docs(changelog): note Symphonia 0.6 migration and streaming fixes (#999)

Add CHANGELOG entries (Changed + Fixed) and a CONTRIBUTORS line for the
Symphonia 0.6 migration, ranged-stream start-latency fix, and probe timeout.

* test(audio): cover streaming probe path and ProbeSeekGate

Add unit tests for SizedDecoder::new_streaming (success + garbage) and
ProbeSeekGate (seekability toggle, byte_len, read/seek passthrough) to
restore decode.rs above the 70% hot-path coverage gate (67.3% -> 79.4%).
2026-06-05 14:12:57 +03:00
cucadmuh d1320ea2c8 chore(deps): refresh npm and Rust dependencies (#997)
* chore(deps): bump npm patch/minor dependencies

Refresh frontend lockfile for React, Vite, Vitest, i18next, axios, Tauri
plugins, and related type packages within existing semver ranges.

* chore(deps): bump Rust patch dependencies

Update id3 to 1.17, reqwest to 0.13.4, and align root zbus with psysonic-audio 5.16.

* chore(deps): upgrade jsdom to v29

Major test-environment bump; all Vitest suites still pass and npm audit is clean.

* chore(deps): upgrade sysinfo 0.39 and zip 8

Align root sysinfo with psysonic-syncfs and migrate backup archives to zip 8.
mach2 0.6 deferred — cpal/rodio still require ^0.5.

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

* docs(changelog): note dependency refresh (PR #997)

* docs(changelog): move deps note to [1.48.0] section

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-05 12:17:42 +03:00
cucadmuh 19fdba006a fix(search): local index rejects FTS syntax in live search queries (#983)
* fix(search): reject FTS syntax chars in local index live search queries

FTS5 treats `=` and similar characters as query syntax, so tokens like
1=2 produced unrelated prefix hits. Skip FTS for unsafe tokens instead.

* docs: CHANGELOG for local index FTS syntax query fix (PR #983)

* fix(search): reject syntax junk in server search3 and live search race

Share FTS-safe token guard with search3; skip network invoke for ** and
similar queries; show empty dropdown without misleading source badge.

* fix(search): allow censorship stars in queries, reject wildcard-only tokens

Block ** and **** but keep ***Flawless-style title searches working
in local FTS and search3.
2026-06-04 13:37:11 +03:00
Frank Stellmacher 47e16ebfef fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket (#977)
* fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket

Three zunoz reports, one change:

- Queue now-playing card: the artist and album links now underline on hover
  (not just recolour), matching clickable names everywhere else. The album
  link sits on .queue-current-sub itself; artist links are nested .is-link
  spans — both selectors covered.

- Genre album cards split multi-artist credits into individual links like the
  rest of the app. Root cause was the local-index genre query hardcoding NULL
  for the album raw_json column, so OpenSubsonic artists[] never reached the
  card and it fell back to the flat "A • B" single link. Select a.raw_json
  instead (parity with the All Albums / advanced-search album queries).

- Artists page alphabet index: # is now digits-only, and a new "Other" bucket
  collects accented Latin (Æ/Ø/Å…) and non-Latin scripts (CJK, Cyrillic, …)
  that previously fell into the # catch-all. Adds artistBucketKey/compareBuckets
  helpers (unit-tested), an OTHER bucket sorted last, and the artists.other
  label across 9 locales.

* docs(changelog): queue link hover, genre card artist split, Artists Other bucket (#977)
2026-06-04 03:13:45 +02:00
cucadmuh 975bb6d9af feat(perf): live runtime logs tab in Performance Probe (#946)
* feat(perf): live runtime logs tab in Performance Probe

Add a Logs tab that streams the backend runtime log ring buffer in-app, so the
stdout/stderr console (unreachable on Windows without exporting) can be read
live. The buffer now tracks a monotonic seq; a new tail_runtime_logs command
returns lines incrementally and get_logging_mode reports the current depth.

The tab has a depth switch (off/normal/debug) mirroring app settings, a line cap
(500-5000), pause/clear, auto-follow, and an ordered comma-separated word filter
where a plain word includes and a -word excludes, applied left to right as
layers (sequence matters).

* docs(changelog): note Performance Probe logs tab (PR #946)

Add CHANGELOG entry and credits line for the live runtime logs tab.

* fix(perf): pin log view position when scrolled up

Auto-scroll keeps the logs tab at the tail, but once the user scrolls up the
view now stays put — the previously-topmost line is re-pinned each tick while
the log keeps appending below for further scrolling. History under the viewport
is no longer trimmed while scrolled up (kept up to the ring-buffer ceiling); the
cap is re-applied when following resumes. Buffer overflow is shown in the status
line instead of an injected marker row.

* fix(perf): scope logs tab to its own internal scroll

The whole probe body scrolled (controls + filter + log) because the log
container sized via height:100%, which WebKitGTK does not resolve against the
flex body. Make the body a flex column with hidden overflow on the Logs tab and
let the log view flex-fill, so depth/keep/pause/clear and the filter stay fixed
while only the log lines scroll.
2026-06-02 11:40:36 +03:00
cucadmuh 42aec6720c fix(cover): stop per-song over-fetch + log failed cover downloads (#944)
* fix(cover): stop per-song cover over-fetch (album/mf-* explosion)

album_has_distinct_disc_covers returned true as soon as two tracks on the same
disc had different cover ids. On Navidrome every song has its own mf-<id>
coverArt, so almost every album was flagged "distinct disc covers" and backfill
warmed one cover per track (~520k for ~170k tracks), filling album/ with mf-*
dirs instead of ~one cover per album.

Treat a release as having distinct disc covers only when each disc has a single
consistent cover that differs across discs (genuine box set); per-song ids now
collapse to one cover per album. Mirror the same fix in the TS
albumHasDistinctDiscCovers used by on-demand warming. Adds regression tests on
both sides.

* docs(changelog): record per-song cover over-fetch fix (PR #944)

Give the album/mf-* over-fetch fix its own [1.47.0] Fixed entry and a
settingsCredits line under PR #944.

* feat(cover): log failed cover downloads with album/artist name

A non-200 (or network-failed) getCoverArt download was swallowed silently. Now
the failure is logged with the resolved album/artist name and the server error,
so a server refusing covers under backfill load (5xx/429/timeouts) is visible.

- cover_resolve: describe_cover_entity() resolves a human label from the local
  index (album "Name" — Artist / artist "Name"), best-effort with id fallback.
- cover_cache: log_cover_fetch_failure() in ensure_inner logs on the download
  error path; threads optional library_server_id through CoverCacheEnsureArgs so
  the name lookup happens only on failure. Backfill logs at normal level,
  on-demand misses at debug level.
2026-06-02 10:58:39 +03:00
cucadmuh a63ba3c9cb fix(cover-backfill): kill idle CPU spin and offline-cache menu re-walks (#943)
* fix(cover-backfill): snapshot-diff worklist and live-tunable parallelism

Aggressive cover backfill pegged one tokio worker at ~100% on large,
fully-synced libraries while the download queues stayed empty.

- Take two snapshots once per pass — the DB catalog (single GROUP BY) and
  the on-disk cover bucket (one directory walk) — and download the
  set-difference. No per-row `stat` syscalls and no re-scan loop; the empty
  cache case (heavy backfill) costs zero per-item disk hits.
- Replace the front-loaded enumeration with a producer/consumer pipeline:
  the producer streams the catalog in chunks and feeds misses into a bounded
  channel; a fixed consumer pool keeps the download/encode pools saturated.
- Make cover backfill parallelism runtime-tunable from the Performance Probe
  (threads slider + "Run full pass now"); HTTP download and CPU encode
  semaphores resize live. Not surfaced in app settings.
- Add a "nothing changed" idle gate (catalog signature) so a settled pass is
  not re-run on every library:sync-idle, mirroring the analysis worker.
- Cancel promptly on switch to lazy: consumers bail on enabled/focus change
  and the producer feeds via try_send so a full channel cannot deadlock.
- Drop the per-item recursive disk walk from the ensure hot path.

* fix(cover-backfill): cheap idle gate, settle on 404s, transient retries

Follow-up to the snapshot-diff backfill: stop the periodic CPU spikes and the
89%-plateau wake storm on libraries whose covers can never reach 100%.

- Idle gate is now disk-free: compare only the catalog COUNT(DISTINCT) instead
  of walking ~all cover dirs on every sync-idle. "Did the server change?" never
  touches the filesystem. Clear-cache commands re-arm the gate (rearm_idle_gate)
  since a clear leaves the catalog total unchanged, and the settings UI wakes the
  active server after a clear.
- Settle the gate on any completed pass regardless of pending: remaining items
  are unfetchable-for-now (404), so the wake/sync-idle storm stops once the
  fetchable set is exhausted.
- Stop auto-clearing .fetch-failed markers every pass (it defeated the 30-min
  backoff and re-attempted 404s forever). The manual "Run full pass now" sends
  force=true to clear them and retry; wake/sync-idle/configure stay opportunistic.
- Rate-limit sync-idle passes (60s cooldown) as defence against chatty syncs.
- Retry cover downloads up to 3x with backoff on transient failures (5xx / 429 /
  network), but never on a real 4xx so missing covers don't hammer the server.

* fix(cover-cache): stop re-walking cover dirs from offline & cache menu

The settings cover-cache section polled disk usage + progress every 15s for
every server, each call doing a full recursive walk of the per-server cover
directory. On a fully populated cache this caused periodic CPU spikes whenever
that menu was open.

- mod.rs: add a 10s TTL memo around the per-server cover dir walk
  (cached_dir_usage_for_server), shared by cover_cache_stats_server and
  library_cover_progress; invalidate on clear (per-server and clear-all).
- CoverCacheStrategySection: recompute on entry only; rely on the
  cover:library-progress and cover:cache-cleared events for live updates;
  drop the per-cover cover:tier-ready refresh storm; turn the 15s loop into a
  5-minute safety net.

* fix(cover-backfill): keep emitting progress during the whole pass

The producer finishes enumerating the worklist long before the consumer pool
finishes downloading it, so progress was only emitted while feeding the channel
— the "offline & cache" menu and overlay then froze through the entire drain
phase. Replace the per-chunk emit with a 3s progress ticker that runs for the
lifetime of the pass and is aborted once the consumers drain (final accurate
emit still happens at settle).

* docs(changelog): record cover-backfill idle-CPU fix (PR #943)

Add [1.47.0] Fixed + Changed entries and a settingsCredits line for the
cover-backfill idle CPU / offline & cache menu work.
2026-06-02 04:56:34 +03:00
cucadmuh 4ac373a65b feat(search): scoped live search on browse pages (#938)
* feat(artists): scoped live search badge replaces page filter

Move Artists browse text search into the header Live Search with a page
scope badge (Users icon), field-local undo, and double-click/backspace
to clear scope. Block the live-search dropdown while scoped so results
only filter the Artists grid; mobile overlay follows the same rules.

* fix(artists): plain grid for scoped search fixes broken card layout

Route the browse grid through VirtualCardGrid, switch to non-virtual CSS
grid when live search filters the catalog, reset scroll on filter changes,
and skip content-visibility on plain tiles to avoid blank/black cards.

* fix(search): scope badge double-Backspace and single clear control

Require two Backspaces on an empty scoped field after prior text input;
one Backspace still clears the badge when the field was never filled.
Move live-search clear/advanced controls inside the field pill, drop the
extra outer clear button, and use type=text to avoid native search clears.

* fix(search): drop duplicate outer live-search clear button

Keep the native in-field clear on type=search and the original pill layout;
remove only the extra × control outside the search border. Reset dropdown
state when the query is cleared via the native control.

* refactor(search): generic scoped browse query helper, drop dead code

Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an
expectedScope argument; wire Artists via useScopedBrowseSearchQuery.
Remove unused liveSearchScoped dropdown helper (scoped mode blocks it).

* feat(search): ghost scope badge and single-click badge remove

After clearing the artists scope on /artists, show a faded ghost chip to
restore page-only search while keeping the global search placeholder.
Active badge removes on one click; tooltips and styles updated.

* feat(search): scoped live search for All Albums and New Releases

Wire albums and newReleases scope badges with debounced album title search
(local index title-only FTS + filtered search3). Plain grid, scroll reset,
and session query restore on album grid browse pages.

* fix(browse): preserve scroll restore after album/artist detail back

Only reset in-page scroll when filter resetKey changes, not when
isScrollRestorePending clears after session restore.

* feat(search): scoped live search for Tracks browse

Wire /tracks to header live search with wide title/artist/album FTS,
hide hero and discovery rails while search is active, and remove the
inline search field from the browse list.

* fix(search): clear header query when leaving scoped browse pages

Prevent global live search from firing on album/detail routes after a
scoped browse query; browse session stashes still restore on back.

* fix(tracks): restore scroll after back from detail during scoped search

Hold stashed song results across fetchSongPage churn, defer leave-stash
teardown past AppShell scroll reset, restore tracks scroll after the list
is ready, and save scroll snapshot when opening artist from song context menu.

* fix(tracks): hide discovery headings during scoped search

Hide the page subtitle and "Browse all tracks" section title when
tracks search is active, matching hero/rails chrome behavior.

* feat(search): scoped live search for Composers browse

Wire /composers to header live search with composers scope badge,
session stash, scroll restore, and plain grid/list during text filter.
Remove the in-page filter input; add i18n and navigation helpers.

* docs: CHANGELOG and credits for scoped browse live search (PR #938)
2026-06-01 13:04:36 +03:00
cucadmuh ddf10ee01d feat(genres): local index genre browse with Subsonic fallback (#937)
* feat(genres): genre detail browse via local index with aligned counts

Move genre detail albums/play/shuffle onto the local library index with
Albums-style in-page scroll, session restore, and genre-scoped stash. Unify
genre album totals between the cloud and detail pages via
library_get_genre_album_counts, and fix grouped browse totals to count
distinct albums rather than matching tracks.

* perf(genres): local genre browse with scoped counts cache

Add dedicated Rust genre album pagination and indexes, slice-mode grid
loading, library-filter-aware counts, and a long-lived in-memory catalog
cache invalidated on sync so genre pages avoid repeated full-library SQL.

* fix(genres): restore scroll after album back; play hold-to-shuffle

Pin restore display count in refs so clearing the return stash no longer
reloads the genre grid mid-restore. Load the first SQL page only (60 rows),
use long-press on Play for shuffle, and add genre play tooltips.

* fix(genres): fall back to Subsonic byGenre when local index unavailable

Genre detail album grid now matches All Albums: try library_list_albums_by_genre
first, then getAlbumsByGenre when the index is off, not ready, or errors.

* docs: add CHANGELOG and credits for PR #937
2026-06-01 04:20:18 +03:00
cucadmuh d3e5a6b704 feat: library browse navigation — restore filters, scroll, and search on back (#936)
* feat(albums): restore scroll position when returning from album detail

Save in-page scroll and grid depth when opening an album from All Albums,
then on browser back restore filters, preload enough rows, and apply scroll
before revealing the grid to avoid a visible jump from the top.

* feat(albums): smart back navigation and restore browse session on return

Remember the originating route when opening album detail, restore All Albums
filters/scroll on back (including explicit returnTo navigation), hide the grid
until scroll is applied, and fix filters being cleared after albumBrowseRestore
state is stripped from the location.

* feat(search): restore Advanced Search session when returning from album

Stash filters and results when leaving /search/advanced for album detail,
then restore them on back navigation (POP or returnTo with advancedSearchRestore).

* feat(search): restore Advanced Search album row scroll on return from album

Save horizontal scrollLeft when opening an album from Advanced Search and
reapply it via AlbumRow on return; keep main viewport at top. Add snapshot
helpers and session stash fields; extend AlbumRow with restoreScrollLeft.

* feat(search): restore Advanced Search session scroll and artist return path

Save filters, main scroll, and album-row scroll when leaving to album or
artist; restore without flash via hidden-until-ready. Add useNavigateToArtist,
restoreMainViewportScroll helper, and AppShell scroll reset only on pathname change.

* feat(search): speed up Advanced Search back restore and year-only queries

Reveal the page right after sync scroll instead of blocking on full viewport
and album-row restore. Retry local index without the ready gate during sync;
use open-ended byYear params on network fallback, matching All Albums browse.

* feat(search): restore Advanced Search artist row scroll on back

Save leave snapshot when opening artist from ArtistCardLocal, persist
artistRowScrollLeft in session stash, and keep row restore targets in refs
so horizontal scroll survives finishLeaveRestoreUi like vertical scrollTop.

* feat(nav): route mouse back on album/artist detail like UI back

Trap history popstate when returnTo is set and call navigateAlbumDetailBack
so browser/mouse back restores browse/search session the same way as the header button.

* feat(artists): restore browse filters and scroll on back from artist detail

Persist Artists page filters, view settings, and vertical scroll when opening
an artist and returning via UI or mouse back, matching All Albums behavior.

* feat(search): unify quick and advanced search; fix LiveSearch dismiss on Enter

Serve /search and /search/advanced from one page with shared session restore
and scroll snapshot. Reset live search overlay state when navigating to full
search so the dropdown does not linger or reopen.

* feat(tracks): unify with search session and restore scroll on back

Route /tracks through AdvancedSearch with shared leave snapshot, song
browse stash, and main-viewport scroll restore when returning from album
or artist detail. Wait for hero/rails layout before applying scroll.

* refactor(search): rename AdvancedSearch page to SearchBrowsePage

The shared route shell serves /search, /search/advanced, and /tracks;
rename the page component and refresh stale file references in comments.

* feat(albums): restore New Releases and Random Albums on back from detail

Unify album grid leave-restore with surface-scoped session stash, live scroll
snapshot sync, and in-page scroll for Random Albums. Keep the same random
batch when returning from album detail; Refresh fetches anew and scrolls up.

* docs: add CHANGELOG and credits for PR #936
2026-06-01 03:11:27 +03:00
cucadmuh a7d533d580 chore(library): squash pre-RC migrations into single 001_initial baseline (#919)
* chore(library): squash pre-RC migrations into single 001_initial baseline

Library SQLite never shipped in a release, so fold migrations 002–008 into
001_initial.sql and drop the dev-only mood-facts purge (009). Sets
LIBRARY_DB_SCHEMA_VERSION to 1 for the RC baseline; analysis migrations
unchanged.

* docs: note library migration squash in CHANGELOG and credits (PR #919)

* fix(library): keep migration SQL files on disk after RC baseline squash

Restore 002–009 as historical dev migration scripts. Runner still ships
only 001 for fresh installs; existing DBs with applied versions are
unchanged. Drop credits/CHANGELOG note for this small internal change.
2026-05-30 00:08:05 +03:00
dependabot[bot] f7c32e6954 chore(deps): bump sysinfo from 0.38.4 to 0.39.3 in /src-tauri (#912)
Bumps [sysinfo](https://github.com/GuillaumeGomez/sysinfo) from 0.38.4 to 0.39.3.
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.38.4...v0.39.3)

---
updated-dependencies:
- dependency-name: sysinfo
  dependency-version: 0.39.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 23:06:59 +03:00
dependabot[bot] 794ddf966e chore(deps): bump zbus from 5.15.0 to 5.16.0 in /src-tauri (#908)
Bumps [zbus](https://github.com/z-galaxy/zbus) from 5.15.0 to 5.16.0.
- [Release notes](https://github.com/z-galaxy/zbus/releases)
- [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml)
- [Commits](https://github.com/z-galaxy/zbus/compare/zbus-5.15.0...zbus-5.16.0)

---
updated-dependencies:
- dependency-name: zbus
  dependency-version: 5.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 23:06:54 +03:00
cucadmuh 90f86d3f87 chore(deps): bump rusqlite to 0.40 workspace-wide (#916)
* chore(deps): bump rusqlite to 0.40 workspace-wide

Align root src-tauri and workspace crates on rusqlite 0.40 so libsqlite3-sys
resolves to a single version (fixes Dependabot #913 links conflict).

* chore(nix): refresh flake.lock for rustc 1.95 dev shell

libsqlite3-sys 0.38 (rusqlite 0.40) needs cfg_select; nixpkgs pin was on
rustc 1.94. Bump workspace MSRV to 1.95 to match.
2026-05-29 23:06:00 +03:00
cucadmuh 9925771a86 feat(browse,cover,perf): lazy catalogs, cover pipeline, and Performance Probe (#890)
* fix(cover): per-server cache stats and cover pipeline perf probe

Stop count_cached_cover_ids from borrowing sibling bucket counts so
Settings progress no longer attributes one server's disk cache to another.

Add cover pipeline queue stats (ui ensure queue, ui vs lib HTTP/WebP
semaphores) to Performance Probe overlay, with clearer ui/lib labels.

* fix(browse): stabilize in-page infinite scroll and cap cover memory caches

Extract useInpageScrollSentinel for album grids and song lists so sentinel
reconnects do not spam loadMore during scroll. Harden useAlbumBrowseData with
sync loading refs, tighter root margin, and hasMore termination when dedupe
adds nothing. Pause middle-priority cover work during SQL pagination and bound
diskSrc/resolve/ensure tail maps on long cold-cache sessions.

* refactor(browse): unify in-page infinite scroll hooks and sentinel UI

Extract shared transport (viewport ref, async pagination guards, client slice)
and InpageScrollSentinel so Albums, New Releases, Artists, and song lists
use one pagination pattern instead of duplicated IntersectionObserver wiring.

* fix(browse): prioritize album SQL pagination over cover ensures

Pause the entire webview ensure pump during grid page fetches, resume after
SQL settles, add cover-queue backpressure before load-more, and re-probe the
sentinel when pagination finishes so cold-cache scroll does not stall.

* fix(browse): unblock covers, SQL spawn_blocking, and pagination retry

Pair grid-pagination hold begin/end on stale fetches, resume the ensure pump
after SQL, retry load-more when the cover backlog drains while the sentinel
stays visible, and run album browse SQL on spawn_blocking so Tokio stays
responsive during library_advanced_search.

* feat(browse): All Albums client-slice scroll on local index (Artists-style)

Load the filtered catalog once from SQLite when the library index is ready,
then grow the visible grid with useClientSliceInfiniteScroll instead of
offset SQL pagination per scroll. Network-only servers keep page mode.

* fix(browse): lazy local catalog chunks instead of full 50k SQL fetch

All Albums slice mode now loads 200 albums first, shows the grid immediately,
then appends catalog chunks in the background as the user scrolls. Avoids the
blocking library_advanced_search that hung the app on large libraries.

* fix(browse): keep album covers loading during active grid scroll

Pass high ensure priority and the in-page scroll root to AlbumCard on All
Albums, stop pausing cover traffic for background catalog chunks, and never
trim high-priority ensure jobs from the queue during scroll bursts.

* fix(cover): viewport priority tiers and unstick ensure invoke pump

All Albums uses IO-driven high/middle instead of blanket high; release
only on unmount so scroll-ahead jobs are not dropped on reprioritize.
Ensure queue shares one Rust flight per cover id, attaches duplicate
waiters without consuming invoke slots, and times out wedged calls.
Warm the first viewport slice on large grids; acquire CPU permits before
spawn_blocking in cover_cache to avoid blocking-thread deadlocks.

* fix(cover): wire in-page scroll root on New Releases and Lossless grids

AlbumCard IO uses the same viewport id as VirtualCardGrid so cover
ensure priority tracks visible in-page rows like All Albums.

* fix(browse): lazy local artist catalog in 200-row chunks

Replace runLocalBrowseAllArtists bulk fetch with paginated local-index
chunks so large libraries do not hang on open; preserve text search,
starred, letter filter, and client-slice scroll behavior.

* feat(perf): add RSS and thread CPU groups to Performance Probe

Extend performance_cpu_snapshot with process RSS (psysonic + WebKit
children) and in-process thread CPU breakdown. Classify tokio-rt-worker
and tokio-* workers separately from glib, audio/pipewire, reqwest, and
other misc threads (Linux /proc only).

* feat(perf): redesign Performance Probe with tabs, pins, and overlay layout

Split the probe into Monitor (live metric cards, per-metric overlay pins,
corner and opacity controls) and Toggles (diagnostic tree). Share live
polling via perfLiveStore; label analysis/cover pipeline blocks in the HUD.

* feat(perf): overlay sparklines, macOS CPU/memory, and sync fixes

Add 1-minute pinned-metric sparklines with right-aligned growth and a shared
poll clock. Enable macOS performance snapshots via sysinfo. Fix overlay infinite
loop from unstable history snapshots, bar/sparkline tick jitter, and probe bar
rescale flicker.

* docs: CHANGELOG and credits for PR #890

* perf(probe): scoped CPU poll, adjustable interval, lazy thread groups

Read only psysonic + WebKit children instead of the full process table;
macOS uses sysctl host CPU and refreshes cached child PIDs. Add 0.5–10s
poll slider (default 2s). Collect /proc thread groups only when the
Monitor section is open or a thread metric is pinned.

* feat(perf): three-way overlay mode switch (off / FPS / pinned)

Add Monitor control for overlay visibility: hidden, FPS-only, or pinned
metrics from Monitor. Live CPU poll runs only in pinned mode with live pins.
2026-05-29 04:40:31 +03:00
Frank Stellmacher 839c438a6d fix(cover): sanitize server_index_key so Windows :port URLs work (#889)
* fix(cover): sanitize server_index_key on disk so Windows accepts ":port" URLs

`serverIndexKeyFromUrl` (frontend) strips the URL scheme and leaves the rest of
the host as the index key — for a Navidrome instance running on the default
`:4533` port that is `host:4533/...`. On Linux/macOS the `:` is fine as a path
segment; on Windows `CreateDirectory` rejects the whole path with
`ERROR_INVALID_NAME` (os error 123). Result: every `cover_cache_ensure` and
`cover_cache_peek_batch` rejected its promise and the album / now-playing /
mainstage / lightbox surfaces stayed blank. Empirically verified — switching
the active server to a colon-free reverse-proxy URL made the covers load again
without any other change.

Centralize the fix in a new `cover_server_dir(root, key)` helper next to the
existing `cover_entity_relative_dir`: it runs `sanitize_path_segment` on the
server key the same way kind/entity ids are already cleaned, so `host:4533`
becomes `host_4533` and embedded URL paths collapse into one flat bucket
instead of nested directories. Every call site that wants the server bucket —
`cover_dir`, `count_cached_cover_ids`, `dir_usage_for_server`, the clear-server
command, and `clear_cover_fetch_failures` in the backfill worker — now goes
through it.

The on-disk layout changes (no more colons, no more nested URL paths), so bump
`LAYOUT_STAMP` to `canonical-segment-v4`. The existing stamp-mismatch sweep at
startup wipes the legacy buckets — users with a previously-working
(colon-free) layout rebuild the cache lazily as they browse. Library, offline,
and hot-cache data are not touched.

Adds two unit tests covering the sanitization on `cover_server_dir` and the
`cover_dir` passthrough.

Follow-up to #878 (which introduced `cover_cache_layout.rs` with
`sanitize_path_segment` applied only to kind/entity_id).

* chore(windows): silence dead_code warnings in debug taskbar_win build

`lib.rs` gates `taskbar_win::init` on `cfg(not(debug_assertions))` (PR #866 —
debug runs alongside an installed release instance and must not fight it for
the taskbar subclass). The `update_taskbar_icon` command still ships in debug
and early-returns until `init` populates the COM/HWND atomics, so the
init-only helpers (icon HICONs, button IDs, subclass plumbing, `make_buttons`,
`subclass_proc`, `init` itself) all look unused — 14 dead_code warnings on
every Windows debug `cargo build`.

File-level `#![cfg_attr(debug_assertions, allow(dead_code))]` suppresses those
warnings only in the debug profile. Release builds keep the strict dead-code
check, so a real removal would still surface there.

* docs(release): CHANGELOG for windows cover-cache server-key fix (PR #889)
2026-05-28 23:13:27 +02:00
Frank Stellmacher 455aec4def feat(discord): show track title in Discord member list (configurable name template) (#885)
* feat(discord): override activity name in member list with track title (configurable template)

The Discord member list and the collapsed Rich Presence card display the
activity's `name` field next to the music icon. Without an override
Discord falls back to the registered application name ("Psysonic"), so
the line reads "Psysonic" while every track plays instead of the actual
track title that comparable players show.

Add a fourth user-configurable template `discordTemplateName` (Settings ->
Integrations -> Discord Rich Presence). The Rust side passes it through
`Activity::new().name(...)` when the rendered template is non-empty; an
empty template falls back to Discord's default application name. Default
template is "{title}".

Tauri boundary: additive optional `nameTemplate` field on the existing
`discord_update_presence` command. Existing call sites that omit it keep
working -- the Rust handler applies the same "{title}" fallback.

* i18n(settings): discord name template label in all locales

Adds the discordTemplateName label across en, de, fr, es, nl, nb, ro,
zh, ru -- shown next to the new "User list line (name)" template input
under Discord Rich Presence.

* docs(release): CHANGELOG and credits for discord name template (PR #885)
2026-05-28 20:32:41 +02:00
cucadmuh 091e61f7a5 fix(analysis): decode Opus in waveform and loudness pipeline (#883)
* fix(analysis): decode Opus in waveform and loudness pipeline

Playback already registered symphonia-adapter-libopus; the analysis crate
used the default Symphonia codec registry without Opus, so .opus tracks
failed at decoder creation. Mirror the audio codec registry, pass format
hints from file suffix and OggS sniffing, and thread hints through the CPU
seed queue.

* docs: CHANGELOG and credits for PR #883 (Opus analysis decode)
2026-05-28 13:07:02 +03:00
cucadmuh 8004ec559c fix(analysis): persist library backfill scan phase across coordinator ticks (#882)
* fix(analysis): persist backfill scan phase and cursor across coordinator ticks

Keep HashBpmGaps progress when the candidate SQL page is empty only because
id > cursor; store scan phase in the native worker so each tick does not
restart from Candidates and rescan the first ~10k ready tracks.

* docs: CHANGELOG and credits for PR #882 backfill scan phase fix

* fix(analysis): move backfill tests below production code for clippy

Clippy items_after_test_module requires all non-test items before mod tests.
2026-05-28 11:26:24 +03:00
cucadmuh b24a7fc5cb fix(analysis): native library backfill coordinator for advanced strategy (#881)
* feat(analysis): native library backfill coordinator for advanced strategy

Move advanced analytics scheduling from the webview loop into a Rust
background worker (configure + spawn_blocking batch/enqueue), matching
cover backfill. Scan hash+BPM gap tracks instead of the full library;
suppress low-priority analysis UI events; limit loudness refresh IPC to
the playback window.

* fix(analysis): flat backfill configure IPC and restore probe track-perf

Use flattened Tauri args like cover backfill so release/prod invoke works;
keep emitting analysis:track-perf for library low-priority work so Performance
Probe tpm/last-track stats update while waveform/enrichment UI events stay suppressed.

* chore(analysis): fix clippy and drop duplicate TS backfill policy

Allow too_many_arguments on library_analysis_backfill_configure for CI;
remove unused frontend batch API and TS watermark helpers now owned in Rust;
clarify analysis_emits_ui_events comment for low-priority track-perf.

* docs: CHANGELOG and credits for PR #881 native analysis backfill
2026-05-28 10:49:31 +03:00
cucadmuh df3533bb5a fix(cover): Windows thumbnails, tier fallback, PNG decode, coverArt id (#878)
* fix(cover): tier fallback for sparse surfaces and Windows asset URLs

Sparse UI (player bar, queue) now reads disk covers via the same tier
ladder as dense grids, so a warm 800.webp satisfies a 128px request.
Reject non-asset convertFileSrc results on Windows, widen Tauri asset
scope, and seed ladder keys on cover:tier-ready. applyDiskPath uses
seedGridDiskSrcCache only to avoid notify/subscriber infinite loops.

* fix(artist): top-track thumb uses album coverArt already warm in grid

Song coverArt ids often differ from album cover ids (e.g. Octastorium in
the grid vs empty track thumb). Prefer the album row's coverArt on artist
pages and ensure high priority for 32px dense cells.

* fix(cover): albumId for playback/queue; no broken img until disk URL ready

Prefer albumId over track-id coverArt (Navidrome). Wire queue to CoverArtImage
with playback scope. CoverArtImage renders a placeholder div until asset src
exists to avoid the browser broken-image icon.

* fix(test): add song id to resolveArtistPageSongCoverArtId fixture

Pick<SubsonicSong, …> requires id; fixes tsc in CI/build.

* fix(cover): resolve albumId for Now Playing and artist top tracks

Prefer albumId when album.coverArt echoes track id; use sparse surface
on artist suggestion thumbs; apply resolveSubsonicSongCoverArtId across
playback surfaces (Now Playing, fullscreen, mobile, mini).

* fix(cover): decode PNG from Subsonic before WebP tier encode

Enable `png` in the image crate — some servers return PNG cover art;
failed decode left `.fetch-failed` and empty thumbs for those albums.

* refactor(cover): consolidate cover id resolution and align tests

Move resolveSubsonicSongCoverArtId helpers to src/cover/resolveCoverArtId.ts
with resolvePlaybackTrackCoverArtId for player surfaces; co-locate tests;
fix FullscreenPlayer expectations for albumId-first resolution.

* docs: CHANGELOG and credits for PR #878

* fix(cover): keep per-track coverArt when distinct from song id

Address PR #878 review (b): albumId only when coverArt is missing or
echoes track id; pin case with unit test; comment isRawFsPath symmetry.

* chore(cover): address PR #878 review nits (scope, tests, rename)

Narrow asset scope to cover-cache dirs only; add diskSrcCache Windows-path
tests; rename ArtistTopTrackCover; CHANGELOG symptom-first wording.

* fix(cover): restore asset scope to app data dirs (Windows regression)

$APPDATA/cover-cache/** did not match Tauri scope resolution — covers
were blocked after load. Use $APPDATA/** and $APPLOCALDATA/** (no $DATA).

* fix(cover): Windows asset URLs — restore DATA scope, path normalize

Regression after review nits: dropped $DATA/** and strict isAssetProtocolUrl
blocked valid http://asset.localhost URLs on Windows. Normalize C:/ paths
before convertFileSrc; CoverArtImage/Hero hide broken img on load error.

* fix(cover): disk peek fallbacks when cache folder id differs

Small surfaces resolve albumId while cover-cache often stores WebP under
track id or album.coverArt from the grid. Peek batch now tries legacy ids;
playback scope resolves server index key by URL key, not UUID-only lookup.

* fix(cover): Navidrome al-* vs mf-* disk id mismatch

UI used mf-* coverArtId while library backfill only cached al-* folders.
Prefer album id for display/peek when coverArt is mf-*; backfill now
queues both distinct album_id and cover_art_id values.

* fix(cover): mf→al disk peek when mf folder missing in cache

Navidrome Subsonic often returns mf-* coverArtId while backfill only
creates al-* folders. Peek mf first, then al-* from hints; load albumId
from library when Subsonic omits it; ensure fallback uses al-* id.

* feat(cover): CoverArtRef, segment disk layout, library-index backfill

Normalize cover caching around stable entity ids from the local library
and Navidrome fetch ids. Disk paths live in psysonic_core::cover_cache_layout
(album/<entityId>/); UI uses CoverArtRef with cacheEntityId + fetchCoverArtId.

- Remove SQLite/mf peek helpers (diskPeekIds, peekCoverOnDisk, mergeDiskIdHints)
- Backfill reads album/artist rows from library SQLite (bare Navidrome ids ok)
- Use stored cover_art_id for HTTP; per-disc dirs only when discs differ
- Migrate call sites to albumCoverRef / albumCoverRefForPlayback

* feat(cover): central CoverEntry resolver (artist, album, track)

Add resolveEntry.ts and Rust CoverEntry helpers as the single source of
truth for cache_entity_id vs fetch_cover_art_id. ref.ts delegates to them;
resolveCoverArtId becomes a thin compatibility shim.

* feat(cover): resolve cover entries from local library index

Add library_resolve_cover_entry IPC and cover_resolve.rs so album,
artist, and track covers use SQLite cover_art_id + disc detection.
TypeScript helpers in resolveEntryLibrary.ts prefer the index over
live API fields when rows exist.

* feat(cover): library-first hooks for grids and playback UI

Add useAlbumCoverRef, useArtistCoverRef, useTrackCoverRef, and
usePlaybackTrackCoverRef — sync fallback then SQLite index upgrade.
Wire album/artist cards, album header, song card, and all player
surfaces to resolve covers from the local library when indexed.

* feat(cover): complete library-first migration across all UI surfaces

Add Album/Artist/TrackCoverArtImage, useLibraryCoverPrefetch, and batch
resolve helpers. Migrate grids, search, home, playback sidecars, warm
peek, playlists, and share flows to hooks that upgrade from SQLite.
Backfill normalizes album rows through cover_resolve; document paths in
COVER_PATHS.md. Radio remains a deliberate non-library exception.

* fix(cover): stop render loop from unstable serverScope in library hooks

Default param `{ kind: 'active' }` created a new object every render, so
every grid cell re-ran library_resolve IPC and setState in a loop. Use
COVER_SCOPE_ACTIVE singleton, coverScopeKey deps, and guarded sync updates.

* chore(cover): remove COVER_PATHS.md from app tree (lives in workdocs)

Audit doc is team spec — see workdocs 2026-05-cover-art-pipeline/cover-paths-audit.md.

* fix(cover): unstick library backfill after route changes (PR #870 regression)

useCoverNavigationPriority cleanup called beginNavigation instead of end,
leaking navigationHoldDepth so ui_priority_hold never released and backfill
never downloaded. Also skip disk check after cover_resolve normalization.

* fix(cover): segment progress, cap backfill CPU, include artists in catalog

Progress and disk size now scan album/ and artist/ segments (canonical 800.webp).
Prune legacy flat server/al-* dirs on startup and backfill pass.

Backfill: max 2 concurrent ensures; JPEG decode and WebP encode run on the
blocking pool behind a shared 2-permit semaphore so Tokio workers stay cool.

Artists were missing because the catalog only read the empty artist table;
add distinct artist_id from track and album rows. Paginate with a composite
(kind, id) cursor so album and artist rows are not skipped.

* fix(cover): drop legacy prune; backfill per-disc and artist catalog

Remove prune_legacy_* and cover_cache_catalog_entry — layout is only
cover_dir (album|artist segments); stale flat dirs clear on LAYOUT_STAMP change.

Backfill: artists from track/album artist_id; expand albums to per-CD mf-* slots
when discs differ; fix resolve_album_cover_entry when album row is missing.

* fix(cover): reduce library IPC storms and fix multi-disc player art

Skip per-row library_resolve on live search and artist album grids; warm
grids from API coverArt after mount instead of blocking layout. Dedupe and
cap concurrent library_resolve calls. Restore per-disc cache keys in the
player and queue when track mf-* art differs from the album bucket.

* fix(cover): skip library resolve on advanced and full search rows

Use API coverArt for album/artist rails and lazy viewport artwork so
result pages do not fire hundreds of library_resolve IPC calls at once.

* fix(cover): default libraryResolve off for browse grids and rails

Skip per-card library_resolve on album/artist/song browse UI by default;
keep it on album/artist headers, playback queue rows, and orbit approval.

* fix(cover): split UI/backfill CPU pools and restore mainstage hero carousel

Library backfill no longer shares the 2-permit JPEG/WebP semaphore with
visible cover ensures. Hero initializes albums from props, re-binds scroll
visibility after mount, updates backdrop on slide change, and uses library
resolve for correct cover art on the banner.

* fix(analysis): resume full-library scan after candidates phase

Reset the SQL cursor when entering full-library mode so tracks with
partial analysis are not skipped. Tighten TS backfill completion and
CPU queue watermarking; align cover-cache key tests with album-scoped
storage keys.

* fix(library): remove useless map_err in cover_resolve (clippy)

CI treats clippy::useless-conversion as error on rusqlite optional() chains.

* fix(cover): satisfy clippy on cover_cache_ensure IPC args

Pass CoverCacheEnsureArgs as a single Tauri parameter instead of nine
positional fields; align frontend invoke payload with { args }.
2026-05-28 03:15:08 +03:00
cucadmuh 06da15caf3 feat(albums): combined browse filters, favorites reconcile, and session restore (#876)
* feat(albums): persist browse sort and genre filter for the session

Keep Albums sort and genre selection in an in-memory Zustand store so
navigating into album detail and back no longer resets browse context.
Fixes #875 (partial).

* feat(albums): restore browse filters only when returning from album detail

Keep sort in the session store for the app lifetime. Stash genre, year,
compilation, starred, and lossless filters when leaving Albums for an
album page and restore them on POP (back). Clear the stash when opening
Albums from elsewhere via sidebar navigation.

* feat(albums): filter quick-clear chips; fix lossless A–Z sort

Add inline × on active toolbar filters (genre, year, favorites, lossless,
compilations) without opening the popover. Route lossless album browse through
advanced search with album sort clauses on Albums and Lossless Albums; client-sort
on the network fallback path.

* fix(albums): apply year filter when only from or to is set

Resolve open-ended year bounds with gte/lte on the local index and partial
fromYear/toYear on Subsonic. Update the year filter chip label for single-bound
ranges.

* refactor(albums): combine browse filters in one query (genre + year + lossless)

Replace mutually exclusive load/loadFiltered branches with fetchAlbumBrowsePage
that ANDs server-side filters on the local index (genre OR union). Network
fallback applies year bounds after genre fetch. Always show sort while a year
filter is active.

* fix(albums): load favorites filter server-side instead of scanning all albums

Starred on Albums was client-only: each page was filtered locally and
pendingClientFilterMatch kept paginating the full catalog. Query starred
albums via the local index or getAlbumList(starred); apply overrides only
for in-session star/unstar.

* feat(library): local album/artist favorites via patch-on-use

Mirror album- and artist-level stars into the library index (library_patch_album,
library_patch_artist, migration 010). Albums and Artists favorites browse use
entity starred_at only; normal album catalog stays track-derived so patch stubs
do not hide the library. Keep album year on favorite cards via track COALESCE,
patch metadata, and safer raw_json merge.

* fix(library): reconcile album/artist stars from server, drop stubs

Favorites browse uses getAlbumList/getStarred2 as source of truth.
library_reconcile_*_stars clears local stars removed elsewhere; patch-on-use
updates existing rows only (no stub INSERT). Reconcile on favorites load and
after star/unstar in-app.

* feat(albums): favorites reconcile, filter combos, and back-navigation fix

Album browse keeps filter state when returning from album detail (POP stash
read on mount, request-generation guard against stale loads). Favorites use
getStarred2 as source of truth: reconcile album.starred_at in the local index
(UPDATE only, no stub rows), with a small session cache for instant paint.

Combine favorites with lossless or genre via restrictAlbumIds in advanced
search. Remove album/artist patch-on-use and migration 010; artist favorites
stay network-only. Track patch-on-use unchanged.

* feat(albums): catalog year bounds and genre list narrowed by filters

Year filter spinners use min/max years from the local track index (not
1900); "from" starts at oldest, "to" at newest, values clamp to catalog.

When year, lossless, favorites, or compilation filters are active, the genre
picker lists only genres present on matching albums (other filters applied,
genre excluded). Adds library_get_catalog_year_bounds for the year UI.

* feat(albums): debounce year filter and show genre album counts

Debounce year range changes by 350ms before reloading browse. Genre picker
lists album counts per genre (from getGenres or from albums matching other
active filters) and sorts genres by count descending.

* fix(albums): compilation filter detection and scan cap

Recognize OpenSubsonic compilation flags (compilation, releaseTypes) so
client-side comp filters work on local index rows. Cap background pagination
at 500 albums when no matches are visible and show empty state instead of
spinning through the whole catalog.

* feat(albums): filter compilations via local library index

Add `compilation` to advanced search (album entity): reads OpenSubsonic
flags from album raw_json. Album browse passes compFilter into
library_advanced_search when the index is ready; network-only path keeps
client-side filtering with the existing scan cap.

* fix(albums): apply compilation filter on track-grouped index browse

Album browse uses track aggregation, so compilation clauses were skipped.
Filter track raw_json (same SQL as album), merge album flags at sync, and
always run the client-side compilation pass as a fallback.

* refactor(albums): split browse modules and extract browse_support commands

Move album browse fetch/filter logic into focused modules and useAlbumBrowseData;
register reconcile/year-bounds Tauri commands from browse_support. Trim dead helpers
and barrel exports; fix typecheck in compilation tests.

* chore: note PR #876 in CHANGELOG and settings credits

* fix(albums): show catalog min/max in partial year filter chip label

When only from or to year is set, the active chip now reads e.g. 1990–2020
instead of 1990– or –2025, using indexed catalog bounds when available.
2026-05-27 12:32:20 +03:00
Frank Stellmacher d353482ac5 fix(analysis): cap HTTP backfill on CPU-seed pipeline load (#873)
* fix(analysis): cap HTTP backfill on CPU-seed pipeline load

Aggressive library analytics with multiple workers grew RAM unbounded:
HTTP downloads finish in ~500 ms, but Symphonia decode + R128 loudness
take seconds, so decoded `Vec<u8>` track buffers piled up in the
CPU-seed queue while the HTTP worker kept fetching. On large libraries
the process eventually saturated memory and forced the system into swap.

Backpressure: the HTTP backfill worker now checks the CPU-seed pipeline
depth (queued + running) against a `workers * 2` cap before popping the
next job. High-priority (now-playing) jobs bypass the cap so playback
prefetch is never starved. The CPU-seed worker pings the HTTP queue
after every completed decode, so the gate releases the instant decode
catches up.

The frontend backfill loop already idles at its own watermark when the
HTTP queue is satisfied — this change keeps the pipeline self-limiting
end-to-end even when callers (library top-up, playlist enqueue) submit
faster than decode drains.

Tests cover cap scaling, the floor for workers=1, the idle decision,
and the high-priority bypass.

* docs(changelog): analytics aggressive scan no longer eats memory (#873)
2026-05-27 00:21:51 +02:00
cucadmuh a8cfff0b62 feat(library): local lossless index, filters, and conserve dedicated page (#871)
* feat(library): local lossless index, filters, and conserve dedicated page

Add SQLite-backed lossless album browse and advanced-search filtering,
wire All Albums and artist/album lossless drill-down mode, and hide the
standalone /lossless-albums nav entry from sidebar visibility settings
(conserved route, default off).

* docs(release): note lossless local index in CHANGELOG and credits (PR #871)
2026-05-27 00:02:46 +03:00
cucadmuh 418b25914a feat(cover): unify cover pipeline and stabilize mainstage/now-playing (#870)
* chore(cover): scaffold cover module and rust cover_cache stub

Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands
in cover_cache/mod.rs (no-op returns until phase B).

* feat(cover): add unified cover module and tier resolver (phase A)

Wave 1A: tiers, storage keys, resolveJs with cold/sibling races,
useCoverArt, CoverArtImage, layoutSizes, playback scope helpers,
coverSiblings tier ladder, deprecated shims on subsonicStreamUrl.

* feat(cover): rust disk cache and tier-ready events (phase B)

Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch,
cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests.

* feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C)

Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB
cover key clear, prefetch registry drain, MainApp wiring.

* feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D)

Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense,
coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid.

* feat(cover): migrate sparse surfaces and integrations (phase E sparse)

Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover,
MPRIS/Discord/export integrations, playback chrome and detail heroes.

* feat(cover): revalidation scheduler and disk pressure gate (phase E+)

Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure,
useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks.

* docs: CHANGELOG and credits for cover art pipeline PR #869

* fix(cover): stop webview getCoverArt storm on dense grids (429)

Dense surfaces no longer put rotating getCoverArt URLs in img src; load
disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead
of invalidating IDB. Throttle background prefetch and cap Home registry.

* fix(cover): omit empty img src until cover URL is ready

React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB
resolves; queue current track shows placeholder when src is still empty.

* fix(cover): disk cache by host index key, parallel ensure, asset protocol

Bind cover storage to serverIndexKey (library host), rename cover IPC/events,
fix REST base URL and Tauri flat args, enable protocol-asset for disk paths,
add prioritized ensure queue, and wipe legacy profile-UUID cache once.
Limit Vite dep scan to index.html so research/target HTML is ignored.

* fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage

Encode lossy WebP (~82), write only missing tiers, library cover backfill,
and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP
asset protocol; no IDB fallback when server is up. Session Home feed cache
with warm peek on return; BecauseYouLike deduped cover hook and high prefetch.

* feat(cover): per-server cache strategy and native library backfill

Move cover disk cache settings to Offline & cache with Lazy/Aggressive
per server, per-server clear, and no size cap. Run full-catalog backfill
on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes
without flooding the webview). Drop global prefetch limits from auth store
and waveform clear from the offline storage block.

* fix(build): CSP connect-src for Subsonic API; quieter prod nix build

Prod webview blocked axios ping after cover CSP (missing connect-src).
Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo.

* fix(cover): complete WebP ladder in library bulk backfill

Aggressive backfill now writes all derived tiers (128–800), skips IDs
only when the full ladder exists (not 800 alone), avoids fetch-failed
markers on bulk HTTP errors, and stops the pass when the active server
changes.

* fix(cover,home): navigation-priority backfill and Because You Like UX

Pause library cover backfill while navigating; split peek/ensure traffic
so grids and rails win over bulk work. Disk src lookup, grid warm hooks,
and non-blocking mainstage prime for faster visible covers.

Because You Like: session snapshot, staggered horizontal skeleton row,
text hidden until cover is ready, and layout aligned with loaded cards.

* feat(random-albums,library): local-first album fetch + cover art pipeline

Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM()
LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер
исчезает практически мгновенно; сеть используется только как фолбэк.

- advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок
- browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums
- RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра
  и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve
  прогревает следующий батч в фоне после каждого Refresh

Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue,
diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers,
useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие
компоненты/хуки).

* fix(random-albums): prevent double-load on Zustand rehydration

useEffect([selectedGenres, load]) fired twice on every visit: first with
default store values, then again ~50 ms later when Zustand rehydrated
mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage.

Previously this was invisible because the first network fetch took ~1.5 s,
so loadingRef.current was still true on the second fire. With the new
local-first SQLite path the first load completes in ~50 ms, leaving the
guard cleared before rehydration triggers a second random batch.

Fix: ref-pattern — keep loadRef.current fresh on every render, effect
depends only on selectedGenres. Manual Refresh and genre-filter changes
still call the latest closure correctly.

* fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash

fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что
вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило
всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку
примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает
сетевые запросы к рейтингам артистов).

- fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при
  consume резерва через primeAlbumCoversForDisplay
- reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums
  (аналогично non-reserve пути; при уже прогретом кэше — мгновенно)

* feat(because-you-like): reserve-first pattern — instant display on return visits

Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку
мгновенно, вместо spinner → сетевые запросы → контент.

Архитектура:
- resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из
  замыкания useEffect); читают текущий localStorage, возвращают
  { anchor, recs, nextAnchorHistory, nextPicksHistory }
- fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу
  после отображения результата, кладёт следующий батч в _becauseReserve.
  Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не
  нужен); они прогреваются через primeAlbumCoversForDisplay при consume.
- useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton
  (контент появляется без мигания)
- useEffect: reserve-first path — consume → primeCovers → setState → fill;
  full-fetch path сохранён как fallback при первом визите или промахе

Поведение:
- Визит 1: full fetch (как раньше) → показ → fillReserve R1
- Визит 2+: consume R1 → мгновенный показ → fillReserve R2
- При сетевом сбое: restore из session cache (как раньше)

* fix(because-you-like): initialise state from reserve — no skeleton flash on remount

При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и
показывал skeleton на один тик до того как useEffect отработает.

Теперь useState() использует lazy initializers, которые читают _becauseReserve
прямо в первом рендере: если reserve валиден — state сразу refreshing=false,
anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache
(из предыдущего показа) и появляются без дополнительных запросов.

useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton
только если reserve отсутствует (для случая navigation без ремаунта).

* fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival

Lazy initializers не могли применить reserve при первом рендере, потому что
mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[]
на первом рендере, poolKey не совпадает с reserve.

useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает):
когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно
до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false.
При отсутствии reserve — сбрасывает в skeleton как прежде.

* fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount

Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect,
поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным
poolKey → mismatch → lazy initializers запускали skeleton.

Теперь двухуровневый fallback без зависимости от poolKey:
1. reserve (serverId + poolKey совпадают) → мгновенный новый батч
2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент
   доступен сразу с mount, обновляется тихо в фоне
3. skeleton → только при полном отсутствии данных (первый визит)

Применяется одинаково в lazy useState initializers, useLayoutEffect и
full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент).

* fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool

Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey=''
→ mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал
mostPlayed.

Исправления:
- BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния
  на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать
  reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms
- hasValidReserve: проверяет только serverId
- fillBecauseReserve: убран poolKey из сигнатуры и хранилища
- useEffect: guard pool.length === 0 → возврат без fetch/consume;
  effect перезапустится когда pool заполнится (реальные deps изменятся)
  → reserve применяется из useLayoutEffect ещё до pool, без стале-флэша

Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate)
> skeleton (только первый визит)

* fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch

Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps
useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId,
потом mix-rating значения. Это вызывало двойной запуск эффекта:
- Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра
- Второй запуск (после rehydration): cache miss или повторный fetch с
  реальными mix-настройками → ~500ms → новый набор

Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор
первые ~500ms при каждом возврате на Mainstage.

Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth()
читается внутри эффекта через getState() — всегда актуальные значения без
пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch,
но не вызывают двойной запуск при rehydration.

* feat(home): local-first discover songs via SQLite ORDER BY RANDOM()

Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков)
в browseTextSearch.ts — использует libraryAdvancedSearch с sort random,
field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку.

В Home.tsx: discoverSongs теперь сначала пробует локальный индекс,
и только при недоступности (индекс не готов, ошибка) падает обратно
на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки
берутся из SSD вместо сети.

* fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits

Причина: Home.tsx размонтируется при навигации. При возврате первый рендер
всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом
useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым
состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]).

Решение: getInitialHomeFeed() читает homeFeedCache синхронно через
useAuthStore.getState() (не hook) в lazy useState initializers. К моменту
повторного визита store уже rehydrated — все state получают кэшированные
данные до первого рендера.

Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot
в useEffect когда state уже заполнен — иначе новые ссылки на массивы
вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными.

* fix(mainstage): keep refresh without return flicker

Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation.

* fix(mainstage): stabilize because rail and hero background framing

Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount.

* fix(now-playing): prewarm track data and prevent stale carry-over

Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track.

* fix(cover,test): refresh playback scope and default tauri cover mocks

Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI.

* fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks

Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs.

* test(cover): hoist mocked useCoverArt and clean EOF whitespace

Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line.

* fix(cover): align playback ensure auth and harden backfill retry flow

Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch.

* fix(ci): resolve clippy lint and update frontend node runtime

Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20.

* chore(settings): simplify cover and analytics strategy copy

Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
2026-05-26 19:35:08 +03:00
cucadmuh 91e7195e0f fix(library): scoped live search FTS, race, and multi-server advanced search (#868)
* fix(library): scoped live search FTS, race, and multi-server advanced search

Scope track_fts live search to active server_id so multi-server libraries
no longer show empty or wrong-server hits. Match Navidrome-style any-word
prefix matching and GROUP BY artist/album dedupe on track_fts only.

Frontend: parallel local vs search3 race (empty waits, 8s network timeout),
merge supplemental hits after both settle, debug via Settings → Logging →
Debug (frontend_debug_log). Advanced Search FTS subqueries use the same
server scope fix.

* docs: CHANGELOG and credits for PR #868 live search fix
2026-05-25 04:20:02 +03:00
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
cucadmuh 67b1dc790b fix(library): sweep orphan tracks after successful full resync (#861)
* fix(library): sweep orphan tracks after successful full resync

Full resync only upserted server tracks and left server-deleted rows
live in SQLite. Add IS-7 mark-and-sweep via track.resync_gen: stamp
rows on ingest during a re-sync, soft-delete unstamped rows when IS-6
completes. Delta tombstone reconcile is unchanged.

* docs(release): CHANGELOG and credits for PR #861 resync orphan sweep

* fix(library): satisfy clippy unnecessary_min_or_max in orphan sweep

execute() returns usize; drop redundant max(0) so CI clippy -D warnings passes.
2026-05-23 00:58:37 +03: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 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
cucadmuh d3fc5c91fc fix(audio): resume playback seamlessly on output device switch (#743) (#765)
* fix(audio): resume playback seamlessly on output device switch (#743)

When the OS default output device changed (Bluetooth, USB DAC, HDMI),
rodio/cpal had to reopen the stream, which silently stopped the active
sink and left the engine with no playback — causing the track to restart
from the beginning (or not restart at all after the null-payload bug).

Root cause (null-payload):
  audio_set_device emitted () (unit), which Tauri serialises to JSON
  null. The null-guard added for the "Rust handled replay internally"
  signal was therefore also triggered by manual device switches, so
  playTrack was never called and the engine stayed silent.
Fix: audio_set_device now emits the current playback position as f64
(null remains the exclusive "Rust handled" sentinel).

Rust-side seamless replay (device watcher path):
  reopen_output_stream captures a ResumeSnapshot before the blocking
  stream reopen, then calls try_resume_after_device_change, which:
    - local files (psysonic-local://): reopens the file, builds a new
      seekable source, seeks to the saved position — zero frontend
      round-trip, no audible restart.
    - fully-cached HTTP tracks (stream_completed_cache / spill file):
      replays from the in-memory or on-disk bytes — no re-download.
    - partial downloads / radio / paused: returns false → falls back to
      the existing frontend path (seekFallbackVisualTarget + playTrack).

Frontend (useAudioDeviceBridge):
  null payload  → Rust already resumed; skip playTrack.
  number payload → call playTrack + seekFallbackVisualTarget(position).

Visibility: pub(super) → pub(crate) on the play_input / progress_task
helpers that device_watcher.rs needs to call directly.

* refactor(audio): split device_resume module; add bridge tests

Split the 551-line device_watcher.rs (above the ~500-line soft ceiling)
by extracting ResumeSnapshot and try_resume_after_device_change into a
dedicated device_resume.rs module. device_watcher.rs is now 320 lines,
device_resume.rs 258 lines.

Add useAudioDeviceBridge.test.ts: 9 characterisation tests covering the
null-payload guard ("Rust replayed, skip playTrack"), the seek-fallback
path (position > 0.5 s sets seekFallbackVisualTarget), paused-device
branch (resetAudioPause), and the device-reset event path.

* chore(changelog): add entry for #765 (device switch seamless resume)
2026-05-18 01:01:23 +03:00
cucadmuh 33ffb94083 fix(isomp4): fix M4A moov-at-end probe failures and streaming fallback (#757)
* fix(stream): defer M4A probe until moov tail or fast-start prefix

Ranged moov-at-end M4A started Symphonia format probe as soon as ~384 KiB
linear data arrived, before the parallel tail prefetch filled the moov
atom — probe hit end of stream and skipped the track. Wait for tail_ready
or detect fast-start moov in the prefix; do not arm playback from linear
bytes alone when tail prefetch is active.

* fix(isomp4): skip EOF-spanning mdat after moov-at-end is parsed

Second-pass header scan with moov already loaded still tried to read
through mdat→EOF on RangedHttpSource holes, causing format probe
end of stream. Re-check play generation after moov wait.

* fix(isomp4): fix AtomIterator overread after seek in patched demuxer

`AtomIterator::new_root(reader, len)` treats `len` as bytes available
from the current `reader.pos()`, not the absolute file length. After
`mss.seek(resume_at)` we were passing the absolute `total_len`, so the
iterator thought there were `total_len` bytes left and tried to read
past EOF on the next iteration, returning "end of stream".

Fix: pass `total_len.map(|tl| tl.saturating_sub(resume_at))` (remaining
bytes from the new position) in both branches:
- `moov.is_none()` (moov-at-end layout, seek to moov offset)
- `moov.is_some()` (fast-start layout, skip bounded mdat body)

This caused Symphonia to fail probing moov-at-end M4A files read from
local disk (hot-cache) and from in-memory buffers — every decode attempt
returned "end of stream", analysis fell back to `byte_envelope_no_ebu`
(no EBU R128 loudness), and rodio produced distorted audio.

Also in this commit:
- `resolve_playback_format_hint()` helper to resolve hint from URL,
  stream suffix, Content-Disposition, or byte sniff
- ISO-BMFF diagnostic helpers (`isobmff_buffer_looks_complete`,
  `log_isobmff_buffer_diagnostic`, `mp4_suspect_zero_holes`)
- Probe-fallback path for ranged-stream failures now uses these helpers
  to decide whether to refetch or wait for the in-flight download

* chore(audio): remove redundant hint recomputation and add missing blank line

`bytes_hint_for_wait` in the ranged-stream fallback path was an exact
duplicate of `effective_hint` already in scope — reuse the existing binding.
Also add missing blank line after `wait_for_ranged_mp4_probe_ready` in mod.rs.

* chore(release): CHANGELOG and credits for PR #757

Add Fixed entry for M4A moov-at-end probe fix and credits line in
settingsCredits.ts under existing cucadmuh contributions.

* fix(audio): extract BuildSourceArgs to fix clippy::too_many_arguments

`build_playback_source_with_probe_fallback` had 12 parameters, exceeding
the clippy limit of 7. Group url/gen/hints/fade/hi-res/duration into
`BuildSourceArgs` so the function signature stays at 4 arguments.
2026-05-17 19:54:38 +03:00
cucadmuh 97957df310 fix(build): enable zbus async-io feature on linux (#738)
Without async-io (or tokio), zbus 5.15 with default-features = false
fails to compile (`Either "async-io" (default) or "tokio" must be enabled`),
which in turn broke `psysonic-audio` and the root `psysonic` crate on
clean rebuilds. Workspace `cargo --workspace` builds happened to succeed
because feature unification masked the gap; standalone clean builds did not.
2026-05-16 23:17:48 +03: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