Commit Graph

361 Commits

Author SHA1 Message Date
cucadmuh e8962c21ab fix(settings): improve in-page search matching and coverage (#968)
* fix(settings): improve in-page search matching and coverage

Index AudioMuse and individual shortcut rows, tighten fuzzy matching so
junk queries return no hits, and scroll to the parent subsection when a
shortcut result is selected.

* docs: note PR #968 in changelog and settings credits
2026-06-03 23:56:54 +03:00
cucadmuh cd47a4b0fa fix(player): clamp custom delay input and align preview with armed timer (#967)
* fix(player): clamp custom delay input and align preview with armed timer

Reject absurd custom minute values that overflow setTimeout, share one
delay helper between the modal preview and schedule actions, and refresh
countdown ticks when a new deadline is armed.

* docs: note PR #967 in changelog and settings credits
2026-06-03 23:37:17 +03:00
cucadmuh f3a0b3f7af fix(artist-detail): Last.fm/Wikipedia/Favorite hover keeps button border (#966)
* fix(artist-detail): keep ext-link border visible on hover

Hover used --border-subtle, which on Catppuccin matches --bg-card and
visually erased the rim while only the fill changed. Match btn-surface:
--ctp-surface1 border, --ctp-overlay0 on hover.

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

* fix(playlists): tooltips on Play/Add Songs and song count pluralization

Add data-tooltip to Play and Add Songs in playlist hero; switch
playlists.songs to count-based _one/_other forms (was {{n}} without
i18next plural suffix, breaking spacing and singular).

* fix(playlists): render BPM and optional cols in Suggested Songs rows

PlaylistSuggestions shared column headers with the main tracklist but
its row switch omitted bpm, genre, playCount, and lastPlayed.
2026-06-03 23:19:51 +03:00
cucadmuh 5990d84f5a fix(random-mix): keyword blocks and scoped genre list on Build a Mix (#965)
* fix(random-mix): honor keyword blocks and scope genre list to library

Keyword filter was gated behind the audiobook exclusion checkbox, so
blocked artists still appeared after Remix. Genre Mix now loads genres
via fetchGenreCatalog (scoped index / library filter) instead of raw
getGenres; show empty state when all tracks are filtered out.

* docs(changelog): credit zunoz on Psysonic Discord for PR #965
2026-06-03 23:06:50 +03: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 be21f7834f fix(composers): hide performer-only artists with zero composer credits (#963)
* fix(composers): drop Navidrome role rows with zero composer albums

Navidrome can list performer-only artists under role=composer with
stats.composer.albumCount 0; filter them out of the Composers catalog
so search no longer surfaces ghost entries like Apollo 440.

* docs(changelog): credit zunoz on Psysonic Discord for PR #963
2026-06-03 22:45:52 +03:00
cucadmuh c683b5e37b fix(cards): selection ring clipping on browse grids (WebKitGTK) (#962)
* fix(artists): inset selection ring on grid cards, stop composer hover clip

Artist multi-select used a positive outline-offset that clipped in the
first grid row and sat outside the card border on hover. Match album
cards with an inset ring; drop composer-card hover translateY that
sheared the top edge in the in-page scrollport.

Reported by zunoz (v1.47.0-rc.3).

* fix(cards): selection ring via inset ::after overlay on WebKitGTK grids

Replace outline-based multi-select rings on album/artist/playlist cards
with the same inset ::after box-shadow pattern used for card focus rings
(card.css) — avoids clipping and the 1px gap vs the inner border on
overflow:hidden tiles in All Albums and related browse grids.

* docs: note browse grid selection ring fix in CHANGELOG (PR #962)

* docs(changelog): credit zunoz on Psysonic Discord for PR #962
2026-06-03 22:38:57 +03:00
cucadmuh a07e8e9593 fix(composers): keep role-split names in page-local search (#961)
* fix(composers): keep role-split names in page-local search

Composers browse already loads the Navidrome role-scoped catalog; scoped
search now filters that list instead of replacing it with generic artist
index/search3 hits that merge split credits into one joined name.

Reported by zunoz on the Psysonic Discord (v1.47.0-rc.3).

* docs: note Composers scoped-search fix in CHANGELOG (PR #961)
2026-06-03 22:23:11 +03:00
cucadmuh 4c70408bd6 fix(now-playing): split artist links and About the Artist tabs (#960)
* fix(now-playing): split artist links and About the Artist tabs

OpenSubsonic artists[] now drives per-artist navigation on Now Playing
hero and the queue current-track row (matching player bar). About the
Artist loads bio for each performer via tabs when a track has multiple
artist ids; queue Info uses the primary ref for bio/tour fetch.

Reported by zunoz on the Psysonic Discord (v1.47.0-rc.3).

* docs: note Now Playing multi-artist fix in CHANGELOG (PR #960)
2026-06-03 22:16:06 +03:00
cucadmuh a142bb1dab fix(albums): scope All Albums genre filter to selected music library (#959)
* fix(albums): scope All Albums genre filter to selected music library

When the sidebar narrows to one Subsonic library, the genre popover fell back
to server-wide getGenres() instead of the scoped local index catalog. Load
genre options from libraryGetGenreAlbumCounts for library-only scope and
enable the catalog path whenever the index is on and a library is selected.

* docs: credit All Albums genre filter fix (PR #959, report zunoz)

* chore: drop settingsCredits entry for small genre-filter fix
2026-06-03 22:05:35 +03:00
cucadmuh 75e2c7f9c6 fix(player): persist player prefs outside quota-bound queue blob (#958)
* fix(player): persist volume/repeat in dedicated localStorage key

Player prefs were bundled with the full queue blob in psysonic-player; after
thin-state #872 a large queue can exceed the quota and safeStorage silently
drops every write, so volume and repeat mode stopped surviving restarts.
Move them to psysonic_player_prefs, gate main-blob writes until rehydrate,
and sync volume to the Rust engine on startup.

* fix(player): split queue visibility and Last.fm cache from main persist blob

Move isQueueVisible and lastfmLovedCache to dedicated localStorage keys so
they keep saving when psysonic-player hits the quota on large queues. Include
the new keys in settings backup export.

* docs: note player prefs persist fix in CHANGELOG and credits (PR #958)
2026-06-03 21:55:20 +03:00
Frank Stellmacher 40932d28e2 UI/CSS fixes: focus rings, search fields, column dropdown, theme accordion (#954)
* fix(focus): keyboard focus ring no longer clipped by overflow or cover

The global :focus-visible ring used a positive outline-offset, so it was
drawn outside the element and clipped by any ancestor with overflow:hidden
or a scroll container (cards, rails, player bar, queue strip). Draw it inset
instead via shared --focus-ring-* variables (single source). Cards draw the
ring as an overlay above the cover, since a cover's own stacking context
(transform/contain for render stability) would otherwise paint over an inset
outline. Dracula now only sets --focus-ring-color.

* refactor(focus): fold scattered focus-ring overrides onto shared knob

Six components declared their own :focus-visible outline (mostly an exact copy
of the old global ring). Remove the redundant ones so they inherit the global
inset ring; keep the custom-coloured ones (genre pill, playback-delay modal)
but source width/offset from --focus-ring-*; move the because-card ring to the
central card focus ring (it has a cover and needs the lifted treatment).

* fix(settings): round theme accordion inner box to match its section

The theme picker's inner accordion had square corners, so the first (open)
group header ran flush into the rounded Theme card. Give .theme-accordion a
border-radius + overflow:hidden so its top/bottom corners continue the parent
card's rounding, matching the other settings sections.

* fix(search): unify search fields to one look

Live-search, Help and Settings search differed (pill vs rounded-rect, glow vs
plain border-change, mismatched backgrounds). Align them on the canonical input
look: radius-md, ctp-base background, accent border + soft accent-dim focus
glow. Drop the live-search pill radius, and suppress the input's own inset ring
so only the outer cluster glow shows (no double ring).

* fix(tracklist): column picker menu no longer clipped on short lists

The column-visibility dropdown was an absolutely-positioned menu inside the
tracklist, so a short list (e.g. a one-song Favorites view) clipped it via the
ancestor's overflow box. Render the menu in a portal to <body> with fixed
positioning anchored to the trigger (flips above when there's no room below),
following on scroll/resize. Outside-click + Escape close now live in the shared
TracklistColumnPicker (the menu is portalled out of the wrapper, so the old
wrapper-only check would have closed it on every in-menu click). Fixes albums,
playlists and favorites in one shared place. Adds a behaviour test.

* docs(changelog): UI/CSS fixes pass (#954)
2026-06-02 21:43:09 +02:00
Frank Stellmacher cc04a0c93d Distinct circular song cards with jump-to-album badge (#953)
* feat(tracks): distinct circular song cards with jump-to-album badge

Single-track cards looked identical to album tiles, so clicking the body
read as album behaviour even though it starts playback. Give them a round
vinyl-style cover (square stays album-only) via a shared .cover-circle
utility, and add a 'To album' badge under the artist that navigates to the
track's album. Card click still plays; the badge is the explicit nav path.

* i18n(tracks): add toAlbum label across 9 locales

* docs(changelog): track-card redesign + to-album badge (#953)
2026-06-02 20:34:54 +02: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 81f900c7a6 perf(analysis): measure tpm over trailing 5s window (#948)
* perf(analysis): measure tpm over trailing 5s instead of full minute

Mirror the cover cpm change: a 60s rolling average added too much inertia and
flattened real bursts/stalls. Count completions in the trailing 5s window and
extrapolate to per-minute, so analysis tpm reacts promptly and decays to 0
within the window when idle. Retention stays at 60s.

* docs(changelog): note trailing-window throughput rate (PR #948)
2026-06-02 12:17:10 +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 c6df05e576 feat(perf): cover pipeline throughput (cpm) in performance probe (#945)
* feat(perf): cover pipeline throughput (cpm) in performance probe

Mirror the analysis pipeline's tpm for covers. A new coverPerfStore samples the
backfill `done` progress from cover:library-progress events and derives a rolling
one-minute covers-per-minute rate. Surfaced as a live diag in perfLiveStore, a
pinnable "Cover backfill" throughput card in the Monitor tab, and a cpm row in
the Cover pipeline overlay block.

* docs(changelog): note cover pipeline cpm metric (PR #945)

Add CHANGELOG entry and credits line for the cover-pipeline covers-per-minute
throughput metric in the Performance Probe.
2026-06-02 11:05:28 +03:00
cucadmuh 42aec6720c fix(cover): stop per-song over-fetch + log failed cover downloads (#944)
* fix(cover): stop per-song cover over-fetch (album/mf-* explosion)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add [1.47.0] Fixed + Changed entries and a settingsCredits line for the
cover-backfill idle CPU / offline & cache menu work.
2026-06-02 04:56:34 +03:00
cucadmuh 5e977cfd49 fix(player-stats): exclude paused time from listened duration (#942)
* fix(player-stats): exclude paused time from listened duration

While paused, the Rust engine stops feeding active progress ticks to the
listen session, so the tick baseline (`lastTickMs`) stayed frozen at the
pause point. The first progress tick after resume then computed its
wall-clock delta against that stale timestamp and billed the entire
paused span as listened time, inflating Player stats.

Settle the partial segment played up to the pause and mark the session
paused; the first resumed progress tick rebaselines instead of counting
the gap. Wire the freeze into the single `pause()` transport action.

* docs(changelog): note paused-time player-stats fix (#942)
2026-06-02 01:50:46 +03:00
cucadmuh a73e9c4436 docs(changelog): restore PR order in [1.47.0] sections (#940)
Reorder Added/Changed/Fixed blocks by ascending PR number per team changelog policy — several recent entries had been appended at section tops instead of the bottom.
2026-06-01 18:46:33 +03:00
cucadmuh 08b6aeeb17 fix(perf): reduce idle Rust CPU and stabilize Performance Probe overlay (#939)
* fix(perf): skip Performance Probe CPU snapshot poll on Windows

Windows has no Rust CPU/RSS sampler, but the probe still invoked
performance_cpu_snapshot every 2s when the modal or overlay pins were
active. Skip the IPC on unsupported platforms and only poll JS-side
metrics; do not start overlay polling for CPU/memory pins alone.

* fix(analysis): park backfill coordinator until Advanced is configured

#881 started run_coordinator_forever at app init with a 2s sleep even when
disabled, waking tokio on every platform for no work. Park on Notify instead;
wake on configure (enable/disable) and library sync-idle. Long sleeps use
select with wake so sync-idle can interrupt COMPLETED_RECHECK waits.

Investigation branch — not for merge until periodic CPU root cause is confirmed.

* fix(perf): stop probe overlay flicker on live poll updates

Publish CPU samples only after a valid jiffies baseline, skip no-op
snapshot emits, and sync sparkline history atomically in the store.
Overlay uses wall-clock sparkline time and auto-scales low CPU values.

* fix(perf): cut idle Rust CPU from probe scan, cover prefetch, and storage poll

Move performance_cpu_snapshot /proc work to spawn_blocking so tokio
workers are not charged with probe sampling. Stop lazy cover strategy
from running route prefetch disk stats every 1.5s, and slow hot-cache
size refresh on Settings → Storage to 15s.

* fix(perf): stabilize probe sparkline clock between live poll ticks

Track sampleAt separately from updatedAt so CPU rate history and overlay
sparklines only advance on real % changes, not FPS re-renders or RSS-only
poll ticks. Hold CPU sparkline Y scale with a peak ref to avoid scale jumps.

* fix(cover): restore lazy route prefetch without idle disk stats poll

Re-enable lazy cover registry warm-up so cached WebP paths reach
diskSrcCache before cells mount. Skip cover_cache_stats on every 1.5s
tick — drain batches via ensure only, poll full disk usage every 30s
when the registry is idle.

* docs: CHANGELOG and credits for PR #939 idle CPU perf fix

* fix(cover): peek before route prefetch ensure to match main responsiveness

Route prefetch moved batch drain ahead of cover_cache_stats for idle CPU,
which removed the accidental throttle and flooded ensure invoke slots.
Use warmCoverDiskSrcBatch first (cached hits skip ensure), ensure misses
only, and yield while high-priority viewport work is queued.
2026-06-01 15:50:17 +03:00
cucadmuh 4ac373a65b feat(search): scoped live search on browse pages (#938)
* feat(artists): scoped live search badge replaces page filter

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(search): rename AdvancedSearch page to SearchBrowsePage

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

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

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

* docs: add CHANGELOG and credits for PR #936
2026-06-01 03:11:27 +03:00
Frank Stellmacher 2a88ca3248 fix(queue): pin queueServerId on auto-add paths so infinite + radio top-up refs resolve (#930)
* fix(queue): extend server-pin contract to auto-add paths

The infinite-queue top-up and radio top-up paths in nextAction.ts
read state.queueServerId directly inside their set callbacks. When
the queue was populated without a queue-replacing playTrack (single-
track enqueue from a SongRow + button, AdvancedSearch row, etc),
queueServerId stayed null, seedQueueResolver skipped its store-write
under the if (serverId) guard, and the auto-added refs landed with
an empty server key. Every auto-added row rendered as the resolver
placeholder (… / 0:00) until the next time something happened to
bind the server. Same symptom PR #892 fixed for the manual enqueue
surface, just on the auto-add paths.

Extract ensureQueueServerPinned() from the private helper in
queueMutationActions.ts into playbackServer.ts so it can be shared.
Call it before every set callback that appends or splices refs in
nextAction.ts — appendTracksAndPlayFirst, proactive infinite top-up,
proactive radio top-up. Helper returns the pinned canonical key so
the caller does not need a second store read.

Regression coverage in ensureQueueServerPinned.test.ts: pin on null
+ active server, idempotent on already-bound, empty-string fallback
when no active server, canonical-key return value matches what
toQueueItemRefs expects (not the raw auth uuid). Existing
b1QueueServerIdentity.test.ts continues to cover the manual
enqueue surface unchanged.

* docs(release): CHANGELOG for queue auto-top-up placeholder fix (PR #930)
2026-05-30 23:04:14 +02:00
Frank Stellmacher ae1572f370 docs(linux): clarify AppImage is the X11/XWayland channel (#928)
After #731 the .deb/.rpm/Nix packages follow the session display server,
but AppImage still pins GDK_BACKEND=x11 via its AppRun hook. Document the
asymmetry in the install guidance and complete the #731 changelog entry so
users know which package gives a native-Wayland launch.
2026-05-30 18:39:44 +02:00
cucadmuh 59a3261f3f fix(ci): refresh npmDepsHash before app-v* tag (#927)
* fix(ci): refresh npmDepsHash on channel branch before app-v* tag

Promote workflows push with GITHUB_TOKEN, so nix-npm-deps-hash-sync never
runs on the finalize commit. verify-nix ran after create-release and opened
a PR, leaving app-v* tags pointing at commits with stale npmDepsHash.

Move Nix hash/lock refresh into prepare-nix-sources (before tagging), commit
directly to the channel branch, and build from the prepared commit SHA.

* docs(changelog): note npmDepsHash CI fix (PR #927)
2026-05-30 14:07:59 +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
Frank Stellmacher 6c74cae0b7 fix(ui): center button label text (#925)
* fix(ui): center button label text

.btn was inline-flex with align-items:center but no justify-content, so
buttons wider than their content (min-width / flex:1) rendered the label
left-aligned — visible on the Advanced Search button (min-width 100).

* docs(changelog): centered button label (#925)
2026-05-30 02:48:12 +02:00
Frank Stellmacher b8fee84cd5 fix(radio): show ICY track in OS media controls (#816) (#924)
* fix(radio): show ICY track in OS media controls (#816)

Internet radio streams through the WebView <audio> element, for which
WebKitGTK registers its own MPRIS player — the one Linux desktops show.
souvlaki metadata pushes were overridden by it, so the OS overlay only
ever showed the app name. Feed the resolved ICY/AzuraCast metadata to
that player via navigator.mediaSession (and mirror to souvlaki), so the
overlay updates per track. Falls back to the station name when a stream
sends no metadata.

* docs(changelog): radio track info in OS media controls (#924)
2026-05-30 02:19:06 +02:00
Frank Stellmacher 1de2b0e850 feat(queue): switchable queue display mode (Queue vs Playlist) (#922)
* feat(queue): add queueDisplayMode setting with rehydrate (default queue)

* feat(queue): render upcoming-only or full timeline by mode, with header toggle

* feat(settings): add queue display mode toggle to Personalisation

* i18n: queue display mode strings across all locales

* test(queue): display-mode rendering and absolute index mapping

* docs: changelog + credits for queue display mode (#922)
2026-05-30 00:26:07 +02:00
cucadmuh 7b06be5ba2 ci: make hot-path coverage gates required PR checks (#921)
* ci: make hot-path coverage gates required PR checks

Remove continue-on-error from frontend and Rust coverage jobs now that
the hot-path lists have stabilized; update docs and script headers.

* docs: note hard coverage gates in changelog and credits (PR #921)

* chore: drop credits entry for CI-only PR #921

Contributor credits are for user-visible work, not infra toggles.
2026-05-30 01:04:35 +03:00
Frank Stellmacher 293672abbf fix(queue): pin queueServerId on first enqueue so refs resolve (#892)
* fix(queue): pin queueServerId on first enqueue so refs resolve (thin-state)

Adding a single track from a page that doesn't replace the queue (Advanced
Search row, SongRow + button, SongCard) left queueServerId null whenever
the app had not yet seen a queue-replacing playTrack. seedIncoming then
became a no-op, the new refs landed with an empty server key, and the
queue panel rendered every row as the resolver placeholder ("…" / 0:00)
until the next time something happened to bind the server.

Add an ensureQueueServerPinned step at the entry of every add-to-queue
mutation (enqueue / enqueueAt / playNext / enqueueRadio). It runs after
blockCrossServerEnqueue so a guarded cross-server enqueue still bails
without touching the pin, and after the undo snapshot so undo restores the
pre-pin baseline. Idempotent: no-op when already pinned or when no active
server is available to pin (e.g. unit tests without an authed store).

Regression cluster in b1QueueServerIdentity.test.ts covers enqueue /
enqueueAt / enqueueRadio cache-hit after pin, the no-active-server
fallback, and the already-pinned no-op.

* docs(release): CHANGELOG for queue placeholder fix (PR #892)
2026-05-29 22:47:15 +02:00
cucadmuh 8ea0308dba feat(perf): explicit toggle for live thread-group CPU polling (#891)
* feat(perf): explicit toggle for live thread-group CPU polling

Replace implicit thread-group collection (section open / pin) with a persisted
checkbox so Linux /proc scans run only when the user opts in for diagnosis.

Fix IPC: pass includeThreadGroups (camelCase) so Tauri maps the flag to Rust;
reset the CPU baseline when the option changes so thread % deltas are valid.

* docs: CHANGELOG and credits for PR #891

* fix(perf): gate CHILD_RESCAN_EVERY to Linux/macOS only

Avoid dead_code warning on Windows where perf child-PID rescan is unused.
2026-05-29 19:18:21 +03:00
cucadmuh 9925771a86 feat(browse,cover,perf): lazy catalogs, cover pipeline, and Performance Probe (#890)
* fix(cover): per-server cache stats and cover pipeline perf probe

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: CHANGELOG and credits for PR #890

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

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

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

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

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

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

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

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

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

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

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

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

* docs(release): CHANGELOG for windows cover-cache server-key fix (PR #889)
2026-05-28 23:13:27 +02:00
ImAsra ae2e123a14 feat: add long press to shuffle with a wave animation (#888)
* feat: add long press to shuffle with a wave animation to singnify how long to press

* refactor: long-press shuffle cleanup

Follow-up on the long-press shuffle PR: shared hook/overlay, playback parity,
pointer events, broader surface coverage, locales, and tests.

* docs: credit ImAsra for long-press album shuffle (PR #888)

Add CHANGELOG entry and Settings credits for the hold-to-shuffle play
interaction shipped in Psychotoxical/psysonic#888.

* fix: restore playAlbumShuffled and long-press hook wiring

The follow-up merge dropped playAlbumShuffled and reverted the shared
long-press hook in album play buttons, breaking tsc and vitest on PR #888.

---------

Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
2026-05-28 23:59:49 +03:00
cucadmuh 8443b3d4be fix(artist): align top-track covers with album grid cover path (#886)
* fix(artist): align top-track covers with album grid cover path

Top tracks now resolve album.id + album.coverArt like AlbumCard, use
the same useAlbumCoverRef/CoverArtImage dense pipeline, and batch-warm
covers on page load instead of a custom sparse resolver.

* docs: CHANGELOG and credits for PR #886 artist top-track covers

* fix(artist): match All Albums cover warm tier and prefetch on detail page

Warm top-track and discography covers at dense grid tier (140px) instead of
32px thumb tier so disk peek hits cached WebP. Register high-priority dense
prefetch like All Albums and ensure top-track cells at high priority so dense
defer-until-visible does not stall visible thumbs.

* fix(artist): satisfy tsc for top-track cover warm helpers

Use optional coverArt access in pushAlbumWarmRow and align album pick
types with topSongAlbumForCover (id + name + coverArt).
2026-05-28 22:47:38 +03:00
Frank Stellmacher 455aec4def feat(discord): show track title in Discord member list (configurable name template) (#885)
* feat(discord): override activity name in member list with track title (configurable template)

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

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

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

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

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

* docs(release): CHANGELOG and credits for discord name template (PR #885)
2026-05-28 20:32:41 +02:00
Frank Stellmacher 403979b35d feat(input): opt-in WebKitGTK focus repaint workaround for Linux (#342, #782) (#884)
* feat(input): opt-in WebKitGTK focus repaint workaround for Linux (#342, #782)

Some Linux users on WebKitGTK 2.50.x report a freeze when clicking
text fields: the field receives focus (right-click paste still works)
but the canvas never redraws until the window is resized. Likely a
layer-flush / composition scheduling bug in 2.50.x's Skia rendering
pipeline.

Off by default. When enabled in Settings -> System -> Behavior, every
input/textarea focus triggers a sync reflow read plus a one-frame
translateZ(0) toggle on the input's parent so WebKit re-evaluates the
layer tree. Side-effect: search-icon siblings flicker briefly on
focus -- accepted trade-off, only paid by users who opt in.

The toggle row is gated on IS_LINUX and sits next to the existing
Linux WebKitGTK options. The effect subscribes to the auth store and
re-attaches the focusin handler whenever the flag flips, so toggling
off cleanly removes the listener.

* i18n(settings): linux input repaint toggle in all locales

Adds linuxWebkitInputForceRepaint label + description across en, de,
fr, es, nl, nb, ro, zh, ru.

* docs(release): CHANGELOG and credits for linux input freeze workaround (PR #884)
2026-05-28 20:01:25 +02: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 091e61f7a5 fix(analysis): decode Opus in waveform and loudness pipeline (#883)
* fix(analysis): decode Opus in waveform and loudness pipeline

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: CHANGELOG and credits for PR #878

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Pass CoverCacheEnsureArgs as a single Tauri parameter instead of nine
positional fields; align frontend invoke payload with { args }.
2026-05-28 03:15:08 +03:00
cucadmuh ee5068c98c feat(artist): sort albums by year on artist detail (#877)
* feat(artist): year sort for albums section on artist detail

Add a sort dropdown next to "Albums by …" with release-type grouping (default),
newest-first, and oldest-first by album year.

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

* fix(artist): toggle year sort inside release groups, session per server

Replace dropdown with a click-to-toggle newest/oldest button. Keep release-type
blocks; sort albums by year within each group. Persist order in session store.
2026-05-27 13:04:00 +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
cucadmuh fab6ff19bf fix(home): Discover Songs covers for local-index tracks (#874)
* fix(home): pre-warm and prefetch Discover Songs row covers

The Discover Songs raблин. il came out of the cover pipeline merge with two
gaps that left its cards stuck on the placeholder disc icon on cold
caches.

- `warmHomeMainstageCovers` walked `heroAlbums` / `recent` / `random`
  through `ensureAlbumCoverMisses` + `predecodeWarmAlbums` but skipped
  `discoverSongs`, so songs that were peeked but missed on disk had to
  wait for lazy per-card ensure
- `Home.tsx`'s `coverPrefetchRegister` lumped `songRefs` into a
  `cappedRest` slice already saturated by 48 album refs + 16 artist
  refs at a 24-entry cap, so the song row's background prefetch was
  discarded entirely

Fix: ensure + decode-warm the Discover Songs cells alongside the album
rails, and register the song refs in their own bucket with a sane cap
and `middle` priority. Both follow the same shape as the working album
rails — no behavior change for surfaces that were already painting.

* fix(library): resolve track cover art from albumId for local index songs

Discover Songs uses runLocalRandomSongs; trackToSong only mapped coverArtId,
so rows with empty cover_art_id but a valid album_id showed the disc
placeholder. Mirror Rust COALESCE(cover_art_id, album_id) and Live Search's
coverArt ?? albumId in trackToSong, SongCard, and Home prefetch.

* docs(release): note Discover Songs cover fix in CHANGELOG and credits (PR #874)

* docs(release): credit PR #874 to Psychotoxical and cucadmuh jointly

* chore(credits): drop PR #874 from settingsCredits — minor fix

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-27 08:49:06 +03:00