Commit Graph

146 Commits

Author SHA1 Message Date
Psychotoxical f41005682d refactor(playback): move thin-state queue resolver out of utils/library into the feature
The queue-resolver family (queueTrackResolver, queueRestore, queueItemRef,
queueTrackView) runtime-imports usePlayerStore / playerStoreTypes — it is the
playback engine's thin-state queue subsystem, not shared library infra. It sat
in utils/library only because it queries the local index. Co-locate into
features/playback/store so those edges become intra-feature; its remaining deps
(advancedSearchLocal, libraryReady, serverLookup, serverIndexKey, authStore) are
plain core/infra (feature -> infra, no inversion).

Removes 4 of the 6 store/utils -> feature runtime inversions that blocked the
utils/library -> lib move. 8 files moved, 46 consumers rewritten. tsc 0, lint 0,
targeted suites green.
2026-06-30 16:50:29 +02:00
Psychotoxical cb1a110afb refactor(playback): move the audio engine into features/playback
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
  store/<x>                    -> features/playback/store/<x>
  store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
  utils/playback/<x>           -> features/playback/utils/playback/<x>
  utils/audio/<x>              -> features/playback/utils/audio/<x>

184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).

KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).

Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).

tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
2026-06-30 15:00:20 +02:00
Psychotoxical 7e9cb60763 refactor(api): co-locate server-protocol clients into lib/api
Move the Subsonic + Navidrome protocol clients and the library IPC facade
(subsonic*, navidrome*, library.ts + tests) from src/api/ into src/lib/api/,
the feature-free infra layer (plan M4, decision §10.3: shared client core → lib/).
Pure move: deep-path import specifiers @/api/* -> @/lib/api/* across ~150
consumers; no behaviour change. Domain REST (lyrics/events) and cover/analysis
infra stay in src/api/ pending their own M4 placement.

tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
2026-06-30 07:53:25 +02:00
Psychotoxical 862941c145 refactor(playlist): co-locate playlist feature into features/playlist 2026-06-30 01:37:05 +02:00
Psychotoxical 2a425862ae refactor(album): co-locate album feature into features/album 2026-06-30 01:33:29 +02:00
Psychotoxical 9ffc42688c refactor(artist): co-locate artist feature into features/artist 2026-06-30 01:27:01 +02:00
Psychotoxical 17ea96dcdb test(offline): mock useOfflineBrowseReloadToken submodule in useSongBrowseList
The offline move collapsed a single-module mock into a `@/features/offline`
barrel mock, which shadowed the real `useOfflineBrowseContext` the test relied
on. Mock the reload-token submodule directly so the barrel re-exports the stub
while the sibling context hook stays live.
2026-06-30 01:26:46 +02:00
Psychotoxical 321d5ff6ab refactor(orbit): co-locate orbit feature into features/orbit 2026-06-30 01:02:58 +02:00
Psychotoxical d6dbb615fd refactor(offline): co-locate offline feature into features/offline 2026-06-30 00:56:08 +02:00
Psychotoxical 931c47e19e refactor(nowPlaying): co-locate now playing feature into features/nowPlaying 2026-06-29 23:38:09 +02:00
Psychotoxical 896fe3f407 refactor(radio): co-locate internet radio feature into features/radio 2026-06-29 23:04:34 +02:00
Psychotoxical 236acc33dd refactor(stats): co-locate statistics feature into features/stats 2026-06-29 22:51:54 +02:00
cucadmuh 281e86fd3b fix(queue): persist timeline play history across queue replace (#1096) (#1204)
* fix(queue): persist timeline play history across queue replace (#1096)

Add a session-scoped play-history buffer (with play_session cold bootstrap)
and timeline UI that shows history + current + upcoming without mutating
the canonical queue or Subsonic sync.

* fix(queue): pin timeline current to top and replay history in-place

Timeline scroll matches queue mode (current at top). History clicks insert
after the playing track instead of replacing the queue, and replayed tracks
stay visible in the history strip.

* docs: add CHANGELOG and credits for timeline play history (PR #1204)

* fix(queue): resolve cross-server cover art for timeline history

Include album/cover ids in play_session bootstrap rows, prefetch history
refs through the queue resolver per server, and resolve before replay so
Now Playing artwork works for inactive-server tracks.

* fix(now-playing): stop playbackReport on cross-server track switch

Send stopped to the previous server's playbackReport session when the
playback server changes (queue click, history replay, etc.) so Who is
listening clears on the server that was showing the prior track.

* fix(queue): close timeline history review gaps for PR #1204

Defer play_session bootstrap until the library index is ready with retry
while timeline mode is active; resolve history and queue rows by serverId
+ trackId for mixed-server queues; add tests for bootstrap defer and ref lookup.

* chore(queue): remove dead timeline scroll guard in QueueList

Timeline scroll is handled in the virtual-rows effect; the legacy branch
is queue/playlist only.

* fix(queue): address PR review nits for timeline play history

Use authoritative row.ref.serverId for history clicks before resolver fill;
simplify empty bootstrap seed; tighten completion types; unify recent_plays SQL.

* fix(queue): immutable session history append for useSyncExternalStore

Replace in-place push with a fresh array so getSnapshot returns a new
reference and React re-renders on live appends without a playerStore update.
2026-06-28 02:08:02 +00:00
cucadmuh 58e6efc68c feat(autodj): configurable overlap cap (auto or 2–30 s) (#1173) 2026-06-24 22:00:35 +03:00
Psychotoxical 7c724a642f chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* chore(eslint): add eslint toolchain and configs

* fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …)

* chore(eslint): clear unused vars in config, contexts, app and test

* chore(eslint): clear unused vars in api and music-network

* chore(eslint): clear unused vars in utils

* chore(eslint): clear unused vars in store

* chore(eslint): clear unused vars in cover and hooks

* chore(eslint): clear unused vars in components

* chore(eslint): clear unused vars in pages

* chore(eslint): remove explicit any in src

* chore(eslint): align react-hooks exhaustive-deps

* chore(eslint): zero gradual config on src

* chore(eslint): strict hook rules in store and utils

* chore(eslint): strict hook rules in hooks and cover

* chore(eslint): strict hook rules in components

* chore(eslint): strict hook rules in pages and contexts

* chore(eslint): document scripts ignore in eslint config

* chore(eslint): add lint script and zero strict findings

* chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons)

* chore(eslint): tighten two set-state disable comments (review round 2 LOW)

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

* docs(changelog): add Under the Hood entry for ESLint setup (PR #1165)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-24 01:33:34 +02:00
Psychotoxical 9b03949f34 feat(orbit): sync the host's track-transition settings to guests (#1158)
* feat(orbit): mirror host track-transition settings into the session

Each client previously used its own local crossfade / gapless / AutoDJ
settings, so guests blended tracks differently from the host and drifted
out of sync — the exact drift the Catch-Up button exists to paper over.

Add an optional OrbitSettings.transitions (crossfade enabled/secs/trim,
AutoDJ smooth-skip, gapless), additive on the wire so older sessions
simply omit it. The host seeds its own prefs at start and refreshes them
every tick, so a mid-session change propagates. Guests snapshot their own
prefs on join, adopt the host's for the session (idempotent — no setState
churn / audio-sync re-fire when unchanged), and restore their own on
leave. Applying via authStore.setState reuses the existing authSyncListener
path to the Rust engine, so no new IPC. Without the Hot cache a guest's
AutoDJ blend degrades gracefully via the existing byte-preload fallback.

Add a bridge module with unit tests for read/apply/save/restore.

* feat(orbit): lock guest transition controls during a session

Since a guest now mirrors the host's track-transition settings (re-applied
every read tick), their own controls would visibly fight the sync. Disable
them while a guest is in a live session and explain why.

Settings -> Audio -> Track transitions greys out the mode picker, seconds
slider and smooth-skip toggle with a "controlled by the Orbit host" note;
the queue toolbar's Gapless / Crossfade / AutoDJ quick-toggles get the same
disabled state and tooltip. Host controls are untouched. New i18n key added
across all locales.

* docs(changelog): add Orbit transition sync (1158)
2026-06-22 16:44:20 +02:00
cucadmuh 15cecb5d7d feat(server): custom HTTP headers for reverse-proxy gates (#1095) (#1156) 2026-06-22 16:25:28 +03:00
cucadmuh d9969ed76f fix(library): Navidrome artist letter buckets + safer index open/swap (#1144) (#1145) 2026-06-21 20:55:35 +03:00
Psychotoxical b950d4704b feat(cover): artist artwork from fanart.tv (off by default) (#1137)
* feat(cover): add artist_artwork_lookup table + accessors

Image-scraper P0 (design-review §12): additive library-SQLite migration 013
plus get/upsert/clear-per-server accessors for the external artist-artwork
lookup (fanart.tv). Render never reads it; the on-demand cover ensure path
and the mbid_ambiguous 24h negative cache use it. server_id = serverIndexKey.

* feat(cover): add fanart.tv + getArtistInfo2 provider layer

Image-scraper P0 (§7/§19/§23): new cover_cache/external.rs — Rust-side
getArtistInfo2 tag-MBID resolution plus fanart.tv v3/music URL + first
artistbackground fetch (BYOK client_key sent in addition to the project
api_key per fanart.tv ToS, §22). Extract a shared build_subsonic_url helper
in fetch.rs (cover URL behaviour unchanged). Add a dedicated low-concurrency
fanart_http_sem so external HTTP never starves Navidrome (§26). URL builders
unit-tested; wired into ensure_inner next.

* feat(cover): wire fanart.tv external branch into ensure_inner

Image-scraper P0 (§16): on-demand artist `fanart` ensures try fanart.tv
before the Navidrome fallback. MBID resolved Rust-side via getArtistInfo2
(§23, tag MBID); on a miss it falls through WITHOUT a .fetch-failed marker so
Navidrome stays the display fallback (§28). External tiers are written as
{tier}-fanart.webp in the same entity dir (same cacheKind, §16) — 2000 + 512
(matryoshka §17); peek prefers them for the fanart surface. Dedicated
low-concurrency fanart lane (§26); .miss-fanart ~30min negative marker.
Additive IPC args (externalArtworkEnabled, surfaceKind), off by default and
gated by PSYSONIC_FANART_KEY — inert until a render surface opts in (P1).
Quality gate (§11), name->MusicBrainz (§19), and lookup-table writes are P1.

* feat(cover): fanart-first peek for the fanart surface

Image-scraper P0: for an artist `fanart` ensure, the early peek serves only
the external {tier}-fanart.webp tiers; if none exist yet it returns None so
ensure runs the external branch (fetch fanart) instead of short-circuiting on
a cached Navidrome tier. Realises "fanart prioritised" (§18) for the opt-in
surface; Navidrome stays the fallback inside the branch's miss path.

* chore(cover): dev-only artist-fanart spike helper

DEV-only window.psyFanartSpike(name) — resolves an artist by name and fires
the real cover_cache_ensure with externalArtworkEnabled+surfaceKind=fanart to
verify the P0 pipeline against a live server (with PSYSONIC_FANART_KEY set).
Not wired in production.

* feat(cover): §11 quality gate for the fanart surface

Before an external fanart fetch, check whether a Navidrome tier already on
disk is an HQ ~16:9 image (width >= 1280, aspect 1.6-2.0) and skip the fetch
if so — square artist portraits never satisfy it, so the common case still
fetches. Reads tier dimensions only (no full decode). Rust consts per the
design review. Unit-tested predicate.

* feat(cover): persist fanart resolution in artist_artwork_lookup (§12)

Wire the lookup table as both the MBID resolution cache and the negative
cache: a cached MBID skips the getArtistInfo2 round-trip; no_mbid/mbid_ambiguous
back off 24h and miss 30min from updated_at before re-querying. Writes
hit/miss/no_mbid with mbid/mbid_source/provider; transient network errors are
not cached. All store reads/writes run off the async executor via
spawn_blocking and no-op before login. Store reached via app.try_state::<LibraryRuntime>().

* feat(cover): compile-time fanart key fallback + album/name IPC args

A runtime PSYSONIC_FANART_KEY still wins (dev), else the key baked in at build
time via option_env! (release). Add additive artistName/albumTitle ensure args
as context for the §19 name->MusicBrainz fallback (inert until the render
passes them). Library backfill passes None.

* feat(settings): add External Artwork Scraper toggle under Integrations

Master toggle (themeStore, off by default per §20) in a new Integrations
subsection, alongside the other opt-in third-party categories. Contacts
fanart.tv only when enabled. i18n across all 9 locales.

* feat(cover): wire fanart background into the fullscreen player (§28)

New useArtistFanart hook resolves a fanart.tv 16:9 background via a dedicated
cover_cache_ensure (surfaceKind=fanart) — it bypasses the shared peek/disk-src
cache (the {tier}-fanart.webp surface is keyed differently) and reuses
coverDiskUrl for the asset URL. Fullscreen background priority is now
fanart -> Navidrome artist image (cover pipeline) -> album cover; the live
useFsArtistPortrait probe is deleted (§28). Additive ensure opts
(surfaceKind/artistName/albumTitle); externalArtworkEnabled is derived in
ensureArgsFromRef from the master toggle and restricted to the artist fanart
surface, so plain cover ensures are unaffected.

* feat(cover): generalize external surface to fanart + banner (§13)

surfaceKind='banner' fetches the fanart.tv musicbanner array -> {tier}-banner.webp
in the same entity dir; fanart stays the 16:9 artistbackground. The ensure
branch, peek, lookup rows (per-surface surface_kind), miss marker
(.miss-<surface>) and tier suffix are all surface-parameterised. The §11
quality gate stays fanart-only (the banner strip has its own aspect). Unit
test for the surface->fanart JSON key map.

* feat(artist): fanart banner on the artist-detail header (§13, Option B)

The artist-detail header gets an album-detail-style background layer: fanart.tv
banner (musicbanner) -> the 16:9 fanart background cropped to the strip ->
empty. Both via a shared useArtistExternalImage hook (useArtistBanner /
useArtistFanart); each fetches on demand and shares the Rust cache, so the
header and the fullscreen player warm each other's images. The header is its
own stacking context (isolation) so a z-index:-1 banner clips behind the avatar
+ meta with no content wrapper; the album-style framing (padding/radius/clip)
is applied only when a banner is shown, so the off-by-default case stays
pixel-identical.

* refactor(artist): reuse album-detail header structure for the fanart banner

The artist-detail header now uses the same album-detail-* container classes as
AlbumHeader (header/bg/overlay/content/hero) with the fanart banner as the
background; the Back button moves inside the header. A surgical
`artist-detail-bleed` cancels the artist page's .content-body padding so the
banner is full-bleed to the container edges, matching the album header exactly
instead of the earlier inset card. Reverts the experimental artist-specific bg
CSS.

* feat(cover): drop artist_artwork_lookup rows on clear-cover-cache (§12/B.4)

cover_cache_clear_server already removed the server's whole cover dir (so the
{tier}-fanart.webp / -banner.webp tiers + .miss-* markers go with it); also
clear the artist_artwork_lookup rows for that server (off-thread) so no stale
resolution state lingers. Automatic toggle-off purge deferred — turning the
toggle off already hides external artwork (render is gated), and explicit
cache-clear now cleans external state too.

* feat(cover): name->MusicBrainz album-confirmed MBID resolution (§19)

When getArtistInfo2 has no tag MBID and the ensure carries the artist name +
an album in context (fullscreen), one MusicBrainz release-search query resolves
the artist MBID: the primary artist across score>=90 releases wins, conflicting
ids -> mbid_ambiguous (24h backoff), none -> no_mbid. Sends the required
User-Agent; a single-permit musicbrainz_sem + >=1s spacing holds us under MB's
rate limit. mbid_source=musicbrainz persisted. Banner surface (no album
context) correctly skips this. Pure classify/escape helpers unit-tested.

* fix(cover): enable banner surface in ensureArgsFromRef

externalEnsureFields only set externalArtworkEnabled for surfaceKind 'fanart',
so the 'banner' surface never fired — the artist-detail header always fell back
to the fanart image instead of the fanart.tv musicbanner. Both external artist
surfaces (fanart/banner) now enable the external branch.

* feat(settings): optional BYOK personal fanart key field

Add an optional personal fanart.tv API key field to the External Artwork
Scraper block (shown when the toggle is on): a masked input, a saved/in-use
status line, and the simple note that it is sent in addition to the app key.
Persisted in themeStore and plumbed through cover_cache_ensure
(externalArtworkByok); Rust prefers the settings key, falling back to the
PSYSONIC_FANART_CLIENT_KEY dev env. i18n x9.

* fix(cover): resolve artist-page fanart image collision on navigation

The artist-detail header keyed its fanart/banner hooks on the route `id`,
which flips immediately on navigation while `artist`/`albums` refetch a beat
later. The mismatched ensure wrote the previous artist's image under the new
artist's id (e.g. Sepultura's image under Lordi's id).

- key on the loaded `artist.id`, not the route `id`, so id/name/album always
  describe the same artist
- pick the §19 album context from an album that actually belongs to this
  artist (`albums.find(a => a.artistId === artist.id)`), so a stale album can't
  run a mismatched name→MusicBrainz query or cache a wrong `no_mbid`
- reset `src` on every input change in `useArtistExternalImage` so a previous
  artist's image never lingers while the new one resolves

* fix(cover): strip trailing album qualifier before MusicBrainz lookup

Library titles like "Show No Mercy (2004 Remastered)" or "Album [Deluxe
Edition]" failed the §19 MusicBrainz release query, blocking name-confirmed
MBID resolution. `normalize_album_for_mb` strips a single trailing
parenthetical/bracketed qualifier; leading qualifiers (e.g. "(What's the
Story) Morning Glory?") are left intact. Unit-tested.

* fix(cover): don't cache no_mbid when album context is unavailable

The banner ensure could fire before the artist's albums loaded, with no album
in context. The old code cached `no_mbid` there and the 24h backoff then
blocked the later ensure that arrived *with* album context. Could-not-attempt
is not tried-and-failed: the no-album branch now returns without persisting.

* fix(cover): don't emit tier-ready for external fanart/banner surfaces

`try_external_fanart` emitted `cover:tier-ready` with the `{tier}-{surface}.webp`
path. That event is keyed by the canonical cover key (cacheKind/cacheEntityId/
tier, no surface), so the frontend `useCoverArtBridge` listener seeded the
Navidrome artist cover's disk-src cache with the external image — leaking
fanart/banner into the plain artist cover (avatar, fullscreen "navidrome-artist"
fallback) even with the scraper toggled off.

Remove the emit: the fanart/banner hooks read the path from the
`cover_cache_ensure` return value, so no event is needed. (No disk-level
overwrite — the suffixed files are never matched by `tier_exists`; this was
frontend disk-src-cache cross-contamination.)

* fix(cover): wait for the final external background before showing it, with fade-in

The fullscreen player and artist-detail header flashed several backgrounds in
sequence while the fanart resolved (upscaled album cover → Navidrome artist
image → fanart), and the artist header could show the fanart first and then
swap to the banner.

- the album cover is no longer a background source — it only feeds the
  foreground thumbnail
- the external-artwork hooks return `{ src, pending }` so callers can tell
  "still resolving" (hold back) from "resolved, no image" (fall back now)
- fullscreen background: scraper on → fanart, empty while it resolves, Navidrome
  artist image only on a confirmed miss; scraper off → Navidrome artist image
- artist header: the banner is preferred — nothing shows while it resolves
  (no fanart flash), fanart is the fallback only once the banner misses
- both backgrounds preload the chosen image and fade it in (`onLoad` plus a
  `ref` `complete` check so an already-cached image, whose load event can fire
  before React attaches the handler, still appears). The header fade is a
  scoped inline opacity so the shared `album-detail-bg` class is untouched.

* ci(release): pass PSYSONIC_FANART_KEY into the macOS + Linux builds

* refactor(cover): extract external-artwork ensure into its own module

Pure code move: the on-demand fanart/banner fetch, the quality gate, the
surface-aware peek and the lookup-table cache move from cover_cache/mod.rs
into cover_cache/external_ensure.rs. Behaviour unchanged; mod.rs 1877 -> 1488.

* chore(cover): remove dev-only fanart spike helper

The real render wiring now exercises the external ensure branch, so the
dev-only window.psyFanartSpike helper is redundant.

* feat(cover): purge external artwork on opt-out (B3)

New cover_cache_purge_external command: when the External Artwork toggle is
turned off, drop every fetched {tier}-{provider}.webp, .miss-{provider}
marker and artist_artwork_lookup row across all configured servers, leaving
the canonical Navidrome covers intact. Opting out now removes the
third-party-sourced data instead of just hiding it (design-review §9/§12/B.4).

* docs: changelog, credits and what's new for artist fanart (PR #1137)
2026-06-20 21:04:21 +02:00
cucadmuh 955a9fcbd6 feat(queue): play queue sync — manual pull, idle auto-sync, multi-server push (#1131) 2026-06-19 18:19:11 +03:00
Psychotoxical c1403f8bd6 feat: now-playing liveness dot + admin-gated radio management (#1086)
* feat(now-playing): liveness indicator dot in the listening popover

Replace the raw "Nm ago" line in the "Who is listening?" popover with a
derived presence dot (green playing / amber paused / dim idle). The presence
is computed in one tested helper that unifies the playbackReport transport
state with the legacy getNowPlaying recency, instead of formatting a raw
timestamp inline. The dot carries the localized status as an aria-label and
tooltip so it is not conveyed by colour alone.

* feat(radio): gate station create/edit/delete behind Navidrome admin role

Navidrome >= 0.62 restricts internet-radio management to admins
(GHSA-jw24-qqrj-633c); non-admin requests fail. Hide Add Station, Search
Directory, the per-card edit chip and delete button for confirmed standard
Navidrome users via a canManageNavidromeRadio() helper on the existing
useNavidromeAdminRole framework. Admins, non-Navidrome servers and transient
states stay unrestricted; playback and favourites remain available to all.

* docs(changelog): now-playing status dot + admin-gated radio (#1086)
2026-06-14 01:30:25 +02:00
cucadmuh 891ab0dd5b feat(now-playing): OpenSubsonic playbackReport for live now-playing (#1080)
* feat(now-playing): adopt OpenSubsonic playbackReport for live now-playing

Drive a small playback state machine (starting → playing ↔ paused → stopped)
on the Subsonic-server channel when the server advertises the OpenSubsonic
`playbackReport` extension (Navidrome ≥ 0.62), giving `getNowPlaying` a real
transport state and an extrapolated position. Reports send `ignoreScrobble=true`
so play counts stay on the existing `scrobble.view` 50% path (no double count),
and the effective playback speed is included so the server extrapolates position
correctly with the speed feature on.

- New `playbackReportSession` FSM mirrors the existing `playListenSession`
  lifecycle hooks and is wired at the same player call sites (start / gapless
  switch / queue restore / resume / 15s heartbeat / pause / seek / stop / ended /
  error / app quit). Servers without the extension degrade to the unchanged
  legacy `scrobble.view?submission=false` presence call.
- Gated through the existing serverCapabilities framework: a new
  `FEATURE_PLAYBACK_REPORT` (auto, extension-detected). The OpenSubsonic
  extensions probe now stores the full advertised list once and serves both
  AudioMuse `sonicSimilarity` and `playbackReport` from it, without disturbing
  the legacy Instant Mix opt-in on pre-0.62 servers.
- Now Playing dropdown shows a live position bar and a paused indicator.
- reportPlayback uses the real request params (mediaId / mediaType / positionMs).

Tests: FSM transitions + gating + legacy fallback, capability resolution,
extension-list probe storage/decoupling. Full suite green; no Tauri-boundary
changes.

* feat(now-playing): glide the Live position bar between polls

Extrapolate the position of `playing` entries locally (elapsed × reported
playbackRate from the last 10 s poll) and re-render once a second, so the Live
progress bar advances smoothly instead of jumping on each refresh. Paused and
position-less entries stay frozen. A linear width transition matched to the tick
keeps the fill gliding. Only applies to clients that report a position via the
playbackReport extension.

* fix(now-playing): tighten Live timer layout and report resume immediately

Keep the progress-bar width stable without a wide empty gap before the
timer: reserve ~2ch inside the current-time span only (right-aligned), not
on the whole clock block. Report `playing` to the server as soon as
resume() runs, matching the immediate `paused` report on pause instead of
waiting for the Rust `audio:playing` event.

* docs: CHANGELOG and credits for playbackReport live now-playing (PR #1080)
2026-06-13 05:31:26 +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 707a41f615 fix(scrobble): Navidrome Now Playing with local playback and mixed-server queue (#1055)
* fix(scrobble): report Now Playing on playback server with local bytes

Navidrome presence and play-count scrobbles no longer skip when audio plays
from hot cache, offline pins, or favorites-auto, and reachability follows the
queue/playback server instead of the browsed active server.

* docs: changelog and credits for PR #1055
2026-06-10 04:25:22 +03:00
cucadmuh 6118b3940f feat(server): capability framework, AudioMuse sonic routing & PsyLab Connections (#1033)
* feat(server): probe AudioMuse via OpenSubsonic and add PsyLab Connections tab

Navidrome ≥0.62: detect sonicSimilarity extension for reliable plugin signal;
older servers keep the legacy getSimilarSongs probe. PsyLab gets a Connections
tab with session, endpoint, and active-server capability details.

* feat(psylab): polish Connections tab, admin role probe, and tab bar layout

Status badges and Navidrome admin/user validation in Connections; prevent
PsyLab tab row from vertically collapsing under the Logs flex layout.

* docs: add CHANGELOG and credits for PR #1032

* feat(settings): auto-enable AudioMuse on Navidrome 0.62+ with status indicator

Replace the per-server manual toggle with a probe-driven badge when
sonicSimilarity is available; pre-0.62 Navidrome keeps the legacy toggle.

* feat(server): capability framework with AudioMuse sonic routing

Add a declarative server-capability catalog (src/serverCapabilities/) that
picks a feature strategy per server generation, runs only the needed probes,
and routes API calls. AudioMuse Instant Mix now prefers the OpenSubsonic
sonicSimilarity endpoint (getSonicSimilarTracks) on Navidrome 0.62+ and
falls back to legacy getSimilarSongs.

- catalog/context/resolve: eligibility, detection, activation, call routing
- storeView: read facade over the existing per-server probe maps
- getSonicSimilarTracks API client + fetchSimilarTracksRouted router
- route Instant Mix and Lucky Mix through the resolver
- ServersTab + PsyLab Connections read the resolver (auto status vs toggle)
- tests: resolve, storeView, router

* docs: update CHANGELOG and credits for PR #1033

Renamed branch supersedes PR #1032: point changelog/credits at #1033 and
document the server-capability framework, auto-managed AudioMuse indicator,
and sonic Instant Mix routing.

* refactor(server): address PR #1033 review — idempotent probe, drop dead code

- Make scheduleInstantMixProbeForServer idempotent: skip when a definitive
  result is cached; re-probe only on force (add/edit/test server), a prior
  error, or a server version/type change (invalidated in setSubsonicServerIdentity).
  Removes the steady-state 120 s re-probe, the present→probing→present flicker,
  and the momentary legacy-fallback routing window.
- Remove now-dead identity helpers (showAudiomuseNavidromeServerSetting,
  isAudiomusePluginAutoManaged, isNavidromeSonicSimilarityEligible,
  resolveAudiomusePluginProbeUiStatus + type) and the superseded
  probeAudiomusePluginWithCredentials; the catalog is the single source of truth.
- Drop the never-emitted 'unsupported' AudiomusePluginProbeResult variant.
- Fill audiomuseStatus* keys in all 8 non-en locales.
- Tests: probe idempotency + version-change invalidation; retarget
  OpenSubsonic test to fetchOpenSubsonicExtensionsWithCredentials.
2026-06-08 23:16:29 +02:00
cucadmuh fc34a0ec59 feat(offline): local-bytes browse when server is unreachable (#1017)
* feat(offline): local-bytes browse for artists and albums

Make Artists, All Albums, and artist/album detail pages work offline
from the library index limited to on-disk library and favorite-auto
tracks. Add a DEV header toggle to simulate offline browse for testing.

* feat(offline): reactive DEV offline toggle with full disconnect simulation

Subscribe nav and browse/detail hooks to useOfflineBrowseActive so UI
refreshes on toggle. DEV force-offline now blocks server probes, reports
disconnected status, and gates Subsonic like real offline for player parity.

* feat(offline): bytes-first favorites when offline browse is active

Load Favorites from local playback bytes, filter starred tracks client-side,
and restrict album-level star queries to local album ids. Drop interim perf
attempts (lean SQL, progressive load, connection singleton, prefetch UX).

* feat(offline): tracks, help, player stats; suspend library picker offline

- Offline browse for Tracks hub from local bytes; sidebar nav for tracks/help/statistics
- Statistics redirects to player-stats offline; server/Last.fm tabs skip network fetches
- Hide music-library picker offline; save filter and restore on reconnect (all libraries while disconnected)
- Unified isOfflineSidebarNavAllowed for library + system entries

* feat(offline): fork disconnect navigation by offline browse capability

When the server drops: stay on the page if nothing is browsable offline;
reload in place on offline-capable routes; otherwise redirect to All Albums
instead of the old /offline or /favorites bounce.

* feat(offline): browse cached playlists when the server is down

List and open manually pinned regular playlists from local library-tier
bytes offline, with sidebar/nav routing and read-only playlist UI.

* feat(offline): read-only artist detail and local play-all paths

Hide favorites and discography offline actions when browse is offline;
load Play All, Shuffle, and top-track continuation from local album bytes.

* feat(offline): read-only album detail and enqueue from local bytes

Hide favorites, download, and cache-offline actions on album pages when
offline browse is active. Favorites album cards enqueue via the same
resolveAlbumForServer path as play, including local playback bytes.

* chore: remove unused import in AlbumCard after enqueue refactor

* feat(offline): unify browse integration contract across the app

Add useOfflineBrowseContext, offlineMediaResolve, and offlineActionPolicy;
wire shell nav to a single capability source; migrate play/enqueue and
context-menu paths off raw getAlbum; replace readOnly with action policy
on detail surfaces. Tests updated for the media-resolve facade.

* feat(offline): close browse contract gaps and fix offline Home feed

Split offline browse modules, align favorites capability across servers,
wire action policy on context menus, migrate hooks to useOfflineBrowseContext,
and preserve stale Home feed cache when offline so the UI does not empty.

* fix(offline): block playbar stars, close audit gaps, trim dead exports

Hide star rating and favorite in PlayerBar when offline browse is active via
offlineActionPolicy playerBar surface. Wire stay-reload token into browse
hooks, migrate hooks to context.active, guard rating prefetch network calls,
and route playlist load through resolvePlaylist.

* docs: add CHANGELOG and credits for offline browse PR #1017

* fix(offline): stop DEV connection probe regression in tests

React to devForceOffline transitions only in useConnectionStatus so mount
does not double-fire check() or ignore disableBackgroundPolling. Add
pingWithCredentials to PlayerBar test mock and DEV-toggle unit tests.
2026-06-07 15:59:41 +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 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 a7c79ee210 fix: show album artist on featured compilation cards (#979)
* fix: show album artist on featured compilation cards

The 'Also featured on' cards are synthesised from search3 child songs,
which only read the flat `albumArtist` field. Compilation children leave
that empty — the album-artist credit lives in OpenSubsonic's structured
`albumArtists` / `displayAlbumArtist` — so the card fell back to the '—'
placeholder. Carry the structured credit (and the display string) onto
the synthesised album so it resolves a name (and stays navigable when the
server supplies an id).

* docs: changelog for featured-compilation artist credit (#979)
2026-06-04 09:54:10 +02:00
cucadmuh e76dac87ae fix(home): scope Because you listened rail to sidebar library (#964)
* fix(home): scope Because you listened rail to sidebar library

Similar-artist album picks used getArtist without library filtering;
session cache also ignored musicLibraryFilterVersion. Filter picks to
the scoped album set and key cache/reserve by filter version.

* docs(changelog): credit zunoz on Psysonic Discord for PR #964

* test: satisfy SubsonicAlbum required fields in new unit tests

Fix tsc --noEmit CI: songCount and duration are required on SubsonicAlbum.
2026-06-03 22:57:35 +03:00
cucadmuh 47832632fd fix(cover): follow connect-URL flips in library cover backfill (#952)
* fix(cover): follow connect-URL flips in library cover backfill

The native cover backfill was configured once with a snapshot of the runtime
connect URL. When a laptop moved off the LAN, the smart endpoint switch flipped
the sticky connect URL to the public address (playback/UI covers rebuild it per
request and followed it), but backfill kept fetching from the now-unreachable
local address — flooding the log with "error sending request" failures.

Make the connect cache observable (notify on effective flips) and have the
backfill hook reconfigure when the resolved URL changes, forcing a pass so the
.fetch-failed backoff from the stale address is cleared and those covers retry
on the reachable endpoint.

* docs(changelog): note cover backfill endpoint-switch fix (PR #952)

* fix(cover): abort stale backfill pass + rerun on connect-URL flip

Frontend reconfigure alone didn't fully cover the boot case: at startup the
first backfill pass starts on the primary (LAN) URL before the reachability
probe resolves, so when the probe flips to public the forced rerun was dropped
by the pass_running guard and the slow LAN pass (every cover timing out) ran to
completion with nothing re-running on the reachable address.

set_session now bumps a session generation; the running pass checks it on every
focus gate and abandons promptly when the URL flips (same server_index_key, new
rest_base_url). try_schedule_full_pass records a rerun when a pass is in flight
and drains it once the abandoned pass returns, so a fresh forced pass runs on
the new address.

* fix(cover-backfill): resolve connect URL per fetch instead of baking it into the queue

The backfill worklist no longer carries a URL. Each cover fetch reads the
current reachable address live from a single worker cell, so a LAN↔public flip
is honoured even by the pass already in flight — its remaining covers download
against the new endpoint without aborting/rebuilding the worklist.

- Drop rest_base_url from CoverBackfillSession; add live base_url cell read in
  ensure_one. Remove the session-generation abort machinery (no longer needed).
- New lightweight library_cover_backfill_set_base_url command pushes the URL on
  every connect-cache flip; a real change clears the stale .fetch-failed backoff
  and runs a forced pass so covers that timed out on the old address retry.
- Split useLibraryCoverBackfill into a configure effect (server/creds/strategy)
  and a flip effect that only pushes the URL.
- Keep a single rerun_pending flag for the boot case (flip mid-pass), since the
  finished pass re-arms the idle gate.
2026-06-02 16:01:01 +03:00
cucadmuh 2224ddbe78 feat(perf): add on-demand (ui) throughput to cover pipeline cpm (#947)
* feat(perf): add on-demand (ui) throughput to cover pipeline cpm

Cover cpm previously measured only the native backfill (lib) via the
cover:library-progress done delta. Add a parallel UI series: every completed
on-demand Rust cover ensure (grid / now-playing) records a timestamp, surfaced
as a covers-per-minute rate. Both are exposed in the live cover diag, shown as
separate Backfill (lib) / On-demand (ui) cards in the Monitor tab (each pinnable
to the overlay) and as lib/ui rows in the Cover pipeline overlay block.

* docs(changelog): note cover on-demand (ui) throughput (PR #947)

Add CHANGELOG entry and credits line for the UI cover cpm metric.

* fix(perf): source on-demand (ui) cpm from backend produced-cover count

The JS ensure-queue counter never tracked: produced covers return hit:true
(only misses/errors are hit:false), and ensure-queue dedup/HMR made client
counting unreliable. Count on-demand covers natively in ensure_inner on the
produce path (non-bulk, past the cache-hit gate), expose a cumulative
uiEnsuredTotal in the pipeline stats, and derive the per-minute rate on the
frontend from polled deltas — mirroring the lib backfill series.

* perf(cover): measure cpm over trailing 5s instead of full minute

A 60s rolling average added too much inertia, flattening real bursts and
stalls in both the lib backfill and on-demand (ui) cover throughput. Compute
the rate from the trailing 5s of samples (still extrapolated to per-minute),
so the figure reacts promptly and decays to 0 within the window when idle.
2026-06-02 12:11:22 +03: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 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
Frank Stellmacher e734a8fc43 feat(genre): play, shuffle and queue buttons on the genre view (#926)
* feat(genre): add paginated songs-by-genre API

Wraps the Subsonic getSongsByGenre endpoint plus a fetchAllSongsByGenre
helper that paginates until exhausted, capped to keep the queue and the
burst of requests bounded for very large genres.

* refactor(playback): extract shared bulk play/shuffle/enqueue helper

A single fetchTracks-driven core (loading flag, empty guard, canonical
shuffleArray) so async detail-page play buttons stop growing divergent
copies. Artist detail now reuses it, dropping its weaker sort-random
shuffle.

* feat(genre): play, shuffle and queue buttons on the genre view

Header buttons load the genre's songs and start ordered or shuffled
playback, or append them to the queue. The slice is bounded to stay
within the queue resolver's cache budget so every row resolves instead
of rendering as a placeholder. Strings added across all nine locales.

* docs(changelog): genre play/shuffle buttons (#926)
2026-05-30 12:59:44 +02: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 9fa086c428 feat(server): dual server address (LAN + public) per profile (#880)
* feat(server): ServerProfile.alternateUrl + shareUsesLocalUrl

Additive optional fields on ServerProfile to back the dual-address work.
'alternateUrl' is the optional second endpoint (e.g. LAN counterpart of a
public 'url' or vice versa); 'shareUsesLocalUrl' chooses which of the two
goes into Orbit / entity / magic-string shares when both are set.

Pure schema change — no runtime consumers yet. Single-address profiles
keep the same persisted shape; the optional fields are simply absent.

* feat(server): serverEndpoint module with LAN classification + IPv6

Introduces a single home for connect-/share-layer URL utilities that
the dual-address work will build on:

  * normalizeServerBaseUrl(raw) — aligned with serverProfileBaseUrl
  * isLanUrl(url) — IPv4 (unchanged) + IPv6: ::1 loopback, fe80::/10
    link-local, fc00::/7 ULA, and IPv4-mapped (both dot-decimal and the
    URL-API-normalized hex form, since new URL() rewrites ::ffff:1.2.3.4
    to ::ffff:HHHH:HHHH)
  * allNormalizedAddresses(profile) — deduped [url, alternateUrl?]
  * serverAddressEndpoints(profile) — LAN-first ServerEndpoint[]

isLanUrl moves out of useConnectionStatus.ts (the hook now imports +
re-exports it for backward compatibility); OrbitStartModal points at
the new path directly. 38 unit tests cover the IPv4/IPv6 matrix,
dedupe, ordering, and edge cases.

* feat(server): pickReachableBaseUrl with in-memory connect cache

Adds the runtime connect layer on top of serverEndpoint:

  * pickReachableBaseUrl(profile) — sequential LAN-first ping via the
    existing pingWithCredentials; first OK wins.
  * ensureConnectUrlResolved(profile) — boot / switch / online-event
    entry point (same mechanism, intent-named).
  * In-memory cache keyed by profileId; sticky behaviour tries the
    cached endpoint first, falls back to the natural order on miss.
  * invalidateReachableEndpointCache(profileId?) — single-profile and
    whole-cache flushes for profile-edit / credentials-change / online.
  * getCachedConnectBaseUrl(profileId) — sync getter for getBaseUrl
    fallback path (next commit).

The cache is **session-only**, never persisted. Single-address profiles
keep the same shape (one endpoint, one ping). 9 new unit tests cover
single + dual address, LAN-first preference, fallthrough, unreachable +
stale-cache clear, sticky hit, and the two invalidate flavours.

* feat(server): getBaseUrl reads connect cache, falls back to primary url

Dual-address profiles need 'getBaseUrl' to return the runtime-probed
connect URL (LAN at home, public elsewhere), not the literal primary
'url'. To keep the sync getter shape that ~60 call sites depend on,
the store now reads 'getCachedConnectBaseUrl(server.id)' from the
serverEndpoint cache.

If no probe has run yet (very early boot, before switchActiveServer
populates the cache), it falls back to the normalized primary URL —
identical to today's behaviour. Single-address profiles see no
difference; their cache entry equals serverProfileBaseUrl(url).

* feat(server): switch + connection-status probe via ensureConnectUrlResolved

Both surfaces that previously called pingWithCredentials(server.url, ...)
directly now route through the dual-address connect layer:

  * switchActiveServer awaits ensureConnectUrlResolved(server), reads the
    identity (type/serverVersion/openSubsonic) off the returned ping, and
    hands probe.baseUrl (not server.url) to scheduleInstantMixProbeForServer
    so the AudioMuse probe also hits the reachable endpoint.
  * useConnectionStatus.check() does the same on every 120 s tick — sticky
    cache fast-paths the steady state, and a network change naturally
    flips the active endpoint without a manual retry.
  * useConnectionStatus.retry() and the 'online' event flush the cached
    entry for the active profile first, so the next probe starts from the
    natural LAN-first order instead of revalidating a stale URL.

Single-address profiles behave identically: one endpoint in the list,
one ping per check. No behaviour change for them.

* feat(server): PickReachableResult.ping + connectBaseUrlForServer helper

Two additive extensions to the serverEndpoint surface that the upcoming
CONNECT migrations all need:

  * 'ping: PingWithCredentialsResult' on the OK branch of
    PickReachableResult so callers (switchActiveServer,
    useConnectionStatus) can read type/serverVersion/openSubsonic from
    the same probe instead of issuing a second pingWithCredentials.
  * connectBaseUrlForServer(server) — sync getter for the connect URL
    of *any* saved profile (active or not). Reads the cache, falls back
    to normalized primary url on miss. Becomes the canonical helper
    for non-active-server HTTP traffic (apiForServer, stream URLs,
    cover fetches, library bind session).

Tests: pingOk() fixture now returns identity fields; the single
toEqual-asserting test in pickReachableBaseUrl became a guarded read
to keep equality concise while still asserting on the new ping field.

* feat(connect): apiForServer + stream URLs route via connectBaseUrlForServer

Non-active-server HTTP traffic (the apiForServer entry point, used by
QueuePanel cross-server cover fetches and share-paste resolution) and
the stream-URL builders previously read 'server.url' directly,
bypassing the dual-address connect cache. Both now go through
connectBaseUrlForServer, which serves the cached LAN/public endpoint
when one exists and falls back to the normalized primary url
otherwise.

buildStreamUrl(id) now uses the baseUrl it already pulled from
getBaseUrl() (which is connect-cache aware as of 2a6d8283) instead of
re-normalizing server.url — same value for single-address profiles,
correctly dual-address for the rest. Single-address callers see no
behaviour change.

* feat(connect): cover fetch + cover-cache ensure use connect URL

Both cover-fetch surfaces previously read 'scope.url' / 'server.url'
straight into HTTP requests, which would freeze the cover pipeline on
the primary URL even when a LAN endpoint was reachable:

  * buildCoverArtFetchUrl(ref, tier) — for the 'server' scope (queue
    rows cached against a non-active profile) and the 'playback' scope
    (cross-server playback) now resolves the connect URL via
    connectBaseUrlForServer before handing off to
    buildCoverArtUrlForServer.
  * ensureArgsFromRef in coverCache.ts — restBaseUrl for the Tauri
    'cover_cache_ensure' invoke now uses the cached connect URL for
    both 'server' and 'active'/'playback' scopes.

Scope.url itself remains the index-stable primary URL — that's what
storageKeys (INDEX) consume. Only the HTTP base shifts to the connect
endpoint, exactly the split the spec calls out.

* feat(connect): library bind + server-test probe via ensureConnectUrlResolved

Two more 'rebind / re-test an existing saved server' paths still pinged
the literal primary URL — both go through the dual-address connect
layer now:

  * bindIndexedServer (librarySession.ts): used to ping server.url then
    pass serverProfileBaseUrl(server) as the bind 'baseUrl' to Rust.
    Now: one ensureConnectUrlResolved call covers both — probe.baseUrl
    feeds librarySyncBindSession directly, so Rust's per-server library
    sync uses whichever endpoint actually answered. Drops the
    pingWithCredentials and serverProfileBaseUrl imports.
  * testConnection (ServersTab.tsx): same shape — probe via the connect
    layer, read identity from probe.ping, hand probe.baseUrl to
    scheduleInstantMixProbeForServer so the AudioMuse probe also hits
    the connect endpoint. Single-address profiles still ping once.

Add/edit save handlers (handleAddServer / handleEditServer) keep the
direct pingWithCredentials against the user-entered data.url — that
flow is the dual-address verify hook, which PR 2 extends.

* feat(server): serverShareBaseUrl — public-by-default share URL picker

Adds the share-layer companion to connectBaseUrlForServer. Different
intent: connect picks the reachable endpoint for HTTP, share picks the
URL that goes into Orbit invites / entity share payloads / queue share
links / magic strings — where a guest opening the link is not on the
host's LAN.

Logic per spec §5.1:
  * Single-address profile → that one address (normalized).
  * Both set, default flag → public.
  * Both set, shareUsesLocalUrl flag → local.
  * Edge cases (both LAN, both public, missing one of the two): fall
    back to the first endpoint in the list so the function is total.

Empty profile returns the normalized url (possibly empty) — defensive,
never throws. 7 unit tests pin all six branches plus the empty case.

Call-site migration (Orbit, copyEntityShareLink, QueuePanel, magic
string srv field, findServerIdForShareUrl, Orbit LAN warning) lands
in the next commits.

* feat(share): Orbit + entity + queue shares use serverShareBaseUrl

Four share-encoding surfaces previously read 'getBaseUrl()' (connect)
or the raw 'server.url' (primary) when embedding the host into outgoing
share links — both wrong for a dual-address profile:

  * copyEntityShareLink (track / album / artist / composer) — was
    getBaseUrl(); now reads serverShareBaseUrl(active). Guests opening
    the link are off-LAN, so public is the right default.
  * OrbitStartModal — was raw server?.url for both 'buildOrbitShareLink'
    and the LAN warning. Now goes through serverShareBaseUrl; the LAN
    warning correspondingly reflects the address the guest will see,
    not the host's primary.
  * OrbitSharePopover — same fix on the host-only share popover.
  * QueuePanel.handleCopyQueueShare — was getBaseUrl(); same migration.

Single-address profiles return exactly the same string from
serverShareBaseUrl as serverProfileBaseUrl(server.url) did before, so
no behaviour change for the common case. shareUsesLocalUrl flips the
default to LAN for the rare 'share into a LAN-only group' use; that
checkbox lands with the form UI in the next sub-phase.

findServerIdForShareUrl + paste-side share matchers are migrated in
the next commit (read-side of the same contract).

* feat(share): paste-side resolves dual-address profiles + connect URL

Read-side counterpart to the encode-side migration:

  * findServerIdForShareUrl(servers, shareSrv) now matches a profile
    when shareSrv normalizes to either profile.url OR profile.alternateUrl.
    Without this, a paste of a link generated against the host's LAN
    address (shareUsesLocalUrl=true) would fail to find the local saved
    profile even though it's the same server.
  * resolveSharedSong / resolveShareSearchAlbum / resolveShareSearchArtist
    in enqueueShareSearchPayload now hand connectBaseUrlForServer(lookup.server)
    to the *WithCredentials HTTP calls instead of the raw lookup.server.url.
    The looked-up profile may be dual-address; this routes the song / album /
    artist fetch through whichever endpoint is currently reachable.

Indirect consumers (shareServerOriginLabel, shareQueueServerContext) read
the same findServerIdForShareUrl, so no code change needed there — they
now match both addresses transparently.

* feat(verify): serverFingerprint + same-server verification

Pure-logic core of dual-address verify. Three exports:

  * fetchServerFingerprint(baseUrl, user, pass) — one ping (envelope
    'version' extracted alongside type/serverVersion/openSubsonic) plus
    four soft-fail optional calls in parallel (getMusicFolders, getUser,
    getLicense, getIndexes). Optional failures collapse to null fields,
    not whole-fingerprint failure. Subsonic-generic — never branches on
    type === 'navidrome'. indexesDigest is a hash of letter-count plus
    sorted first 20 artist ids, so two probes against the same library
    see the same digest without comparing full payloads.

  * compareFingerprints(a, b) — strict on the ping triple
    (type case-insensitive, serverVersion exact, openSubsonic boolean);
    envelope apiVersion informational only. Body signals counted only
    when both sides have a non-null value; empty musicFolders [] on both
    sides still counts as a matching signal. Result: 'match' (>=1 common
    signal all agreeing) / 'mismatch' (any common differs) /
    'insufficient' (0 common). No 'save anyway' for insufficient in v1.

  * verifySameServerEndpoints(profile, user, pass) — single-address
    short-circuits to ok:true (nothing to verify). Otherwise parallel
    fingerprint probes, then pairwise compare. Ping-fail on any
    endpoint reports the offending host for the UI.

20 unit tests cover the compare matrix (every body signal in every
direction), Navidrome- vs minimal-Subsonic-shape probes, ping-fail,
and all four verify outcomes (ok / unreachable / mismatch /
insufficient). HTTP mocked via vi.stubGlobal('fetch') + plain-object
Response shape (avoiding any Response-polyfill dependency).

* feat(boundary): resolve_host_addresses Tauri command + TS wrapper

Adds one additive invoke for dual-address form hints. Spec §9.1
+ contracts.md §6.

Rust side (src-tauri/src/lib_commands/app_api/network.rs):
  * #[tauri::command] resolve_host_addresses(hostname: String) ->
    Result<Vec<String>, String>
  * tokio::net::lookup_host with a port suffix (':0'); discards the
    port from each SocketAddr, dedupes addresses via HashSet.
  * strip_port helper handles 'host:port', 'ipv4:port',
    '[ipv6]:port', '[ipv6]' (no port), and bare 'ipv6' (left as-is
    for lookup_host to wrap). 7 unit tests cover each shape.
  * Lookup failure → Ok(vec![]) so a DNS hiccup doesn't surface as
    an error toast or block the save flow — form-hint only.

Frontend wrapper (src/api/network.ts):
  * resolveHostAddresses(hostname) — trims, invokes, swallows errors
    to an empty array so consumers get a clean total function.

§04 boundary note: additive command, no breaking change. Added to
the invoke_handler! generate_handler list in lib.rs at the natural
alphabetical-ish slot near migration_run.

* feat(form): AddServerForm — second optional address + share flag + DNS hint

UI side of dual-address. AddServerForm now carries the four new
moving parts spec §6 calls out:

  * Second address field ('Second address (optional)') under the
    primary URL. Placeholder flips to suggest the opposite kind
    of address based on the primary's LAN classification.
  * Contextual hint under the second field when it's empty —
    'add a public address for outside-home use' (primary is LAN)
    or 'add a local address for faster home access' (primary is
    public). Hint disappears once the field has content.
  * Two-LAN client-side check on submit: when both addresses
    classify as LAN, save is blocked with a toast. Reverse case
    (both public) intentionally allowed per spec §6.3.
  * shareUsesLocalUrl checkbox — visibility rule per spec §5.3:
    hidden until the second address has content; shows with the
    persisted value on edit; cleared when the user empties the
    second address before save.

DNS hint: on primary-URL blur we call the Tauri
resolve_host_addresses command and classify the response by
isLanUrl. Literal IPs skip DNS (already classified locally). DNS
miss → no hint surfaces (never blocks save). Used only for the
hint text — connect still goes through pingWithCredentials.

i18n: 9 new keys in en + ru (serverAlternateUrl*, shareUsesLocalUrl*,
serverBothLanError). Other locales fall back to English.

onSave signature widened to 'void | Promise<void>' — ServersTab /
Login both accept that already. The save flow itself still calls
the legacy pingWithCredentials path; verify wiring lands in 2e.

* feat(verify): wire verifySameServerEndpoints into add + dual-edit flows

Save flow now blocks persisting a dual-address profile that fails the
same-server check. Spec §6.4 + §7.4.

handleAddServer:
  * When data.alternateUrl is non-empty, runs verifySameServerEndpoints
    BEFORE the existing ping. mismatch / insufficient / unreachable each
    surface a localized toast and abort the save (no addServer call).
  * Single-address adds keep the legacy single-ping path — no extra
    network round-trip when there's nothing to verify.

handleEditServer:
  * Unconditional save remains the default — but if the edit either
    introduces / changes alternateUrl OR changes url / username / password
    while alternateUrl is set, verify runs first and may block.
  * After persist, invalidates the reachable-endpoint cache for this
    profile id so any sticky cached connect URL from before the edit is
    re-probed on the next access (credentials may have changed, alternate
    may have appeared).

i18n: 4 new toast keys in en + ru (dualAddressVerifying placeholder for
future progress UI, plus mismatch / insufficient / unreachable). Other
locales fall back to English.

announceVerifyResult helper keeps the toast routing in one place so
handleAddServer and handleEditServer share the same surface.

* i18n(settings): dual-address keys across all 9 locales

Adds the 13 new dual-address keys to the remaining 7 locales:
de · fr · es · nl · nb · ro · zh.

  * serverAlternateUrl + placeholderPublic / placeholderLocal
  * serverAlternateUrlHintAddPublic / HintAddLocal (contextual hints
    under the second-address field)
  * serverBothLanError (client-side two-LAN validation)
  * dualAddressVerifying / Mismatch / Insufficient / Unreachable
    (toast strings; Unreachable carries the {{host}} interpolation)
  * shareUsesLocalUrl + Desc (checkbox label + short description)

Placeholders (example.com URL / 192.168.1.100:4533) kept identical
across all locales — same shape the existing serverUrlPlaceholder
already uses.

Single coordinated sweep so every supported language ships dual-
address fully translated rather than falling back to English.

* feat(cover): cover_cache_rename_server_bucket Tauri command

Adds the disk-side companion to the upcoming URL-change remigration
flow (dual-server-address spec §8.3). When a user edits the primary
url so the derived index key changes, the SQLite migration already
re-tags rows via the existing migration_run command — this command
moves the cover-cache bucket on disk so cached WebP tiles stay
reachable under the new key.

Behaviour:
  * old_key == new_key → no-op.
  * Old bucket missing → no-op success (nothing to migrate).
  * New bucket missing → simple fs::rename (fastest path).
  * Both exist → recursive merge with 'prefer existing' on file
    collision (the newer bucket wins; data is never lost).
  * Emits 'cover:bucket-renamed' with {oldKey, newKey} on success
    so the frontend disk-src cache can invalidate stale URLs.

Sanitization: rejects empty, backslash, and '..' path segments at
the FS boundary. Forward slashes are legitimate (a Navidrome
mounted at a subpath like 'music.example.com/navidrome' produces
an index key with one); they're handled by Path::join.

4 unit tests cover key sanitization (accepts real keys, rejects
traversal/backslash) and the merge semantics (unique-file move +
prefer-existing on collision). Registered in lib.rs invoke handler.

* feat(remap): rewriteFrontendStoreKeysForRemap — explicit oldKey→newKey path

Adds the URL-change remigration entry point next to the existing
UUID→indexKey migration. Same plumbing (offline store, hot cache,
analysis-strategy maps) but driven by explicit { oldKey, newKey }[]
mappings instead of being derived from the current servers list.

Also folds in the player-side cleanup the existing path didn't have:
  * queueServerId — if currently bound to a remapped index key,
    repoint to the new one so playback continues through the rename
    instead of looking unbound.
  * analysisStrategyStore — runs the same map-key swap inline (the
    'migrateServerOverrides' helper handles UUID→indexKey, not
    indexKey→indexKey).

Same prefer-existing-on-collision semantics as the disk-side
cover_cache_rename_server_bucket — if a destination key already
carries data, we keep it. 8 unit tests cover no-ops, the four
store rewrites, the queue repoint, the 'leave other servers
untouched' guarantee, and the collision case.

* feat(remap): serverUrlRemigration orchestrator — 4-stage pipeline

Single-file orchestrator that runs the full URL-change remigration when
a profile edit shifts the primary url's derived index key. Spec §8 +
contracts.md §4.

Two exports:
  * indexKeyRemapForUrlChange(prev, next): IndexKeyRemap | null
    — short-circuits scheme-only edits ('http://x' ↔ 'https://x'),
      trailing-slash differences, and alternateUrl-only changes by
      normalizing both sides through serverIndexKeyFromUrl and
      returning null when they match. Empty urls also return null.
  * runIndexKeyRemigration(remap): Promise<IndexKeyRemigrationResult>
    — runs inspect → run → frontend-rewrite → cover-rename in order,
      aborts on the first failure and reports the offending stage.

Failure ordering rationale:
  * inspect / run failures stop the destructive step — DB is
    untouched, the caller can retry safely.
  * frontend rewrite swallows errors (best-effort; zustand persist
    catches up on rehydrate next session).
  * cover-rename failure is reported but does NOT roll back the DB
    rows (that would be even more destructive); covers under the
    old key recover via the existing cover backfill on next access.

10 tests cover the detect logic (5 cases including the path-suffix
case) plus all four pipeline stages with mocked Tauri invoke —
including verifying the legacyId/indexKey mapping shape lands on
both inspect and run calls.

* feat(remap): wire URL-change remigration into ServersTab edit flow

handleEditServer now detects a primary-url index-key change BEFORE
any other save logic (verify, persist, etc.) and orchestrates the
full remigration when one is needed.

Flow:
  * indexKeyRemapForUrlChange(prev, next) — null for scheme-only
    edits / alternateUrl-only edits / no-op edits, so the common case
    falls straight through without prompting the user.
  * On a real remap → confirm modal via useConfirmModalStore.request
    (danger style, plain-language oldKey → newKey copy, explicit
    'cannot be undone'). User cancel → abort save entirely.
  * On confirm → runIndexKeyRemigration pipeline. Failure surfaces a
    stage-specific toast (inspect / run / cover-rename — wording per
    spec §8.4 + the recoverability matrix in serverUrlRemigration.ts).
  * On success → fall through to the existing dual-address verify,
    then auth.updateServer + invalidate cache + ping (unchanged).

i18n: 7 new keys (urlRemigrationTitle / Message with oldKey + newKey
interpolation / Confirm / Progress / FailureInspect / FailureRun /
FailureCoverRename) — full sweep across all 9 locales (en, de, ru,
fr, es, nl, nb, ro, zh).

Test fixup: the offline-store collision test in rewriteFrontendStoreKeys
needed an 'as unknown as' cast — the existing OfflineTrackMeta type
doesn't carry the test-only marker property, and a one-step cast
TS rejected as not overlapping enough.

* feat(magic): magic-string v2 encode/decode with dual-address fields

Adds the v2 wire format for server invites. Spec §10 + contracts.md §5.2.

Encode is opportunistic: payloads with alternateUrl set OR
shareUsesLocalUrl=true emit v2; everything else stays v1 byte-identical.
This means single-address profiles keep producing exactly the same
invite they did before — older receivers can't tell the difference.

v2 shape: { v: 2, url, alt?, shareLocal?, u, w, n? }
  * url — the host's share URL (public by default; LAN if shareLocal),
    NOT necessarily the host's primary URL. Receiver treats it as the
    primary of the new profile (their own index key).
  * alt — the host's alternate address (the other half of the pair).
  * shareLocal — mirrors the host's shareUsesLocalUrl preference so
    onward shares from the receiver behave the same way.

Decode accepts both v1 and v2. v2-only fields are left undefined when
decoding a v1 payload (no '|| undefined' shim — kept absent so zustand
persist diffs stay clean). Defensive: an empty alt on a v2 invite
decodes as if the field were absent, never as ''.

Test updates: 'rejects a payload with the wrong version' now targets
v: 3 (v: 2 became valid). 7 new tests cover the v1/v2 wire-format
choice, both v2 round-trip shapes, the v1-decode-into-v2-fields-
undefined backward-compat case, and the empty-alt edge.

* feat(magic): wire magic-string v2 through invite-encode + paste consumers

Five surfaces now produce / accept v2 invites with the dual-address
fields end-to-end:

Encode side (admin → user invite):
  * MagicStringModal — looks up the saved profile that matches the
    serverUrl prop (primary OR alternateUrl normalize-match) and
    encodes through serverShareBaseUrl + alternateUrl + the share
    flag. Falls back to plain v1 for single-address profiles.
  * UserForm — same lookup + encode plumbing on the in-form
    'Save & get magic string' flow.

Decode side (paste invite into form):
  * AddServerForm useEffect (initialInvite from outer state) +
    handleMagicStringChange (live paste) both pull alternateUrl
    and shareUsesLocalUrl off the decoded payload into form state,
    so the second-address field + checkbox surface immediately on a
    v2 paste.
  * Login form state gains hidden alternateUrl + shareUsesLocalUrl
    fields (not user-editable — Login stays single-address by
    design); v2 paste / initial-invite both populate them so they
    persist with the new profile via addServer. handleQuickConnect
    likewise forwards the existing dual-address shape for
    already-saved profiles.
  * attemptConnect signature widened with the two optional fields;
    addServer / updateServer call sites conditionally include them
    only when alternateUrl is non-empty so single-address profiles
    keep their lean persisted shape.

Both encode helpers share the same magicPayloadAddressFields lookup
(MagicStringModal + UserForm) — the duplication is acceptable for
two co-located small files but a single shared helper would be the
natural place to consolidate if a third encoder appears.

* fix(form): magic-string submit forwards decoded alternateUrl + share flag

AddServerForm.submit's magic-string branch was forwarding only
name/url/username/password from the decoded payload, silently dropping
alternateUrl + shareUsesLocalUrl. A v2 invite pasted into the
magic-string field and saved would land in the store as a single-
address profile even though handleMagicStringChange had already
populated the dual-address fields into form state for display.

Now the magic branch picks the dual-address fields off the decoded
payload (same conditional spread as the non-magic branch a few lines
down) so v2 invites round-trip end-to-end through the form.

* fix(verify): extractUserId drops username fallback to avoid false mismatches

Spec §7.2 footnote: 'fallback (normalized username) only if no id on
either side'. The old extract unconditionally fell back to the
authenticated username whenever user.id was empty — but the
comparator can't tell username-fallback apart from a server-supplied
id. So a server pair where endpoint A returns user.id='42' and
endpoint B only returns user.username='frank' would land in compare
with userId values '42' vs 'frank' and report mismatch on what is
actually the same user on the same server.

Now extractUserId returns null when user.id is absent. Both sides
returning null means compareFingerprints skips userId as a common
signal (correct behaviour per the §7.3 'both sides have a value'
rule). One new test pins it; the call site drops the redundant
fallbackUsername argument.

* fix(remap): rewriteFrontendStoreKeysForRemap migrates coverStrategyStore

Spec §8.2 lists 'analysis/cover strategy maps' as targets of the
URL-change remigration. The For-Remap path covered analysis but
missed cover — a user-set cover strategy override (e.g. 'aggressive'
for one server) would silently drop when the user edited that
profile's primary URL, because the key tagged under the old index
key never made it to the new one.

Same shape as the analysis remap inline: prefer-existing on
collision (newer key wins), delete the legacy entry afterwards.
One new test pins the move; setup adds a fresh useCoverStrategyStore
reset between cases.

* fix(cover): is_safe_index_key rejects absolute paths + drive letters

Defense-in-depth gap in cover_cache_rename_server_bucket. The old
sanitizer only rejected backslashes and '..' segments — '/etc/passwd'
on Unix and 'C:\Windows' on Windows both passed. Path::join with an
absolute argument REPLACES the base path, so root.join('/etc/passwd')
on Unix would walk out of cover-cache entirely.

Real index keys never reach this state — they come out of
serverIndexKeyFromUrl which strips schemes and trailing slashes, so
no leading separator and no drive-letter prefix is ever produced.
But the comment promises 'defense in depth' and that defense is
worth completing.

Now rejects:
  * empty keys (would join to the cover-cache root itself)
  * leading '/' or '\'
  * Windows drive-letter prefix 'X:' (case-insensitive ASCII letter)
  * backslashes anywhere (separators are forward-slash only)
  * '..' segments after split('/')

One new Rust test covers all four cases.

* fix(connection): isLan badge reflects active endpoint, not primary url

useConnectionStatus.isLan was reading isLanUrl(server.url) — the
*primary* address — so a dual-address profile that had fallen over to
its public alternate kept advertising 'LAN' in the badge until the
user looked at the server URL itself. Spec call-site-checklist line
25: 'active endpoint kind for badge (LAN vs extern)'.

Now: ensureConnectUrlResolved returns probe.endpoint.kind on success;
the hook tracks the latest kind in local state and surfaces that.
Pre-first-probe fall-through keeps the old primary-URL classification
so the badge has something to render at mount time.

Three tests pin the three states (LAN-active after probe, public-
active after fallback, primary-fallback before first probe completes);
plus one for the online-event handler that invalidates the cache and
re-probes.

* fix(server): dedupe concurrent pickReachableBaseUrl calls for same profile

Multiple call sites probe on roughly the same beat — useConnectionStatus
120-s tick + online handler + initial mount, switchActiveServer,
bindIndexedServer, and ServersTab.testConnection. Two near-simultaneous
probes for the same profile both observed an empty cache, both pinged
every endpoint, and raced to write the sticky URL with last-write-wins.
On a dual-address profile the slower probe could stomp the correct
LAN sticky a millisecond after it was set.

Now: an in-flight Map<profileId, Promise<PickReachableResult>>; a
second call for the same id during an active probe returns the same
promise instead of starting a fresh one. The finally-clear keeps the
dedup window narrow (one settled probe → next call probes fresh).
invalidateReachableEndpointCache deliberately doesn't touch the
in-flight map — the racing probe's own finally will clean up.

Three tests pin it: two concurrent calls share one ping; a fresh
call after the previous settled does ping again; plus the existing
shareBaseUrl two-LAN-with-flag-on test + the two-public-default test
for completeness.

* refactor(magic): extract magicPayloadAddressFields to single shared helper

The lookup that turns a serverUrl into the v2-encode-ready
{ url, alternateUrl?, shareUsesLocalUrl? } shape was duplicated
byte-identically between MagicStringModal.tsx and UserForm.tsx —
both Navidrome admin user-mgmt encode sites. Workdocs §03 / §1a
'one source of truth for behavior'.

Now lives in src/utils/server/serverMagicString.ts as a named
export. Both call sites import + pass the current servers list
(via useAuthStore.getState().servers) — keeps the helper pure so
it's straight to unit-test if a third encoder ever appears.

* fix(cover): wire cover:bucket-renamed listener for URL-change remigration

cover_cache_rename_server_bucket emits cover:bucket-renamed on
successful rename / merge, but no frontend listener was wiring it in.
After a URL-change remigration the in-memory disk-src cache still
held entries pointing at `{root}/{oldKey}/…/.webp` paths the disk
no longer carries — the next read would serve a stale URL until
the entry naturally aged out.

New: forgetDiskSrcForServer(serverIndexKey) in diskSrcCache drops
every cover entry under one server key in one pass (the existing
forgetDiskSrcPrefix needs a non-empty coverArtId and can't blanket-
clear a server). useCoverArtBridge subscribes to cover:bucket-
renamed and calls it with oldKey.

Tests: forgetDiskSrcForServer happy path, empty-key defensive
no-op, no-match no-op; plus a regression-pin on the existing
forgetDiskSrcPrefix so the two helpers don't drift in semantics.

* test(dual-server): fill the high-impact coverage gaps from the branch review

Adds the tests the review identified as missing on the hot-path UI
flows + the partial-fail / alt-only edges:

  * AddServerForm.test.tsx (new) — five component tests:
    single-address save / dual-address save / two-LAN block-with-
    toast / v2 magic-string paste forwarding alt + share flag /
    edit-flow stripping alt+flag when the field is emptied.
  * Login.test.tsx (new) — v2 invite paste persists alternateUrl +
    shareUsesLocalUrl onto the saved profile; v1 invite leaves
    those fields undefined.
  * shareLink.test.ts — alternateUrl-only match for
    findServerIdForShareUrl (the dual-address paste case where the
    host shared the LAN URL via shareUsesLocalUrl=true); 'first hit
    wins' across two profiles where both match.
  * serverFingerprint.test.ts — partial-fail mix (folders+user ok,
    license+indexes rejected) so a Promise.allSettled regression
    wouldn't go silent.
  * cover_cache/mod.rs — rename_bucket_inner extracted as a
    testable FS-only helper (the Tauri command wrapper now just
    locks state + calls it + emits the event), plus six tests
    covering empty/unsafe keys, no-op-when-missing, no-op-when-
    equal, simple-rename, merge-with-prefer-existing.

Also: every test fixture that I authored on this branch using
'frank' / 'frank@example.com' as a stand-in username/email
swapped to 'tester' / 'tester@example.com' — keeping personal
names out of test data is the codebase convention (see
factories.ts) and is now a memory rule.

* fix(test): typed mock access for pingWithCredentials in useConnectionStatus

vi.mocked(...) returns the mock typed properly — bare .mock fails
tsc because the imported function signature isn't a vi.Mock at
import time.

* docs(changelog): dual server address (PR #880)

* fix(test): align diskSrcCache tests with main cover storage key API

After rebase onto main, forgetDiskSrcPrefix takes a CoverArtRef-shaped
argument and storage keys include cacheKind; merge coverDiskUrl tests
from main with dual-address forgetDiskSrcForServer coverage.
2026-05-28 14:36:25 +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 45b9229ceb refactor(queue): thin-state refs as canonical, full Track via resolver (#872)
* refactor(queue): wire queue UI to the track resolver (thin-state phase 3)

cucadmuh's phase-3 steps:
- Selectors (useQueueTracks) read resolver-first: getCachedTrack → queue: Track[]
  fallback (until phase 4), F4 star/rating overrides merged on read.
- QueueList rows source their track from the resolver (queue fallback); rows show
  title/artist/duration only, so no override merge there.
- pendingStarSync star/rating success → invalidateQueueResolver so the cache
  reflects the synced value.
- queueResolverBridge re-seeds on queueIndex change too — the prefetch window
  travels with the playing track.

Additive: queue: Track[] stays canonical and behaviour is unchanged (rows
resolve to the same data). Phase 4 drops queue: Track[] and the fallbacks.

* docs(changelog): queue panel reads through track cache (#860)

* fix(queue): stop a render loop that froze the UI on long queues

A long virtualized queue + a track change could lock the WebView for ~2 min:
- useVirtualizer was handed a fresh `initialRect` object literal every render, so
  it kept re-initializing in a loop. Hoisted it to a stable module constant.
- getCachedTrack did an LRU bump (Map delete+set) during render — a render-time
  side effect. Made it a pure read; recency is set at write time in cacheSet.

* perf(mobile): virtualize the mobile player queue drawer

The mobile now-playing queue drawer rendered the full queue with .map; a
multi-thousand-track queue meant thousands of DOM nodes. Virtualize it with
@tanstack/react-virtual (uniform rows, stable initialRect) so the DOM stays at
O(visible rows), matching the desktop QueuePanel. Active track is centred on open
via scrollToIndex.

* perf(mini): virtualize the mini-player queue list

The mini-player queue rendered the full MiniSyncPayload queue with .map.
Virtualize it against the OverlayScrollArea viewport (stable initialRect) so the
mini window's DOM stays at O(visible rows). Drag-reorder is preserved: rows keep
data-mq-idx alongside the virtualizer's measureElement.

* refactor(queue): add resolveQueueTrack/getQueueTracksView helper (thin-state phase 4)

Render-safe ref→Track view for the phase-4 consumer migration off queue: Track[].
Resolver cache → caller fallback (legacy queue[idx] during dual-write) →
placeholder; ref queue-only flags carried, F4 overrides merged. Pure synchronous
read, no cache mutation (the freeze landmine), so it is safe in render.

* refactor(queue): keep queueItems as the canonical in-memory mirror (thin-state phase 4)

Step 1b: dual-write the thin queueItems ref list at every queue write site
(the 11 mutations, next/radio top-up, playTrack, undo/redo restore, instant-mix,
radio, server-queue init, lucky-mix rollback, and hydrate) so it tracks
queue: Track[] in memory, not only at persist time. Identity-preserving maps
(star/rating overrides) keep the same refs and are intentionally left untouched.

Resolves the restore double-role flagged for 1b: queueItemsIndex is now the
restore-pending sentinel that gates hydrateQueueFromIndex, while queueItems
stays canonical -- rebuilt from the whole queue after a full hydrate instead of
cleared. Normal mutations never set the sentinel, so it only fires on a fresh
cold-start restore, not on later server switches.

No behaviour change; queue: Track[] stays the source consumers read until
phase 3. tsc + full vitest suite (1119 tests) green.

* refactor(queue): mobile queue drawer reads through the track resolver (thin-state phase 4)

Step 2: the mobile now-playing queue drawer resolves each row's track from the
resolver cache (→ queue: Track[] fallback until phase 4), matching the desktop
QueueList wired in the phase-3 commit. Subscribes to the resolver version so
rows re-render as the cache fills. Structure (count, order, keys, the playTrack
arg) still comes from queue: Track[] until it is dropped in the final step.

The mobile drawer was the last queue display surface still reading track
metadata straight off the fat queue. tsc + full vitest suite green.

* refactor(queue): ref-native queue mutations + dual-write bridge (thin-state phase 4)

Step 3a: the 11 queueMutationActions now splice/filter/reorder QueueItemRef[]
(matching by trackId + the ref's queue-only flags) instead of Track[].
`bridgeQueueFromItems` rebuilds the dual-written queue: Track[] from the new
refs by id — purely structural (no resolver/override merge), so behaviour is
byte-identical and playerStore.queue.test.ts stays unchanged green. The working
ref list comes from `itemsOf(state)` (derived from queue: Track[] for now); the
final step swaps that one line to state.queueItems once the fat queue is gone.

enqueue / enqueueAt / enqueueRadio seed the resolver cache with incoming tracks
(seed-before-splice) so they resolve without a network round-trip after the fat
queue is dropped. Adds a DEV-only id-parity guardrail (queue vs queueItems);
dev-runtime only, silent in vitest and prod.

tsc + full vitest suite (1119) green; contract test unchanged.

* refactor(queue): ref-native radio/infinite top-ups (thin-state phase 4)

Step 3b: nextAction's proactive infinite-queue and radio top-ups build the new
queue as QueueItemRef[] and bridge back to queue: Track[] (same as the queue
mutations), and seed the resolver cache with the freshly fetched tracks so they
resolve without a network round-trip after the fat queue is dropped. The radio
top-up keeps its HISTORY_KEEP front-trim, now expressed on refs.

The exhausted-queue refill paths hand their new queue to playTrack, which keeps
its fat-queue handling until the final step (its no-arg case needs the resolver-
derived queue that lands with the queue: Track[] removal). tsc + full vitest
(1119) green; contract test unchanged.

* refactor(queue): undo snapshots store thin refs, not Track[] (thin-state phase 4)

Step 4: QueueUndoSnapshot.queue: Track[] becomes queueItems: QueueItemRef[],
killing the undo "hidden multiplier" — 32 snapshots of a 50k queue now cost
refs, not 32×50k full tracks. applyQueueHistorySnapshot rebuilds the display
queue from the refs via resolveQueueTrack: resolver cache → the live queue by id
(covers tracks the edit didn't remove) → placeholder. currentTrack stays a full
track in the snapshot and is restored to the engine unchanged.

The snapshot refs derive from queue: Track[] for now (so the undo/redo contract
cases, which seed only `queue`, stay green); the final step swaps that to
[...s.queueItems]. tsc + full vitest suite (1119) green.

* perf(mini): cap the mini-player queue snapshot to ±100 around the current track (thin-state phase 4)

Step 5: the mini bridge no longer serializes the full queue over IPC on every
push — a 50k Artist-Radio queue would otherwise re-encode in full on every track
advance. snapshot() sends a window of 100 tracks before/after the playing song;
queueIndex is made slice-relative. The mini component stays unchanged (slice-
relative); jump/reorder/remove control events are translated back to absolute
queue indices via the window offset captured on the last push.

tsc + full vitest suite (1119) green. Mini bridge has no unit tests — needs a
quick mini-player smoke (queue shows ±100, jump/reorder/remove land correctly).

* refactor(queue): make queueItems a required PlayerState field (thin-state phase 4)

Foundation for the final consumer migration off queue: Track[]: queueItems has
been written at every queue write site since phase 1b, so promoting it from
optional to required is a no-op at runtime (tsc confirms zero new errors) and
lets the upcoming reader migrations read state.queueItems without `?? []` noise.

* refactor(queue): migrate structural queue readers off queue: Track[] (thin-state phase 4)

First reader batch toward dropping queue: Track[]: the queue-length selectors
(usePlaybackServerId, usePlaybackCoverArt, useQueuePanelDrag, useMiniQueueDrag)
now read state.queueItems.length, and FullscreenPlayer's next-track cover prefetch
resolves through useQueueTrackAt instead of indexing the fat queue. All behaviour-
identical during dual-write (queueItems is in lockstep with queue). tsc + full
vitest suite (1119) green.

Note: getPlaybackServerId() (playbackServer.ts) deliberately stays on queue for
now — it is called from many partially-mocked test stores, so it migrates with
the final field removal where the seedQueue helper covers those tests.

* refactor(queue): QueuePanel save/share/playlist read queueItems (thin-state phase 4)

The id/length reads (save to playlist, share link, create playlist, empty-queue
guards, next-tracks divider) now read state.queueItems instead of the fat queue.
Behaviour-identical during dual-write; queue: Track[] stays for the rendered
QueueList + auto-scroll until the field is dropped. tsc + full suite (1119) green.

* refactor(queue): drop queue: Track[] — thin queueItems is the only queue (thin-state phase 4)

The store no longer holds the fat queue. `queueItems: QueueItemRef[]` is the sole
canonical queue; full `Track`s resolve on demand via the resolver (index batch →
getSong fallback, bounded LRU cache); only `currentTrack` stays a full Track. At
50k tracks the store holds ~hundreds of resolved tracks + the refs, not 50k Track
objects.

- **Persist:** partialize is refs-only (no windowed slice / PERSIST_QUEUE_HALF).
  A `merge` migrates every historical blob shape → `queueItems` (existing
  `queueItems` → legacy `queueRefs` → pre-ref windowed `queue: Track[]`) and drops
  the obsolete `queue` key, so saved queues survive the upgrade.
- **Restore (decision B):** `hydrateQueueFromIndex` eager-resolves the whole
  ref list into the cache on cold start (index → getSong, so an index-off queue
  still plays), clears the restore sentinel.
- **Resolver bridge:** keeps `[idx-50, idx+200]` warm via `resolveVisibleRange`.
- **Mutations / actions / playback:** operate on refs; the playing track is
  `currentTrack`, the next/neighbour tracks resolve from the cache. Navigation
  (next/previous/row-jump) keeps `queueItems` and only moves the index — no full
  resolve or queue rebuild per track change.
- **Persist tests** cover the three old-blob migrations; `seedQueue` test helper
  replaces the `setState({ queue })` seeds.

tsc + full vitest suite (1115) green. Behaviour-preserving by the test contract;
the gapless track change + cold-start restore + mini cap still want a live smoke
before merge.

* fix(queue): star/rating keeps the queue row resolved instead of blanking to "…" (thin-state)

Rating/starring a queue song flashed the row's title to the "…" placeholder
until the next track change. Root cause: on sync success pendingStarSync called
invalidateQueueResolver, which DROPPED the cached track — and with queue: Track[]
gone there's no fat fallback, so the row resolved to a placeholder until the
resolver bridge re-fetched the window.

Fix: add patchCachedTrack(trackId, patch) and use it on star/rating success to
update the cached entry in place (title kept, synced starred/userRating applied)
instead of dropping it. No placeholder flash, no re-fetch.

tsc + full vitest suite (1115) green.

* fix(player): quota-safe persist so a full localStorage can't kill playback

A very large queue (~50k refs) overflows the localStorage quota; the persist
write then threw QuotaExceededError from inside set(), which aborted playTrack
before audio_play — no audio output at all. Back the player persist with a
quota-safe storage wrapper so a failed write degrades to a no-op instead of
throwing. Restoring the full ref list at that ceiling (vs a windowed cap) is
left as a follow-up.

* polish(player): throttle the quota-skip persist warning to once per key

The quota-safe persist logs a skip on every failed write; on a huge queue that
floods the dev console once per mutation. Warn once per key per quota-exceeded
streak, re-armed when a write to that key next succeeds.

* fix(queue): port new cover-pipeline readers to thin-state

Main's cover pipeline (#870) reads s.queue.length and seeds the player
store with queue: [track] in its tests. Under thin-state, queue: Track[]
no longer exists — the canonical queue is queueItems: QueueItemRef[].
These four files were brought across in the merge but still spoke the
old shape; this commit aligns them with the thin-state contract.

- src/cover/usePlaybackCoverArt: queueLength = queueItems.length
- src/cover/usePlaybackCoverArt.test: seed via toQueueItemRefs
- src/api/coverCache.test: same
- src/hooks/useNowPlayingPrewarm.test: same (two test cases)

* fix(queue): canonicalize thin-state server identity for mixed-server queues

`QueueItemRef.serverId` and `PlayerState.queueServerId` are now written as
the URL-derived index key on every writer path, matching the library index
direction. Mixed-server queues with duplicate `trackId` across servers stay
unambiguous because the resolver cache, persistence, and playback bindings
all share one key shape.

- new `canonicalQueueServerKey()` helper (idempotent UUID-or-key normalizer)
- `toQueueItemRefs`, `bindQueueServerForPlayback`, `seedQueueResolver`, and
  `hydrateQueueFromIndex` emit canonical keys
- `getCachedTrack` falls back to the canonical lookup so refs persisted in
  the legacy UUID shape still resolve through the migration window
- persist `merge` rewrites `queueServerId` and every ref `serverId` on
  rehydrate, so the live store never holds mixed shapes
- `removeServer` compares against the resolved id so a profile delete still
  clears the matching queue binding
- the two `playbackServer.test.ts` asserts that hard-coded the UUID shape
  are updated to the canonical key (existing reader-tolerance is unchanged)

* fix(queue-undo): bind snapshot prepend to snapshot-canonical server identity

When `applyQueueHistorySnapshot` has to prepend the still-playing track
(the snapshot's queue does not contain it), the new ref must follow the
snapshot's playback server, not the live `queueServerId`. A server switch
racing the undo would otherwise stamp the prepended ref with the new
server, mis-resolving the playing track on the very next render.

- `QueueUndoSnapshot` now carries `queueServerId` (captured by
  `queueUndoSnapshotFromState`); older in-memory entries fall back through
  the snapshot's own refs and finally the live store value
- the prepend in `applyQueueHistorySnapshot` plus the post-restore
  `seedQueueResolver` both source the server identity from this snapshot
  context, run through `canonicalQueueServerKey` so cache bucket and ref
  shape stay in lockstep

* test(queue): regression cluster for mixed-server queues with duplicate trackId

Covers the four invariants the thin-state review called out:

- resolver correctness: same `trackId` on two servers maps to two distinct
  cache entries via canonical keys, and legacy UUID-shaped refs still read
  the same entries through the compat lookup path
- restore/hydrate: persist `merge` forward-migrates UUID-form blobs in
  three shapes (canonical `queueItems`, legacy `queueRefs`, mixed-server
  `queueItems`) to canonical keys
- undo snapshot application: prepended ref follows the snapshot's playback
  server even when the live queue has been rebound to a different one,
  with fallback to snapshot refs and live state for legacy entries
- queue sync id emission: `flushPlayQueuePosition` -> `savePlayQueue`
  passes plain track ids and the playback server out of band, no per-ref
  `serverId` ever leaks into the request body

Also asserts the write helpers (`toQueueItemRefs`,
`bindQueueServerForPlayback`) emit canonical keys directly.

* perf(queue-header): coalesce resolver burst updates and aggregate in one pass

`QueueHeader` recomputed total and remaining queue durations on every
resolver cache version bump via two separate full-queue reduces. A mass
resolve burst (queue restore, prefetch window slide) bumps the version
dozens of times in one frame, and very long queues turned that into
visible main-thread stutter.

- one pass: a single for-loop produces both total and future-tracks
  duration; a 50k-track queue costs one walk per recompute, not two
- `useDeferredValue(version)` coalesces the burst into a single
  low-priority commit so the cache version is only sampled once per
  React frame instead of once per cache write

* fix(queue): use stable artist seed for radio top-up

The proactive radio top-up in `runNext` seeded `getSimilarSongs2` and
`getTopSongs` from `resolveQueueTrack(nextRef)` metadata. When the next
ref is still cold in the resolver cache, the placeholder track has empty
artist fields, and the top-up would fire `getSimilarSongs2('')` -- silently
returning nothing and leaving the queue dry just before the radio rail
would have refilled.

- prefer the just-played `currentTrack` (always fully resolved in the
  player store) and the stored radio seed artist id
- fall back to the next-track metadata only when those are missing
- skip the top-up entirely when no stable seed is available, instead of
  emitting a non-deterministic empty request

* docs(changelog): queue mixed-server routing and quota-safe persist (#872)
2026-05-27 00:10:34 +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